from __future__ import annotations import hashlib from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.audit.logging import audit_from_principal from govoplan_core.core.records import RecordFilingRequest, RecordSourceLocator from govoplan_core.db.session import get_session from govoplan_records.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE from govoplan_records.backend.schemas import ( FilePlanNodeWriteRequest, RecordAppraisalRequest, RecordArchiveProviderResponse, RecordCatalogResponse, RecordClassWriteRequest, RecordCloseRequest, RecordCreateRequest, RecordDetailResponse, RecordDispositionCreateRequest, RecordDispositionFinalizeRequest, RecordDispositionWithdrawRequest, RecordHoldCreateRequest, RecordHoldReleaseRequest, RecordItemCreateRequest, RecordLifecycleActionRequest, RecordListResponse, RecordRecoveryStatusResponse, RecordSourceProviderResponse, RecordTransferDispatchRequest, RecordTransferPackageCreateRequest, RecordUpdateRequest, RecordVolumeCreateRequest, ) from govoplan_records.backend.service import ( RecordConflictError, RecordNotFoundError, RecordSourceUnavailableError, RecordStoreError, SqlRecordRegistry, ) from govoplan_records.backend.recovery import ( RecordRecoveryError, begin_record_atomic_recovery, bind_record_recovery_operation, reset_record_recovery_operation, ) def create_router(registry: object | None = None) -> APIRouter: router = APIRouter(prefix="/records", tags=["records"]) records = SqlRecordRegistry(registry) @router.get("/catalog", response_model=RecordCatalogResponse) def api_catalog( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordCatalogResponse: _require(principal, READ_SCOPE) return RecordCatalogResponse(**records.catalog(session, principal)) @router.post( "/catalog/file-plan", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_write_file_plan_node( payload: FilePlanNodeWriteRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, ADMIN_SCOPE) return _write( session, lambda: records.write_file_plan_node( session, principal, payload=payload.model_dump(mode="python") ), principal=principal, operation_type="catalog.file_plan.write", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record_file_plan_node", resource_id=payload.node_id, ) @router.post( "/catalog/classes", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_write_record_class( payload: RecordClassWriteRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, ADMIN_SCOPE) return _write( session, lambda: records.write_record_class( session, principal, payload=payload.model_dump(mode="python") ), principal=principal, operation_type="catalog.class.write", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record_class", resource_id=payload.class_id, ) @router.get("/sources", response_model=RecordSourceProviderResponse) def api_source_providers( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordSourceProviderResponse: _require(principal, WRITE_SCOPE) return RecordSourceProviderResponse( providers=records.source_providers(session, principal) ) @router.get("/archive-providers", response_model=RecordArchiveProviderResponse) def api_archive_providers( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordArchiveProviderResponse: _require(principal, WRITE_SCOPE) return RecordArchiveProviderResponse( providers=records.archive_providers(session, principal) ) @router.get("", response_model=RecordListResponse) def api_list_records( query: str | None = Query(default=None, max_length=500), record_state: str | None = Query(default=None, alias="state", max_length=40), class_id: str | None = Query(default=None, max_length=255), file_plan_node_id: str | None = Query(default=None, max_length=255), 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), ) -> RecordListResponse: _require(principal, READ_SCOPE) items, total = records.list_records( session, principal, query=query, state=record_state, class_id=class_id, file_plan_node_id=file_plan_node_id, offset=offset, limit=limit, ) return RecordListResponse( records=items, total=total, offset=offset, limit=limit ) @router.post("", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) def api_create_record( payload: RecordCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.create_record( session, principal, payload=payload.model_dump(mode="python") ), principal=principal, operation_type="record.create", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=payload.record_id or payload.record_number, ) @router.get("/{record_id}", response_model=RecordDetailResponse) def api_get_record( record_id: str, revision: int | None = Query(default=None, ge=1), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordDetailResponse: _require(principal, READ_SCOPE) try: return RecordDetailResponse( **records.get_record( session, principal, record_id=record_id, revision=revision ) ) except RecordStoreError as exc: raise _http_error(exc) from exc @router.get("/{record_id}/recovery", response_model=RecordRecoveryStatusResponse) def api_record_recovery_status( record_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> RecordRecoveryStatusResponse: _require(principal, ADMIN_SCOPE) try: return RecordRecoveryStatusResponse( **records.recovery_status(session, principal, record_id=record_id) ) except RecordStoreError as exc: raise _http_error(exc) from exc @router.patch("/{record_id}", response_model=dict[str, Any]) def api_update_record( record_id: str, payload: RecordUpdateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.update_record( session, principal, record_id=record_id, payload=payload.model_dump(mode="python", exclude_unset=True), ), principal=principal, operation_type="record.revise", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json", exclude_unset=True), resource_type="record", resource_id=record_id, ) @router.post("/{record_id}/close", response_model=dict[str, Any]) def api_close_record( record_id: str, payload: RecordCloseRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) if payload.restart_retention: _require(principal, ADMIN_SCOPE) return _write( session, lambda: records.close_record( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="record.close", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post("/{record_id}/reopen", response_model=dict[str, Any]) def api_reopen_record( record_id: str, payload: RecordLifecycleActionRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.reopen_record( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="record.reopen", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post("/{record_id}/appraise", response_model=dict[str, Any]) def api_appraise_record( record_id: str, payload: RecordAppraisalRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) if payload.override_retention_not_due: _require(principal, ADMIN_SCOPE) return _write( session, lambda: records.appraise_record( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="record.appraise", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/holds", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_apply_hold( record_id: str, payload: RecordHoldCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.apply_hold( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="hold.apply", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/holds/{hold_id}/release", response_model=dict[str, Any], ) def api_release_hold( record_id: str, hold_id: str, payload: RecordHoldReleaseRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.release_hold( session, principal, record_id=record_id, hold_id=hold_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="hold.release", idempotency_key=payload.idempotency_key, request={"hold_id": hold_id, **payload.model_dump(mode="json")}, resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/dispositions", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_propose_disposition( record_id: str, payload: RecordDispositionCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.propose_disposition( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="disposition.propose", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/dispositions/{disposition_id}/finalize", response_model=dict[str, Any], ) def api_finalize_disposition( record_id: str, disposition_id: str, payload: RecordDispositionFinalizeRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.finalize_disposition( session, principal, record_id=record_id, disposition_id=disposition_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="disposition.finalize", idempotency_key=payload.idempotency_key, request={ "disposition_id": disposition_id, **payload.model_dump(mode="json"), }, resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/dispositions/{disposition_id}/withdraw", response_model=dict[str, Any], ) def api_withdraw_disposition( record_id: str, disposition_id: str, payload: RecordDispositionWithdrawRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.withdraw_disposition( session, principal, record_id=record_id, disposition_id=disposition_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="disposition.withdraw", idempotency_key=payload.idempotency_key, request={ "disposition_id": disposition_id, **payload.model_dump(mode="json"), }, resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/transfer-packages", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_prepare_transfer_package( record_id: str, payload: RecordTransferPackageCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.prepare_transfer_package( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="transfer.prepare", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/transfer-packages/{package_id}/dispatch", response_model=dict[str, Any], ) def api_dispatch_transfer_package( record_id: str, package_id: str, payload: RecordTransferDispatchRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.dispatch_transfer_package( session, principal, record_id=record_id, package_id=package_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="transfer.simulate", idempotency_key=payload.idempotency_key, request={"package_id": package_id, **payload.model_dump(mode="json")}, resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/volumes", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_create_volume( record_id: str, payload: RecordVolumeCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) return _write( session, lambda: records.create_volume( session, principal, record_id=record_id, payload=payload.model_dump(mode="python"), ), principal=principal, operation_type="volume.create", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) @router.post( "/{record_id}/items", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED, ) def api_file_item( record_id: str, payload: RecordItemCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> dict[str, Any]: _require(principal, WRITE_SCOPE) request = RecordFilingRequest( tenant_id=principal.tenant_id, record_id=record_id, source=RecordSourceLocator( tenant_id=principal.tenant_id, source_module=payload.source.source_module, resource_type=payload.source.resource_type, resource_id=payload.source.resource_id, source_revision=payload.source.source_revision, metadata=payload.source.metadata, ), purpose=payload.purpose, filing_reason=payload.filing_reason, idempotency_key=payload.idempotency_key, volume_id=payload.volume_id, relationship=payload.relationship, institutional_context=payload.institutional_context, metadata=payload.metadata, ) def operation() -> dict[str, Any]: result = records.file(session, principal, request=request) return { "record_id": result.record_id, "item_id": result.item_id, "sequence": result.sequence, "filed_at": result.filed_at, "replayed": result.replayed, "source": { "source_module": result.source.locator.source_module, "resource_type": result.source.locator.resource_type, "resource_id": result.source.locator.resource_id, "source_revision": result.source.locator.source_revision, "label": result.source.label, }, } return _write( session, operation, principal=principal, operation_type="item.file", idempotency_key=payload.idempotency_key, request=payload.model_dump(mode="json"), resource_type="record", resource_id=record_id, ) return router def _require(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") def _write( session: Session, operation, *, principal: ApiPrincipal, operation_type: str, idempotency_key: str, request: dict[str, Any], resource_type: str, resource_id: str, ): recovery = None try: recovery = begin_record_atomic_recovery( session, tenant_id=principal.tenant_id, operation_type=operation_type, idempotency_key=idempotency_key, request=request, resource_type=resource_type, resource_id=resource_id, ) recovery_token = bind_record_recovery_operation(recovery.operation_id) try: result = operation() finally: reset_record_recovery_operation(recovery_token) if not recovery.replayed: audit_from_principal( session, principal, action=f"records.{operation_type}", object_type=resource_type, object_id=resource_id, details={ "recovery_operation_id": recovery.operation_id, "idempotency_key_sha256": _sha256(idempotency_key), }, commit=False, ) recovery.commit_success(session, result=result, resource_id=resource_id) return result except (RecordStoreError, IntegrityError) as exc: session.rollback() if recovery is not None: recovery.reject(summary=str(exc), error_type=type(exc).__name__) if isinstance(exc, IntegrityError): raise HTTPException( status_code=409, detail="The record write conflicts with existing data." ) from exc raise _http_error(exc) from exc except RecordRecoveryError as exc: session.rollback() raise HTTPException(status_code=503, detail=str(exc)) from exc except Exception as exc: session.rollback() if recovery is not None: recovery.fail(summary=str(exc), error_type=type(exc).__name__) raise def _sha256(value: str) -> str: return hashlib.sha256(value.encode("utf-8")).hexdigest() def _http_error(exc: RecordStoreError) -> HTTPException: if isinstance(exc, RecordNotFoundError): code = 404 elif isinstance(exc, RecordConflictError): code = 409 elif isinstance(exc, RecordSourceUnavailableError): code = 503 else: code = 422 return HTTPException(status_code=code, detail=str(exc)) __all__ = ["create_router"]