feat(campaigns): add accountable work assignments
Module Package Release / publish-packages (push) Successful in 14s
Module Package Release / publish-packages (push) Successful in 14s
This commit is contained in:
@@ -34,6 +34,8 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
@@ -48,6 +50,9 @@ READ_ACTIONS = {
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
}
|
||||
CAMPAIGN_RESOURCE_TYPES = {
|
||||
"campaign",
|
||||
@@ -62,6 +67,14 @@ CAMPAIGN_COLLABORATION_RESOURCE_TYPES = {
|
||||
"campaign_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",
|
||||
"campaign_job",
|
||||
@@ -615,6 +628,85 @@ class CampaignAccessService(CampaignAccessProvider):
|
||||
},
|
||||
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:
|
||||
share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined]
|
||||
if share is None:
|
||||
|
||||
@@ -223,6 +223,96 @@ class CampaignCollaborationEntry(Base, TimestampMixin):
|
||||
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):
|
||||
__tablename__ = "campaign_schedules"
|
||||
__table_args__ = (
|
||||
@@ -856,6 +946,8 @@ __all__ = [
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"CampaignWorkAssignment",
|
||||
"CampaignWorkAssignmentEvent",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
|
||||
@@ -28,6 +28,9 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -53,6 +56,9 @@ _TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
|
||||
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
||||
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
_TASKS_INTEGRATION = "tasks.commands"
|
||||
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
|
||||
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
|
||||
|
||||
|
||||
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(
|
||||
topic_id="campaigns.workflow.reuse-content-library",
|
||||
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: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: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:
|
||||
_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.")
|
||||
@@ -1040,6 +1096,17 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
||||
else:
|
||||
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(
|
||||
registry,
|
||||
_CALENDAR_INVITATION_INTEGRATION,
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
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 govoplan_campaign.backend.db.models import (
|
||||
@@ -18,6 +18,8 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
@@ -279,6 +281,7 @@ class CampaignDsarProvider:
|
||||
append=append,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
subject_account_id=subject.account_id,
|
||||
campaign_ids=evidence_campaign_ids,
|
||||
version_ids=version_ids,
|
||||
)
|
||||
@@ -643,9 +646,124 @@ class CampaignDsarProvider:
|
||||
append: object,
|
||||
tenant_id: str,
|
||||
subject_user_id: str | None,
|
||||
subject_account_id: str | None,
|
||||
campaign_ids: set[str],
|
||||
version_ids: set[str],
|
||||
) -> 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:
|
||||
collaboration_rows = _bounded_rows(
|
||||
db.query(CampaignCollaborationEntry)
|
||||
|
||||
@@ -51,6 +51,9 @@ from govoplan_core.core.templates import (
|
||||
)
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
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.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -121,6 +124,24 @@ PERMISSIONS = (
|
||||
"Read moderator-only entries and redact campaign collaboration content while retaining tombstones.",
|
||||
"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(
|
||||
"campaigns:campaign:create",
|
||||
"Create campaigns",
|
||||
@@ -324,6 +345,9 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -340,6 +364,8 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
),
|
||||
@@ -358,6 +384,8 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:diagnostic:read",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
@@ -411,7 +439,7 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.20",
|
||||
version="0.1.21",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -420,6 +448,9 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
CAPABILITY_APPROVAL_REQUESTS,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
CAPABILITY_TASK_COMMANDS,
|
||||
),
|
||||
optional_dependencies=(
|
||||
"files",
|
||||
@@ -433,6 +464,9 @@ manifest = ModuleManifest(
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
"organizations",
|
||||
"idm",
|
||||
"tasks",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
@@ -550,6 +584,24 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
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(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""add accountable campaign work assignments
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
op.create_table(
|
||||
"campaign_work_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||
sa.Column("purpose", sa.String(length=500), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False, server_default="open"),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("assignee_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("assignee_current_label", sa.String(length=500), nullable=True),
|
||||
sa.Column("assignee_resolution_state", sa.String(length=30), nullable=False, server_default="resolved"),
|
||||
sa.Column("resolution_provenance", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("resolution_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("assigned_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("assigned_by_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("task_mirror_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("task_mirror_status", sa.String(length=30), nullable=False, server_default="not_configured"),
|
||||
sa.Column("task_mirror_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("task_mirrored_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignments_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignments_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignments_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_work_assignments_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_work_assignments_status", ["status"]),
|
||||
("ix_campaign_work_assignments_due_at", ["due_at"]),
|
||||
("ix_campaign_work_assignments_assignee_type", ["assignee_type"]),
|
||||
("ix_campaign_work_assignments_assignee_id", ["assignee_id"]),
|
||||
("ix_campaign_work_assignments_assignee_resolution_state", ["assignee_resolution_state"]),
|
||||
("ix_campaign_work_assignments_assigned_by_user_id", ["assigned_by_user_id"]),
|
||||
("ix_campaign_work_assignments_campaign_status", ["tenant_id", "campaign_id", "status", "due_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignments", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignment_events"):
|
||||
op.create_table(
|
||||
"campaign_work_assignment_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("status_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("assignee_type_snapshot", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("resolution_state_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["assignment_id"], ["campaign_work_assignments.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignment_events_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignment_events_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignment_events_assignment_id", ["assignment_id"]),
|
||||
("ix_campaign_work_assignment_events_event_kind", ["event_kind"]),
|
||||
("ix_campaign_work_assignment_events_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_work_assignment_events_history", ["tenant_id", "assignment_id", "created_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignment_events", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignment_events"):
|
||||
op.drop_table("campaign_work_assignment_events")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignments"):
|
||||
op.drop_table("campaign_work_assignments")
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter
|
||||
|
||||
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.collaboration import router as collaboration_router
|
||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
@@ -18,6 +19,7 @@ router = APIRouter()
|
||||
for workflow_router in (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
assignments_router,
|
||||
collaboration_router,
|
||||
versions_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"]
|
||||
@@ -133,6 +133,143 @@ class CampaignCollaborationListResponse(BaseModel):
|
||||
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):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user