"""User management routes.""" from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.auth import get_redis, invalidate_all_user_sessions from app.core.db import get_db from app.core.notifications import post_system_message from app.core.permissions import invalidate_permission_cache from app.deps import get_current_user, require_permission from app.models.user import User from app.schemas.user import PaginatedUsers, UserCreate, UserResponse, UserUpdate from app.services.owner_transfer_service import transfer_ownership from app.services.user_service import _UNSET, user_service router = APIRouter(prefix="/api/v1/users", tags=["users"]) class MenuOrderRequest(BaseModel): """Update the current user's menu order preference.""" menu_order: list[str] = Field(..., min_length=0) def _parse_role_id(raw: str | None) -> uuid.UUID | None: """Convert a string body value into a UUID or None. Empty string and None are both treated as "clear role_id". """ if raw is None or raw == "": return None try: return uuid.UUID(raw) except (ValueError, AttributeError): raise HTTPException( 400, detail={"detail": "Invalid role_id", "code": "invalid_role_id"}, ) from None @router.get("", response_model=PaginatedUsers) async def list_users( page: int = Query(1, ge=1), page_size: int = Query(25, ge=1, le=100), search: str | None = Query(None), db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("users:read")), ): """List users (admin only, paginated).""" tenant_id = uuid.UUID(current_user["tenant_id"]) return await user_service.list_users(db, tenant_id, page, page_size, search) @router.post("", status_code=status.HTTP_201_CREATED, response_model=UserResponse) async def create_user( body: UserCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("users:write")), ): """Create a new user (admin only).""" tenant_id = uuid.UUID(current_user["tenant_id"]) user_id = uuid.UUID(current_user["user_id"]) role_id = _parse_role_id(body.role_id) # Mass-Assignment protection: only system admin can create admin users role = body.role if role == "admin" and not current_user.get("is_system_admin"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"detail": "Only system admin can create admin users", "code": "role_escalation_forbidden"}, ) try: user = await user_service.create_user( db, tenant_id, body.email, body.name, body.password, role, role_id, body.is_active, ) except Exception as exc: from sqlalchemy.exc import IntegrityError if isinstance(exc, IntegrityError): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"detail": "User with this email already exists", "code": "duplicate_email"}, ) from exc raise # Audit log await log_audit( db, tenant_id, user_id, "create", "user", user.id, changes={"email": body.email, "name": body.name, "role": body.role, "role_id": body.role_id}, ) # Notification await post_system_message( db, tenant_id, user.id, "info", "Account created", f"Your account has been created by {current_user['name']}.", ) # Publish user.created event from app.core.event_bus import get_event_bus event_bus = get_event_bus() await event_bus.publish('user.created', { 'user_id': str(user.id), 'tenant_id': str(tenant_id), 'email': body.email, 'name': body.name, 'role': body.role, }) return { "id": str(user.id), "email": user.email, "name": user.name, "role": body.role, "role_id": str(role_id) if role_id else None, "is_active": user.is_active, "tenant_id": str(tenant_id), } @router.get("/{user_id}", response_model=UserResponse) async def get_user( user_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("users:read")), ): """Get a single user.""" tenant_id = uuid.UUID(current_user["tenant_id"]) try: uid = uuid.UUID(user_id) except ValueError: raise HTTPException( 400, detail={"detail": "Invalid user_id", "code": "invalid_id"} ) from None result = await user_service.get_user(db, tenant_id, uid) if result is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) user, user_tenant = result return { "id": str(user.id), "email": user.email, "name": user.name, "role": user_tenant.role, "role_id": str(user_tenant.role_id) if user_tenant.role_id else None, "is_active": user.is_active, "tenant_id": str(user_tenant.tenant_id), } @router.patch("/{user_id}") async def update_user( user_id: str, body: UserUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("users:write")), ): """Update a user (admin only). Uses ``model_fields_set`` to detect whether ``role_id`` was explicitly present in the request body (even if sent as ``null``). This allows the caller to clear the FK by sending ``role_id: null``. Self-modification prevention: a user cannot change their own role, is_active status, or system_admin flag. """ tenant_id = uuid.UUID(current_user["tenant_id"]) acting_user_id = uuid.UUID(current_user["user_id"]) try: uid = uuid.UUID(user_id) except ValueError: raise HTTPException( 400, detail={"detail": "Invalid user_id", "code": "invalid_id"} ) from None # Self-modification prevention: cannot change own role or active status if uid == acting_user_id: if body.role is not None or body.is_active is not None or "role_id" in body.model_fields_set: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"detail": "Cannot modify your own role or active status", "code": "self_modification_forbidden"}, ) # Mass-Assignment protection: only system admin can change roles to admin if body.role == "admin" and not current_user.get("is_system_admin"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"detail": "Only system admin can assign admin role", "code": "role_escalation_forbidden"}, ) # Only system admin can change is_system_admin flag if body.is_system_admin is not None and not current_user.get("is_system_admin"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"detail": "Only system admin can change system admin flag", "code": "admin_flag_forbidden"}, ) # Determine if role_id was explicitly sent (Pydantic v2) role_id_sent = "role_id" in body.model_fields_set changes: dict[str, Any] = {} if body.name is not None: changes["name"] = body.name if body.role is not None: changes["role"] = body.role if role_id_sent: changes["role_id"] = body.role_id if body.is_active is not None: changes["is_active"] = body.is_active if body.is_system_admin is not None: changes["is_system_admin"] = body.is_system_admin # Pass _UNSET sentinel when role_id was not in the request body # so the service leaves the existing value untouched. role_id: uuid.UUID | None | Any if role_id_sent: role_id = _parse_role_id(body.role_id) else: role_id = _UNSET # Handle profile fields if body.first_name is not None: changes["first_name"] = body.first_name if body.last_name is not None: changes["last_name"] = body.last_name if body.email is not None: changes["email"] = body.email if body.avatar_url is not None: changes["avatar_url"] = body.avatar_url if body.new_password is not None: changes["password_changed"] = True try: result = await user_service.update_user( db, tenant_id, uid, body.name, body.role, role_id, body.is_active, body.first_name, body.last_name, body.avatar_url, body.email, body.current_password, body.new_password, body.is_system_admin, ) except ValueError as exc: raise HTTPException(400, detail={"detail": str(exc), "code": "invalid_password"}) from None if result is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) user, user_tenant = result # Auto-transfer ownership when user is deactivated if body.is_active is False: await transfer_ownership( db, tenant_id, from_user_id=uid, to_user_id=acting_user_id, ) await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes) # Invalidate permission cache for the updated user redis = get_redis() await invalidate_permission_cache(redis, uid, tenant_id) # If is_system_admin was changed, invalidate ALL sessions for this user # so the stale admin flag doesn't persist in Redis until TTL (8h) if body.is_system_admin is not None: try: await invalidate_all_user_sessions(redis, uid) except Exception: pass # Best-effort — don't fail the update if Redis is down return { "id": str(user.id), "email": user.email, "name": user.name, "first_name": user.first_name, "last_name": user.last_name, "avatar_url": user.avatar_url, "role": user_tenant.role, "role_id": str(user_tenant.role_id) if user_tenant.role_id else None, "is_active": user.is_active, "tenant_id": str(user_tenant.tenant_id), } @router.delete("/{user_id}") async def delete_user( user_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("users:write")), ): """Delete a user (admin only).""" tenant_id = uuid.UUID(current_user["tenant_id"]) acting_user_id = uuid.UUID(current_user["user_id"]) try: uid = uuid.UUID(user_id) except ValueError: raise HTTPException( 400, detail={"detail": "Invalid user_id", "code": "invalid_id"} ) from None # Get user snapshot for audit before deletion result = await user_service.get_user(db, tenant_id, uid) if result is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) user, user_tenant = result success = await user_service.delete_user(db, tenant_id, uid) if not success: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) await log_audit( db, tenant_id, acting_user_id, "delete", "user", uid, changes={"email": user.email, "name": user.name}, ) return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get("/me/menu-order") async def get_menu_order( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get the current user's menu order preference.""" user_id = uuid.UUID(current_user["user_id"]) result = await db.execute( select(User).where(User.id == user_id) ) user = result.scalar_one_or_none() if user is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) menu_order = user.preferences.get("menu_order", []) return {"menu_order": menu_order} @router.put("/me/menu-order") async def update_menu_order( body: MenuOrderRequest, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Update the current user's menu order preference.""" user_id = uuid.UUID(current_user["user_id"]) menu_order = body.menu_order result = await db.execute( select(User).where(User.id == user_id) ) user = result.scalar_one_or_none() if user is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) prefs = dict(user.preferences) if user.preferences else {} prefs["menu_order"] = menu_order user.preferences = prefs await db.commit() return {"menu_order": menu_order}