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:
Stefano Manfredi
2025-11-26 23:37:50 +00:00
commit c74a0ed350
49 changed files with 9094 additions and 0 deletions

0
app/__init__.py Normal file
View File

44
app/config.py Normal file
View File

@@ -0,0 +1,44 @@
"""
Application Configuration
Environment-based settings with sensible defaults
"""
import os
from pathlib import Path
# Database
DATABASE_PATH = os.getenv("DATABASE_PATH", "parking.db")
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATABASE_PATH}")
# JWT Authentication
SECRET_KEY = os.getenv("SECRET_KEY", "change-me-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 1440 # 24 hours
# Server
HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000"))
# CORS
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",")
# Authelia Integration
AUTHELIA_ENABLED = os.getenv("AUTHELIA_ENABLED", "false").lower() == "true"
# Header names sent by Authelia
AUTHELIA_HEADER_USER = os.getenv("AUTHELIA_HEADER_USER", "Remote-User")
AUTHELIA_HEADER_NAME = os.getenv("AUTHELIA_HEADER_NAME", "Remote-Name")
AUTHELIA_HEADER_EMAIL = os.getenv("AUTHELIA_HEADER_EMAIL", "Remote-Email")
AUTHELIA_HEADER_GROUPS = os.getenv("AUTHELIA_HEADER_GROUPS", "Remote-Groups")
# Group to role mapping
AUTHELIA_ADMIN_GROUP = os.getenv("AUTHELIA_ADMIN_GROUP", "parking-admins")
AUTHELIA_MANAGER_GROUP = os.getenv("AUTHELIA_MANAGER_GROUP", "parking-managers")
# Email (optional)
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", SMTP_USER)
# Paths
BASE_DIR = Path(__file__).resolve().parent.parent
FRONTEND_DIR = BASE_DIR / "frontend"

0
app/routes/__init__.py Normal file
View File

