9 Commits
Author SHA1 Message Date
zemion e864f840e3 docs(approvals): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 19:31:21 +02:00
zemion 5d3eeb0060 feat(approvals): add governed DSAR coverage 2026-08-21 12:06:09 +02:00
zemion 327b35888d refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:45 +02:00
zemion eded5dbaf1 feat: align approvals with shared UI foundations 2026-08-18 21:32:34 +02:00
zemion 183a091539 Adopt shared WebUI structural primitives 2026-08-18 13:17:28 +02:00
zemion 9731daae4a Adopt shared WebUI layout primitives 2026-08-18 10:42:50 +02:00
zemion 4cf870f322 refactor: adopt shared split workspace 2026-08-18 02:17:21 +02:00
zemion b691a0c2d5 Project pending approvals as authorized work 2026-08-06 16:06:17 +02:00
zemion 956624be0e Release v0.1.18
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 21:07:44 +02:00
19 changed files with 1456 additions and 122 deletions
+11 -4
View File
@@ -26,6 +26,12 @@ signature reference; Approvals does not implement document signing or key
custody. A fail-fast rejection ends the request. Due steps can enter an custody. A fail-fast rejection ends the request. Due steps can enter an
explicit escalated state without silently changing their outcome. explicit escalated state without silently changing their outcome.
When Tasks is enabled, Approvals projects only currently actionable steps into
the common work inbox. The projection applies the same selector, expiration,
prior-decision, unique-actor, evidence-role, and requester-separation checks as
the decision command. Approvals remains the owner of decision and completion
state; Tasks receives no copied approval record.
## Recovery and scale-out ## Recovery and scale-out
All API and worker nodes use the logically shared database. Back up and restore All API and worker nodes use the logically shared database. Back up and restore
@@ -44,7 +50,8 @@ database and reconcile every module object that retains an Approval reference.
## Optional integrations ## Optional integrations
Workflow Engine may wait for completion and Notifications may announce an Workflow Engine may wait for completion, Tasks may aggregate actionable work,
assignment, due date, escalation, or outcome. Audit may retain additional and Notifications may announce an assignment, due date, escalation, or
cross-domain evidence. Policy may provide chain templates. These integrations outcome. Audit may retain additional cross-domain evidence. Policy may provide
use capabilities and events; none reads Approval tables directly. chain templates. These integrations use capabilities and events; none reads
Approval tables directly.
+5 -3
View File
@@ -25,6 +25,8 @@ importing optional sibling modules.
- Signature references are evidence pointers and never a cryptographic claim. - Signature references are evidence pointers and never a cryptographic claim.
The module uses Core dialogs, controls, status, blockers, help, loading, empty, The module uses Core dialogs, controls, status, blockers, help, loading, empty,
error, and draft-guard contracts. Native selection buttons preserve keyboard error, draft-guard, and `WorkspaceLayout` contracts. The shared split-pane
order; bounded list/detail viewports remain responsive. English and German shell keeps the request collection and selected evidence in independently
catalogues cover module-owned copy and dates follow the active platform locale. scrollable panes, then stacks them at the platform narrow-layout breakpoint.
Native selection buttons preserve keyboard order. English and German catalogues
cover module-owned copy and dates follow the active platform locale.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/approvals", "name": "@govoplan/approvals",
"version": "0.1.17", "version": "0.1.19",
"private": true, "private": true,
"description": "Governed approval chains, decisions, delegation, and escalation for GovOPlaN.", "description": "Governed approval chains, decisions, delegation, and escalation for GovOPlaN.",
"type": "module", "type": "module",
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-approvals" name = "govoplan-approvals"
version = "0.1.17" version = "0.1.19"
description = "Governed approval chains, decisions, delegation, and escalation for GovOPlaN." description = "Governed approval chains, decisions, delegation, and escalation for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.17", "govoplan-core>=0.1.18",
"govoplan-access>=0.1.17", "govoplan-access>=0.1.18",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -0,0 +1,427 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalTemplateRevision,
)
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
APPROVALS_DSAR_CAPABILITY = dsar_capability_name("approvals")
_MAX_RECORDS = 5_000
_CONFLICT = object()
@dataclass(frozen=True, slots=True)
class _SubjectSelectors:
actor_ids: tuple[str, ...]
request_id: str | None
class ApprovalsDsarProvider:
provider_id = "approvals"
module_id = "approvals"
def search_subject(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
) -> Sequence[DsarRecordRef]:
db = _session(session)
selectors = _subject_selectors(subject)
if selectors is None:
return ()
records: list[DsarRecordRef] = []
decisions = db.query(ApprovalDecisionRecord).filter(
ApprovalDecisionRecord.tenant_id == tenant_id,
or_(
ApprovalDecisionRecord.actor_id.in_(selectors.actor_ids),
ApprovalDecisionRecord.effective_actor_id.in_(selectors.actor_ids),
),
)
requests = db.query(ApprovalRequestRevision).filter(
ApprovalRequestRevision.tenant_id == tenant_id,
ApprovalRequestRevision.actor_id.in_(selectors.actor_ids),
)
events = db.query(ApprovalLifecycleEvent).filter(
ApprovalLifecycleEvent.tenant_id == tenant_id,
ApprovalLifecycleEvent.actor_id.in_(selectors.actor_ids),
)
templates = db.query(ApprovalTemplateRevision).filter(
ApprovalTemplateRevision.tenant_id == tenant_id,
ApprovalTemplateRevision.actor_id.in_(selectors.actor_ids),
)
if selectors.request_id:
decisions = decisions.filter(
ApprovalDecisionRecord.request_id == selectors.request_id
)
requests = requests.filter(
ApprovalRequestRevision.request_id == selectors.request_id
)
events = events.filter(
ApprovalLifecycleEvent.request_id == selectors.request_id
)
templates = templates.filter(False)
records.extend(
_decision_record(row, selectors.actor_ids)
for row in _limited(
decisions,
ApprovalDecisionRecord.recorded_at,
ApprovalDecisionRecord.id,
label="decision",
)
)
records.extend(
_request_attribution(row)
for row in _limited(
requests,
ApprovalRequestRevision.recorded_at,
ApprovalRequestRevision.id,
label="request attribution",
)
)
records.extend(
_event_attribution(row)
for row in _limited(
events,
ApprovalLifecycleEvent.recorded_at,
ApprovalLifecycleEvent.id,
label="lifecycle attribution",
)
)
records.extend(
_template_attribution(row)
for row in _limited(
templates,
ApprovalTemplateRevision.recorded_at,
ApprovalTemplateRevision.id,
label="template attribution",
)
)
if len(records) > _MAX_RECORDS:
raise ValueError(
"Approvals DSAR combined result limit exceeded; narrow the selectors."
)
return tuple(
sorted(records, key=lambda item: (item.resource_type, item.resource_id))
)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Approvals DSAR subject selectors conflict.")
actions: list[DsarErasureActionRef] = []
for record in records:
_validate_record(record)
authored_reason = record.resource_type == "approval_decision_participation"
actions.append(
DsarErasureActionRef(
action_id=(
f"approvals:{'manual_review' if authored_reason else 'retain'}:"
f"{record.resource_type}:{record.resource_id}"
),
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review" if authored_reason else "retain",
resource_type=record.resource_type,
resource_id=record.resource_id,
title=("Review " if authored_reason else "Retain ") + record.title,
rationale=(
"The authored decision reason may contain personal data, but "
"any minimization must preserve the immutable approval chain, "
"signature evidence, and the consuming subject's legal state."
if authored_reason
else record.retention_reason
or "Approval attribution remains immutable evidence."
),
executable=False,
)
)
return tuple(actions)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if _subject_selectors(subject) is None:
raise ValueError("Approvals DSAR subject selectors conflict.")
results: list[DsarExecutionResultRef] = []
for action in actions:
_validate_action(action)
if action.executable or action.kind not in {"manual_review", "retain"}:
raise ValueError("Approvals DSAR publishes non-executable actions only.")
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary=(
"The decision remains unchanged pending legal, signature, and "
"approval-chain review."
if action.kind == "manual_review"
else "Approval lifecycle attribution remains immutable evidence."
),
evidence={"request_id": request_id},
)
)
return tuple(results)
def _subject_selectors(subject: DsarSubjectRef) -> _SubjectSelectors | None:
references = subject.external_references
values = {
"account_id": _coalesce(
subject.account_id,
references.get("approvals.account"),
references.get("access.account"),
),
"membership_id": _coalesce(
subject.membership_id,
references.get("approvals.membership"),
references.get("tenancy.membership"),
),
"identity_id": _coalesce(
subject.identity_id,
references.get("approvals.identity"),
references.get("identity.id"),
),
"actor_id": _coalesce(
references.get("approvals.actor"),
references.get("approvals.effective_actor"),
),
"request_id": _coalesce(
references.get("approvals.request"),
references.get("approvals.request_id"),
),
}
if any(value is _CONFLICT for value in values.values()):
return None
actor_ids = tuple(
dict.fromkeys(
value
for value in (
_optional_string(values["account_id"]),
_prefixed("account", values["account_id"]),
_optional_string(values["membership_id"]),
_prefixed("membership", values["membership_id"]),
_optional_string(values["identity_id"]),
_prefixed("identity", values["identity_id"]),
)
if value
)
)
direct_actor = _optional_string(values["actor_id"])
if direct_actor:
if actor_ids and direct_actor not in actor_ids:
return None
if not actor_ids:
actor_ids = (direct_actor,)
if not actor_ids:
return None
return _SubjectSelectors(
actor_ids=actor_ids,
request_id=_optional_string(values["request_id"]),
)
def _decision_record(
row: ApprovalDecisionRecord,
actor_ids: Sequence[str],
) -> DsarRecordRef:
actor_set = set(actor_ids)
activities = []
if row.actor_id in actor_set:
activities.append("recorded_decision")
if row.effective_actor_id in actor_set:
activities.append("effective_decision_actor")
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_decision_participation",
resource_id=row.id,
category="institutional_approval_participation",
title="Approval decision participation",
data={
"request_id": row.request_id,
"request_revision": row.request_revision,
"step_key": row.step_key,
"outcome": row.outcome,
"reason": row.reason[:4_000],
"activities": activities,
"delegation_id": row.delegation_id,
"recorded_at": _iso(row.recorded_at),
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason=(
"Approval decisions and their reasons are immutable institutional evidence."
),
)
def _request_attribution(row: ApprovalRequestRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_request_actor_attribution",
resource_id=row.id,
category="approval_lifecycle_attribution",
title="Approval request actor attribution",
data={
"request_id": row.request_id,
"revision": row.revision,
"state": row.state,
"current_step_key": row.current_step_key,
"subject_module": row.subject_module,
"subject_type": row.subject_type,
"subject_id": row.subject_id,
"subject_version": row.subject_version,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_request_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Approval request attribution is immutable lifecycle evidence.",
)
def _event_attribution(row: ApprovalLifecycleEvent) -> DsarRecordRef:
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_lifecycle_actor_attribution",
resource_id=row.id,
category="approval_lifecycle_attribution",
title="Approval lifecycle actor attribution",
data={
"request_id": row.request_id,
"sequence": row.sequence,
"event_type": row.event_type,
"recorded_at": _iso(row.recorded_at),
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Approval lifecycle attribution is immutable evidence.",
)
def _template_attribution(row: ApprovalTemplateRevision) -> DsarRecordRef:
return DsarRecordRef(
provider_id="approvals",
module_id="approvals",
resource_type="approval_template_actor_attribution",
resource_id=row.id,
category="approval_configuration_attribution",
title="Approval template actor attribution",
data={
"template_id": row.template_id,
"key": row.key,
"revision": row.revision,
"state": row.state,
"recorded_at": _iso(row.recorded_at),
"superseded_at": _iso(row.superseded_at),
"activity": "recorded_template_revision",
},
observed_at=_aware(row.recorded_at),
immutable_evidence=True,
retention_reason="Approval template attribution is governance evidence.",
)
def _limited(query, first, second, *, label: str):
rows = query.order_by(first, second).limit(_MAX_RECORDS + 1).all()
if len(rows) > _MAX_RECORDS:
raise ValueError(f"Approvals DSAR {label} limit exceeded; narrow selectors.")
return rows
def _coalesce(*values: str | None) -> str | None | object:
normalized = {str(value).strip() for value in values if str(value or "").strip()}
if len(normalized) > 1:
return _CONFLICT
return next(iter(normalized), None)
def _optional_string(value: object) -> str | None:
return value if isinstance(value, str) and value else None
def _prefixed(prefix: str, value: object) -> str | None:
normalized = _optional_string(value)
return f"{prefix}:{normalized}" if normalized else None
def _iso(value: datetime | None) -> str | None:
aware = _aware(value)
return aware.isoformat() if aware else None
def _aware(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Approvals DSAR requires a SQLAlchemy Session.")
return value
_RESOURCE_TYPES = {
"approval_decision_participation",
"approval_request_actor_attribution",
"approval_lifecycle_actor_attribution",
"approval_template_actor_attribution",
}
def _validate_record(record: DsarRecordRef) -> None:
if record.provider_id != "approvals" or record.module_id != "approvals":
raise ValueError("Approvals DSAR cannot plan a foreign provider record.")
if record.resource_type not in _RESOURCE_TYPES or not record.resource_id:
raise ValueError("Approvals DSAR record identity is invalid.")
def _validate_action(action: DsarErasureActionRef) -> None:
if action.provider_id != "approvals" or action.module_id != "approvals":
raise ValueError("Approvals DSAR cannot execute a foreign provider action.")
if not action.action_id.startswith("approvals:"):
raise ValueError("Approvals DSAR action identity is invalid.")
__all__ = ["APPROVALS_DSAR_CAPABILITY", "ApprovalsDsarProvider"]
+241 -9
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.module_guards import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
@@ -23,23 +24,36 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAreaContribution,
RoleTemplate, RoleTemplate,
) )
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_approvals.backend.db import models as approval_models from govoplan_approvals.backend.db import models as approval_models
from govoplan_approvals.backend.dsar_provider import (
APPROVALS_DSAR_CAPABILITY,
ApprovalsDsarProvider,
)
from govoplan_approvals.backend.service import SqlApprovalRequests from govoplan_approvals.backend.service import SqlApprovalRequests
MODULE_ID = "approvals" MODULE_ID = "approvals"
MODULE_NAME = "Approvals" MODULE_NAME = "Approvals"
MODULE_VERSION = "0.1.17" MODULE_VERSION = "0.1.19"
READ_SCOPE = "approvals:workspace:read" READ_SCOPE = "approvals:workspace:read"
WRITE_SCOPE = "approvals:workspace:write" WRITE_SCOPE = "approvals:workspace:write"
DECIDE_SCOPE = "approvals:workspace:decide" DECIDE_SCOPE = "approvals:workspace:decide"
ADMIN_SCOPE = "approvals:workspace:admin" ADMIN_SCOPE = "approvals:workspace:admin"
OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy") OPTIONAL_DEPENDENCIES = (
"workflow_engine",
"audit",
"files",
"notifications",
"policy",
"tasks",
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -66,6 +80,16 @@ def _requests(_context: ModuleContext) -> SqlApprovalRequests:
return SqlApprovalRequests() return SqlApprovalRequests()
def _dsar_provider(_context: ModuleContext) -> ApprovalsDsarProvider:
return ApprovalsDsarProvider()
def _work_items(_context: ModuleContext):
from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
return ApprovalWorkItemProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(approval_models.ApprovalRequestRevision).filter( current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id, approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
@@ -91,6 +115,7 @@ manifest = ModuleManifest(
), ),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_APPROVAL_REQUESTS, version="0.1.0"), ModuleInterfaceProvider(name=CAPABILITY_APPROVAL_REQUESTS, version="0.1.0"),
ModuleInterfaceProvider(name=APPROVALS_DSAR_CAPABILITY, version="0.1.0"),
), ),
permissions=( permissions=(
_permission( _permission(
@@ -164,6 +189,17 @@ manifest = ModuleManifest(
order=37, order=37,
), ),
), ),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("approvals.nav.approvals", "approvals.route.approvals"),
order=10,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="approvals.navigation", id="approvals.navigation",
@@ -188,14 +224,32 @@ manifest = ModuleManifest(
), ),
), ),
), ),
capability_factories={CAPABILITY_APPROVAL_REQUESTS: _requests}, capability_factories={
CAPABILITY_APPROVAL_REQUESTS: _requests,
APPROVALS_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={ capability_documentation={
CAPABILITY_APPROVAL_REQUESTS: CapabilityDocumentation( CAPABILITY_APPROVAL_REQUESTS: CapabilityDocumentation(
label="Governed approval requests", label="Governed approval requests",
summary="Freezes exact subject approval chains and resolves auditable sequential decisions.", summary="Freezes exact subject approval chains and resolves auditable sequential decisions.",
contract_version="0.1.0", contract_version="0.1.0",
) ),
APPROVALS_DSAR_CAPABILITY: CapabilityDocumentation(
label="Approvals data-subject request provider",
summary=(
"Exports personal decision participation and minimized actor "
"attribution without exposing immutable approval internals."
),
contract_version="0.1.0",
),
}, },
work_item_providers=(
WorkItemProviderRegistration(
id="approvals.pending",
factory=_work_items,
order=30,
),
),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
module_id=MODULE_ID, module_id=MODULE_ID,
metadata=Base.metadata, metadata=Base.metadata,
@@ -223,17 +277,92 @@ manifest = ModuleManifest(
), ),
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
documentation=( documentation=(
DocumentationTopic(
id="approvals.data-subject-requests",
title="Approval data-subject requests",
summary=(
"Export a subject's approval decisions and minimized lifecycle "
"attribution without disclosing unrelated chain content."
),
body=(
"Approvals correlates exact account, membership, identity, or explicit "
"actor identifiers inside the active tenant. An optional request "
"identifier only narrows an already verified actor search and never "
"discloses a request by itself. Authored decisions include their bounded "
"reason, step, outcome, delegation reference, and actor activities. "
"Request, lifecycle, and template activity is minimized to attribution "
"and stable context. Approval payloads, authority provenance, signature "
"objects, hashes, idempotency keys, and replay state are excluded. "
"Decision-reason erasure requires manual legal and chain-integrity "
"review; all other attribution remains immutable evidence."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"),
related_modules=("core", "access", "workflow_engine", "audit"),
metadata={
"kind": "reference",
"help_contexts": [
"approvals.workspace",
"privacy.data-subject-requests",
],
"consequence_classes": {
"export_decision_participation": (
"Returns bounded subject-authored decision evidence."
),
"review_reason_erasure": (
"Requires legal and approval-chain integrity review."
),
"retain_attribution": (
"Preserves minimized immutable lifecycle evidence."
),
},
},
translations={
"de": {
"title": "Datenschutzanfragen zu Genehmigungen",
"summary": (
"Genehmigungsentscheidungen einer betroffenen Person und minimierte Lebenszykluszuordnungen ausgeben, "
"ohne Inhalte fremder Genehmigungsketten offenzulegen."
),
"body": (
"Approvals gleicht innerhalb des aktiven Mandanten exakte Konto-, Mitgliedschafts-, Identitäts- oder "
"Akteurskennungen ab. Eine optionale Antragskennung schränkt nur eine bereits verifizierte "
"Akteurssuche ein und legt für sich allein keinen Antrag offen. Von der Person verfasste Entscheidungen "
"enthalten den begrenzten Grund, Schritt, Ausgang, Delegationsverweis und Akteursaktivitäten. Antrags-, "
"Lebenszyklus- und Vorlagenaktivitäten werden auf Zuordnung und stabilen Kontext minimiert. "
"Genehmigungsinhalte, Herkunft der Befugnis, Signaturobjekte, Prüfsummen, Idempotenzschlüssel und "
"Wiederholungszustand bleiben ausgeschlossen. Das Löschen eines Entscheidungsgrunds erfordert eine "
"manuelle rechtliche Prüfung und Integritätsprüfung der Genehmigungskette; alle übrigen Zuordnungen "
"bleiben unveränderliche Nachweise."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"export_decision_participation": "Gibt begrenzte, von der betroffenen Person verfasste Entscheidungsnachweise zurück.",
"review_reason_erasure": "Erfordert eine rechtliche Prüfung und eine Integritätsprüfung der Genehmigungskette.",
"retain_attribution": "Bewahrt minimierte, unveränderliche Lebenszyklusnachweise.",
}
}
},
),
DocumentationTopic( DocumentationTopic(
id="approvals.module-boundary", id="approvals.module-boundary",
title="Governed approval chains", title="Governed approval chains",
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.", summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
body=( body=(
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. " "An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. "
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables." "Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables. "
"When Tasks is enabled, a pending step appears in the common work inbox only for a principal who currently passes the exact decision eligibility checks. "
"The workspace keeps the permission-filtered request collection and selected evidence in separately scrollable panes and stacks them at narrow widths without losing selection."
), ),
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner", "auditor"), audience=("user", "operator", "module_admin", "product_owner", "auditor"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=OPTIONAL_DEPENDENCIES, related_modules=OPTIONAL_DEPENDENCIES,
links=( links=(
DocumentationLink( DocumentationLink(
@@ -243,6 +372,7 @@ manifest = ModuleManifest(
), ),
), ),
metadata={ metadata={
"kind": "workflow",
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
"approvals.navigation", "approvals.navigation",
@@ -257,6 +387,34 @@ manifest = ModuleManifest(
"Decision history retains actor and reason as governed evidence.", "Decision history retains actor and reason as governed evidence.",
], ],
}, },
translations={
"de": {
"title": "Gesteuerte Genehmigungsketten",
"summary": (
"Genehmigungsketten für exakt bezeichnete Gegenstände mit Delegation, Funktionstrennung, Eskalation und Signaturnachweisen erstellen."
),
"body": (
"Ein Genehmigungsantrag fixiert Gegenstandsrevision, geordnete Schritte, zulässige Selektoren, Quorum, "
"Ablehnungsregel, Signaturanforderung und Governance-Verweise. Entscheidungen werden nur angefügt, sind "
"mandantengebunden, durch optimistische Nebenläufigkeit geschützt und wiederholungssicher. Verbrauchende "
"Module prüfen den exakten Gegenstand über die Fähigkeit, statt Approval-Tabellen zu lesen. Wenn Tasks "
"aktiv ist, erscheint ein offener Schritt nur für Personen im gemeinsamen Arbeitseingang, die die exakte "
"Entscheidungsberechtigung aktuell erfüllen. Der Arbeitsbereich hält die berechtigungsgefilterte Sammlung "
"und den ausgewählten Nachweis in getrennt scrollbaren Bereichen und stapelt sie bei schmaler Darstellung, "
"ohne die Auswahl zu verlieren."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"privacy_notes": [
"Genehmigungslisten und Verläufe bleiben mandantengebunden und berechtigungsgefiltert.",
"Signaturverweise bezeichnen Nachweise, legen aber kein privates Schlüsselmaterial offen.",
"Der Entscheidungsverlauf bewahrt Akteur und Grund als gesteuerten Nachweis.",
]
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="approvals.reference.fields-and-consequences", id="approvals.reference.fields-and-consequences",
@@ -282,6 +440,7 @@ manifest = ModuleManifest(
), ),
), ),
metadata={ metadata={
"kind": "reference",
"seed": True, "seed": True,
"help_contexts": [ "help_contexts": [
"approvals.field.subject-reference", "approvals.field.subject-reference",
@@ -299,6 +458,34 @@ manifest = ModuleManifest(
"retain_evidence": "Keeps request revisions, decisions, reasons, and signature references for reconstruction.", "retain_evidence": "Keeps request revisions, decisions, reasons, and signature references for reconstruction.",
}, },
}, },
translations={
"de": {
"title": "Genehmigungsfelder und Folgen",
"summary": (
"Folgen exakter Gegenstandsidentität, Selektoren, Funktionstrennung, Signatur und Entscheidung."
),
"body": (
"Gegenstandsmodul, Typ, Kennung, Version und SHA-256-Prüfsumme fixieren die exakte zu genehmigende "
"Objektrevision. Geordnete Schritte, Akteursselektoren, erforderliche Anzahlen, Trennung vom Antragsteller, "
"eindeutige Akteure und Signaturanforderungen werden in den unveränderlichen Antrag kopiert und folgen "
"späteren Vorlagenänderungen nicht. Akteurswerte sind anbieterneutrale Kennungen, die über Access- und "
"IDM-Verträge interpretiert werden. Genehmigung oder Ablehnung fügt eine Entscheidung mit Akteur, Grund, "
"optionalem Signaturverweis und Nebenläufigkeitsrevision an. Abgeschlossene, abgelehnte, abgebrochene und "
"abgelaufene Anträge bleiben Nachweise und können nicht erneut entschieden werden."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"create_request": "Fixiert einen exakten Gegenstand und eine unveränderliche Genehmigungskette.",
"approve_step": "Fügt eine zuordenbare Entscheidung an und kann die Kette fortsetzen oder abschließen.",
"reject_request": "Fügt eine Ablehnung an und beendet den Antrag gemäß seiner fixierten Regel.",
"retain_evidence": "Bewahrt Antragsrevisionen, Entscheidungen, Gründe und Signaturverweise für die Rekonstruktion.",
}
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="approvals.workflow.administer-templates", id="approvals.workflow.administer-templates",
@@ -311,13 +498,29 @@ manifest = ModuleManifest(
layer="configured", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner", "auditor"), audience=("operator", "module_admin", "product_owner", "auditor"),
conditions=(DocumentationCondition(required_scopes=(ADMIN_SCOPE,)),),
links=( links=(
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"), DocumentationLink(
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"), label="Approval templates",
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"), href="/admin?section=tenant-approval-templates",
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"), 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={ metadata={
"kind": "workflow",
"help_contexts": [ "help_contexts": [
"approvals.admin.templates", "approvals.admin.templates",
"approvals.action.escalate-request", "approvals.action.escalate-request",
@@ -328,6 +531,35 @@ manifest = ModuleManifest(
"escalate_request": "Records that the current due step entered escalation without deciding it.", "escalate_request": "Records that the current due step entered escalation without deciding it.",
}, },
}, },
translations={
"de": {
"title": "Genehmigungsvorlagen verwalten",
"summary": (
"Wiederverwendbare Genehmigungsketten erstellen, unveränderliche Revisionen veröffentlichen, Verläufe vergleichen und Schritte erst nach ihrer Fälligkeit eskalieren."
),
"body": (
"Genehmigungsadministratoren verwalten Vorlagen unter Administration > Mandant > Genehmigungsvorlagen. "
"Ein stabiler Schlüssel bezeichnet die Vorlage; jede Bearbeitung erzeugt eine neue Entwurfsrevision mit "
"eigener Inhaltsprüfsumme, Akteur, Vorgänger und Zeitangabe. Die Veröffentlichung erzeugt eine weitere "
"unveränderliche Revision, an die neue Anträge exakt gebunden werden können; bestehende Anträge folgen "
"späteren Änderungen nie. Der Verlaufsdialog vergleicht zwei mandantensichtbare Revisionen als "
"deterministische JSON-Pointer-Änderungen, ohne unveränderte Nachweise auszublenden. Antragsbetreiber mit "
"Genehmigungsadministrationsrecht sehen Eskalieren nur bei offenen Anträgen und erst nach Fälligkeit des "
"aktuellen Schritts. Das Backend prüft Fälligkeit und Nebenläufigkeitsrevision erneut, bevor es den "
"Lebenszyklusübergang festhält."
),
}
},
structured_translation_version="1",
structured_translations={
"de": {
"consequence_classes": {
"revise_template": "Ersetzt die aktuelle Vorlage und erzeugt eine neue Entwurfsrevision.",
"publish_template": "Erzeugt eine unveränderliche veröffentlichte Revision für neue Anträge.",
"escalate_request": "Hält fest, dass der aktuell fällige Schritt eskaliert wurde, ohne ihn zu entscheiden.",
}
}
},
), ),
), ),
architecture=declared_module_architecture( architecture=declared_module_architecture(
+98 -54
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from dataclasses import asdict from dataclasses import asdict, dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
import hashlib import hashlib
import json import json
@@ -33,6 +33,13 @@ class ApprovalStoreError(ValueError):
pass pass
@dataclass(frozen=True, slots=True)
class ApprovalDecisionContext:
step: Mapping[str, Any]
effective_actor: str
matched_selector: Mapping[str, object]
class SqlApprovalRequests: class SqlApprovalRequests:
def create_template( def create_template(
self, self,
@@ -278,9 +285,7 @@ class SqlApprovalRequests:
.filter( .filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal), ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id, ApprovalTemplateRevision.template_id == template_id,
ApprovalTemplateRevision.revision.in_( ApprovalTemplateRevision.revision.in_((from_revision, to_revision)),
(from_revision, to_revision)
),
) )
.all() .all()
) )
@@ -480,56 +485,17 @@ class SqlApprovalRequests:
raise ApprovalStoreError( raise ApprovalStoreError(
"This Approval request no longer accepts decisions." "This Approval request no longer accepts decisions."
) )
expires_at = _datetime(current.payload.get("expires_at")) decision_context = approval_decision_context(
if expires_at is not None and _now() >= expires_at: typed_session,
raise ApprovalStoreError("This Approval request has expired.") principal,
current,
delegated_for_account_id=command.delegated_for_account_id,
)
steps = list(current.payload["steps"]) steps = list(current.payload["steps"])
step_index = int(current.payload.get("current_step_index") or 0) step_index = int(current.payload.get("current_step_index") or 0)
step = steps[step_index] step = decision_context.step
effective_actor = _effective_actor(principal, command.delegated_for_account_id) effective_actor = decision_context.effective_actor
matched_selector = _matched_selector( matched_selector = decision_context.matched_selector
principal, step.get("selectors") or [], effective_actor
)
if matched_selector is None:
raise ApprovalStoreError(
"The current principal is not eligible for this Approval step."
)
if bool(
current.payload.get("separation_of_duties")
) and effective_actor == current.payload.get("requested_by"):
raise ApprovalStoreError(
"Approval separation of duties prevents requester self-approval."
)
prior = (
typed_session.query(ApprovalDecisionRecord)
.filter(
ApprovalDecisionRecord.tenant_id == tenant_id,
ApprovalDecisionRecord.request_id == request_id,
ApprovalDecisionRecord.effective_actor_id == effective_actor,
)
.all()
)
if any(item.step_key == step["key"] for item in prior):
raise ApprovalStoreError(
"This actor has already decided the current Approval step."
)
if bool(current.payload.get("unique_actors_across_steps")) and any(
item.outcome == "approved" for item in prior
):
raise ApprovalStoreError(
"Approval policy requires a different actor for each step."
)
evidence_actors = {
str(key): {str(actor) for actor in (actors or [])}
for key, actors in dict(
current.payload.get("evidence_actors") or {}
).items()
}
for role in step.get("forbidden_evidence_roles") or []:
if effective_actor in evidence_actors.get(str(role), set()):
raise ApprovalStoreError(
f"Approval separation of duties prevents the {role} actor from deciding this step."
)
signature_ref = ( signature_ref = (
dict(command.signature_ref) if command.signature_ref is not None else None dict(command.signature_ref) if command.signature_ref is not None else None
) )
@@ -764,6 +730,79 @@ class SqlApprovalRequests:
) )
def approval_decision_context(
session: Session,
principal: object,
request: ApprovalRequestRevision,
*,
delegated_for_account_id: str | None = None,
prior_decisions: Sequence[ApprovalDecisionRecord] | None = None,
) -> ApprovalDecisionContext:
if request.state not in {"pending", "escalated"}:
raise ApprovalStoreError("This Approval request no longer accepts decisions.")
expires_at = _datetime(request.payload.get("expires_at"))
if expires_at is not None and _now() >= expires_at:
raise ApprovalStoreError("This Approval request has expired.")
steps = list(request.payload.get("steps") or ())
step_index = int(request.payload.get("current_step_index") or 0)
if step_index < 0 or step_index >= len(steps):
raise ApprovalStoreError("The current Approval step is unavailable.")
step = steps[step_index]
effective_actor = _effective_actor(principal, delegated_for_account_id)
matched_selector = _matched_selector(
principal,
list(step.get("selectors") or ()),
effective_actor,
)
if matched_selector is None:
raise ApprovalStoreError(
"The current principal is not eligible for this Approval step."
)
if bool(
request.payload.get("separation_of_duties")
) and effective_actor == request.payload.get("requested_by"):
raise ApprovalStoreError(
"Approval separation of duties prevents requester self-approval."
)
prior = (
list(prior_decisions)
if prior_decisions is not None
else (
session.query(ApprovalDecisionRecord)
.filter(
ApprovalDecisionRecord.tenant_id == request.tenant_id,
ApprovalDecisionRecord.request_id == request.request_id,
ApprovalDecisionRecord.effective_actor_id == effective_actor,
)
.all()
)
)
if any(item.step_key == step["key"] for item in prior):
raise ApprovalStoreError(
"This actor has already decided the current Approval step."
)
if bool(request.payload.get("unique_actors_across_steps")) and any(
item.outcome == "approved" for item in prior
):
raise ApprovalStoreError(
"Approval policy requires a different actor for each step."
)
evidence_actors = {
str(key): {str(actor) for actor in (actors or [])}
for key, actors in dict(request.payload.get("evidence_actors") or {}).items()
}
for role in step.get("forbidden_evidence_roles") or ():
if effective_actor in evidence_actors.get(str(role), set()):
raise ApprovalStoreError(
f"Approval separation of duties prevents the {role} actor from deciding this step."
)
return ApprovalDecisionContext(
step=step,
effective_actor=effective_actor,
matched_selector=matched_selector,
)
def _step_payload(step: object) -> dict[str, Any]: def _step_payload(step: object) -> dict[str, Any]:
return { return {
"key": str(getattr(step, "key")), "key": str(getattr(step, "key")),
@@ -1383,4 +1422,9 @@ def _now() -> datetime:
return datetime.now(UTC) return datetime.now(UTC)
__all__ = ["ApprovalStoreError", "SqlApprovalRequests"] __all__ = [
"ApprovalDecisionContext",
"ApprovalStoreError",
"SqlApprovalRequests",
"approval_decision_context",
]
@@ -0,0 +1,232 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Mapping
from datetime import UTC, datetime
from urllib.parse import quote
from sqlalchemy import select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalRequestRevision,
)
from govoplan_approvals.backend.service import (
ApprovalStoreError,
approval_decision_context,
)
PROVIDER_ID = "approvals.pending"
READ_SCOPE = "approvals:workspace:read"
DECIDE_SCOPE = "approvals:workspace:decide"
class ApprovalWorkItemProvider:
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Approval work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id:
return WorkItemPage(items=(), total=0)
if not has_scope(principal, READ_SCOPE) or not has_scope(
principal, DECIDE_SCOPE
):
return WorkItemPage(items=(), total=0)
if query.statuses and "open" not in query.statuses:
return WorkItemPage(items=(), total=0)
rows = list(
session.scalars(
select(ApprovalRequestRevision)
.where(
ApprovalRequestRevision.tenant_id == query.tenant_id,
ApprovalRequestRevision.superseded_at.is_(None),
ApprovalRequestRevision.state.in_(("pending", "escalated")),
)
.order_by(
ApprovalRequestRevision.recorded_at.asc(),
ApprovalRequestRevision.request_id.asc(),
)
)
)
prior_by_request: dict[str, list[ApprovalDecisionRecord]] = defaultdict(list)
if rows and principal.account_id:
decisions = session.scalars(
select(ApprovalDecisionRecord).where(
ApprovalDecisionRecord.tenant_id == query.tenant_id,
ApprovalDecisionRecord.effective_actor_id == principal.account_id,
ApprovalDecisionRecord.request_id.in_(
tuple(row.request_id for row in rows)
),
)
)
for decision in decisions:
prior_by_request[decision.request_id].append(decision)
items: list[WorkItem] = []
total = 0
now = datetime.now(UTC)
for row in rows:
try:
context = approval_decision_context(
session,
principal,
row,
prior_decisions=prior_by_request.get(row.request_id, ()),
)
except ApprovalStoreError:
continue
item = _work_item(row, context.step, now=now)
if query.priorities and item.priority not in query.priorities:
continue
if query.due_before is not None and (
item.due_at is None or _aware(item.due_at) > _aware(query.due_before)
):
continue
if query.text and query.text.casefold() not in _search_text(item):
continue
total += 1
if len(items) < query.limit:
items.append(item)
items.sort(key=_sort_key)
return WorkItemPage(
items=tuple(items),
total=total,
truncated=total > len(items),
)
def _work_item(
row: ApprovalRequestRevision,
step: Mapping[str, object],
*,
now: datetime,
) -> WorkItem:
payload = dict(row.payload or {})
due_at = _date(step.get("due_at")) or _date(payload.get("expires_at"))
priority = "high" if row.state == "escalated" else "normal"
if due_at is not None and _aware(due_at) < now:
priority = "urgent"
title = str(payload.get("title") or "Approval required").strip()
step_label = str(step.get("label") or row.current_step_key or "Decide").strip()
action_url = f"/approvals?request={quote(row.request_id, safe='')}"
return WorkItem(
id=row.request_id,
provider_id=PROVIDER_ID,
owner_module="approvals",
tenant_id=row.tenant_id,
title=title,
summary=(
str(payload.get("description") or "").strip()
or f"{row.subject_module}: {row.subject_type}"
),
status="open",
priority=priority, # type: ignore[arg-type]
required_action=step_label,
action_url=action_url,
due_at=due_at,
assignments=_assignments(step),
sources=(
WorkSourceRef(
module_id=row.subject_module,
resource_type=row.subject_type,
resource_id=row.subject_id,
revision=row.subject_version,
),
WorkSourceRef(
module_id="approvals",
resource_type="approval_request",
resource_id=row.request_id,
revision=str(row.revision),
url=action_url,
label=title,
),
),
provenance={
"subject_digest": row.subject_digest,
"policy_refs": list(payload.get("policy_refs") or ()),
"template": payload.get("template"),
},
metadata={
"current_step_key": row.current_step_key,
"signature_required": bool(step.get("signature_required")),
"approval_state": row.state,
},
revision=str(row.revision),
created_at=row.created_at,
updated_at=row.updated_at,
)
def _assignments(step: Mapping[str, object]) -> tuple[WorkAssignmentRef, ...]:
assignments: list[WorkAssignmentRef] = []
for raw in step.get("selectors") or ():
if not isinstance(raw, Mapping):
continue
kind = str(raw.get("kind") or "").strip()
assignment_id = str(raw.get("value") or "").strip()
if kind == "any_account":
kind, assignment_id = "anyone", "*"
if kind not in {"account", "group", "role", "function_assignment"}:
if kind != "anyone":
continue
label = str(raw.get("label") or "").strip()[:500] or None
try:
assignments.append(
WorkAssignmentRef(
kind=kind, # type: ignore[arg-type]
id=assignment_id,
label=label,
)
)
except ValueError:
continue
return tuple(assignments)
def _date(value: object) -> datetime | None:
if isinstance(value, datetime):
return value
text = str(value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
return None
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _search_text(item: WorkItem) -> str:
return " ".join(
value for value in (item.title, item.summary, item.required_action) if value
).casefold()
def _sort_key(item: WorkItem) -> tuple[object, ...]:
priority = {"urgent": 0, "high": 1, "normal": 2, "low": 3}[item.priority]
due_at = _aware(item.due_at) if item.due_at else datetime.max.replace(tzinfo=UTC)
return priority, due_at, item.id
__all__ = ["ApprovalWorkItemProvider", "PROVIDER_ID"]
+60
View File
@@ -7,6 +7,8 @@ import unittest
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.approvals import ( from govoplan_core.core.approvals import (
ApprovalActorSelector, ApprovalActorSelector,
ApprovalDecisionCommand, ApprovalDecisionCommand,
@@ -16,6 +18,8 @@ from govoplan_core.core.approvals import (
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
from govoplan_core.core.tasks import WorkItemQuery
from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
DIGEST = "a" * 64 DIGEST = "a" * 64
@@ -241,6 +245,62 @@ class ApprovalRuntimeTests(unittest.TestCase):
) )
self.assertEqual("escalated", escalated.state) self.assertEqual("escalated", escalated.state)
def test_work_provider_reuses_exact_decision_eligibility(self) -> None:
with self.Session() as session:
created = self.service.create_request(
session,
self.requester,
command=request_command(),
idempotency_key="work-provider-request",
)
reviewer = ApiPrincipal(
principal=PrincipalRef(
account_id="reviewer",
membership_id="membership-reviewer",
tenant_id="tenant-1",
scopes=frozenset(
{
"approvals:workspace:read",
"approvals:workspace:decide",
}
),
group_ids=frozenset({"reviewers"}),
),
account=object(),
user=object(),
)
provider = ApprovalWorkItemProvider()
page = provider.list_items(
session,
reviewer,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(1, page.total)
self.assertEqual(created.id, page.items[0].id)
self.assertEqual("Review", page.items[0].required_action)
self.assertEqual("campaign_version", page.items[0].sources[0].resource_type)
self.service.decide(
session,
reviewer,
request_id=created.id,
command=ApprovalDecisionCommand(
"approved",
"Reviewed.",
1,
"work-provider-decision",
),
)
self.assertEqual(
0,
provider.list_items(
session,
reviewer,
query=WorkItemQuery(tenant_id="tenant-1"),
).total,
)
def test_template_and_evidence_role_constraints_are_frozen(self) -> None: def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
template_command = ApprovalTemplateCreateCommand( template_command = ApprovalTemplateCreateCommand(
key="campaign-release", key="campaign-release",
+288
View File
@@ -0,0 +1,288 @@
from __future__ import annotations
import json
import unittest
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_approvals.backend.db.models import (
ApprovalDecisionRecord,
ApprovalLifecycleEvent,
ApprovalRequestRevision,
ApprovalTemplateRevision,
)
from govoplan_approvals.backend.dsar_provider import (
APPROVALS_DSAR_CAPABILITY,
ApprovalsDsarProvider,
)
from govoplan_approvals.backend.manifest import manifest
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
create_data_subject_request,
search_data_subject_request,
)
NOW = datetime(2026, 8, 21, 13, 0, tzinfo=UTC)
class _Registry:
def __init__(self, provider: ApprovalsDsarProvider) -> None:
self.provider = provider
def capability_names(self):
return (APPROVALS_DSAR_CAPABILITY,)
def capability_owner(self, name):
if name != APPROVALS_DSAR_CAPABILITY:
raise KeyError(name)
return "approvals"
def tenant_entitlement_resolver(self):
class _Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type("State", (), {"effective_modules": ("approvals",)})()
return _Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
if name != APPROVALS_DSAR_CAPABILITY:
raise KeyError(name)
return self.provider
def manifests(self):
return (type("Manifest", (), {"id": "approvals"})(),)
class ApprovalsDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session = Session(self.engine)
self.provider = ApprovalsDsarProvider()
self.assertIsInstance(self.provider, DsarProvider)
self._seed()
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def _seed(self) -> None:
self.session.add_all(
(
ApprovalRequestRevision(
id="request-revision-1",
tenant_id="tenant-1",
request_id="request-1",
revision=1,
state="pending",
current_step_key="legal",
subject_module="cases",
subject_type="case",
subject_id="case-1",
subject_version="4",
subject_digest="subject-digest-do-not-export",
recorded_at=NOW,
payload={"secret": "request-payload-do-not-export"},
actor_id="account-1",
),
ApprovalRequestRevision(
id="request-revision-other",
tenant_id="tenant-1",
request_id="request-other",
revision=1,
state="pending",
current_step_key="legal",
subject_module="cases",
subject_type="case",
subject_id="case-other",
subject_digest="other-digest",
recorded_at=NOW,
payload={"private": "other-request"},
actor_id="account-other",
),
ApprovalRequestRevision(
id="request-revision-other-tenant",
tenant_id="tenant-2",
request_id="request-other-tenant",
revision=1,
state="pending",
subject_module="cases",
subject_type="case",
subject_id="case-other-tenant",
subject_digest="other-tenant-digest",
recorded_at=NOW,
payload={"private": "other-tenant-request"},
actor_id="account-1",
),
)
)
self.session.add(
ApprovalDecisionRecord(
id="decision-1",
tenant_id="tenant-1",
request_id="request-1",
request_revision=1,
step_key="legal",
outcome="approved",
reason="I verified the resident evidence.",
actor_id="account-1",
effective_actor_id="account-1",
delegation_id="delegation-1",
authority_provenance={
"secret": "authority-provenance-do-not-export"
},
signature_ref={"secret": "signature-object-do-not-export"},
recorded_at=NOW,
idempotency_key="decision-idempotency-do-not-export",
receipt_sha256="receipt-hash-do-not-export",
)
)
self.session.add(
ApprovalLifecycleEvent(
id="event-1",
tenant_id="tenant-1",
request_id="request-1",
sequence=1,
event_type="request.created",
recorded_at=NOW,
actor_id="account-1",
payload={"secret": "event-payload-do-not-export"},
)
)
self.session.add(
ApprovalTemplateRevision(
id="template-revision-1",
tenant_id="tenant-1",
template_id="template-1",
key="resident-permit",
revision=1,
state="published",
content_sha256="template-hash-do-not-export",
recorded_at=NOW,
payload={"secret": "template-payload-do-not-export"},
actor_id="account-1",
)
)
@staticmethod
def _subject() -> DsarSubjectRef:
return DsarSubjectRef(account_id="account-1")
def test_search_exports_decision_and_minimized_attribution(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
self.assertEqual(
{
"approval_decision_participation",
"approval_request_actor_attribution",
"approval_lifecycle_actor_attribution",
"approval_template_actor_attribution",
},
{record.resource_type for record in records},
)
exported = json.dumps([record.to_dict() for record in records])
self.assertIn("I verified the resident evidence.", exported)
self.assertIn("case-1", exported)
for excluded in (
"request-payload-do-not-export",
"subject-digest-do-not-export",
"authority-provenance-do-not-export",
"signature-object-do-not-export",
"decision-idempotency-do-not-export",
"receipt-hash-do-not-export",
"event-payload-do-not-export",
"template-payload-do-not-export",
"other-request",
"other-tenant-request",
):
self.assertNotIn(excluded, exported)
def test_request_narrowing_and_conflicting_actor_fail_closed(self) -> None:
narrowed = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"approvals.request": "request-1"},
),
)
conflict = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-1",
external_references={"approvals.actor": "account-other"},
),
)
request_only = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
external_references={"approvals.request": "request-1"}
),
)
self.assertEqual(3, len(narrowed))
self.assertNotIn(
"approval_template_actor_attribution",
{record.resource_type for record in narrowed},
)
self.assertEqual((), conflict)
self.assertEqual((), request_only)
def test_erasure_preserves_chain_and_requires_reason_review(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self._subject()
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
records=records,
)
self.assertEqual(
{"manual_review", "retain"}, {action.kind for action in actions}
)
results = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self._subject(),
actions=actions,
request_id="dsar-approvals-1",
)
self.assertTrue(all(result.status == "blocked" for result in results))
self.assertEqual(1, self.session.query(ApprovalDecisionRecord).count())
def test_manifest_and_core_workflow_discover_provider(self) -> None:
self.assertIn(APPROVALS_DSAR_CAPABILITY, manifest.capability_factories)
row = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-APPROVALS-1",
request_kind="access",
subject=self._subject(),
purpose="Approval participation access request",
legal_basis=None,
due_at=None,
requested_by_account_id="operator-1",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=row,
expected_revision=row.resource_revision,
)
self.assertEqual("searched", row.status)
self.assertEqual(4, row.search_result["record_count"])
if __name__ == "__main__":
unittest.main()
+27 -1
View File
@@ -2,6 +2,10 @@ from __future__ import annotations
import unittest import unittest
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_approvals.backend.manifest import manifest from govoplan_approvals.backend.manifest import manifest
@@ -26,11 +30,33 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
templates = topics["approvals.workflow.administer-templates"] templates = topics["approvals.workflow.administer-templates"]
self.assertIn("approvals.workspace", guide.metadata["help_contexts"]) self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3) self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"]) self.assertIn(
"approvals.field.subject-digest", reference.metadata["help_contexts"]
)
self.assertIn("create_request", reference.metadata["consequence_classes"]) self.assertIn("create_request", reference.metadata["consequence_classes"])
self.assertIn("reject_request", reference.metadata["consequence_classes"]) self.assertIn("reject_request", reference.metadata["consequence_classes"])
self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"]) self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"])
def test_german_reference_documentation_is_complete(self) -> None:
topics = manifest.documentation
self.assertEqual(4, len(topics))
for topic in topics:
translation = topic.translations.get("de", {})
self.assertTrue(translation.get("title"), topic.id)
self.assertTrue(translation.get("summary"), topic.id)
self.assertTrue(translation.get("body"), topic.id)
if localizable_documentation_metadata_keys(topic):
self.assertEqual("1", topic.structured_translation_version, topic.id)
self.assertIn("de", topic.structured_translations, topic.id)
self.assertEqual((), documentation_structured_translation_issues(topic))
kinds = {topic.metadata.get("kind") for topic in topics}
self.assertIn("workflow", kinds)
self.assertIn("reference", kinds)
for topic in topics:
if topic.metadata.get("kind") == "workflow" and "user" in topic.documentation_types:
self.assertTrue(topic.conditions, topic.id)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1
View File
@@ -30,6 +30,7 @@ class ManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend) self.assertIsNotNone(manifest.frontend)
self.assertEqual("approvals.pending", manifest.work_item_providers[0].id)
if __name__ == "__main__": if __name__ == "__main__":
+4 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/approvals-webui", "name": "@govoplan/approvals-webui",
"version": "0.1.17", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/approvals.css": "./src/styles/approvals.css" "./styles/approvals.css": "./src/styles/approvals.css"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.17", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20" "react-dom": ">=19.2.7 <20"
@@ -25,6 +25,7 @@
} }
}, },
"scripts": { "scripts": {
"test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs" "test:approval-templates": "node tests/approval-templates-ui-structure.test.mjs",
"test:workspace-layout": "node tests/workspace-layout-ui-structure.test.mjs"
} }
} }
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui"; import { FormGrid, Button, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, ToggleSwitch, useUnsavedChanges, useUnsavedDraftGuard, type ApiSettings } from "@govoplan/core-webui";
import { createApproval, type ApprovalDraft, type ApprovalRequest } from "../../api/approvals"; import { createApproval, type ApprovalDraft, type ApprovalRequest } from "../../api/approvals";
import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor"; import ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns"; import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
@@ -45,7 +45,7 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
<div className="approval-editor"> <div className="approval-editor">
<div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div> <div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="approval-editor-grid"> <FormGrid columns={2} gap="compact" collapseAt="workspace">
<FormField label="Title" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField> <FormField label="Title" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Subject module" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField> <FormField label="Subject module" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_module} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_module: event.target.value })} /></FormField>
<FormField label="Subject type" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField> <FormField label="Subject type" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={draft.subject_type} disabled={busy} onChange={(event) => setDraft({ ...draft, subject_type: event.target.value })} /></FormField>
@@ -55,7 +55,7 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} /> <ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} /> <ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</div> </FormGrid>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} /> <ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div> </div>
</Dialog>; </Dialog>;
@@ -1,6 +1,6 @@
import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react"; import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import { FormGrid, ActionToolbar,
AdminIconButton, AdminIconButton,
AdminPageLayout, AdminPageLayout,
Button, Button,
@@ -196,13 +196,13 @@ export default function ApprovalTemplatesPanel({
<Dialog open={Boolean(editor)} title={editor?.mode === "create" ? "Create approval template" : "Revise approval template"} onClose={() => !busy && setEditor(null)} closeDisabled={busy} className="approval-template-dialog" footer={<><Button onClick={() => setEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !valid || !canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : !valid ? APPROVALS_I18N.incomplete : busy ? APPROVALS_I18N.busy : undefined}>{busy ? "Saving..." : "Save draft revision"}</Button></>}> <Dialog open={Boolean(editor)} title={editor?.mode === "create" ? "Create approval template" : "Revise approval template"} onClose={() => !busy && setEditor(null)} closeDisabled={busy} className="approval-template-dialog" footer={<><Button onClick={() => setEditor(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void save()} disabled={busy || !valid || !canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : !valid ? APPROVALS_I18N.incomplete : busy ? APPROVALS_I18N.busy : undefined}>{busy ? "Saving..." : "Save draft revision"}</Button></>}>
<div className="approval-editor"> <div className="approval-editor">
<div className="approval-editor-grid"> <FormGrid columns={2} gap="compact" collapseAt="workspace">
<FormField label="Stable key"><input value={draft.key} disabled={busy || editor?.mode === "edit"} onChange={(event) => setDraft({ ...draft, key: event.target.value })} /></FormField> <FormField label="Stable key"><input value={draft.key} disabled={busy || editor?.mode === "edit"} onChange={(event) => setDraft({ ...draft, key: event.target.value })} /></FormField>
<FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField> <FormField label="Title"><input value={draft.title} disabled={busy} onChange={(event) => setDraft({ ...draft, title: event.target.value })} /></FormField>
<FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="Description" className="approval-editor-wide"><textarea rows={3} value={draft.description ?? ""} disabled={busy} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
<ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} /> <ToggleSwitch label="Separate requester and approver" checked={draft.separation_of_duties} disabled={busy} onChange={(value) => setDraft({ ...draft, separation_of_duties: value })} />
<ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} /> <ToggleSwitch label="Different actor for every step" checked={draft.unique_actors_across_steps} disabled={busy} onChange={(value) => setDraft({ ...draft, unique_actors_across_steps: value })} />
</div> </FormGrid>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} /> <ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div> </div>
</Dialog> </Dialog>
@@ -210,11 +210,11 @@ export default function ApprovalTemplatesPanel({
<Dialog open={Boolean(historyTemplate)} title={`${historyTemplate?.title ?? "Template"} history`} onClose={() => !busy && setHistoryTemplate(null)} className="approval-template-history-dialog" footer={<Button onClick={() => setHistoryTemplate(null)} disabled={busy}>Close</Button>}> <Dialog open={Boolean(historyTemplate)} title={`${historyTemplate?.title ?? "Template"} history`} onClose={() => !busy && setHistoryTemplate(null)} className="approval-template-history-dialog" footer={<Button onClick={() => setHistoryTemplate(null)} disabled={busy}>Close</Button>}>
<div className="approval-template-history-layout"> <div className="approval-template-history-layout">
<div className="admin-table-surface"><DataGrid id="approval-template-history-v1" rows={history} columns={historyColumns} initialFit="container" getRowKey={(row) => `${row.id}:${row.revision}`} emptyText="No template revisions found." /></div> <div className="admin-table-surface"><DataGrid id="approval-template-history-v1" rows={history} columns={historyColumns} initialFit="container" getRowKey={(row) => `${row.id}:${row.revision}`} emptyText="No template revisions found." /></div>
<div className="approval-compare-toolbar"> <ActionToolbar className="approval-compare-toolbar">
<FormField label="From revision"><select value={fromRevision} onChange={(event) => setFromRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField> <FormField label="From revision"><select value={fromRevision} onChange={(event) => setFromRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
<FormField label="To revision"><select value={toRevision} onChange={(event) => setToRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField> <FormField label="To revision"><select value={toRevision} onChange={(event) => setToRevision(Number(event.target.value))}>{history.map((item) => <option key={item.revision} value={item.revision}>Revision {item.revision} ({item.state})</option>)}</select></FormField>
<Button onClick={() => void compare()} disabled={busy || !history.length}><GitCompareArrows aria-hidden="true" />Compare</Button> <Button onClick={() => void compare()} disabled={busy || !history.length}><GitCompareArrows aria-hidden="true" />Compare</Button>
</div> </ActionToolbar>
{comparison && <div className="admin-table-surface"><DataGrid id="approval-template-compare-v1" rows={comparison.changes} columns={changeColumns} initialFit="container" getRowKey={(row) => `${row.path}:${row.change}`} emptyText="These revisions have identical template content." /></div>} {comparison && <div className="admin-table-surface"><DataGrid id="approval-template-compare-v1" rows={comparison.changes} columns={changeColumns} initialFit="container" getRowKey={(row) => `${row.path}:${row.change}`} emptyText="These revisions have identical template content." /></div>}
</div> </div>
</Dialog> </Dialog>
+24 -13
View File
@@ -1,6 +1,6 @@
import { AlarmClock, Check, Plus, RefreshCw, X } from "lucide-react"; import { AlarmClock, Check, Plus, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, IconButton, LoadingIndicator, PageScrollViewport, StatusBadge, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui"; import { ActionBlockerHint, Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FormField, LoadingIndicator, MetricCard, MetricGrid, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceLayout, hasScope, usePlatformLanguage, useUnsavedChanges, useUnsavedDraftGuard, type PlatformRouteContext } from "@govoplan/core-webui";
import { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals"; import { approvalHistory, decideApproval, escalateApproval, getApproval, listApprovals, type ApprovalEvent, type ApprovalRequest } from "../../api/approvals";
import ApprovalRequestDialog from "./ApprovalRequestDialog"; import ApprovalRequestDialog from "./ApprovalRequestDialog";
import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns"; import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
@@ -54,24 +54,36 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
await load(undefined, id); await load(undefined, id);
} }
return <main className="approvals-page"><div className="approvals-shell"> return <main className="approvals-page"><WorkspaceLayout
<aside className="approvals-list-panel"> variant="split"
<div className="approvals-toolbar"><IconButton label="Refresh approvals" icon={<RefreshCw size={16} />} disabled={loading || busy} disabledReason={loading ? APPROVALS_I18N.loading : busy ? APPROVALS_I18N.busy : undefined} onClick={() => void load()} /><Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /></div> primarySize="compact"
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<div className="approvals-list">{items.map((item) => <button type="button" key={item.id} className={item.id === selectedId ? "is-selected" : ""} onClick={() => setSelectedId(item.id)}><span><strong>{item.title}</strong><small>{item.subject_module} / {item.subject_type}</small></span><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></button>)}</div>{!loading && items.length === 0 && <div className="approvals-empty">No approval requests</div>}</PageScrollViewport> primaryScrollable={false}
</aside> contentScrollable={false}
<section className="approvals-workspace"> surface="contained"
className="approvals-shell"
primaryClassName="approvals-list-panel"
contentClassName="approvals-workspace"
primaryLabel="Approval requests"
contentLabel="Approval request details"
interfaceId="approvals.workspace"
helpContextId="approvals.workspace"
helpModuleId="approvals"
primary={<>
<WorkspaceActionBar scope="collection-pane" variant="collection" refreshable reloadAction={{ onReload: () => void load(), loading: loading || busy, label: "Refresh approvals" }} className="approvals-toolbar" createAction={<Button variant="primary" disabled={!canWrite} disabledReason={!canWrite ? APPROVALS_I18N.writeReason : undefined} onClick={() => setCreating(true)}><Plus size={16} aria-hidden="true" />New request</Button>} helpAction={<DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} />} />
<PageScrollViewport className="approvals-list-viewport">{loading && <LoadingIndicator label="Loading approvals" />}<SelectionList label="Approval requests" variant="navigation">{items.map((item) => <SelectionListItem key={item.id} selected={item.id === selectedId} onClick={() => setSelectedId(item.id)}><SelectionListItemContent title={item.title} description={`${item.subject_module} / ${item.subject_type}`} /><StatusBadge status={tone(item.state)} label={humanize(item.state)} /></SelectionListItem>)}</SelectionList>{!loading && items.length === 0 && <StatePanel size="compact" description="No approval requests" />}</PageScrollViewport>
</>}
>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />} {!canWrite && <ActionBlockerHint tone="info" reason={{ summary: "No Approval creation permission", details: APPROVALS_I18N.writeReason, requiredAction: APPROVALS_I18N.permissionAction, actor: APPROVALS_I18N.permissionActor, target: APPROVALS_I18N.permissionDestination }} labels={{ requiredAction: APPROVALS_I18N.requiredAction, actor: APPROVALS_I18N.actor, target: APPROVALS_I18N.destination }} documentation={APPROVALS_DOCUMENTATION} />}
{selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail"> {selected && <PageScrollViewport className="approvals-detail-viewport"><div className="approvals-detail">
<header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canAdmin && selected.state === "pending" && <Button disabled={busy || !escalationDue} disabledReason={busy ? APPROVALS_I18N.busy : !escalationDue ? "The current step is not due for escalation." : undefined} onClick={() => setEscalating(true)}><AlarmClock size={16} aria-hidden="true" />Escalate</Button>}<Button variant="primary" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></div></header> <header><div><h2>{selected.title}</h2><span>{selected.subject_module} / {selected.subject_type} / {selected.subject_id}{selected.subject_version ? ` @ ${selected.subject_version}` : ""}</span></div><div><StatusBadge status={tone(selected.state)} label={humanize(selected.state)} />{canAdmin && selected.state === "pending" && <Button disabled={busy || !escalationDue} disabledReason={busy ? APPROVALS_I18N.busy : !escalationDue ? "The current step is not due for escalation." : undefined} onClick={() => setEscalating(true)}><AlarmClock size={16} aria-hidden="true" />Escalate</Button>}<Button variant="primary" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("approved")}><Check size={16} aria-hidden="true" />Approve</Button><Button variant="danger" disabled={busy || !canDecide || !["pending", "escalated"].includes(selected.state)} disabledReason={busy ? APPROVALS_I18N.busy : !canDecide ? APPROVALS_I18N.decideReason : !["pending", "escalated"].includes(selected.state) ? APPROVALS_I18N.lifecycleReason : undefined} onClick={() => setDecision("rejected")}><X size={16} aria-hidden="true" />Reject</Button></div></header>
<div className="approval-metrics"><Metric label="Revision" value={selected.revision} /><Metric label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><Metric label="Requested by" value={selected.requested_by || "-"} /><Metric label="Steps" value={selected.steps.length} /></div> <MetricGrid columns={4} spacing="block"><MetricCard density="compact" label="Revision" value={selected.revision} /><MetricCard density="compact" label="Current step" value={selected.current_step_key ? humanize(selected.current_step_key) : "Complete"} /><MetricCard density="compact" label="Requested by" value={selected.requested_by || "-"} /><MetricCard density="compact" label="Steps" value={selected.steps.length} /></MetricGrid>
{selected.description && <p>{selected.description}</p>} {selected.description && <p>{selected.description}</p>}
<section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section> <section><h3>Approval chain</h3><div className="approval-chain">{selected.steps.map((step, index) => <div key={step.key} className={step.key === selected.current_step_key ? "is-current" : ""}><span>{index + 1}</span><strong>{step.label}</strong><small>{step.required_approvals} required / {step.selectors.map((item) => `${humanize(item.kind)}: ${item.label || item.value}`).join(", ")}</small>{step.signature_required && <em>Signature</em>}</div>)}</div></section>
<section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section> <section><h3>History</h3><div className="approval-history">{history.map((event) => <div key={event.sequence}><span>{event.sequence}</span><strong>{humanize(event.event_type)}</strong><time>{new Date(event.recorded_at).toLocaleString(language)}</time></div>)}</div></section>
</div></PageScrollViewport>} </div></PageScrollViewport>}
{!selected && !loading && <div className="approvals-empty">Select or create an approval request.</div>} {!selected && !loading && <StatePanel size="fill" title="Approval requests" description="Select or create an approval request." />}
</section> </WorkspaceLayout>
</div>
{creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />} {creating && <ApprovalRequestDialog settings={settings} onClose={() => setCreating(false)} onSaved={(item) => { setCreating(false); setSelectedId(item.id); void load(undefined, item.id); }} />}
{selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); return true; } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); return false; } finally { setBusy(false); } }} />} {selected && decision && <DecisionDialog outcome={decision} busy={busy} signatureRequired={Boolean(selected.steps[selected.current_step_index]?.signature_required)} onClose={() => setDecision(null)} onConfirm={async (reason, signatureId) => { setBusy(true); setError(""); try { await decideApproval(settings, selected, decision, reason, signatureId ? { owner_module: "signatures", object_id: signatureId } : undefined); setDecision(null); await reload(selected.id); return true; } catch (failure) { setError(text(failure, "The Approval decision could not be recorded.")); return false; } finally { setBusy(false); } }} />}
{selected && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => setEscalating(false)} onConfirm={() => { setBusy(true); setError(""); void escalateApproval(settings, selected).then(() => { setEscalating(false); return reload(selected.id); }).catch((failure) => setError(text(failure, "The Approval step could not be escalated."))).finally(() => setBusy(false)); }} />} {selected && <ConfirmDialog open={escalating} title="Escalate approval step" message={`Escalate ${currentStep?.label ?? "the current step"}? This records an explicit lifecycle transition and lets the configured escalation workflow react.`} confirmLabel="Escalate step" busy={busy} onCancel={() => setEscalating(false)} onConfirm={() => { setBusy(true); setError(""); void escalateApproval(settings, selected).then(() => { setEscalating(false); return reload(selected.id); }).catch((failure) => setError(text(failure, "The Approval step could not be escalated."))).finally(() => setBusy(false)); }} />}
@@ -89,7 +101,6 @@ function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }
return <Dialog open title={`${humanize(outcome)} request`} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div><FormField label="Reason" documentation={APPROVALS_FIELD_DOCUMENTATION}><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>; return <Dialog open title={`${humanize(outcome)} request`} onClose={requestClose} closeDisabled={busy} portal footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant={outcome === "approved" ? "primary" : "danger"} disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void onConfirm(reason.trim(), signatureId.trim())}>Confirm</Button></>}><div className="approval-decision-form"><div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div><FormField label="Reason" documentation={APPROVALS_FIELD_DOCUMENTATION}><textarea rows={5} value={reason} disabled={busy} onChange={(event) => setReason(event.target.value)} /></FormField>{signatureRequired && <FormField label="Signature reference" documentation={APPROVALS_FIELD_DOCUMENTATION}><input value={signatureId} disabled={busy} onChange={(event) => setSignatureId(event.target.value)} /></FormField>}</div></Dialog>;
} }
function Metric({ label, value }: { label: string; value: string | number }) { return <div><span>{label}</span><strong>{value}</strong></div>; }
function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; } function tone(state: ApprovalRequest["state"]): "active" | "inactive" | "warning" { if (state === "approved") return "active"; if (["rejected", "cancelled", "expired"].includes(state)) return "inactive"; return "warning"; }
function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); } function humanize(value: string): string { return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); }
function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; } function text(value: unknown, fallback: string): string { return value instanceof Error ? value.message : fallback; }
+2 -2
View File
@@ -28,9 +28,9 @@ const approvalsAdminSections: AdminSectionsUiCapability = {
export const approvalsModule: PlatformWebModule = { export const approvalsModule: PlatformWebModule = {
id: "approvals", id: "approvals",
label: "i18n:govoplan-approvals.approvals", label: "i18n:govoplan-approvals.approvals",
version: "0.1.14", version: "0.1.19",
dependencies: ["access"], dependencies: ["access"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"], optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy", "tasks"],
translations: generatedTranslations, translations: generatedTranslations,
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }], routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }], navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
+10 -21
View File
@@ -1,42 +1,31 @@
.approvals-page { height: 100%; min-height: 0; overflow: hidden; } .approvals-page { height: 100%; min-height: 0; overflow: hidden; }
.approvals-shell { display: grid; grid-template-columns: minmax(250px, 320px) minmax(0, 1fr); height: 100%; min-height: 0; } .approvals-list-panel { display: flex; min-height: 0; flex-direction: column; }
.approvals-list-panel { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--border-color, #d8dde3); }
.approvals-toolbar { display: flex; align-items: center; gap: 8px; min-height: 50px; padding: 8px 12px; border-bottom: 1px solid var(--border-color, #d8dde3); }
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; } .approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
.approvals-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; } .approvals-detail header span { color: var(--muted); }
.approvals-list button { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; min-height: 52px; padding: 7px 8px; border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; }
.approvals-list button:hover, .approvals-list button.is-selected { background: var(--hover-bg, rgba(54, 99, 135, 0.1)); }
.approvals-list button > span { display: flex; min-width: 0; flex-direction: column; }
.approvals-list small, .approvals-detail header span { color: var(--text-muted, #65717e); }
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; } .approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.approvals-workspace > .action-blocker-hint { margin: 12px 16px 0; } .approvals-workspace > .action-blocker-hint { margin: 12px 16px 0; }
.approvals-empty { display: grid; min-height: 120px; place-items: center; padding: 16px; color: var(--text-muted, #65717e); text-align: center; }
.approvals-detail { padding: 16px 20px 28px; } .approvals-detail { padding: 16px 20px 28px; }
.approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--border-color, #d8dde3); } .approvals-detail header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--line); }
.approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; } .approvals-detail header > div:last-child { display: flex; align-items: center; gap: 8px; }
.approvals-detail h2, .approvals-detail h3, .approval-editor-heading h3 { margin: 0; font-size: 1rem; letter-spacing: 0; } .approvals-detail h2, .approvals-detail h3, .approval-editor-heading h3 { margin: 0; font-size: 1rem; letter-spacing: 0; }
.approval-metrics { display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr)); gap: 10px; margin: 16px 0; } .approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
.approval-metrics > div { display: flex; flex-direction: column; padding: 10px 12px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; }
.approval-metrics span { color: var(--text-muted, #65717e); font-size: .75rem; text-transform: uppercase; }
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border-color, #d8dde3); }
.approval-chain, .approval-history { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; } .approval-chain, .approval-history { display: flex; flex-direction: column; gap: 6px; margin-top: 10px; }
.approval-chain > div, .approval-history > div { display: grid; grid-template-columns: 32px minmax(120px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 7px 9px; background: var(--surface-muted, rgba(127,137,147,.08)); } .approval-chain > div, .approval-history > div { display: grid; grid-template-columns: 32px minmax(120px, .6fr) minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 40px; padding: 7px 9px; background: var(--surface-muted); }
.approval-chain > div.is-current { border-left: 3px solid var(--accent-color, #36709a); } .approval-chain > div.is-current { border-left: 3px solid var(--data-category-blue); }
.approval-chain em { font-size: .8rem; font-style: normal; } .approval-chain em { font-size: .8rem; font-style: normal; }
.approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; } .approval-history > div { grid-template-columns: 32px minmax(0, 1fr) auto; }
.approval-history time { color: var(--text-muted, #65717e); font-size: .82rem; } .approval-history time { color: var(--muted); font-size: .82rem; }
.approval-request-dialog, .approval-template-dialog { width: min(1160px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); } .approval-request-dialog, .approval-template-dialog { width: min(1160px, calc(100vw - 32px)); height: min(860px, calc(100vh - 32px)); }
.approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; } .approval-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
.approval-editor-help { display: flex; justify-content: flex-end; } .approval-editor-help { display: flex; justify-content: flex-end; }
.approval-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.approval-editor-wide { grid-column: 1 / -1; } .approval-editor-wide { grid-column: 1 / -1; }
.approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .approval-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-list { display: flex; flex-direction: column; gap: 8px; } .approval-step-list { display: flex; flex-direction: column; gap: 8px; }
.approval-step-editor { display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px solid var(--border-color, #d8dde3); border-radius: 4px; } .approval-step-editor { display: flex; flex-direction: column; gap: 10px; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-sm); }
.approval-step-heading, .approval-selector-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .approval-step-heading, .approval-selector-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.approval-step-heading > div { display: flex; align-items: center; gap: 4px; } .approval-step-heading > div { display: flex; align-items: center; gap: 4px; }
.approval-step-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 8px; } .approval-step-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: end; gap: 8px; }
.approval-selector-heading { padding-top: 8px; border-top: 1px solid var(--border-color, #d8dde3); } .approval-selector-heading { padding-top: 8px; border-top: 1px solid var(--line); }
.approval-selector-list { display: flex; flex-direction: column; gap: 6px; } .approval-selector-list { display: flex; flex-direction: column; gap: 6px; }
.approval-selector-list > div { display: grid; grid-template-columns: 180px minmax(160px, 1fr) minmax(160px, 1fr) 34px; align-items: end; gap: 8px; } .approval-selector-list > div { display: grid; grid-template-columns: 180px minmax(160px, 1fr) minmax(160px, 1fr) 34px; align-items: end; gap: 8px; }
.approval-template-history-dialog { width: min(1180px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); } .approval-template-history-dialog { width: min(1180px, calc(100vw - 32px)); height: min(820px, calc(100vh - 32px)); }
@@ -45,4 +34,4 @@
.approval-compare-toolbar { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 8px; } .approval-compare-toolbar { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(180px, 1fr) auto; align-items: end; gap: 8px; }
.approval-diff-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .approval-diff-value { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; } .approval-decision-form { display: flex; min-width: min(520px, 75vw); flex-direction: column; gap: 10px; }
@media (max-width: 850px) { .approvals-shell { grid-template-columns: 1fr; grid-template-rows: minmax(160px, 34%) minmax(0, 1fr); } .approvals-list-panel { border-right: 0; border-bottom: 1px solid var(--border-color, #d8dde3); } .approval-metrics, .approval-editor-grid, .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } } @media (max-width: 900px) { .approval-step-fields, .approval-selector-list > div, .approval-compare-toolbar { grid-template-columns: 1fr; } .approval-editor-wide { grid-column: auto; } }
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import fs from "node:fs";
const page = fs.readFileSync("src/features/approvals/ApprovalsPage.tsx", "utf8");
const styles = fs.readFileSync("src/styles/approvals.css", "utf8");
assert.ok(page.includes("<WorkspaceLayout"), "Approvals must use the shared workspace shell");
assert.ok(page.includes('variant="split"'), "Approvals must use the shared split-pane geometry");
assert.ok(page.includes('primarySize="compact"'), "The request list uses the bounded compact pane width");
assert.ok(page.includes("primaryScrollable={false}"), "The request list delegates scrolling to its contained viewport");
assert.ok(page.includes("contentScrollable={false}"), "The detail pane delegates scrolling to its contained viewport");
assert.ok(!styles.includes(".approvals-shell { display: grid"), "Approvals must not redefine the shared split-pane grid");
console.log("Approval workspace layout structural contract passed.");