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