feat(records): govern lifecycle recovery and transfer evidence
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
@@ -7,18 +8,31 @@ 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,
|
||||
)
|
||||
@@ -29,6 +43,12 @@ from govoplan_records.backend.service import (
|
||||
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:
|
||||
@@ -59,6 +79,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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(
|
||||
@@ -77,6 +103,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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)
|
||||
@@ -89,6 +121,16 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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),
|
||||
@@ -127,6 +169,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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)
|
||||
@@ -146,6 +194,20 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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,
|
||||
@@ -162,6 +224,294 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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(
|
||||
@@ -184,6 +534,12 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
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(
|
||||
@@ -235,7 +591,16 @@ def create_router(registry: object | None = None) -> APIRouter:
|
||||
},
|
||||
}
|
||||
|
||||
return _write(session, operation)
|
||||
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
|
||||
|
||||
@@ -245,18 +610,69 @@ def _require(principal: ApiPrincipal, scope: str) -> None:
|
||||
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
|
||||
|
||||
|
||||
def _write(session: Session, operation):
|
||||
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:
|
||||
result = operation()
|
||||
session.commit()
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user