feat: add Campaign work orchestration contract
Module Package Release / publish-packages (push) Failing after 6s
Module Package Release / publish-packages (push) Failing after 6s
This commit is contained in:
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-core"
|
name = "govoplan-core"
|
||||||
version = "0.1.27"
|
version = "0.1.28"
|
||||||
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Iterable, Mapping
|
from collections.abc import Callable, Iterable, Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
|
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_DELIVERY_TASKS = "campaigns.deliveryTasks"
|
||||||
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
|
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -32,6 +46,88 @@ class CampaignPolicyContext:
|
|||||||
settings: Mapping[str, object] = field(default_factory=dict)
|
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
|
@runtime_checkable
|
||||||
class CampaignMailPolicyContextProvider(Protocol):
|
class CampaignMailPolicyContextProvider(Protocol):
|
||||||
def get_campaign_mail_policy_context(
|
def get_campaign_mail_policy_context(
|
||||||
@@ -132,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
|
|||||||
policy_for_campaign_id: Callable[[str | None], object],
|
policy_for_campaign_id: Callable[[str | None], object],
|
||||||
) -> Mapping[str, Mapping[str, int]]:
|
) -> 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
|
||||||
|
)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from govoplan_core.core.campaigns import (
|
|||||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
CampaignAccessProvider,
|
CampaignAccessProvider,
|
||||||
CampaignDeliveryTaskProvider,
|
CampaignDeliveryTaskProvider,
|
||||||
CampaignMailPolicyContext,
|
CampaignMailPolicyContext,
|
||||||
@@ -78,6 +79,10 @@ from govoplan_core.core.campaigns import (
|
|||||||
CampaignPolicyContext,
|
CampaignPolicyContext,
|
||||||
CampaignPolicyContextProvider,
|
CampaignPolicyContextProvider,
|
||||||
CampaignRetentionProvider,
|
CampaignRetentionProvider,
|
||||||
|
CampaignWorkHandoffInspection,
|
||||||
|
CampaignWorkHandoffRef,
|
||||||
|
CampaignWorkHandoffRequest,
|
||||||
|
CampaignWorkOrchestrationProvider,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
|
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
@@ -464,6 +469,40 @@ class _FakeCampaignRetentionProvider:
|
|||||||
return {"raw_campaign_json": {"eligible": int(dry_run)}}
|
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:
|
class _FakeSecretProvider:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._values: dict[str, str] = {}
|
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.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
|
||||||
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
|
||||||
self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION)
|
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("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
|
||||||
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
|
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
|
||||||
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
|
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
|
||||||
@@ -642,6 +685,10 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider)
|
self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider)
|
||||||
self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider)
|
self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider)
|
||||||
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
|
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
|
||||||
|
self.assertIsInstance(
|
||||||
|
_FakeCampaignWorkOrchestrationProvider(),
|
||||||
|
CampaignWorkOrchestrationProvider,
|
||||||
|
)
|
||||||
self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
|
self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
|
||||||
self.assertIsInstance(_FakeAuditSink(), AuditSink)
|
self.assertIsInstance(_FakeAuditSink(), AuditSink)
|
||||||
self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder)
|
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({"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()))
|
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:
|
def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None:
|
||||||
directory = _FakeAccessDirectory()
|
directory = _FakeAccessDirectory()
|
||||||
semantic_directory = _FakeAccessSemanticDirectory()
|
semantic_directory = _FakeAccessSemanticDirectory()
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.27",
|
"version": "0.1.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.27",
|
"version": "0.1.28",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/core-webui",
|
"name": "@govoplan/core-webui",
|
||||||
"version": "0.1.27",
|
"version": "0.1.28",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user