feat(campaigns): add governed collaboration thread
Module Package Release / publish-packages (push) Successful in 13s
Module Package Release / publish-packages (push) Successful in 13s
This commit is contained in:
@@ -28,6 +28,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
AttachmentInstance,
|
||||
CampaignIssue,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
@@ -41,7 +42,13 @@ from govoplan_campaign.backend.db.models import (
|
||||
)
|
||||
|
||||
|
||||
READ_ACTIONS = {"campaigns:campaign:read", "campaign:read"}
|
||||
READ_ACTIONS = {
|
||||
"campaigns:campaign:read",
|
||||
"campaign:read",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
}
|
||||
CAMPAIGN_RESOURCE_TYPES = {
|
||||
"campaign",
|
||||
"campaign_object",
|
||||
@@ -51,6 +58,10 @@ CAMPAIGN_VERSION_RESOURCE_TYPES = {
|
||||
"campaign_version",
|
||||
"campaigns:version",
|
||||
}
|
||||
CAMPAIGN_COLLABORATION_RESOURCE_TYPES = {
|
||||
"campaign_collaboration_entry",
|
||||
"campaigns:collaboration_entry",
|
||||
}
|
||||
CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES = {
|
||||
"campaign_delivery_job",
|
||||
"campaign_job",
|
||||
@@ -558,6 +569,52 @@ class CampaignAccessService(CampaignAccessProvider):
|
||||
return tuple(items)
|
||||
if normalized_type in CAMPAIGN_RESOURCE_TYPES:
|
||||
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
|
||||
elif normalized_type in CAMPAIGN_COLLABORATION_RESOURCE_TYPES:
|
||||
entry = session.get(CampaignCollaborationEntry, resource_id) # type: ignore[attr-defined]
|
||||
if entry is None:
|
||||
return _missing_resource_provenance(
|
||||
principal,
|
||||
resource_type="campaign_collaboration_entry",
|
||||
resource_id=resource_id,
|
||||
)
|
||||
campaign = session.get(Campaign, entry.campaign_id) # type: ignore[attr-defined]
|
||||
normalized_action = action.strip().lower()
|
||||
required_actions = (
|
||||
("campaigns:discussion:moderate",)
|
||||
if normalized_action.endswith(":moderate")
|
||||
else (
|
||||
("campaigns:discussion:post",)
|
||||
if normalized_action.endswith(":post")
|
||||
else ("campaigns:discussion:read",)
|
||||
)
|
||||
)
|
||||
child_item = _child_provenance(
|
||||
principal,
|
||||
resource_id=entry.id,
|
||||
source="campaigns.collaboration_entry",
|
||||
label="Campaign collaboration entry",
|
||||
campaign=campaign,
|
||||
version_id=entry.campaign_version_id,
|
||||
details={
|
||||
"resource_type": "campaign_collaboration_entry",
|
||||
"visibility": entry.visibility,
|
||||
"reference_kind": entry.reference_kind,
|
||||
"reference_id": entry.reference_id,
|
||||
"state": (
|
||||
"redacted"
|
||||
if entry.redacted_at
|
||||
else "withdrawn" if entry.withdrawn_at else "posted"
|
||||
),
|
||||
"content_disclosed": False,
|
||||
"actor_identity_disclosed": False,
|
||||
"permission_classes": {
|
||||
"read": ["campaigns:discussion:read"],
|
||||
"post": ["campaigns:discussion:post"],
|
||||
"moderate": ["campaigns:discussion:moderate"],
|
||||
},
|
||||
},
|
||||
required_actions=required_actions,
|
||||
)
|
||||
elif normalized_type in CAMPAIGN_SHARE_RESOURCE_TYPES:
|
||||
share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined]
|
||||
if share is None:
|
||||
|
||||
@@ -168,6 +168,61 @@ class CampaignShare(Base, TimestampMixin):
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
class CampaignCollaborationEntry(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_collaboration_entries"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_collaboration_entries_thread",
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="collaborators",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
mention_user_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
withdrawn_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
redacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
redacted_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
tombstone_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class CampaignSchedule(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_schedules"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -25,6 +25,9 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -195,6 +198,50 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.collaborate-on-campaign",
|
||||
title="Discuss campaign work without changing its evidence",
|
||||
summary="Use the governed collaboration thread for human discussion linked to stable Campaign evidence.",
|
||||
body="Collaboration is governed independently from Campaign editing. A discussion reader still needs access to the parent Campaign; posting and moderation use separate permissions. Posted text is append-only. Authors can withdraw their own entry and moderators can redact an entry, but both actions leave the actor, timestamp, reference, evidence hash, tombstone, and audit record. A comment may reference a Campaign version, recipient import batch, attachment rule, delivery job, or report. The reference never edits the historical version. Mentions create an in-app notification only when Notifications is available and only for active users who already have Campaign access. Human discussion is not system state and never replaces Audit evidence.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:discussion:read"),
|
||||
route="/campaigns/{campaign_id}/activity",
|
||||
screen="Campaign collaboration",
|
||||
help_contexts=(
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
),
|
||||
prerequisites=(
|
||||
"You can read the Campaign and its discussion.",
|
||||
"Posting requires the separate campaign discussion-post permission.",
|
||||
),
|
||||
steps=(
|
||||
"Open Collaboration in the selected Campaign workspace.",
|
||||
"Optionally select a stable version or enter the stable ID of another supported Campaign evidence reference.",
|
||||
"Mention only collaborators who already have access, then post the bounded comment.",
|
||||
"Withdraw your own mistaken entry or ask an authorized moderator to redact content that must no longer be displayed.",
|
||||
"Use Tenant audit for system events and durable action evidence; do not treat discussion as workflow state.",
|
||||
),
|
||||
outcome="An attributable human discussion entry that does not mutate Campaign versions or impersonate audit evidence.",
|
||||
verification="Reload Collaboration, follow the typed reference, and confirm any withdrawal or redaction appears as a tombstone while the referenced version remains unchanged.",
|
||||
required_capabilities=(),
|
||||
related_modules=("notifications", "audit"),
|
||||
limitations=(
|
||||
"Notifications is optional; the discussion remains available when mention delivery is not configured.",
|
||||
"Comments do not approve, validate, build, queue, send, or otherwise transition a Campaign.",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagnenarbeit besprechen, ohne Nachweise zu verändern",
|
||||
"summary": "Den geregelten Diskussionsverlauf für menschliche Abstimmung mit stabilen Kampagnennachweisen verwenden.",
|
||||
"body": "Die Zusammenarbeit wird unabhängig von der Kampagnenbearbeitung berechtigt. Lesende benötigen weiterhin Zugriff auf die übergeordnete Kampagne; Veröffentlichung und Moderation verwenden eigene Berechtigungen. Veröffentlichter Text ist unveränderlich. Verfassende können eigene Einträge zurücknehmen, Moderierende können Einträge schwärzen. Dabei bleiben Person, Zeitstempel, Referenz, Nachweis-Hash, Platzhalter und Auditnachweis erhalten. Kommentare können Kampagnenversionen, Empfänger-Importläufe, Anlagenregeln, Sendeaufträge oder Berichte referenzieren, ohne historische Versionen zu verändern. Erwähnungen erzeugen nur bei verfügbarem Benachrichtigungsmodul eine interne Benachrichtigung und nur für aktive Personen mit bestehendem Kampagnenzugriff. Diskussion ist kein Systemzustand und ersetzt keinen Auditnachweis.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.reuse-content-library",
|
||||
title="Reuse Campaign content through Templates",
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
@@ -646,6 +647,62 @@ class CampaignDsarProvider:
|
||||
version_ids: set[str],
|
||||
) -> None:
|
||||
if subject_user_id is not None:
|
||||
collaboration_rows = _bounded_rows(
|
||||
db.query(CampaignCollaborationEntry)
|
||||
.filter(
|
||||
CampaignCollaborationEntry.tenant_id == tenant_id,
|
||||
or_(
|
||||
CampaignCollaborationEntry.actor_user_id == subject_user_id,
|
||||
CampaignCollaborationEntry.withdrawn_by_user_id == subject_user_id,
|
||||
CampaignCollaborationEntry.redacted_by_user_id == subject_user_id,
|
||||
cast(CampaignCollaborationEntry.mention_user_ids, Text).contains(
|
||||
f'"{subject_user_id}"'
|
||||
),
|
||||
),
|
||||
)
|
||||
.order_by(CampaignCollaborationEntry.id)
|
||||
)
|
||||
for entry in collaboration_rows:
|
||||
match_fields = _matching_fields(
|
||||
entry,
|
||||
subject_user_id,
|
||||
(
|
||||
"actor_user_id",
|
||||
"withdrawn_by_user_id",
|
||||
"redacted_by_user_id",
|
||||
),
|
||||
)
|
||||
if subject_user_id in (entry.mention_user_ids or []):
|
||||
match_fields.append("mention_user_ids")
|
||||
authored = entry.actor_user_id == subject_user_id
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"campaign_collaboration_entry",
|
||||
entry.id,
|
||||
"campaign_collaboration_evidence",
|
||||
"Campaign collaboration entry",
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"campaign_id": entry.campaign_id,
|
||||
"campaign_version_id": entry.campaign_version_id,
|
||||
"visibility": entry.visibility,
|
||||
"reference_kind": entry.reference_kind,
|
||||
"reference_id": entry.reference_id,
|
||||
"posted_text": entry.content if authored else None,
|
||||
"content_disclosed": authored and entry.content is not None,
|
||||
"content_sha256": entry.content_sha256,
|
||||
"withdrawn_at": _iso(entry.withdrawn_at),
|
||||
"redacted_at": _iso(entry.redacted_at),
|
||||
},
|
||||
observed_at=entry.updated_at,
|
||||
immutable=True,
|
||||
retention_reason=(
|
||||
"Append-only discussion identity, reference, hash, tombstone, and moderation evidence are retained with Campaign access and Audit evidence."
|
||||
),
|
||||
source_path=f"/campaigns/{entry.campaign_id}/activity",
|
||||
)
|
||||
)
|
||||
|
||||
shares = _bounded_rows(
|
||||
db.query(CampaignShare)
|
||||
.filter(
|
||||
|
||||
@@ -50,6 +50,7 @@ from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
)
|
||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -102,6 +103,24 @@ PERMISSIONS = (
|
||||
"Open campaign metadata, versions and permitted message summaries.",
|
||||
"Campaigns",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:discussion:read",
|
||||
"View campaign discussions",
|
||||
"Read human collaboration entries attached to campaigns the user can already access.",
|
||||
"Campaign collaboration",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:discussion:post",
|
||||
"Post campaign discussions",
|
||||
"Post and withdraw the user's own append-only campaign collaboration entries.",
|
||||
"Campaign collaboration",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:discussion:moderate",
|
||||
"Moderate campaign discussions",
|
||||
"Read moderator-only entries and redact campaign collaboration content while retaining tombstones.",
|
||||
"Campaign collaboration",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:create",
|
||||
"Create campaigns",
|
||||
@@ -302,6 +321,9 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:ownership:accept_group",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -316,6 +338,8 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
),
|
||||
@@ -332,6 +356,8 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:diagnostic:read",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
@@ -385,7 +411,7 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.19",
|
||||
version="0.1.20",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -393,6 +419,7 @@ manifest = ModuleManifest(
|
||||
optional_capabilities=(
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
CAPABILITY_APPROVAL_REQUESTS,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
),
|
||||
optional_dependencies=(
|
||||
"files",
|
||||
@@ -517,6 +544,12 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
@@ -606,11 +639,19 @@ manifest = ModuleManifest(
|
||||
"campaigns.route.operator-redirect",
|
||||
OPERATOR_QUEUE_SURFACE_ID,
|
||||
REPORTS_SURFACE_ID,
|
||||
"campaigns.page.activity",
|
||||
),
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="campaigns.page.activity",
|
||||
module_id="campaigns",
|
||||
kind="page",
|
||||
label="Campaign collaboration",
|
||||
order=45,
|
||||
),
|
||||
ViewSurface(
|
||||
id="campaigns.widget.activity",
|
||||
module_id="campaigns",
|
||||
@@ -656,6 +697,7 @@ manifest = ModuleManifest(
|
||||
campaign_models.CampaignSchedule,
|
||||
campaign_models.CampaignScheduleOccurrence,
|
||||
campaign_models.CampaignShare,
|
||||
campaign_models.CampaignCollaborationEntry,
|
||||
campaign_models.RecipientImportMappingProfile,
|
||||
campaign_models.CampaignVersion,
|
||||
campaign_models.CampaignJob,
|
||||
@@ -678,6 +720,7 @@ manifest = ModuleManifest(
|
||||
campaign_models.CampaignSchedule,
|
||||
campaign_models.CampaignScheduleOccurrence,
|
||||
campaign_models.CampaignShare,
|
||||
campaign_models.CampaignCollaborationEntry,
|
||||
campaign_models.RecipientImportMappingProfile,
|
||||
campaign_models.CampaignVersion,
|
||||
campaign_models.CampaignJob,
|
||||
@@ -737,14 +780,71 @@ manifest = ModuleManifest(
|
||||
"help_contexts": ["campaigns.quick_access.campaigns"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.admin.collaboration-governance",
|
||||
title="Govern Campaign collaboration permissions and retention",
|
||||
summary="Configure discussion access separately from Campaign editing and retain auditable moderation tombstones.",
|
||||
body=(
|
||||
"Campaign collaboration uses separate read, post, and moderate permissions in addition to parent Campaign read access. "
|
||||
"The built-in manager role can moderate, while reviewer and sender roles can read and post without receiving Campaign edit permission. "
|
||||
"A read share is sufficient as the parent resource grant; comments never upgrade it to write access. Moderator-only visibility is filtered server-side. "
|
||||
"Posted content has no edit API. Author withdrawal and moderator redaction remove displayed content while retaining the stable entry, SHA-256 evidence, actor snapshot, timestamp, typed reference, tombstone, and bounded Audit event. "
|
||||
"Mention targets are restricted to active users who already have Campaign ownership or share access. If the optional Notifications dispatch capability is available, Campaign emits content-free inbox notifications; provider failure does not make Notifications a required Campaign dependency. "
|
||||
"Operators must apply institutional retention and privacy policy to collaboration rows and Audit evidence together and must not represent comments as approvals, workflow transitions, or system events."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("campaign_manager", "module_admin", "privacy_officer", "records_manager"),
|
||||
order=41,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=(
|
||||
"campaigns:discussion:moderate",
|
||||
"access:roles:manage",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Campaign collaboration",
|
||||
href="/campaigns",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Campaign handbook",
|
||||
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "notifications"),
|
||||
metadata={
|
||||
"kind": "configuration",
|
||||
"route": "/campaigns/{campaign_id}/activity",
|
||||
"screen": "Campaign collaboration",
|
||||
"help_contexts": [
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
],
|
||||
"permission_scopes": [
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
],
|
||||
"verification": "Test a read-only collaborator, a poster without Campaign edit, and a moderator; confirm moderator visibility, mention access filtering, tombstones, and Audit records.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.privacy.data-subject-requests",
|
||||
title="Review Campaign data in a data-subject request",
|
||||
summary="Collect recipient, version, delivery, report, and artifact metadata without rewriting immutable evidence.",
|
||||
summary="Collect recipient, collaboration, version, delivery, report, and artifact metadata without rewriting immutable evidence.",
|
||||
body=(
|
||||
"Campaign's DSAR provider searches the effective tenant by normalized recipient email, direct membership references, and namespaced Campaign job, entry, version, or Campaign references. "
|
||||
"It isolates matching inline-recipient fields and job metadata, and reports built versions, delivery attempts, Postbox and print outcomes, message-action corrections, recipient-specific report projections, generated-message digests, and attachment metadata. It does not export EML bytes, object or local paths, provider target snapshots, worker claims, idempotency material, secrets, credentials, or unrelated recipient addresses. "
|
||||
"Built, locked, published, terminal, delivered, or corrected records remain retained with a reason and continue through Campaign's configured retention/redaction process. Draft recipient content and user-owned attachment content require coordinated manual review because the same data may occur in version JSON, jobs, and generated artifacts. The provider can idempotently delete a personal recipient-import mapping profile and revoke an active Campaign share aimed at the subject. It never rewrites delivered evidence or deletes generated artifacts directly. Campaign reports are derived projections rather than a separate personal-data store."
|
||||
"It isolates matching inline-recipient fields and job metadata, and reports built versions, delivery attempts, Postbox and print outcomes, message-action corrections, recipient-specific report projections, generated-message digests, attachment metadata, and collaboration entries authored, mentioned, or moderated by the subject. Authored collaboration text is included; text authored by somebody else is not copied merely because the subject was mentioned. It does not export EML bytes, object or local paths, provider target snapshots, worker claims, idempotency material, secrets, credentials, or unrelated recipient addresses. "
|
||||
"Built, locked, published, terminal, delivered, corrected, withdrawn, or redacted records remain retained with a reason and continue through Campaign's configured retention/redaction process. Collaboration tombstones, hashes, and Audit evidence remain immutable. Draft recipient content and user-owned attachment content require coordinated manual review because the same data may occur in version JSON, jobs, and generated artifacts. The provider can idempotently delete a personal recipient-import mapping profile and revoke an active Campaign share aimed at the subject. It never rewrites delivered evidence or deletes generated artifacts directly. Campaign reports are derived projections rather than a separate personal-data store."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
"""add governed campaign collaboration entries
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "b6c7d8e9f0a1"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "b6c7d8e9f0a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_collaboration_entries"):
|
||||
return
|
||||
op.create_table(
|
||||
"campaign_collaboration_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"visibility",
|
||||
sa.String(length=30),
|
||||
nullable=False,
|
||||
server_default="collaborators",
|
||||
),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("content_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("mention_user_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("withdrawn_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("redacted_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("tombstone_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_version_id"],
|
||||
["campaign_versions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["redacted_by_user_id"],
|
||||
["access_users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["withdrawn_by_user_id"],
|
||||
["access_users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_collaboration_entries_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_collaboration_entries_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_collaboration_entries_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_collaboration_entries_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_collaboration_entries_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_collaboration_entries_visibility", ["visibility"]),
|
||||
("ix_campaign_collaboration_entries_withdrawn_at", ["withdrawn_at"]),
|
||||
("ix_campaign_collaboration_entries_redacted_at", ["redacted_at"]),
|
||||
(
|
||||
"ix_campaign_collaboration_entries_thread",
|
||||
["tenant_id", "campaign_id", "created_at", "id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "campaign_collaboration_entries", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_collaboration_entries"):
|
||||
op.drop_table("campaign_collaboration_entries")
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter
|
||||
|
||||
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
|
||||
@@ -17,6 +18,7 @@ router = APIRouter()
|
||||
for workflow_router in (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
collaboration_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
|
||||
@@ -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
|
||||
@@ -42,6 +42,97 @@ class CampaignUpdateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
CampaignCollaborationReferenceKind = Literal[
|
||||
"campaign_version",
|
||||
"recipient_import_batch",
|
||||
"attachment_rule",
|
||||
"delivery_job",
|
||||
"report",
|
||||
]
|
||||
|
||||
|
||||
class CampaignCollaborationReferenceInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: CampaignCollaborationReferenceKind
|
||||
id: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def strip_reference_text(cls, value: str) -> str:
|
||||
clean = value.strip()
|
||||
if not clean:
|
||||
raise ValueError("A collaboration reference ID cannot be empty.")
|
||||
return clean
|
||||
|
||||
|
||||
class CampaignCollaborationCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
content: str = Field(min_length=1, max_length=8_000)
|
||||
visibility: Literal["collaborators", "moderators"] = "collaborators"
|
||||
reference: CampaignCollaborationReferenceInput | None = None
|
||||
mention_user_ids: list[str] = Field(default_factory=list, max_length=20)
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def strip_content(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("A collaboration entry cannot be empty.")
|
||||
return value.strip()
|
||||
|
||||
@field_validator("mention_user_ids")
|
||||
@classmethod
|
||||
def normalize_mentions(cls, value: list[str]) -> list[str]:
|
||||
normalized = list(dict.fromkeys(item.strip() for item in value if item.strip()))
|
||||
if len(normalized) > 20:
|
||||
raise ValueError("A collaboration entry can mention at most 20 users.")
|
||||
if any(len(item) > 64 for item in normalized):
|
||||
raise ValueError("A collaboration mention contains an invalid user ID.")
|
||||
return normalized
|
||||
|
||||
|
||||
class CampaignCollaborationModerationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_reason(cls, value: str | None) -> str | None:
|
||||
clean = value.strip() if value is not None else None
|
||||
return clean or None
|
||||
|
||||
|
||||
class CampaignCollaborationReferenceResponse(BaseModel):
|
||||
kind: CampaignCollaborationReferenceKind
|
||||
id: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class CampaignCollaborationEntryResponse(BaseModel):
|
||||
id: str
|
||||
campaign_id: str
|
||||
actor_user_id: str | None = None
|
||||
actor_label: str
|
||||
visibility: Literal["collaborators", "moderators"]
|
||||
content: str | None = None
|
||||
content_sha256: str
|
||||
mention_user_ids: list[str] = Field(default_factory=list)
|
||||
reference: CampaignCollaborationReferenceResponse | None = None
|
||||
tombstone: Literal["withdrawn", "redacted"] | None = None
|
||||
tombstone_reason: str | None = None
|
||||
withdrawn_at: datetime | None = None
|
||||
redacted_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CampaignCollaborationListResponse(BaseModel):
|
||||
items: list[CampaignCollaborationEntryResponse]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class CampaignLifecycleMutationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user