Files
org-parking/services/parking.py

365 lines
12 KiB
Python

"""
Parking Assignment Service
Office-centric parking spot management with fairness algorithm
Key concepts:
- Offices own parking spots (defined by Office.parking_quota)
- Each office has a spot prefix (A, B, C...) for display names
- Spots are named like A1, A2, B1, B2 based on office prefix
- Fairness: users with lowest parking_days/presence_days ratio get priority
"""
from datetime import datetime, date, timezone, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import or_
from database.models import (
DailyParkingAssignment, User, UserPresence, Office, OfficeSpot,
ParkingGuarantee, ParkingExclusion, OfficeClosingDay, OfficeWeeklyClosingDay,
UserRole, PresenceStatus
)
from utils.helpers import generate_uuid
from app import config
def get_spot_prefix(office: Office, db: Session) -> str:
"""Get the spot prefix for an office (from office.spot_prefix or auto-assign)"""
# Logic moved to Office creation/update mostly, but keeping helper if needed
if office.spot_prefix:
return office.spot_prefix
return "A" # Fallback
def get_spot_display_name(spot_id: str, office_id: str, db: Session) -> str:
"""Get display name for a spot"""
# Now easy: fetch from OfficeSpot
# But wait: spot_id in assignment IS the OfficeSpot.id
spot = db.query(OfficeSpot).filter(OfficeSpot.id == spot_id).first()
if spot:
return spot.name
return "Unknown"
def is_closing_day(office_id: str, check_date: date, db: Session) -> bool:
"""
Check if date is a closing day for this office.
Checks both specific closing days and weekly recurring closing days.
"""
# Check specific closing day (single day or range)
specific = db.query(OfficeClosingDay).filter(
OfficeClosingDay.office_id == office_id,
or_(
OfficeClosingDay.date == check_date,
(OfficeClosingDay.end_date != None) & (OfficeClosingDay.date <= check_date) & (OfficeClosingDay.end_date >= check_date)
)
).first()
if specific:
return True
# Check weekly closing day
# Python: 0=Monday, 6=Sunday
# DB/API: 0=Sunday, 1=Monday... (Legacy convention)
python_weekday = check_date.weekday()
db_weekday = (python_weekday + 1) % 7
weekly = db.query(OfficeWeeklyClosingDay).filter(
OfficeWeeklyClosingDay.office_id == office_id,
OfficeWeeklyClosingDay.weekday == db_weekday
).first()
return weekly is not None
def initialize_parking_pool(office_id: str, quota: int, pool_date: date, db: Session) -> int:
"""
Get total capacity for the date.
(Legacy name kept for compatibility, but now it just returns count).
"""
if is_closing_day(office_id, pool_date, db):
return 0
return db.query(OfficeSpot).filter(
OfficeSpot.office_id == office_id,
OfficeSpot.is_unavailable == False
).count()
def get_available_spots(office_id: str, pool_date: date, db: Session) -> list[OfficeSpot]:
"""Get list of unassigned OfficeSpots for a date"""
# 1. Get all active spots
all_spots = db.query(OfficeSpot).filter(
OfficeSpot.office_id == office_id,
OfficeSpot.is_unavailable == False
).all()
# 2. Get assigned spot IDs
assigned_ids = db.query(DailyParkingAssignment.spot_id).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date
).all()
assigned_set = {a[0] for a in assigned_ids}
# 3. Filter
return [s for s in all_spots if s.id not in assigned_set]
def get_user_parking_ratio(user_id: str, office_id: str, db: Session) -> float:
"""
Calculate user's parking ratio: parking_days / presence_days
Lower ratio = higher priority for next parking spot
"""
# Count days user was present
presence_days = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.status == PresenceStatus.PRESENT
).count()
if presence_days == 0:
return 0.0 # New user, highest priority
# Count days user got parking
parking_days = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.office_id == office_id
).count()
return parking_days / presence_days
def is_user_excluded(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user is excluded from parking for this date"""
exclusions = db.query(ParkingExclusion).filter(
ParkingExclusion.office_id == office_id,
ParkingExclusion.user_id == user_id
).all()
if not exclusions:
return False
# Check against all exclusions
for exclusion in exclusions:
# If any exclusion covers this date, user is excluded
# Check date range
start_ok = True
if exclusion.start_date and check_date < exclusion.start_date:
start_ok = False
end_ok = True
if exclusion.end_date and check_date > exclusion.end_date:
end_ok = False
if start_ok and end_ok:
return True
return False
def has_guarantee(user_id: str, office_id: str, check_date: date, db: Session) -> bool:
"""Check if user has a parking guarantee for this date"""
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.office_id == office_id,
ParkingGuarantee.user_id == user_id
).first()
if not guarantee:
return False
# Check date range
if guarantee.start_date and check_date < guarantee.start_date:
return False
if guarantee.end_date and check_date > guarantee.end_date:
return False
return True
def get_users_wanting_parking(office_id: str, pool_date: date, db: Session) -> list[dict]:
"""
Get all users who want parking for this date, sorted by fairness priority.
Returns list of {user_id, has_guarantee, ratio}
"""
# Get users who marked "present" for this date:
# - Users belonging to this office
present_users = db.query(UserPresence).join(User).filter(
UserPresence.date == pool_date,
UserPresence.status == PresenceStatus.PRESENT,
User.office_id == office_id
).all()
candidates = []
for presence in present_users:
user_id = presence.user_id
# Skip excluded users
if is_user_excluded(user_id, office_id, pool_date, db):
continue
# Skip users who already have a spot
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == user_id
).first()
if existing:
continue
candidates.append({
"user_id": user_id,
"has_guarantee": has_guarantee(user_id, office_id, pool_date, db),
"ratio": get_user_parking_ratio(user_id, office_id, db)
})
# Sort: guaranteed users first, then by ratio (lowest first for fairness)
candidates.sort(key=lambda x: (not x["has_guarantee"], x["ratio"]))
return candidates
def assign_parking_fairly(office_id: str, pool_date: date, db: Session) -> dict:
"""
Assign parking spots fairly based on parking ratio.
Creates new DailyParkingAssignment rows only for assigned users.
"""
if is_closing_day(office_id, pool_date, db):
return {"assigned": [], "waitlist": [], "closed": True}
# Get candidates sorted by fairness
candidates = get_users_wanting_parking(office_id, pool_date, db)
# Get available spots (OfficeSpots not yet in assignments table)
free_spots = get_available_spots(office_id, pool_date, db)
assigned = []
waitlist = []
for candidate in candidates:
if free_spots:
# Sort spots by number to fill A1, A2... order
free_spots.sort(key=lambda s: s.spot_number)
spot = free_spots.pop(0)
# Create assignment
assignment = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=spot.id,
user_id=candidate["user_id"],
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(assignment)
assigned.append(candidate["user_id"])
else:
waitlist.append(candidate["user_id"])
db.commit()
return {"assigned": assigned, "waitlist": waitlist}
def release_user_spot(office_id: str, user_id: str, pool_date: date, db: Session) -> bool:
"""Release a user's parking spot and reassign to next in fairness queue"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date,
DailyParkingAssignment.user_id == user_id
).first()
if not assignment:
return False
# Capture spot ID before deletion
spot_id = assignment.spot_id
# Release the spot (Delete the row)
db.delete(assignment)
db.commit()
# Try to assign to next user in fairness queue
candidates = get_users_wanting_parking(office_id, pool_date, db)
if candidates:
top_candidate = candidates[0]
# Create new assignment for top candidate
new_assignment = DailyParkingAssignment(
id=generate_uuid(),
date=pool_date,
spot_id=spot_id,
user_id=top_candidate["user_id"],
office_id=office_id,
created_at=datetime.now(timezone.utc)
)
db.add(new_assignment)
db.commit()
return True
def handle_presence_change(user_id: str, change_date: date, old_status: PresenceStatus, new_status: PresenceStatus, office_id: str, db: Session):
"""
Handle presence status change and update parking accordingly.
Uses fairness algorithm for assignment.
"""
# Don't process past dates
if change_date < datetime.utcnow().date():
return
# Get office (must be valid)
office = db.query(Office).filter(Office.id == office_id).first()
if not office or not office.parking_quota:
return
# No initialization needed for sparse model
if old_status == PresenceStatus.PRESENT and new_status in [PresenceStatus.REMOTE, PresenceStatus.ABSENT]:
# User no longer coming - release their spot (will auto-reassign)
release_user_spot(office.id, user_id, change_date, db)
elif new_status == PresenceStatus.PRESENT:
# Check booking window
should_assign = True
if office.booking_window_enabled:
# Allocation time is Day-1 at cutoff hour
cutoff_dt = datetime.combine(change_date - timedelta(days=1), datetime.min.time())
cutoff_dt = cutoff_dt.replace(
hour=office.booking_window_end_hour,
minute=office.booking_window_end_minute
)
# If now is before cutoff, do not assign yet (wait for batch job)
if datetime.utcnow() < cutoff_dt:
should_assign = False
config.logger.debug(f"Queuing parking request for user {user_id} on {change_date} (Window open until {cutoff_dt})")
if should_assign:
# User coming in - run fair assignment for this date
assign_parking_fairly(office.id, change_date, db)
def clear_assignments_for_office_date(office_id: str, pool_date: date, db: Session) -> int:
"""
Clear all parking assignments for an office on a specific date.
Returns number of cleared spots.
"""
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.office_id == office_id,
DailyParkingAssignment.date == pool_date
).all()
count = len(assignments)
for a in assignments:
db.delete(a)
db.commit()
return count
def run_batch_allocation(office_id: str, pool_date: date, db: Session) -> dict:
"""
Run the batch allocation for a specific date.
Force clears existing assignments to ensure a fair clean-slate allocation.
"""
# 1. Clear existing assignments
clear_assignments_for_office_date(office_id, pool_date, db)
# 2. Run fair allocation
return assign_parking_fairly(office_id, pool_date, db)