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,472 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.assignments import (
|
||||
create_campaign_work_assignment,
|
||||
list_campaign_work_assignment_history,
|
||||
reassign_campaign_work_assignment,
|
||||
reconcile_campaign_work_assignments,
|
||||
transition_campaign_work_assignment,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignWorkAssigneeInput,
|
||||
CampaignWorkAssignmentCreateRequest,
|
||||
CampaignWorkAssignmentReassignRequest,
|
||||
CampaignWorkAssignmentTransitionRequest,
|
||||
)
|
||||
from govoplan_core.core.access import GroupRef, UserRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.core.organizations import OrganizationFunctionRef
|
||||
from govoplan_core.core.tasks import WorkItem
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = TENANT_ID
|
||||
api_key = None
|
||||
|
||||
def __init__(self, user_id: str, account_id: str, *scopes: str) -> None:
|
||||
self.user = SimpleNamespace(
|
||||
id=user_id,
|
||||
display_name=f"User {user_id}",
|
||||
email=f"{user_id}@example.test",
|
||||
)
|
||||
self.account_id = account_id
|
||||
self.scopes = frozenset(scopes)
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes or "tenant:*" in self.scopes
|
||||
|
||||
|
||||
class _Directory:
|
||||
def __init__(self) -> None:
|
||||
self.user_2_active = True
|
||||
|
||||
def users_for_tenant(self, tenant_id: str):
|
||||
if tenant_id != TENANT_ID:
|
||||
return ()
|
||||
return (
|
||||
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Owner"),
|
||||
UserRef(
|
||||
id="user-2",
|
||||
account_id="account-2",
|
||||
tenant_id=TENANT_ID,
|
||||
display_name="Collaborator",
|
||||
status="active" if self.user_2_active else "inactive",
|
||||
),
|
||||
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
|
||||
)
|
||||
|
||||
def groups_for_tenant(self, tenant_id: str):
|
||||
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Campaign group"),) if tenant_id == TENANT_ID else ()
|
||||
|
||||
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||
if tenant_id == TENANT_ID and user_id == "user-2":
|
||||
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Campaign group"),)
|
||||
return ()
|
||||
|
||||
|
||||
class _Tasks:
|
||||
def __init__(self) -> None:
|
||||
self.commands = []
|
||||
|
||||
def create_task(self, _session, _principal, *, command):
|
||||
self.commands.append(command)
|
||||
return WorkItem(
|
||||
id=f"task-{len(self.commands)}",
|
||||
provider_id="tasks",
|
||||
owner_module="tasks",
|
||||
tenant_id=command.tenant_id,
|
||||
title=command.title,
|
||||
assignments=command.assignments,
|
||||
sources=command.sources,
|
||||
)
|
||||
|
||||
|
||||
class _Notifications:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def tenant_id_for_notification(self, _session, *, notification_id: str):
|
||||
del notification_id
|
||||
return TENANT_ID
|
||||
|
||||
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
|
||||
self.requests.append((request, enqueue_delivery))
|
||||
return {"id": f"notification-{len(self.requests)}"}
|
||||
|
||||
def deliver_notification(self, _session, *, notification_id: str):
|
||||
return {"id": notification_id}
|
||||
|
||||
def deliver_pending(self, _session, *, tenant_id=None, limit: int = 50):
|
||||
return {"tenant_id": tenant_id, "limit": limit}
|
||||
|
||||
|
||||
class _FailingTasks(_Tasks):
|
||||
def create_task(self, _session, _principal, *, command):
|
||||
del command
|
||||
raise RuntimeError("Tasks is temporarily unavailable")
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, tasks: _Tasks | None = None, notifications: _Notifications | None = None) -> None:
|
||||
self.tasks = tasks
|
||||
self.notifications = notifications
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return (
|
||||
(name == "tasks.commands" and self.tasks is not None)
|
||||
or (name == "notifications.dispatch" and self.notifications is not None)
|
||||
)
|
||||
|
||||
def capability(self, name: str):
|
||||
if name == "tasks.commands":
|
||||
return self.tasks
|
||||
if name == "notifications.dispatch":
|
||||
return self.notifications
|
||||
return None
|
||||
|
||||
|
||||
class _Organizations:
|
||||
def get_function(self, function_id: str):
|
||||
if function_id != "function-1":
|
||||
return None
|
||||
return OrganizationFunctionRef(
|
||||
id=function_id,
|
||||
tenant_id=TENANT_ID,
|
||||
organization_unit_id="unit-1",
|
||||
slug="campaign-review",
|
||||
name="Campaign review function",
|
||||
)
|
||||
|
||||
|
||||
class _Idm:
|
||||
def organization_function_assignments_for_function(self, function_id: str, *, tenant_id=None, effective_at=None):
|
||||
del effective_at
|
||||
if function_id == "function-1" and tenant_id == TENANT_ID:
|
||||
return (SimpleNamespace(account_id="account-2", status="active", valid_from=None, valid_until=None),)
|
||||
return ()
|
||||
|
||||
def organization_function_incumbencies(self, function_ids, *, tenant_id, effective_at=None):
|
||||
del effective_at
|
||||
return {item: SimpleNamespace(assignments=self.organization_function_assignments_for_function(item, tenant_id=tenant_id)) for item in function_ids}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session() -> Session:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignWorkAssignment.__table__,
|
||||
CampaignWorkAssignmentEvent.__table__,
|
||||
],
|
||||
)
|
||||
factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||
database = factory()
|
||||
database.add_all(
|
||||
[
|
||||
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.flush()
|
||||
database.add_all(
|
||||
[
|
||||
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
|
||||
for number in range(1, 4)
|
||||
]
|
||||
)
|
||||
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="campaign-group", name="Campaign group"))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id="user-1",
|
||||
external_id="campaign-1",
|
||||
name="Campaign One",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
database.add_all(
|
||||
[
|
||||
campaign,
|
||||
CampaignVersion(id="version-1", campaign_id=campaign.id, version_number=1, raw_json={"version": "1.0"}),
|
||||
CampaignShare(
|
||||
id="share-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
target_type="group",
|
||||
target_id="group-1",
|
||||
permission="read",
|
||||
),
|
||||
]
|
||||
)
|
||||
database.commit()
|
||||
try:
|
||||
yield database
|
||||
finally:
|
||||
database.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _manager() -> _Principal:
|
||||
return _Principal(
|
||||
"user-1",
|
||||
"account-1",
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
)
|
||||
|
||||
|
||||
def _assignee() -> _Principal:
|
||||
return _Principal(
|
||||
"user-2",
|
||||
"account-2",
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
)
|
||||
|
||||
|
||||
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_assignment_is_authorization_neutral_and_mirrors_through_optional_tasks(session: Session) -> None:
|
||||
directory = _Directory()
|
||||
tasks = _Tasks()
|
||||
notifications = _Notifications()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.routes.assignments.get_registry", return_value=_Registry(tasks, notifications)),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
response = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Review the frozen recipient import",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
reference={"kind": "campaign_version", "id": "version-1"},
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert response.assignee_label_snapshot == "Collaborator"
|
||||
assert response.assignee_resolution_state == "resolved"
|
||||
assert response.resolution_provenance["policy_code"] == "assignment_does_not_grant_access"
|
||||
assert response.task_mirror_id == "task-1"
|
||||
assert tasks.commands[0].assignments[0].kind == "account"
|
||||
assert tasks.commands[0].provenance["authorization_neutral"] is True
|
||||
assert notifications.requests[0][0].recipient_id == "account-2"
|
||||
assert notifications.requests[0][0].payload["purpose_disclosed"] is False
|
||||
assert notifications.requests[0][1] is False
|
||||
assert session.query(CampaignShare).count() == 1
|
||||
assert [item.event_kind for item in session.query(CampaignWorkAssignmentEvent).all()] == ["assigned"]
|
||||
|
||||
|
||||
def test_assignment_rejects_target_without_existing_campaign_access(session: Session) -> None:
|
||||
with patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Should not grant access",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-3"),
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail["code"] == "campaign_assignment_assignee_inaccessible"
|
||||
assert session.query(CampaignWorkAssignment).count() == 0
|
||||
assert session.query(CampaignShare).count() == 1
|
||||
|
||||
|
||||
def test_optional_tasks_failure_is_recorded_without_blocking_campaign_work(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
|
||||
patch("govoplan_campaign.backend.routes.assignments.get_registry", return_value=_Registry(_FailingTasks())),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Continue even without Tasks",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert created.task_mirror_status == "failed"
|
||||
assert "RuntimeError" in (created.task_mirror_error or "")
|
||||
assert session.get(CampaignWorkAssignment, created.id) is not None
|
||||
|
||||
|
||||
def test_assignee_can_start_and_complete_but_not_cancel(session: Session) -> None:
|
||||
directory = _Directory()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.route_support._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Review campaign",
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
started = transition_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.id,
|
||||
CampaignWorkAssignmentTransitionRequest(expected_revision=1, action="start"),
|
||||
session,
|
||||
_assignee(),
|
||||
)
|
||||
completed = transition_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.id,
|
||||
CampaignWorkAssignmentTransitionRequest(expected_revision=2, action="complete"),
|
||||
session,
|
||||
_assignee(),
|
||||
)
|
||||
|
||||
assert started.status == "in_progress"
|
||||
assert completed.status == "completed"
|
||||
assert completed.resource_revision == 3
|
||||
assert [item.event_kind for item in session.query(CampaignWorkAssignmentEvent).order_by(CampaignWorkAssignmentEvent.created_at, CampaignWorkAssignmentEvent.id)] == ["assigned", "started", "completed"]
|
||||
|
||||
|
||||
def test_reassignment_and_deactivation_reconciliation_preserve_history(session: Session) -> None:
|
||||
directory = _Directory()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=directory),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Coordinate delivery",
|
||||
assignee=CampaignWorkAssigneeInput(type="group", id="group-1"),
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
reassigned = reassign_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
created.id,
|
||||
CampaignWorkAssignmentReassignRequest(
|
||||
expected_revision=1,
|
||||
assignee=CampaignWorkAssigneeInput(type="account", id="account-2"),
|
||||
reason="Named accountability is now required.",
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
directory.user_2_active = False
|
||||
result = reconcile_campaign_work_assignments("campaign-1", 100, session, _manager())
|
||||
history = list_campaign_work_assignment_history("campaign-1", created.id, 50, None, session, _manager())
|
||||
|
||||
assert reassigned.assignee_type == "account"
|
||||
assert result.changed == 1
|
||||
assert result.assignments[0].assignee_resolution_state == "unavailable"
|
||||
assert [item.event_kind for item in reversed(history.items)] == ["assigned", "reassigned", "assignee_unavailable"]
|
||||
assert history.items[1].details["assignee_id"] == "group-1"
|
||||
|
||||
|
||||
def test_organization_function_requires_authorized_current_incumbencies(session: Session) -> None:
|
||||
with (
|
||||
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
|
||||
patch("govoplan_campaign.backend.routes.assignments.organization_directory", return_value=_Organizations()),
|
||||
patch("govoplan_campaign.backend.routes.assignments._idm_function_directory", return_value=_Idm()),
|
||||
patch("govoplan_campaign.backend.routes.assignments.audit_from_principal", side_effect=_commit_audit),
|
||||
):
|
||||
created = create_campaign_work_assignment(
|
||||
"campaign-1",
|
||||
CampaignWorkAssignmentCreateRequest(
|
||||
purpose="Approve the recipient segment",
|
||||
assignee=CampaignWorkAssigneeInput(type="organization_function", id="function-1"),
|
||||
mirror_to_tasks=False,
|
||||
),
|
||||
session,
|
||||
_manager(),
|
||||
)
|
||||
|
||||
assert created.assignee_label_snapshot == "Campaign review function"
|
||||
assert created.resolution_provenance["resolved_members"] == 1
|
||||
assert created.resolution_provenance["all_current_incumbents_authorized"] is True
|
||||
|
||||
|
||||
def test_assignment_migration_is_repeatable_and_creates_history_indexes() -> None:
|
||||
migration = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||
connection.execute(text("CREATE TABLE campaign_versions (id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"))
|
||||
context = MigrationContext.configure(connection)
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
inspector = inspect(connection)
|
||||
assignment_columns = {item["name"] for item in inspector.get_columns("campaign_work_assignments")}
|
||||
assignment_indexes = {item["name"] for item in inspector.get_indexes("campaign_work_assignments")}
|
||||
event_indexes = {item["name"] for item in inspector.get_indexes("campaign_work_assignment_events")}
|
||||
assert {
|
||||
"purpose",
|
||||
"assignee_type",
|
||||
"assignee_id",
|
||||
"assignee_label_snapshot",
|
||||
"assignee_resolution_state",
|
||||
"resolution_provenance",
|
||||
"task_mirror_status",
|
||||
"resource_revision",
|
||||
}.issubset(assignment_columns)
|
||||
assert "ix_campaign_work_assignments_campaign_status" in assignment_indexes
|
||||
assert "ix_campaign_work_assignment_events_history" in event_indexes
|
||||
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.downgrade()
|
||||
assert not inspect(connection).has_table("campaign_work_assignment_events")
|
||||
assert not inspect(connection).has_table("campaign_work_assignments")
|
||||
Reference in New Issue
Block a user