Files
org-parking/app/routes/users.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

318 lines
12 KiB
Python

"""
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"}