Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd24f9b5b | ||
|
|
4f52f010ee | ||
|
|
2630498026 |
@@ -138,6 +138,35 @@ Notifications. The thread displays only human discussion; approvals, workflow
|
|||||||
state, delivery events, and durable system evidence remain on their owning
|
state, delivery events, and durable system evidence remain on their owning
|
||||||
surfaces and in Tenant audit.
|
surfaces and in Tenant audit.
|
||||||
|
|
||||||
|
### Assign accountable campaign work
|
||||||
|
|
||||||
|
Open **Work** to assign one bounded purpose to an account, group, or
|
||||||
|
organization function that already has Campaign access. Assignment records
|
||||||
|
responsibility only: it never creates a share, transfers ownership, or grants a
|
||||||
|
permission. Assignees may accept, complete, or reject their work; rejection is
|
||||||
|
distinct from administrative cancellation. Managers may reassign or cancel
|
||||||
|
open work, and every transition retains the expected revision, actor snapshot,
|
||||||
|
typed target, and append-only event history.
|
||||||
|
|
||||||
|
Workflow may create or reference a Campaign and open the same assignment through
|
||||||
|
the optional `campaigns.workOrchestration` capability. Those assignments pin the
|
||||||
|
Campaign version and store the Workflow instance, step, correlation, and
|
||||||
|
idempotency provenance. Campaign emits `campaign.work.changed` for assignment,
|
||||||
|
acceptance, start, reassignment, completion, rejection, and cancellation.
|
||||||
|
Workflow uses the assignment ID and event revision, rechecks current Campaign
|
||||||
|
access, and then resumes the matching durable external hand-off without browser
|
||||||
|
polling. A missing Tasks or Notifications capability only removes the optional
|
||||||
|
projection or notification. A missing Campaign provider, revoked Campaign
|
||||||
|
access, or stale event revision keeps the Workflow blocked and inspectable.
|
||||||
|
|
||||||
|
Campaign also contributes the opt-in **Accountable Campaign work hand-off**
|
||||||
|
Workflow template. It is deliberately not activated on installation. A
|
||||||
|
configurator must copy or activate it and supply either `campaign_id` or
|
||||||
|
`create_campaign`; unused optional input keys must be present with `null`
|
||||||
|
values. The template prepares the assignment idempotently, opens the exact
|
||||||
|
Campaign work URL, and waits for completion, rejection, cancellation, or the
|
||||||
|
configured timeout. Opening the link never completes the Workflow.
|
||||||
|
|
||||||
### Prepare a campaign
|
### Prepare a campaign
|
||||||
|
|
||||||
1. Create a campaign and confirm its owner or owning group.
|
1. Create a campaign and confirm its owner or owning group.
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.20"
|
version = "0.1.23"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.28",
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
CampaignMessageActionAttempt,
|
CampaignMessageActionAttempt,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
@@ -48,6 +50,9 @@ READ_ACTIONS = {
|
|||||||
"campaigns:discussion:read",
|
"campaigns:discussion:read",
|
||||||
"campaigns:discussion:post",
|
"campaigns:discussion:post",
|
||||||
"campaigns:discussion:moderate",
|
"campaigns:discussion:moderate",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
}
|
}
|
||||||
CAMPAIGN_RESOURCE_TYPES = {
|
CAMPAIGN_RESOURCE_TYPES = {
|
||||||
"campaign",
|
"campaign",
|
||||||
@@ -62,6 +67,14 @@ CAMPAIGN_COLLABORATION_RESOURCE_TYPES = {
|
|||||||
"campaign_collaboration_entry",
|
"campaign_collaboration_entry",
|
||||||
"campaigns:collaboration_entry",
|
"campaigns:collaboration_entry",
|
||||||
}
|
}
|
||||||
|
CAMPAIGN_WORK_ASSIGNMENT_RESOURCE_TYPES = {
|
||||||
|
"campaign_work_assignment",
|
||||||
|
"campaigns:work_assignment",
|
||||||
|
}
|
||||||
|
CAMPAIGN_WORK_ASSIGNMENT_EVENT_RESOURCE_TYPES = {
|
||||||
|
"campaign_work_assignment_event",
|
||||||
|
"campaigns:work_assignment_event",
|
||||||
|
}
|
||||||
CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES = {
|
CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES = {
|
||||||
"campaign_delivery_job",
|
"campaign_delivery_job",
|
||||||
"campaign_job",
|
"campaign_job",
|
||||||
@@ -615,6 +628,85 @@ class CampaignAccessService(CampaignAccessProvider):
|
|||||||
},
|
},
|
||||||
required_actions=required_actions,
|
required_actions=required_actions,
|
||||||
)
|
)
|
||||||
|
elif normalized_type in CAMPAIGN_WORK_ASSIGNMENT_RESOURCE_TYPES:
|
||||||
|
assignment = session.get(CampaignWorkAssignment, resource_id) # type: ignore[attr-defined]
|
||||||
|
if assignment is None:
|
||||||
|
return _missing_resource_provenance(
|
||||||
|
principal,
|
||||||
|
resource_type="campaign_work_assignment",
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
campaign = session.get(Campaign, assignment.campaign_id) # type: ignore[attr-defined]
|
||||||
|
normalized_action = action.strip().lower()
|
||||||
|
required_actions = (
|
||||||
|
("campaigns:assignment:manage",)
|
||||||
|
if normalized_action.endswith(":manage")
|
||||||
|
else (
|
||||||
|
("campaigns:assignment:complete",)
|
||||||
|
if normalized_action.endswith(":complete")
|
||||||
|
else ("campaigns:assignment:read",)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
child_item = _child_provenance(
|
||||||
|
principal,
|
||||||
|
resource_id=assignment.id,
|
||||||
|
source="campaigns.work_assignment",
|
||||||
|
label="Campaign work assignment",
|
||||||
|
campaign=campaign,
|
||||||
|
version_id=assignment.campaign_version_id,
|
||||||
|
details={
|
||||||
|
"resource_type": "campaign_work_assignment",
|
||||||
|
"status": assignment.status,
|
||||||
|
"due_at": _iso_value(assignment.due_at),
|
||||||
|
"assignee_type": assignment.assignee_type,
|
||||||
|
"assignee_resolution_state": assignment.assignee_resolution_state,
|
||||||
|
"reference_kind": assignment.reference_kind,
|
||||||
|
"reference_id": assignment.reference_id,
|
||||||
|
"revision": assignment.resource_revision,
|
||||||
|
"purpose_disclosed": False,
|
||||||
|
"assignee_identity_disclosed": False,
|
||||||
|
"authorization_mode": "accountability_does_not_grant_access",
|
||||||
|
"assignment_policy_provenance": dict(
|
||||||
|
assignment.resolution_provenance or {}
|
||||||
|
),
|
||||||
|
"permission_classes": {
|
||||||
|
"read": ["campaigns:assignment:read"],
|
||||||
|
"complete": ["campaigns:assignment:complete"],
|
||||||
|
"manage": ["campaigns:assignment:manage"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required_actions=required_actions,
|
||||||
|
)
|
||||||
|
elif normalized_type in CAMPAIGN_WORK_ASSIGNMENT_EVENT_RESOURCE_TYPES:
|
||||||
|
event = session.get(CampaignWorkAssignmentEvent, resource_id) # type: ignore[attr-defined]
|
||||||
|
if event is None:
|
||||||
|
return _missing_resource_provenance(
|
||||||
|
principal,
|
||||||
|
resource_type="campaign_work_assignment_event",
|
||||||
|
resource_id=resource_id,
|
||||||
|
)
|
||||||
|
assignment = session.get(CampaignWorkAssignment, event.assignment_id) # type: ignore[attr-defined]
|
||||||
|
campaign = session.get(Campaign, event.campaign_id) # type: ignore[attr-defined]
|
||||||
|
child_item = _child_provenance(
|
||||||
|
principal,
|
||||||
|
resource_id=event.id,
|
||||||
|
source="campaigns.work_assignment_event",
|
||||||
|
label="Campaign work assignment event",
|
||||||
|
campaign=campaign,
|
||||||
|
version_id=assignment.campaign_version_id if assignment else None,
|
||||||
|
details={
|
||||||
|
"resource_type": "campaign_work_assignment_event",
|
||||||
|
"assignment_id": event.assignment_id,
|
||||||
|
"event_kind": event.event_kind,
|
||||||
|
"status": event.status_snapshot,
|
||||||
|
"assignee_type": event.assignee_type_snapshot,
|
||||||
|
"resolution_state": event.resolution_state_snapshot,
|
||||||
|
"event_details_disclosed": False,
|
||||||
|
"assignee_identity_disclosed": False,
|
||||||
|
"authorization_mode": "immutable_accountability_evidence",
|
||||||
|
},
|
||||||
|
required_actions=("campaigns:assignment:read",),
|
||||||
|
)
|
||||||
elif normalized_type in CAMPAIGN_SHARE_RESOURCE_TYPES:
|
elif normalized_type in CAMPAIGN_SHARE_RESOURCE_TYPES:
|
||||||
share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined]
|
share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined]
|
||||||
if share is None:
|
if share is None:
|
||||||
|
|||||||
@@ -223,6 +223,116 @@ class CampaignCollaborationEntry(Base, TimestampMixin):
|
|||||||
tombstone_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
tombstone_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignment(Base, TimestampMixin):
|
||||||
|
__tablename__ = "campaign_work_assignments"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"orchestration_idempotency_key",
|
||||||
|
name="uq_campaign_work_assignment_orchestration_key",
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_campaign_work_assignments_campaign_status",
|
||||||
|
"tenant_id",
|
||||||
|
"campaign_id",
|
||||||
|
"status",
|
||||||
|
"due_at",
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
campaign_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
campaign_version_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||||
|
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
purpose: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
|
||||||
|
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
assignee_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
assignee_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
assignee_current_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
assignee_resolution_state: Mapped[str] = mapped_column(
|
||||||
|
String(30), default="resolved", nullable=False, index=True
|
||||||
|
)
|
||||||
|
resolution_provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
resolution_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
assigned_by_user_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
assigned_by_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
task_mirror_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
task_mirror_status: Mapped[str] = mapped_column(
|
||||||
|
String(30), default="not_configured", nullable=False
|
||||||
|
)
|
||||||
|
task_mirror_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
task_mirrored_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
orchestration_idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, index=True
|
||||||
|
)
|
||||||
|
orchestration_request_sha256: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True
|
||||||
|
)
|
||||||
|
orchestration_correlation_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(128), nullable=True, index=True
|
||||||
|
)
|
||||||
|
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
workflow_step_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentEvent(Base, TimestampMixin):
|
||||||
|
__tablename__ = "campaign_work_assignment_events"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_campaign_work_assignment_events_history",
|
||||||
|
"tenant_id",
|
||||||
|
"assignment_id",
|
||||||
|
"created_at",
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||||
|
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||||
|
campaign_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
assignment_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("campaign_work_assignments.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||||
|
actor_user_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
assignee_type_snapshot: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
assignee_id_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
resolution_state_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
class CampaignSchedule(Base, TimestampMixin):
|
class CampaignSchedule(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_schedules"
|
__tablename__ = "campaign_schedules"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -856,6 +966,8 @@ __all__ = [
|
|||||||
"CampaignVersion",
|
"CampaignVersion",
|
||||||
"CampaignVersionFlow",
|
"CampaignVersionFlow",
|
||||||
"CampaignVersionWorkflowState",
|
"CampaignVersionWorkflowState",
|
||||||
|
"CampaignWorkAssignment",
|
||||||
|
"CampaignWorkAssignmentEvent",
|
||||||
"ImapAppendAttempt",
|
"ImapAppendAttempt",
|
||||||
"IssueSeverity",
|
"IssueSeverity",
|
||||||
"JobBuildStatus",
|
"JobBuildStatus",
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ _CAMPAIGN_USER_SCOPES = (
|
|||||||
"campaigns:discussion:read",
|
"campaigns:discussion:read",
|
||||||
"campaigns:discussion:post",
|
"campaigns:discussion:post",
|
||||||
"campaigns:discussion:moderate",
|
"campaigns:discussion:moderate",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:recipient:write",
|
"campaigns:recipient:write",
|
||||||
"campaigns:recipient:import",
|
"campaigns:recipient:import",
|
||||||
@@ -53,6 +56,9 @@ _TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
|
|||||||
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
||||||
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
||||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||||
|
_TASKS_INTEGRATION = "tasks.commands"
|
||||||
|
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
|
||||||
|
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
|
||||||
|
|
||||||
|
|
||||||
def _workflow_topic(
|
def _workflow_topic(
|
||||||
@@ -242,6 +248,54 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.assign-accountable-work",
|
||||||
|
title="Assign accountable Campaign work without granting access",
|
||||||
|
summary="Record bounded work for an account, group, or organization function while keeping authorization and Campaign ownership separate.",
|
||||||
|
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can accept, complete, or reject their own work; rejection is distinct from manager cancellation. Managers can also reassign or cancel it. Workflow-opened work additionally retains the correlation, idempotency, Workflow instance and step, exact Campaign version, and emits a common revision-bearing lifecycle event for assignment, acceptance, start, reassignment, completion, rejection, or cancellation. Workflow rechecks Campaign access before it resumes; the assignment itself never grants access. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
|
||||||
|
order=34,
|
||||||
|
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:assignment:read"),
|
||||||
|
route="/campaigns/{campaign_id}/work",
|
||||||
|
screen="Campaign work",
|
||||||
|
help_contexts=(
|
||||||
|
"campaign.work",
|
||||||
|
"campaign.work.create",
|
||||||
|
"campaign.work.action.start",
|
||||||
|
"campaign.work.action.complete",
|
||||||
|
"campaign.work.action.reject",
|
||||||
|
"campaign.work.action.reassign",
|
||||||
|
"campaign.work.action.cancel",
|
||||||
|
"campaign.work.history",
|
||||||
|
),
|
||||||
|
prerequisites=(
|
||||||
|
"You can read the Campaign and its work assignments.",
|
||||||
|
"Creating, reassigning, cancelling, or reconciling requires the assignment-manage permission.",
|
||||||
|
"The target account, group, or all current function incumbents already have Campaign access.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Work in the selected Campaign workspace and choose Add assignment.",
|
||||||
|
"Enter a bounded purpose, optional due date, typed target, and optional stable Campaign evidence reference.",
|
||||||
|
"Resolve any authorization-neutral rejection by granting access through the separate Campaign sharing workflow or choosing another assignee; creating the assignment itself never grants access.",
|
||||||
|
"Accept and complete your own assignment, reject it explicitly when it cannot be taken on, or use manager actions to reassign or cancel open work.",
|
||||||
|
"Reload and reconcile assignments after account, group, organization-function, or incumbency changes; inspect the retained history before acting on unavailable work.",
|
||||||
|
),
|
||||||
|
outcome="A durable accountability record whose lifecycle is independent from Campaign ownership, authorization, and delivery state.",
|
||||||
|
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. For Workflow-opened work, follow the focused assignment link and verify the exact terminal event and revision resume only the pinned Workflow step. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
|
||||||
|
related_modules=("access", "organizations", "idm", "tasks", "notifications", "audit", "policy"),
|
||||||
|
limitations=(
|
||||||
|
"Organizations and IDM are optional; organization-function assignment is unavailable until both directory and incumbency capabilities are active.",
|
||||||
|
"Tasks mirroring is a convenience projection. Campaign remains the authoritative assignment and history owner.",
|
||||||
|
"Ownership transfer continues to use its separate two-party governance protocol.",
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Verantwortliche Kampagnenarbeit zuweisen, ohne Zugriff zu vergeben",
|
||||||
|
"summary": "Begrenzte Arbeit für Konto, Gruppe oder Organisationsfunktion erfassen und Berechtigung sowie Kampagneneigentum getrennt halten.",
|
||||||
|
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Zugewiesene Personen können Arbeit annehmen, abschließen oder ausdrücklich ablehnen; Ablehnung bleibt von einer administrativen Stornierung getrennt. Durch Workflow eröffnete Arbeit bewahrt Korrelation, Idempotenz, Workflow-Instanz und -Schritt sowie die genaue Kampagnenversion und erzeugt revisionsgebundene Lebenszyklusereignisse. Workflow prüft den Kampagnenzugriff vor der Fortsetzung erneut. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.reuse-content-library",
|
topic_id="campaigns.workflow.reuse-content-library",
|
||||||
title="Reuse Campaign content through Templates",
|
title="Reuse Campaign content through Templates",
|
||||||
@@ -933,6 +987,9 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
|
|||||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:assignment:read",), "Read accountable work attached to campaigns you can already access.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:assignment:manage",), "Create, reassign, cancel, and reconcile authorization-neutral Campaign work.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:assignment:complete",), "Start and complete Campaign work assigned to your account, group, or organization function.")
|
||||||
if mail_available:
|
if mail_available:
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||||
@@ -1040,6 +1097,17 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
|||||||
else:
|
else:
|
||||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||||
|
|
||||||
|
if _integration_available(registry, _TASKS_INTEGRATION):
|
||||||
|
configured.append("Installed composition: Campaign work assignments may be mirrored into Tasks while Campaign remains the authoritative lifecycle and access boundary.")
|
||||||
|
else:
|
||||||
|
limitations.append("Campaign work remains available, but optional Tasks mirroring is not configured.")
|
||||||
|
if _integration_available(registry, _ORGANIZATIONS_INTEGRATION) and _integration_available(
|
||||||
|
registry, _IDM_FUNCTION_ASSIGNMENTS_INTEGRATION
|
||||||
|
):
|
||||||
|
configured.append("Installed composition: Organization-function assignees can be resolved against active functions and their current IDM incumbencies.")
|
||||||
|
else:
|
||||||
|
limitations.append("Organization-function work assignment requires both Organizations directory and IDM incumbency capabilities; account and group assignment remain available.")
|
||||||
|
|
||||||
calendar_available = _integration_available(
|
calendar_available = _integration_available(
|
||||||
registry,
|
registry,
|
||||||
_CALENDAR_INVITATION_INTEGRATION,
|
_CALENDAR_INVITATION_INTEGRATION,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from collections import Counter
|
|||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Text, cast, func, or_
|
from sqlalchemy import Text, and_, cast, func, or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
@@ -18,6 +18,8 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
@@ -279,6 +281,7 @@ class CampaignDsarProvider:
|
|||||||
append=append,
|
append=append,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
subject_user_id=subject_user_id,
|
subject_user_id=subject_user_id,
|
||||||
|
subject_account_id=subject.account_id,
|
||||||
campaign_ids=evidence_campaign_ids,
|
campaign_ids=evidence_campaign_ids,
|
||||||
version_ids=version_ids,
|
version_ids=version_ids,
|
||||||
)
|
)
|
||||||
@@ -643,9 +646,124 @@ class CampaignDsarProvider:
|
|||||||
append: object,
|
append: object,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
subject_user_id: str | None,
|
subject_user_id: str | None,
|
||||||
|
subject_account_id: str | None,
|
||||||
campaign_ids: set[str],
|
campaign_ids: set[str],
|
||||||
version_ids: set[str],
|
version_ids: set[str],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
assignment_filters = []
|
||||||
|
if subject_user_id is not None:
|
||||||
|
assignment_filters.append(
|
||||||
|
CampaignWorkAssignment.assigned_by_user_id == subject_user_id
|
||||||
|
)
|
||||||
|
if subject_account_id is not None:
|
||||||
|
assignment_filters.append(
|
||||||
|
and_(
|
||||||
|
CampaignWorkAssignment.assignee_type == "account",
|
||||||
|
CampaignWorkAssignment.assignee_id == subject_account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assignment_ids: set[str] = set()
|
||||||
|
if assignment_filters:
|
||||||
|
assignment_rows = _bounded_rows(
|
||||||
|
db.query(CampaignWorkAssignment)
|
||||||
|
.filter(
|
||||||
|
CampaignWorkAssignment.tenant_id == tenant_id,
|
||||||
|
or_(*assignment_filters),
|
||||||
|
)
|
||||||
|
.order_by(CampaignWorkAssignment.id)
|
||||||
|
)
|
||||||
|
assignment_ids = {item.id for item in assignment_rows}
|
||||||
|
for assignment in assignment_rows:
|
||||||
|
match_fields = []
|
||||||
|
if assignment.assigned_by_user_id == subject_user_id:
|
||||||
|
match_fields.append("assigned_by_user_id")
|
||||||
|
if (
|
||||||
|
assignment.assignee_type == "account"
|
||||||
|
and assignment.assignee_id == subject_account_id
|
||||||
|
):
|
||||||
|
match_fields.append("assignee_id")
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"campaign_work_assignment",
|
||||||
|
assignment.id,
|
||||||
|
"campaign_work_accountability",
|
||||||
|
"Campaign work assignment",
|
||||||
|
{
|
||||||
|
"match_fields": match_fields,
|
||||||
|
"campaign_id": assignment.campaign_id,
|
||||||
|
"campaign_version_id": assignment.campaign_version_id,
|
||||||
|
"purpose": assignment.purpose,
|
||||||
|
"status": assignment.status,
|
||||||
|
"due_at": _iso(assignment.due_at),
|
||||||
|
"assignee_type": assignment.assignee_type,
|
||||||
|
"assignee_id": assignment.assignee_id,
|
||||||
|
"assignee_label_snapshot": assignment.assignee_label_snapshot,
|
||||||
|
"assignee_resolution_state": assignment.assignee_resolution_state,
|
||||||
|
"reference_kind": assignment.reference_kind,
|
||||||
|
"reference_id": assignment.reference_id,
|
||||||
|
"completed_at": _iso(assignment.completed_at),
|
||||||
|
"cancelled_at": _iso(assignment.cancelled_at),
|
||||||
|
},
|
||||||
|
observed_at=assignment.updated_at,
|
||||||
|
source_path=f"/campaigns/{assignment.campaign_id}/work",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
event_filters = []
|
||||||
|
if subject_user_id is not None:
|
||||||
|
event_filters.append(
|
||||||
|
CampaignWorkAssignmentEvent.actor_user_id == subject_user_id
|
||||||
|
)
|
||||||
|
if assignment_ids:
|
||||||
|
event_filters.append(
|
||||||
|
CampaignWorkAssignmentEvent.assignment_id.in_(assignment_ids)
|
||||||
|
)
|
||||||
|
if subject_account_id is not None:
|
||||||
|
event_filters.extend(
|
||||||
|
(
|
||||||
|
and_(
|
||||||
|
CampaignWorkAssignmentEvent.assignee_type_snapshot == "account",
|
||||||
|
CampaignWorkAssignmentEvent.assignee_id_snapshot
|
||||||
|
== subject_account_id,
|
||||||
|
),
|
||||||
|
cast(CampaignWorkAssignmentEvent.details, Text).contains(
|
||||||
|
subject_account_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if event_filters:
|
||||||
|
event_rows = _bounded_rows(
|
||||||
|
db.query(CampaignWorkAssignmentEvent)
|
||||||
|
.filter(
|
||||||
|
CampaignWorkAssignmentEvent.tenant_id == tenant_id,
|
||||||
|
or_(*event_filters),
|
||||||
|
)
|
||||||
|
.order_by(CampaignWorkAssignmentEvent.id)
|
||||||
|
)
|
||||||
|
for event in event_rows:
|
||||||
|
append( # type: ignore[operator]
|
||||||
|
_record(
|
||||||
|
"campaign_work_assignment_event",
|
||||||
|
event.id,
|
||||||
|
"campaign_work_accountability_evidence",
|
||||||
|
"Campaign work assignment event",
|
||||||
|
{
|
||||||
|
"campaign_id": event.campaign_id,
|
||||||
|
"assignment_id": event.assignment_id,
|
||||||
|
"event_kind": event.event_kind,
|
||||||
|
"status": event.status_snapshot,
|
||||||
|
"assignee_type": event.assignee_type_snapshot,
|
||||||
|
"assignee_id": event.assignee_id_snapshot,
|
||||||
|
"assignee_label_snapshot": event.assignee_label_snapshot,
|
||||||
|
"resolution_state": event.resolution_state_snapshot,
|
||||||
|
},
|
||||||
|
observed_at=event.created_at,
|
||||||
|
immutable=True,
|
||||||
|
retention_reason="Assignment lifecycle events retain accountable institutional work history.",
|
||||||
|
source_path=f"/campaigns/{event.campaign_id}/work",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if subject_user_id is not None:
|
if subject_user_id is not None:
|
||||||
collaboration_rows = _bounded_rows(
|
collaboration_rows = _bounded_rows(
|
||||||
db.query(CampaignCollaborationEntry)
|
db.query(CampaignCollaborationEntry)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from govoplan_core.core.campaigns import (
|
|||||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||||
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
|
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
|
||||||
from govoplan_core.core.module_guards import (
|
from govoplan_core.core.module_guards import (
|
||||||
@@ -51,6 +52,9 @@ from govoplan_core.core.templates import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
|
from govoplan_core.core.idm import CAPABILITY_IDM_FUNCTION_ASSIGNMENTS
|
||||||
|
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||||
|
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
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
|
||||||
@@ -69,6 +73,9 @@ from govoplan_campaign.backend.documentation import (
|
|||||||
)
|
)
|
||||||
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
|
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
|
||||||
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||||
|
from govoplan_campaign.backend.workflow_definitions import (
|
||||||
|
campaign_workflow_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
register_campaign_change_tracking()
|
register_campaign_change_tracking()
|
||||||
|
|
||||||
@@ -121,6 +128,24 @@ PERMISSIONS = (
|
|||||||
"Read moderator-only entries and redact campaign collaboration content while retaining tombstones.",
|
"Read moderator-only entries and redact campaign collaboration content while retaining tombstones.",
|
||||||
"Campaign collaboration",
|
"Campaign collaboration",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"View campaign work assignments",
|
||||||
|
"Read accountable work assignments for campaigns the user can already access.",
|
||||||
|
"Campaign work",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"Manage campaign work assignments",
|
||||||
|
"Create, reassign, cancel, and reconcile authorization-neutral campaign work assignments, including Workflow-opened hand-offs.",
|
||||||
|
"Campaign work",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:assignment:complete",
|
||||||
|
"Complete assigned campaign work",
|
||||||
|
"Accept, complete, or reject campaign work assigned to the current account, group, or organization function.",
|
||||||
|
"Campaign work",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:create",
|
"campaigns:campaign:create",
|
||||||
"Create campaigns",
|
"Create campaigns",
|
||||||
@@ -324,6 +349,9 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:discussion:read",
|
"campaigns:discussion:read",
|
||||||
"campaigns:discussion:post",
|
"campaigns:discussion:post",
|
||||||
"campaigns:discussion:moderate",
|
"campaigns:discussion:moderate",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:recipient:write",
|
"campaigns:recipient:write",
|
||||||
"campaigns:recipient:import",
|
"campaigns:recipient:import",
|
||||||
@@ -340,6 +368,8 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:review",
|
"campaigns:campaign:review",
|
||||||
"campaigns:discussion:read",
|
"campaigns:discussion:read",
|
||||||
"campaigns:discussion:post",
|
"campaigns:discussion:post",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
),
|
),
|
||||||
@@ -358,6 +388,8 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:reconcile",
|
"campaigns:campaign:reconcile",
|
||||||
"campaigns:discussion:read",
|
"campaigns:discussion:read",
|
||||||
"campaigns:discussion:post",
|
"campaigns:discussion:post",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
"campaigns:diagnostic:read",
|
"campaigns:diagnostic:read",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
@@ -411,7 +443,8 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.20",
|
version="0.1.23",
|
||||||
|
workflow_definitions=campaign_workflow_definitions(module_version="0.1.23"),
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -420,6 +453,9 @@ manifest = ModuleManifest(
|
|||||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
CAPABILITY_APPROVAL_REQUESTS,
|
CAPABILITY_APPROVAL_REQUESTS,
|
||||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
CAPABILITY_TASK_COMMANDS,
|
||||||
),
|
),
|
||||||
optional_dependencies=(
|
optional_dependencies=(
|
||||||
"files",
|
"files",
|
||||||
@@ -433,6 +469,9 @@ manifest = ModuleManifest(
|
|||||||
"approvals",
|
"approvals",
|
||||||
"reporting",
|
"reporting",
|
||||||
"search",
|
"search",
|
||||||
|
"organizations",
|
||||||
|
"idm",
|
||||||
|
"tasks",
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
@@ -441,6 +480,10 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="campaigns.work_orchestration",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
ModuleInterfaceProvider(
|
ModuleInterfaceProvider(
|
||||||
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
@@ -550,6 +593,24 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
version_min="0.1.0",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_TASK_COMMANDS,
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="search.source",
|
name="search.source",
|
||||||
version_min="1.0.0",
|
version_min="1.0.0",
|
||||||
@@ -639,12 +700,20 @@ manifest = ModuleManifest(
|
|||||||
"campaigns.route.operator-redirect",
|
"campaigns.route.operator-redirect",
|
||||||
OPERATOR_QUEUE_SURFACE_ID,
|
OPERATOR_QUEUE_SURFACE_ID,
|
||||||
REPORTS_SURFACE_ID,
|
REPORTS_SURFACE_ID,
|
||||||
|
"campaigns.page.work",
|
||||||
"campaigns.page.activity",
|
"campaigns.page.activity",
|
||||||
),
|
),
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="campaigns.page.work",
|
||||||
|
module_id="campaigns",
|
||||||
|
kind="page",
|
||||||
|
label="Campaign work",
|
||||||
|
order=44,
|
||||||
|
),
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="campaigns.page.activity",
|
id="campaigns.page.activity",
|
||||||
module_id="campaigns",
|
module_id="campaigns",
|
||||||
@@ -1546,6 +1615,10 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
fromlist=["retention_capability"],
|
fromlist=["retention_capability"],
|
||||||
).retention_capability(context),
|
).retention_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.work_orchestration",
|
||||||
|
fromlist=["SqlCampaignWorkOrchestrationProvider"],
|
||||||
|
).SqlCampaignWorkOrchestrationProvider(registry=context.registry),
|
||||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
||||||
"govoplan_campaign.backend.reports.provider",
|
"govoplan_campaign.backend.reports.provider",
|
||||||
fromlist=["CampaignAggregateReportProvider"],
|
fromlist=["CampaignAggregateReportProvider"],
|
||||||
@@ -1553,6 +1626,16 @@ manifest = ModuleManifest(
|
|||||||
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
|
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: CapabilityDocumentation(
|
||||||
|
label="Campaign work orchestration",
|
||||||
|
summary=(
|
||||||
|
"Creates or references Campaign work idempotently and exposes "
|
||||||
|
"revision-bearing lifecycle events without granting access."
|
||||||
|
),
|
||||||
|
contract_version="1.0",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("campaign_manager", "workflow_designer", "module_admin"),
|
||||||
|
),
|
||||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||||
label="Campaign aggregate report provider",
|
label="Campaign aggregate report provider",
|
||||||
summary=(
|
summary=(
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
"""add accountable campaign work assignments
|
||||||
|
|
||||||
|
revision = "d8e9f0a1b2c3"
|
||||||
|
down_revision = "c7d8e9f0a1b2"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "d8e9f0a1b2c3"
|
||||||
|
down_revision = "c7d8e9f0a1b2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_work_assignments"):
|
||||||
|
op.create_table(
|
||||||
|
"campaign_work_assignments",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||||
|
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("purpose", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False, server_default="open"),
|
||||||
|
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("assignee_type", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("assignee_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("assignee_current_label", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("assignee_resolution_state", sa.String(length=30), nullable=False, server_default="resolved"),
|
||||||
|
sa.Column("resolution_provenance", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("resolution_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("assigned_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("assigned_by_label_snapshot", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("task_mirror_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("task_mirror_status", sa.String(length=30), nullable=False, server_default="not_configured"),
|
||||||
|
sa.Column("task_mirror_error", sa.String(length=500), nullable=True),
|
||||||
|
sa.Column("task_mirrored_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("resource_revision", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_campaign_work_assignments_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_campaign_work_assignments_campaign_id", ["campaign_id"]),
|
||||||
|
("ix_campaign_work_assignments_campaign_version_id", ["campaign_version_id"]),
|
||||||
|
("ix_campaign_work_assignments_reference_kind", ["reference_kind"]),
|
||||||
|
("ix_campaign_work_assignments_status", ["status"]),
|
||||||
|
("ix_campaign_work_assignments_due_at", ["due_at"]),
|
||||||
|
("ix_campaign_work_assignments_assignee_type", ["assignee_type"]),
|
||||||
|
("ix_campaign_work_assignments_assignee_id", ["assignee_id"]),
|
||||||
|
("ix_campaign_work_assignments_assignee_resolution_state", ["assignee_resolution_state"]),
|
||||||
|
("ix_campaign_work_assignments_assigned_by_user_id", ["assigned_by_user_id"]),
|
||||||
|
("ix_campaign_work_assignments_campaign_status", ["tenant_id", "campaign_id", "status", "due_at", "id"]),
|
||||||
|
):
|
||||||
|
op.create_index(name, "campaign_work_assignments", columns, unique=False)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_work_assignment_events"):
|
||||||
|
op.create_table(
|
||||||
|
"campaign_work_assignment_events",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("assignment_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("event_kind", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("status_snapshot", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("assignee_type_snapshot", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("assignee_id_snapshot", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("resolution_state_snapshot", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("details", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["assignment_id"], ["campaign_work_assignments.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_campaign_work_assignment_events_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_campaign_work_assignment_events_campaign_id", ["campaign_id"]),
|
||||||
|
("ix_campaign_work_assignment_events_assignment_id", ["assignment_id"]),
|
||||||
|
("ix_campaign_work_assignment_events_event_kind", ["event_kind"]),
|
||||||
|
("ix_campaign_work_assignment_events_actor_user_id", ["actor_user_id"]),
|
||||||
|
("ix_campaign_work_assignment_events_history", ["tenant_id", "assignment_id", "created_at", "id"]),
|
||||||
|
):
|
||||||
|
op.create_index(name, "campaign_work_assignment_events", columns, unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if inspector.has_table("campaign_work_assignment_events"):
|
||||||
|
op.drop_table("campaign_work_assignment_events")
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if inspector.has_table("campaign_work_assignments"):
|
||||||
|
op.drop_table("campaign_work_assignments")
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
"""add durable Campaign work orchestration provenance
|
||||||
|
|
||||||
|
revision = "f3c7a9d2e6b1"
|
||||||
|
down_revision = "d8e9f0a1b2c3"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f3c7a9d2e6b1"
|
||||||
|
down_revision = "d8e9f0a1b2c3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_COLUMN_SPECS = (
|
||||||
|
("orchestration_idempotency_key", sa.String(length=255)),
|
||||||
|
("orchestration_request_sha256", sa.String(length=64)),
|
||||||
|
("orchestration_correlation_id", sa.String(length=128)),
|
||||||
|
("workflow_instance_id", sa.String(length=36)),
|
||||||
|
("workflow_step_id", sa.String(length=36)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_work_assignments"):
|
||||||
|
return
|
||||||
|
existing = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_columns("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||||
|
for name, column_type in _COLUMN_SPECS:
|
||||||
|
if name not in existing:
|
||||||
|
batch.add_column(sa.Column(name, column_type, nullable=True))
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_indexes("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
for name, columns in (
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||||
|
["orchestration_idempotency_key"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||||
|
["orchestration_correlation_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_workflow_instance_id",
|
||||||
|
["workflow_instance_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_workflow_step_id",
|
||||||
|
["workflow_step_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uq_campaign_work_assignment_orchestration_key",
|
||||||
|
["tenant_id", "orchestration_idempotency_key"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
name,
|
||||||
|
"campaign_work_assignments",
|
||||||
|
columns,
|
||||||
|
unique=name.startswith("uq_"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_work_assignments"):
|
||||||
|
return
|
||||||
|
indexes = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_indexes("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
for name in (
|
||||||
|
"uq_campaign_work_assignment_orchestration_key",
|
||||||
|
"ix_campaign_work_assignments_workflow_step_id",
|
||||||
|
"ix_campaign_work_assignments_workflow_instance_id",
|
||||||
|
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||||
|
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="campaign_work_assignments")
|
||||||
|
existing = {
|
||||||
|
item["name"]
|
||||||
|
for item in sa.inspect(op.get_bind()).get_columns(
|
||||||
|
"campaign_work_assignments"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||||
|
for name, _column_type in reversed(_COLUMN_SPECS):
|
||||||
|
if name in existing:
|
||||||
|
batch.drop_column(name)
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||||
|
from govoplan_campaign.backend.routes.assignments import router as assignments_router
|
||||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||||
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
||||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||||
@@ -18,6 +19,7 @@ router = APIRouter()
|
|||||||
for workflow_router in (
|
for workflow_router in (
|
||||||
operations_router,
|
operations_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
assignments_router,
|
||||||
collaboration_router,
|
collaboration_router,
|
||||||
versions_router,
|
versions_router,
|
||||||
jobs_router,
|
jobs_router,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,149 @@ class CampaignCollaborationListResponse(BaseModel):
|
|||||||
has_more: bool = False
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
CampaignWorkAssigneeType = Literal["account", "group", "organization_function"]
|
||||||
|
CampaignWorkAssignmentStatus = Literal[
|
||||||
|
"open",
|
||||||
|
"in_progress",
|
||||||
|
"completed",
|
||||||
|
"rejected",
|
||||||
|
"cancelled",
|
||||||
|
]
|
||||||
|
CampaignWorkAssigneeResolutionState = Literal[
|
||||||
|
"resolved",
|
||||||
|
"unavailable",
|
||||||
|
"provider_unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssigneeInput(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
type: CampaignWorkAssigneeType
|
||||||
|
id: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("id")
|
||||||
|
@classmethod
|
||||||
|
def strip_assignee_id(cls, value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
purpose: str = Field(min_length=1, max_length=500)
|
||||||
|
assignee: CampaignWorkAssigneeInput
|
||||||
|
due_at: datetime | None = None
|
||||||
|
reference: CampaignCollaborationReferenceInput | None = None
|
||||||
|
mirror_to_tasks: bool = True
|
||||||
|
|
||||||
|
@field_validator("purpose")
|
||||||
|
@classmethod
|
||||||
|
def strip_assignment_purpose(cls, value: str) -> str:
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
@field_validator("due_at")
|
||||||
|
@classmethod
|
||||||
|
def require_due_timezone(cls, value: datetime | None) -> datetime | None:
|
||||||
|
if value is not None and value.tzinfo is None:
|
||||||
|
raise ValueError("Assignment due dates must include a timezone.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentReassignRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
assignee: CampaignWorkAssigneeInput
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
mirror_to_tasks: bool = True
|
||||||
|
|
||||||
|
@field_validator("reason")
|
||||||
|
@classmethod
|
||||||
|
def strip_reassignment_reason(cls, value: str | None) -> str | None:
|
||||||
|
clean = value.strip() if value is not None else None
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentTransitionRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
expected_revision: int = Field(ge=1)
|
||||||
|
action: Literal["accept", "start", "complete", "reject", "cancel"]
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
@field_validator("reason")
|
||||||
|
@classmethod
|
||||||
|
def strip_transition_reason(cls, value: str | None) -> str | None:
|
||||||
|
clean = value.strip() if value is not None else None
|
||||||
|
return clean or None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentReferenceResponse(BaseModel):
|
||||||
|
kind: CampaignCollaborationReferenceKind
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
campaign_id: str
|
||||||
|
purpose: str
|
||||||
|
status: CampaignWorkAssignmentStatus
|
||||||
|
due_at: datetime | None = None
|
||||||
|
assignee_type: CampaignWorkAssigneeType
|
||||||
|
assignee_id: str
|
||||||
|
assignee_label_snapshot: str
|
||||||
|
assignee_current_label: str | None = None
|
||||||
|
assignee_resolution_state: CampaignWorkAssigneeResolutionState
|
||||||
|
resolution_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
resolution_checked_at: datetime
|
||||||
|
assigned_by_user_id: str | None = None
|
||||||
|
assigned_by_label: str
|
||||||
|
reference: CampaignWorkAssignmentReferenceResponse | None = None
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
cancelled_at: datetime | None = None
|
||||||
|
task_mirror_id: str | None = None
|
||||||
|
task_mirror_status: Literal["not_configured", "mirrored", "failed", "skipped"]
|
||||||
|
task_mirror_error: str | None = None
|
||||||
|
resource_revision: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentListResponse(BaseModel):
|
||||||
|
items: list[CampaignWorkAssignmentResponse]
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentEventResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
assignment_id: str
|
||||||
|
event_kind: str
|
||||||
|
actor_user_id: str | None = None
|
||||||
|
actor_label: str
|
||||||
|
status: CampaignWorkAssignmentStatus
|
||||||
|
assignee_type: CampaignWorkAssigneeType
|
||||||
|
assignee_id: str
|
||||||
|
assignee_label: str
|
||||||
|
resolution_state: CampaignWorkAssigneeResolutionState
|
||||||
|
details: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentHistoryResponse(BaseModel):
|
||||||
|
items: list[CampaignWorkAssignmentEventResponse]
|
||||||
|
next_cursor: str | None = None
|
||||||
|
has_more: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignWorkAssignmentReconcileResponse(BaseModel):
|
||||||
|
checked: int
|
||||||
|
changed: int
|
||||||
|
assignments: list[CampaignWorkAssignmentResponse]
|
||||||
|
|
||||||
|
|
||||||
class CampaignLifecycleMutationRequest(BaseModel):
|
class CampaignLifecycleMutationRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,778 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.persistence.versions import create_minimal_campaign
|
||||||
|
from govoplan_campaign.backend.route_support import _get_campaign_for_principal
|
||||||
|
from govoplan_campaign.backend.routes.assignments import (
|
||||||
|
_actor_label,
|
||||||
|
_mirror_assignment_to_tasks,
|
||||||
|
_notify_assignment,
|
||||||
|
_record_event,
|
||||||
|
_require_resolved_assignee,
|
||||||
|
_resolve_assignee,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignWorkAssigneeInput
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.automation import (
|
||||||
|
ActionDefinition,
|
||||||
|
ActionExecutionRequest,
|
||||||
|
ActionExecutionResult,
|
||||||
|
ActionPreview,
|
||||||
|
EffectDefinition,
|
||||||
|
EffectPreview,
|
||||||
|
ObservedEffect,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.campaigns import (
|
||||||
|
CampaignWorkHandoffInspection,
|
||||||
|
CampaignWorkHandoffRef,
|
||||||
|
CampaignWorkHandoffRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
|
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_KEY = "campaigns.work.prepare"
|
||||||
|
ASSIGNMENT_EFFECT = "campaigns.work.assignment_created"
|
||||||
|
CAMPAIGN_EFFECT = "campaigns.work.campaign_created"
|
||||||
|
|
||||||
|
|
||||||
|
class SqlCampaignWorkOrchestrationProvider:
|
||||||
|
"""Campaign-owned adapter used through optional Core capabilities only."""
|
||||||
|
|
||||||
|
def __init__(self, *, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def action_definitions(self) -> tuple[ActionDefinition, ...]:
|
||||||
|
return (
|
||||||
|
ActionDefinition(
|
||||||
|
action_key=ACTION_KEY,
|
||||||
|
owner_module="campaigns",
|
||||||
|
description=(
|
||||||
|
"Reference or create a Campaign and open one authorization-neutral "
|
||||||
|
"accountable work hand-off."
|
||||||
|
),
|
||||||
|
input_schema_ref="govoplan/campaigns/work-handoff.v1",
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
),
|
||||||
|
policy_checks=(
|
||||||
|
"campaign access is checked independently of assignment",
|
||||||
|
"the assignee must already have Campaign access",
|
||||||
|
"the expected Campaign revision must still be current",
|
||||||
|
),
|
||||||
|
risk_level="moderate",
|
||||||
|
reversibility="compensatable",
|
||||||
|
expected_effect_keys=(ASSIGNMENT_EFFECT, CAMPAIGN_EFFECT),
|
||||||
|
idempotency_strategy="caller_supplied",
|
||||||
|
audit_event_types=(
|
||||||
|
"campaign.assignment.created",
|
||||||
|
"campaign.created_minimal",
|
||||||
|
),
|
||||||
|
preview_required=True,
|
||||||
|
recovery_mode="atomic",
|
||||||
|
recovery_verification=(
|
||||||
|
"resolve the assignment by tenant and orchestration idempotency key",
|
||||||
|
"verify the exact Campaign version and assignment revisions",
|
||||||
|
"confirm the assigned principal still has independent Campaign access",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def effect_definitions(self) -> tuple[EffectDefinition, ...]:
|
||||||
|
return (
|
||||||
|
EffectDefinition(
|
||||||
|
effect_key=ASSIGNMENT_EFFECT,
|
||||||
|
owner_module="campaigns",
|
||||||
|
operation="created",
|
||||||
|
description="Create an accountable Campaign work assignment.",
|
||||||
|
resource_types=("campaign_work_assignment",),
|
||||||
|
audit_event_types=("campaign.assignment.created",),
|
||||||
|
compensation_hint="Cancel the open assignment through Campaign work.",
|
||||||
|
),
|
||||||
|
EffectDefinition(
|
||||||
|
effect_key=CAMPAIGN_EFFECT,
|
||||||
|
owner_module="campaigns",
|
||||||
|
operation="created",
|
||||||
|
description="Create a minimal Campaign draft when no campaign is referenced.",
|
||||||
|
resource_types=("campaign", "campaign_version"),
|
||||||
|
audit_event_types=("campaign.created_minimal",),
|
||||||
|
compensation_hint=(
|
||||||
|
"Delete the untouched draft under the normal Campaign lifecycle policy."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def preview_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ActionExecutionRequest,
|
||||||
|
) -> ActionPreview:
|
||||||
|
if request.action_key != ACTION_KEY:
|
||||||
|
return _blocked_preview("The Campaign work action is not supported.")
|
||||||
|
try:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
handoff = _request(request)
|
||||||
|
_preview_handoff(sql_session, api_principal, handoff)
|
||||||
|
except (HTTPException, TypeError, ValueError) as exc:
|
||||||
|
return _blocked_preview(_message(exc))
|
||||||
|
creating = handoff.campaign_id is None
|
||||||
|
effects = [
|
||||||
|
EffectPreview(
|
||||||
|
effect_key=ASSIGNMENT_EFFECT,
|
||||||
|
summary="Open one revision-bearing Campaign work assignment.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if creating:
|
||||||
|
effects.insert(
|
||||||
|
0,
|
||||||
|
EffectPreview(
|
||||||
|
effect_key=CAMPAIGN_EFFECT,
|
||||||
|
summary="Create one minimal Campaign draft and initial version.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ActionPreview(
|
||||||
|
action_key=ACTION_KEY,
|
||||||
|
allowed=True,
|
||||||
|
summary=(
|
||||||
|
"Create a Campaign draft and open accountable work."
|
||||||
|
if creating
|
||||||
|
else "Reference the current Campaign revision and open accountable work."
|
||||||
|
),
|
||||||
|
risk_level="moderate",
|
||||||
|
reversibility="compensatable",
|
||||||
|
effects=tuple(effects),
|
||||||
|
policy_provenance=(
|
||||||
|
{
|
||||||
|
"code": "campaign_assignment_does_not_grant_access",
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"campaign_access_rechecked_on_resume": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
preview_ref=f"campaign-work-preview:{_request_hash(handoff)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ActionExecutionRequest,
|
||||||
|
) -> ActionExecutionResult:
|
||||||
|
if request.action_key != ACTION_KEY:
|
||||||
|
raise ValueError("The Campaign work action is not supported.")
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
handoff = _request(request)
|
||||||
|
ref = self.prepare_handoff(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
request=handoff,
|
||||||
|
)
|
||||||
|
effects = [
|
||||||
|
ObservedEffect(
|
||||||
|
effect_key=ASSIGNMENT_EFFECT,
|
||||||
|
operation="created",
|
||||||
|
resource_ref=ref.assignment_ref,
|
||||||
|
summary=(
|
||||||
|
"Reused the existing idempotent Campaign work assignment."
|
||||||
|
if ref.replayed
|
||||||
|
else "Created the Campaign work assignment."
|
||||||
|
),
|
||||||
|
metadata={"replayed": ref.replayed},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not ref.replayed and handoff.campaign_id is None:
|
||||||
|
effects.insert(
|
||||||
|
0,
|
||||||
|
ObservedEffect(
|
||||||
|
effect_key=CAMPAIGN_EFFECT,
|
||||||
|
operation="created",
|
||||||
|
resource_ref=ref.campaign_ref,
|
||||||
|
summary="Created the minimal Campaign draft.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ActionExecutionResult(
|
||||||
|
state="completed",
|
||||||
|
output=_ref_payload(ref),
|
||||||
|
observed_effects=tuple(effects),
|
||||||
|
audit_event_refs=(
|
||||||
|
str(ref.provenance["audit_event_ref"]),
|
||||||
|
)
|
||||||
|
if ref.provenance.get("audit_event_ref")
|
||||||
|
else (),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare_handoff(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
) -> CampaignWorkHandoffRef:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
if api_principal.tenant_id != request.tenant_id:
|
||||||
|
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||||
|
request_hash = _request_hash(request)
|
||||||
|
existing = (
|
||||||
|
sql_session.query(CampaignWorkAssignment)
|
||||||
|
.filter(
|
||||||
|
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||||
|
CampaignWorkAssignment.orchestration_idempotency_key
|
||||||
|
== request.idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.orchestration_request_sha256 != request_hash:
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign hand-off idempotency key was already used for "
|
||||||
|
"different input."
|
||||||
|
)
|
||||||
|
campaign = _get_campaign_for_principal(
|
||||||
|
sql_session,
|
||||||
|
existing.campaign_id,
|
||||||
|
api_principal,
|
||||||
|
)
|
||||||
|
return _handoff_ref(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=existing,
|
||||||
|
registry=self._registry,
|
||||||
|
replayed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
campaign, version, created = _campaign_and_version(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
request,
|
||||||
|
create=True,
|
||||||
|
)
|
||||||
|
resolution = _resolve_assignee(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignee=CampaignWorkAssigneeInput(
|
||||||
|
type=request.assignee_kind,
|
||||||
|
id=request.assignee_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_require_resolved_assignee(resolution)
|
||||||
|
now = utc_now()
|
||||||
|
assignment = CampaignWorkAssignment(
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
reference_kind="campaign_version",
|
||||||
|
reference_id=version.id,
|
||||||
|
reference_label=f"Campaign version {version.version_number}",
|
||||||
|
purpose=request.purpose.strip(),
|
||||||
|
status="open",
|
||||||
|
due_at=request.due_at,
|
||||||
|
assignee_type=request.assignee_kind,
|
||||||
|
assignee_id=request.assignee_id.strip(),
|
||||||
|
assignee_label_snapshot=(resolution.label or request.assignee_id)[:500],
|
||||||
|
assignee_current_label=resolution.label,
|
||||||
|
assignee_resolution_state=resolution.state,
|
||||||
|
resolution_provenance={
|
||||||
|
**resolution.provenance,
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
"expected_campaign_revision": request.expected_campaign_revision,
|
||||||
|
},
|
||||||
|
resolution_checked_at=now,
|
||||||
|
assigned_by_user_id=api_principal.user.id,
|
||||||
|
assigned_by_label_snapshot=_actor_label(api_principal),
|
||||||
|
orchestration_idempotency_key=request.idempotency_key,
|
||||||
|
orchestration_request_sha256=request_hash,
|
||||||
|
orchestration_correlation_id=request.correlation_id,
|
||||||
|
workflow_instance_id=request.workflow_instance_id,
|
||||||
|
workflow_step_id=request.workflow_step_id,
|
||||||
|
)
|
||||||
|
sql_session.add(assignment)
|
||||||
|
sql_session.flush()
|
||||||
|
_record_event(
|
||||||
|
sql_session,
|
||||||
|
assignment=assignment,
|
||||||
|
principal=api_principal,
|
||||||
|
event_kind="assigned",
|
||||||
|
details={
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.mirror_to_tasks:
|
||||||
|
_mirror_assignment_to_tasks(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=assignment,
|
||||||
|
principal=api_principal,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assignment.task_mirror_status = "skipped"
|
||||||
|
_notify_assignment(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=assignment,
|
||||||
|
event_kind="assigned",
|
||||||
|
)
|
||||||
|
audit_ref = audit_from_principal(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
action="campaign.assignment.created",
|
||||||
|
object_type="campaign_work_assignment",
|
||||||
|
object_id=assignment.id,
|
||||||
|
details={
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_version_id": version.id,
|
||||||
|
"campaign_revision": version.edit_revision,
|
||||||
|
"resource_revision": assignment.resource_revision,
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"purpose_disclosed": False,
|
||||||
|
},
|
||||||
|
correlation_id=request.correlation_id,
|
||||||
|
causation_id=request.workflow_step_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
audit_from_principal(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
action="campaign.created_minimal",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign.id,
|
||||||
|
details={
|
||||||
|
"version_id": version.id,
|
||||||
|
"external_id": campaign.external_id,
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
},
|
||||||
|
correlation_id=request.correlation_id,
|
||||||
|
causation_id=request.workflow_step_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
sql_session.flush()
|
||||||
|
ref = _handoff_ref(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=assignment,
|
||||||
|
registry=self._registry,
|
||||||
|
)
|
||||||
|
return replace(
|
||||||
|
ref,
|
||||||
|
provenance={**dict(ref.provenance), "audit_event_ref": audit_ref.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect_handoff(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
assignment_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> CampaignWorkHandoffInspection:
|
||||||
|
try:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
except TypeError as exc:
|
||||||
|
return CampaignWorkHandoffInspection(allowed=False, reason=str(exc))
|
||||||
|
if api_principal.tenant_id != tenant_id:
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
reason="Campaign hand-off tenant does not match the principal.",
|
||||||
|
provenance={"code": "campaign_handoff_tenant_mismatch"},
|
||||||
|
)
|
||||||
|
assignment = sql_session.get(CampaignWorkAssignment, assignment_id)
|
||||||
|
if assignment is None or assignment.tenant_id != tenant_id:
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
reason="Campaign work assignment is unavailable.",
|
||||||
|
provenance={"code": "campaign_handoff_missing"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_get_campaign_for_principal(
|
||||||
|
sql_session,
|
||||||
|
assignment.campaign_id,
|
||||||
|
api_principal,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
reason=_message(exc),
|
||||||
|
provenance={
|
||||||
|
"code": "campaign_handoff_access_revoked",
|
||||||
|
"campaign_id": assignment.campaign_id,
|
||||||
|
"assignment_does_not_grant_access": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
expected_revision is not None
|
||||||
|
and assignment.resource_revision != expected_revision
|
||||||
|
):
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
action_url=_action_url(assignment),
|
||||||
|
assignment_ref=_assignment_ref(assignment),
|
||||||
|
reason="Campaign work assignment revision changed; reload its event.",
|
||||||
|
provenance={
|
||||||
|
"code": "campaign_handoff_revision_conflict",
|
||||||
|
"expected_revision": expected_revision,
|
||||||
|
"current_revision": assignment.resource_revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=True,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
action_url=_action_url(assignment),
|
||||||
|
assignment_ref=_assignment_ref(assignment),
|
||||||
|
provenance={
|
||||||
|
"code": "campaign_handoff_access_rechecked",
|
||||||
|
"campaign_id": assignment.campaign_id,
|
||||||
|
"assignment_does_not_grant_access": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _context(
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[Session, ApiPrincipal]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Campaign work orchestration requires a SQLAlchemy Session.")
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
raise TypeError("Campaign work orchestration requires an API principal.")
|
||||||
|
return session, principal
|
||||||
|
|
||||||
|
|
||||||
|
def _request(request: ActionExecutionRequest) -> CampaignWorkHandoffRequest:
|
||||||
|
value = request.input
|
||||||
|
assignee = value.get("assignee")
|
||||||
|
if not isinstance(assignee, Mapping):
|
||||||
|
raise ValueError("Campaign work hand-offs require an assignee object.")
|
||||||
|
create = value.get("create_campaign")
|
||||||
|
if create is not None and not isinstance(create, Mapping):
|
||||||
|
raise ValueError("Campaign creation input must be an object.")
|
||||||
|
due_at = _date(value.get("due_at"))
|
||||||
|
return CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
idempotency_key=request.idempotency_key,
|
||||||
|
purpose=str(value.get("purpose") or ""),
|
||||||
|
assignee_kind=str(assignee.get("kind") or ""), # type: ignore[arg-type]
|
||||||
|
assignee_id=str(assignee.get("id") or ""),
|
||||||
|
campaign_id=_optional(value.get("campaign_id")),
|
||||||
|
create_external_id=_optional(create.get("external_id")) if create else None,
|
||||||
|
create_name=_optional(create.get("name")) if create else None,
|
||||||
|
create_description=(
|
||||||
|
_optional(create.get("description")) if create else None
|
||||||
|
),
|
||||||
|
expected_campaign_revision=_integer(
|
||||||
|
value.get("expected_campaign_revision")
|
||||||
|
),
|
||||||
|
due_at=due_at,
|
||||||
|
mirror_to_tasks=bool(value.get("mirror_to_tasks", True)),
|
||||||
|
correlation_id=request.invocation.correlation_id,
|
||||||
|
workflow_instance_id=_reference_id(
|
||||||
|
request.metadata.get("workflow_instance_ref"),
|
||||||
|
"workflow-instance:",
|
||||||
|
),
|
||||||
|
workflow_step_id=_reference_id(
|
||||||
|
request.metadata.get("workflow_step_ref"),
|
||||||
|
"workflow-step:",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_handoff(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
) -> None:
|
||||||
|
if principal.tenant_id != request.tenant_id:
|
||||||
|
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||||
|
for scope in (
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
):
|
||||||
|
if not has_scope(principal, scope):
|
||||||
|
raise ValueError(f"Campaign work hand-off requires {scope}.")
|
||||||
|
existing = (
|
||||||
|
session.query(CampaignWorkAssignment)
|
||||||
|
.filter(
|
||||||
|
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||||
|
CampaignWorkAssignment.orchestration_idempotency_key
|
||||||
|
== request.idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.orchestration_request_sha256 != _request_hash(request):
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign hand-off idempotency key was already used for different input."
|
||||||
|
)
|
||||||
|
_get_campaign_for_principal(session, existing.campaign_id, principal)
|
||||||
|
return
|
||||||
|
if request.campaign_id is None:
|
||||||
|
if request.assignee_kind != "account" or (
|
||||||
|
request.assignee_id != principal.account_id
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"A newly created Campaign can initially be assigned only to its "
|
||||||
|
"creating account; share it explicitly before assigning other principals."
|
||||||
|
)
|
||||||
|
duplicate = (
|
||||||
|
session.query(Campaign.id)
|
||||||
|
.filter(
|
||||||
|
Campaign.tenant_id == request.tenant_id,
|
||||||
|
Campaign.external_id == request.create_external_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
raise ValueError("Campaign external ID already exists for this tenant.")
|
||||||
|
if request.expected_campaign_revision not in {None, 1}:
|
||||||
|
raise ValueError("A new Campaign starts at revision one.")
|
||||||
|
return
|
||||||
|
campaign, _version, _created = _campaign_and_version(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
create=False,
|
||||||
|
)
|
||||||
|
resolution = _resolve_assignee(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignee=CampaignWorkAssigneeInput(
|
||||||
|
type=request.assignee_kind,
|
||||||
|
id=request.assignee_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_require_resolved_assignee(resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_and_version(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
*,
|
||||||
|
create: bool,
|
||||||
|
) -> tuple[Campaign, CampaignVersion, bool]:
|
||||||
|
if request.campaign_id is None:
|
||||||
|
if not create:
|
||||||
|
raise ValueError("Campaign creation is not available during preview.")
|
||||||
|
campaign, version = create_minimal_campaign(
|
||||||
|
session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
external_id=str(request.create_external_id),
|
||||||
|
name=str(request.create_name),
|
||||||
|
description=request.create_description,
|
||||||
|
current_flow="create",
|
||||||
|
current_step="basics",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
return campaign, version, True
|
||||||
|
campaign = _get_campaign_for_principal(
|
||||||
|
session,
|
||||||
|
request.campaign_id,
|
||||||
|
principal,
|
||||||
|
)
|
||||||
|
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
|
if version is None or version.campaign_id != campaign.id:
|
||||||
|
raise ValueError("The Campaign current version is unavailable.")
|
||||||
|
if (
|
||||||
|
request.expected_campaign_revision is not None
|
||||||
|
and version.edit_revision != request.expected_campaign_revision
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign revision changed; reload the Campaign before opening work."
|
||||||
|
)
|
||||||
|
return campaign, version, False
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_ref(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
assignment: CampaignWorkAssignment,
|
||||||
|
registry: object | None,
|
||||||
|
replayed: bool = False,
|
||||||
|
) -> CampaignWorkHandoffRef:
|
||||||
|
version = session.get(CampaignVersion, assignment.campaign_version_id)
|
||||||
|
if version is None or version.campaign_id != campaign.id:
|
||||||
|
raise ValueError("The pinned Campaign hand-off version is unavailable.")
|
||||||
|
return CampaignWorkHandoffRef(
|
||||||
|
tenant_id=assignment.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
campaign_revision=version.edit_revision,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
action_url=_action_url(assignment),
|
||||||
|
campaign_ref=(
|
||||||
|
f"campaign:{campaign.id}:version:{version.id}:r{version.edit_revision}"
|
||||||
|
),
|
||||||
|
assignment_ref=_assignment_ref(assignment),
|
||||||
|
replayed=replayed,
|
||||||
|
optional_capabilities={
|
||||||
|
"tasks": _has_capability(registry, CAPABILITY_TASK_COMMANDS),
|
||||||
|
"notifications": _has_capability(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
provenance={
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"campaign_access_checked": True,
|
||||||
|
"workflow_instance_id": assignment.workflow_instance_id,
|
||||||
|
"workflow_step_id": assignment.workflow_step_id,
|
||||||
|
"correlation_id": assignment.orchestration_correlation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_payload(ref: CampaignWorkHandoffRef) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"campaign_id": ref.campaign_id,
|
||||||
|
"campaign_version_id": ref.campaign_version_id,
|
||||||
|
"campaign_revision": ref.campaign_revision,
|
||||||
|
"assignment_id": ref.assignment_id,
|
||||||
|
"assignment_revision": ref.assignment_revision,
|
||||||
|
"status": ref.status,
|
||||||
|
"action_url": ref.action_url,
|
||||||
|
"campaign_ref": ref.campaign_ref,
|
||||||
|
"assignment_ref": ref.assignment_ref,
|
||||||
|
"event_type": ref.event_type,
|
||||||
|
"replayed": ref.replayed,
|
||||||
|
"optional_capabilities": dict(ref.optional_capabilities),
|
||||||
|
"provenance": dict(ref.provenance),
|
||||||
|
"outcome": "success",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_hash(request: CampaignWorkHandoffRequest) -> str:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"purpose": request.purpose.strip(),
|
||||||
|
"assignee_kind": request.assignee_kind,
|
||||||
|
"assignee_id": request.assignee_id.strip(),
|
||||||
|
"campaign_id": request.campaign_id,
|
||||||
|
"create_external_id": request.create_external_id,
|
||||||
|
"create_name": request.create_name,
|
||||||
|
"create_description": request.create_description,
|
||||||
|
"expected_campaign_revision": request.expected_campaign_revision,
|
||||||
|
"due_at": request.due_at.isoformat() if request.due_at else None,
|
||||||
|
"mirror_to_tasks": request.mirror_to_tasks,
|
||||||
|
"correlation_id": request.correlation_id,
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_preview(reason: str) -> ActionPreview:
|
||||||
|
return ActionPreview(
|
||||||
|
action_key=ACTION_KEY,
|
||||||
|
allowed=False,
|
||||||
|
summary=reason,
|
||||||
|
risk_level="moderate",
|
||||||
|
reversibility="compensatable",
|
||||||
|
blockers=(reason,),
|
||||||
|
policy_provenance=(
|
||||||
|
{
|
||||||
|
"code": "campaign_work_handoff_blocked",
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message(exc: Exception) -> str:
|
||||||
|
if isinstance(exc, HTTPException):
|
||||||
|
detail = exc.detail
|
||||||
|
if isinstance(detail, Mapping):
|
||||||
|
return str(detail.get("explanation") or detail.get("code") or detail)
|
||||||
|
return str(detail)
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: object) -> datetime | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Campaign hand-off due date must use ISO 8601.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("Campaign revisions must be integers.")
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Campaign revisions must be integers.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(value: object) -> str | None:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
return candidate or None
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_id(value: object, prefix: str) -> str | None:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
return candidate.removeprefix(prefix) or None if candidate.startswith(prefix) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_ref(assignment: CampaignWorkAssignment) -> str:
|
||||||
|
return f"campaign-work-assignment:{assignment.id}:r{assignment.resource_revision}"
|
||||||
|
|
||||||
|
|
||||||
|
def _action_url(assignment: CampaignWorkAssignment) -> str:
|
||||||
|
return (
|
||||||
|
f"/campaigns/{assignment.campaign_id}/work"
|
||||||
|
f"?assignment={assignment.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_capability(registry: object | None, name: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
registry is not None
|
||||||
|
and hasattr(registry, "has_capability")
|
||||||
|
and registry.has_capability(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ACTION_KEY", "SqlCampaignWorkOrchestrationProvider"]
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
|
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_workflow_definitions(
|
||||||
|
*,
|
||||||
|
module_version: str,
|
||||||
|
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||||
|
"""Return opt-in Campaign workflow templates owned by this module."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
WorkflowDefinitionContribution(
|
||||||
|
origin_module_id="campaigns",
|
||||||
|
origin_module_version=module_version,
|
||||||
|
definition_key="accountable-campaign-work-handoff",
|
||||||
|
name="Accountable Campaign work hand-off",
|
||||||
|
description=(
|
||||||
|
"Create or reference a Campaign, assign bounded work, and wait "
|
||||||
|
"for its revision-bearing completion, rejection, cancellation, "
|
||||||
|
"or timeout event."
|
||||||
|
),
|
||||||
|
graph=_campaign_work_handoff_graph(),
|
||||||
|
definition_kind="template",
|
||||||
|
scope_type="system",
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
allow_start=True,
|
||||||
|
allow_reuse=True,
|
||||||
|
allow_automation=False,
|
||||||
|
execution_mode="guided",
|
||||||
|
activate_on_install=False,
|
||||||
|
required_capabilities=(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,),
|
||||||
|
required_interfaces=("campaigns.work_orchestration",),
|
||||||
|
metadata={
|
||||||
|
"domain": "campaigns.accountable_work",
|
||||||
|
"state_owner": "campaigns",
|
||||||
|
"template_requires_configuration": True,
|
||||||
|
},
|
||||||
|
policy_metadata={
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"campaign_access_rechecked_on_resume": True,
|
||||||
|
"navigation_does_not_complete_work": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_work_handoff_graph() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "workflow.start.manual",
|
||||||
|
"label": "Campaign work requested",
|
||||||
|
"position": {"x": 20, "y": 140},
|
||||||
|
"config": {
|
||||||
|
"input_schema_ref": "govoplan/campaigns/work-handoff.v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prepare",
|
||||||
|
"type": "workflow.capability",
|
||||||
|
"label": "Prepare Campaign work",
|
||||||
|
"position": {"x": 250, "y": 140},
|
||||||
|
"config": {
|
||||||
|
"capability": CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
|
"operation": "campaigns.work.prepare",
|
||||||
|
"input_mapping": {
|
||||||
|
"campaign_id": "$input.campaign_id",
|
||||||
|
"create_campaign": "$input.create_campaign",
|
||||||
|
"expected_campaign_revision": (
|
||||||
|
"$input.expected_campaign_revision"
|
||||||
|
),
|
||||||
|
"purpose": "$input.purpose",
|
||||||
|
"assignee": "$input.assignee",
|
||||||
|
"due_at": "$input.due_at",
|
||||||
|
"mirror_to_tasks": "$input.mirror_to_tasks",
|
||||||
|
},
|
||||||
|
"idempotency_key": "workflow-step",
|
||||||
|
"failure_policy": "manual",
|
||||||
|
"view_surface_ids": ["campaigns.page.work"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "campaign_work",
|
||||||
|
"type": "workflow.external_handoff",
|
||||||
|
"label": "Complete Campaign work",
|
||||||
|
"position": {"x": 510, "y": 140},
|
||||||
|
"config": {
|
||||||
|
"provider_capability": (
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
|
),
|
||||||
|
"event_type": "campaign.work.changed",
|
||||||
|
"event_filter": {
|
||||||
|
"payload": {
|
||||||
|
"assignment_id": (
|
||||||
|
"$steps.prepare.execution.output.assignment_id"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outcome_path": "payload.outcome",
|
||||||
|
"terminal_outcomes": {
|
||||||
|
"completed": "completed",
|
||||||
|
"rejected": "rejected",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
},
|
||||||
|
"observed_outcomes": [
|
||||||
|
"assigned",
|
||||||
|
"accepted",
|
||||||
|
"started",
|
||||||
|
"reassigned",
|
||||||
|
],
|
||||||
|
"external_id": (
|
||||||
|
"$steps.prepare.execution.output.assignment_id"
|
||||||
|
),
|
||||||
|
"expected_revision": (
|
||||||
|
"$steps.prepare.execution.output.assignment_revision"
|
||||||
|
),
|
||||||
|
"action_url": "$steps.prepare.execution.output.action_url",
|
||||||
|
"immutable_ref": (
|
||||||
|
"$steps.prepare.execution.output.assignment_ref"
|
||||||
|
),
|
||||||
|
"optional_capabilities": (
|
||||||
|
"$steps.prepare.execution.output.optional_capabilities"
|
||||||
|
),
|
||||||
|
"timeout_after": "$input.timeout_after",
|
||||||
|
"view_surface_ids": ["campaigns.page.work"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "completed",
|
||||||
|
"type": "workflow.end.completed",
|
||||||
|
"label": "Campaign work completed",
|
||||||
|
"position": {"x": 790, "y": 20},
|
||||||
|
"config": {"output_mapping": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rejected",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Campaign work rejected",
|
||||||
|
"position": {"x": 790, "y": 120},
|
||||||
|
"config": {"reason": "Campaign work was rejected"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cancelled",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Campaign work cancelled",
|
||||||
|
"position": {"x": 790, "y": 220},
|
||||||
|
"config": {"reason": "Campaign work was cancelled"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "timed_out",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Campaign work timed out",
|
||||||
|
"position": {"x": 790, "y": 320},
|
||||||
|
"config": {"reason": "Campaign work timed out"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"id": "start-prepare", "source": "start", "target": "prepare"},
|
||||||
|
{
|
||||||
|
"id": "prepare-work",
|
||||||
|
"source": "prepare",
|
||||||
|
"source_port": "success",
|
||||||
|
"target": "campaign_work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-completed",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "completed",
|
||||||
|
"target": "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-rejected",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "rejected",
|
||||||
|
"target": "rejected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-cancelled",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "cancelled",
|
||||||
|
"target": "cancelled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-timeout",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "timed_out",
|
||||||
|
"target": "timed_out",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"notation": "govoplan.workflow.native",
|
||||||
|
"domain": "campaigns.accountable_work",
|
||||||
|
"configuration_notes": (
|
||||||
|
"Provide either campaign_id or create_campaign and explicit null "
|
||||||
|
"values for unused optional inputs."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["campaign_workflow_definitions"]
|
||||||
@@ -28,6 +28,8 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
CampaignMessageActionAttempt,
|
CampaignMessageActionAttempt,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
@@ -47,6 +49,81 @@ GROUP_ID = "group-1"
|
|||||||
|
|
||||||
|
|
||||||
class CampaignAccessProviderTests(unittest.TestCase):
|
class CampaignAccessProviderTests(unittest.TestCase):
|
||||||
|
def test_work_assignment_provenance_keeps_accountability_separate_from_access(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-work",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=OTHER_USER_ID,
|
||||||
|
external_id="work",
|
||||||
|
name="Work campaign",
|
||||||
|
)
|
||||||
|
checked_at = datetime.now(UTC)
|
||||||
|
assignment = CampaignWorkAssignment(
|
||||||
|
id="assignment-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
purpose="Not disclosed by provenance",
|
||||||
|
status="open",
|
||||||
|
assignee_type="account",
|
||||||
|
assignee_id="account-1",
|
||||||
|
assignee_label_snapshot="Subject",
|
||||||
|
assignee_resolution_state="resolved",
|
||||||
|
resolution_provenance={"policy_code": "assignment_does_not_grant_access"},
|
||||||
|
resolution_checked_at=checked_at,
|
||||||
|
assigned_by_user_id=OTHER_USER_ID,
|
||||||
|
assigned_by_label_snapshot="Other user",
|
||||||
|
)
|
||||||
|
event = CampaignWorkAssignmentEvent(
|
||||||
|
id="assignment-event-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
event_kind="assigned",
|
||||||
|
actor_user_id=OTHER_USER_ID,
|
||||||
|
actor_label_snapshot="Other user",
|
||||||
|
status_snapshot="open",
|
||||||
|
assignee_type_snapshot="account",
|
||||||
|
assignee_id_snapshot="account-1",
|
||||||
|
assignee_label_snapshot="Subject",
|
||||||
|
resolution_state_snapshot="resolved",
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
campaign,
|
||||||
|
assignment,
|
||||||
|
event,
|
||||||
|
CampaignShare(
|
||||||
|
id="share-work",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
target_type="group",
|
||||||
|
target_id=GROUP_ID,
|
||||||
|
permission="read",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
items = CampaignAccessService().explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
_principal(
|
||||||
|
scopes={"campaigns:campaign:read", "campaigns:assignment:read"},
|
||||||
|
group_ids={GROUP_ID},
|
||||||
|
),
|
||||||
|
resource_type="campaign_work_assignment",
|
||||||
|
resource_id=assignment.id,
|
||||||
|
action="campaigns:assignment:read",
|
||||||
|
)
|
||||||
|
work = next(item for item in items if item.source == "campaigns.work_assignment")
|
||||||
|
self.assertEqual("accountability_does_not_grant_access", work.details["authorization_mode"])
|
||||||
|
self.assertEqual(["campaigns:assignment:read"], work.details["permission_actions"])
|
||||||
|
self.assertFalse(work.details["purpose_disclosed"])
|
||||||
|
self.assertNotIn("Not disclosed", repr(items))
|
||||||
|
self.assertTrue(any(item.id == "share-work" for item in items))
|
||||||
|
|
||||||
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
|
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
|
||||||
session = _session()
|
session = _session()
|
||||||
self.addCleanup(_close_session, session)
|
self.addCleanup(_close_session, session)
|
||||||
@@ -975,6 +1052,8 @@ def _session():
|
|||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
CampaignCollaborationEntry.__table__,
|
CampaignCollaborationEntry.__table__,
|
||||||
|
CampaignWorkAssignment.__table__,
|
||||||
|
CampaignWorkAssignmentEvent.__table__,
|
||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
CampaignJob.__table__,
|
CampaignJob.__table__,
|
||||||
CampaignIssue.__table__,
|
CampaignIssue.__table__,
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.routes.assignments import (
|
||||||
|
create_campaign_work_assignment,
|
||||||
|
list_campaign_work_assignment_history,
|
||||||
|
reassign_campaign_work_assignment,
|
||||||
|
reconcile_campaign_work_assignments,
|
||||||
|
transition_campaign_work_assignment,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignWorkAssigneeInput,
|
||||||
|
CampaignWorkAssignmentCreateRequest,
|
||||||
|
CampaignWorkAssignmentReassignRequest,
|
||||||
|
CampaignWorkAssignmentTransitionRequest,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.work_orchestration import (
|
||||||
|
SqlCampaignWorkOrchestrationProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import GroupRef, PrincipalRef, UserRef
|
||||||
|
from govoplan_core.core.campaigns import CampaignWorkHandoffRequest
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.events import EventBus, event_bus_context
|
||||||
|
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||||
|
from govoplan_core.core.tasks import WorkItem
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ID = "tenant-1"
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
tenant_id = TENANT_ID
|
||||||
|
api_key = None
|
||||||
|
|
||||||
|
def __init__(self, user_id: str, account_id: str, *scopes: str) -> None:
|
||||||
|
self.user = SimpleNamespace(
|
||||||
|
id=user_id,
|
||||||
|
display_name=f"User {user_id}",
|
||||||
|
email=f"{user_id}@example.test",
|
||||||
|
)
|
||||||
|
self.account_id = account_id
|
||||||
|
self.scopes = frozenset(scopes)
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes or "tenant:*" in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class _Directory:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.user_2_active = True
|
||||||
|
|
||||||
|
def users_for_tenant(self, tenant_id: str):
|
||||||
|
if tenant_id != TENANT_ID:
|
||||||
|
return ()
|
||||||
|
return (
|
||||||
|
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Owner"),
|
||||||
|
UserRef(
|
||||||
|
id="user-2",
|
||||||
|
account_id="account-2",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
display_name="Collaborator",
|
||||||
|
status="active" if self.user_2_active else "inactive",
|
||||||
|
),
|
||||||
|
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def groups_for_tenant(self, tenant_id: str):
|
||||||
|
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Campaign group"),) if tenant_id == TENANT_ID else ()
|
||||||
|
|
||||||
|
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||||
|
if tenant_id == TENANT_ID and user_id == "user-2":
|
||||||
|
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Campaign group"),)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class _Tasks:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.commands = []
|
||||||
|
|
||||||
|
def create_task(self, _session, _principal, *, command):
|
||||||
|
self.commands.append(command)
|
||||||
|
return WorkItem(
|
||||||
|
id=f"task-{len(self.commands)}",
|
||||||
|
provider_id="tasks",
|
||||||
|
owner_module="tasks",
|
||||||
|
tenant_id=command.tenant_id,
|
||||||
|
title=command.title,
|
||||||
|
assignments=command.assignments,
|
||||||
|
sources=command.sources,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Notifications:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def tenant_id_for_notification(self, _session, *, notification_id: str):
|
||||||
|
del notification_id
|
||||||
|
return TENANT_ID
|
||||||
|
|
||||||
|
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
|
||||||
|
self.requests.append((request, enqueue_delivery))
|
||||||
|
return {"id": f"notification-{len(self.requests)}"}
|
||||||
|
|
||||||
|
def deliver_notification(self, _session, *, notification_id: str):
|
||||||
|
return {"id": notification_id}
|
||||||
|
|
||||||
|
def deliver_pending(self, _session, *, tenant_id=None, limit: int = 50):
|
||||||
|
return {"tenant_id": tenant_id, "limit": limit}
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingTasks(_Tasks):
|
||||||
|
def create_task(self, _session, _principal, *, command):
|
||||||
|
del command
|
||||||
|
raise RuntimeError("Tasks is temporarily unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, tasks: _Tasks | None = None, notifications: _Notifications | None = None) -> None:
|
||||||
|
self.tasks = tasks
|
||||||
|
self.notifications = notifications
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
(name == "tasks.commands" and self.tasks is not None)
|
||||||
|
or (name == "notifications.dispatch" and self.notifications is not None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
if name == "tasks.commands":
|
||||||
|
return self.tasks
|
||||||
|
if name == "notifications.dispatch":
|
||||||
|
return self.notifications
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Organizations:
|
||||||
|
def get_function(self, function_id: str):
|
||||||
|
if function_id != "function-1":
|
||||||
|
return None
|
||||||
|
return OrganizationFunctionRef(
|
||||||
|
id=function_id,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
organization_unit_id="unit-1",
|
||||||
|
slug="campaign-review",
|
||||||
|
name="Campaign review function",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Idm:
|
||||||
|
def organization_function_assignments_for_function(self, function_id: str, *, tenant_id=None, effective_at=None):
|
||||||
|
del effective_at
|
||||||
|
if function_id == "function-1" and tenant_id == TENANT_ID:
|
||||||
|
return (SimpleNamespace(account_id="account-2", status="active", valid_from=None, valid_until=None),)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def organization_function_incumbencies(self, function_ids, *, tenant_id, effective_at=None):
|
||||||
|
del effective_at
|
||||||
|
return {item: SimpleNamespace(assignments=self.organization_function_assignments_for_function(item, tenant_id=tenant_id)) for item in function_ids}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def session() -> Session:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignShare.__table__,
|
||||||
|
CampaignWorkAssignment.__table__,
|
||||||
|
CampaignWorkAssignmentEvent.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||||
|
database = factory()
|
||||||
|
database.add_all(
|
||||||
|
[
|
||||||
|
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
|
||||||
|
for number in range(1, 4)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database.flush()
|
||||||
|
database.add_all(
|
||||||
|
[
|
||||||
|
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
|
||||||
|
for number in range(1, 4)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="campaign-group", name="Campaign group"))
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id="user-1",
|
||||||
|
external_id="campaign-1",
|
||||||
|
name="Campaign One",
|
||||||
|
current_version_id="version-1",
|
||||||
|
)
|
||||||
|
database.add_all(
|
||||||
|
[
|
||||||
|
campaign,
|
||||||
|
CampaignVersion(id="version-1", campaign_id=campaign.id, version_number=1, raw_json={"version": "1.0"}),
|
||||||
|
CampaignShare(
|
||||||
|
id="share-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
target_type="group",
|
||||||
|
target_id="group-1",
|
||||||
|
permission="read",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
try:
|
||||||
|
yield database
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _manager() -> _Principal:
|
||||||
|
return _Principal(
|
||||||
|
"user-1",
|
||||||
|
"account-1",
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assignee() -> _Principal:
|
||||||
|
return _Principal(
|
||||||
|
"user-2",
|
||||||
|
"account-2",
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_principal(user_id: str, account_id: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=user_id,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
scopes=frozenset(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id=account_id),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=user_id,
|
||||||
|
display_name=f"User {user_id}",
|
||||||
|
email=f"{user_id}@example.test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_is_authorization_neutral_and_mirrors_through_optional_tasks(session: Session) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
tasks = _Tasks()
|
||||||
|
notifications = _Notifications()
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.get_registry", return_value=_Registry(tasks, notifications)),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
response = create_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignWorkAssignmentCreateRequest(
|
||||||
|
purpose="Review the frozen recipient import",
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||||
|
reference={"kind": "campaign_version", "id": "version-1"},
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.assignee_label_snapshot == "Collaborator"
|
||||||
|
assert response.assignee_resolution_state == "resolved"
|
||||||
|
assert response.resolution_provenance["policy_code"] == "assignment_does_not_grant_access"
|
||||||
|
assert response.task_mirror_id == "task-1"
|
||||||
|
assert tasks.commands[0].assignments[0].kind == "account"
|
||||||
|
assert tasks.commands[0].provenance["authorization_neutral"] is True
|
||||||
|
assert notifications.requests[0][0].recipient_id == "account-2"
|
||||||
|
assert notifications.requests[0][0].payload["purpose_disclosed"] is False
|
||||||
|
assert notifications.requests[0][1] is False
|
||||||
|
assert session.query(CampaignShare).count() == 1
|
||||||
|
assert [item.event_kind for item in session.query(CampaignWorkAssignmentEvent).all()] == ["assigned"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_rejects_target_without_existing_campaign_access(session: Session) -> None:
|
||||||
|
with patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
create_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignWorkAssignmentCreateRequest(
|
||||||
|
purpose="Should not grant access",
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="account", id="account-3"),
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 422
|
||||||
|
assert exc_info.value.detail["code"] == "campaign_assignment_assignee_inaccessible"
|
||||||
|
assert session.query(CampaignWorkAssignment).count() == 0
|
||||||
|
assert session.query(CampaignShare).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_optional_tasks_failure_is_recorded_without_blocking_campaign_work(session: Session) -> None:
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.get_registry", return_value=_Registry(_FailingTasks())),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
created = create_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignWorkAssignmentCreateRequest(
|
||||||
|
purpose="Continue even without Tasks",
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.task_mirror_status == "failed"
|
||||||
|
assert "RuntimeError" in (created.task_mirror_error or "")
|
||||||
|
assert session.get(CampaignWorkAssignment, created.id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignee_can_start_and_complete_but_not_cancel(session: Session) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||||
|
patch("govoplan_campaign.backend.route_support._access_directory", return_value=directory),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
created = create_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignWorkAssignmentCreateRequest(
|
||||||
|
purpose="Review campaign",
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||||
|
mirror_to_tasks=False,
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
started = transition_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.id,
|
||||||
|
CampaignWorkAssignmentTransitionRequest(expected_revision=1, action="start"),
|
||||||
|
session,
|
||||||
|
_assignee(),
|
||||||
|
)
|
||||||
|
completed = transition_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.id,
|
||||||
|
CampaignWorkAssignmentTransitionRequest(expected_revision=2, action="complete"),
|
||||||
|
session,
|
||||||
|
_assignee(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert started.status == "in_progress"
|
||||||
|
assert completed.status == "completed"
|
||||||
|
assert completed.resource_revision == 3
|
||||||
|
assert [item.event_kind for item in session.query(CampaignWorkAssignmentEvent).order_by(CampaignWorkAssignmentEvent.created_at, CampaignWorkAssignmentEvent.id)] == ["assigned", "started", "completed"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reassignment_and_deactivation_reconciliation_preserve_history(session: Session) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
created = create_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignWorkAssignmentCreateRequest(
|
||||||
|
purpose="Coordinate delivery",
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="group", id="group-1"),
|
||||||
|
mirror_to_tasks=False,
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
reassigned = reassign_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.id,
|
||||||
|
CampaignWorkAssignmentReassignRequest(
|
||||||
|
expected_revision=1,
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||||
|
reason="Named accountability is now required.",
|
||||||
|
mirror_to_tasks=False,
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
directory.user_2_active = False
|
||||||
|
result = reconcile_campaign_work_assignments("campaign-1", 100, session, _manager())
|
||||||
|
history = list_campaign_work_assignment_history("campaign-1", created.id, 50, None, session, _manager())
|
||||||
|
|
||||||
|
assert reassigned.assignee_type == "account"
|
||||||
|
assert result.changed == 1
|
||||||
|
assert result.assignments[0].assignee_resolution_state == "unavailable"
|
||||||
|
assert [item.event_kind for item in reversed(history.items)] == [
|
||||||
|
"assigned",
|
||||||
|
"reassigned",
|
||||||
|
"assignee_unavailable",
|
||||||
|
]
|
||||||
|
assert history.items[1].details["assignee_id"] == "group-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_provider_is_idempotent_emits_typed_events_and_rechecks_access(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
registry = _Registry(_Tasks(), _Notifications())
|
||||||
|
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
|
||||||
|
manager = _api_principal("user-1", "account-1")
|
||||||
|
assignee = _api_principal("user-2", "account-2")
|
||||||
|
request = CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
expected_campaign_revision=1,
|
||||||
|
idempotency_key="workflow-handoff-1",
|
||||||
|
purpose="Review the Campaign evidence",
|
||||||
|
assignee_kind="account",
|
||||||
|
assignee_id="account-2",
|
||||||
|
correlation_id="workflow-correlation-1",
|
||||||
|
workflow_instance_id="workflow-instance-1",
|
||||||
|
workflow_step_id="workflow-step-1",
|
||||||
|
)
|
||||||
|
bus = EventBus()
|
||||||
|
events = []
|
||||||
|
bus.subscribe("campaign.work.changed", events.append)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments._access_directory",
|
||||||
|
return_value=directory,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.route_support._access_directory",
|
||||||
|
return_value=directory,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments.get_registry",
|
||||||
|
return_value=registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
|
||||||
|
return_value=SimpleNamespace(id="audit-workflow-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments.audit_from_principal",
|
||||||
|
side_effect=_commit_audit,
|
||||||
|
),
|
||||||
|
event_bus_context(bus),
|
||||||
|
):
|
||||||
|
created = provider.prepare_handoff(session, manager, request=request)
|
||||||
|
session.commit()
|
||||||
|
replayed = provider.prepare_handoff(session, manager, request=request)
|
||||||
|
accepted = transition_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.assignment_id,
|
||||||
|
CampaignWorkAssignmentTransitionRequest(
|
||||||
|
expected_revision=1,
|
||||||
|
action="accept",
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
)
|
||||||
|
completed = transition_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.assignment_id,
|
||||||
|
CampaignWorkAssignmentTransitionRequest(
|
||||||
|
expected_revision=2,
|
||||||
|
action="complete",
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.replayed is False
|
||||||
|
assert replayed.replayed is True
|
||||||
|
assert created.assignment_id == replayed.assignment_id
|
||||||
|
assert created.campaign_ref == "campaign:campaign-1:version:version-1:r1"
|
||||||
|
assert created.assignment_ref.endswith(":r1")
|
||||||
|
assert created.optional_capabilities == {"tasks": True, "notifications": True}
|
||||||
|
assert session.query(CampaignWorkAssignment).filter(
|
||||||
|
CampaignWorkAssignment.orchestration_idempotency_key
|
||||||
|
== "workflow-handoff-1"
|
||||||
|
).count() == 1
|
||||||
|
assert accepted.status == "in_progress"
|
||||||
|
assert completed.status == "completed"
|
||||||
|
assert [event.payload["outcome"] for event in events] == [
|
||||||
|
"assigned",
|
||||||
|
"accepted",
|
||||||
|
"completed",
|
||||||
|
]
|
||||||
|
assert [event.payload["assignment_revision"] for event in events] == [1, 2, 3]
|
||||||
|
assert all(event.correlation_id == "workflow-correlation-1" for event in events)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.route_support._access_directory",
|
||||||
|
return_value=directory,
|
||||||
|
):
|
||||||
|
allowed = provider.inspect_handoff(
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
assignment_id=created.assignment_id,
|
||||||
|
expected_revision=3,
|
||||||
|
)
|
||||||
|
share = session.get(CampaignShare, "share-1")
|
||||||
|
assert share is not None
|
||||||
|
share.revoked_at = completed.updated_at
|
||||||
|
session.flush()
|
||||||
|
revoked = provider.inspect_handoff(
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
assignment_id=created.assignment_id,
|
||||||
|
expected_revision=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert allowed.allowed is True
|
||||||
|
assert revoked.allowed is False
|
||||||
|
assert revoked.provenance["code"] == "campaign_handoff_access_revoked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_provider_can_create_self_assigned_campaign_and_rejects_stale_revision(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
registry = _Registry()
|
||||||
|
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
|
||||||
|
manager = _api_principal("user-1", "account-1")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments._access_directory",
|
||||||
|
return_value=directory,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments.get_registry",
|
||||||
|
return_value=registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
|
||||||
|
return_value=SimpleNamespace(id="audit-workflow-create"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
created = provider.prepare_handoff(
|
||||||
|
session,
|
||||||
|
manager,
|
||||||
|
request=CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
create_external_id="workflow-created",
|
||||||
|
create_name="Workflow-created Campaign",
|
||||||
|
idempotency_key="workflow-create-1",
|
||||||
|
purpose="Prepare the Campaign",
|
||||||
|
assignee_kind="account",
|
||||||
|
assignee_id="account-1",
|
||||||
|
workflow_instance_id="workflow-instance-create",
|
||||||
|
workflow_step_id="workflow-step-create",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session.get(Campaign, created.campaign_id).external_id == "workflow-created"
|
||||||
|
version = session.get(CampaignVersion, "version-1")
|
||||||
|
assert version is not None
|
||||||
|
version.edit_revision = 2
|
||||||
|
with pytest.raises(ValueError, match="Campaign revision changed"):
|
||||||
|
provider.prepare_handoff(
|
||||||
|
session,
|
||||||
|
manager,
|
||||||
|
request=CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
expected_campaign_revision=1,
|
||||||
|
idempotency_key="workflow-stale-1",
|
||||||
|
purpose="Review stale Campaign",
|
||||||
|
assignee_kind="account",
|
||||||
|
assignee_id="account-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_organization_function_requires_authorized_current_incumbencies(session: Session) -> None:
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.organization_directory", return_value=_Organizations()),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments._idm_function_directory", return_value=_Idm()),
|
||||||
|
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
created = create_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignWorkAssignmentCreateRequest(
|
||||||
|
purpose="Approve the recipient segment",
|
||||||
|
assignee=CampaignWorkAssigneeInput(type="organization_function", id="function-1"),
|
||||||
|
mirror_to_tasks=False,
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_manager(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.assignee_label_snapshot == "Campaign review function"
|
||||||
|
assert created.resolution_provenance["resolved_members"] == 1
|
||||||
|
assert created.resolution_provenance["all_current_incumbents_authorized"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_migration_is_repeatable_and_creates_history_indexes() -> None:
|
||||||
|
migration = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||||
|
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||||
|
connection.execute(text("CREATE TABLE campaign_versions (id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"))
|
||||||
|
context = MigrationContext.configure(connection)
|
||||||
|
with patch.object(migration, "op", Operations(context)):
|
||||||
|
migration.upgrade()
|
||||||
|
migration.upgrade()
|
||||||
|
|
||||||
|
inspector = inspect(connection)
|
||||||
|
assignment_columns = {item["name"] for item in inspector.get_columns("campaign_work_assignments")}
|
||||||
|
assignment_indexes = {item["name"] for item in inspector.get_indexes("campaign_work_assignments")}
|
||||||
|
event_indexes = {item["name"] for item in inspector.get_indexes("campaign_work_assignment_events")}
|
||||||
|
assert {
|
||||||
|
"purpose",
|
||||||
|
"assignee_type",
|
||||||
|
"assignee_id",
|
||||||
|
"assignee_label_snapshot",
|
||||||
|
"assignee_resolution_state",
|
||||||
|
"resolution_provenance",
|
||||||
|
"task_mirror_status",
|
||||||
|
"resource_revision",
|
||||||
|
}.issubset(assignment_columns)
|
||||||
|
assert "ix_campaign_work_assignments_campaign_status" in assignment_indexes
|
||||||
|
assert "ix_campaign_work_assignment_events_history" in event_indexes
|
||||||
|
|
||||||
|
with patch.object(migration, "op", Operations(context)):
|
||||||
|
migration.downgrade()
|
||||||
|
assert not inspect(connection).has_table("campaign_work_assignment_events")
|
||||||
|
assert not inspect(connection).has_table("campaign_work_assignments")
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_orchestration_migration_is_repeatable() -> None:
|
||||||
|
assignments = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
|
||||||
|
)
|
||||||
|
orchestration = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"f3c7a9d2e6b1_v0123_campaign_work_orchestration"
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||||
|
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||||
|
connection.execute(text("CREATE TABLE campaign_versions (id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"))
|
||||||
|
context = MigrationContext.configure(connection)
|
||||||
|
with patch.object(assignments, "op", Operations(context)):
|
||||||
|
assignments.upgrade()
|
||||||
|
with patch.object(orchestration, "op", Operations(context)):
|
||||||
|
orchestration.upgrade()
|
||||||
|
orchestration.upgrade()
|
||||||
|
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_columns("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
indexes = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_indexes("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
assert {
|
||||||
|
"orchestration_idempotency_key",
|
||||||
|
"orchestration_request_sha256",
|
||||||
|
"orchestration_correlation_id",
|
||||||
|
"workflow_instance_id",
|
||||||
|
"workflow_step_id",
|
||||||
|
}.issubset(columns)
|
||||||
|
assert "uq_campaign_work_assignment_orchestration_key" in indexes
|
||||||
|
|
||||||
|
with patch.object(orchestration, "op", Operations(context)):
|
||||||
|
orchestration.downgrade()
|
||||||
|
assert "workflow_instance_id" not in {
|
||||||
|
item["name"]
|
||||||
|
for item in inspect(connection).get_columns(
|
||||||
|
"campaign_work_assignments"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -480,7 +480,15 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
|||||||
"campaign.activity.composer",
|
"campaign.activity.composer",
|
||||||
"campaign.activity.action.post",
|
"campaign.activity.action.post",
|
||||||
"campaign.activity.action.withdraw",
|
"campaign.activity.action.withdraw",
|
||||||
"campaign.activity.action.redact",
|
"campaign.activity.action.redact",
|
||||||
|
"campaign.work",
|
||||||
|
"campaign.work.create",
|
||||||
|
"campaign.work.action.start",
|
||||||
|
"campaign.work.action.complete",
|
||||||
|
"campaign.work.action.reject",
|
||||||
|
"campaign.work.action.reassign",
|
||||||
|
"campaign.work.action.cancel",
|
||||||
|
"campaign.work.history",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert len(ids) == len(set(ids))
|
assert len(ids) == len(set(ids))
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
@@ -102,6 +104,8 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
|||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
CampaignCollaborationEntry.__table__,
|
CampaignCollaborationEntry.__table__,
|
||||||
|
CampaignWorkAssignment.__table__,
|
||||||
|
CampaignWorkAssignmentEvent.__table__,
|
||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
CampaignJob.__table__,
|
CampaignJob.__table__,
|
||||||
CampaignIssue.__table__,
|
CampaignIssue.__table__,
|
||||||
@@ -437,6 +441,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
|||||||
self.session.commit()
|
self.session.commit()
|
||||||
self.provider = CampaignDsarProvider()
|
self.provider = CampaignDsarProvider()
|
||||||
self.subject = DsarSubjectRef(
|
self.subject = DsarSubjectRef(
|
||||||
|
account_id=self.account.id,
|
||||||
membership_id=self.user.id,
|
membership_id=self.user.id,
|
||||||
email="subject@example.test",
|
email="subject@example.test",
|
||||||
)
|
)
|
||||||
@@ -455,6 +460,54 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
|||||||
{topic.id for topic in manifest.documentation},
|
{topic.id for topic in manifest.documentation},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_search_includes_account_assignment_and_immutable_lifecycle_evidence(self) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
assignment = CampaignWorkAssignment(
|
||||||
|
id="work-assignment-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
purpose="Review the individualized notice",
|
||||||
|
status="open",
|
||||||
|
assignee_type="account",
|
||||||
|
assignee_id=self.account.id,
|
||||||
|
assignee_label_snapshot="Subject",
|
||||||
|
assignee_resolution_state="resolved",
|
||||||
|
resolution_provenance={"policy_code": "assignment_does_not_grant_access"},
|
||||||
|
resolution_checked_at=now,
|
||||||
|
assigned_by_user_id=self.other_user.id,
|
||||||
|
assigned_by_label_snapshot="Other",
|
||||||
|
)
|
||||||
|
event = CampaignWorkAssignmentEvent(
|
||||||
|
id="work-assignment-event-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
event_kind="assigned",
|
||||||
|
actor_user_id=self.other_user.id,
|
||||||
|
actor_label_snapshot="Other",
|
||||||
|
status_snapshot="open",
|
||||||
|
assignee_type_snapshot="account",
|
||||||
|
assignee_id_snapshot=self.account.id,
|
||||||
|
assignee_label_snapshot="Subject",
|
||||||
|
resolution_state_snapshot="resolved",
|
||||||
|
)
|
||||||
|
self.session.add_all((assignment, event))
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
work = next(item for item in records if item.resource_type == "campaign_work_assignment")
|
||||||
|
history = next(item for item in records if item.resource_type == "campaign_work_assignment_event")
|
||||||
|
|
||||||
|
self.assertIn("assignee_id", work.data["match_fields"])
|
||||||
|
self.assertEqual("Review the individualized notice", work.data["purpose"])
|
||||||
|
self.assertFalse(work.immutable_evidence)
|
||||||
|
self.assertTrue(history.immutable_evidence)
|
||||||
|
self.assertIn("accountable institutional work history", history.retention_reason)
|
||||||
|
|
||||||
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
|
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
|
||||||
records = self.provider.search_subject(
|
records = self.provider.search_subject(
|
||||||
self.session,
|
self.session,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from collections import Counter
|
|||||||
|
|
||||||
from govoplan_campaign.backend.router import router
|
from govoplan_campaign.backend.router import router
|
||||||
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||||
|
from govoplan_campaign.backend.routes.assignments import router as assignments_router
|
||||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||||
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
|
||||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||||
@@ -27,6 +28,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
|||||||
workflow_routers = (
|
workflow_routers = (
|
||||||
operations_router,
|
operations_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
assignments_router,
|
||||||
collaboration_router,
|
collaboration_router,
|
||||||
versions_router,
|
versions_router,
|
||||||
jobs_router,
|
jobs_router,
|
||||||
@@ -44,7 +46,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
|||||||
actual = _operation_keys(router)
|
actual = _operation_keys(router)
|
||||||
|
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
assert len(actual) == 86
|
assert len(actual) == 93
|
||||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +58,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
|
|||||||
),
|
),
|
||||||
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||||
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
|
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
|
||||||
|
(assignments_router, ("POST", "/campaigns/{campaign_id}/assignments")),
|
||||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
from govoplan_campaign.backend.workflow_definitions import (
|
||||||
|
campaign_workflow_definitions,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_work_handoff_is_an_opt_in_reusable_template() -> None:
|
||||||
|
contribution = campaign_workflow_definitions(module_version="0.1.23")[0]
|
||||||
|
|
||||||
|
assert contribution.origin_module_id == "campaigns"
|
||||||
|
assert contribution.definition_kind == "template"
|
||||||
|
assert contribution.activate_on_install is False
|
||||||
|
assert contribution.allow_reuse is True
|
||||||
|
assert contribution.required_capabilities == (
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
|
)
|
||||||
|
assert contribution.policy_metadata["assignment_authorization_neutral"] is True
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
str(node["id"]): node
|
||||||
|
for node in contribution.graph["nodes"] # type: ignore[index]
|
||||||
|
}
|
||||||
|
prepare = nodes["prepare"]
|
||||||
|
handoff = nodes["campaign_work"]
|
||||||
|
assert prepare["config"]["operation"] == "campaigns.work.prepare" # type: ignore[index]
|
||||||
|
assert handoff["type"] == "workflow.external_handoff"
|
||||||
|
assert handoff["config"]["event_type"] == "campaign.work.changed" # type: ignore[index]
|
||||||
|
assert handoff["config"]["terminal_outcomes"] == { # type: ignore[index]
|
||||||
|
"completed": "completed",
|
||||||
|
"rejected": "rejected",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_contributes_the_current_campaign_work_template() -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
|
||||||
|
assert len(manifest.workflow_definitions) == 1
|
||||||
|
assert manifest.workflow_definitions[0].origin_module_version == manifest.version
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.20",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -36,7 +36,8 @@
|
|||||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||||
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
|
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
|
||||||
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs",
|
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs",
|
||||||
"test:campaign-collaboration": "node tests/campaign-collaboration-ui-structure.test.mjs"
|
"test:campaign-collaboration": "node tests/campaign-collaboration-ui-structure.test.mjs",
|
||||||
|
"test:campaign-work": "node tests/campaign-work-ui-structure.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
@@ -81,6 +81,63 @@ export type CampaignCollaborationCreate = {
|
|||||||
mention_user_ids?: string[];
|
mention_user_ids?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkAssigneeType = "account" | "group" | "organization_function";
|
||||||
|
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "rejected" | "cancelled";
|
||||||
|
export type CampaignWorkAssignmentResolutionState = "resolved" | "unavailable" | "provider_unavailable";
|
||||||
|
|
||||||
|
export type CampaignWorkAssignment = {
|
||||||
|
id: string;
|
||||||
|
campaign_id: string;
|
||||||
|
purpose: string;
|
||||||
|
status: CampaignWorkAssignmentStatus;
|
||||||
|
due_at?: string | null;
|
||||||
|
assignee_type: CampaignWorkAssigneeType;
|
||||||
|
assignee_id: string;
|
||||||
|
assignee_label_snapshot: string;
|
||||||
|
assignee_current_label?: string | null;
|
||||||
|
assignee_resolution_state: CampaignWorkAssignmentResolutionState;
|
||||||
|
resolution_provenance: Record<string, unknown>;
|
||||||
|
resolution_checked_at: string;
|
||||||
|
assigned_by_user_id?: string | null;
|
||||||
|
assigned_by_label: string;
|
||||||
|
reference?: CampaignCollaborationReference | null;
|
||||||
|
completed_at?: string | null;
|
||||||
|
cancelled_at?: string | null;
|
||||||
|
task_mirror_id?: string | null;
|
||||||
|
task_mirror_status: "not_configured" | "mirrored" | "failed" | "skipped";
|
||||||
|
task_mirror_error?: string | null;
|
||||||
|
resource_revision: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkAssignmentListResponse = {
|
||||||
|
items: CampaignWorkAssignment[];
|
||||||
|
next_cursor?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkAssignmentEvent = {
|
||||||
|
id: string;
|
||||||
|
assignment_id: string;
|
||||||
|
event_kind: string;
|
||||||
|
actor_user_id?: string | null;
|
||||||
|
actor_label: string;
|
||||||
|
status: CampaignWorkAssignmentStatus;
|
||||||
|
assignee_type: CampaignWorkAssigneeType;
|
||||||
|
assignee_id: string;
|
||||||
|
assignee_label: string;
|
||||||
|
resolution_state: CampaignWorkAssignmentResolutionState;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignWorkAssignmentHistoryResponse = {
|
||||||
|
items: CampaignWorkAssignmentEvent[];
|
||||||
|
next_cursor?: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignArchiveEncryptionPolicy = {
|
export type CampaignArchiveEncryptionPolicy = {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
|
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
|
||||||
@@ -1865,6 +1922,106 @@ export function campaignCollaborationMentionProvider(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCampaignWorkAssignments(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
options: { cursor?: string | null; limit?: number; statuses?: CampaignWorkAssignmentStatus[] } = {}
|
||||||
|
): Promise<CampaignWorkAssignmentListResponse> {
|
||||||
|
const params = new URLSearchParams({ limit: String(options.limit ?? 50) });
|
||||||
|
if (options.cursor) params.set("cursor", options.cursor);
|
||||||
|
for (const status of options.statuses ?? []) params.append("assignment_status", status);
|
||||||
|
return apiFetch<CampaignWorkAssignmentListResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCampaignWorkAssignment(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
payload: {
|
||||||
|
purpose: string;
|
||||||
|
assignee: { type: CampaignWorkAssigneeType; id: string };
|
||||||
|
due_at?: string | null;
|
||||||
|
reference?: Pick<CampaignCollaborationReference, "kind" | "id"> | null;
|
||||||
|
mirror_to_tasks?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<CampaignWorkAssignment> {
|
||||||
|
return apiFetch<CampaignWorkAssignment>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reassignCampaignWorkAssignment(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
payload: {
|
||||||
|
expected_revision: number;
|
||||||
|
assignee: { type: CampaignWorkAssigneeType; id: string };
|
||||||
|
reason?: string | null;
|
||||||
|
mirror_to_tasks?: boolean;
|
||||||
|
}
|
||||||
|
): Promise<CampaignWorkAssignment> {
|
||||||
|
return apiFetch<CampaignWorkAssignment>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignmentId)}/reassign`,
|
||||||
|
{ method: "POST", body: JSON.stringify(payload) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionCampaignWorkAssignment(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
assignment: Pick<CampaignWorkAssignment, "id" | "resource_revision">,
|
||||||
|
action: "accept" | "start" | "complete" | "reject" | "cancel"
|
||||||
|
): Promise<CampaignWorkAssignment> {
|
||||||
|
return apiFetch<CampaignWorkAssignment>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignment.id)}/transition`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ expected_revision: assignment.resource_revision, action }) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCampaignWorkAssignmentHistory(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
cursor?: string | null
|
||||||
|
): Promise<CampaignWorkAssignmentHistoryResponse> {
|
||||||
|
const params = new URLSearchParams({ limit: "50" });
|
||||||
|
if (cursor) params.set("cursor", cursor);
|
||||||
|
return apiFetch<CampaignWorkAssignmentHistoryResponse>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignmentId)}/history?${params.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reconcileCampaignWorkAssignments(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string
|
||||||
|
): Promise<{ checked: number; changed: number; assignments: CampaignWorkAssignment[] }> {
|
||||||
|
return apiFetch(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/reconcile`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function campaignWorkAssignmentTargetProvider(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
assigneeType: CampaignWorkAssigneeType
|
||||||
|
): ReferenceOptionProvider {
|
||||||
|
return apiReferenceOptionProvider(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/options`,
|
||||||
|
{ assignee_type: assigneeType }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function campaignShareTargetProvider(
|
export function campaignShareTargetProvider(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DateTimeField,
|
||||||
|
Dialog,
|
||||||
|
DismissibleAlert,
|
||||||
|
FormField,
|
||||||
|
PageActionBar,
|
||||||
|
PageLayout,
|
||||||
|
ReferenceSelect,
|
||||||
|
StatusBadge,
|
||||||
|
hasScope
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
|
import {
|
||||||
|
campaignWorkAssignmentTargetProvider,
|
||||||
|
createCampaignWorkAssignment,
|
||||||
|
listCampaignWorkAssignmentHistory,
|
||||||
|
listCampaignWorkAssignments,
|
||||||
|
reassignCampaignWorkAssignment,
|
||||||
|
reconcileCampaignWorkAssignments,
|
||||||
|
transitionCampaignWorkAssignment,
|
||||||
|
type CampaignWorkAssigneeType,
|
||||||
|
type CampaignWorkAssignment,
|
||||||
|
type CampaignWorkAssignmentEvent
|
||||||
|
} from "../../api/campaigns";
|
||||||
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
|
|
||||||
|
type AssignmentDraft = {
|
||||||
|
purpose: string;
|
||||||
|
assigneeType: CampaignWorkAssigneeType;
|
||||||
|
assigneeId: string;
|
||||||
|
dueAt: string;
|
||||||
|
versionId: string;
|
||||||
|
mirrorToTasks: boolean;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: AssignmentDraft = {
|
||||||
|
purpose: "",
|
||||||
|
assigneeType: "account",
|
||||||
|
assigneeId: "",
|
||||||
|
dueAt: "",
|
||||||
|
versionId: "",
|
||||||
|
mirrorToTasks: true,
|
||||||
|
reason: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CampaignWorkPage({
|
||||||
|
settings,
|
||||||
|
auth,
|
||||||
|
campaignId
|
||||||
|
}: {
|
||||||
|
settings: ApiSettings;
|
||||||
|
auth: AuthInfo;
|
||||||
|
campaignId: string;
|
||||||
|
}) {
|
||||||
|
const workspace = useCampaignWorkspaceData(settings, campaignId);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const requestedAssignmentId = searchParams.get("assignment");
|
||||||
|
const [assignments, setAssignments] = useState<CampaignWorkAssignment[]>([]);
|
||||||
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [draft, setDraft] = useState<AssignmentDraft>(EMPTY_DRAFT);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [reassigning, setReassigning] = useState<CampaignWorkAssignment | null>(null);
|
||||||
|
const [cancelling, setCancelling] = useState<CampaignWorkAssignment | null>(null);
|
||||||
|
const [rejecting, setRejecting] = useState<CampaignWorkAssignment | null>(null);
|
||||||
|
const [historyFor, setHistoryFor] = useState<CampaignWorkAssignment | null>(null);
|
||||||
|
const [history, setHistory] = useState<CampaignWorkAssignmentEvent[]>([]);
|
||||||
|
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
|
||||||
|
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const canManage = hasScope(auth, "campaigns:assignment:manage");
|
||||||
|
const canComplete = hasScope(auth, "campaigns:assignment:complete");
|
||||||
|
const targetProvider = useMemo(
|
||||||
|
() => campaignWorkAssignmentTargetProvider(settings, campaignId, draft.assigneeType),
|
||||||
|
[campaignId, draft.assigneeType, settings]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadAssignments = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await listCampaignWorkAssignments(settings, campaignId);
|
||||||
|
setAssignments(response.items);
|
||||||
|
setNextCursor(response.next_cursor ?? null);
|
||||||
|
setHasMore(response.has_more);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [campaignId, settings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAssignments();
|
||||||
|
}, [loadAssignments]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestedAssignmentId || loading) return;
|
||||||
|
document
|
||||||
|
.getElementById(`campaign-assignment-${requestedAssignmentId}`)
|
||||||
|
?.focus({ preventScroll: false });
|
||||||
|
}, [assignments, loading, requestedAssignmentId]);
|
||||||
|
|
||||||
|
function replaceAssignment(updated: CampaignWorkAssignment) {
|
||||||
|
setAssignments((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
setMessage("");
|
||||||
|
await Promise.all([loadAssignments(), workspace.reload({ force: true })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!nextCursor || loadingMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await listCampaignWorkAssignments(settings, campaignId, { cursor: nextCursor });
|
||||||
|
setAssignments((current) => [
|
||||||
|
...current,
|
||||||
|
...response.items.filter((item) => !current.some((existing) => existing.id === item.id))
|
||||||
|
]);
|
||||||
|
setNextCursor(response.next_cursor ?? null);
|
||||||
|
setHasMore(response.has_more);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAssignment() {
|
||||||
|
if (!draft.purpose.trim() || !draft.assigneeId || busyId) return;
|
||||||
|
setBusyId("create");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await createCampaignWorkAssignment(settings, campaignId, {
|
||||||
|
purpose: draft.purpose.trim(),
|
||||||
|
assignee: { type: draft.assigneeType, id: draft.assigneeId },
|
||||||
|
due_at: draft.dueAt ? new Date(draft.dueAt).toISOString() : null,
|
||||||
|
reference: draft.versionId ? { kind: "campaign_version", id: draft.versionId } : null,
|
||||||
|
mirror_to_tasks: draft.mirrorToTasks
|
||||||
|
});
|
||||||
|
setAssignments((current) => [created, ...current]);
|
||||||
|
setDraft(EMPTY_DRAFT);
|
||||||
|
setCreateOpen(false);
|
||||||
|
setMessage("Work assignment created. Access and ownership were not changed.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reassign() {
|
||||||
|
if (!reassigning || !draft.assigneeId || busyId) return;
|
||||||
|
setBusyId(reassigning.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await reassignCampaignWorkAssignment(settings, campaignId, reassigning.id, {
|
||||||
|
expected_revision: reassigning.resource_revision,
|
||||||
|
assignee: { type: draft.assigneeType, id: draft.assigneeId },
|
||||||
|
reason: draft.reason.trim() || null,
|
||||||
|
mirror_to_tasks: draft.mirrorToTasks
|
||||||
|
});
|
||||||
|
replaceAssignment(updated);
|
||||||
|
setReassigning(null);
|
||||||
|
setDraft(EMPTY_DRAFT);
|
||||||
|
setMessage("Work reassigned; the previous target remains in history.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(assignment: CampaignWorkAssignment, action: "accept" | "start" | "complete" | "reject" | "cancel") {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(assignment.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
|
||||||
|
replaceAssignment(updated);
|
||||||
|
setCancelling(null);
|
||||||
|
setRejecting(null);
|
||||||
|
const resultLabel = {
|
||||||
|
accept: "accepted",
|
||||||
|
start: "started",
|
||||||
|
complete: "completed",
|
||||||
|
reject: "rejected",
|
||||||
|
cancel: "cancelled"
|
||||||
|
}[action];
|
||||||
|
setMessage(`Work ${resultLabel}.`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcile() {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId("reconcile");
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const result = await reconcileCampaignWorkAssignments(settings, campaignId);
|
||||||
|
await loadAssignments();
|
||||||
|
setMessage(`Checked ${result.checked} active assignments; ${result.changed} resolution state${result.changed === 1 ? "" : "s"} changed.`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setBusyId("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openHistory(assignment: CampaignWorkAssignment) {
|
||||||
|
setHistoryFor(assignment);
|
||||||
|
setHistory([]);
|
||||||
|
setHistoryLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const response = await listCampaignWorkAssignmentHistory(settings, campaignId, assignment.id);
|
||||||
|
setHistory(response.items);
|
||||||
|
setHistoryCursor(response.next_cursor ?? null);
|
||||||
|
setHistoryHasMore(response.has_more);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOlderHistory() {
|
||||||
|
if (!historyFor || !historyCursor || historyLoading) return;
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await listCampaignWorkAssignmentHistory(settings, campaignId, historyFor.id, historyCursor);
|
||||||
|
setHistory((current) => [...current, ...response.items]);
|
||||||
|
setHistoryCursor(response.next_cursor ?? null);
|
||||||
|
setHistoryHasMore(response.has_more);
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorText(err));
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginCreate() {
|
||||||
|
setDraft({ ...EMPTY_DRAFT, versionId: workspace.data.campaign?.current_version_id ?? "" });
|
||||||
|
setCreateOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginReassign(assignment: CampaignWorkAssignment) {
|
||||||
|
setDraft({
|
||||||
|
...EMPTY_DRAFT,
|
||||||
|
assigneeType: assignment.assignee_type,
|
||||||
|
assigneeId: assignment.assignee_id
|
||||||
|
});
|
||||||
|
setReassigning(assignment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
archetype="collection"
|
||||||
|
mode="workspace"
|
||||||
|
interfaceId="campaigns.page.work"
|
||||||
|
helpContextId="campaign.work"
|
||||||
|
helpModuleId="campaign"
|
||||||
|
title="Campaign work"
|
||||||
|
description="Assign accountable work without granting Campaign access or transferring ownership."
|
||||||
|
loading={loading || workspace.loading}
|
||||||
|
loadingLabel="Loading Campaign work…"
|
||||||
|
error={error || workspace.error}
|
||||||
|
success={message}
|
||||||
|
actions={
|
||||||
|
<PageActionBar
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(), loading: loading || workspace.loading }}
|
||||||
|
createAction={canManage ? <Button variant="primary" onClick={beginCreate} helpContextId="campaign.work.create" helpModuleId="campaign"><Plus size={16} aria-hidden="true" /> Add assignment</Button> : null}
|
||||||
|
contextActions={canManage ? <Button onClick={() => void reconcile()} disabled={Boolean(busyId)}><RefreshCw size={16} aria-hidden="true" /> Reconcile assignees</Button> : null}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DismissibleAlert tone="info" resetKey={campaignId}>
|
||||||
|
Assignment records responsibility only. Campaign sharing, ownership transfer, approval and Audit remain separate governed surfaces.
|
||||||
|
</DismissibleAlert>
|
||||||
|
|
||||||
|
<Card title="Accountable work" interfaceId="campaigns.work.list" helpContextId="campaign.work" helpModuleId="campaign">
|
||||||
|
{assignments.length === 0 ? (
|
||||||
|
<p className="muted">No work has been assigned for this Campaign.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="campaign-work-list" aria-label="Campaign work assignments">
|
||||||
|
{assignments.map((assignment) => (
|
||||||
|
<li
|
||||||
|
key={assignment.id}
|
||||||
|
id={`campaign-assignment-${assignment.id}`}
|
||||||
|
className={`campaign-work-item${assignment.id === requestedAssignmentId ? " is-focused" : ""}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<article>
|
||||||
|
<header className="campaign-work-item-header">
|
||||||
|
<div>
|
||||||
|
<h3>{assignment.purpose}</h3>
|
||||||
|
<p className="muted small-note">
|
||||||
|
Assigned to <strong>{assignment.assignee_current_label || assignment.assignee_label_snapshot}</strong>
|
||||||
|
{` · ${assignment.assignee_type.replace(/_/g, " ")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="campaign-work-badges">
|
||||||
|
<StatusBadge status={assignment.status} label={assignment.status.replace(/_/g, " ")} />
|
||||||
|
<StatusBadge
|
||||||
|
status={assignment.assignee_resolution_state === "resolved" ? "active" : "warning"}
|
||||||
|
label={assignment.assignee_resolution_state.replace(/_/g, " ")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<dl className="campaign-work-meta">
|
||||||
|
<div><dt>Assigned by</dt><dd>{assignment.assigned_by_label}</dd></div>
|
||||||
|
<div><dt>Due</dt><dd>{assignment.due_at ? formatDate(assignment.due_at) : "No due date"}</dd></div>
|
||||||
|
<div><dt>Reference</dt><dd>{assignment.reference?.label ?? "Campaign"}</dd></div>
|
||||||
|
<div><dt>Tasks mirror</dt><dd>{assignment.task_mirror_status.replace(/_/g, " ")}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{assignment.assignee_resolution_state !== "resolved" ? (
|
||||||
|
<DismissibleAlert tone="warning" resetKey={`${assignment.id}:${assignment.resource_revision}`}>
|
||||||
|
This target is no longer available or cannot currently be resolved. History is retained; reassign it or restore the separate Campaign access/directory relationship.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
<div className="button-row compact-actions campaign-work-actions">
|
||||||
|
<Button onClick={() => void openHistory(assignment)} disabled={busyId === assignment.id} helpContextId="campaign.work.history" helpModuleId="campaign">
|
||||||
|
<History size={16} aria-hidden="true" /> History
|
||||||
|
</Button>
|
||||||
|
{canComplete && assignment.status === "open" ? (
|
||||||
|
<Button onClick={() => void transition(assignment, "accept")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
|
||||||
|
<Play size={16} aria-hidden="true" /> Accept
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||||
|
<Button variant="primary" onClick={() => void transition(assignment, "complete")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.complete" helpModuleId="campaign">
|
||||||
|
<CheckCircle2 size={16} aria-hidden="true" /> Complete
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||||
|
<Button onClick={() => beginReassign(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.reassign" helpModuleId="campaign">
|
||||||
|
<UserRoundCog size={16} aria-hidden="true" /> Reassign
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||||
|
<span className="campaign-work-destructive-action">
|
||||||
|
<Button variant="danger" onClick={() => setRejecting(assignment)} disabled={Boolean(busyId)}>
|
||||||
|
Reject work
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||||
|
<span className="campaign-work-destructive-action">
|
||||||
|
<Button variant="danger" onClick={() => setCancelling(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.cancel" helpModuleId="campaign">
|
||||||
|
Cancel work
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{hasMore ? (
|
||||||
|
<div className="button-row compact-actions campaign-work-load-more">
|
||||||
|
<Button onClick={() => void loadMore()} disabled={loadingMore}>{loadingMore ? "Loading…" : "Load older work"}</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={createOpen}
|
||||||
|
title="Add Campaign work assignment"
|
||||||
|
onClose={() => !busyId && setCreateOpen(false)}
|
||||||
|
footer={<><Button onClick={() => setCreateOpen(false)} disabled={Boolean(busyId)}>Close</Button><Button variant="primary" onClick={() => void createAssignment()} disabled={Boolean(busyId) || !draft.purpose.trim() || !draft.assigneeId}>Create assignment</Button></>}
|
||||||
|
>
|
||||||
|
<AssignmentFields draft={draft} setDraft={setDraft} provider={targetProvider} versions={workspace.data.versions} busy={Boolean(busyId)} includePurpose />
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(reassigning)}
|
||||||
|
title="Reassign Campaign work"
|
||||||
|
onClose={() => !busyId && setReassigning(null)}
|
||||||
|
footer={<><Button onClick={() => setReassigning(null)} disabled={Boolean(busyId)}>Close</Button><Button variant="primary" onClick={() => void reassign()} disabled={Boolean(busyId) || !draft.assigneeId}>Reassign</Button></>}
|
||||||
|
>
|
||||||
|
<AssignmentFields draft={draft} setDraft={setDraft} provider={targetProvider} versions={[]} busy={Boolean(busyId)} includeReason />
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={Boolean(historyFor)}
|
||||||
|
title={historyFor ? `History: ${historyFor.purpose}` : "Assignment history"}
|
||||||
|
onClose={() => !historyLoading && setHistoryFor(null)}
|
||||||
|
footer={<Button onClick={() => setHistoryFor(null)} disabled={historyLoading}>Close</Button>}
|
||||||
|
>
|
||||||
|
{historyLoading && history.length === 0 ? <p className="muted">Loading history…</p> : (
|
||||||
|
<ol className="campaign-work-history">
|
||||||
|
{history.map((event) => (
|
||||||
|
<li key={event.id}>
|
||||||
|
<div><StatusBadge status={event.status} /> <strong>{event.event_kind.replace(/_/g, " ")}</strong></div>
|
||||||
|
<p>{event.assignee_label} · {event.resolution_state.replace(/_/g, " ")}</p>
|
||||||
|
<small>{event.actor_label} · {formatDate(event.created_at)}</small>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{historyHasMore ? <Button onClick={() => void loadOlderHistory()} disabled={historyLoading}>Load older history</Button> : null}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(rejecting)}
|
||||||
|
title="Reject assigned work?"
|
||||||
|
message="The work will close as rejected, separately from cancellation. Its purpose, assignee and transition history remain durable evidence."
|
||||||
|
confirmLabel="Reject work"
|
||||||
|
tone="danger"
|
||||||
|
busy={Boolean(busyId)}
|
||||||
|
onCancel={() => setRejecting(null)}
|
||||||
|
onConfirm={() => rejecting ? void transition(rejecting, "reject") : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(cancelling)}
|
||||||
|
title="Cancel assigned work?"
|
||||||
|
message="The work will close without being completed. Its purpose, assignee and transition history remain as durable evidence."
|
||||||
|
confirmLabel="Cancel work"
|
||||||
|
tone="danger"
|
||||||
|
busy={Boolean(busyId)}
|
||||||
|
onCancel={() => setCancelling(null)}
|
||||||
|
onConfirm={() => cancelling ? void transition(cancelling, "cancel") : undefined}
|
||||||
|
/>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentFields({
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
provider,
|
||||||
|
versions,
|
||||||
|
busy,
|
||||||
|
includePurpose = false,
|
||||||
|
includeReason = false
|
||||||
|
}: {
|
||||||
|
draft: AssignmentDraft;
|
||||||
|
setDraft: (draft: AssignmentDraft) => void;
|
||||||
|
provider: ReturnType<typeof campaignWorkAssignmentTargetProvider>;
|
||||||
|
versions: Array<{ id: string; version_number: number }>;
|
||||||
|
busy: boolean;
|
||||||
|
includePurpose?: boolean;
|
||||||
|
includeReason?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="campaign-work-form">
|
||||||
|
{includePurpose ? (
|
||||||
|
<FormField label="Purpose" help="Describe one bounded outcome. This text is retained in assignment history and may be mirrored to Tasks." helpContextId="campaign.work.create" helpModuleId="campaign">
|
||||||
|
<textarea rows={3} maxLength={500} value={draft.purpose} disabled={busy} onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
|
<FormField label="Assignee type" help="The selected target must already have Campaign access; assigning work never grants it.">
|
||||||
|
<select value={draft.assigneeType} disabled={busy} onChange={(event) => setDraft({ ...draft, assigneeType: event.target.value as CampaignWorkAssigneeType, assigneeId: "" })}>
|
||||||
|
<option value="account">Account</option>
|
||||||
|
<option value="group">Group</option>
|
||||||
|
<option value="organization_function">Organization function</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Assignee">
|
||||||
|
<ReferenceSelect value={draft.assigneeId} onChange={(assigneeId) => setDraft({ ...draft, assigneeId })} provider={provider} disabled={busy} placeholder="Search authorized targets" />
|
||||||
|
</FormField>
|
||||||
|
{includePurpose ? (
|
||||||
|
<FormField label="Due at" help="Optional; shown in Campaign work and an available Tasks mirror.">
|
||||||
|
<DateTimeField value={draft.dueAt} onChange={(dueAt) => setDraft({ ...draft, dueAt })} disabled={busy} />
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
|
{includePurpose && versions.length > 0 ? (
|
||||||
|
<FormField label="Campaign evidence reference" help="Optionally attach this work to one immutable Campaign version.">
|
||||||
|
<select value={draft.versionId} disabled={busy} onChange={(event) => setDraft({ ...draft, versionId: event.target.value })}>
|
||||||
|
<option value="">Campaign only</option>
|
||||||
|
{versions.map((version) => <option key={version.id} value={version.id}>Version {version.version_number}</option>)}
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
|
{includeReason ? (
|
||||||
|
<FormField label="Reason" help="Optional bounded context retained with the reassignment event.">
|
||||||
|
<textarea rows={3} maxLength={500} value={draft.reason} disabled={busy} onChange={(event) => setDraft({ ...draft, reason: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
) : null}
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input type="checkbox" checked={draft.mirrorToTasks} disabled={busy} onChange={(event) => setDraft({ ...draft, mirrorToTasks: event.target.checked })} />
|
||||||
|
Mirror into Tasks when the optional provider is available
|
||||||
|
</label>
|
||||||
|
<p className="muted small-note">Tasks is only a projection. Campaign remains authoritative, and no notification or mirror grants access.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : "Campaign work could not be updated.";
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
|
|||||||
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
|
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
|
||||||
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
|
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
|
||||||
const CampaignCollaborationPage = lazy(() => import("./CampaignCollaborationPage"));
|
const CampaignCollaborationPage = lazy(() => import("./CampaignCollaborationPage"));
|
||||||
|
const CampaignWorkPage = lazy(() => import("./CampaignWorkPage"));
|
||||||
|
|
||||||
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
|
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
|
||||||
overview: "",
|
overview: "",
|
||||||
@@ -41,6 +42,7 @@ const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
|
|||||||
review: "review",
|
review: "review",
|
||||||
report: "report",
|
report: "report",
|
||||||
activity: "activity",
|
activity: "activity",
|
||||||
|
work: "work",
|
||||||
audit: "audit",
|
audit: "audit",
|
||||||
json: "json"
|
json: "json"
|
||||||
};
|
};
|
||||||
@@ -90,7 +92,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceLayout
|
<WorkspaceLayout
|
||||||
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} />}
|
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} canReadWork={hasScope(auth, "campaigns:assignment:read")} />}
|
||||||
primaryLabel="Campaign sections"
|
primaryLabel="Campaign sections"
|
||||||
contentLabel="Campaign workspace"
|
contentLabel="Campaign workspace"
|
||||||
interfaceId="campaign.workspace"
|
interfaceId="campaign.workspace"
|
||||||
@@ -116,6 +118,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||||
|
<Route path="work" element={hasScope(auth, "campaigns:assignment:read") ? <CampaignWorkPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||||
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
|
||||||
@@ -153,6 +156,7 @@ function sectionFromPath(pathname: string): CampaignWorkspaceNavigationSection {
|
|||||||
if (section === "send") return "review";
|
if (section === "send") return "review";
|
||||||
if (section === "report" || section === "reports") return "report";
|
if (section === "report" || section === "reports") return "report";
|
||||||
if (section === "activity" || section === "collaboration") return "activity";
|
if (section === "activity" || section === "collaboration") return "activity";
|
||||||
|
if (section === "work" || section === "assignments") return "work";
|
||||||
if (section === "audit") return "audit";
|
if (section === "audit") return "audit";
|
||||||
if (section === "json") return "json";
|
if (section === "json") return "json";
|
||||||
return "overview";
|
return "overview";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { CampaignWorkspaceSection } from "../types";
|
import type { CampaignWorkspaceSection } from "../types";
|
||||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type CampaignWorkspaceNavigationSection = CampaignWorkspaceSection | "activity";
|
export type CampaignWorkspaceNavigationSection = CampaignWorkspaceSection | "activity" | "work";
|
||||||
|
|
||||||
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] = [
|
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] = [
|
||||||
{
|
{
|
||||||
@@ -40,6 +40,7 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] =
|
|||||||
title: "i18n:govoplan-campaign.report.7b8ddb90",
|
title: "i18n:govoplan-campaign.report.7b8ddb90",
|
||||||
items: [
|
items: [
|
||||||
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
|
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
|
||||||
|
{ id: "work", label: "Campaign work" },
|
||||||
{ id: "activity", label: "i18n:govoplan-campaign.collaboration" },
|
{ id: "activity", label: "i18n:govoplan-campaign.collaboration" },
|
||||||
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
|
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
|
||||||
|
|
||||||
@@ -53,18 +54,20 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] =
|
|||||||
export default function SectionSidebar({
|
export default function SectionSidebar({
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
canReadActivity
|
canReadActivity,
|
||||||
|
canReadWork
|
||||||
|
|
||||||
|
|
||||||
}: {
|
}: {
|
||||||
active: CampaignWorkspaceNavigationSection;
|
active: CampaignWorkspaceNavigationSection;
|
||||||
onSelect: (section: CampaignWorkspaceNavigationSection) => void;
|
onSelect: (section: CampaignWorkspaceNavigationSection) => void;
|
||||||
canReadActivity: boolean;
|
canReadActivity: boolean;
|
||||||
|
canReadWork: boolean;
|
||||||
}) {
|
}) {
|
||||||
const groups = campaignSubnav.map((group) => ({
|
const groups = campaignSubnav.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
items: group.items.filter((item) => item.id !== "activity" || canReadActivity)
|
items: group.items.filter((item) =>
|
||||||
|
(item.id !== "activity" || canReadActivity)
|
||||||
|
&& (item.id !== "work" || canReadWork)
|
||||||
|
)
|
||||||
}));
|
}));
|
||||||
return <ModuleSubnav active={active} groups={groups} onSelect={onSelect} />;
|
return <ModuleSubnav active={active} groups={groups} onSelect={onSelect} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -88,9 +88,16 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["files", "mail"],
|
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
|
||||||
translations,
|
translations,
|
||||||
viewSurfaces: [
|
viewSurfaces: [
|
||||||
|
{
|
||||||
|
id: "campaigns.page.work",
|
||||||
|
moduleId: "campaigns",
|
||||||
|
kind: "page",
|
||||||
|
label: "Campaign work",
|
||||||
|
order: 44
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "campaigns.page.activity",
|
id: "campaigns.page.activity",
|
||||||
moduleId: "campaigns",
|
moduleId: "campaigns",
|
||||||
|
|||||||
@@ -2799,9 +2799,33 @@
|
|||||||
.campaign-collaboration-tombstone { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 9px 11px; border: var(--border-line); border-radius: var(--radius-sm); color: var(--muted); background: var(--subtle-bg); font-style: italic; }
|
.campaign-collaboration-tombstone { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 9px 11px; border: var(--border-line); border-radius: var(--radius-sm); color: var(--muted); background: var(--subtle-bg); font-style: italic; }
|
||||||
.campaign-collaboration-entry-actions { justify-content: flex-end; padding-top: 4px; border-top: var(--border-line); }
|
.campaign-collaboration-entry-actions { justify-content: flex-end; padding-top: 4px; border-top: var(--border-line); }
|
||||||
.campaign-collaboration-load-more { justify-content: center; margin-top: 14px; }
|
.campaign-collaboration-load-more { justify-content: center; margin-top: 14px; }
|
||||||
|
.campaign-work-list,
|
||||||
|
.campaign-work-history { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||||
|
.campaign-work-item { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-bg); }
|
||||||
|
.campaign-work-item.is-focused { box-shadow: var(--focus-ring-strong); }
|
||||||
|
.campaign-work-item article { display: grid; gap: 14px; padding: 16px; }
|
||||||
|
.campaign-work-item-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||||
|
.campaign-work-item-header h3 { margin: 0 0 4px; font-size: var(--font-size-md); }
|
||||||
|
.campaign-work-item-header p { margin: 0; }
|
||||||
|
.campaign-work-badges { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.campaign-work-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0; }
|
||||||
|
.campaign-work-meta div { min-width: 0; }
|
||||||
|
.campaign-work-meta dt { color: var(--muted); font-size: var(--font-size-sm); }
|
||||||
|
.campaign-work-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||||
|
.campaign-work-actions { align-items: center; padding-top: 4px; border-top: var(--border-line); }
|
||||||
|
.campaign-work-destructive-action { display: inline-flex; margin-inline-start: auto; padding-inline-start: 16px; border-inline-start: var(--border-line); }
|
||||||
|
.campaign-work-load-more { justify-content: center; margin-top: 14px; }
|
||||||
|
.campaign-work-form { display: grid; gap: 14px; }
|
||||||
|
.campaign-work-form textarea,
|
||||||
|
.campaign-work-form select { width: 100%; }
|
||||||
|
.campaign-work-history li { display: grid; gap: 5px; padding: 12px; border: var(--border-line); border-radius: var(--radius-sm); }
|
||||||
|
.campaign-work-history p,
|
||||||
|
.campaign-work-history small { margin: 0; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.campaign-collaboration-options { grid-template-columns: minmax(0, 1fr); }
|
.campaign-collaboration-options { grid-template-columns: minmax(0, 1fr); }
|
||||||
.campaign-collaboration-submit,
|
.campaign-collaboration-submit,
|
||||||
.campaign-collaboration-entry-actions { justify-content: flex-start; }
|
.campaign-collaboration-entry-actions { justify-content: flex-start; }
|
||||||
|
.campaign-work-meta { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.campaign-work-destructive-action { width: 100%; margin-inline-start: 0; padding: 12px 0 0; border-inline-start: 0; border-top: var(--border-line); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
const page = readFileSync(new URL("../src/features/campaigns/CampaignWorkPage.tsx", import.meta.url), "utf8");
|
||||||
|
const api = readFileSync(new URL("../src/api/campaigns.ts", import.meta.url), "utf8");
|
||||||
|
const workspace = readFileSync(new URL("../src/features/campaigns/CampaignWorkspace.tsx", import.meta.url), "utf8");
|
||||||
|
const sidebar = readFileSync(new URL("../src/layout/SectionSidebar.tsx", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
for (const primitive of [
|
||||||
|
"PageLayout",
|
||||||
|
"PageActionBar",
|
||||||
|
"Card",
|
||||||
|
"FormField",
|
||||||
|
"ReferenceSelect",
|
||||||
|
"DateTimeField",
|
||||||
|
"Dialog",
|
||||||
|
"ConfirmDialog",
|
||||||
|
"StatusBadge",
|
||||||
|
"DismissibleAlert"
|
||||||
|
]) {
|
||||||
|
assert.match(page, new RegExp(`\\b${primitive}\\b`), `Campaign work should use centralized ${primitive}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(page, /archetype="collection"/);
|
||||||
|
assert.match(page, /refreshable/);
|
||||||
|
assert.match(page, /createAction=/);
|
||||||
|
assert.match(page, /campaign-work-destructive-action/);
|
||||||
|
assert.match(page, /tone="danger"/);
|
||||||
|
assert.match(page, /assignment records responsibility only/i);
|
||||||
|
assert.match(page, /Campaign sharing, ownership transfer, approval and Audit remain separate/i);
|
||||||
|
assert.match(page, /Reconcile assignees/);
|
||||||
|
assert.match(page, /History/);
|
||||||
|
|
||||||
|
for (const contract of [
|
||||||
|
"/assignments?",
|
||||||
|
"/assignments/${encodeURIComponent(assignment.id)}/transition",
|
||||||
|
"/assignments/${encodeURIComponent(assignmentId)}/reassign",
|
||||||
|
"/assignments/${encodeURIComponent(assignmentId)}/history",
|
||||||
|
"/assignments/reconcile",
|
||||||
|
"/assignments/options"
|
||||||
|
]) {
|
||||||
|
assert.ok(api.includes(contract), `Campaign API should expose ${contract}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(workspace, /path="work"/);
|
||||||
|
assert.match(workspace, /campaigns:assignment:read/);
|
||||||
|
assert.match(sidebar, /id: "work"/);
|
||||||
|
assert.match(sidebar, /canReadWork/);
|
||||||
|
|
||||||
|
console.log("campaign work UI structure tests passed");
|
||||||
Reference in New Issue
Block a user