"""User accounts. An admin creates people, corrects their details, and offboards them. The two guardrails here exist because an admin who locks themselves out has no second admin to call: you cannot change your own role, and you cannot delete yourself. """ import uuid from typing import Annotated from fastapi import Depends, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.admin.routing import admin_router from app.auth.deps import get_current_user from app.auth.passwords import hash_password from app.auth.sessions import revoke_user_sessions from app.db import get_db from app.errors import ApiError from app.models import Department, User, UserRole router = admin_router() EMAIL_TAKEN = ("Email address already in use.", "email_taken") class AdminUserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID email: str name: str role: UserRole department_id: uuid.UUID | None class AdminUserPage(BaseModel): items: list[AdminUserOut] total: int per_page: int class UserCreate(BaseModel): email: str = Field(min_length=3, max_length=320) name: str = Field(min_length=1, max_length=200) role: UserRole = UserRole.member department_id: uuid.UUID | None = None password: str = Field(min_length=8, max_length=200) class UserUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=200) # Correcting a typo in an address should not mean deleting the person # and losing everything hanging off their id. Plain `str` like # UserCreate: `EmailStr` would pull in email-validator, a dependency we # deliberately avoid, and the format is already guarded by the browser's # type="email" and the column's unique constraint. email: str | None = Field(default=None, min_length=3, max_length=320) role: UserRole | None = None department_id: uuid.UUID | None = None clear_department: bool | None = None # Setting a password IS the reset mechanism. password: str | None = Field(default=None, min_length=8, max_length=200) def _normalize_email(email: str) -> str: """The same normalisation on create and update, or an edited address would stop matching what login looks up.""" return email.strip().lower() async def _department_must_exist( db: AsyncSession, department_id: uuid.UUID | None ) -> None: if department_id is not None and await db.get(Department, department_id) is None: raise ApiError(404, "Department not found.", "not_found") @router.get("/users") async def list_users( db: Annotated[AsyncSession, Depends(get_db)], search: str | None = Query(None, max_length=200), page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100), ) -> AdminUserPage: """Paged, because the admin screen is the one place that scales with headcount: a company with two hundred employees would otherwise get two hundred rows and no way to find anyone. Departments deliberately stay unpaged: an SME has a handful, and a pager over five rows is furniture. """ filters = [] if search: needle = f"%{search.strip()}%" filters.append(or_(User.email.ilike(needle), User.name.ilike(needle))) total = (await db.execute(select(func.count(User.id)).where(*filters))).scalar_one() rows = ( ( await db.execute( select(User) .where(*filters) .order_by(User.email) .offset((page - 1) * per_page) .limit(per_page) ) ) .scalars() .all() ) return AdminUserPage( items=[AdminUserOut.model_validate(row) for row in rows], total=total, per_page=per_page, ) @router.post("/users") async def create_user( body: UserCreate, db: Annotated[AsyncSession, Depends(get_db)], ) -> AdminUserOut: await _department_must_exist(db, body.department_id) user = User( email=_normalize_email(body.email), name=body.name, role=body.role, department_id=body.department_id, password_hash=hash_password(body.password), ) db.add(user) try: await db.commit() except IntegrityError: await db.rollback() raise ApiError(409, *EMAIL_TAKEN) from None return AdminUserOut.model_validate(user) @router.patch("/users/{user_id}") async def update_user( user_id: uuid.UUID, body: UserUpdate, admin: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> AdminUserOut: user = await db.get(User, user_id) if user is None: raise ApiError(404, "User not found.", "not_found") if user.id == admin.id and body.role is not None and body.role != user.role: raise ApiError(409, "You cannot change your own role.", "self_modification") if body.name is not None: user.name = body.name if body.email is not None: user.email = _normalize_email(body.email) if body.role is not None: user.role = body.role if body.clear_department: user.department_id = None elif body.department_id is not None: await _department_must_exist(db, body.department_id) user.department_id = body.department_id if body.password is not None: user.password_hash = hash_password(body.password) # A password change revokes every session of that user — including # the current one if an admin changes their own password. await revoke_user_sessions(db, user.id) try: await db.commit() except IntegrityError: await db.rollback() raise ApiError(409, *EMAIL_TAKEN) from None return AdminUserOut.model_validate(user) @router.delete("/users/{user_id}", status_code=204) async def delete_user( user_id: uuid.UUID, admin: Annotated[User, Depends(get_current_user)], db: Annotated[AsyncSession, Depends(get_db)], ) -> None: """Offboarding: sessions and conversations cascade, documents survive with author set to NULL.""" if user_id == admin.id: raise ApiError(409, "You cannot delete your own account.", "self_modification") user = await db.get(User, user_id) if user is None: raise ApiError(404, "User not found.", "not_found") await db.delete(user) await db.commit()