Initial commit: Parking Manager
Features: - Manager-centric parking spot management - Fair assignment algorithm (parking/presence ratio) - Presence tracking calendar - Closing days (specific & weekly recurring) - Guarantees and exclusions - Authelia/LLDAP integration for SSO Stack: - FastAPI backend - SQLite database - Vanilla JS frontend - Docker deployment
This commit is contained in:
343
services/parking.py
Normal file
343
services/parking.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
Parking Assignment Service
|
||||
Manager-centric parking spot management with fairness algorithm
|
||||
|
||||
Key concepts:
|
||||
- Managers own parking spots (defined by manager_parking_quota)
|
||||
- Each manager has a spot prefix (A, B, C...) for display names
|
||||
- Spots are named like A1, A2, B1, B2 based on manager prefix
|
||||
- Fairness: users with lowest parking_days/office_days ratio get priority
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from database.models import (
|
||||
DailyParkingAssignment, User, OfficeMembership, UserPresence,
|
||||
ParkingGuarantee, ParkingExclusion, ManagerClosingDay, ManagerWeeklyClosingDay
|
||||
)
|
||||
|
||||
|
||||
def get_manager_for_office(office_id: str, db: Session) -> User | None:
|
||||
"""Find the manager responsible for an office"""
|
||||
membership = db.query(OfficeMembership).filter(
|
||||
OfficeMembership.office_id == office_id
|
||||
).first()
|
||||
|
||||
if not membership:
|
||||
return None
|
||||
|
||||
return db.query(User).filter(User.id == membership.user_id).first()
|
||||
|
||||
|
||||
def get_spot_prefix(manager: User, db: Session) -> str:
|
||||
"""Get the spot prefix for a manager (from manager_spot_prefix or auto-assign)"""
|
||||
if manager.manager_spot_prefix:
|
||||
return manager.manager_spot_prefix
|
||||
|
||||
# Auto-assign based on alphabetical order of managers without prefix
|
||||
managers = db.query(User).filter(
|
||||
User.role == "manager",
|
||||
User.manager_spot_prefix == None
|
||||
).order_by(User.name).all()
|
||||
|
||||
# Find existing prefixes
|
||||
existing_prefixes = set(
|
||||
m.manager_spot_prefix for m in db.query(User).filter(
|
||||
User.role == "manager",
|
||||
User.manager_spot_prefix != None
|
||||
).all()
|
||||
)
|
||||
|
||||
# Find first available letter
|
||||
manager_index = next((i for i, m in enumerate(managers) if m.id == manager.id), 0)
|
||||
letter = 'A'
|
||||
count = 0
|
||||
while letter in existing_prefixes or count < manager_index:
|
||||
if letter not in existing_prefixes:
|
||||
count += 1
|
||||
letter = chr(ord(letter) + 1)
|
||||
if ord(letter) > ord('Z'):
|
||||
letter = 'A'
|
||||
break
|
||||
|
||||
return letter
|
||||
|
||||
|
||||
def get_spot_display_name(spot_id: str, manager_id: str, db: Session) -> str:
|
||||
"""Get display name for a spot (e.g., 'A3' instead of 'spot-3')"""
|
||||
manager = db.query(User).filter(User.id == manager_id).first()
|
||||
if not manager:
|
||||
return spot_id
|
||||
|
||||
prefix = get_spot_prefix(manager, db)
|
||||
spot_number = spot_id.replace("spot-", "")
|
||||
return f"{prefix}{spot_number}"
|
||||
|
||||
|
||||
def is_closing_day(manager_id: str, date: str, db: Session) -> bool:
|
||||
"""
|
||||
Check if date is a closing day for this manager.
|
||||
Checks both specific closing days and weekly recurring closing days.
|
||||
"""
|
||||
# Check specific closing day
|
||||
specific = db.query(ManagerClosingDay).filter(
|
||||
ManagerClosingDay.manager_id == manager_id,
|
||||
ManagerClosingDay.date == date
|
||||
).first()
|
||||
if specific:
|
||||
return True
|
||||
|
||||
# Check weekly closing day
|
||||
date_obj = datetime.strptime(date, "%Y-%m-%d")
|
||||
weekday = date_obj.weekday() # 0=Monday in Python
|
||||
# Convert to our format: 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
weekday_sunday_start = (weekday + 1) % 7
|
||||
|
||||
weekly = db.query(ManagerWeeklyClosingDay).filter(
|
||||
ManagerWeeklyClosingDay.manager_id == manager_id,
|
||||
ManagerWeeklyClosingDay.weekday == weekday_sunday_start
|
||||
).first()
|
||||
|
||||
return weekly is not None
|
||||
|
||||
|
||||
def initialize_parking_pool(manager_id: str, quota: int, date: str, db: Session) -> int:
|
||||
"""Initialize empty parking spots for a manager's pool on a given date.
|
||||
Returns 0 if it's a closing day (no parking available).
|
||||
"""
|
||||
# Don't create pool on closing days
|
||||
if is_closing_day(manager_id, date, db):
|
||||
return 0
|
||||
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == manager_id,
|
||||
DailyParkingAssignment.date == date
|
||||
).count()
|
||||
|
||||
if existing > 0:
|
||||
return existing
|
||||
|
||||
for i in range(1, quota + 1):
|
||||
spot = DailyParkingAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
date=date,
|
||||
spot_id=f"spot-{i}",
|
||||
user_id=None,
|
||||
manager_id=manager_id,
|
||||
created_at=datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
db.add(spot)
|
||||
|
||||
db.commit()
|
||||
return quota
|
||||
|
||||
|
||||
def get_user_parking_ratio(user_id: str, manager_id: str, db: Session) -> float:
|
||||
"""
|
||||
Calculate user's parking ratio: parking_days / office_days
|
||||
Lower ratio = higher priority for next parking spot
|
||||
"""
|
||||
# Get offices managed by this manager
|
||||
managed_office_ids = [
|
||||
m.office_id for m in db.query(OfficeMembership).filter(
|
||||
OfficeMembership.user_id == manager_id
|
||||
).all()
|
||||
]
|
||||
|
||||
# Count days user was present (office_days)
|
||||
office_days = db.query(UserPresence).filter(
|
||||
UserPresence.user_id == user_id,
|
||||
UserPresence.status == "present"
|
||||
).count()
|
||||
|
||||
if office_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.manager_id == manager_id
|
||||
).count()
|
||||
|
||||
return parking_days / office_days
|
||||
|
||||
|
||||
def is_user_excluded(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
||||
"""Check if user is excluded from parking for this date"""
|
||||
exclusion = db.query(ParkingExclusion).filter(
|
||||
ParkingExclusion.manager_id == manager_id,
|
||||
ParkingExclusion.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not exclusion:
|
||||
return False
|
||||
|
||||
# Check date range
|
||||
if exclusion.start_date and date < exclusion.start_date:
|
||||
return False
|
||||
if exclusion.end_date and date > exclusion.end_date:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def has_guarantee(user_id: str, manager_id: str, date: str, db: Session) -> bool:
|
||||
"""Check if user has a parking guarantee for this date"""
|
||||
guarantee = db.query(ParkingGuarantee).filter(
|
||||
ParkingGuarantee.manager_id == manager_id,
|
||||
ParkingGuarantee.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not guarantee:
|
||||
return False
|
||||
|
||||
# Check date range
|
||||
if guarantee.start_date and date < guarantee.start_date:
|
||||
return False
|
||||
if guarantee.end_date and date > guarantee.end_date:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_users_wanting_parking(manager_id: str, date: str, 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 offices managed by this manager
|
||||
managed_office_ids = [
|
||||
m.office_id for m in db.query(OfficeMembership).filter(
|
||||
OfficeMembership.user_id == manager_id
|
||||
).all()
|
||||
]
|
||||
|
||||
# Get users who marked "present" for this date and belong to managed offices
|
||||
present_users = db.query(UserPresence).join(User).filter(
|
||||
UserPresence.date == date,
|
||||
UserPresence.status == "present",
|
||||
User.office_id.in_(managed_office_ids)
|
||||
).all()
|
||||
|
||||
candidates = []
|
||||
for presence in present_users:
|
||||
user_id = presence.user_id
|
||||
|
||||
# Skip excluded users
|
||||
if is_user_excluded(user_id, manager_id, date, db):
|
||||
continue
|
||||
|
||||
# Skip users who already have a spot
|
||||
existing = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.date == date,
|
||||
DailyParkingAssignment.user_id == user_id
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
candidates.append({
|
||||
"user_id": user_id,
|
||||
"has_guarantee": has_guarantee(user_id, manager_id, date, db),
|
||||
"ratio": get_user_parking_ratio(user_id, manager_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(manager_id: str, date: str, db: Session) -> dict:
|
||||
"""
|
||||
Assign parking spots fairly based on parking ratio.
|
||||
Called after presence is set for a date.
|
||||
Returns {assigned: [...], waitlist: [...]}
|
||||
"""
|
||||
manager = db.query(User).filter(User.id == manager_id).first()
|
||||
if not manager or not manager.manager_parking_quota:
|
||||
return {"assigned": [], "waitlist": []}
|
||||
|
||||
# No parking on closing days
|
||||
if is_closing_day(manager_id, date, db):
|
||||
return {"assigned": [], "waitlist": [], "closed": True}
|
||||
|
||||
# Initialize pool
|
||||
initialize_parking_pool(manager_id, manager.manager_parking_quota, date, db)
|
||||
|
||||
# Get candidates sorted by fairness
|
||||
candidates = get_users_wanting_parking(manager_id, date, db)
|
||||
|
||||
# Get available spots
|
||||
free_spots = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == manager_id,
|
||||
DailyParkingAssignment.date == date,
|
||||
DailyParkingAssignment.user_id == None
|
||||
).all()
|
||||
|
||||
assigned = []
|
||||
waitlist = []
|
||||
|
||||
for candidate in candidates:
|
||||
if free_spots:
|
||||
spot = free_spots.pop(0)
|
||||
spot.user_id = candidate["user_id"]
|
||||
assigned.append(candidate["user_id"])
|
||||
else:
|
||||
waitlist.append(candidate["user_id"])
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"assigned": assigned, "waitlist": waitlist}
|
||||
|
||||
|
||||
def release_user_spot(manager_id: str, user_id: str, date: str, db: Session) -> bool:
|
||||
"""Release a user's parking spot and reassign to next in fairness queue"""
|
||||
assignment = db.query(DailyParkingAssignment).filter(
|
||||
DailyParkingAssignment.manager_id == manager_id,
|
||||
DailyParkingAssignment.date == date,
|
||||
DailyParkingAssignment.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
return False
|
||||
|
||||
# Release the spot
|
||||
assignment.user_id = None
|
||||
db.commit()
|
||||
|
||||
# Try to assign to next user in fairness queue
|
||||
candidates = get_users_wanting_parking(manager_id, date, db)
|
||||
if candidates:
|
||||
assignment.user_id = candidates[0]["user_id"]
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_presence_change(user_id: str, date: str, old_status: str, new_status: str, office_id: str, db: Session):
|
||||
"""
|
||||
Handle presence status change and update parking accordingly.
|
||||
Uses fairness algorithm for assignment.
|
||||
"""
|
||||
# Don't process past dates
|
||||
target_date = datetime.strptime(date, "%Y-%m-%d").date()
|
||||
if target_date < datetime.now().date():
|
||||
return
|
||||
|
||||
# Find manager for this office
|
||||
manager = get_manager_for_office(office_id, db)
|
||||
if not manager or not manager.manager_parking_quota:
|
||||
return
|
||||
|
||||
# Initialize pool if needed
|
||||
initialize_parking_pool(manager.id, manager.manager_parking_quota, date, db)
|
||||
|
||||
if old_status == "present" and new_status in ["remote", "absent"]:
|
||||
# User no longer coming - release their spot (will auto-reassign)
|
||||
release_user_spot(manager.id, user_id, date, db)
|
||||
|
||||
elif new_status == "present":
|
||||
# User coming in - run fair assignment for this date
|
||||
assign_parking_fairly(manager.id, date, db)
|
||||
Reference in New Issue
Block a user