Files
org-parking/app/routes/parking.py
Stefano Manfredi c74a0ed350 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
2025-11-26 23:37:50 +00:00

359 lines
13 KiB
Python

"""
Parking Management Routes
Parking assignments, spot management, and pool initialization
Manager-centric model:
- Managers own parking spots (defined by manager_parking_quota)
- Spots are named with manager's letter prefix (A1, A2, B1, B2...)
- Assignments reference manager_id directly
"""
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
import uuid
from database.connection import get_db
from database.models import DailyParkingAssignment, User, OfficeMembership
from utils.auth_middleware import get_current_user, require_manager_or_admin
from services.parking import initialize_parking_pool, get_spot_display_name
from services.notifications import queue_parking_change_notification
router = APIRouter(prefix="/api/parking", tags=["parking"])
# Request/Response Models
class InitPoolRequest(BaseModel):
date: str # YYYY-MM-DD
class ManualAssignRequest(BaseModel):
manager_id: str
user_id: str
spot_id: str
date: str
class ReassignSpotRequest(BaseModel):
assignment_id: str
new_user_id: str | None # None = release spot
class AssignmentResponse(BaseModel):
id: str
date: str
spot_id: str
spot_display_name: str | None = None
user_id: str | None
manager_id: str
user_name: str | None = None
user_email: str | None = None
user_office_id: str | None = None
# Routes
@router.post("/init-manager-pool")
def init_manager_pool(request: InitPoolRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Initialize parking pool for a manager on a given date"""
try:
datetime.strptime(request.date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
quota = current_user.manager_parking_quota or 0
if quota == 0:
return {"success": True, "message": "No parking quota configured", "spots": 0}
spots = initialize_parking_pool(current_user.id, quota, request.date, db)
return {"success": True, "spots": spots}
@router.get("/assignments/{date}", response_model=List[AssignmentResponse])
def get_assignments(date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get parking assignments for a date, optionally filtered by manager"""
try:
datetime.strptime(date, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
query = db.query(DailyParkingAssignment).filter(DailyParkingAssignment.date == date)
if manager_id:
query = query.filter(DailyParkingAssignment.manager_id == manager_id)
assignments = query.all()
results = []
for assignment in assignments:
# Get display name using manager's spot prefix
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
result = AssignmentResponse(
id=assignment.id,
date=assignment.date,
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id
)
if assignment.user_id:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
result.user_name = user.name
result.user_email = user.email
result.user_office_id = user.office_id
results.append(result)
return results
@router.get("/my-assignments", response_model=List[AssignmentResponse])
def get_my_assignments(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's parking assignments"""
query = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == current_user.id
)
if start_date:
query = query.filter(DailyParkingAssignment.date >= start_date)
if end_date:
query = query.filter(DailyParkingAssignment.date <= end_date)
assignments = query.order_by(DailyParkingAssignment.date.desc()).all()
results = []
for assignment in assignments:
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
results.append(AssignmentResponse(
id=assignment.id,
date=assignment.date,
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id,
user_name=current_user.name,
user_email=current_user.email
))
return results
@router.post("/manual-assign")
def manual_assign(data: ManualAssignRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Manually assign a spot to a user"""
# Verify user exists
user = db.query(User).filter(User.id == data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Verify manager exists and check permission
manager = db.query(User).filter(User.id == data.manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
# Only admin or the manager themselves can assign spots
if current_user.role != "admin" and current_user.id != data.manager_id:
raise HTTPException(status_code=403, detail="Not authorized to assign spots for this manager")
# Check if spot exists and is free
spot = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.manager_id == data.manager_id,
DailyParkingAssignment.date == data.date,
DailyParkingAssignment.spot_id == data.spot_id
).first()
if not spot:
raise HTTPException(status_code=404, detail="Spot not found")
if spot.user_id:
raise HTTPException(status_code=400, detail="Spot already assigned")
# Check if user already has a spot for this date (from any manager)
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.date == data.date,
DailyParkingAssignment.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a spot for this date")
spot.user_id = data.user_id
db.commit()
spot_display_name = get_spot_display_name(data.spot_id, data.manager_id, db)
return {"message": "Spot assigned", "spot_id": data.spot_id, "spot_display_name": spot_display_name}
@router.post("/release-my-spot/{assignment_id}")
def release_my_spot(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Release a parking spot assigned to the current user"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == assignment_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only release your own parking spot")
# Get spot display name for notification
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
assignment.user_id = None
db.commit()
# Queue notification (self-release, so just confirmation)
queue_parking_change_notification(
current_user, assignment.date, "released",
spot_display_name, db=db
)
return {"message": "Parking spot released"}
@router.post("/reassign-spot")
def reassign_spot(data: ReassignSpotRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Reassign a spot to another user or release it.
Allowed by: spot owner, their manager, or admin.
"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == data.assignment_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check permission: admin, manager who owns the spot, or current spot holder
is_admin = current_user.role == 'admin'
is_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id
if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized to reassign this spot")
# Store old user for notification
old_user_id = assignment.user_id
old_user = db.query(User).filter(User.id == old_user_id).first() if old_user_id else None
# Get spot display name for notifications
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
if data.new_user_id:
# Check new user exists
new_user = db.query(User).filter(User.id == data.new_user_id).first()
if not new_user:
raise HTTPException(status_code=404, detail="User not found")
# Check user doesn't already have a spot for this date
existing = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == data.new_user_id,
DailyParkingAssignment.date == assignment.date,
DailyParkingAssignment.id != assignment.id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a spot for this date")
assignment.user_id = data.new_user_id
# Queue notifications
# Notify old user that spot was reassigned
if old_user and old_user.id != new_user.id:
queue_parking_change_notification(
old_user, assignment.date, "reassigned",
spot_display_name, new_user.name, db
)
# Notify new user that spot was assigned
queue_parking_change_notification(
new_user, assignment.date, "assigned",
spot_display_name, db=db
)
else:
assignment.user_id = None
# Notify old user that spot was released
if old_user:
queue_parking_change_notification(
old_user, assignment.date, "released",
spot_display_name, db=db
)
db.commit()
db.refresh(assignment)
# Build response
spot_display_name = get_spot_display_name(assignment.spot_id, assignment.manager_id, db)
result = AssignmentResponse(
id=assignment.id,
date=assignment.date,
spot_id=assignment.spot_id,
spot_display_name=spot_display_name,
user_id=assignment.user_id,
manager_id=assignment.manager_id
)
if assignment.user_id:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
result.user_name = user.name
result.user_email = user.email
return result
@router.get("/eligible-users/{assignment_id}")
def get_eligible_users(assignment_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get users eligible for reassignment of a parking spot.
Returns users in the same manager's offices.
"""
assignment = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.id == assignment_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check permission: admin, manager who owns the spot, or current spot holder
is_admin = current_user.role == 'admin'
is_spot_owner = assignment.user_id == current_user.id
is_manager = current_user.id == assignment.manager_id
if not (is_admin or is_manager or is_spot_owner):
raise HTTPException(status_code=403, detail="Not authorized")
# Get all users belonging to offices managed by this manager
# Get offices managed by this manager
managed_office_ids = db.query(OfficeMembership.office_id).filter(
OfficeMembership.user_id == assignment.manager_id
).all()
managed_office_ids = [o[0] for o in managed_office_ids]
# Get users in those offices
users = db.query(User).filter(
User.office_id.in_(managed_office_ids),
User.id != assignment.user_id # Exclude current holder
).all()
# Filter out users who already have a spot for this date
existing_assignments = db.query(DailyParkingAssignment.user_id).filter(
DailyParkingAssignment.date == assignment.date,
DailyParkingAssignment.user_id.isnot(None),
DailyParkingAssignment.id != assignment.id
).all()
users_with_spots = {a[0] for a in existing_assignments}
result = []
for user in users:
if user.id not in users_with_spots:
result.append({
"id": user.id,
"name": user.name,
"email": user.email,
"office_id": user.office_id
})
return result