feat(campaigns): add accountable work assignments
Module Package Release / publish-packages (push) Successful in 14s

This commit is contained in:
2026-08-22 00:50:03 +02:00
parent c2f083e5f6
commit 2630498026
23 changed files with 3011 additions and 15 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-campaign" name = "govoplan-campaign"
version = "0.1.20" version = "0.1.21"
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"
@@ -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,96 @@ 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__ = (
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)
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 +946,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,53 @@ 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 start and complete their own work; managers can reassign or cancel it. 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.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.",
"Start and complete your own assignment, 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. 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. 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 +986,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 +1096,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,
+119 -1
View File
@@ -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)
+53 -1
View File
@@ -51,6 +51,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
@@ -121,6 +124,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.",
"Campaign work",
),
_permission(
"campaigns:assignment:complete",
"Complete assigned campaign work",
"Start or complete 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 +345,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 +364,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 +384,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 +439,7 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest( manifest = ModuleManifest(
id="campaigns", id="campaigns",
name="Campaigns", name="Campaigns",
version="0.1.20", version="0.1.21",
required_capabilities=( required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -420,6 +448,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 +464,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"),
@@ -550,6 +584,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",
@@ -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")
+2
View File
@@ -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,
@@ -0,0 +1,988 @@
from __future__ import annotations
import base64
import binascii
import json
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignShare,
CampaignWorkAssignment,
CampaignWorkAssignmentEvent,
)
from govoplan_campaign.backend.route_support import (
_access_directory,
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.routes.collaboration import _validated_reference
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.schemas import (
CampaignWorkAssigneeInput,
CampaignWorkAssignmentCreateRequest,
CampaignWorkAssignmentEventResponse,
CampaignWorkAssignmentHistoryResponse,
CampaignWorkAssignmentListResponse,
CampaignWorkAssignmentReassignRequest,
CampaignWorkAssignmentReconcileResponse,
CampaignWorkAssignmentReferenceResponse,
CampaignWorkAssignmentResponse,
CampaignWorkAssignmentTransitionRequest,
)
from govoplan_core.api.v1.schemas import ReferenceOptionListResponse, ReferenceOptionResponse
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.idm import (
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
IdmFunctionAssignmentDirectory,
)
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_core.core.organizations import organization_directory
from govoplan_core.core.references import ReferenceOption
from govoplan_core.core.tasks import (
TaskCreateCommand,
WorkAssignmentRef,
WorkSourceRef,
task_command_provider,
)
from govoplan_core.db.session import get_session
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/campaigns", tags=["campaign work assignments"])
AssigneeType = Literal["account", "group", "organization_function"]
@dataclass(frozen=True, slots=True)
class _AssigneeResolution:
state: Literal["resolved", "unavailable", "provider_unavailable"]
label: str | None
recipient_type: str
recipient_id: str
account_ids: tuple[str, ...]
provenance: dict[str, object]
@router.get(
"/{campaign_id}/assignments/options",
response_model=ReferenceOptionListResponse,
)
def search_campaign_assignment_options(
campaign_id: str,
assignee_type: AssigneeType,
q: str = "",
selected: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> ReferenceOptionListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
options = _assignment_options(
session,
campaign=campaign,
assignee_type=assignee_type,
query=q,
selected_values=selected,
limit=limit,
)
provider_available = (
assignee_type != "organization_function"
or (
organization_directory(get_registry()) is not None
and _idm_function_directory() is not None
)
)
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**item.to_dict()) for item in options],
provider_available=provider_available,
next_cursor=None,
has_more=False,
)
@router.get(
"/{campaign_id}/assignments",
response_model=CampaignWorkAssignmentListResponse,
)
def list_campaign_work_assignments(
campaign_id: str,
assignment_status: list[str] = Query(default=[]),
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
) -> CampaignWorkAssignmentListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
statuses = tuple(dict.fromkeys(item.strip() for item in assignment_status if item.strip()))
if any(item not in {"open", "in_progress", "completed", "cancelled"} for item in statuses):
raise HTTPException(status_code=422, detail="Unsupported assignment status filter.")
query = session.query(CampaignWorkAssignment).filter(
CampaignWorkAssignment.tenant_id == principal.tenant_id,
CampaignWorkAssignment.campaign_id == campaign.id,
)
if statuses:
query = query.filter(CampaignWorkAssignment.status.in_(statuses))
if cursor:
created_at, assignment_id = _decode_cursor(cursor)
query = query.filter(
or_(
CampaignWorkAssignment.created_at < created_at,
and_(
CampaignWorkAssignment.created_at == created_at,
CampaignWorkAssignment.id < assignment_id,
),
)
)
rows = (
query.order_by(CampaignWorkAssignment.created_at.desc(), CampaignWorkAssignment.id.desc())
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
items = rows[:limit]
return CampaignWorkAssignmentListResponse(
items=[_assignment_response(item) for item in items],
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
has_more=has_more,
)
@router.post(
"/{campaign_id}/assignments",
response_model=CampaignWorkAssignmentResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_work_assignment(
campaign_id: str,
payload: CampaignWorkAssignmentCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> CampaignWorkAssignmentResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
reference = _validated_reference(session, campaign=campaign, reference=payload.reference)
resolution = _resolve_assignee(session, campaign=campaign, assignee=payload.assignee)
_require_resolved_assignee(resolution)
now = utc_now()
actor_label = _actor_label(principal)
assignment = CampaignWorkAssignment(
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
campaign_version_id=reference[0] if reference else None,
reference_kind=reference[1] if reference else None,
reference_id=reference[2] if reference else None,
reference_label=reference[3] if reference else None,
purpose=payload.purpose,
status="open",
due_at=payload.due_at,
assignee_type=payload.assignee.type,
assignee_id=payload.assignee.id,
assignee_label_snapshot=(resolution.label or payload.assignee.id)[:500],
assignee_current_label=resolution.label,
assignee_resolution_state=resolution.state,
resolution_provenance=resolution.provenance,
resolution_checked_at=now,
assigned_by_user_id=principal.user.id,
assigned_by_label_snapshot=actor_label,
)
session.add(assignment)
session.flush()
_record_event(session, assignment=assignment, principal=principal, event_kind="assigned")
if payload.mirror_to_tasks:
_mirror_assignment_to_tasks(session, campaign=campaign, assignment=assignment, principal=principal)
else:
assignment.task_mirror_status = "skipped"
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind="assigned")
_audit_assignment(session, principal, assignment=assignment, action="campaign.assignment.created")
session.refresh(assignment)
return _assignment_response(assignment)
@router.post(
"/{campaign_id}/assignments/{assignment_id}/reassign",
response_model=CampaignWorkAssignmentResponse,
)
def reassign_campaign_work_assignment(
campaign_id: str,
assignment_id: str,
payload: CampaignWorkAssignmentReassignRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> CampaignWorkAssignmentResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
_require_revision(assignment, payload.expected_revision)
if assignment.status in {"completed", "cancelled"}:
raise HTTPException(status_code=409, detail="Closed assignments cannot be reassigned.")
resolution = _resolve_assignee(session, campaign=campaign, assignee=payload.assignee)
_require_resolved_assignee(resolution)
if assignment.assignee_type == payload.assignee.type and assignment.assignee_id == payload.assignee.id:
return _assignment_response(assignment)
previous = {
"assignee_type": assignment.assignee_type,
"assignee_id": assignment.assignee_id,
"assignee_label": assignment.assignee_label_snapshot,
"reason": payload.reason,
}
assignment.assignee_type = payload.assignee.type
assignment.assignee_id = payload.assignee.id
assignment.assignee_label_snapshot = (resolution.label or payload.assignee.id)[:500]
assignment.assignee_current_label = resolution.label
assignment.assignee_resolution_state = resolution.state
assignment.resolution_provenance = resolution.provenance
assignment.resolution_checked_at = utc_now()
assignment.resource_revision += 1
assignment.task_mirror_id = None
assignment.task_mirror_error = None
assignment.task_mirrored_at = None
assignment.task_mirror_status = "not_configured"
_record_event(
session,
assignment=assignment,
principal=principal,
event_kind="reassigned",
details=previous,
)
if payload.mirror_to_tasks:
_mirror_assignment_to_tasks(session, campaign=campaign, assignment=assignment, principal=principal)
else:
assignment.task_mirror_status = "skipped"
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind="reassigned")
_audit_assignment(session, principal, assignment=assignment, action="campaign.assignment.reassigned")
session.refresh(assignment)
return _assignment_response(assignment)
@router.post(
"/{campaign_id}/assignments/{assignment_id}/transition",
response_model=CampaignWorkAssignmentResponse,
)
def transition_campaign_work_assignment(
campaign_id: str,
assignment_id: str,
payload: CampaignWorkAssignmentTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
) -> CampaignWorkAssignmentResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
if not has_scope(principal, "campaigns:assignment:manage"):
_require_permission(principal, "campaigns:assignment:complete")
if not _principal_matches_assignment(session, principal=principal, assignment=assignment):
raise HTTPException(status_code=403, detail="Only the assignee or an assignment manager may update this work.")
if payload.action == "cancel":
raise HTTPException(status_code=403, detail="Only an assignment manager may cancel work.")
_require_revision(assignment, payload.expected_revision)
target_status = {"start": "in_progress", "complete": "completed", "cancel": "cancelled"}[payload.action]
if assignment.status == target_status:
return _assignment_response(assignment)
allowed = {
"start": {"open"},
"complete": {"open", "in_progress"},
"cancel": {"open", "in_progress"},
}
if assignment.status not in allowed[payload.action]:
raise HTTPException(status_code=409, detail=f"Assignment status {assignment.status!r} cannot be changed using {payload.action!r}.")
assignment.status = target_status
assignment.completed_at = utc_now() if target_status == "completed" else None
assignment.cancelled_at = utc_now() if target_status == "cancelled" else None
assignment.resource_revision += 1
_record_event(
session,
assignment=assignment,
principal=principal,
event_kind={"start": "started", "complete": "completed", "cancel": "cancelled"}[payload.action],
details={"reason": payload.reason},
)
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind=payload.action)
_audit_assignment(session, principal, assignment=assignment, action=f"campaign.assignment.{target_status}")
session.refresh(assignment)
return _assignment_response(assignment)
@router.get(
"/{campaign_id}/assignments/{assignment_id}/history",
response_model=CampaignWorkAssignmentHistoryResponse,
)
def list_campaign_work_assignment_history(
campaign_id: str,
assignment_id: str,
limit: int = Query(default=50, ge=1, le=100),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
) -> CampaignWorkAssignmentHistoryResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
query = session.query(CampaignWorkAssignmentEvent).filter(
CampaignWorkAssignmentEvent.tenant_id == principal.tenant_id,
CampaignWorkAssignmentEvent.assignment_id == assignment.id,
)
if cursor:
created_at, event_id = _decode_cursor(cursor)
query = query.filter(
or_(
CampaignWorkAssignmentEvent.created_at < created_at,
and_(
CampaignWorkAssignmentEvent.created_at == created_at,
CampaignWorkAssignmentEvent.id < event_id,
),
)
)
rows = query.order_by(CampaignWorkAssignmentEvent.created_at.desc(), CampaignWorkAssignmentEvent.id.desc()).limit(limit + 1).all()
has_more = len(rows) > limit
items = rows[:limit]
return CampaignWorkAssignmentHistoryResponse(
items=[_event_response(item) for item in items],
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
has_more=has_more,
)
@router.post(
"/{campaign_id}/assignments/reconcile",
response_model=CampaignWorkAssignmentReconcileResponse,
)
def reconcile_campaign_work_assignments(
campaign_id: str,
limit: int = Query(default=100, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
) -> CampaignWorkAssignmentReconcileResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
rows = (
session.query(CampaignWorkAssignment)
.filter(
CampaignWorkAssignment.tenant_id == campaign.tenant_id,
CampaignWorkAssignment.campaign_id == campaign.id,
CampaignWorkAssignment.status.in_(("open", "in_progress")),
)
.order_by(CampaignWorkAssignment.updated_at, CampaignWorkAssignment.id)
.limit(limit)
.all()
)
changed: list[CampaignWorkAssignment] = []
for assignment in rows:
resolution = _resolve_assignee(
session,
campaign=campaign,
assignee=CampaignWorkAssigneeInput(type=assignment.assignee_type, id=assignment.assignee_id),
)
if (
assignment.assignee_resolution_state == resolution.state
and assignment.assignee_current_label == resolution.label
):
assignment.resolution_checked_at = utc_now()
assignment.resolution_provenance = resolution.provenance
continue
previous_state = assignment.assignee_resolution_state
assignment.assignee_resolution_state = resolution.state
assignment.assignee_current_label = resolution.label
assignment.resolution_provenance = resolution.provenance
assignment.resolution_checked_at = utc_now()
assignment.resource_revision += 1
_record_event(
session,
assignment=assignment,
principal=principal,
event_kind="assignee_resolved" if resolution.state == "resolved" else "assignee_unavailable",
details={"previous_resolution_state": previous_state},
)
changed.append(assignment)
audit_from_principal(
session,
principal,
action="campaign.assignment.reconciled",
object_type="campaign",
object_id=campaign.id,
details={"checked": len(rows), "changed": len(changed), "content_disclosed": False},
commit=True,
)
return CampaignWorkAssignmentReconcileResponse(
checked=len(rows),
changed=len(changed),
assignments=[_assignment_response(item) for item in changed],
)
def _assignment_options(
session: Session,
*,
campaign: Campaign,
assignee_type: AssigneeType,
query: str,
selected_values: list[str],
limit: int,
) -> tuple[ReferenceOption, ...]:
clean_query = query.strip().casefold()
selected = set(item.strip() for item in selected_values if item.strip())
candidates: list[ReferenceOption] = []
directory = _access_directory()
if assignee_type == "account":
for user in directory.users_for_tenant(campaign.tenant_id):
if user.status != "active" or not _user_has_campaign_access(session, campaign=campaign, user_id=user.id):
continue
label = user.display_name or user.email or user.account_id
candidates.append(ReferenceOption(value=user.account_id, label=label, description=user.email, kind="account"))
elif assignee_type == "group":
for group in directory.groups_for_tenant(campaign.tenant_id):
if group.status == "active" and _group_has_campaign_access(session, campaign=campaign, group_id=group.id):
candidates.append(ReferenceOption(value=group.id, label=group.name, kind="group"))
else:
provider = organization_directory(get_registry())
if provider is not None:
seen: set[str] = set()
for unit in provider.organization_units_for_tenant(campaign.tenant_id):
for function in provider.functions_for_organization_unit(unit.id):
if function.id in seen:
continue
seen.add(function.id)
resolution = _resolve_assignee(
session,
campaign=campaign,
assignee=CampaignWorkAssigneeInput(type="organization_function", id=function.id),
)
candidates.append(
ReferenceOption(
value=function.id,
label=function.name,
description=unit.name,
kind="organization_function",
availability="available" if resolution.state == "resolved" else "unavailable",
disabled=resolution.state != "resolved",
source_module="organizations",
provenance=resolution.provenance,
)
)
filtered = [
item for item in candidates
if item.value in selected or not clean_query or clean_query in f"{item.label} {item.description or ''} {item.value}".casefold()
]
filtered.sort(key=lambda item: (item.disabled, item.label.casefold(), item.value))
return tuple(filtered[:limit])
def _resolve_assignee(
session: Session,
*,
campaign: Campaign,
assignee: CampaignWorkAssigneeInput,
) -> _AssigneeResolution:
provenance: dict[str, object] = {
"policy_code": "assignment_does_not_grant_access",
"campaign_access_checked": True,
"assignee_type": assignee.type,
}
directory = _access_directory()
if assignee.type == "account":
user = next(
(item for item in directory.users_for_tenant(campaign.tenant_id) if item.account_id == assignee.id),
None,
)
available = user is not None and user.status == "active" and _user_has_campaign_access(session, campaign=campaign, user_id=user.id)
provenance.update({"resolved_members": 1 if available else 0, "provider": "access.directory"})
return _AssigneeResolution(
state="resolved" if available else "unavailable",
label=(user.display_name or user.email or user.account_id) if user is not None else None,
recipient_type="account",
recipient_id=assignee.id,
account_ids=(assignee.id,) if available else (),
provenance=provenance,
)
if assignee.type == "group":
group = next((item for item in directory.groups_for_tenant(campaign.tenant_id) if item.id == assignee.id), None)
available = group is not None and group.status == "active" and _group_has_campaign_access(session, campaign=campaign, group_id=assignee.id)
provenance.update({"resolved_members": 1 if available else 0, "provider": "access.directory"})
return _AssigneeResolution(
state="resolved" if available else "unavailable",
label=group.name if group is not None else None,
recipient_type="group",
recipient_id=assignee.id,
account_ids=(),
provenance=provenance,
)
organizations = organization_directory(get_registry())
idm = _idm_function_directory()
if organizations is None or idm is None:
provenance.update({"provider": "organizations.directory+idm.function_assignments", "provider_available": False})
return _AssigneeResolution("provider_unavailable", None, "function", assignee.id, (), provenance)
function = organizations.get_function(assignee.id)
if function is None or function.tenant_id != campaign.tenant_id or function.status != "active":
provenance.update({"provider": "organizations.directory", "resolved_members": 0})
return _AssigneeResolution("unavailable", function.name if function else None, "function", assignee.id, (), provenance)
now = utc_now()
incumbencies = idm.organization_function_assignments_for_function(
assignee.id,
tenant_id=campaign.tenant_id,
effective_at=now,
)
account_ids = tuple(
dict.fromkeys(
item.account_id
for item in incumbencies
if item.account_id
and _incumbency_is_effective(item, now=now)
)
)
user_by_account = {
user.account_id: user for user in directory.users_for_tenant(campaign.tenant_id)
}
authorized = bool(account_ids) and all(
account_id in user_by_account
and user_by_account[account_id].status == "active"
and _user_has_campaign_access(session, campaign=campaign, user_id=user_by_account[account_id].id)
for account_id in account_ids
)
provenance.update(
{
"provider": "organizations.directory+idm.function_assignments",
"provider_available": True,
"resolved_members": len(account_ids),
"all_current_incumbents_authorized": authorized,
}
)
return _AssigneeResolution(
"resolved" if authorized else "unavailable",
function.name,
"function",
assignee.id,
account_ids if authorized else (),
provenance,
)
def _idm_function_directory() -> IdmFunctionAssignmentDirectory | None:
registry = get_registry()
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS)
):
return None
capability = registry.capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS)
return capability if isinstance(capability, IdmFunctionAssignmentDirectory) else None
def _require_resolved_assignee(resolution: _AssigneeResolution) -> None:
if resolution.state == "resolved":
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"code": "campaign_assignment_assignee_inaccessible",
"explanation": "The assignment target must already resolve to an active principal with access to this campaign. Assignment never grants access.",
"resolution_state": resolution.state,
"provenance": resolution.provenance,
},
)
def _user_has_campaign_access(session: Session, *, campaign: Campaign, user_id: str) -> bool:
if campaign.owner_user_id == user_id:
return True
directory = _access_directory()
group_ids = {item.id for item in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)}
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return True
clauses = [and_(CampaignShare.target_type == "user", CampaignShare.target_id == user_id)]
if group_ids:
clauses.append(and_(CampaignShare.target_type == "group", CampaignShare.target_id.in_(sorted(group_ids))))
return session.query(CampaignShare.id).filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(*clauses),
).first() is not None
def _group_has_campaign_access(session: Session, *, campaign: Campaign, group_id: str) -> bool:
if campaign.owner_group_id == group_id:
return True
return session.query(CampaignShare.id).filter(
CampaignShare.tenant_id == campaign.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.target_type == "group",
CampaignShare.target_id == group_id,
CampaignShare.revoked_at.is_(None),
).first() is not None
def _principal_matches_assignment(
session: Session,
*,
principal: ApiPrincipal,
assignment: CampaignWorkAssignment,
) -> bool:
if assignment.assignee_type == "account":
return principal.account_id == assignment.assignee_id
if assignment.assignee_type == "group":
return assignment.assignee_id in {
item.id for item in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)
}
idm = _idm_function_directory()
if idm is None:
return False
return any(
item.account_id == principal.account_id and item.status == "active"
for item in idm.organization_function_assignments_for_function(
assignment.assignee_id,
tenant_id=principal.tenant_id,
effective_at=utc_now(),
)
)
def _assignment_for_campaign(
session: Session,
*,
campaign: Campaign,
assignment_id: str,
) -> CampaignWorkAssignment:
assignment = session.get(CampaignWorkAssignment, assignment_id)
if assignment is None or assignment.tenant_id != campaign.tenant_id or assignment.campaign_id != campaign.id:
raise HTTPException(status_code=404, detail="Campaign work assignment not found.")
return assignment
def _require_revision(assignment: CampaignWorkAssignment, expected: int) -> None:
if assignment.resource_revision != expected:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={
"code": "campaign_assignment_revision_conflict",
"expected_revision": expected,
"current_revision": assignment.resource_revision,
},
)
def _record_event(
session: Session,
*,
assignment: CampaignWorkAssignment,
principal: ApiPrincipal,
event_kind: str,
details: dict[str, object] | None = None,
) -> CampaignWorkAssignmentEvent:
event = CampaignWorkAssignmentEvent(
tenant_id=assignment.tenant_id,
campaign_id=assignment.campaign_id,
assignment_id=assignment.id,
event_kind=event_kind,
actor_user_id=principal.user.id,
actor_label_snapshot=_actor_label(principal),
status_snapshot=assignment.status,
assignee_type_snapshot=assignment.assignee_type,
assignee_id_snapshot=assignment.assignee_id,
assignee_label_snapshot=assignment.assignee_label_snapshot,
resolution_state_snapshot=assignment.assignee_resolution_state,
details={key: value for key, value in (details or {}).items() if value is not None},
)
session.add(event)
session.flush()
return event
def _mirror_assignment_to_tasks(
session: Session,
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
principal: ApiPrincipal,
) -> None:
provider = task_command_provider(get_registry())
if provider is None:
assignment.task_mirror_status = "not_configured"
return
task_kind = "function" if assignment.assignee_type == "organization_function" else assignment.assignee_type
sources = [
WorkSourceRef(
module_id="campaigns",
resource_type="campaign_work_assignment",
resource_id=assignment.id,
revision=str(assignment.resource_revision),
url=f"/campaigns/{campaign.id}/work",
label=campaign.name,
)
]
if assignment.reference_kind and assignment.reference_id:
sources.append(
WorkSourceRef(
module_id="campaigns",
resource_type=assignment.reference_kind,
resource_id=assignment.reference_id,
label=assignment.reference_label,
)
)
try:
with session.begin_nested():
task = provider.create_task(
session,
principal,
command=TaskCreateCommand(
tenant_id=campaign.tenant_id,
title=assignment.purpose,
idempotency_key=f"campaign-assignment:{assignment.id}:r{assignment.resource_revision}",
summary=f"Accountable work assignment for campaign {campaign.name}.",
due_at=assignment.due_at,
required_action=assignment.purpose,
action_url=f"/campaigns/{campaign.id}/work",
assignments=(
WorkAssignmentRef(
kind=task_kind,
id=assignment.assignee_id,
label=assignment.assignee_label_snapshot,
),
),
sources=tuple(sources),
provenance={
"authorization_neutral": True,
"campaign_access_checked": True,
},
metadata={"campaign_id": campaign.id, "assignment_id": assignment.id},
),
)
assignment.task_mirror_id = task.id
assignment.task_mirror_status = "mirrored"
assignment.task_mirror_error = None
assignment.task_mirrored_at = utc_now()
except Exception as exc:
assignment.task_mirror_status = "failed"
assignment.task_mirror_error = f"{type(exc).__name__}: optional Tasks mirroring failed"[:500]
def _notify_assignment(
session: Session,
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
event_kind: str,
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None:
return
recipient_account_ids = _notification_account_ids(
campaign=campaign,
assignment=assignment,
)
if not recipient_account_ids:
return
try:
with session.begin_nested():
for account_id in recipient_account_ids:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=campaign.tenant_id,
source_module="campaigns",
source_resource_type="campaign_work_assignment",
source_resource_id=assignment.id,
event_kind=f"campaign.assignment.{event_kind}",
channel="inbox",
recipient_type="account",
recipient_id=account_id,
recipient_label=assignment.assignee_label_snapshot,
subject=f"Campaign work {event_kind}: {campaign.name}",
body_text="Open the campaign work area to review the assignment. Campaign access is managed separately.",
action_url=f"/campaigns/{campaign.id}/work",
payload={
"campaign_id": campaign.id,
"assignment_id": assignment.id,
"assignment_status": assignment.status,
"purpose_disclosed": False,
},
),
enqueue_delivery=False,
)
except Exception:
return
def _notification_account_ids(
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
) -> tuple[str, ...]:
if assignment.assignee_type == "account":
return (assignment.assignee_id,)
if assignment.assignee_type == "group":
directory = _access_directory()
return tuple(
dict.fromkeys(
user.account_id
for user in directory.users_for_tenant(campaign.tenant_id)
if user.status == "active"
and assignment.assignee_id
in {
group.id
for group in directory.groups_for_user(
user.id,
tenant_id=campaign.tenant_id,
)
}
)
)[:100]
idm = _idm_function_directory()
if idm is None:
return ()
return tuple(
dict.fromkeys(
item.account_id
for item in idm.organization_function_assignments_for_function(
assignment.assignee_id,
tenant_id=campaign.tenant_id,
effective_at=utc_now(),
)
if item.account_id and _incumbency_is_effective(item, now=utc_now())
)
)[:100]
def _incumbency_is_effective(item: object, *, now: datetime) -> bool:
if getattr(item, "status", None) != "active":
return False
valid_from = getattr(item, "valid_from", None)
valid_until = getattr(item, "valid_until", None)
comparison_now = now
for boundary in (valid_from, valid_until):
if isinstance(boundary, datetime) and boundary.tzinfo is None:
comparison_now = now.replace(tzinfo=None)
break
return (
(valid_from is None or valid_from <= comparison_now)
and (valid_until is None or valid_until > comparison_now)
)
def _audit_assignment(
session: Session,
principal: ApiPrincipal,
*,
assignment: CampaignWorkAssignment,
action: str,
) -> None:
audit_from_principal(
session,
principal,
action=action,
object_type="campaign_work_assignment",
object_id=assignment.id,
details={
"campaign_id": assignment.campaign_id,
"status": assignment.status,
"assignee_type": assignment.assignee_type,
"assignee_id": assignment.assignee_id,
"assignee_resolution_state": assignment.assignee_resolution_state,
"reference_kind": assignment.reference_kind,
"reference_id": assignment.reference_id,
"resource_revision": assignment.resource_revision,
"task_mirror_status": assignment.task_mirror_status,
"purpose_disclosed": False,
},
commit=True,
)
def _actor_label(principal: ApiPrincipal) -> str:
return str(
getattr(principal.user, "display_name", None)
or getattr(principal.user, "email", None)
or principal.user.id
)[:255]
def _assignment_response(assignment: CampaignWorkAssignment) -> CampaignWorkAssignmentResponse:
reference = None
if assignment.reference_kind and assignment.reference_id:
reference = CampaignWorkAssignmentReferenceResponse(
kind=assignment.reference_kind,
id=assignment.reference_id,
label=assignment.reference_label,
)
return CampaignWorkAssignmentResponse(
id=assignment.id,
campaign_id=assignment.campaign_id,
purpose=assignment.purpose,
status=assignment.status,
due_at=assignment.due_at,
assignee_type=assignment.assignee_type,
assignee_id=assignment.assignee_id,
assignee_label_snapshot=assignment.assignee_label_snapshot,
assignee_current_label=assignment.assignee_current_label,
assignee_resolution_state=assignment.assignee_resolution_state,
resolution_provenance=dict(assignment.resolution_provenance or {}),
resolution_checked_at=assignment.resolution_checked_at,
assigned_by_user_id=assignment.assigned_by_user_id,
assigned_by_label=assignment.assigned_by_label_snapshot,
reference=reference,
completed_at=assignment.completed_at,
cancelled_at=assignment.cancelled_at,
task_mirror_id=assignment.task_mirror_id,
task_mirror_status=assignment.task_mirror_status,
task_mirror_error=assignment.task_mirror_error,
resource_revision=assignment.resource_revision,
created_at=assignment.created_at,
updated_at=assignment.updated_at,
)
def _event_response(event: CampaignWorkAssignmentEvent) -> CampaignWorkAssignmentEventResponse:
return CampaignWorkAssignmentEventResponse(
id=event.id,
assignment_id=event.assignment_id,
event_kind=event.event_kind,
actor_user_id=event.actor_user_id,
actor_label=event.actor_label_snapshot,
status=event.status_snapshot,
assignee_type=event.assignee_type_snapshot,
assignee_id=event.assignee_id_snapshot,
assignee_label=event.assignee_label_snapshot,
resolution_state=event.resolution_state_snapshot,
details=dict(event.details or {}),
created_at=event.created_at,
)
def _encode_cursor(item: CampaignWorkAssignment | CampaignWorkAssignmentEvent) -> str:
created_at = item.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=UTC)
payload = json.dumps(
{"created_at": created_at.astimezone(UTC).isoformat(), "id": item.id},
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def _decode_cursor(value: str) -> tuple[datetime, str]:
try:
padded = value + "=" * (-len(value) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
created_at = datetime.fromisoformat(str(payload["created_at"]))
item_id = str(payload["id"])
if created_at.tzinfo is None or not item_id or len(item_id) > 36:
raise ValueError
return created_at, item_id
except (KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error) as exc:
raise HTTPException(status_code=422, detail="Invalid assignment cursor.") from exc
__all__ = ["router"]
+137
View File
@@ -133,6 +133,143 @@ class CampaignCollaborationListResponse(BaseModel):
has_more: bool = False has_more: bool = False
CampaignWorkAssigneeType = Literal["account", "group", "organization_function"]
CampaignWorkAssignmentStatus = Literal["open", "in_progress", "completed", "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["start", "complete", "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")
+79
View File
@@ -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__,
+472
View File
@@ -0,0 +1,472 @@
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_core.core.access import GroupRef, UserRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
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 _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_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")
+7
View File
@@ -481,6 +481,13 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"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.reassign",
"campaign.work.action.cancel",
"campaign.work.history",
} }
assert len(ids) == len(set(ids)) assert len(ids) == len(set(ids))
+53
View File
@@ -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 -1
View File
@@ -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")),
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.20", "version": "0.1.21",
"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"
+157
View File
@@ -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" | "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: "start" | "complete" | "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,476 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
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 [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 [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]);
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: "start" | "complete" | "cancel") {
if (busyId) return;
setBusyId(assignment.id);
setError("");
try {
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
replaceAssignment(updated);
setCancelling(null);
setMessage(`Work ${action === "start" ? "started" : action === "complete" ? "completed" : "cancelled"}.`);
} 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} className="campaign-work-item">
<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, "start")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
<Play size={16} aria-hidden="true" /> Start
</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}
{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(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";
+9 -6
View File
@@ -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
View File
@@ -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",
+23
View File
@@ -2799,9 +2799,32 @@
.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 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");