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
|
||||
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.
|
||||
|
||||
@@ -26,6 +26,7 @@ from govoplan_core.core.modules import (
|
||||
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
|
||||
@@ -39,7 +40,14 @@ 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 +74,12 @@ def _requests(_context: ModuleContext) -> 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]:
|
||||
current = session.query(approval_models.ApprovalRequestRevision).filter(
|
||||
approval_models.ApprovalRequestRevision.tenant_id == tenant_id,
|
||||
@@ -196,6 +210,13 @@ manifest = ModuleManifest(
|
||||
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,
|
||||
@@ -230,6 +251,7 @@ manifest = ModuleManifest(
|
||||
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. "
|
||||
"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",
|
||||
documentation_types=("admin", "user"),
|
||||
@@ -312,10 +334,24 @@ manifest = ModuleManifest(
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("operator", "module_admin", "product_owner", "auditor"),
|
||||
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={
|
||||
"help_contexts": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -26,7 +26,9 @@ 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"])
|
||||
|
||||
@@ -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__":
|
||||
|
||||
+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.18",
|
||||
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" }],
|
||||
|
||||
Reference in New Issue
Block a user