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