Add approval template revision administration
This commit is contained in:
@@ -179,6 +179,13 @@ manifest = ModuleManifest(
|
||||
label="Approval request workspace",
|
||||
order=20,
|
||||
),
|
||||
ViewSurface(
|
||||
id="approvals.admin.templates",
|
||||
module_id=MODULE_ID,
|
||||
kind="section",
|
||||
label="Approval templates",
|
||||
order=30,
|
||||
),
|
||||
),
|
||||
),
|
||||
capability_factories={CAPABILITY_APPROVAL_REQUESTS: _requests},
|
||||
@@ -240,6 +247,7 @@ manifest = ModuleManifest(
|
||||
"help_contexts": [
|
||||
"approvals.navigation",
|
||||
"approvals.workspace",
|
||||
"approvals.admin.templates",
|
||||
"approvals.state.permission-blocked",
|
||||
"approvals.state.empty",
|
||||
],
|
||||
@@ -292,6 +300,35 @@ manifest = ModuleManifest(
|
||||
},
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="approvals.workflow.administer-templates",
|
||||
title="Administer approval templates",
|
||||
summary="Create reusable approval chains, publish immutable revisions, compare history, and escalate steps only after their configured due time.",
|
||||
body=(
|
||||
"Approval administrators manage templates under Admin > Tenant > Approval templates. A stable key identifies the template while every edit creates a new draft revision with its own content hash, actor, predecessor, and timestamp. Publishing creates another immutable revision that new requests can bind to exactly; existing requests never follow later template changes. "
|
||||
"The history dialog compares any two tenant-visible revisions as deterministic JSON-pointer changes without hiding unchanged evidence. Request operators with approval administration permission see Escalate only for pending requests, and the action becomes available after the current step's due time. The backend rechecks the due time and optimistic-concurrency revision before recording the lifecycle transition."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner", "auditor"),
|
||||
links=(
|
||||
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"),
|
||||
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"),
|
||||
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"),
|
||||
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"),
|
||||
),
|
||||
metadata={
|
||||
"help_contexts": [
|
||||
"approvals.admin.templates",
|
||||
"approvals.action.escalate-request",
|
||||
],
|
||||
"consequence_classes": {
|
||||
"revise_template": "Supersedes the current template and creates a new draft revision.",
|
||||
"publish_template": "Creates an immutable published revision available to new requests.",
|
||||
"escalate_request": "Records that the current due step entered escalation without deciding it.",
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="human_work_procedure",
|
||||
|
||||
@@ -173,6 +173,80 @@ def api_publish_template(
|
||||
raise _error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}", response_model=dict[str, Any])
|
||||
def api_get_template(
|
||||
template_id: str,
|
||||
revision: int | None = Query(default=None, ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||
|
||||
_require(principal, READ_SCOPE)
|
||||
item = SqlApprovalRequests().get_template(
|
||||
session,
|
||||
principal,
|
||||
template_id=template_id,
|
||||
revision=revision,
|
||||
)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Approval template not found")
|
||||
return dict(item)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/templates/{template_id}/history",
|
||||
response_model=list[dict[str, Any]],
|
||||
)
|
||||
def api_get_template_history(
|
||||
template_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> list[dict[str, Any]]:
|
||||
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
return [
|
||||
dict(item)
|
||||
for item in SqlApprovalRequests().template_history(
|
||||
session,
|
||||
principal,
|
||||
template_id=template_id,
|
||||
)
|
||||
]
|
||||
except (ApprovalStoreError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
|
||||
|
||||
@router.get(
|
||||
"/templates/{template_id}/compare",
|
||||
response_model=dict[str, Any],
|
||||
)
|
||||
def api_compare_template_revisions(
|
||||
template_id: str,
|
||||
from_revision: int = Query(ge=1),
|
||||
to_revision: int = Query(ge=1),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> dict[str, Any]:
|
||||
from govoplan_approvals.backend.manifest import READ_SCOPE
|
||||
|
||||
_require(principal, READ_SCOPE)
|
||||
try:
|
||||
return dict(
|
||||
SqlApprovalRequests().compare_template_revisions(
|
||||
session,
|
||||
principal,
|
||||
template_id=template_id,
|
||||
from_revision=from_revision,
|
||||
to_revision=to_revision,
|
||||
)
|
||||
)
|
||||
except (ApprovalStoreError, LookupError) as exc:
|
||||
raise _error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/{request_id}", response_model=dict[str, Any])
|
||||
def api_get_request(
|
||||
request_id: str,
|
||||
|
||||
@@ -238,6 +238,75 @@ class SqlApprovalRequests:
|
||||
.all()
|
||||
)
|
||||
|
||||
def template_history(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
) -> tuple[Mapping[str, object], ...]:
|
||||
rows = (
|
||||
_session(session)
|
||||
.query(ApprovalTemplateRevision)
|
||||
.filter(
|
||||
ApprovalTemplateRevision.tenant_id == _tenant(principal),
|
||||
ApprovalTemplateRevision.template_id == template_id,
|
||||
)
|
||||
.order_by(ApprovalTemplateRevision.revision.desc())
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
raise LookupError("Approval template not found.")
|
||||
return tuple(_template_mapping(row) for row in rows)
|
||||
|
||||
def compare_template_revisions(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
from_revision: int,
|
||||
to_revision: int,
|
||||
) -> Mapping[str, object]:
|
||||
if from_revision < 1 or to_revision < 1:
|
||||
raise ApprovalStoreError(
|
||||
"Approval template revisions must be positive integers."
|
||||
)
|
||||
rows = (
|
||||
_session(session)
|
||||
.query(ApprovalTemplateRevision)
|
||||
.filter(
|
||||
ApprovalTemplateRevision.tenant_id == _tenant(principal),
|
||||
ApprovalTemplateRevision.template_id == template_id,
|
||||
ApprovalTemplateRevision.revision.in_(
|
||||
(from_revision, to_revision)
|
||||
),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
by_revision = {row.revision: row for row in rows}
|
||||
missing = [
|
||||
revision
|
||||
for revision in (from_revision, to_revision)
|
||||
if revision not in by_revision
|
||||
]
|
||||
if missing:
|
||||
raise LookupError(
|
||||
"Approval template revision not found: "
|
||||
+ ", ".join(str(revision) for revision in missing)
|
||||
)
|
||||
before = by_revision[from_revision]
|
||||
after = by_revision[to_revision]
|
||||
return {
|
||||
"template_id": template_id,
|
||||
"from_revision": _template_mapping(before),
|
||||
"to_revision": _template_mapping(after),
|
||||
"changes": _structured_changes(
|
||||
before.payload,
|
||||
after.payload,
|
||||
),
|
||||
}
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
session: object,
|
||||
@@ -1073,10 +1142,80 @@ def _template_mapping(row: ApprovalTemplateRevision) -> dict[str, object]:
|
||||
"state": row.state,
|
||||
"content_sha256": row.content_sha256,
|
||||
"recorded_at": _iso(row.recorded_at),
|
||||
"superseded_at": _iso(row.superseded_at),
|
||||
"previous_revision_id": row.previous_revision_id,
|
||||
"actor_id": row.actor_id,
|
||||
**dict(row.payload),
|
||||
}
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _structured_changes(
|
||||
before: object,
|
||||
after: object,
|
||||
*,
|
||||
path: str = "",
|
||||
) -> list[dict[str, object]]:
|
||||
if isinstance(before, Mapping) and isinstance(after, Mapping):
|
||||
changes: list[dict[str, object]] = []
|
||||
keys = sorted(set(before).union(after), key=str)
|
||||
for key in keys:
|
||||
child_path = f"{path}/{_json_pointer_segment(str(key))}"
|
||||
changes.extend(
|
||||
_structured_changes(
|
||||
before.get(key, _MISSING),
|
||||
after.get(key, _MISSING),
|
||||
path=child_path,
|
||||
)
|
||||
)
|
||||
return changes
|
||||
if isinstance(before, list) and isinstance(after, list):
|
||||
changes = []
|
||||
for index in range(max(len(before), len(after))):
|
||||
changes.extend(
|
||||
_structured_changes(
|
||||
before[index] if index < len(before) else _MISSING,
|
||||
after[index] if index < len(after) else _MISSING,
|
||||
path=f"{path}/{index}",
|
||||
)
|
||||
)
|
||||
return changes
|
||||
if before is _MISSING:
|
||||
return [
|
||||
{
|
||||
"path": path or "/",
|
||||
"change": "added",
|
||||
"before": None,
|
||||
"after": after,
|
||||
}
|
||||
]
|
||||
if after is _MISSING:
|
||||
return [
|
||||
{
|
||||
"path": path or "/",
|
||||
"change": "removed",
|
||||
"before": before,
|
||||
"after": None,
|
||||
}
|
||||
]
|
||||
if before != after:
|
||||
return [
|
||||
{
|
||||
"path": path or "/",
|
||||
"change": "changed",
|
||||
"before": before,
|
||||
"after": after,
|
||||
}
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _json_pointer_segment(value: str) -> str:
|
||||
return value.replace("~", "~0").replace("/", "~1")
|
||||
|
||||
|
||||
def _template_ref(row: ApprovalTemplateRevision) -> ApprovalTemplateRef:
|
||||
return ApprovalTemplateRef(
|
||||
id=row.template_id,
|
||||
|
||||
Reference in New Issue
Block a user