from __future__ import annotations from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session from govoplan_core.api.v1.schemas import ( ReferenceOptionListResponse, ReferenceOptionResponse, ) from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.institutional import ( EvidenceReference, InstitutionalContextError, InstitutionalReference, ) from govoplan_core.db.session import get_session from govoplan_core.core.references import ( access_scope_reference_page, access_scope_reference_provider_available, ) from govoplan_core.core.runtime import get_registry from govoplan_cases.backend.domain import CaseGrant, CaseRecord from govoplan_cases.backend.manifest import ( ADMIN_SCOPE, ASSIGN_SCOPE, CLOSE_SCOPE, CREATE_SCOPE, READ_SCOPE, SHARE_SCOPE, UPDATE_SCOPE, ) from govoplan_cases.backend.schemas import ( CaseDecisionRequest, CaseEvidenceLinkRequest, CaseEvidenceUnlinkRequest, CaseHistoryResponse, CaseListResponse, CaseStatusWriteRequest, CaseTimelineResponse, CaseTypeWriteRequest, CaseUpdateRequest, CaseWriteRequest, ) from govoplan_cases.backend.evidence_links import ( link_case_evidence, unlink_case_evidence, ) from govoplan_cases.backend.decision_path import ( CaseDecisionCommand, CaseDecisionError, CaseDecisionPath, CaseDecisionUnavailable, DECISION_READ_SCOPE, DECISION_SENSITIVE_READ_SCOPE, DECISION_WRITE_SCOPE, ) from govoplan_cases.backend.service import ( CaseStoreError, can_access_case, case_history, case_timeline, create_case, get_case, list_case_catalog, list_cases, update_case, upsert_case_status, upsert_case_type, ) router = APIRouter(prefix="/cases", tags=["cases"]) def _require(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") def _error(exc: Exception) -> HTTPException: message = str(exc) lowered = message.casefold() if isinstance(exc, CaseDecisionUnavailable): code = status.HTTP_424_FAILED_DEPENDENCY elif isinstance(exc, LookupError): code = 404 elif isinstance(exc, PermissionError): code = 403 elif any(word in lowered for word in ("conflict", "already", "stale")): code = 409 else: code = 400 return HTTPException(status_code=code, detail=message) @router.get("/catalog", response_model=dict[str, list[dict[str, Any]]]) def api_case_catalog( include_inactive: bool = False, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, list[dict[str, Any]]]: _require(principal, ADMIN_SCOPE if include_inactive else READ_SCOPE) return list_case_catalog( session, principal, include_inactive=include_inactive, ) @router.put("/catalog/statuses/{status_key}", response_model=dict[str, Any]) def api_upsert_case_status( status_key: str, payload: CaseStatusWriteRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, ADMIN_SCOPE) if status_key != payload.status_key: raise HTTPException( status_code=400, detail="Case status path and payload keys must match.", ) try: row = upsert_case_status(session, principal, **payload.model_dump()) session.commit() except (CaseStoreError, InstitutionalContextError) as exc: session.rollback() raise _error(exc) from exc return { "status_key": row.status_key, "label": row.label, "category": row.category, "terminal": row.terminal, "sort_order": row.sort_order, "active": row.active, "revision": row.revision, } @router.put("/catalog/types/{type_key}", response_model=dict[str, Any]) def api_upsert_case_type( type_key: str, payload: CaseTypeWriteRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, ADMIN_SCOPE) if type_key != payload.type_key: raise HTTPException( status_code=400, detail="Case type path and payload keys must match.", ) try: row = upsert_case_type(session, principal, **payload.model_dump()) session.commit() except (CaseStoreError, InstitutionalContextError) as exc: session.rollback() raise _error(exc) from exc return { "type_key": row.type_key, "label": row.label, "description": row.description, "initial_status_key": row.initial_status_key, "allowed_status_keys": list(row.allowed_status_keys or ()), "active": row.active, "revision": row.revision, } @router.get("", response_model=CaseListResponse) def api_list_cases( query: str = "", status_key: list[str] | None = Query(default=None), case_type_key: list[str] | None = Query(default=None), offset: int = Query(default=0, ge=0), limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> CaseListResponse: _require(principal, READ_SCOPE) try: items, total = list_cases( session, principal, query=query, status_keys=status_key, case_type_keys=case_type_key, offset=offset, limit=limit, ) except CaseStoreError as exc: raise _error(exc) from exc return CaseListResponse( cases=[item.to_dict() for item in items], total=total, offset=offset, limit=limit, ) @router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) def api_create_case( payload: CaseWriteRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, CREATE_SCOPE) try: record = CaseRecord.from_mapping(payload.record) if record.access_mode == "restricted" or record.access_grants: _require(principal, SHARE_SCOPE) item = create_case( session, principal, record=record, idempotency_key=payload.idempotency_key, ) session.commit() except (CaseStoreError, InstitutionalContextError) as exc: session.rollback() raise _error(exc) from exc return item.to_dict() @router.get("/{case_id}/decisions", response_model=dict[str, list[dict[str, Any]]]) def api_case_decisions( case_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, list[dict[str, Any]]]: _require(principal, READ_SCOPE) _require(principal, DECISION_READ_SCOPE) try: items = CaseDecisionPath(get_registry()).linked( session, principal, case_id=case_id, ) except ( CaseDecisionError, InstitutionalContextError, LookupError, PermissionError, ) as exc: raise _error(exc) from exc disclose = has_scope(principal, DECISION_SENSITIVE_READ_SCOPE) return { "decisions": [ item.to_dict(include_protected=disclose) for item in items ] } @router.post( "/{case_id}/decisions", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_record_case_decision( case_id: str, payload: CaseDecisionRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, UPDATE_SCOPE) _require(principal, DECISION_WRITE_SCOPE) try: result = CaseDecisionPath(get_registry()).record( session, principal, case_id=case_id, command=CaseDecisionCommand( expected_case_revision=payload.expected_case_revision, effective_at=payload.effective_at, decision_type=payload.decision_type, operative_result=payload.operative_result, reasoning=payload.reasoning, conditions=tuple(payload.conditions), change_reason=payload.change_reason, idempotency_key=payload.idempotency_key, ), ) session.commit() except ( CaseDecisionError, InstitutionalContextError, LookupError, PermissionError, ValueError, ) as exc: session.rollback() raise _error(exc) from exc return result.to_dict() @router.get("/{case_id}", response_model=dict[str, Any]) def api_get_case( case_id: str, revision: int | None = Query(default=None, ge=1), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, READ_SCOPE) item = get_case(session, principal, case_id=case_id, revision=revision) if item is None: raise HTTPException(status_code=404, detail="Case not found") return item.to_dict() @router.patch("/{case_id}", response_model=dict[str, Any]) def api_update_case( case_id: str, payload: CaseUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, UPDATE_SCOPE) fields = payload.model_fields_set if "assignment_refs" in fields: _require(principal, ASSIGN_SCOPE) if fields & {"access_mode", "access_grants"}: _require(principal, SHARE_SCOPE) changes = _update_changes(payload) if "status_key" in changes: catalog = list_case_catalog(session, principal) terminal = { str(item["status_key"]): bool(item["terminal"]) for item in catalog["statuses"] } if terminal.get(str(changes["status_key"]), False): _require(principal, CLOSE_SCOPE) try: item = update_case( session, principal, case_id=case_id, expected_revision=payload.expected_revision, changes=changes, recorded_at=payload.recorded_at, change_reason=payload.change_reason, idempotency_key=payload.idempotency_key, ) session.commit() except ( CaseStoreError, InstitutionalContextError, LookupError, PermissionError, ) as exc: session.rollback() raise _error(exc) from exc return item.to_dict() @router.post("/{case_id}/evidence-links", response_model=dict[str, Any]) def api_link_case_evidence( case_id: str, payload: CaseEvidenceLinkRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, UPDATE_SCOPE) try: item = link_case_evidence( session, principal, case_id=case_id, expected_revision=payload.expected_revision, reference=payload.reference.model_dump(), recorded_at=payload.recorded_at, change_reason=payload.change_reason, idempotency_key=payload.idempotency_key, ) session.commit() except ( CaseStoreError, InstitutionalContextError, LookupError, PermissionError, ) as exc: session.rollback() raise _error(exc) from exc return item.to_dict() @router.post( "/{case_id}/evidence-links/{evidence_id}/unlink", response_model=dict[str, Any], ) def api_unlink_case_evidence( case_id: str, evidence_id: str, payload: CaseEvidenceUnlinkRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, UPDATE_SCOPE) try: item = unlink_case_evidence( session, principal, case_id=case_id, evidence_id=evidence_id, expected_revision=payload.expected_revision, recorded_at=payload.recorded_at, change_reason=payload.change_reason, idempotency_key=payload.idempotency_key, ) session.commit() except ( CaseStoreError, InstitutionalContextError, LookupError, PermissionError, ) as exc: session.rollback() raise _error(exc) from exc return item.to_dict() @router.get( "/{case_id}/share-target-options", response_model=ReferenceOptionListResponse, ) def api_case_share_target_options( case_id: str, target_type: Literal["user", "group"], q: str = "", selected: list[str] = Query(default=[]), limit: int = Query(default=50, ge=1, le=200), cursor: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> ReferenceOptionListResponse: _require(principal, SHARE_SCOPE) if not can_access_case( session, principal, case_id=case_id, permission="share", ): raise HTTPException(status_code=403, detail="Case share access is denied") try: page = access_scope_reference_page( get_registry(), principal, scope_type=target_type, reference_kind="user" if target_type == "user" else "group", query=q, selected_values=selected, limit=limit, cursor=cursor, administrative=True, session=session, ) except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc return ReferenceOptionListResponse( options=[ReferenceOptionResponse(**item.to_dict()) for item in page.options], provider_available=access_scope_reference_provider_available(get_registry()), next_cursor=page.next_cursor, has_more=page.has_more, ) @router.get("/{case_id}/history", response_model=CaseHistoryResponse) def api_case_history( case_id: str, limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> CaseHistoryResponse: _require(principal, READ_SCOPE) return CaseHistoryResponse( revisions=[ item.to_dict() for item in case_history( session, principal, case_id=case_id, limit=limit, ) ] ) @router.get("/{case_id}/timeline", response_model=CaseTimelineResponse) def api_case_timeline( case_id: str, limit: int = Query(default=200, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> CaseTimelineResponse: _require(principal, READ_SCOPE) return CaseTimelineResponse( entries=list( case_timeline( session, principal, case_id=case_id, limit=limit, ) ) ) def _update_changes(payload: CaseUpdateRequest) -> dict[str, object]: excluded = { "expected_revision", "recorded_at", "change_reason", "idempotency_key", } raw = payload.model_dump(exclude_unset=True, exclude=excluded) for key in ("party_refs", "assignment_refs", "decision_refs", "record_refs"): if key in raw: raw[key] = tuple( InstitutionalReference.from_mapping(item) for item in (raw[key] or ()) ) if "access_grants" in raw: raw["access_grants"] = tuple( CaseGrant.from_mapping(item) for item in (raw["access_grants"] or ()) ) if "evidence_refs" in raw: raw["evidence_refs"] = tuple( EvidenceReference.from_mapping(item) for item in (raw["evidence_refs"] or ()) ) if "metadata" in raw and raw["metadata"] is None: raw["metadata"] = {} return raw __all__ = ["router"]