"""Authenticated, project-scoped node removal and restore routes.""" from collections.abc import Callable from typing import Annotated, Literal from fastapi import Cookie, Depends, FastAPI, Header, HTTPException, Request, Response from pydantic import BaseModel, ConfigDict from gewu.adapters.sqlite_node_removals import NodeRemoval, RemovalConflict, SQLiteNodeRemovalStore from gewu.application.identity import Principal from gewu.application.reading import CanonicalNodeReader, ReadError from gewu.domain.contracts import RecordId class NodeRemovalRequest(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) confirmed: Literal[True] class NodeRemovalItem(BaseModel): removal_id: str project_id: str node_id: str title: str requested_at: str restore_until: str @classmethod def from_record(cls, record: NodeRemoval) -> "NodeRemovalItem": return cls( removal_id=record.removal_id, project_id=record.project_id, node_id=record.node_id, title=record.title, requested_at=record.requested_at, restore_until=record.restore_until, ) class NodeRemovalList(BaseModel): can_manage: bool removals: list[NodeRemovalItem] def register_node_removal_routes( app: FastAPI, store: SQLiteNodeRemovalStore, reader: CanonicalNodeReader, authenticate: Callable[..., Principal], require_same_origin: Callable[[Request], None], ) -> None: def human( request: Request, response: Response, authorization: str | None = Header(default=None), session_cookie: str | None = Cookie(default=None, alias="gewu_session"), csrf_token: str | None = Header(default=None, alias="X-GEWU-CSRF"), ) -> Principal: response.headers["Cache-Control"] = "private, no-store" write = request.method not in {"GET", "HEAD"} if write: require_same_origin(request) principal = authenticate(authorization, session_cookie, csrf_token, require_csrf=write) if "agent" in principal.roles: raise HTTPException(403, "A human account is required") return principal def require_manager(principal: Principal, project_id: str) -> None: if not principal.can_read_project(project_id): raise HTTPException(404, "Project removal queue is unavailable") if not ({"editor", "admin", "owner"} & principal.roles): raise HTTPException(403, "Editor role is required to remove or restore knowledge") @app.get( "/v1/projects/{project_id}/node-removals", response_model=NodeRemovalList, tags=["knowledge"], operation_id="node_removal_list", ) def list_removals( project_id: RecordId, principal: Annotated[Principal, Depends(human)], ) -> NodeRemovalList: if not principal.can_read_project(project_id): raise HTTPException(404, "Project removal queue is unavailable") can_manage = bool({"editor", "admin", "owner"} & principal.roles) removals = store.list_restorable(project_id) if can_manage else () return NodeRemovalList( can_manage=can_manage, removals=[NodeRemovalItem.from_record(record) for record in removals], ) @app.post( "/v1/projects/{project_id}/nodes/{node_id}/removal", response_model=NodeRemovalItem, status_code=201, tags=["knowledge"], operation_id="node_removal_request", ) def request_removal( project_id: RecordId, node_id: RecordId, body: NodeRemovalRequest, principal: Annotated[Principal, Depends(human)], ) -> NodeRemovalItem: require_manager(principal, project_id) try: envelope = reader.get_node(project_id, node_id, principal=principal) title = str(envelope.data[0]["title"]) return NodeRemovalItem.from_record( store.request(project_id, node_id, title, principal.principal_id) ) except ReadError as error: raise HTTPException(status_code=error.status_code, detail=str(error)) from error except RemovalConflict as error: raise HTTPException(status_code=409, detail=str(error)) from error except RuntimeError as error: raise HTTPException(status_code=503, detail=str(error)) from error @app.post( "/v1/projects/{project_id}/nodes/{node_id}/restore-removal", response_model=NodeRemovalItem, tags=["knowledge"], operation_id="node_removal_restore", ) def restore_removal( project_id: RecordId, node_id: RecordId, principal: Annotated[Principal, Depends(human)], ) -> NodeRemovalItem: require_manager(principal, project_id) try: return NodeRemovalItem.from_record( store.restore(project_id, node_id, principal.principal_id) ) except KeyError as error: raise HTTPException(status_code=404, detail="Removed node was not found") from error except RemovalConflict as error: raise HTTPException(status_code=410, detail=str(error)) from error except RuntimeError as error: raise HTTPException(status_code=503, detail=str(error)) from error