14 Commits
Author SHA1 Message Date
zemion 11eca4ed28 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:29 +02:00
zemion 36781c86f7 fix(packaging): expose immutable WebUI Git package for v0.1.21
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 02:06:08 +02:00
zemion 7c9ae1f116 fix(webui): bind approval decisions to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:33 +02:00
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
zemion a3232f6e12 Release v0.1.17
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 20:33:57 +02:00
zemion ad9a580ad3 Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:51:58 +02:00
21 changed files with 1514 additions and 129 deletions
+18
View File
@@ -73,3 +73,21 @@ From the core checkout, labels can be synced once a local `GITEA_TOKEN` is avail
cd /mnt/DATA/git/govoplan-core
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-approvals --apply
```
## Git-source WebUI package
The repository root exposes `@govoplan/approvals-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/approvals-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+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
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
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
Workflow Engine may wait for completion and Notifications may announce an
assignment, due date, escalation, or outcome. Audit may retain additional
cross-domain evidence. Policy may provide chain templates. These integrations
use capabilities and events; none reads Approval tables directly.
Workflow Engine may wait for completion, Tasks may aggregate actionable work,
and Notifications may announce an assignment, due date, escalation, or
outcome. Audit may retain additional cross-domain evidence. Policy may provide
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.
The module uses Core dialogs, controls, status, blockers, help, loading, empty,
error, and draft-guard contracts. Native selection buttons preserve keyboard
order; bounded list/detail viewports remain responsive. English and German
catalogues cover module-owned copy and dates follow the active platform locale.
error, draft-guard, and `WorkspaceLayout` contracts. The shared split-pane
shell keeps the request collection and selected evidence in independently
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.
+28 -3
View File
@@ -1,8 +1,33 @@
{
"name": "@govoplan/approvals",
"version": "0.1.15",
"name": "@govoplan/approvals-webui",
"version": "0.1.21",
"private": true,
"description": "Governed approval chains, decisions, delegation, and escalation for GovOPlaN.",
"type": "module",
"peerDependencies": {}
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
},
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/approvals.css": "./webui/src/styles/approvals.css"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-approvals"
version = "0.1.15"
version = "0.1.21"
description = "Governed approval chains, decisions, delegation, and escalation for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.15",
"govoplan-access>=0.1.15",
"govoplan-core>=0.1.18",
"govoplan-access>=0.1.18",
]
[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"]
+246 -9
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.module_guards import (
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -23,23 +24,36 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
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.db.base import Base
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
MODULE_ID = "approvals"
MODULE_NAME = "Approvals"
MODULE_VERSION = "0.1.15"
MODULE_VERSION = "0.1.21"
READ_SCOPE = "approvals:workspace:read"
WRITE_SCOPE = "approvals:workspace:write"
DECIDE_SCOPE = "approvals:workspace:decide"
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:
@@ -66,6 +80,16 @@ def _requests(_context: ModuleContext) -> 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]:
current = session.query(approval_models.ApprovalRequestRevision).filter(
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
@@ -91,6 +115,7 @@ manifest = ModuleManifest(
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_APPROVAL_REQUESTS, version="0.1.0"),
ModuleInterfaceProvider(name=APPROVALS_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=(
_permission(
@@ -164,6 +189,17 @@ manifest = ModuleManifest(
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=(
ViewSurface(
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_APPROVAL_REQUESTS: CapabilityDocumentation(
label="Governed approval requests",
summary="Freezes exact subject approval chains and resolves auditable sequential decisions.",
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(
module_id=MODULE_ID,
metadata=Base.metadata,
@@ -223,17 +277,94 @@ manifest = ModuleManifest(
),
tenant_summary_providers=(_tenant_summary,),
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(
id="approvals.module-boundary",
title="Governed approval chains",
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
body=(
"Documentation books are beside the Approval requests and template headings and beside "
"request or decision dialog titles; field help stays with its label. "
"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",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner", "auditor"),
conditions=(DocumentationCondition(required_scopes=(READ_SCOPE,)),),
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
@@ -243,6 +374,7 @@ manifest = ModuleManifest(
),
),
metadata={
"kind": "workflow",
"seed": True,
"help_contexts": [
"approvals.navigation",
@@ -257,6 +389,37 @@ manifest = ModuleManifest(
"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": (
"Dokumentationsbücher stehen neben den Überschriften der Genehmigungsanfragen und Vorlagen "
"sowie neben den Titeln der Anfrage- und Entscheidungsdialoge; Feldhilfe bleibt bei der "
"Feldbezeichnung. "
"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(
id="approvals.reference.fields-and-consequences",
@@ -282,6 +445,7 @@ manifest = ModuleManifest(
),
),
metadata={
"kind": "reference",
"seed": True,
"help_contexts": [
"approvals.field.subject-reference",
@@ -299,6 +463,34 @@ manifest = ModuleManifest(
"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(
id="approvals.workflow.administer-templates",
@@ -311,13 +503,29 @@ manifest = ModuleManifest(
layer="configured",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "product_owner", "auditor"),
conditions=(DocumentationCondition(required_scopes=(ADMIN_SCOPE,)),),
links=(
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"),
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"),
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"),
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"),
DocumentationLink(
label="Approval templates",
href="/admin?section=tenant-approval-templates",
kind="runtime",
),
DocumentationLink(
label="Template API", href="/api/v1/approvals/templates", kind="api"
),
DocumentationLink(
label="Template history API",
href="/api/v1/approvals/templates/{template_id}/history",
kind="api",
),
DocumentationLink(
label="Template comparison API",
href="/api/v1/approvals/templates/{template_id}/compare",
kind="api",
),
),
metadata={
"kind": "workflow",
"help_contexts": [
"approvals.admin.templates",
"approvals.action.escalate-request",
@@ -328,6 +536,35 @@ manifest = ModuleManifest(
"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(
+98 -54
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict
from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
import hashlib
import json
@@ -33,6 +33,13 @@ class ApprovalStoreError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ApprovalDecisionContext:
step: Mapping[str, Any]
effective_actor: str
matched_selector: Mapping[str, object]
class SqlApprovalRequests:
def create_template(
self,
@@ -278,9 +285,7 @@ class SqlApprovalRequests:
.filter(
ApprovalTemplateRevision.tenant_id == _tenant(principal),
ApprovalTemplateRevision.template_id == template_id,
ApprovalTemplateRevision.revision.in_(
(from_revision, to_revision)
),
ApprovalTemplateRevision.revision.in_((from_revision, to_revision)),
)
.all()
)
@@ -480,56 +485,17 @@ class SqlApprovalRequests:
raise ApprovalStoreError(
"This Approval request no longer accepts decisions."
)
expires_at = _datetime(current.payload.get("expires_at"))
if expires_at is not None and _now() >= expires_at:
raise ApprovalStoreError("This Approval request has expired.")
decision_context = approval_decision_context(
typed_session,
principal,
current,
delegated_for_account_id=command.delegated_for_account_id,
)
steps = list(current.payload["steps"])
step_index = int(current.payload.get("current_step_index") or 0)
step = steps[step_index]
effective_actor = _effective_actor(principal, command.delegated_for_account_id)
matched_selector = _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."
)
step = decision_context.step
effective_actor = decision_context.effective_actor
matched_selector = decision_context.matched_selector
signature_ref = (
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]:
return {
"key": str(getattr(step, "key")),
@@ -1383,4 +1422,9 @@ def _now() -> datetime:
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.orm import sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.approvals import (
ApprovalActorSelector,
ApprovalDecisionCommand,
@@ -16,6 +18,8 @@ from govoplan_core.core.approvals import (
)
from govoplan_core.db.base import Base
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
@@ -241,6 +245,62 @@ class ApprovalRuntimeTests(unittest.TestCase):
)
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:
template_command = ApprovalTemplateCreateCommand(
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
from govoplan_core.core.modules import (
documentation_structured_translation_issues,
localizable_documentation_metadata_keys,
)
from govoplan_approvals.backend.manifest import manifest
@@ -26,11 +30,33 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
templates = topics["approvals.workflow.administer-templates"]
self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
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("reject_request", reference.metadata["consequence_classes"])
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__":
unittest.main()
+1
View File
@@ -30,6 +30,7 @@ class ManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertEqual("approvals.pending", manifest.work_item_providers[0].id)
if __name__ == "__main__":
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/approvals-webui",
"version": "0.1.15",
"version": "0.1.21",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/approvals.css": "./src/styles/approvals.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.15",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20"
@@ -25,6 +25,7 @@
}
},
"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 { 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 ApprovalStepsEditor, { emptyApprovalStep } from "./ApprovalStepsEditor";
import { APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
@@ -41,11 +41,10 @@ export default function ApprovalRequestDialog({ settings, onClose, onSaved }: {
else onClose();
}
return <Dialog open title="New Approval request" onClose={requestClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
return <Dialog open title="New Approval request" titleHelp={<DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} />} onClose={requestClose} closeDisabled={busy} portal className="approval-request-dialog" footer={<><Button onClick={requestClose} disabled={busy} disabledReason={busy ? APPROVALS_I18N.busy : undefined}>Cancel</Button><Button variant="primary" disabled={busy || !valid} disabledReason={busy ? APPROVALS_I18N.busy : !valid ? APPROVALS_I18N.incomplete : undefined} onClick={() => void save()}>{busy ? "Creating" : "Create request"}</Button></>}>
<div className="approval-editor">
<div className="approval-editor-help"><DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} /></div>
{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="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>
@@ -55,7 +54,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>
<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 })} />
</div>
</FormGrid>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div>
</Dialog>;
@@ -1,6 +1,6 @@
import { GitCompareArrows, History, Pencil, Plus, Send } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
import { FormGrid, ActionToolbar,
AdminIconButton,
AdminPageLayout,
Button,
@@ -190,19 +190,19 @@ export default function ApprovalTemplatesPanel({
);
return <>
<AdminPageLayout title="Approval templates" description="Define reusable, immutable approval chains and compare every published or draft revision." loading={loading} error={error} success={success} actions={<><DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} /><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Create approval template" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : undefined} /></>}>
<AdminPageLayout title="Approval templates" titleHelp={<DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} />} description="Define reusable, immutable approval chains and compare every published or draft revision." loading={loading} error={error} success={success} actions={<><Button onClick={() => void load()} disabled={loading}>Reload</Button><AdminIconButton label="Create approval template" icon={<Plus />} variant="primary" onClick={openCreate} disabled={!canAdmin} disabledReason={!canAdmin ? "Approval administration permission is required." : undefined} /></>}>
<div className="admin-table-surface"><DataGrid id="approval-templates-v1" rows={templates} columns={columns} initialFit="container" getRowKey={(row) => row.id} emptyText="No approval templates found." /></div>
</AdminPageLayout>
<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-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="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>
<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 })} />
</div>
</FormGrid>
<ApprovalStepsEditor steps={draft.steps} disabled={busy} onChange={(steps) => setDraft({ ...draft, steps })} />
</div>
</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>}>
<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="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="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>
</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>}
</div>
</Dialog>
+28 -15
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 { 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 ApprovalRequestDialog from "./ApprovalRequestDialog";
import { APPROVALS_DOCUMENTATION, APPROVALS_FIELD_DOCUMENTATION, APPROVALS_I18N } from "./interfacePatterns";
@@ -54,24 +54,38 @@ export default function ApprovalsPage({ settings, auth }: PlatformRouteContext)
await load(undefined, id);
}
return <main className="approvals-page"><div className="approvals-shell">
<aside className="approvals-list-panel">
<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>
<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>
</aside>
<section className="approvals-workspace">
return <main className="approvals-page"><WorkspaceLayout
variant="split"
primarySize="compact"
primaryScrollable={false}
contentScrollable={false}
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
title="Approval requests"
titleHelp={<DocumentationHelpLink reference={APPROVALS_DOCUMENTATION} />} 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>} />
<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>}
{!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">
<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>
<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" helpContextId="approvals.action.decide-request" helpModuleId="approvals" 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" helpContextId="approvals.action.decide-request" helpModuleId="approvals" 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>
<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>}
<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>
</div></PageScrollViewport>}
{!selected && !loading && <div className="approvals-empty">Select or create an approval request.</div>}
</section>
</div>
{!selected && !loading && <StatePanel size="fill" title="Approval requests" description="Select or create an approval request." />}
</WorkspaceLayout>
{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 && <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)); }} />}
@@ -86,10 +100,9 @@ function DecisionDialog({ outcome, busy, signatureRequired, onClose, onConfirm }
const valid = Boolean(reason.trim() && (!signatureRequired || signatureId.trim()));
useUnsavedDraftGuard({ dirty, onSave: async () => valid && onConfirm(reason.trim(), signatureId.trim()), onDiscard: () => { setReason(""); setSignatureId(""); }, title: "i18n:govoplan-approvals.unsaved_title", message: "i18n:govoplan-approvals.unsaved_message" });
const requestClose = () => { if (busy) return; if (dirty) requestDiscard(onClose); else onClose(); };
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`} titleHelp={<DocumentationHelpLink reference={APPROVALS_FIELD_DOCUMENTATION} />} 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"><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 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; }
+2
View File
@@ -1,6 +1,7 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"Approval requests": "Approval requests",
"i18n:govoplan-approvals.approvals": "Approvals",
"i18n:govoplan-approvals.loading_reason": "Approval data is still loading.",
"i18n:govoplan-approvals.busy_reason": "Another Approval action is still running.",
@@ -64,6 +65,7 @@ const en = {
} as const;
const de: Record<keyof typeof en, string> = {
"Approval requests": "Genehmigungsanträge",
"i18n:govoplan-approvals.approvals": "Genehmigungen",
"i18n:govoplan-approvals.loading_reason": "Genehmigungsdaten werden noch geladen.",
"i18n:govoplan-approvals.busy_reason": "Eine andere Genehmigungsaktion läuft noch.",
+2 -2
View File
@@ -28,9 +28,9 @@ const approvalsAdminSections: AdminSectionsUiCapability = {
export const approvalsModule: PlatformWebModule = {
id: "approvals",
label: "i18n:govoplan-approvals.approvals",
version: "0.1.14",
version: "0.1.19",
dependencies: ["access"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy", "tasks"],
translations: generatedTranslations,
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" }],
+10 -21
View File
@@ -1,42 +1,31 @@
.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; 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-panel { display: flex; min-height: 0; flex-direction: column; }
.approvals-list-viewport, .approvals-detail-viewport { min-height: 0; flex: 1; }
.approvals-list { display: flex; flex-direction: column; gap: 2px; padding: 6px; }
.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-detail header span { color: var(--muted); }
.approvals-workspace { display: flex; min-width: 0; min-height: 0; flex-direction: column; }
.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 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 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; }
.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); }
.approvals-detail section { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--line); }
.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.is-current { border-left: 3px solid var(--accent-color, #36709a); }
.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(--data-category-blue); }
.approval-chain em { font-size: .8rem; font-style: normal; }
.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-editor { display: flex; min-height: 0; flex-direction: column; gap: 12px; overflow: auto; }
.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-heading { display: flex; align-items: center; justify-content: space-between; 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 > 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-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 > 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)); }
@@ -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-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; }
@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.");