Files
govoplan-cases/src/govoplan_cases/backend/router.py
T
zemion f236034e88
Module Package Release / publish-packages (push) Successful in 11s
feat(cases): link provider-owned evidence references
2026-08-21 16:10:48 +02:00

539 lines
16 KiB
Python

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"]