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.events import ( EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, emit_platform_event, ) 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", "rejected", "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 = { "accept": "in_progress", "start": "in_progress", "complete": "completed", "reject": "rejected", "cancel": "cancelled", }[payload.action] if assignment.status == target_status: return _assignment_response(assignment) allowed = { "accept": {"open"}, "start": {"open"}, "complete": {"open", "in_progress"}, "reject": {"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={ "accept": "accepted", "start": "started", "complete": "completed", "reject": "rejected", "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() emit_platform_event( session, PlatformEvent( type="campaign.work.changed", module_id="campaigns", event_id=event.id, occurred_at=event.created_at, correlation_id=assignment.orchestration_correlation_id, causation_id=assignment.workflow_step_id, actor=EventActorRef( type="account", id=principal.account_id, label=_actor_label(principal), ), tenant=EventTenantRef(id=assignment.tenant_id), subject=EventObjectRef( type="campaign_work_assignment", id=assignment.id, ), resource=EventObjectRef( type="campaign", id=assignment.campaign_id, ), classification="internal", payload={ "campaign_id": assignment.campaign_id, "campaign_version_id": assignment.campaign_version_id, "assignment_id": assignment.id, "assignment_revision": assignment.resource_revision, "assignment_ref": ( "campaign-work-assignment:" f"{assignment.id}:r{assignment.resource_revision}" ), "outcome": event_kind, "status": assignment.status, "assignee_type": assignment.assignee_type, "assignee_id": assignment.assignee_id, "action_url": ( f"/campaigns/{assignment.campaign_id}/work" f"?assignment={assignment.id}" ), }, ), registry=get_registry(), ) 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"]