539 lines
16 KiB
Python
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"]
|