From a9035c4c3b7d3c55e2251ec5985808d1e464f048 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 02:14:34 +0200 Subject: [PATCH] feat: add Campaign work orchestration contract --- pyproject.toml | 2 +- src/govoplan_core/core/campaigns.py | 140 +++++++++++++++++++++++++++- tests/test_access_contracts.py | 78 ++++++++++++++++ webui/package-lock.json | 4 +- webui/package.json | 2 +- 5 files changed, 221 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c16f0d..c1b7712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.27" +version = "0.1.28" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/campaigns.py b/src/govoplan_core/core/campaigns.py index 48d9e86..420459a 100644 --- a/src/govoplan_core/core/campaigns.py +++ b/src/govoplan_core/core/campaigns.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field from datetime import datetime -from typing import Protocol, runtime_checkable +from typing import Literal, Protocol, runtime_checkable CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext" @@ -12,6 +12,20 @@ CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext" CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks" CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules" CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention" +CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration" + +CampaignWorkAssigneeKind = Literal[ + "account", + "group", + "organization_function", +] +CampaignWorkHandoffStatus = Literal[ + "open", + "in_progress", + "completed", + "rejected", + "cancelled", +] @dataclass(frozen=True, slots=True) @@ -32,6 +46,88 @@ class CampaignPolicyContext: settings: Mapping[str, object] = field(default_factory=dict) +@dataclass(frozen=True, slots=True) +class CampaignWorkHandoffRequest: + """Typed request used by Workflow to open accountable Campaign work.""" + + tenant_id: str + idempotency_key: str + purpose: str + assignee_kind: CampaignWorkAssigneeKind + assignee_id: str + campaign_id: str | None = None + create_external_id: str | None = None + create_name: str | None = None + create_description: str | None = None + expected_campaign_revision: int | None = None + due_at: datetime | None = None + mirror_to_tasks: bool = True + correlation_id: str | None = None + workflow_instance_id: str | None = None + workflow_step_id: str | None = None + + def __post_init__(self) -> None: + for value, label in ( + (self.tenant_id, "Campaign hand-off tenant"), + (self.idempotency_key, "Campaign hand-off idempotency key"), + (self.purpose, "Campaign hand-off purpose"), + (self.assignee_id, "Campaign hand-off assignee"), + ): + if not value.strip(): + raise ValueError(f"{label} is required") + references_existing = bool(self.campaign_id and self.campaign_id.strip()) + creates_new = bool( + self.create_external_id + and self.create_external_id.strip() + and self.create_name + and self.create_name.strip() + ) + if references_existing == creates_new: + raise ValueError( + "Campaign hand-offs must either reference one campaign or " + "declare one new campaign." + ) + if self.expected_campaign_revision is not None and ( + self.expected_campaign_revision < 1 + ): + raise ValueError("Expected Campaign revisions start at one") + if self.due_at is not None and self.due_at.tzinfo is None: + raise ValueError("Campaign hand-off due dates require a timezone") + + +@dataclass(frozen=True, slots=True) +class CampaignWorkHandoffRef: + """Stable, revision-bearing reference returned to the Workflow instance.""" + + tenant_id: str + campaign_id: str + campaign_version_id: str + campaign_revision: int + assignment_id: str + assignment_revision: int + status: CampaignWorkHandoffStatus + action_url: str + campaign_ref: str + assignment_ref: str + event_type: str = "campaign.work.changed" + replayed: bool = False + optional_capabilities: Mapping[str, bool] = field(default_factory=dict) + provenance: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class CampaignWorkHandoffInspection: + """Current authorization and revision check before Workflow continuation.""" + + allowed: bool + status: CampaignWorkHandoffStatus | None = None + assignment_revision: int | None = None + action_url: str | None = None + assignment_ref: str | None = None + reason: str | None = None + provenance: Mapping[str, object] = field(default_factory=dict) + + @runtime_checkable class CampaignMailPolicyContextProvider(Protocol): def get_campaign_mail_policy_context( @@ -132,3 +228,45 @@ class CampaignRetentionProvider(Protocol): policy_for_campaign_id: Callable[[str | None], object], ) -> Mapping[str, Mapping[str, int]]: ... + + +@runtime_checkable +class CampaignWorkOrchestrationProvider(Protocol): + """Optional Campaign boundary for durable Workflow-owned hand-offs.""" + + def prepare_handoff( + self, + session: object, + principal: object, + *, + request: CampaignWorkHandoffRequest, + ) -> CampaignWorkHandoffRef: + ... + + def inspect_handoff( + self, + session: object, + principal: object, + *, + tenant_id: str, + assignment_id: str, + expected_revision: int | None = None, + ) -> CampaignWorkHandoffInspection: + ... + + +def campaign_work_orchestration_provider( + registry: object | None, +) -> CampaignWorkOrchestrationProvider | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION) + ): + return None + capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION) + return ( + capability + if isinstance(capability, CampaignWorkOrchestrationProvider) + else None + ) diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index f44a97d..64dfdf2 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -71,6 +71,7 @@ from govoplan_core.core.campaigns import ( CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_RETENTION, + CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION, CampaignAccessProvider, CampaignDeliveryTaskProvider, CampaignMailPolicyContext, @@ -78,6 +79,10 @@ from govoplan_core.core.campaigns import ( CampaignPolicyContext, CampaignPolicyContextProvider, CampaignRetentionProvider, + CampaignWorkHandoffInspection, + CampaignWorkHandoffRef, + CampaignWorkHandoffRequest, + CampaignWorkOrchestrationProvider, ) from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider from govoplan_core.core.modules import ModuleContext, ModuleManifest @@ -464,6 +469,40 @@ class _FakeCampaignRetentionProvider: return {"raw_campaign_json": {"eligible": int(dry_run)}} +class _FakeCampaignWorkOrchestrationProvider: + def prepare_handoff(self, session: object, principal: object, *, request): + del session, principal + return CampaignWorkHandoffRef( + tenant_id=request.tenant_id, + campaign_id=request.campaign_id or "campaign-created", + campaign_version_id="campaign-version-1", + campaign_revision=1, + assignment_id="assignment-1", + assignment_revision=1, + status="open", + action_url="/campaigns/campaign-1/work?assignment=assignment-1", + campaign_ref="campaign:campaign-1:version:campaign-version-1:r1", + assignment_ref="campaign-work-assignment:assignment-1:r1", + ) + + def inspect_handoff( + self, + session: object, + principal: object, + *, + tenant_id: str, + assignment_id: str, + expected_revision: int | None = None, + ): + del session, principal, tenant_id, assignment_id + return CampaignWorkHandoffInspection( + allowed=expected_revision in {None, 1}, + status="open", + assignment_revision=1, + assignment_ref="campaign-work-assignment:assignment-1:r1", + ) + + class _FakeSecretProvider: def __init__(self) -> None: self._values: dict[str, str] = {} @@ -528,6 +567,10 @@ class AccessContractTests(unittest.TestCase): self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT) self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT) self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION) + self.assertEqual( + "campaigns.workOrchestration", + CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION, + ) self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER) self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER) self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK) @@ -642,6 +685,10 @@ class AccessContractTests(unittest.TestCase): self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider) self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider) self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider) + self.assertIsInstance( + _FakeCampaignWorkOrchestrationProvider(), + CampaignWorkOrchestrationProvider, + ) self.assertIsInstance(_FakeSecretProvider(), SecretProvider) self.assertIsInstance(_FakeAuditSink(), AuditSink) self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder) @@ -676,6 +723,37 @@ class AccessContractTests(unittest.TestCase): self.assertEqual({"job_id": "job-1", "status": "appended"}, delivery_provider.append_sent_for_job(object(), job_id="job-1")) self.assertEqual({"raw_campaign_json": {"eligible": 1}}, retention_provider.apply_retention(object(), dry_run=True, now=object(), policy_for_campaign_id=lambda campaign_id: object())) + def test_campaign_work_handoff_contract_requires_one_campaign_source(self) -> None: + request = CampaignWorkHandoffRequest( + tenant_id="tenant-1", + campaign_id="campaign-1", + idempotency_key="workflow-step-1", + purpose="Review the campaign", + assignee_kind="account", + assignee_id="account-1", + ) + provider = _FakeCampaignWorkOrchestrationProvider() + + handoff = provider.prepare_handoff(object(), object(), request=request) + inspection = provider.inspect_handoff( + object(), + object(), + tenant_id="tenant-1", + assignment_id=handoff.assignment_id, + expected_revision=handoff.assignment_revision, + ) + + self.assertEqual("campaign-1", handoff.campaign_id) + self.assertTrue(inspection.allowed) + with self.assertRaisesRegex(ValueError, "either reference one campaign"): + CampaignWorkHandoffRequest( + tenant_id="tenant-1", + idempotency_key="workflow-step-2", + purpose="Review", + assignee_kind="account", + assignee_id="account-1", + ) + def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None: directory = _FakeAccessDirectory() semantic_directory = _FakeAccessSemanticDirectory() diff --git a/webui/package-lock.json b/webui/package-lock.json index a4dd85c..3d3f6ec 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.27", + "version": "0.1.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.27", + "version": "0.1.28", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", diff --git a/webui/package.json b/webui/package.json index 90f8cfa..24e0070 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.27", + "version": "0.1.28", "private": true, "type": "module", "main": "src/index.ts",