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
@@ -0,0 +1,571 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import json
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,
CampaignCollaborationEntry,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.path_security import _attachment_rules
from govoplan_campaign.backend.route_support import (
_access_directory,
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.schemas import (
CampaignCollaborationCreateRequest,
CampaignCollaborationEntryResponse,
CampaignCollaborationListResponse,
CampaignCollaborationModerationRequest,
CampaignCollaborationReferenceInput,
CampaignCollaborationReferenceResponse,
)
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.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_core.core.references import (
access_scope_reference_page,
access_scope_reference_provider_available,
)
from govoplan_core.db.session import get_session
from govoplan_core.security.time import utc_now
router = APIRouter(prefix="/campaigns", tags=["campaign collaboration"])
@router.get(
"/{campaign_id}/collaboration/mention-options",
response_model=ReferenceOptionListResponse,
)
def search_campaign_collaboration_mentions(
campaign_id: str,
q: str = "",
selected: 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:discussion:post")),
) -> ReferenceOptionListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
try:
page = access_scope_reference_page(
get_registry(),
principal,
scope_type="user",
reference_kind="membership",
query=q,
selected_values=selected,
limit=limit,
cursor=cursor,
administrative=True,
session=session,
)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
allowed = [
option
for option in page.options
if _mentioned_user_has_campaign_access(
session,
campaign=campaign,
user_id=option.value,
)
]
return ReferenceOptionListResponse(
options=[ReferenceOptionResponse(**option.to_dict()) for option in allowed],
provider_available=access_scope_reference_provider_available(get_registry()),
next_cursor=page.next_cursor,
has_more=page.has_more,
)
@router.get(
"/{campaign_id}/collaboration",
response_model=CampaignCollaborationListResponse,
)
def list_campaign_collaboration(
campaign_id: str,
limit: int = Query(default=25, ge=1, le=50),
cursor: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:read")),
) -> CampaignCollaborationListResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
query = session.query(CampaignCollaborationEntry).filter(
CampaignCollaborationEntry.tenant_id == principal.tenant_id,
CampaignCollaborationEntry.campaign_id == campaign.id,
)
if not has_scope(principal, "campaigns:discussion:moderate"):
query = query.filter(CampaignCollaborationEntry.visibility == "collaborators")
if cursor:
created_at, entry_id = _decode_cursor(cursor)
query = query.filter(
or_(
CampaignCollaborationEntry.created_at < created_at,
and_(
CampaignCollaborationEntry.created_at == created_at,
CampaignCollaborationEntry.id < entry_id,
),
)
)
rows = (
query.order_by(
CampaignCollaborationEntry.created_at.desc(),
CampaignCollaborationEntry.id.desc(),
)
.limit(limit + 1)
.all()
)
has_more = len(rows) > limit
items = rows[:limit]
return CampaignCollaborationListResponse(
items=[_entry_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}/collaboration",
response_model=CampaignCollaborationEntryResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_collaboration_entry(
campaign_id: str,
payload: CampaignCollaborationCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
) -> CampaignCollaborationEntryResponse:
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
if payload.visibility == "moderators":
_require_permission(principal, "campaigns:discussion:moderate")
reference = _validated_reference(
session,
campaign=campaign,
reference=payload.reference,
)
mentions = _validated_mentions(
session,
campaign=campaign,
user_ids=payload.mention_user_ids,
actor_user_id=principal.user.id,
)
actor_label = (
getattr(principal.user, "display_name", None)
or getattr(principal.user, "email", None)
or principal.user.id
)
entry = CampaignCollaborationEntry(
tenant_id=principal.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,
actor_user_id=principal.user.id,
actor_label_snapshot=str(actor_label)[:255],
visibility=payload.visibility,
content=payload.content,
content_sha256=hashlib.sha256(payload.content.encode("utf-8")).hexdigest(),
mention_user_ids=mentions,
)
session.add(entry)
session.flush()
_enqueue_mention_notifications(
session,
campaign=campaign,
entry=entry,
mention_user_ids=mentions,
)
audit_from_principal(
session,
principal,
action="campaign.collaboration.posted",
object_type="campaign_collaboration_entry",
object_id=entry.id,
details={
"campaign_id": campaign.id,
"campaign_version_id": entry.campaign_version_id,
"visibility": entry.visibility,
"reference_kind": entry.reference_kind,
"reference_id": entry.reference_id,
"mention_count": len(mentions),
"content_sha256": entry.content_sha256,
"content_disclosed": False,
},
commit=True,
)
session.refresh(entry)
return _entry_response(entry)
@router.post(
"/{campaign_id}/collaboration/{entry_id}/withdraw",
response_model=CampaignCollaborationEntryResponse,
)
def withdraw_campaign_collaboration_entry(
campaign_id: str,
entry_id: str,
payload: CampaignCollaborationModerationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
) -> CampaignCollaborationEntryResponse:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
if entry.actor_user_id != principal.user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the author can withdraw this collaboration entry.",
)
if entry.redacted_at is not None or entry.withdrawn_at is not None:
return _entry_response(entry)
entry.content = None
entry.withdrawn_at = utc_now()
entry.withdrawn_by_user_id = principal.user.id
entry.tombstone_reason = payload.reason
audit_from_principal(
session,
principal,
action="campaign.collaboration.withdrawn",
object_type="campaign_collaboration_entry",
object_id=entry.id,
details={
"campaign_id": campaign_id,
"content_sha256": entry.content_sha256,
"reason_recorded": bool(payload.reason),
"content_disclosed": False,
},
commit=True,
)
session.refresh(entry)
return _entry_response(entry)
@router.post(
"/{campaign_id}/collaboration/{entry_id}/redact",
response_model=CampaignCollaborationEntryResponse,
)
def redact_campaign_collaboration_entry(
campaign_id: str,
entry_id: str,
payload: CampaignCollaborationModerationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:moderate")),
) -> CampaignCollaborationEntryResponse:
_get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
if entry.redacted_at is not None:
return _entry_response(entry)
entry.content = None
entry.redacted_at = utc_now()
entry.redacted_by_user_id = principal.user.id
entry.tombstone_reason = payload.reason
audit_from_principal(
session,
principal,
action="campaign.collaboration.redacted",
object_type="campaign_collaboration_entry",
object_id=entry.id,
details={
"campaign_id": campaign_id,
"content_sha256": entry.content_sha256,
"reason_recorded": bool(payload.reason),
"previously_withdrawn": entry.withdrawn_at is not None,
"content_disclosed": False,
},
commit=True,
)
session.refresh(entry)
return _entry_response(entry)
def _entry_for_campaign(
session: Session,
*,
campaign_id: str,
entry_id: str,
principal: ApiPrincipal,
) -> CampaignCollaborationEntry:
entry = session.get(CampaignCollaborationEntry, entry_id)
if (
entry is None
or entry.tenant_id != principal.tenant_id
or entry.campaign_id != campaign_id
):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collaboration entry not found")
return entry
def _validated_reference(
session: Session,
*,
campaign: Campaign,
reference: CampaignCollaborationReferenceInput | None,
) -> tuple[str | None, str, str, str] | None:
if reference is None:
return None
kind = reference.kind
reference_id = reference.id
version: CampaignVersion | None = None
default_label = kind.replace("_", " ").title()
if kind == "campaign_version":
version = session.get(CampaignVersion, reference_id)
if version is not None:
default_label = f"Version {version.version_number}"
elif kind == "delivery_job":
job = session.get(CampaignJob, reference_id)
if job is None or job.campaign_id != campaign.id or job.tenant_id != campaign.tenant_id:
raise _invalid_reference()
version = session.get(CampaignVersion, job.campaign_version_id)
default_label = f"Delivery job {job.id[:8]}"
elif kind in {"recipient_import_batch", "attachment_rule"}:
version_id, separator, child_id = reference_id.partition(":")
if not separator or not version_id or not child_id:
raise _invalid_reference()
version = session.get(CampaignVersion, version_id)
if version is not None and kind == "recipient_import_batch":
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
imports = entries.get("imports") if isinstance(entries, dict) else []
if not any(isinstance(item, dict) and str(item.get("id") or "") == child_id for item in imports or []):
raise _invalid_reference()
default_label = "Recipient import batch"
elif version is not None:
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
if child_id not in {path for path, _rule in _attachment_rules(raw)}:
raise _invalid_reference()
default_label = "Attachment rule"
else:
referenced_campaign_id, separator, remainder = reference_id.partition(":")
version_id, separator_two, report_kind = remainder.partition(":")
if (
not separator
or not separator_two
or referenced_campaign_id != campaign.id
or not version_id
or not report_kind
):
raise _invalid_reference()
version = session.get(CampaignVersion, version_id)
default_label = report_kind.replace("_", " ").title()
if version is None or version.campaign_id != campaign.id:
raise _invalid_reference()
return version.id, kind, reference_id, default_label[:255]
def _invalid_reference() -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="The collaboration reference is not stable evidence owned by this campaign.",
)
def _validated_mentions(
session: Session,
*,
campaign: Campaign,
user_ids: list[str],
actor_user_id: str,
) -> list[str]:
mentions = [user_id for user_id in user_ids if user_id != actor_user_id]
invalid = [
user_id
for user_id in mentions
if not _mentioned_user_has_campaign_access(
session,
campaign=campaign,
user_id=user_id,
)
]
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Mentioned users must be active and already have access to this campaign.",
)
return mentions
def _mentioned_user_has_campaign_access(
session: Session,
*,
campaign: Campaign,
user_id: str,
) -> bool:
directory = _access_directory()
user = next(
(candidate for candidate in directory.users_for_tenant(campaign.tenant_id) if candidate.id == user_id),
None,
)
if user is None or user.status != "active":
return False
if campaign.owner_user_id == user_id:
return True
group_ids = {
group.id
for group 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 _enqueue_mention_notifications(
session: Session,
*,
campaign: Campaign,
entry: CampaignCollaborationEntry,
mention_user_ids: list[str],
) -> None:
provider = notification_dispatch_provider(get_registry())
if provider is None or not mention_user_ids:
return
try:
with session.begin_nested():
for user_id in mention_user_ids:
provider.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=campaign.tenant_id,
source_module="campaigns",
source_resource_type="campaign_collaboration_entry",
source_resource_id=entry.id,
event_kind="campaign.collaboration.mentioned",
channel="inbox",
recipient_type="user",
recipient_id=user_id,
subject=f"Mentioned in campaign: {campaign.name}",
body_text=(
f"{entry.actor_label_snapshot} mentioned you in the campaign collaboration thread."
),
action_url=f"/campaigns/{campaign.id}/activity",
payload={
"campaign_id": campaign.id,
"entry_id": entry.id,
"content_disclosed": False,
},
),
enqueue_delivery=False,
)
except Exception:
# Collaboration remains available when the optional Notifications
# provider is absent or temporarily unhealthy.
return
def _entry_response(entry: CampaignCollaborationEntry) -> CampaignCollaborationEntryResponse:
tombstone: Literal["withdrawn", "redacted"] | None = None
if entry.redacted_at is not None:
tombstone = "redacted"
elif entry.withdrawn_at is not None:
tombstone = "withdrawn"
reference = None
if entry.reference_kind and entry.reference_id:
reference = CampaignCollaborationReferenceResponse(
kind=entry.reference_kind, # type: ignore[arg-type]
id=entry.reference_id,
label=entry.reference_label,
)
return CampaignCollaborationEntryResponse(
id=entry.id,
campaign_id=entry.campaign_id,
actor_user_id=entry.actor_user_id,
actor_label=entry.actor_label_snapshot,
visibility=entry.visibility, # type: ignore[arg-type]
content=entry.content if tombstone is None else None,
content_sha256=entry.content_sha256,
mention_user_ids=list(entry.mention_user_ids or []),
reference=reference,
tombstone=tombstone,
tombstone_reason=entry.tombstone_reason,
withdrawn_at=entry.withdrawn_at,
redacted_at=entry.redacted_at,
created_at=entry.created_at,
)
def _encode_cursor(entry: CampaignCollaborationEntry) -> str:
created_at = entry.created_at
if created_at.tzinfo is None:
# SQLite returns timezone-aware columns as naive UTC values.
created_at = created_at.replace(tzinfo=UTC)
payload = json.dumps(
{"created_at": created_at.astimezone(UTC).isoformat(), "id": entry.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"]))
entry_id = str(payload["id"])
if created_at.tzinfo is None or not entry_id or len(entry_id) > 36:
raise ValueError
return created_at, entry_id
except (
KeyError,
TypeError,
ValueError,
UnicodeDecodeError,
json.JSONDecodeError,
binascii.Error,
) as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Invalid collaboration cursor.",
) from exc