1058 lines
42 KiB
Python
1058 lines
42 KiB
Python
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"]
|