"""User management routes.""" from __future__ import annotations import uuid from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.audit import log_audit from app.core.db import get_db from app.core.notifications import create_notification from app.deps import get_current_user, require_admin from app.schemas.user import UserCreate, UserUpdate from app.services.user_service import user_service, _UNSET router = APIRouter(prefix="/api/v1/users", tags=["users"]) 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("") 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_admin), ): """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) async def create_user( body: UserCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """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) user = await user_service.create_user( db, tenant_id, body.email, body.name, body.password, body.role, role_id, body.is_active, ) # 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 create_notification( db, tenant_id, user.id, "info", "Account created", f"Your account has been created by {current_user['name']}.", ) return { "id": str(user.id), "email": user.email, "name": user.name, "role": user.role, "role_id": str(user.role_id) if user.role_id else None, "is_active": user.is_active, "tenant_id": str(user.tenant_id), } @router.get("/{user_id}") async def get_user( user_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """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 user = await user_service.get_user(db, tenant_id, uid) if user is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) return { "id": str(user.id), "email": user.email, "name": user.name, "role": user.role, "role_id": str(user.role_id) if user.role_id else None, "is_active": user.is_active, "tenant_id": str(user.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_admin), ): """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``. """ 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 # 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 # 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 user = await user_service.update_user( db, tenant_id, uid, body.name, body.role, role_id, body.is_active, ) if user is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes) return { "id": str(user.id), "email": user.email, "name": user.name, "role": user.role, "role_id": str(user.role_id) if user.role_id else None, "is_active": user.is_active, "tenant_id": str(user.tenant_id), } @router.delete("/{user_id}") async def delete_user( user_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_admin), ): """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 user = await user_service.get_user(db, tenant_id, uid) if user is None: raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"}) 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)