335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""
|
|
User Management Routes
|
|
Admin user CRUD and user self-service (profile, settings, password)
|
|
"""
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy.orm import Session
|
|
|
|
from database.connection import get_db
|
|
from database.models import User, UserRole, Office
|
|
from utils.auth_middleware import get_current_user, require_admin
|
|
from utils.helpers import (
|
|
generate_uuid, is_ldap_user, is_ldap_admin,
|
|
validate_password, format_password_errors, get_notification_default
|
|
)
|
|
from services.auth import hash_password, verify_password
|
|
from app import config
|
|
|
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
|
|
|
|
|
# Request/Response Models
|
|
class UserCreate(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
name: str | None = None
|
|
role: UserRole = UserRole.EMPLOYEE
|
|
office_id: str | None = None
|
|
|
|
|
|
class UserUpdate(BaseModel):
|
|
name: str | None = None
|
|
role: UserRole | None = None
|
|
office_id: str | None = None
|
|
|
|
|
|
class ProfileUpdate(BaseModel):
|
|
name: str | None = None
|
|
|
|
|
|
class SettingsUpdate(BaseModel):
|
|
week_start_day: int | None = None
|
|
# Notification preferences
|
|
notify_weekly_parking: bool | None = None
|
|
notify_daily_parking: bool | None = None
|
|
notify_daily_parking_hour: int | None = None
|
|
notify_daily_parking_minute: int | None = None
|
|
notify_parking_changes: bool | None = None
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class UserResponse(BaseModel):
|
|
id: str
|
|
email: str
|
|
name: str | None
|
|
role: UserRole
|
|
office_id: str | None = None
|
|
office_name: str | None = None
|
|
is_ldap_user: bool = False
|
|
is_ldap_admin: bool = False
|
|
created_at: str | None
|
|
parking_ratio: float | None = None
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
def user_to_response(user: User, db: Session, office_lookup: dict = None) -> dict:
|
|
"""
|
|
Convert user to response dict with computed fields.
|
|
"""
|
|
# Get office name - use lookup if available, otherwise query
|
|
office_name = None
|
|
if user.office_id:
|
|
if office_lookup is not None:
|
|
office_name = office_lookup.get(user.office_id)
|
|
else:
|
|
office = db.query(Office).filter(Office.id == user.office_id).first()
|
|
if office:
|
|
office_name = office.name
|
|
|
|
# Calculate parking ratio (score)
|
|
parking_ratio = None
|
|
if user.office_id:
|
|
try:
|
|
# Avoid circular import by importing inside function if needed,
|
|
# or ensure services.parking doesn't import this file.
|
|
from services.parking import get_user_parking_ratio
|
|
parking_ratio = get_user_parking_ratio(user.id, user.office_id, db)
|
|
except ImportError:
|
|
pass
|
|
|
|
return {
|
|
"id": user.id,
|
|
"email": user.email,
|
|
"name": user.name,
|
|
"role": user.role,
|
|
"office_id": user.office_id,
|
|
"office_name": office_name,
|
|
"is_ldap_user": is_ldap_user(user),
|
|
"is_ldap_admin": is_ldap_admin(user),
|
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
|
"parking_ratio": parking_ratio
|
|
}
|
|
|
|
|
|
# Admin Routes
|
|
@router.get("")
|
|
def list_users(db: Session = Depends(get_db), user=Depends(require_admin)):
|
|
"""List all users (admin only)"""
|
|
users = db.query(User).all()
|
|
|
|
# Build lookups to avoid N+1 queries
|
|
# Office lookup: id -> name
|
|
office_ids = list(set(u.office_id for u in users if u.office_id))
|
|
offices = db.query(Office).filter(Office.id.in_(office_ids)).all() if office_ids else []
|
|
office_lookup = {o.id: o.name for o in offices}
|
|
|
|
return [user_to_response(u, db, office_lookup) for u in users]
|
|
|
|
|
|
@router.get("/{user_id}")
|
|
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 user_to_response(target, db)
|
|
|
|
|
|
@router.post("")
|
|
def create_user(data: UserCreate, db: Session = Depends(get_db), user=Depends(require_admin)):
|
|
"""Create new user (admin only) - only for non-LDAP mode"""
|
|
if config.AUTHELIA_ENABLED:
|
|
raise HTTPException(status_code=400, detail="User creation disabled in LDAP mode. Users are created on first login.")
|
|
|
|
if db.query(User).filter(User.email == data.email).first():
|
|
raise HTTPException(status_code=400, detail="Email already registered")
|
|
|
|
# Role validation handled by Pydantic Enum
|
|
|
|
# Validate password strength
|
|
password_errors = validate_password(data.password)
|
|
if password_errors:
|
|
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
|
|
|
if data.office_id:
|
|
office = db.query(Office).filter(Office.id == data.office_id).first()
|
|
if not office:
|
|
raise HTTPException(status_code=400, detail="Invalid office")
|
|
|
|
new_user = User(
|
|
id=generate_uuid(),
|
|
email=data.email,
|
|
password_hash=hash_password(data.password),
|
|
name=data.name,
|
|
role=data.role,
|
|
office_id=data.office_id,
|
|
created_at=datetime.utcnow()
|
|
)
|
|
|
|
db.add(new_user)
|
|
db.commit()
|
|
db.refresh(new_user)
|
|
config.logger.info(f"Admin created new user: {data.email}")
|
|
return user_to_response(new_user, db)
|
|
|
|
|
|
@router.put("/{user_id}")
|
|
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")
|
|
|
|
# Check if user is LDAP-managed
|
|
target_is_ldap = is_ldap_user(target)
|
|
target_is_ldap_admin = is_ldap_admin(target)
|
|
|
|
# Name update - blocked for LDAP users
|
|
if data.name is not None:
|
|
if target_is_ldap:
|
|
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
|
target.name = data.name
|
|
|
|
# Role update
|
|
if data.role is not None:
|
|
# Can't change admin role for LDAP admins (they get admin from parking_admins group)
|
|
if target_is_ldap_admin and data.role != UserRole.ADMIN:
|
|
raise HTTPException(status_code=400, detail="Admin role is managed by LDAP group (parking_admins)")
|
|
target.role = data.role
|
|
|
|
# Office assignment
|
|
if "office_id" in data.__fields_set__:
|
|
if data.office_id:
|
|
office = db.query(Office).filter(Office.id == data.office_id).first()
|
|
if not office:
|
|
raise HTTPException(status_code=400, detail="Invalid office")
|
|
target.office_id = data.office_id if data.office_id else None
|
|
|
|
target.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(target)
|
|
return user_to_response(target, db)
|
|
|
|
|
|
@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/profile")
|
|
def get_profile(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
|
"""Get current user's profile"""
|
|
# Get office name
|
|
office_name = None
|
|
if current_user.office_id:
|
|
office = db.query(Office).filter(Office.id == current_user.office_id).first()
|
|
if office:
|
|
office_name = office.name
|
|
|
|
return {
|
|
"id": current_user.id,
|
|
"email": current_user.email,
|
|
"name": current_user.name,
|
|
"role": current_user.role,
|
|
"office_id": current_user.office_id,
|
|
"office_name": office_name,
|
|
"is_ldap_user": is_ldap_user(current_user)
|
|
}
|
|
|
|
|
|
@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 (limited fields)"""
|
|
if data.name is not None:
|
|
if is_ldap_user(current_user):
|
|
raise HTTPException(status_code=400, detail="Name is managed by LDAP")
|
|
current_user.name = data.name
|
|
current_user.updated_at = datetime.utcnow()
|
|
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": get_notification_default(current_user.week_start_day, 0),
|
|
"notify_weekly_parking": get_notification_default(current_user.notify_weekly_parking, True),
|
|
"notify_daily_parking": get_notification_default(current_user.notify_daily_parking, True),
|
|
"notify_daily_parking_hour": get_notification_default(current_user.notify_daily_parking_hour, 8),
|
|
"notify_daily_parking_minute": get_notification_default(current_user.notify_daily_parking_minute, 0),
|
|
"notify_parking_changes": get_notification_default(current_user.notify_parking_changes, True)
|
|
}
|
|
|
|
|
|
@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, 6]:
|
|
raise HTTPException(status_code=400, detail="Week start must be 0 (Monday) or 6 (Sunday)")
|
|
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()
|
|
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 (not available in LDAP mode)"""
|
|
if is_ldap_user(current_user):
|
|
raise HTTPException(status_code=400, detail="Password is managed by LDAP")
|
|
|
|
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_errors = validate_password(data.new_password)
|
|
if password_errors:
|
|
raise HTTPException(status_code=400, detail=format_password_errors(password_errors))
|
|
|
|
current_user.password_hash = hash_password(data.new_password)
|
|
current_user.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
config.logger.info(f"User {current_user.email} changed password")
|
|
return {"message": "Password changed"}
|