Project pending approvals as authorized work
This commit is contained in:
@@ -26,6 +26,12 @@ signature reference; Approvals does not implement document signing or key
|
|||||||
custody. A fail-fast rejection ends the request. Due steps can enter an
|
custody. A fail-fast rejection ends the request. Due steps can enter an
|
||||||
explicit escalated state without silently changing their outcome.
|
explicit escalated state without silently changing their outcome.
|
||||||
|
|
||||||
|
When Tasks is enabled, Approvals projects only currently actionable steps into
|
||||||
|
the common work inbox. The projection applies the same selector, expiration,
|
||||||
|
prior-decision, unique-actor, evidence-role, and requester-separation checks as
|
||||||
|
the decision command. Approvals remains the owner of decision and completion
|
||||||
|
state; Tasks receives no copied approval record.
|
||||||
|
|
||||||
## Recovery and scale-out
|
## Recovery and scale-out
|
||||||
|
|
||||||
All API and worker nodes use the logically shared database. Back up and restore
|
All API and worker nodes use the logically shared database. Back up and restore
|
||||||
@@ -44,7 +50,8 @@ database and reconcile every module object that retains an Approval reference.
|
|||||||
|
|
||||||
## Optional integrations
|
## Optional integrations
|
||||||
|
|
||||||
Workflow Engine may wait for completion and Notifications may announce an
|
Workflow Engine may wait for completion, Tasks may aggregate actionable work,
|
||||||
assignment, due date, escalation, or outcome. Audit may retain additional
|
and Notifications may announce an assignment, due date, escalation, or
|
||||||
cross-domain evidence. Policy may provide chain templates. These integrations
|
outcome. Audit may retain additional cross-domain evidence. Policy may provide
|
||||||
use capabilities and events; none reads Approval tables directly.
|
chain templates. These integrations use capabilities and events; none reads
|
||||||
|
Approval tables directly.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
|
|||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_approvals.backend.db import models as approval_models
|
from govoplan_approvals.backend.db import models as approval_models
|
||||||
@@ -39,7 +40,14 @@ READ_SCOPE = "approvals:workspace:read"
|
|||||||
WRITE_SCOPE = "approvals:workspace:write"
|
WRITE_SCOPE = "approvals:workspace:write"
|
||||||
DECIDE_SCOPE = "approvals:workspace:decide"
|
DECIDE_SCOPE = "approvals:workspace:decide"
|
||||||
ADMIN_SCOPE = "approvals:workspace:admin"
|
ADMIN_SCOPE = "approvals:workspace:admin"
|
||||||
OPTIONAL_DEPENDENCIES = ("workflow_engine", "audit", "files", "notifications", "policy")
|
OPTIONAL_DEPENDENCIES = (
|
||||||
|
"workflow_engine",
|
||||||
|
"audit",
|
||||||
|
"files",
|
||||||
|
"notifications",
|
||||||
|
"policy",
|
||||||
|
"tasks",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
@@ -66,6 +74,12 @@ def _requests(_context: ModuleContext) -> SqlApprovalRequests:
|
|||||||
return SqlApprovalRequests()
|
return SqlApprovalRequests()
|
||||||
|
|
||||||
|
|
||||||
|
def _work_items(_context: ModuleContext):
|
||||||
|
from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
|
||||||
|
|
||||||
|
return ApprovalWorkItemProvider()
|
||||||
|
|
||||||
|
|
||||||
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||||
current = session.query(approval_models.ApprovalRequestRevision).filter(
|
current = session.query(approval_models.ApprovalRequestRevision).filter(
|
||||||
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
|
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
|
||||||
@@ -196,6 +210,13 @@ manifest = ModuleManifest(
|
|||||||
contract_version="0.1.0",
|
contract_version="0.1.0",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
work_item_providers=(
|
||||||
|
WorkItemProviderRegistration(
|
||||||
|
id="approvals.pending",
|
||||||
|
factory=_work_items,
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
metadata=Base.metadata,
|
metadata=Base.metadata,
|
||||||
@@ -229,7 +250,8 @@ manifest = ModuleManifest(
|
|||||||
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
|
summary="Create exact-subject approval chains with delegation, separation of duties, escalation, and signature evidence.",
|
||||||
body=(
|
body=(
|
||||||
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. "
|
"An Approval request freezes its subject revision, ordered steps, eligible selectors, quorum, rejection policy, signature requirement, and governance references. "
|
||||||
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables."
|
"Decisions are append-only, tenant-bound, optimistic-concurrency protected, and replay safe. Consuming modules verify the exact subject through the capability rather than reading Approval tables. "
|
||||||
|
"When Tasks is enabled, a pending step appears in the common work inbox only for a principal who currently passes the exact decision eligibility checks."
|
||||||
),
|
),
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
@@ -312,10 +334,24 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("operator", "module_admin", "product_owner", "auditor"),
|
audience=("operator", "module_admin", "product_owner", "auditor"),
|
||||||
links=(
|
links=(
|
||||||
DocumentationLink(label="Approval templates", href="/admin?section=tenant-approval-templates", kind="runtime"),
|
DocumentationLink(
|
||||||
DocumentationLink(label="Template API", href="/api/v1/approvals/templates", kind="api"),
|
label="Approval templates",
|
||||||
DocumentationLink(label="Template history API", href="/api/v1/approvals/templates/{template_id}/history", kind="api"),
|
href="/admin?section=tenant-approval-templates",
|
||||||
DocumentationLink(label="Template comparison API", href="/api/v1/approvals/templates/{template_id}/compare", kind="api"),
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Template API", href="/api/v1/approvals/templates", kind="api"
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Template history API",
|
||||||
|
href="/api/v1/approvals/templates/{template_id}/history",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Template comparison API",
|
||||||
|
href="/api/v1/approvals/templates/{template_id}/compare",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
metadata={
|
metadata={
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, Sequence
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -33,6 +33,13 @@ class ApprovalStoreError(ValueError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ApprovalDecisionContext:
|
||||||
|
step: Mapping[str, Any]
|
||||||
|
effective_actor: str
|
||||||
|
matched_selector: Mapping[str, object]
|
||||||
|
|
||||||
|
|
||||||
class SqlApprovalRequests:
|
class SqlApprovalRequests:
|
||||||
def create_template(
|
def create_template(
|
||||||
self,
|
self,
|
||||||
@@ -278,9 +285,7 @@ class SqlApprovalRequests:
|
|||||||
.filter(
|
.filter(
|
||||||
ApprovalTemplateRevision.tenant_id == _tenant(principal),
|
ApprovalTemplateRevision.tenant_id == _tenant(principal),
|
||||||
ApprovalTemplateRevision.template_id == template_id,
|
ApprovalTemplateRevision.template_id == template_id,
|
||||||
ApprovalTemplateRevision.revision.in_(
|
ApprovalTemplateRevision.revision.in_((from_revision, to_revision)),
|
||||||
(from_revision, to_revision)
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
@@ -480,56 +485,17 @@ class SqlApprovalRequests:
|
|||||||
raise ApprovalStoreError(
|
raise ApprovalStoreError(
|
||||||
"This Approval request no longer accepts decisions."
|
"This Approval request no longer accepts decisions."
|
||||||
)
|
)
|
||||||
expires_at = _datetime(current.payload.get("expires_at"))
|
decision_context = approval_decision_context(
|
||||||
if expires_at is not None and _now() >= expires_at:
|
typed_session,
|
||||||
raise ApprovalStoreError("This Approval request has expired.")
|
principal,
|
||||||
|
current,
|
||||||
|
delegated_for_account_id=command.delegated_for_account_id,
|
||||||
|
)
|
||||||
steps = list(current.payload["steps"])
|
steps = list(current.payload["steps"])
|
||||||
step_index = int(current.payload.get("current_step_index") or 0)
|
step_index = int(current.payload.get("current_step_index") or 0)
|
||||||
step = steps[step_index]
|
step = decision_context.step
|
||||||
effective_actor = _effective_actor(principal, command.delegated_for_account_id)
|
effective_actor = decision_context.effective_actor
|
||||||
matched_selector = _matched_selector(
|
matched_selector = decision_context.matched_selector
|
||||||
principal, step.get("selectors") or [], effective_actor
|
|
||||||
)
|
|
||||||
if matched_selector is None:
|
|
||||||
raise ApprovalStoreError(
|
|
||||||
"The current principal is not eligible for this Approval step."
|
|
||||||
)
|
|
||||||
if bool(
|
|
||||||
current.payload.get("separation_of_duties")
|
|
||||||
) and effective_actor == current.payload.get("requested_by"):
|
|
||||||
raise ApprovalStoreError(
|
|
||||||
"Approval separation of duties prevents requester self-approval."
|
|
||||||
)
|
|
||||||
prior = (
|
|
||||||
typed_session.query(ApprovalDecisionRecord)
|
|
||||||
.filter(
|
|
||||||
ApprovalDecisionRecord.tenant_id == tenant_id,
|
|
||||||
ApprovalDecisionRecord.request_id == request_id,
|
|
||||||
ApprovalDecisionRecord.effective_actor_id == effective_actor,
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
if any(item.step_key == step["key"] for item in prior):
|
|
||||||
raise ApprovalStoreError(
|
|
||||||
"This actor has already decided the current Approval step."
|
|
||||||
)
|
|
||||||
if bool(current.payload.get("unique_actors_across_steps")) and any(
|
|
||||||
item.outcome == "approved" for item in prior
|
|
||||||
):
|
|
||||||
raise ApprovalStoreError(
|
|
||||||
"Approval policy requires a different actor for each step."
|
|
||||||
)
|
|
||||||
evidence_actors = {
|
|
||||||
str(key): {str(actor) for actor in (actors or [])}
|
|
||||||
for key, actors in dict(
|
|
||||||
current.payload.get("evidence_actors") or {}
|
|
||||||
).items()
|
|
||||||
}
|
|
||||||
for role in step.get("forbidden_evidence_roles") or []:
|
|
||||||
if effective_actor in evidence_actors.get(str(role), set()):
|
|
||||||
raise ApprovalStoreError(
|
|
||||||
f"Approval separation of duties prevents the {role} actor from deciding this step."
|
|
||||||
)
|
|
||||||
signature_ref = (
|
signature_ref = (
|
||||||
dict(command.signature_ref) if command.signature_ref is not None else None
|
dict(command.signature_ref) if command.signature_ref is not None else None
|
||||||
)
|
)
|
||||||
@@ -764,6 +730,79 @@ class SqlApprovalRequests:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approval_decision_context(
|
||||||
|
session: Session,
|
||||||
|
principal: object,
|
||||||
|
request: ApprovalRequestRevision,
|
||||||
|
*,
|
||||||
|
delegated_for_account_id: str | None = None,
|
||||||
|
prior_decisions: Sequence[ApprovalDecisionRecord] | None = None,
|
||||||
|
) -> ApprovalDecisionContext:
|
||||||
|
if request.state not in {"pending", "escalated"}:
|
||||||
|
raise ApprovalStoreError("This Approval request no longer accepts decisions.")
|
||||||
|
expires_at = _datetime(request.payload.get("expires_at"))
|
||||||
|
if expires_at is not None and _now() >= expires_at:
|
||||||
|
raise ApprovalStoreError("This Approval request has expired.")
|
||||||
|
steps = list(request.payload.get("steps") or ())
|
||||||
|
step_index = int(request.payload.get("current_step_index") or 0)
|
||||||
|
if step_index < 0 or step_index >= len(steps):
|
||||||
|
raise ApprovalStoreError("The current Approval step is unavailable.")
|
||||||
|
step = steps[step_index]
|
||||||
|
effective_actor = _effective_actor(principal, delegated_for_account_id)
|
||||||
|
matched_selector = _matched_selector(
|
||||||
|
principal,
|
||||||
|
list(step.get("selectors") or ()),
|
||||||
|
effective_actor,
|
||||||
|
)
|
||||||
|
if matched_selector is None:
|
||||||
|
raise ApprovalStoreError(
|
||||||
|
"The current principal is not eligible for this Approval step."
|
||||||
|
)
|
||||||
|
if bool(
|
||||||
|
request.payload.get("separation_of_duties")
|
||||||
|
) and effective_actor == request.payload.get("requested_by"):
|
||||||
|
raise ApprovalStoreError(
|
||||||
|
"Approval separation of duties prevents requester self-approval."
|
||||||
|
)
|
||||||
|
prior = (
|
||||||
|
list(prior_decisions)
|
||||||
|
if prior_decisions is not None
|
||||||
|
else (
|
||||||
|
session.query(ApprovalDecisionRecord)
|
||||||
|
.filter(
|
||||||
|
ApprovalDecisionRecord.tenant_id == request.tenant_id,
|
||||||
|
ApprovalDecisionRecord.request_id == request.request_id,
|
||||||
|
ApprovalDecisionRecord.effective_actor_id == effective_actor,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if any(item.step_key == step["key"] for item in prior):
|
||||||
|
raise ApprovalStoreError(
|
||||||
|
"This actor has already decided the current Approval step."
|
||||||
|
)
|
||||||
|
if bool(request.payload.get("unique_actors_across_steps")) and any(
|
||||||
|
item.outcome == "approved" for item in prior
|
||||||
|
):
|
||||||
|
raise ApprovalStoreError(
|
||||||
|
"Approval policy requires a different actor for each step."
|
||||||
|
)
|
||||||
|
evidence_actors = {
|
||||||
|
str(key): {str(actor) for actor in (actors or [])}
|
||||||
|
for key, actors in dict(request.payload.get("evidence_actors") or {}).items()
|
||||||
|
}
|
||||||
|
for role in step.get("forbidden_evidence_roles") or ():
|
||||||
|
if effective_actor in evidence_actors.get(str(role), set()):
|
||||||
|
raise ApprovalStoreError(
|
||||||
|
f"Approval separation of duties prevents the {role} actor from deciding this step."
|
||||||
|
)
|
||||||
|
return ApprovalDecisionContext(
|
||||||
|
step=step,
|
||||||
|
effective_actor=effective_actor,
|
||||||
|
matched_selector=matched_selector,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _step_payload(step: object) -> dict[str, Any]:
|
def _step_payload(step: object) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"key": str(getattr(step, "key")),
|
"key": str(getattr(step, "key")),
|
||||||
@@ -1383,4 +1422,9 @@ def _now() -> datetime:
|
|||||||
return datetime.now(UTC)
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ApprovalStoreError", "SqlApprovalRequests"]
|
__all__ = [
|
||||||
|
"ApprovalDecisionContext",
|
||||||
|
"ApprovalStoreError",
|
||||||
|
"SqlApprovalRequests",
|
||||||
|
"approval_decision_context",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.tasks import (
|
||||||
|
WorkAssignmentRef,
|
||||||
|
WorkItem,
|
||||||
|
WorkItemPage,
|
||||||
|
WorkItemQuery,
|
||||||
|
WorkSourceRef,
|
||||||
|
)
|
||||||
|
from govoplan_approvals.backend.db.models import (
|
||||||
|
ApprovalDecisionRecord,
|
||||||
|
ApprovalRequestRevision,
|
||||||
|
)
|
||||||
|
from govoplan_approvals.backend.service import (
|
||||||
|
ApprovalStoreError,
|
||||||
|
approval_decision_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER_ID = "approvals.pending"
|
||||||
|
READ_SCOPE = "approvals:workspace:read"
|
||||||
|
DECIDE_SCOPE = "approvals:workspace:decide"
|
||||||
|
|
||||||
|
|
||||||
|
class ApprovalWorkItemProvider:
|
||||||
|
def list_items(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: WorkItemQuery,
|
||||||
|
) -> WorkItemPage:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Approval work aggregation requires a SQLAlchemy Session.")
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
if principal.tenant_id != query.tenant_id:
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
if not has_scope(principal, READ_SCOPE) or not has_scope(
|
||||||
|
principal, DECIDE_SCOPE
|
||||||
|
):
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
if query.statuses and "open" not in query.statuses:
|
||||||
|
return WorkItemPage(items=(), total=0)
|
||||||
|
|
||||||
|
rows = list(
|
||||||
|
session.scalars(
|
||||||
|
select(ApprovalRequestRevision)
|
||||||
|
.where(
|
||||||
|
ApprovalRequestRevision.tenant_id == query.tenant_id,
|
||||||
|
ApprovalRequestRevision.superseded_at.is_(None),
|
||||||
|
ApprovalRequestRevision.state.in_(("pending", "escalated")),
|
||||||
|
)
|
||||||
|
.order_by(
|
||||||
|
ApprovalRequestRevision.recorded_at.asc(),
|
||||||
|
ApprovalRequestRevision.request_id.asc(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
prior_by_request: dict[str, list[ApprovalDecisionRecord]] = defaultdict(list)
|
||||||
|
if rows and principal.account_id:
|
||||||
|
decisions = session.scalars(
|
||||||
|
select(ApprovalDecisionRecord).where(
|
||||||
|
ApprovalDecisionRecord.tenant_id == query.tenant_id,
|
||||||
|
ApprovalDecisionRecord.effective_actor_id == principal.account_id,
|
||||||
|
ApprovalDecisionRecord.request_id.in_(
|
||||||
|
tuple(row.request_id for row in rows)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for decision in decisions:
|
||||||
|
prior_by_request[decision.request_id].append(decision)
|
||||||
|
|
||||||
|
items: list[WorkItem] = []
|
||||||
|
total = 0
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
context = approval_decision_context(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
row,
|
||||||
|
prior_decisions=prior_by_request.get(row.request_id, ()),
|
||||||
|
)
|
||||||
|
except ApprovalStoreError:
|
||||||
|
continue
|
||||||
|
item = _work_item(row, context.step, now=now)
|
||||||
|
if query.priorities and item.priority not in query.priorities:
|
||||||
|
continue
|
||||||
|
if query.due_before is not None and (
|
||||||
|
item.due_at is None or _aware(item.due_at) > _aware(query.due_before)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if query.text and query.text.casefold() not in _search_text(item):
|
||||||
|
continue
|
||||||
|
total += 1
|
||||||
|
if len(items) < query.limit:
|
||||||
|
items.append(item)
|
||||||
|
items.sort(key=_sort_key)
|
||||||
|
return WorkItemPage(
|
||||||
|
items=tuple(items),
|
||||||
|
total=total,
|
||||||
|
truncated=total > len(items),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _work_item(
|
||||||
|
row: ApprovalRequestRevision,
|
||||||
|
step: Mapping[str, object],
|
||||||
|
*,
|
||||||
|
now: datetime,
|
||||||
|
) -> WorkItem:
|
||||||
|
payload = dict(row.payload or {})
|
||||||
|
due_at = _date(step.get("due_at")) or _date(payload.get("expires_at"))
|
||||||
|
priority = "high" if row.state == "escalated" else "normal"
|
||||||
|
if due_at is not None and _aware(due_at) < now:
|
||||||
|
priority = "urgent"
|
||||||
|
title = str(payload.get("title") or "Approval required").strip()
|
||||||
|
step_label = str(step.get("label") or row.current_step_key or "Decide").strip()
|
||||||
|
action_url = f"/approvals?request={quote(row.request_id, safe='')}"
|
||||||
|
return WorkItem(
|
||||||
|
id=row.request_id,
|
||||||
|
provider_id=PROVIDER_ID,
|
||||||
|
owner_module="approvals",
|
||||||
|
tenant_id=row.tenant_id,
|
||||||
|
title=title,
|
||||||
|
summary=(
|
||||||
|
str(payload.get("description") or "").strip()
|
||||||
|
or f"{row.subject_module}: {row.subject_type}"
|
||||||
|
),
|
||||||
|
status="open",
|
||||||
|
priority=priority, # type: ignore[arg-type]
|
||||||
|
required_action=step_label,
|
||||||
|
action_url=action_url,
|
||||||
|
due_at=due_at,
|
||||||
|
assignments=_assignments(step),
|
||||||
|
sources=(
|
||||||
|
WorkSourceRef(
|
||||||
|
module_id=row.subject_module,
|
||||||
|
resource_type=row.subject_type,
|
||||||
|
resource_id=row.subject_id,
|
||||||
|
revision=row.subject_version,
|
||||||
|
),
|
||||||
|
WorkSourceRef(
|
||||||
|
module_id="approvals",
|
||||||
|
resource_type="approval_request",
|
||||||
|
resource_id=row.request_id,
|
||||||
|
revision=str(row.revision),
|
||||||
|
url=action_url,
|
||||||
|
label=title,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
provenance={
|
||||||
|
"subject_digest": row.subject_digest,
|
||||||
|
"policy_refs": list(payload.get("policy_refs") or ()),
|
||||||
|
"template": payload.get("template"),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"current_step_key": row.current_step_key,
|
||||||
|
"signature_required": bool(step.get("signature_required")),
|
||||||
|
"approval_state": row.state,
|
||||||
|
},
|
||||||
|
revision=str(row.revision),
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignments(step: Mapping[str, object]) -> tuple[WorkAssignmentRef, ...]:
|
||||||
|
assignments: list[WorkAssignmentRef] = []
|
||||||
|
for raw in step.get("selectors") or ():
|
||||||
|
if not isinstance(raw, Mapping):
|
||||||
|
continue
|
||||||
|
kind = str(raw.get("kind") or "").strip()
|
||||||
|
assignment_id = str(raw.get("value") or "").strip()
|
||||||
|
if kind == "any_account":
|
||||||
|
kind, assignment_id = "anyone", "*"
|
||||||
|
if kind not in {"account", "group", "role", "function_assignment"}:
|
||||||
|
if kind != "anyone":
|
||||||
|
continue
|
||||||
|
label = str(raw.get("label") or "").strip()[:500] or None
|
||||||
|
try:
|
||||||
|
assignments.append(
|
||||||
|
WorkAssignmentRef(
|
||||||
|
kind=kind, # type: ignore[arg-type]
|
||||||
|
id=assignment_id,
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return tuple(assignments)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: object) -> datetime | None:
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value: datetime) -> datetime:
|
||||||
|
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_text(item: WorkItem) -> str:
|
||||||
|
return " ".join(
|
||||||
|
value for value in (item.title, item.summary, item.required_action) if value
|
||||||
|
).casefold()
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key(item: WorkItem) -> tuple[object, ...]:
|
||||||
|
priority = {"urgent": 0, "high": 1, "normal": 2, "low": 3}[item.priority]
|
||||||
|
due_at = _aware(item.due_at) if item.due_at else datetime.max.replace(tzinfo=UTC)
|
||||||
|
return priority, due_at, item.id
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ApprovalWorkItemProvider", "PROVIDER_ID"]
|
||||||
@@ -7,6 +7,8 @@ import unittest
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.core.approvals import (
|
from govoplan_core.core.approvals import (
|
||||||
ApprovalActorSelector,
|
ApprovalActorSelector,
|
||||||
ApprovalDecisionCommand,
|
ApprovalDecisionCommand,
|
||||||
@@ -16,6 +18,8 @@ from govoplan_core.core.approvals import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
|
from govoplan_approvals.backend.service import ApprovalStoreError, SqlApprovalRequests
|
||||||
|
from govoplan_core.core.tasks import WorkItemQuery
|
||||||
|
from govoplan_approvals.backend.work_items import ApprovalWorkItemProvider
|
||||||
|
|
||||||
|
|
||||||
DIGEST = "a" * 64
|
DIGEST = "a" * 64
|
||||||
@@ -241,6 +245,62 @@ class ApprovalRuntimeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual("escalated", escalated.state)
|
self.assertEqual("escalated", escalated.state)
|
||||||
|
|
||||||
|
def test_work_provider_reuses_exact_decision_eligibility(self) -> None:
|
||||||
|
with self.Session() as session:
|
||||||
|
created = self.service.create_request(
|
||||||
|
session,
|
||||||
|
self.requester,
|
||||||
|
command=request_command(),
|
||||||
|
idempotency_key="work-provider-request",
|
||||||
|
)
|
||||||
|
reviewer = ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="reviewer",
|
||||||
|
membership_id="membership-reviewer",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(
|
||||||
|
{
|
||||||
|
"approvals:workspace:read",
|
||||||
|
"approvals:workspace:decide",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
group_ids=frozenset({"reviewers"}),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
provider = ApprovalWorkItemProvider()
|
||||||
|
|
||||||
|
page = provider.list_items(
|
||||||
|
session,
|
||||||
|
reviewer,
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, page.total)
|
||||||
|
self.assertEqual(created.id, page.items[0].id)
|
||||||
|
self.assertEqual("Review", page.items[0].required_action)
|
||||||
|
self.assertEqual("campaign_version", page.items[0].sources[0].resource_type)
|
||||||
|
|
||||||
|
self.service.decide(
|
||||||
|
session,
|
||||||
|
reviewer,
|
||||||
|
request_id=created.id,
|
||||||
|
command=ApprovalDecisionCommand(
|
||||||
|
"approved",
|
||||||
|
"Reviewed.",
|
||||||
|
1,
|
||||||
|
"work-provider-decision",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
provider.list_items(
|
||||||
|
session,
|
||||||
|
reviewer,
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||||
|
).total,
|
||||||
|
)
|
||||||
|
|
||||||
def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
|
def test_template_and_evidence_role_constraints_are_frozen(self) -> None:
|
||||||
template_command = ApprovalTemplateCreateCommand(
|
template_command = ApprovalTemplateCreateCommand(
|
||||||
key="campaign-release",
|
key="campaign-release",
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ class ApprovalsInterfaceDocumentationContractTests(unittest.TestCase):
|
|||||||
templates = topics["approvals.workflow.administer-templates"]
|
templates = topics["approvals.workflow.administer-templates"]
|
||||||
self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
|
self.assertIn("approvals.workspace", guide.metadata["help_contexts"])
|
||||||
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
|
||||||
self.assertIn("approvals.field.subject-digest", reference.metadata["help_contexts"])
|
self.assertIn(
|
||||||
|
"approvals.field.subject-digest", reference.metadata["help_contexts"]
|
||||||
|
)
|
||||||
self.assertIn("create_request", reference.metadata["consequence_classes"])
|
self.assertIn("create_request", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("reject_request", reference.metadata["consequence_classes"])
|
self.assertIn("reject_request", reference.metadata["consequence_classes"])
|
||||||
self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"])
|
self.assertIn("approvals.admin.templates", templates.metadata["help_contexts"])
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ManifestTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(manifest.route_factory)
|
self.assertIsNotNone(manifest.route_factory)
|
||||||
self.assertIsNotNone(manifest.migration_spec)
|
self.assertIsNotNone(manifest.migration_spec)
|
||||||
self.assertIsNotNone(manifest.frontend)
|
self.assertIsNotNone(manifest.frontend)
|
||||||
|
self.assertEqual("approvals.pending", manifest.work_item_providers[0].id)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+2
-2
@@ -28,9 +28,9 @@ const approvalsAdminSections: AdminSectionsUiCapability = {
|
|||||||
export const approvalsModule: PlatformWebModule = {
|
export const approvalsModule: PlatformWebModule = {
|
||||||
id: "approvals",
|
id: "approvals",
|
||||||
label: "i18n:govoplan-approvals.approvals",
|
label: "i18n:govoplan-approvals.approvals",
|
||||||
version: "0.1.14",
|
version: "0.1.18",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy"],
|
optionalDependencies: ["workflow_engine", "audit", "files", "notifications", "policy", "tasks"],
|
||||||
translations: generatedTranslations,
|
translations: generatedTranslations,
|
||||||
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
|
routes: [{ path: "/approvals", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.workspace", render: (context) => createElement(ApprovalsPage, context) }],
|
||||||
navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
|
navItems: [{ to: "/approvals", label: "i18n:govoplan-approvals.approvals", iconName: "list-checks", anyOf: ["approvals:workspace:read"], order: 37, surfaceId: "approvals.navigation" }],
|
||||||
|
|||||||
Reference in New Issue
Block a user