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:
@@ -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"]
|
||||
Reference in New Issue
Block a user