Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36781c86f7 | ||
|
|
7c9ae1f116 | ||
|
|
e864f840e3 | ||
|
|
5d3eeb0060 | ||
|
|
327b35888d | ||
|
|
eded5dbaf1 | ||
|
|
183a091539 | ||
|
|
9731daae4a | ||
|
|
4cf870f322 | ||
|
|
b691a0c2d5 | ||
|
|
956624be0e |
@@ -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
|
cd /mnt/DATA/git/govoplan-core
|
||||||
/mnt/DATA/git/govoplan/tools/gitea/gitea-sync-labels.py --root /mnt/DATA/git/govoplan-approvals --apply
|
/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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+28
-3
@@ -1,8 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/approvals",
|
"name": "@govoplan/approvals-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.21",
|
||||||
"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",
|
||||||
"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
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-approvals"
|
name = "govoplan-approvals"
|
||||||
version = "0.1.17"
|
version = "0.1.21"
|
||||||
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"]
|
||||||
@@ -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.21"
|
||||||
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(
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/approvals-webui",
|
"name": "@govoplan/approvals-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.21",
|
||||||
"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>
|
||||||
|
|||||||
@@ -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" 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>
|
||||||
<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
@@ -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" }],
|
||||||
|
|||||||
@@ -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.");
|
||||||
Reference in New Issue
Block a user