137
app/routes/auth.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Authentication Routes
Login, register, logout, and user info
"""
from fastapi import APIRouter, Depends, HTTPException, status, Response
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from database.connection import get_db
from services.auth import (
create_user, authenticate_user, create_access_token,
get_user_by_email, hash_password, verify_password
)
from utils.auth_middleware import get_current_user
from app import config
import re
router = APIRouter(prefix="/api/auth", tags=["auth"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str
name: str
office_id: str | None = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: str
email: str
name: str | None
office_id: str | None
role: str
manager_parking_quota: int | None = None
week_start_day: int = 0
# Notification preferences
notify_weekly_parking: int = 1
notify_daily_parking: int = 1
notify_daily_parking_hour: int = 8
notify_daily_parking_minute: int = 0
notify_parking_changes: int = 1
@router.post("/register", response_model=TokenResponse)
def register(data: RegisterRequest, db: Session = Depends(get_db)):
"""Register a new user"""
if get_user_by_email(db, data.email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if len(data.password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters"
)
user = create_user(
db=db,
email=data.email,
password=data.password,
name=data.name,
office_id=data.office_id
)
token = create_access_token(user.id, user.email)
return TokenResponse(access_token=token)
@router.post("/login", response_model=TokenResponse)
def login(data: LoginRequest, response: Response, db: Session = Depends(get_db)):
"""Login with email and password"""
user = authenticate_user(db, data.email, data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
token = create_access_token(user.id, user.email)
response.set_cookie(
key="session_token",
value=token,
httponly=True,
max_age=config.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
samesite="lax"
)
return TokenResponse(access_token=token)
@router.post("/logout")
def logout(response: Response):
"""Logout and clear session"""
response.delete_cookie("session_token")
return {"message": "Logged out"}
@router.get("/me", response_model=UserResponse)
def get_me(user=Depends(get_current_user)):
"""Get current user info"""
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
office_id=user.office_id,
role=user.role,
manager_parking_quota=user.manager_parking_quota,
week_start_day=user.week_start_day or 0,
notify_weekly_parking=user.notify_weekly_parking if user.notify_weekly_parking is not None else 1,
notify_daily_parking=user.notify_daily_parking if user.notify_daily_parking is not None else 1,
notify_daily_parking_hour=user.notify_daily_parking_hour if user.notify_daily_parking_hour is not None else 8,
notify_daily_parking_minute=user.notify_daily_parking_minute if user.notify_daily_parking_minute is not None else 0,
notify_parking_changes=user.notify_parking_changes if user.notify_parking_changes is not None else 1
)
@router.get("/holidays/{year}")
def get_holidays(year: int):
"""Get public holidays for a given year"""
from services.holidays import get_holidays_for_year
if year < 2000 or year > 2100:
raise HTTPException(status_code=400, detail="Year must be between 2000 and 2100")
return get_holidays_for_year(year)

372
app/routes/managers.py Normal file
View File

@@ -0,0 +1,372 @@
"""
Manager Rules Routes
Manager settings, closing days, guarantees, and exclusions
Key concept: Managers own parking spots and set rules for all their managed offices.
Rules are set at manager level, not office level.
"""
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 (
Office, User, OfficeMembership,
ManagerClosingDay, ManagerWeeklyClosingDay,
ParkingGuarantee, ParkingExclusion
)
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
router = APIRouter(prefix="/api/managers", tags=["managers"])
# Request/Response Models
class ClosingDayCreate(BaseModel):
date: str # YYYY-MM-DD
reason: str | None = None
class WeeklyClosingDayCreate(BaseModel):
weekday: int # 0=Sunday, 1=Monday, ..., 6=Saturday
class GuaranteeCreate(BaseModel):
user_id: str
start_date: str | None = None
end_date: str | None = None
class ExclusionCreate(BaseModel):
user_id: str
start_date: str | None = None
end_date: str | None = None
class ManagerSettingsUpdate(BaseModel):
parking_quota: int | None = None
spot_prefix: str | None = None
# Manager listing and details
@router.get("")
def list_managers(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed offices and parking quota"""
managers = db.query(User).filter(User.role == "manager").all()
result = []
for manager in managers:
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
result.append({
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
})
return result
@router.get("/{manager_id}")
def get_manager_details(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get manager details including offices and parking settings"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
return {
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
}
@router.put("/{manager_id}/settings")
def update_manager_settings(manager_id: str, data: ManagerSettingsUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update manager parking settings (admin only)"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
if data.parking_quota is not None:
if data.parking_quota < 0:
raise HTTPException(status_code=400, detail="Parking quota must be non-negative")
manager.manager_parking_quota = data.parking_quota
if data.spot_prefix is not None:
if data.spot_prefix and not data.spot_prefix.isalpha():
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
if data.spot_prefix:
data.spot_prefix = data.spot_prefix.upper()
existing = db.query(User).filter(
User.manager_spot_prefix == data.spot_prefix,
User.id != manager_id
).first()
if existing:
raise HTTPException(status_code=400, detail=f"Spot prefix '{data.spot_prefix}' is already used")
manager.manager_spot_prefix = data.spot_prefix
manager.updated_at = datetime.utcnow().isoformat()
db.commit()
return {
"id": manager.id,
"parking_quota": manager.manager_parking_quota,
"spot_prefix": manager.manager_spot_prefix
}
@router.get("/{manager_id}/users")
def get_manager_users(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all users from offices managed by this manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager_id).all()
managed_office_ids = [m.office_id for m in memberships]
if not managed_office_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
return [{"id": u.id, "name": u.name, "email": u.email, "role": u.role, "office_id": u.office_id} for u in users]
# Closing days
@router.get("/{manager_id}/closing-days")
def get_manager_closing_days(manager_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get closing days for a manager"""
days = db.query(ManagerClosingDay).filter(
ManagerClosingDay.manager_id == manager_id
).order_by(ManagerClosingDay.date).all()
return [{"id": d.id, "date": d.date, "reason": d.reason} for d in days]
@router.post("/{manager_id}/closing-days")
def add_manager_closing_day(manager_id: str, data: ClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a closing day for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
existing = db.query(ManagerClosingDay).filter(
ManagerClosingDay.manager_id == manager_id,
ManagerClosingDay.date == data.date
).first()
if existing:
raise HTTPException(status_code=400, detail="Closing day already exists for this date")
closing_day = ManagerClosingDay(
id=str(uuid.uuid4()),
manager_id=manager_id,
date=data.date,
reason=data.reason
)
db.add(closing_day)
db.commit()
return {"id": closing_day.id, "message": "Closing day added"}
@router.delete("/{manager_id}/closing-days/{closing_day_id}")
def remove_manager_closing_day(manager_id: str, closing_day_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a closing day for a manager"""
closing_day = db.query(ManagerClosingDay).filter(
ManagerClosingDay.id == closing_day_id,
ManagerClosingDay.manager_id == manager_id
).first()
if not closing_day:
raise HTTPException(status_code=404, detail="Closing day not found")
db.delete(closing_day)
db.commit()
return {"message": "Closing day removed"}
# Weekly closing days
@router.get("/{manager_id}/weekly-closing-days")
def get_manager_weekly_closing_days(manager_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get weekly closing days for a manager"""
days = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id
).all()
return [{"id": d.id, "weekday": d.weekday} for d in days]
@router.post("/{manager_id}/weekly-closing-days")
def add_manager_weekly_closing_day(manager_id: str, data: WeeklyClosingDayCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add a weekly closing day for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
if data.weekday < 0 or data.weekday > 6:
raise HTTPException(status_code=400, detail="Weekday must be 0-6 (0=Sunday, 6=Saturday)")
existing = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.manager_id == manager_id,
ManagerWeeklyClosingDay.weekday == data.weekday
).first()
if existing:
raise HTTPException(status_code=400, detail="Weekly closing day already exists for this weekday")
weekly_closing = ManagerWeeklyClosingDay(
id=str(uuid.uuid4()),
manager_id=manager_id,
weekday=data.weekday
)
db.add(weekly_closing)
db.commit()
return {"id": weekly_closing.id, "message": "Weekly closing day added"}
@router.delete("/{manager_id}/weekly-closing-days/{weekly_id}")
def remove_manager_weekly_closing_day(manager_id: str, weekly_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove a weekly closing day for a manager"""
weekly_closing = db.query(ManagerWeeklyClosingDay).filter(
ManagerWeeklyClosingDay.id == weekly_id,
ManagerWeeklyClosingDay.manager_id == manager_id
).first()
if not weekly_closing:
raise HTTPException(status_code=404, detail="Weekly closing day not found")
db.delete(weekly_closing)
db.commit()
return {"message": "Weekly closing day removed"}
# Guarantees
@router.get("/{manager_id}/guarantees")
def get_manager_guarantees(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking guarantees for a manager"""
guarantees = db.query(ParkingGuarantee).filter(ParkingGuarantee.manager_id == manager_id).all()
result = []
for g in guarantees:
target_user = db.query(User).filter(User.id == g.user_id).first()
result.append({
"id": g.id,
"user_id": g.user_id,
"user_name": target_user.name if target_user else None,
"start_date": g.start_date,
"end_date": g.end_date
})
return result
@router.post("/{manager_id}/guarantees")
def add_manager_guarantee(manager_id: str, data: GuaranteeCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking guarantee for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingGuarantee).filter(
ParkingGuarantee.manager_id == manager_id,
ParkingGuarantee.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking guarantee")
guarantee = ParkingGuarantee(
id=str(uuid.uuid4()),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
created_at=datetime.utcnow().isoformat()
)
db.add(guarantee)
db.commit()
return {"id": guarantee.id, "message": "Guarantee added"}
@router.delete("/{manager_id}/guarantees/{guarantee_id}")
def remove_manager_guarantee(manager_id: str, guarantee_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking guarantee for a manager"""
guarantee = db.query(ParkingGuarantee).filter(
ParkingGuarantee.id == guarantee_id,
ParkingGuarantee.manager_id == manager_id
).first()
if not guarantee:
raise HTTPException(status_code=404, detail="Guarantee not found")
db.delete(guarantee)
db.commit()
return {"message": "Guarantee removed"}
# Exclusions
@router.get("/{manager_id}/exclusions")
def get_manager_exclusions(manager_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get parking exclusions for a manager"""
exclusions = db.query(ParkingExclusion).filter(ParkingExclusion.manager_id == manager_id).all()
result = []
for e in exclusions:
target_user = db.query(User).filter(User.id == e.user_id).first()
result.append({
"id": e.id,
"user_id": e.user_id,
"user_name": target_user.name if target_user else None,
"start_date": e.start_date,
"end_date": e.end_date
})
return result
@router.post("/{manager_id}/exclusions")
def add_manager_exclusion(manager_id: str, data: ExclusionCreate, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Add parking exclusion for a manager"""
manager = db.query(User).filter(User.id == manager_id, User.role == "manager").first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
if not db.query(User).filter(User.id == data.user_id).first():
raise HTTPException(status_code=404, detail="User not found")
existing = db.query(ParkingExclusion).filter(
ParkingExclusion.manager_id == manager_id,
ParkingExclusion.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="User already has a parking exclusion")
exclusion = ParkingExclusion(
id=str(uuid.uuid4()),
manager_id=manager_id,
user_id=data.user_id,
start_date=data.start_date,
end_date=data.end_date,
created_at=datetime.utcnow().isoformat()
)
db.add(exclusion)
db.commit()
return {"id": exclusion.id, "message": "Exclusion added"}
@router.delete("/{manager_id}/exclusions/{exclusion_id}")
def remove_manager_exclusion(manager_id: str, exclusion_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Remove parking exclusion for a manager"""
exclusion = db.query(ParkingExclusion).filter(
ParkingExclusion.id == exclusion_id,
ParkingExclusion.manager_id == manager_id
).first()
if not exclusion:
raise HTTPException(status_code=404, detail="Exclusion not found")
db.delete(exclusion)
db.commit()
return {"message": "Exclusion removed"}

197
app/routes/offices.py Normal file
View File

@@ -0,0 +1,197 @@
"""
Office Management Routes
Admin CRUD for offices and manager-office memberships
"""
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 Office, User, OfficeMembership
from utils.auth_middleware import require_admin, require_manager_or_admin, get_current_user
router = APIRouter(prefix="/api/offices", tags=["offices"])
# Request/Response Models
class OfficeCreate(BaseModel):
name: str
location: str | None = None
class OfficeUpdate(BaseModel):
name: str | None = None
location: str | None = None
class OfficeResponse(BaseModel):
id: str
name: str
location: str | None = None
created_at: str | None
class Config:
from_attributes = True
class AddManagerRequest(BaseModel):
user_id: str
# Office CRUD Routes
@router.get("")
def list_offices(db: Session = Depends(get_db), user=Depends(get_current_user)):
"""List all offices with counts"""
offices = db.query(Office).all()
result = []
for office in offices:
manager_count = db.query(OfficeMembership).filter(OfficeMembership.office_id == office.id).count()
employee_count = db.query(User).filter(User.office_id == office.id).count()
result.append({
"id": office.id,
"name": office.name,
"location": office.location,
"created_at": office.created_at,
"manager_count": manager_count,
"employee_count": employee_count
})
return result
@router.get("/{office_id}", response_model=OfficeResponse)
def get_office(office_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""Get office by ID"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
return office
@router.post("", response_model=OfficeResponse)
def create_office(data: OfficeCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create new office (admin only)"""
office = Office(
id=str(uuid.uuid4()),
name=data.name,
location=data.location,
created_at=datetime.utcnow().isoformat()
)
db.add(office)
db.commit()
db.refresh(office)
return office
@router.put("/{office_id}", response_model=OfficeResponse)
def update_office(office_id: str, data: OfficeUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update office (admin only)"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if data.name is not None:
office.name = data.name
if data.location is not None:
office.location = data.location
office.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(office)
return office
@router.delete("/{office_id}")
def delete_office(office_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Delete office (admin only)"""
office = db.query(Office).filter(Office.id == office_id).first()
if not office:
raise HTTPException(status_code=404, detail="Office not found")
if db.query(User).filter(User.office_id == office_id).count() > 0:
raise HTTPException(status_code=400, detail="Cannot delete office with assigned users")
db.delete(office)
db.commit()
return {"message": "Office deleted"}
# Office membership routes (linking managers to offices)
@router.get("/{office_id}/managers")
def get_office_managers(office_id: str, db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get managers for an office"""
memberships = db.query(OfficeMembership).filter(OfficeMembership.office_id == office_id).all()
manager_ids = [m.user_id for m in memberships]
managers = db.query(User).filter(User.id.in_(manager_ids)).all()
return [{"id": m.id, "name": m.name, "email": m.email} for m in managers]
@router.post("/{office_id}/managers")
def add_office_manager(office_id: str, data: AddManagerRequest, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Add manager to office (admin only)"""
if not db.query(Office).filter(Office.id == office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
manager = db.query(User).filter(User.id == data.user_id).first()
if not manager:
raise HTTPException(status_code=404, detail="User not found")
if manager.role != "manager":
raise HTTPException(status_code=400, detail="User must have manager role")
existing = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id,
OfficeMembership.user_id == data.user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Manager already assigned to office")
membership = OfficeMembership(
id=str(uuid.uuid4()),
office_id=office_id,
user_id=data.user_id,
created_at=datetime.utcnow().isoformat()
)
db.add(membership)
db.commit()
return {"message": "Manager added to office"}
@router.delete("/{office_id}/managers/{manager_id}")
def remove_office_manager(office_id: str, manager_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Remove manager from office (admin only)"""
membership = db.query(OfficeMembership).filter(
OfficeMembership.office_id == office_id,
OfficeMembership.user_id == manager_id
).first()
if not membership:
raise HTTPException(status_code=404, detail="Manager not assigned to office")
db.delete(membership)
db.commit()
return {"message": "Manager removed from office"}
# Legacy redirect for /api/offices/managers/list -> /api/managers
@router.get("/managers/list")
def list_managers_legacy(db: Session = Depends(get_db), user=Depends(require_manager_or_admin)):
"""Get all managers with their managed offices and parking quota (legacy endpoint)"""
managers = db.query(User).filter(User.role == "manager").all()
result = []
for manager in managers:
memberships = db.query(OfficeMembership).filter(OfficeMembership.user_id == manager.id).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
result.append({
"id": manager.id,
"name": manager.name,
"email": manager.email,
"parking_quota": manager.manager_parking_quota or 0,
"spot_prefix": manager.manager_spot_prefix,
"offices": [{"id": o.id, "name": o.name} for o in offices]
})
return result

358
app/routes/parking.py Normal file
View File

@@ -0,0 +1,358 @@
"""
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

437
app/routes/presence.py Normal file
View File

@@ -0,0 +1,437 @@
"""
Presence Management Routes
User presence marking and admin management
"""
from typing import List
from datetime import datetime, timedelta
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 UserPresence, User, DailyParkingAssignment, OfficeMembership, Office
from utils.auth_middleware import get_current_user, require_manager_or_admin, check_manager_access_to_user
from services.parking import handle_presence_change, get_spot_display_name
router = APIRouter(prefix="/api/presence", tags=["presence"])
# Request/Response Models
class PresenceMarkRequest(BaseModel):
date: str # YYYY-MM-DD
status: str # present, remote, absent
class AdminPresenceMarkRequest(BaseModel):
user_id: str
date: str
status: str
class BulkPresenceRequest(BaseModel):
start_date: str
end_date: str
status: str
days: List[int] | None = None # Optional: [0,1,2,3,4] for Mon-Fri
class AdminBulkPresenceRequest(BaseModel):
user_id: str
start_date: str
end_date: str
status: str
days: List[int] | None = None
class PresenceResponse(BaseModel):
id: str
user_id: str
date: str
status: str
created_at: str | None
updated_at: str | None
parking_spot_number: str | None = None
class Config:
from_attributes = True
# Helper functions
def validate_status(status: str):
if status not in ["present", "remote", "absent"]:
raise HTTPException(status_code=400, detail="Status must be: present, remote, or absent")
def parse_date(date_str: str) -> datetime:
try:
return datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
def _mark_presence_for_user(
user_id: str,
date: str,
status: str,
db: Session,
target_user: User
) -> UserPresence:
"""
Core presence marking logic - shared by user and admin routes.
"""
validate_status(status)
parse_date(date)
existing = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date
).first()
now = datetime.utcnow().isoformat()
old_status = existing.status if existing else None
if existing:
existing.status = status
existing.updated_at = now
db.commit()
db.refresh(existing)
presence = existing
else:
presence = UserPresence(
id=str(uuid.uuid4()),
user_id=user_id,
date=date,
status=status,
created_at=now,
updated_at=now
)
db.add(presence)
db.commit()
db.refresh(presence)
# Handle parking assignment
if old_status != status and target_user.office_id:
try:
handle_presence_change(
user_id, date,
old_status or "absent", status,
target_user.office_id, db
)
except Exception as e:
print(f"Warning: Parking handler failed: {e}")
return presence
def _bulk_mark_presence(
user_id: str,
start_date: str,
end_date: str,
status: str,
days: List[int] | None,
db: Session,
target_user: User
) -> List[UserPresence]:
"""
Core bulk presence marking logic - shared by user and admin routes.
"""
validate_status(status)
start = parse_date(start_date)
end = parse_date(end_date)
if end < start:
raise HTTPException(status_code=400, detail="End date must be after start date")
if (end - start).days > 90:
raise HTTPException(status_code=400, detail="Range cannot exceed 90 days")
results = []
current_date = start
now = datetime.utcnow().isoformat()
while current_date <= end:
if days is None or current_date.weekday() in days:
date_str = current_date.strftime("%Y-%m-%d")
existing = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date_str
).first()
old_status = existing.status if existing else None
if existing:
existing.status = status
existing.updated_at = now
results.append(existing)
else:
presence = UserPresence(
id=str(uuid.uuid4()),
user_id=user_id,
date=date_str,
status=status,
created_at=now,
updated_at=now
)
db.add(presence)
results.append(presence)
# Handle parking for each date
if old_status != status and target_user.office_id:
try:
handle_presence_change(
user_id, date_str,
old_status or "absent", status,
target_user.office_id, db
)
except Exception:
pass
current_date += timedelta(days=1)
db.commit()
return results
def _delete_presence(
user_id: str,
date: str,
db: Session,
target_user: User
) -> dict:
"""
Core presence deletion logic - shared by user and admin routes.
"""
parse_date(date)
presence = db.query(UserPresence).filter(
UserPresence.user_id == user_id,
UserPresence.date == date
).first()
if not presence:
raise HTTPException(status_code=404, detail="Presence not found")
old_status = presence.status
db.delete(presence)
db.commit()
if target_user.office_id:
try:
handle_presence_change(
user_id, date,
old_status, "absent",
target_user.office_id, db
)
except Exception:
pass
return {"message": "Presence deleted"}
# User Routes
@router.post("/mark", response_model=PresenceResponse)
def mark_presence(data: PresenceMarkRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Mark presence for a date"""
return _mark_presence_for_user(current_user.id, data.date, data.status, db, current_user)
@router.post("/mark-bulk", response_model=List[PresenceResponse])
def mark_bulk_presence(data: BulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Mark presence for a date range"""
return _bulk_mark_presence(
current_user.id, data.start_date, data.end_date,
data.status, data.days, db, current_user
)
@router.get("/my-presences", response_model=List[PresenceResponse])
def get_my_presences(start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get current user's presences"""
query = db.query(UserPresence).filter(UserPresence.user_id == current_user.id)
if start_date:
parse_date(start_date)
query = query.filter(UserPresence.date >= start_date)
if end_date:
parse_date(end_date)
query = query.filter(UserPresence.date <= end_date)
return query.order_by(UserPresence.date.desc()).all()
@router.delete("/{date}")
def delete_presence(date: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Delete presence for a date"""
return _delete_presence(current_user.id, date, db, current_user)
# Admin/Manager Routes
@router.post("/admin/mark", response_model=PresenceResponse)
def admin_mark_presence(data: AdminPresenceMarkRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Mark presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == data.user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db)
return _mark_presence_for_user(data.user_id, data.date, data.status, db, target_user)
@router.post("/admin/mark-bulk", response_model=List[PresenceResponse])
def admin_mark_bulk_presence(data: AdminBulkPresenceRequest, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Bulk mark presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == data.user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db)
return _bulk_mark_presence(
data.user_id, data.start_date, data.end_date,
data.status, data.days, db, target_user
)
@router.delete("/admin/{user_id}/{date}")
def admin_delete_presence(user_id: str, date: str, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Delete presence for any user (manager/admin)"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db)
return _delete_presence(user_id, date, db, target_user)
@router.get("/team")
def get_team_presences(start_date: str, end_date: str, manager_id: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Get team presences with parking info for managers/admins, filtered by manager"""
parse_date(start_date)
parse_date(end_date)
# Get users based on permissions and manager filter
if manager_id:
# Filter by specific manager's offices
managed_office_ids = [m.office_id for m in db.query(OfficeMembership).filter(
OfficeMembership.user_id == manager_id
).all()]
if not managed_office_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_office_ids)).all()
elif current_user.role == "admin":
# Admin sees all users
users = db.query(User).all()
else:
# Manager sees only users in their managed offices
managed_ids = [m.office_id for m in current_user.managed_offices]
if not managed_ids:
return []
users = db.query(User).filter(User.office_id.in_(managed_ids)).all()
# Batch query presences and parking for all users
user_ids = [u.id for u in users]
presences = db.query(UserPresence).filter(
UserPresence.user_id.in_(user_ids),
UserPresence.date >= start_date,
UserPresence.date <= end_date
).all()
parking_assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id.in_(user_ids),
DailyParkingAssignment.date >= start_date,
DailyParkingAssignment.date <= end_date
).all()
# Build lookups
parking_lookup = {}
parking_info_lookup = {}
for p in parking_assignments:
if p.user_id not in parking_lookup:
parking_lookup[p.user_id] = []
parking_info_lookup[p.user_id] = []
parking_lookup[p.user_id].append(p.date)
spot_display_name = get_spot_display_name(p.spot_id, p.manager_id, db)
parking_info_lookup[p.user_id].append({
"id": p.id,
"date": p.date,
"spot_id": p.spot_id,
"spot_display_name": spot_display_name
})
# Build office and managed offices lookups
offices_lookup = {o.id: o.name for o in db.query(Office).all()}
managed_offices_lookup = {}
for m in db.query(OfficeMembership).all():
if m.user_id not in managed_offices_lookup:
managed_offices_lookup[m.user_id] = []
managed_offices_lookup[m.user_id].append(offices_lookup.get(m.office_id, m.office_id))
# Build response
result = []
for user in users:
user_presences = [p for p in presences if p.user_id == user.id]
# For managers, show managed offices; for others, show their office
if user.role == "manager" and user.id in managed_offices_lookup:
office_display = ", ".join(managed_offices_lookup[user.id])
else:
office_display = offices_lookup.get(user.office_id)
result.append({
"id": user.id,
"name": user.name,
"office_id": user.office_id,
"office_name": office_display,
"presences": [{"date": p.date, "status": p.status} for p in user_presences],
"parking_dates": parking_lookup.get(user.id, []),
"parking_info": parking_info_lookup.get(user.id, [])
})
return result
@router.get("/admin/{user_id}")
def get_user_presences(user_id: str, start_date: str = None, end_date: str = None, db: Session = Depends(get_db), current_user=Depends(require_manager_or_admin)):
"""Get any user's presences with parking info (manager/admin)"""
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(status_code=404, detail="User not found")
check_manager_access_to_user(current_user, target_user, db)
query = db.query(UserPresence).filter(UserPresence.user_id == user_id)
if start_date:
parse_date(start_date)
query = query.filter(UserPresence.date >= start_date)
if end_date:
parse_date(end_date)
query = query.filter(UserPresence.date <= end_date)
presences = query.order_by(UserPresence.date.desc()).all()
# Batch query parking assignments
date_strs = [p.date for p in presences]
parking_map = {}
if date_strs:
assignments = db.query(DailyParkingAssignment).filter(
DailyParkingAssignment.user_id == user_id,
DailyParkingAssignment.date.in_(date_strs)
).all()
for a in assignments:
parking_map[a.date] = get_spot_display_name(a.spot_id, a.manager_id, db)
# Build response
result = []
for presence in presences:
result.append({
"id": presence.id,
"user_id": presence.user_id,
"date": presence.date,
"status": presence.status,
"created_at": presence.created_at,
"updated_at": presence.updated_at,
"parking_spot_number": parking_map.get(presence.date)
})
return result

317
app/routes/users.py Normal file
View File

@@ -0,0 +1,317 @@
"""
User Management Routes
Admin user CRUD and user self-service (profile, settings, password)
"""
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
import uuid
import re
from database.connection import get_db
from database.models import User, Office, OfficeMembership
from utils.auth_middleware import get_current_user, require_admin
from services.auth import hash_password, verify_password
router = APIRouter(prefix="/api/users", tags=["users"])
# Request/Response Models
class UserCreate(BaseModel):
email: EmailStr
password: str
name: str | None = None
role: str = "employee"
office_id: str | None = None
class UserUpdate(BaseModel):
email: EmailStr | None = None
name: str | None = None
role: str | None = None
office_id: str | None = None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
class ProfileUpdate(BaseModel):
name: str | None = None
office_id: str | None = None
class SettingsUpdate(BaseModel):
week_start_day: int | None = None
# Notification preferences
notify_weekly_parking: int | None = None
notify_daily_parking: int | None = None
notify_daily_parking_hour: int | None = None
notify_daily_parking_minute: int | None = None
notify_parking_changes: int | None = None
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str
class UserResponse(BaseModel):
id: str
email: str
name: str | None
role: str
office_id: str | None
manager_parking_quota: int | None = None
manager_spot_prefix: str | None = None
created_at: str | None
class Config:
from_attributes = True
# Admin Routes
@router.get("", response_model=List[UserResponse])
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
"""List all users (admin only)"""
users = db.query(User).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
def get_user(user_id: str, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Get user by ID (admin only)"""
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
return target
@router.post("", response_model=UserResponse)
def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Create new user (admin only)"""
if db.query(User).filter(User.email == data.email).first():
raise HTTPException(status_code=400, detail="Email already registered")
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
if data.office_id:
if not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
new_user = User(
id=str(uuid.uuid4()),
email=data.email,
password_hash=hash_password(data.password),
name=data.name,
role=data.role,
office_id=data.office_id,
created_at=datetime.utcnow().isoformat()
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.put("/{user_id}", response_model=UserResponse)
def update_user(user_id: str, data: UserUpdate, db: Session = Depends(get_db), user=Depends(require_admin)):
"""Update user (admin only)"""
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
if data.email is not None:
existing = db.query(User).filter(User.email == data.email, User.id != user_id).first()
if existing:
raise HTTPException(status_code=400, detail="Email already in use")
target.email = data.email
if data.name is not None:
target.name = data.name
if data.role is not None:
if data.role not in ["admin", "manager", "employee"]:
raise HTTPException(status_code=400, detail="Invalid role")
target.role = data.role
if data.office_id is not None:
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
target.office_id = data.office_id
if data.manager_parking_quota is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Parking quota only for managers")
target.manager_parking_quota = data.manager_parking_quota
if data.manager_spot_prefix is not None:
if target.role != "manager":
raise HTTPException(status_code=400, detail="Spot prefix only for managers")
prefix = data.manager_spot_prefix.upper() if data.manager_spot_prefix else None
if prefix and not prefix.isalpha():
raise HTTPException(status_code=400, detail="Spot prefix must be a letter")
# Check for duplicate prefix
if prefix:
existing = db.query(User).filter(
User.manager_spot_prefix == prefix,
User.id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail=f"Spot prefix '{prefix}' is already used by another manager")
target.manager_spot_prefix = prefix
target.updated_at = datetime.utcnow().isoformat()
db.commit()
db.refresh(target)
return target
@router.delete("/{user_id}")
def delete_user(user_id: str, db: Session = Depends(get_db), current_user=Depends(require_admin)):
"""Delete user (admin only)"""
if user_id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
target = db.query(User).filter(User.id == user_id).first()
if not target:
raise HTTPException(status_code=404, detail="User not found")
db.delete(target)
db.commit()
return {"message": "User deleted"}
# Self-service Routes
@router.get("/me/managed-offices")
def get_managed_offices(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Get offices the current user manages"""
if current_user.role == "admin":
offices = db.query(Office).all()
return {"role": "admin", "offices": [{"id": o.id, "name": o.name} for o in offices]}
if current_user.role == "manager":
memberships = db.query(OfficeMembership).filter(
OfficeMembership.user_id == current_user.id
).all()
office_ids = [m.office_id for m in memberships]
offices = db.query(Office).filter(Office.id.in_(office_ids)).all()
return {"role": "manager", "offices": [{"id": o.id, "name": o.name} for o in offices]}
if current_user.office_id:
office = db.query(Office).filter(Office.id == current_user.office_id).first()
if office:
return {"role": "employee", "offices": [{"id": office.id, "name": office.name}]}
return {"role": current_user.role, "offices": []}
@router.get("/me/profile")
def get_profile(current_user=Depends(get_current_user)):
"""Get current user's profile"""
return {
"id": current_user.id,
"email": current_user.email,
"name": current_user.name,
"role": current_user.role,
"office_id": current_user.office_id
}
@router.put("/me/profile")
def update_profile(data: ProfileUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's profile"""
if data.name is not None:
current_user.name = data.name
if data.office_id is not None:
if data.office_id and not db.query(Office).filter(Office.id == data.office_id).first():
raise HTTPException(status_code=404, detail="Office not found")
current_user.office_id = data.office_id if data.office_id else None
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {"message": "Profile updated"}
@router.get("/me/settings")
def get_settings(current_user=Depends(get_current_user)):
"""Get current user's settings"""
return {
"week_start_day": current_user.week_start_day or 0,
"notify_weekly_parking": current_user.notify_weekly_parking if current_user.notify_weekly_parking is not None else 1,
"notify_daily_parking": current_user.notify_daily_parking if current_user.notify_daily_parking is not None else 1,
"notify_daily_parking_hour": current_user.notify_daily_parking_hour if current_user.notify_daily_parking_hour is not None else 8,
"notify_daily_parking_minute": current_user.notify_daily_parking_minute if current_user.notify_daily_parking_minute is not None else 0,
"notify_parking_changes": current_user.notify_parking_changes if current_user.notify_parking_changes is not None else 1
}
@router.put("/me/settings")
def update_settings(data: SettingsUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Update current user's settings"""
if data.week_start_day is not None:
if data.week_start_day not in [0, 1]:
raise HTTPException(status_code=400, detail="Week start must be 0 (Sunday) or 1 (Monday)")
current_user.week_start_day = data.week_start_day
# Notification preferences
if data.notify_weekly_parking is not None:
current_user.notify_weekly_parking = data.notify_weekly_parking
if data.notify_daily_parking is not None:
current_user.notify_daily_parking = data.notify_daily_parking
if data.notify_daily_parking_hour is not None:
if data.notify_daily_parking_hour < 0 or data.notify_daily_parking_hour > 23:
raise HTTPException(status_code=400, detail="Hour must be 0-23")
current_user.notify_daily_parking_hour = data.notify_daily_parking_hour
if data.notify_daily_parking_minute is not None:
if data.notify_daily_parking_minute < 0 or data.notify_daily_parking_minute > 59:
raise HTTPException(status_code=400, detail="Minute must be 0-59")
current_user.notify_daily_parking_minute = data.notify_daily_parking_minute
if data.notify_parking_changes is not None:
current_user.notify_parking_changes = data.notify_parking_changes
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {
"message": "Settings updated",
"week_start_day": current_user.week_start_day,
"notify_weekly_parking": current_user.notify_weekly_parking,
"notify_daily_parking": current_user.notify_daily_parking,
"notify_daily_parking_hour": current_user.notify_daily_parking_hour,
"notify_daily_parking_minute": current_user.notify_daily_parking_minute,
"notify_parking_changes": current_user.notify_parking_changes
}
@router.post("/me/change-password")
def change_password(data: ChangePasswordRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""Change current user's password"""
if not verify_password(data.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
# Validate new password
password = data.new_password
errors = []
if len(password) < 8:
errors.append("at least 8 characters")
if not re.search(r'[A-Z]', password):
errors.append("one uppercase letter")
if not re.search(r'[a-z]', password):
errors.append("one lowercase letter")
if not re.search(r'[0-9]', password):
errors.append("one number")
if errors:
raise HTTPException(status_code=400, detail=f"Password must contain: {', '.join(errors)}")
current_user.password_hash = hash_password(password)
current_user.updated_at = datetime.utcnow().isoformat()
db.commit()
return {"message": "Password changed"}