"""Departments. Deliberately unpaged: an SME has a handful of them. The interesting rule is deletion — a department's read grants CASCADE away with it, and that access loss is invisible, so it has to be confirmed rather than discovered later. """ import uuid from typing import Annotated from fastapi import Depends from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.api.admin.routing import admin_router from app.db import get_db from app.errors import ApiError from app.models import Department, DocPermission, Document, User router = admin_router() NAME_TAKEN = ("Department name already exists.", "name_taken") class DepartmentCreate(BaseModel): name: str = Field(min_length=1, max_length=200) class AdminDepartmentOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID name: str @router.post("/departments") async def create_department( body: DepartmentCreate, db: Annotated[AsyncSession, Depends(get_db)], ) -> AdminDepartmentOut: department = Department(name=body.name.strip()) db.add(department) try: await db.commit() except IntegrityError: await db.rollback() raise ApiError(409, *NAME_TAKEN) from None return AdminDepartmentOut.model_validate(department) @router.patch("/departments/{department_id}") async def rename_department( department_id: uuid.UUID, body: DepartmentCreate, db: Annotated[AsyncSession, Depends(get_db)], ) -> AdminDepartmentOut: department = await db.get(Department, department_id) if department is None: raise ApiError(404, "Department not found.", "not_found") department.name = body.name.strip() try: await db.commit() except IntegrityError: await db.rollback() raise ApiError(409, *NAME_TAKEN) from None return AdminDepartmentOut.model_validate(department) async def _still_in_use(db: AsyncSession, department_id: uuid.UUID) -> bool: """Members, owned documents or read grants — anything whose access changes when this department disappears.""" for count in ( select(func.count(User.id)).where(User.department_id == department_id), select(func.count(Document.id)).where(Document.department_id == department_id), select(func.count()) .select_from(DocPermission) .where(DocPermission.department_id == department_id), ): if (await db.execute(count)).scalar_one(): return True return False @router.delete("/departments/{department_id}", status_code=204) async def delete_department( department_id: uuid.UUID, db: Annotated[AsyncSession, Depends(get_db)], confirm: bool = False, ) -> None: """Delete a department. Members and owned documents survive with their `department_id` set to NULL, but this department's `doc_permissions` grants CASCADE away — silently dropping the shared read access they gave. Because that access loss is invisible, deleting a department that still has members, owned documents or grants requires `?confirm=true` (409 `department_in_use` otherwise).""" department = await db.get(Department, department_id) if department is None: raise ApiError(404, "Department not found.", "not_found") if not confirm and await _still_in_use(db, department_id): raise ApiError( 409, "This department is still in use; deleting it drops that access.", "department_in_use", ) await db.delete(department) await db.commit()