feat: implement governed cases workspace
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
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 (
|
||||
CaseHistoryResponse,
|
||||
CaseListResponse,
|
||||
CaseStatusWriteRequest,
|
||||
CaseTimelineResponse,
|
||||
CaseTypeWriteRequest,
|
||||
CaseUpdateRequest,
|
||||
CaseWriteRequest,
|
||||
)
|
||||
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, 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}", 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.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"]
|
||||
Reference in New Issue
Block a user