feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s

This commit is contained in:
2026-08-22 00:09:34 +02:00
parent 5a21067e44
commit c2f083e5f6
25 changed files with 2315 additions and 24 deletions
+57
View File
@@ -21,6 +21,7 @@ from govoplan_campaign.backend.db.models import (
AttachmentBlob,
AttachmentInstance,
Campaign,
CampaignCollaborationEntry,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
@@ -46,6 +47,61 @@ GROUP_ID = "group-1"
class CampaignAccessProviderTests(unittest.TestCase):
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
_seed_access_subjects(session)
campaign = Campaign(
id="campaign-collaboration",
tenant_id=TENANT_ID,
owner_user_id=OTHER_USER_ID,
external_id="collaboration",
name="Collaboration campaign",
)
entry = CampaignCollaborationEntry(
id="discussion-1",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
actor_user_id=OTHER_USER_ID,
actor_label_snapshot="Other user",
visibility="collaborators",
content="Not disclosed by provenance",
content_sha256="a" * 64,
)
session.add_all(
[
campaign,
entry,
CampaignShare(
id="share-collaboration",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="group",
target_id=GROUP_ID,
permission="read",
),
]
)
session.commit()
items = CampaignAccessService().explain_resource_provenance(
session,
_principal(
scopes={"campaigns:campaign:read", "campaigns:discussion:post"},
group_ids={GROUP_ID},
),
resource_type="campaign_collaboration_entry",
resource_id=entry.id,
action="campaigns:discussion:post",
)
discussion = next(item for item in items if item.source == "campaigns.collaboration_entry")
self.assertEqual(["campaigns:discussion:post"], discussion.details["permission_actions"])
self.assertFalse(discussion.details["content_disclosed"])
self.assertTrue(any(item.id == "share-collaboration" for item in items))
self.assertNotIn("campaigns:campaign:update", repr(discussion.details))
self.assertNotIn("Not disclosed", repr(items))
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
session = _session()
self.addCleanup(_close_session, session)
@@ -918,6 +974,7 @@ def _session():
Group.__table__,
Campaign.__table__,
CampaignShare.__table__,
CampaignCollaborationEntry.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignIssue.__table__,
+382
View File
@@ -0,0 +1,382 @@
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,
CampaignCollaborationEntry,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.routes.collaboration import (
create_campaign_collaboration_entry,
list_campaign_collaboration,
redact_campaign_collaboration_entry,
withdraw_campaign_collaboration_entry,
)
from govoplan_campaign.backend.schemas import (
CampaignCollaborationCreateRequest,
CampaignCollaborationModerationRequest,
CampaignCollaborationReferenceInput,
)
from govoplan_core.core.access import GroupRef, UserRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
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, *scopes: str) -> None:
self.user = SimpleNamespace(
id=user_id,
display_name=f"User {user_id}",
email=f"{user_id}@example.test",
)
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes or "tenant:*" in self.scopes
class _Directory:
users = (
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Author"),
UserRef(id="user-2", account_id="account-2", tenant_id=TENANT_ID, display_name="Collaborator"),
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
)
def users_for_tenant(self, tenant_id: str):
return self.users 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="Collaborators"),)
return ()
class _Notifications:
def __init__(self) -> None:
self.requests = []
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
self.requests.append((request, enqueue_delivery))
return {"id": f"notification-{len(self.requests)}"}
class _UnavailableNotifications:
def enqueue_notification(self, _session, _request, *, enqueue_delivery: bool = True):
raise RuntimeError("Notifications is temporarily unavailable")
@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__,
CampaignJob.__table__,
CampaignShare.__table__,
CampaignCollaborationEntry.__table__,
],
)
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
database = session_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="collaborators", name="Collaborators"))
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",
)
version = CampaignVersion(
id="version-1",
campaign_id=campaign.id,
version_number=1,
raw_json={
"version": "1.0",
"entries": {"imports": [{"id": "import-1", "source_type": "csv"}]},
"attachments": {"global": [{"label": "Notice"}]},
},
)
database.add_all(
[
campaign,
version,
CampaignShare(
id="share-1",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
target_type="group",
target_id="group-1",
permission="read",
),
CampaignJob(
id="job-1",
tenant_id=TENANT_ID,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=0,
),
]
)
database.commit()
try:
yield database
finally:
database.close()
engine.dispose()
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
session.commit()
def _principal(user_id: str = "user-1", *, moderate: bool = False) -> _Principal:
scopes = [
"campaigns:campaign:read",
"campaigns:discussion:read",
"campaigns:discussion:post",
]
if moderate:
scopes.append("campaigns:discussion:moderate")
return _Principal(user_id, *scopes)
def test_post_is_append_only_references_a_version_and_notifies_authorized_mentions(session: Session) -> None:
notifications = _Notifications()
original_version = dict(session.get(CampaignVersion, "version-1").raw_json)
with (
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=notifications),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit) as audit,
):
response = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(
content="Please review the frozen version.",
mention_user_ids=["user-2", "user-1", "user-2"],
reference=CampaignCollaborationReferenceInput(
kind="campaign_version",
id="version-1",
),
),
session,
_principal(),
)
assert response.content == "Please review the frozen version."
assert response.reference is not None
assert response.reference.label == "Version 1"
assert response.mention_user_ids == ["user-2"]
assert session.get(CampaignVersion, "version-1").raw_json == original_version
assert len(notifications.requests) == 1
notification, enqueue_delivery = notifications.requests[0]
assert notification.recipient_id == "user-2"
assert notification.payload["content_disclosed"] is False
assert enqueue_delivery is False
details = audit.call_args.kwargs["details"]
assert details["content_disclosed"] is False
assert "Please review" not in repr(details)
def test_mentions_reject_users_without_campaign_access(session: Session) -> None:
with patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()):
with pytest.raises(HTTPException) as raised:
create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(content="No leak", mention_user_ids=["user-3"]),
session,
_principal(),
)
assert raised.value.status_code == 422
assert session.query(CampaignCollaborationEntry).count() == 0
def test_notification_failure_does_not_block_or_roll_back_collaboration(session: Session) -> None:
with (
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
patch(
"govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider",
return_value=_UnavailableNotifications(),
),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
):
response = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(
content="This discussion entry must survive an optional integration outage.",
mention_user_ids=["user-2"],
),
session,
_principal(),
)
stored = session.get(CampaignCollaborationEntry, response.id)
assert stored is not None
assert stored.content == response.content
assert stored.mention_user_ids == ["user-2"]
def test_collaboration_migration_is_repeatable_and_creates_thread_index() -> None:
migration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions."
"c7d8e9f0a1b2_v0120_campaign_collaboration"
)
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)
columns = {
column["name"]
for column in inspector.get_columns("campaign_collaboration_entries")
}
indexes = {
index["name"]
for index in inspector.get_indexes("campaign_collaboration_entries")
}
assert {
"campaign_id",
"campaign_version_id",
"content_sha256",
"mention_user_ids",
"withdrawn_at",
"redacted_at",
}.issubset(columns)
assert "ix_campaign_collaboration_entries_thread" in indexes
with patch.object(migration, "op", Operations(context)):
migration.downgrade()
assert not inspect(connection).has_table("campaign_collaboration_entries")
def test_visibility_pagination_withdrawal_and_redaction_leave_tombstones(session: Session) -> None:
with (
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
):
public_entry = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(content="Visible discussion"),
session,
_principal(),
)
moderator_entry = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(content="Restricted discussion", visibility="moderators"),
session,
_principal(moderate=True),
)
ordinary = list_campaign_collaboration("campaign-1", 1, None, session, _principal())
moderated = list_campaign_collaboration("campaign-1", 1, None, session, _principal(moderate=True))
withdrawn = withdraw_campaign_collaboration_entry(
"campaign-1",
public_entry.id,
CampaignCollaborationModerationRequest(reason="Posted in error"),
session,
_principal(),
)
redacted = redact_campaign_collaboration_entry(
"campaign-1",
moderator_entry.id,
CampaignCollaborationModerationRequest(reason="Contains restricted material"),
session,
_principal(moderate=True),
)
assert [entry.id for entry in ordinary.items] == [public_entry.id]
assert ordinary.has_more is False
assert [entry.id for entry in moderated.items] == [moderator_entry.id]
assert moderated.has_more is True
assert moderated.next_cursor
second_page = list_campaign_collaboration(
"campaign-1",
1,
moderated.next_cursor,
session,
_principal(moderate=True),
)
assert [entry.id for entry in second_page.items] == [public_entry.id]
assert withdrawn.tombstone == "withdrawn"
assert withdrawn.content is None
assert redacted.tombstone == "redacted"
assert redacted.content is None
assert session.get(CampaignCollaborationEntry, public_entry.id).content is None
assert session.get(CampaignCollaborationEntry, redacted.id).content_sha256 == redacted.content_sha256
@pytest.mark.parametrize(
("kind", "reference_id"),
[
("recipient_import_batch", "version-1:import-1"),
("attachment_rule", "version-1:attachments.global[0]"),
("delivery_job", "job-1"),
("report", "campaign-1:version-1:delivery"),
],
)
def test_supported_reference_contexts_are_validated(session: Session, kind: str, reference_id: str) -> None:
with (
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
):
response = create_campaign_collaboration_entry(
"campaign-1",
CampaignCollaborationCreateRequest(
content="Reference context",
reference=CampaignCollaborationReferenceInput(kind=kind, id=reference_id), # type: ignore[arg-type]
),
session,
_principal(),
)
assert response.reference is not None
assert response.reference.kind == kind
assert response.reference.id == reference_id
+7 -2
View File
@@ -475,8 +475,13 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.review-send",
"campaign.report",
"campaign.audit",
"campaign.json",
}
"campaign.json",
"campaign.activity",
"campaign.activity.composer",
"campaign.activity.action.post",
"campaign.activity.action.withdraw",
"campaign.activity.action.redact",
}
assert len(ids) == len(set(ids))
assert "single resend" not in rendered_static
+29
View File
@@ -11,6 +11,7 @@ from govoplan_campaign.backend.db.models import (
AttachmentBlob,
AttachmentInstance,
Campaign,
CampaignCollaborationEntry,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
@@ -100,6 +101,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
DataSubjectRequest.__table__,
Campaign.__table__,
CampaignShare.__table__,
CampaignCollaborationEntry.__table__,
CampaignVersion.__table__,
CampaignJob.__table__,
CampaignIssue.__table__,
@@ -337,6 +339,21 @@ class CampaignDsarProviderTests(unittest.TestCase):
permission="read",
created_by_user_id=self.other_user.id,
)
collaboration = CampaignCollaborationEntry(
id="collaboration-1",
tenant_id="tenant-1",
campaign_id=self.campaign.id,
campaign_version_id=self.version.id,
actor_user_id=self.user.id,
actor_label_snapshot="Subject",
visibility="collaborators",
content="Subject-authored discussion text",
content_sha256="9" * 64,
mention_user_ids=[self.other_user.id],
reference_kind="campaign_version",
reference_id=self.version.id,
reference_label="Version 1",
)
self.profile = RecipientImportMappingProfile(
id="mapping-1",
tenant_id="tenant-1",
@@ -410,6 +427,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
postbox_attempt,
print_attempt,
self.share,
collaboration,
self.profile,
blob,
attachment,
@@ -456,11 +474,22 @@ class CampaignDsarProviderTests(unittest.TestCase):
"campaign_print_attempt",
"campaign_report_projection",
"campaign_share",
"campaign_collaboration_entry",
"recipient_import_mapping_profile",
"campaign_schedule",
"campaign_attachment",
}.issubset(resource_types)
)
collaboration_record = next(
record
for record in records
if record.resource_type == "campaign_collaboration_entry"
)
self.assertEqual(
"Subject-authored discussion text",
collaboration_record.data["posted_text"],
)
self.assertTrue(collaboration_record.immutable_evidence)
report = next(
record
for record in records
+4 -1
View File
@@ -5,6 +5,7 @@ from collections import Counter
from govoplan_campaign.backend.router import router
from govoplan_campaign.backend.routes.attachments import router as attachments_router
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
from govoplan_campaign.backend.routes.collaboration import router as collaboration_router
from govoplan_campaign.backend.routes.delivery import router as delivery_router
from govoplan_campaign.backend.routes.jobs import router as jobs_router
from govoplan_campaign.backend.routes.operations import router as operations_router
@@ -26,6 +27,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
workflow_routers = (
operations_router,
campaigns_router,
collaboration_router,
versions_router,
jobs_router,
reports_router,
@@ -42,7 +44,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 81
assert len(actual) == 86
assert not [operation for operation, count in Counter(actual).items() if count > 1]
@@ -53,6 +55,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
("POST", "/campaigns/operations/artifacts/reconcile"),
),
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),