Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f52f010ee | ||
|
|
2630498026 | ||
|
|
c2f083e5f6 |
@@ -45,7 +45,8 @@ Campaign owns:
|
||||
- message and attachment rules for a version;
|
||||
- validation, review, build, queue, and delivery-control state;
|
||||
- the durable jobs and attempts needed to explain delivery outcomes; and
|
||||
- campaign-specific reports, shares, and frozen execution evidence.
|
||||
- campaign-specific reports, shares, frozen execution evidence, and governed
|
||||
human collaboration entries.
|
||||
|
||||
Campaign does not own:
|
||||
|
||||
@@ -111,6 +112,32 @@ action.
|
||||
|
||||
## User tasks
|
||||
|
||||
### Discuss campaign work
|
||||
|
||||
Open **Collaboration** inside a Campaign to keep human coordination beside the
|
||||
work without changing its version history. Discussion access is independent
|
||||
from Campaign editing: parent Campaign read access remains mandatory, while
|
||||
`campaigns:discussion:read`, `campaigns:discussion:post`, and
|
||||
`campaigns:discussion:moderate` separately control reading, posting, and
|
||||
moderation. A read share is sufficient as the parent grant and a comment never
|
||||
turns that share into write access.
|
||||
|
||||
Comments are append-only and bounded to 8,000 characters. They can carry one
|
||||
validated reference to an immutable Campaign version, saved recipient import,
|
||||
attachment rule, delivery job, or report. References to version-bound evidence
|
||||
include the exact version ID and never edit that version. Authors can withdraw
|
||||
their own comments; moderators can redact comments and use moderator-only
|
||||
visibility. Both operations remove displayed text but preserve a tombstone,
|
||||
content hash, actor snapshot, timestamp, reference context, and bounded Audit
|
||||
event. There is deliberately no comment-edit API.
|
||||
|
||||
Mentions are limited to 20 active users who already have Campaign ownership or
|
||||
share access. When Notifications is installed and healthy, Campaign emits a
|
||||
content-free in-app mention notification. Collaboration remains usable without
|
||||
Notifications. The thread displays only human discussion; approvals, workflow
|
||||
state, delivery events, and durable system evidence remain on their owning
|
||||
surfaces and in Tenant audit.
|
||||
|
||||
### Prepare a campaign
|
||||
|
||||
1. Create a campaign and confirm its owner or owning group.
|
||||
@@ -563,7 +590,10 @@ namespaced Campaign references have been independently authorized and
|
||||
corroborated, the provider searches only the effective tenant and isolates the
|
||||
matching recipient entries and jobs. Its JSON result includes safe Campaign,
|
||||
version, delivery-attempt, schedule, report-projection, share, import-mapping,
|
||||
attachment, and generated-artifact metadata. Generated EML bytes and paths,
|
||||
attachment, generated-artifact, and relevant collaboration metadata. It also
|
||||
finds collaboration entries authored, mentioned, or moderated by the subject.
|
||||
Text authored by the subject is included; somebody else's text is not copied
|
||||
merely because the subject was mentioned. Generated EML bytes and paths,
|
||||
storage locators, delivery target snapshots, worker claims, idempotency
|
||||
material, credentials, secret-like values, and unrelated recipients are never
|
||||
embedded in that result. Authorized Campaign and Files review surfaces remain
|
||||
@@ -577,7 +607,9 @@ version JSON, jobs, generated messages, and managed files. The provider can
|
||||
idempotently revoke an active share aimed at the subject and delete the
|
||||
subject's personal recipient-import mapping profile. It does not rewrite
|
||||
delivery evidence, delete generated artifacts, or report derived Campaign
|
||||
counts as a separate store. Re-running an approved action is safe: already
|
||||
counts as a separate store. Collaboration withdrawal and redaction retain the
|
||||
tombstone, content hash, context, and Audit evidence; the DSAR workflow does
|
||||
not rewrite these append-only records. Re-running an approved action is safe: already
|
||||
revoked or absent data is reported as unchanged, and tenant, subject, and row
|
||||
ownership are revalidated immediately before mutation.
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.19"
|
||||
version = "0.1.22"
|
||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -28,11 +28,14 @@ from govoplan_campaign.backend.db.models import (
|
||||
AttachmentInstance,
|
||||
CampaignIssue,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
@@ -41,7 +44,16 @@ 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",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
}
|
||||
CAMPAIGN_RESOURCE_TYPES = {
|
||||
"campaign",
|
||||
"campaign_object",
|
||||
@@ -51,6 +63,18 @@ CAMPAIGN_VERSION_RESOURCE_TYPES = {
|
||||
"campaign_version",
|
||||
"campaigns:version",
|
||||
}
|
||||
CAMPAIGN_COLLABORATION_RESOURCE_TYPES = {
|
||||
"campaign_collaboration_entry",
|
||||
"campaigns:collaboration_entry",
|
||||
}
|
||||
CAMPAIGN_WORK_ASSIGNMENT_RESOURCE_TYPES = {
|
||||
"campaign_work_assignment",
|
||||
"campaigns:work_assignment",
|
||||
}
|
||||
CAMPAIGN_WORK_ASSIGNMENT_EVENT_RESOURCE_TYPES = {
|
||||
"campaign_work_assignment_event",
|
||||
"campaigns:work_assignment_event",
|
||||
}
|
||||
CAMPAIGN_DELIVERY_JOB_RESOURCE_TYPES = {
|
||||
"campaign_delivery_job",
|
||||
"campaign_job",
|
||||
@@ -558,6 +582,131 @@ 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_WORK_ASSIGNMENT_RESOURCE_TYPES:
|
||||
assignment = session.get(CampaignWorkAssignment, resource_id) # type: ignore[attr-defined]
|
||||
if assignment is None:
|
||||
return _missing_resource_provenance(
|
||||
principal,
|
||||
resource_type="campaign_work_assignment",
|
||||
resource_id=resource_id,
|
||||
)
|
||||
campaign = session.get(Campaign, assignment.campaign_id) # type: ignore[attr-defined]
|
||||
normalized_action = action.strip().lower()
|
||||
required_actions = (
|
||||
("campaigns:assignment:manage",)
|
||||
if normalized_action.endswith(":manage")
|
||||
else (
|
||||
("campaigns:assignment:complete",)
|
||||
if normalized_action.endswith(":complete")
|
||||
else ("campaigns:assignment:read",)
|
||||
)
|
||||
)
|
||||
child_item = _child_provenance(
|
||||
principal,
|
||||
resource_id=assignment.id,
|
||||
source="campaigns.work_assignment",
|
||||
label="Campaign work assignment",
|
||||
campaign=campaign,
|
||||
version_id=assignment.campaign_version_id,
|
||||
details={
|
||||
"resource_type": "campaign_work_assignment",
|
||||
"status": assignment.status,
|
||||
"due_at": _iso_value(assignment.due_at),
|
||||
"assignee_type": assignment.assignee_type,
|
||||
"assignee_resolution_state": assignment.assignee_resolution_state,
|
||||
"reference_kind": assignment.reference_kind,
|
||||
"reference_id": assignment.reference_id,
|
||||
"revision": assignment.resource_revision,
|
||||
"purpose_disclosed": False,
|
||||
"assignee_identity_disclosed": False,
|
||||
"authorization_mode": "accountability_does_not_grant_access",
|
||||
"assignment_policy_provenance": dict(
|
||||
assignment.resolution_provenance or {}
|
||||
),
|
||||
"permission_classes": {
|
||||
"read": ["campaigns:assignment:read"],
|
||||
"complete": ["campaigns:assignment:complete"],
|
||||
"manage": ["campaigns:assignment:manage"],
|
||||
},
|
||||
},
|
||||
required_actions=required_actions,
|
||||
)
|
||||
elif normalized_type in CAMPAIGN_WORK_ASSIGNMENT_EVENT_RESOURCE_TYPES:
|
||||
event = session.get(CampaignWorkAssignmentEvent, resource_id) # type: ignore[attr-defined]
|
||||
if event is None:
|
||||
return _missing_resource_provenance(
|
||||
principal,
|
||||
resource_type="campaign_work_assignment_event",
|
||||
resource_id=resource_id,
|
||||
)
|
||||
assignment = session.get(CampaignWorkAssignment, event.assignment_id) # type: ignore[attr-defined]
|
||||
campaign = session.get(Campaign, event.campaign_id) # type: ignore[attr-defined]
|
||||
child_item = _child_provenance(
|
||||
principal,
|
||||
resource_id=event.id,
|
||||
source="campaigns.work_assignment_event",
|
||||
label="Campaign work assignment event",
|
||||
campaign=campaign,
|
||||
version_id=assignment.campaign_version_id if assignment else None,
|
||||
details={
|
||||
"resource_type": "campaign_work_assignment_event",
|
||||
"assignment_id": event.assignment_id,
|
||||
"event_kind": event.event_kind,
|
||||
"status": event.status_snapshot,
|
||||
"assignee_type": event.assignee_type_snapshot,
|
||||
"resolution_state": event.resolution_state_snapshot,
|
||||
"event_details_disclosed": False,
|
||||
"assignee_identity_disclosed": False,
|
||||
"authorization_mode": "immutable_accountability_evidence",
|
||||
},
|
||||
required_actions=("campaigns:assignment:read",),
|
||||
)
|
||||
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,151 @@ 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 CampaignWorkAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_work_assignments"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_work_assignments_campaign_status",
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"status",
|
||||
"due_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)
|
||||
purpose: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
assignee_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
assignee_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
assignee_current_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
assignee_resolution_state: Mapped[str] = mapped_column(
|
||||
String(30), default="resolved", nullable=False, index=True
|
||||
)
|
||||
resolution_provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
resolution_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
assigned_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
assigned_by_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
task_mirror_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
task_mirror_status: Mapped[str] = mapped_column(
|
||||
String(30), default="not_configured", nullable=False
|
||||
)
|
||||
task_mirror_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
task_mirrored_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
|
||||
class CampaignWorkAssignmentEvent(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_work_assignment_events"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_work_assignment_events_history",
|
||||
"tenant_id",
|
||||
"assignment_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
|
||||
)
|
||||
assignment_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_work_assignments.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=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)
|
||||
status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
assignee_type_snapshot: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
assignee_id_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
resolution_state_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class CampaignSchedule(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_schedules"
|
||||
__table_args__ = (
|
||||
@@ -801,6 +946,8 @@ __all__ = [
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"CampaignWorkAssignment",
|
||||
"CampaignWorkAssignmentEvent",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
|
||||
@@ -25,6 +25,12 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -50,6 +56,9 @@ _TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
|
||||
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
||||
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
_TASKS_INTEGRATION = "tasks.commands"
|
||||
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
|
||||
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
|
||||
|
||||
|
||||
def _workflow_topic(
|
||||
@@ -195,6 +204,97 @@ 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.assign-accountable-work",
|
||||
title="Assign accountable Campaign work without granting access",
|
||||
summary="Record bounded work for an account, group, or organization function while keeping authorization and Campaign ownership separate.",
|
||||
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can start and complete their own work; managers can reassign or cancel it. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:assignment:read"),
|
||||
route="/campaigns/{campaign_id}/work",
|
||||
screen="Campaign work",
|
||||
help_contexts=(
|
||||
"campaign.work",
|
||||
"campaign.work.create",
|
||||
"campaign.work.action.start",
|
||||
"campaign.work.action.complete",
|
||||
"campaign.work.action.reassign",
|
||||
"campaign.work.action.cancel",
|
||||
"campaign.work.history",
|
||||
),
|
||||
prerequisites=(
|
||||
"You can read the Campaign and its work assignments.",
|
||||
"Creating, reassigning, cancelling, or reconciling requires the assignment-manage permission.",
|
||||
"The target account, group, or all current function incumbents already have Campaign access.",
|
||||
),
|
||||
steps=(
|
||||
"Open Work in the selected Campaign workspace and choose Add assignment.",
|
||||
"Enter a bounded purpose, optional due date, typed target, and optional stable Campaign evidence reference.",
|
||||
"Resolve any authorization-neutral rejection by granting access through the separate Campaign sharing workflow or choosing another assignee; creating the assignment itself never grants access.",
|
||||
"Start and complete your own assignment, or use manager actions to reassign or cancel open work.",
|
||||
"Reload and reconcile assignments after account, group, organization-function, or incumbency changes; inspect the retained history before acting on unavailable work.",
|
||||
),
|
||||
outcome="A durable accountability record whose lifecycle is independent from Campaign ownership, authorization, and delivery state.",
|
||||
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
|
||||
related_modules=("access", "organizations", "idm", "tasks", "notifications", "audit", "policy"),
|
||||
limitations=(
|
||||
"Organizations and IDM are optional; organization-function assignment is unavailable until both directory and incumbency capabilities are active.",
|
||||
"Tasks mirroring is a convenience projection. Campaign remains the authoritative assignment and history owner.",
|
||||
"Ownership transfer continues to use its separate two-party governance protocol.",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Verantwortliche Kampagnenarbeit zuweisen, ohne Zugriff zu vergeben",
|
||||
"summary": "Begrenzte Arbeit für Konto, Gruppe oder Organisationsfunktion erfassen und Berechtigung sowie Kampagneneigentum getrennt halten.",
|
||||
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.reuse-content-library",
|
||||
title="Reuse Campaign content through Templates",
|
||||
@@ -886,6 +986,9 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:read",), "Read accountable work attached to campaigns you can already access.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:manage",), "Create, reassign, cancel, and reconcile authorization-neutral Campaign work.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:complete",), "Start and complete Campaign work assigned to your account, group, or organization function.")
|
||||
if mail_available:
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||
@@ -993,6 +1096,17 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
||||
else:
|
||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||
|
||||
if _integration_available(registry, _TASKS_INTEGRATION):
|
||||
configured.append("Installed composition: Campaign work assignments may be mirrored into Tasks while Campaign remains the authoritative lifecycle and access boundary.")
|
||||
else:
|
||||
limitations.append("Campaign work remains available, but optional Tasks mirroring is not configured.")
|
||||
if _integration_available(registry, _ORGANIZATIONS_INTEGRATION) and _integration_available(
|
||||
registry, _IDM_FUNCTION_ASSIGNMENTS_INTEGRATION
|
||||
):
|
||||
configured.append("Installed composition: Organization-function assignees can be resolved against active functions and their current IDM incumbencies.")
|
||||
else:
|
||||
limitations.append("Organization-function work assignment requires both Organizations directory and IDM incumbency capabilities; account and group assignment remain available.")
|
||||
|
||||
calendar_available = _integration_available(
|
||||
registry,
|
||||
_CALENDAR_INVITATION_INTEGRATION,
|
||||
|
||||
@@ -4,12 +4,13 @@ from collections import Counter
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Text, cast, func, or_
|
||||
from sqlalchemy import Text, and_, cast, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
@@ -17,6 +18,8 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
@@ -278,6 +281,7 @@ class CampaignDsarProvider:
|
||||
append=append,
|
||||
tenant_id=tenant_id,
|
||||
subject_user_id=subject_user_id,
|
||||
subject_account_id=subject.account_id,
|
||||
campaign_ids=evidence_campaign_ids,
|
||||
version_ids=version_ids,
|
||||
)
|
||||
@@ -642,10 +646,181 @@ class CampaignDsarProvider:
|
||||
append: object,
|
||||
tenant_id: str,
|
||||
subject_user_id: str | None,
|
||||
subject_account_id: str | None,
|
||||
campaign_ids: set[str],
|
||||
version_ids: set[str],
|
||||
) -> None:
|
||||
assignment_filters = []
|
||||
if subject_user_id is not None:
|
||||
assignment_filters.append(
|
||||
CampaignWorkAssignment.assigned_by_user_id == subject_user_id
|
||||
)
|
||||
if subject_account_id is not None:
|
||||
assignment_filters.append(
|
||||
and_(
|
||||
CampaignWorkAssignment.assignee_type == "account",
|
||||
CampaignWorkAssignment.assignee_id == subject_account_id,
|
||||
)
|
||||
)
|
||||
assignment_ids: set[str] = set()
|
||||
if assignment_filters:
|
||||
assignment_rows = _bounded_rows(
|
||||
db.query(CampaignWorkAssignment)
|
||||
.filter(
|
||||
CampaignWorkAssignment.tenant_id == tenant_id,
|
||||
or_(*assignment_filters),
|
||||
)
|
||||
.order_by(CampaignWorkAssignment.id)
|
||||
)
|
||||
assignment_ids = {item.id for item in assignment_rows}
|
||||
for assignment in assignment_rows:
|
||||
match_fields = []
|
||||
if assignment.assigned_by_user_id == subject_user_id:
|
||||
match_fields.append("assigned_by_user_id")
|
||||
if (
|
||||
assignment.assignee_type == "account"
|
||||
and assignment.assignee_id == subject_account_id
|
||||
):
|
||||
match_fields.append("assignee_id")
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"campaign_work_assignment",
|
||||
assignment.id,
|
||||
"campaign_work_accountability",
|
||||
"Campaign work assignment",
|
||||
{
|
||||
"match_fields": match_fields,
|
||||
"campaign_id": assignment.campaign_id,
|
||||
"campaign_version_id": assignment.campaign_version_id,
|
||||
"purpose": assignment.purpose,
|
||||
"status": assignment.status,
|
||||
"due_at": _iso(assignment.due_at),
|
||||
"assignee_type": assignment.assignee_type,
|
||||
"assignee_id": assignment.assignee_id,
|
||||
"assignee_label_snapshot": assignment.assignee_label_snapshot,
|
||||
"assignee_resolution_state": assignment.assignee_resolution_state,
|
||||
"reference_kind": assignment.reference_kind,
|
||||
"reference_id": assignment.reference_id,
|
||||
"completed_at": _iso(assignment.completed_at),
|
||||
"cancelled_at": _iso(assignment.cancelled_at),
|
||||
},
|
||||
observed_at=assignment.updated_at,
|
||||
source_path=f"/campaigns/{assignment.campaign_id}/work",
|
||||
)
|
||||
)
|
||||
|
||||
event_filters = []
|
||||
if subject_user_id is not None:
|
||||
event_filters.append(
|
||||
CampaignWorkAssignmentEvent.actor_user_id == subject_user_id
|
||||
)
|
||||
if assignment_ids:
|
||||
event_filters.append(
|
||||
CampaignWorkAssignmentEvent.assignment_id.in_(assignment_ids)
|
||||
)
|
||||
if subject_account_id is not None:
|
||||
event_filters.extend(
|
||||
(
|
||||
and_(
|
||||
CampaignWorkAssignmentEvent.assignee_type_snapshot == "account",
|
||||
CampaignWorkAssignmentEvent.assignee_id_snapshot
|
||||
== subject_account_id,
|
||||
),
|
||||
cast(CampaignWorkAssignmentEvent.details, Text).contains(
|
||||
subject_account_id
|
||||
),
|
||||
)
|
||||
)
|
||||
if event_filters:
|
||||
event_rows = _bounded_rows(
|
||||
db.query(CampaignWorkAssignmentEvent)
|
||||
.filter(
|
||||
CampaignWorkAssignmentEvent.tenant_id == tenant_id,
|
||||
or_(*event_filters),
|
||||
)
|
||||
.order_by(CampaignWorkAssignmentEvent.id)
|
||||
)
|
||||
for event in event_rows:
|
||||
append( # type: ignore[operator]
|
||||
_record(
|
||||
"campaign_work_assignment_event",
|
||||
event.id,
|
||||
"campaign_work_accountability_evidence",
|
||||
"Campaign work assignment event",
|
||||
{
|
||||
"campaign_id": event.campaign_id,
|
||||
"assignment_id": event.assignment_id,
|
||||
"event_kind": event.event_kind,
|
||||
"status": event.status_snapshot,
|
||||
"assignee_type": event.assignee_type_snapshot,
|
||||
"assignee_id": event.assignee_id_snapshot,
|
||||
"assignee_label_snapshot": event.assignee_label_snapshot,
|
||||
"resolution_state": event.resolution_state_snapshot,
|
||||
},
|
||||
observed_at=event.created_at,
|
||||
immutable=True,
|
||||
retention_reason="Assignment lifecycle events retain accountable institutional work history.",
|
||||
source_path=f"/campaigns/{event.campaign_id}/work",
|
||||
)
|
||||
)
|
||||
|
||||
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,10 @@ 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.idm import CAPABILITY_IDM_FUNCTION_ASSIGNMENTS
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
|
||||
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||
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 +106,42 @@ 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:assignment:read",
|
||||
"View campaign work assignments",
|
||||
"Read accountable work assignments for campaigns the user can already access.",
|
||||
"Campaign work",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:assignment:manage",
|
||||
"Manage campaign work assignments",
|
||||
"Create, reassign, cancel, and reconcile authorization-neutral campaign work assignments.",
|
||||
"Campaign work",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:assignment:complete",
|
||||
"Complete assigned campaign work",
|
||||
"Start or complete campaign work assigned to the current account, group, or organization function.",
|
||||
"Campaign work",
|
||||
),
|
||||
_permission(
|
||||
"campaigns:campaign:create",
|
||||
"Create campaigns",
|
||||
@@ -302,6 +342,12 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:ownership:accept_group",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -316,6 +362,10 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
),
|
||||
@@ -332,6 +382,10 @@ ROLE_TEMPLATES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:diagnostic:read",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
@@ -385,7 +439,7 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.19",
|
||||
version="0.1.22",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -393,6 +447,10 @@ manifest = ModuleManifest(
|
||||
optional_capabilities=(
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
CAPABILITY_APPROVAL_REQUESTS,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
CAPABILITY_TASK_COMMANDS,
|
||||
),
|
||||
optional_dependencies=(
|
||||
"files",
|
||||
@@ -406,6 +464,9 @@ manifest = ModuleManifest(
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
"organizations",
|
||||
"idm",
|
||||
"tasks",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
@@ -517,6 +578,30 @@ 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=CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_TASK_COMMANDS,
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
@@ -606,11 +691,27 @@ manifest = ModuleManifest(
|
||||
"campaigns.route.operator-redirect",
|
||||
OPERATOR_QUEUE_SURFACE_ID,
|
||||
REPORTS_SURFACE_ID,
|
||||
"campaigns.page.work",
|
||||
"campaigns.page.activity",
|
||||
),
|
||||
order=40,
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="campaigns.page.work",
|
||||
module_id="campaigns",
|
||||
kind="page",
|
||||
label="Campaign work",
|
||||
order=44,
|
||||
),
|
||||
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 +757,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 +780,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 +840,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")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""add accountable campaign work assignments
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
op.create_table(
|
||||
"campaign_work_assignments",
|
||||
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("purpose", sa.String(length=500), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False, server_default="open"),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("assignee_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("assignee_current_label", sa.String(length=500), nullable=True),
|
||||
sa.Column("assignee_resolution_state", sa.String(length=30), nullable=False, server_default="resolved"),
|
||||
sa.Column("resolution_provenance", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("resolution_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("assigned_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("assigned_by_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("task_mirror_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("task_mirror_status", sa.String(length=30), nullable=False, server_default="not_configured"),
|
||||
sa.Column("task_mirror_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("task_mirrored_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["assigned_by_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.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignments_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignments_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignments_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_work_assignments_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_work_assignments_status", ["status"]),
|
||||
("ix_campaign_work_assignments_due_at", ["due_at"]),
|
||||
("ix_campaign_work_assignments_assignee_type", ["assignee_type"]),
|
||||
("ix_campaign_work_assignments_assignee_id", ["assignee_id"]),
|
||||
("ix_campaign_work_assignments_assignee_resolution_state", ["assignee_resolution_state"]),
|
||||
("ix_campaign_work_assignments_assigned_by_user_id", ["assigned_by_user_id"]),
|
||||
("ix_campaign_work_assignments_campaign_status", ["tenant_id", "campaign_id", "status", "due_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignments", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignment_events"):
|
||||
op.create_table(
|
||||
"campaign_work_assignment_events",
|
||||
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("assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("status_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("assignee_type_snapshot", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("resolution_state_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=False, server_default="{}"),
|
||||
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(["assignment_id"], ["campaign_work_assignments.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignment_events_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignment_events_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignment_events_assignment_id", ["assignment_id"]),
|
||||
("ix_campaign_work_assignment_events_event_kind", ["event_kind"]),
|
||||
("ix_campaign_work_assignment_events_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_work_assignment_events_history", ["tenant_id", "assignment_id", "created_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignment_events", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignment_events"):
|
||||
op.drop_table("campaign_work_assignment_events")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignments"):
|
||||
op.drop_table("campaign_work_assignments")
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter
|
||||
|
||||
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||
from govoplan_campaign.backend.routes.assignments import router as assignments_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 +19,8 @@ router = APIRouter()
|
||||
for workflow_router in (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
assignments_router,
|
||||
collaboration_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
|
||||
@@ -0,0 +1,988 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignShare,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_access_directory,
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.collaboration import _validated_reference
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignWorkAssigneeInput,
|
||||
CampaignWorkAssignmentCreateRequest,
|
||||
CampaignWorkAssignmentEventResponse,
|
||||
CampaignWorkAssignmentHistoryResponse,
|
||||
CampaignWorkAssignmentListResponse,
|
||||
CampaignWorkAssignmentReassignRequest,
|
||||
CampaignWorkAssignmentReconcileResponse,
|
||||
CampaignWorkAssignmentReferenceResponse,
|
||||
CampaignWorkAssignmentResponse,
|
||||
CampaignWorkAssignmentTransitionRequest,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import ReferenceOptionListResponse, ReferenceOptionResponse
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.core.idm import (
|
||||
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||
IdmFunctionAssignmentDirectory,
|
||||
)
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_core.core.organizations import organization_directory
|
||||
from govoplan_core.core.references import ReferenceOption
|
||||
from govoplan_core.core.tasks import (
|
||||
TaskCreateCommand,
|
||||
WorkAssignmentRef,
|
||||
WorkSourceRef,
|
||||
task_command_provider,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaign work assignments"])
|
||||
|
||||
AssigneeType = Literal["account", "group", "organization_function"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AssigneeResolution:
|
||||
state: Literal["resolved", "unavailable", "provider_unavailable"]
|
||||
label: str | None
|
||||
recipient_type: str
|
||||
recipient_id: str
|
||||
account_ids: tuple[str, ...]
|
||||
provenance: dict[str, object]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/assignments/options",
|
||||
response_model=ReferenceOptionListResponse,
|
||||
)
|
||||
def search_campaign_assignment_options(
|
||||
campaign_id: str,
|
||||
assignee_type: AssigneeType,
|
||||
q: str = "",
|
||||
selected: list[str] = Query(default=[]),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
|
||||
) -> ReferenceOptionListResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
options = _assignment_options(
|
||||
session,
|
||||
campaign=campaign,
|
||||
assignee_type=assignee_type,
|
||||
query=q,
|
||||
selected_values=selected,
|
||||
limit=limit,
|
||||
)
|
||||
provider_available = (
|
||||
assignee_type != "organization_function"
|
||||
or (
|
||||
organization_directory(get_registry()) is not None
|
||||
and _idm_function_directory() is not None
|
||||
)
|
||||
)
|
||||
return ReferenceOptionListResponse(
|
||||
options=[ReferenceOptionResponse(**item.to_dict()) for item in options],
|
||||
provider_available=provider_available,
|
||||
next_cursor=None,
|
||||
has_more=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/assignments",
|
||||
response_model=CampaignWorkAssignmentListResponse,
|
||||
)
|
||||
def list_campaign_work_assignments(
|
||||
campaign_id: str,
|
||||
assignment_status: list[str] = Query(default=[]),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
|
||||
) -> CampaignWorkAssignmentListResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
statuses = tuple(dict.fromkeys(item.strip() for item in assignment_status if item.strip()))
|
||||
if any(item not in {"open", "in_progress", "completed", "cancelled"} for item in statuses):
|
||||
raise HTTPException(status_code=422, detail="Unsupported assignment status filter.")
|
||||
query = session.query(CampaignWorkAssignment).filter(
|
||||
CampaignWorkAssignment.tenant_id == principal.tenant_id,
|
||||
CampaignWorkAssignment.campaign_id == campaign.id,
|
||||
)
|
||||
if statuses:
|
||||
query = query.filter(CampaignWorkAssignment.status.in_(statuses))
|
||||
if cursor:
|
||||
created_at, assignment_id = _decode_cursor(cursor)
|
||||
query = query.filter(
|
||||
or_(
|
||||
CampaignWorkAssignment.created_at < created_at,
|
||||
and_(
|
||||
CampaignWorkAssignment.created_at == created_at,
|
||||
CampaignWorkAssignment.id < assignment_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
query.order_by(CampaignWorkAssignment.created_at.desc(), CampaignWorkAssignment.id.desc())
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return CampaignWorkAssignmentListResponse(
|
||||
items=[_assignment_response(item) for item in items],
|
||||
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/assignments",
|
||||
response_model=CampaignWorkAssignmentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_work_assignment(
|
||||
campaign_id: str,
|
||||
payload: CampaignWorkAssignmentCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
|
||||
) -> CampaignWorkAssignmentResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
reference = _validated_reference(session, campaign=campaign, reference=payload.reference)
|
||||
resolution = _resolve_assignee(session, campaign=campaign, assignee=payload.assignee)
|
||||
_require_resolved_assignee(resolution)
|
||||
now = utc_now()
|
||||
actor_label = _actor_label(principal)
|
||||
assignment = CampaignWorkAssignment(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=reference[0] if reference else None,
|
||||
reference_kind=reference[1] if reference else None,
|
||||
reference_id=reference[2] if reference else None,
|
||||
reference_label=reference[3] if reference else None,
|
||||
purpose=payload.purpose,
|
||||
status="open",
|
||||
due_at=payload.due_at,
|
||||
assignee_type=payload.assignee.type,
|
||||
assignee_id=payload.assignee.id,
|
||||
assignee_label_snapshot=(resolution.label or payload.assignee.id)[:500],
|
||||
assignee_current_label=resolution.label,
|
||||
assignee_resolution_state=resolution.state,
|
||||
resolution_provenance=resolution.provenance,
|
||||
resolution_checked_at=now,
|
||||
assigned_by_user_id=principal.user.id,
|
||||
assigned_by_label_snapshot=actor_label,
|
||||
)
|
||||
session.add(assignment)
|
||||
session.flush()
|
||||
_record_event(session, assignment=assignment, principal=principal, event_kind="assigned")
|
||||
if payload.mirror_to_tasks:
|
||||
_mirror_assignment_to_tasks(session, campaign=campaign, assignment=assignment, principal=principal)
|
||||
else:
|
||||
assignment.task_mirror_status = "skipped"
|
||||
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind="assigned")
|
||||
_audit_assignment(session, principal, assignment=assignment, action="campaign.assignment.created")
|
||||
session.refresh(assignment)
|
||||
return _assignment_response(assignment)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/assignments/{assignment_id}/reassign",
|
||||
response_model=CampaignWorkAssignmentResponse,
|
||||
)
|
||||
def reassign_campaign_work_assignment(
|
||||
campaign_id: str,
|
||||
assignment_id: str,
|
||||
payload: CampaignWorkAssignmentReassignRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
|
||||
) -> CampaignWorkAssignmentResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
|
||||
_require_revision(assignment, payload.expected_revision)
|
||||
if assignment.status in {"completed", "cancelled"}:
|
||||
raise HTTPException(status_code=409, detail="Closed assignments cannot be reassigned.")
|
||||
resolution = _resolve_assignee(session, campaign=campaign, assignee=payload.assignee)
|
||||
_require_resolved_assignee(resolution)
|
||||
if assignment.assignee_type == payload.assignee.type and assignment.assignee_id == payload.assignee.id:
|
||||
return _assignment_response(assignment)
|
||||
previous = {
|
||||
"assignee_type": assignment.assignee_type,
|
||||
"assignee_id": assignment.assignee_id,
|
||||
"assignee_label": assignment.assignee_label_snapshot,
|
||||
"reason": payload.reason,
|
||||
}
|
||||
assignment.assignee_type = payload.assignee.type
|
||||
assignment.assignee_id = payload.assignee.id
|
||||
assignment.assignee_label_snapshot = (resolution.label or payload.assignee.id)[:500]
|
||||
assignment.assignee_current_label = resolution.label
|
||||
assignment.assignee_resolution_state = resolution.state
|
||||
assignment.resolution_provenance = resolution.provenance
|
||||
assignment.resolution_checked_at = utc_now()
|
||||
assignment.resource_revision += 1
|
||||
assignment.task_mirror_id = None
|
||||
assignment.task_mirror_error = None
|
||||
assignment.task_mirrored_at = None
|
||||
assignment.task_mirror_status = "not_configured"
|
||||
_record_event(
|
||||
session,
|
||||
assignment=assignment,
|
||||
principal=principal,
|
||||
event_kind="reassigned",
|
||||
details=previous,
|
||||
)
|
||||
if payload.mirror_to_tasks:
|
||||
_mirror_assignment_to_tasks(session, campaign=campaign, assignment=assignment, principal=principal)
|
||||
else:
|
||||
assignment.task_mirror_status = "skipped"
|
||||
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind="reassigned")
|
||||
_audit_assignment(session, principal, assignment=assignment, action="campaign.assignment.reassigned")
|
||||
session.refresh(assignment)
|
||||
return _assignment_response(assignment)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/assignments/{assignment_id}/transition",
|
||||
response_model=CampaignWorkAssignmentResponse,
|
||||
)
|
||||
def transition_campaign_work_assignment(
|
||||
campaign_id: str,
|
||||
assignment_id: str,
|
||||
payload: CampaignWorkAssignmentTransitionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
|
||||
) -> CampaignWorkAssignmentResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
|
||||
if not has_scope(principal, "campaigns:assignment:manage"):
|
||||
_require_permission(principal, "campaigns:assignment:complete")
|
||||
if not _principal_matches_assignment(session, principal=principal, assignment=assignment):
|
||||
raise HTTPException(status_code=403, detail="Only the assignee or an assignment manager may update this work.")
|
||||
if payload.action == "cancel":
|
||||
raise HTTPException(status_code=403, detail="Only an assignment manager may cancel work.")
|
||||
_require_revision(assignment, payload.expected_revision)
|
||||
target_status = {"start": "in_progress", "complete": "completed", "cancel": "cancelled"}[payload.action]
|
||||
if assignment.status == target_status:
|
||||
return _assignment_response(assignment)
|
||||
allowed = {
|
||||
"start": {"open"},
|
||||
"complete": {"open", "in_progress"},
|
||||
"cancel": {"open", "in_progress"},
|
||||
}
|
||||
if assignment.status not in allowed[payload.action]:
|
||||
raise HTTPException(status_code=409, detail=f"Assignment status {assignment.status!r} cannot be changed using {payload.action!r}.")
|
||||
assignment.status = target_status
|
||||
assignment.completed_at = utc_now() if target_status == "completed" else None
|
||||
assignment.cancelled_at = utc_now() if target_status == "cancelled" else None
|
||||
assignment.resource_revision += 1
|
||||
_record_event(
|
||||
session,
|
||||
assignment=assignment,
|
||||
principal=principal,
|
||||
event_kind={"start": "started", "complete": "completed", "cancel": "cancelled"}[payload.action],
|
||||
details={"reason": payload.reason},
|
||||
)
|
||||
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind=payload.action)
|
||||
_audit_assignment(session, principal, assignment=assignment, action=f"campaign.assignment.{target_status}")
|
||||
session.refresh(assignment)
|
||||
return _assignment_response(assignment)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/assignments/{assignment_id}/history",
|
||||
response_model=CampaignWorkAssignmentHistoryResponse,
|
||||
)
|
||||
def list_campaign_work_assignment_history(
|
||||
campaign_id: str,
|
||||
assignment_id: str,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:read")),
|
||||
) -> CampaignWorkAssignmentHistoryResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
assignment = _assignment_for_campaign(session, campaign=campaign, assignment_id=assignment_id)
|
||||
query = session.query(CampaignWorkAssignmentEvent).filter(
|
||||
CampaignWorkAssignmentEvent.tenant_id == principal.tenant_id,
|
||||
CampaignWorkAssignmentEvent.assignment_id == assignment.id,
|
||||
)
|
||||
if cursor:
|
||||
created_at, event_id = _decode_cursor(cursor)
|
||||
query = query.filter(
|
||||
or_(
|
||||
CampaignWorkAssignmentEvent.created_at < created_at,
|
||||
and_(
|
||||
CampaignWorkAssignmentEvent.created_at == created_at,
|
||||
CampaignWorkAssignmentEvent.id < event_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
rows = query.order_by(CampaignWorkAssignmentEvent.created_at.desc(), CampaignWorkAssignmentEvent.id.desc()).limit(limit + 1).all()
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return CampaignWorkAssignmentHistoryResponse(
|
||||
items=[_event_response(item) for item in items],
|
||||
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/assignments/reconcile",
|
||||
response_model=CampaignWorkAssignmentReconcileResponse,
|
||||
)
|
||||
def reconcile_campaign_work_assignments(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:assignment:manage")),
|
||||
) -> CampaignWorkAssignmentReconcileResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
rows = (
|
||||
session.query(CampaignWorkAssignment)
|
||||
.filter(
|
||||
CampaignWorkAssignment.tenant_id == campaign.tenant_id,
|
||||
CampaignWorkAssignment.campaign_id == campaign.id,
|
||||
CampaignWorkAssignment.status.in_(("open", "in_progress")),
|
||||
)
|
||||
.order_by(CampaignWorkAssignment.updated_at, CampaignWorkAssignment.id)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
changed: list[CampaignWorkAssignment] = []
|
||||
for assignment in rows:
|
||||
resolution = _resolve_assignee(
|
||||
session,
|
||||
campaign=campaign,
|
||||
assignee=CampaignWorkAssigneeInput(type=assignment.assignee_type, id=assignment.assignee_id),
|
||||
)
|
||||
if (
|
||||
assignment.assignee_resolution_state == resolution.state
|
||||
and assignment.assignee_current_label == resolution.label
|
||||
):
|
||||
assignment.resolution_checked_at = utc_now()
|
||||
assignment.resolution_provenance = resolution.provenance
|
||||
continue
|
||||
previous_state = assignment.assignee_resolution_state
|
||||
assignment.assignee_resolution_state = resolution.state
|
||||
assignment.assignee_current_label = resolution.label
|
||||
assignment.resolution_provenance = resolution.provenance
|
||||
assignment.resolution_checked_at = utc_now()
|
||||
assignment.resource_revision += 1
|
||||
_record_event(
|
||||
session,
|
||||
assignment=assignment,
|
||||
principal=principal,
|
||||
event_kind="assignee_resolved" if resolution.state == "resolved" else "assignee_unavailable",
|
||||
details={"previous_resolution_state": previous_state},
|
||||
)
|
||||
changed.append(assignment)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.assignment.reconciled",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={"checked": len(rows), "changed": len(changed), "content_disclosed": False},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignWorkAssignmentReconcileResponse(
|
||||
checked=len(rows),
|
||||
changed=len(changed),
|
||||
assignments=[_assignment_response(item) for item in changed],
|
||||
)
|
||||
|
||||
|
||||
def _assignment_options(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignee_type: AssigneeType,
|
||||
query: str,
|
||||
selected_values: list[str],
|
||||
limit: int,
|
||||
) -> tuple[ReferenceOption, ...]:
|
||||
clean_query = query.strip().casefold()
|
||||
selected = set(item.strip() for item in selected_values if item.strip())
|
||||
candidates: list[ReferenceOption] = []
|
||||
directory = _access_directory()
|
||||
if assignee_type == "account":
|
||||
for user in directory.users_for_tenant(campaign.tenant_id):
|
||||
if user.status != "active" or not _user_has_campaign_access(session, campaign=campaign, user_id=user.id):
|
||||
continue
|
||||
label = user.display_name or user.email or user.account_id
|
||||
candidates.append(ReferenceOption(value=user.account_id, label=label, description=user.email, kind="account"))
|
||||
elif assignee_type == "group":
|
||||
for group in directory.groups_for_tenant(campaign.tenant_id):
|
||||
if group.status == "active" and _group_has_campaign_access(session, campaign=campaign, group_id=group.id):
|
||||
candidates.append(ReferenceOption(value=group.id, label=group.name, kind="group"))
|
||||
else:
|
||||
provider = organization_directory(get_registry())
|
||||
if provider is not None:
|
||||
seen: set[str] = set()
|
||||
for unit in provider.organization_units_for_tenant(campaign.tenant_id):
|
||||
for function in provider.functions_for_organization_unit(unit.id):
|
||||
if function.id in seen:
|
||||
continue
|
||||
seen.add(function.id)
|
||||
resolution = _resolve_assignee(
|
||||
session,
|
||||
campaign=campaign,
|
||||
assignee=CampaignWorkAssigneeInput(type="organization_function", id=function.id),
|
||||
)
|
||||
candidates.append(
|
||||
ReferenceOption(
|
||||
value=function.id,
|
||||
label=function.name,
|
||||
description=unit.name,
|
||||
kind="organization_function",
|
||||
availability="available" if resolution.state == "resolved" else "unavailable",
|
||||
disabled=resolution.state != "resolved",
|
||||
source_module="organizations",
|
||||
provenance=resolution.provenance,
|
||||
)
|
||||
)
|
||||
filtered = [
|
||||
item for item in candidates
|
||||
if item.value in selected or not clean_query or clean_query in f"{item.label} {item.description or ''} {item.value}".casefold()
|
||||
]
|
||||
filtered.sort(key=lambda item: (item.disabled, item.label.casefold(), item.value))
|
||||
return tuple(filtered[:limit])
|
||||
|
||||
|
||||
def _resolve_assignee(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignee: CampaignWorkAssigneeInput,
|
||||
) -> _AssigneeResolution:
|
||||
provenance: dict[str, object] = {
|
||||
"policy_code": "assignment_does_not_grant_access",
|
||||
"campaign_access_checked": True,
|
||||
"assignee_type": assignee.type,
|
||||
}
|
||||
directory = _access_directory()
|
||||
if assignee.type == "account":
|
||||
user = next(
|
||||
(item for item in directory.users_for_tenant(campaign.tenant_id) if item.account_id == assignee.id),
|
||||
None,
|
||||
)
|
||||
available = user is not None and user.status == "active" and _user_has_campaign_access(session, campaign=campaign, user_id=user.id)
|
||||
provenance.update({"resolved_members": 1 if available else 0, "provider": "access.directory"})
|
||||
return _AssigneeResolution(
|
||||
state="resolved" if available else "unavailable",
|
||||
label=(user.display_name or user.email or user.account_id) if user is not None else None,
|
||||
recipient_type="account",
|
||||
recipient_id=assignee.id,
|
||||
account_ids=(assignee.id,) if available else (),
|
||||
provenance=provenance,
|
||||
)
|
||||
if assignee.type == "group":
|
||||
group = next((item for item in directory.groups_for_tenant(campaign.tenant_id) if item.id == assignee.id), None)
|
||||
available = group is not None and group.status == "active" and _group_has_campaign_access(session, campaign=campaign, group_id=assignee.id)
|
||||
provenance.update({"resolved_members": 1 if available else 0, "provider": "access.directory"})
|
||||
return _AssigneeResolution(
|
||||
state="resolved" if available else "unavailable",
|
||||
label=group.name if group is not None else None,
|
||||
recipient_type="group",
|
||||
recipient_id=assignee.id,
|
||||
account_ids=(),
|
||||
provenance=provenance,
|
||||
)
|
||||
organizations = organization_directory(get_registry())
|
||||
idm = _idm_function_directory()
|
||||
if organizations is None or idm is None:
|
||||
provenance.update({"provider": "organizations.directory+idm.function_assignments", "provider_available": False})
|
||||
return _AssigneeResolution("provider_unavailable", None, "function", assignee.id, (), provenance)
|
||||
function = organizations.get_function(assignee.id)
|
||||
if function is None or function.tenant_id != campaign.tenant_id or function.status != "active":
|
||||
provenance.update({"provider": "organizations.directory", "resolved_members": 0})
|
||||
return _AssigneeResolution("unavailable", function.name if function else None, "function", assignee.id, (), provenance)
|
||||
now = utc_now()
|
||||
incumbencies = idm.organization_function_assignments_for_function(
|
||||
assignee.id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
effective_at=now,
|
||||
)
|
||||
account_ids = tuple(
|
||||
dict.fromkeys(
|
||||
item.account_id
|
||||
for item in incumbencies
|
||||
if item.account_id
|
||||
and _incumbency_is_effective(item, now=now)
|
||||
)
|
||||
)
|
||||
user_by_account = {
|
||||
user.account_id: user for user in directory.users_for_tenant(campaign.tenant_id)
|
||||
}
|
||||
authorized = bool(account_ids) and all(
|
||||
account_id in user_by_account
|
||||
and user_by_account[account_id].status == "active"
|
||||
and _user_has_campaign_access(session, campaign=campaign, user_id=user_by_account[account_id].id)
|
||||
for account_id in account_ids
|
||||
)
|
||||
provenance.update(
|
||||
{
|
||||
"provider": "organizations.directory+idm.function_assignments",
|
||||
"provider_available": True,
|
||||
"resolved_members": len(account_ids),
|
||||
"all_current_incumbents_authorized": authorized,
|
||||
}
|
||||
)
|
||||
return _AssigneeResolution(
|
||||
"resolved" if authorized else "unavailable",
|
||||
function.name,
|
||||
"function",
|
||||
assignee.id,
|
||||
account_ids if authorized else (),
|
||||
provenance,
|
||||
)
|
||||
|
||||
|
||||
def _idm_function_directory() -> IdmFunctionAssignmentDirectory | None:
|
||||
registry = get_registry()
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS)
|
||||
):
|
||||
return None
|
||||
capability = registry.capability(CAPABILITY_IDM_FUNCTION_ASSIGNMENTS)
|
||||
return capability if isinstance(capability, IdmFunctionAssignmentDirectory) else None
|
||||
|
||||
|
||||
def _require_resolved_assignee(resolution: _AssigneeResolution) -> None:
|
||||
if resolution.state == "resolved":
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail={
|
||||
"code": "campaign_assignment_assignee_inaccessible",
|
||||
"explanation": "The assignment target must already resolve to an active principal with access to this campaign. Assignment never grants access.",
|
||||
"resolution_state": resolution.state,
|
||||
"provenance": resolution.provenance,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _user_has_campaign_access(session: Session, *, campaign: Campaign, user_id: str) -> bool:
|
||||
if campaign.owner_user_id == user_id:
|
||||
return True
|
||||
directory = _access_directory()
|
||||
group_ids = {item.id for item in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)}
|
||||
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
||||
return True
|
||||
clauses = [and_(CampaignShare.target_type == "user", CampaignShare.target_id == user_id)]
|
||||
if group_ids:
|
||||
clauses.append(and_(CampaignShare.target_type == "group", CampaignShare.target_id.in_(sorted(group_ids))))
|
||||
return session.query(CampaignShare.id).filter(
|
||||
CampaignShare.tenant_id == campaign.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
or_(*clauses),
|
||||
).first() is not None
|
||||
|
||||
|
||||
def _group_has_campaign_access(session: Session, *, campaign: Campaign, group_id: str) -> bool:
|
||||
if campaign.owner_group_id == group_id:
|
||||
return True
|
||||
return session.query(CampaignShare.id).filter(
|
||||
CampaignShare.tenant_id == campaign.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.target_type == "group",
|
||||
CampaignShare.target_id == group_id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
).first() is not None
|
||||
|
||||
|
||||
def _principal_matches_assignment(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
assignment: CampaignWorkAssignment,
|
||||
) -> bool:
|
||||
if assignment.assignee_type == "account":
|
||||
return principal.account_id == assignment.assignee_id
|
||||
if assignment.assignee_type == "group":
|
||||
return assignment.assignee_id in {
|
||||
item.id for item in _access_directory().groups_for_user(principal.user.id, tenant_id=principal.tenant_id)
|
||||
}
|
||||
idm = _idm_function_directory()
|
||||
if idm is None:
|
||||
return False
|
||||
return any(
|
||||
item.account_id == principal.account_id and item.status == "active"
|
||||
for item in idm.organization_function_assignments_for_function(
|
||||
assignment.assignee_id,
|
||||
tenant_id=principal.tenant_id,
|
||||
effective_at=utc_now(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _assignment_for_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignment_id: str,
|
||||
) -> CampaignWorkAssignment:
|
||||
assignment = session.get(CampaignWorkAssignment, assignment_id)
|
||||
if assignment is None or assignment.tenant_id != campaign.tenant_id or assignment.campaign_id != campaign.id:
|
||||
raise HTTPException(status_code=404, detail="Campaign work assignment not found.")
|
||||
return assignment
|
||||
|
||||
|
||||
def _require_revision(assignment: CampaignWorkAssignment, expected: int) -> None:
|
||||
if assignment.resource_revision != expected:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "campaign_assignment_revision_conflict",
|
||||
"expected_revision": expected,
|
||||
"current_revision": assignment.resource_revision,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_event(
|
||||
session: Session,
|
||||
*,
|
||||
assignment: CampaignWorkAssignment,
|
||||
principal: ApiPrincipal,
|
||||
event_kind: str,
|
||||
details: dict[str, object] | None = None,
|
||||
) -> CampaignWorkAssignmentEvent:
|
||||
event = CampaignWorkAssignmentEvent(
|
||||
tenant_id=assignment.tenant_id,
|
||||
campaign_id=assignment.campaign_id,
|
||||
assignment_id=assignment.id,
|
||||
event_kind=event_kind,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_label_snapshot=_actor_label(principal),
|
||||
status_snapshot=assignment.status,
|
||||
assignee_type_snapshot=assignment.assignee_type,
|
||||
assignee_id_snapshot=assignment.assignee_id,
|
||||
assignee_label_snapshot=assignment.assignee_label_snapshot,
|
||||
resolution_state_snapshot=assignment.assignee_resolution_state,
|
||||
details={key: value for key, value in (details or {}).items() if value is not None},
|
||||
)
|
||||
session.add(event)
|
||||
session.flush()
|
||||
return event
|
||||
|
||||
|
||||
def _mirror_assignment_to_tasks(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignment: CampaignWorkAssignment,
|
||||
principal: ApiPrincipal,
|
||||
) -> None:
|
||||
provider = task_command_provider(get_registry())
|
||||
if provider is None:
|
||||
assignment.task_mirror_status = "not_configured"
|
||||
return
|
||||
task_kind = "function" if assignment.assignee_type == "organization_function" else assignment.assignee_type
|
||||
sources = [
|
||||
WorkSourceRef(
|
||||
module_id="campaigns",
|
||||
resource_type="campaign_work_assignment",
|
||||
resource_id=assignment.id,
|
||||
revision=str(assignment.resource_revision),
|
||||
url=f"/campaigns/{campaign.id}/work",
|
||||
label=campaign.name,
|
||||
)
|
||||
]
|
||||
if assignment.reference_kind and assignment.reference_id:
|
||||
sources.append(
|
||||
WorkSourceRef(
|
||||
module_id="campaigns",
|
||||
resource_type=assignment.reference_kind,
|
||||
resource_id=assignment.reference_id,
|
||||
label=assignment.reference_label,
|
||||
)
|
||||
)
|
||||
try:
|
||||
with session.begin_nested():
|
||||
task = provider.create_task(
|
||||
session,
|
||||
principal,
|
||||
command=TaskCreateCommand(
|
||||
tenant_id=campaign.tenant_id,
|
||||
title=assignment.purpose,
|
||||
idempotency_key=f"campaign-assignment:{assignment.id}:r{assignment.resource_revision}",
|
||||
summary=f"Accountable work assignment for campaign {campaign.name}.",
|
||||
due_at=assignment.due_at,
|
||||
required_action=assignment.purpose,
|
||||
action_url=f"/campaigns/{campaign.id}/work",
|
||||
assignments=(
|
||||
WorkAssignmentRef(
|
||||
kind=task_kind,
|
||||
id=assignment.assignee_id,
|
||||
label=assignment.assignee_label_snapshot,
|
||||
),
|
||||
),
|
||||
sources=tuple(sources),
|
||||
provenance={
|
||||
"authorization_neutral": True,
|
||||
"campaign_access_checked": True,
|
||||
},
|
||||
metadata={"campaign_id": campaign.id, "assignment_id": assignment.id},
|
||||
),
|
||||
)
|
||||
assignment.task_mirror_id = task.id
|
||||
assignment.task_mirror_status = "mirrored"
|
||||
assignment.task_mirror_error = None
|
||||
assignment.task_mirrored_at = utc_now()
|
||||
except Exception as exc:
|
||||
assignment.task_mirror_status = "failed"
|
||||
assignment.task_mirror_error = f"{type(exc).__name__}: optional Tasks mirroring failed"[:500]
|
||||
|
||||
|
||||
def _notify_assignment(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignment: CampaignWorkAssignment,
|
||||
event_kind: str,
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None:
|
||||
return
|
||||
recipient_account_ids = _notification_account_ids(
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
)
|
||||
if not recipient_account_ids:
|
||||
return
|
||||
try:
|
||||
with session.begin_nested():
|
||||
for account_id in recipient_account_ids:
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_work_assignment",
|
||||
source_resource_id=assignment.id,
|
||||
event_kind=f"campaign.assignment.{event_kind}",
|
||||
channel="inbox",
|
||||
recipient_type="account",
|
||||
recipient_id=account_id,
|
||||
recipient_label=assignment.assignee_label_snapshot,
|
||||
subject=f"Campaign work {event_kind}: {campaign.name}",
|
||||
body_text="Open the campaign work area to review the assignment. Campaign access is managed separately.",
|
||||
action_url=f"/campaigns/{campaign.id}/work",
|
||||
payload={
|
||||
"campaign_id": campaign.id,
|
||||
"assignment_id": assignment.id,
|
||||
"assignment_status": assignment.status,
|
||||
"purpose_disclosed": False,
|
||||
},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _notification_account_ids(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignment: CampaignWorkAssignment,
|
||||
) -> tuple[str, ...]:
|
||||
if assignment.assignee_type == "account":
|
||||
return (assignment.assignee_id,)
|
||||
if assignment.assignee_type == "group":
|
||||
directory = _access_directory()
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
user.account_id
|
||||
for user in directory.users_for_tenant(campaign.tenant_id)
|
||||
if user.status == "active"
|
||||
and assignment.assignee_id
|
||||
in {
|
||||
group.id
|
||||
for group in directory.groups_for_user(
|
||||
user.id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
)
|
||||
}
|
||||
)
|
||||
)[:100]
|
||||
idm = _idm_function_directory()
|
||||
if idm is None:
|
||||
return ()
|
||||
return tuple(
|
||||
dict.fromkeys(
|
||||
item.account_id
|
||||
for item in idm.organization_function_assignments_for_function(
|
||||
assignment.assignee_id,
|
||||
tenant_id=campaign.tenant_id,
|
||||
effective_at=utc_now(),
|
||||
)
|
||||
if item.account_id and _incumbency_is_effective(item, now=utc_now())
|
||||
)
|
||||
)[:100]
|
||||
|
||||
|
||||
def _incumbency_is_effective(item: object, *, now: datetime) -> bool:
|
||||
if getattr(item, "status", None) != "active":
|
||||
return False
|
||||
valid_from = getattr(item, "valid_from", None)
|
||||
valid_until = getattr(item, "valid_until", None)
|
||||
comparison_now = now
|
||||
for boundary in (valid_from, valid_until):
|
||||
if isinstance(boundary, datetime) and boundary.tzinfo is None:
|
||||
comparison_now = now.replace(tzinfo=None)
|
||||
break
|
||||
return (
|
||||
(valid_from is None or valid_from <= comparison_now)
|
||||
and (valid_until is None or valid_until > comparison_now)
|
||||
)
|
||||
|
||||
|
||||
def _audit_assignment(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
assignment: CampaignWorkAssignment,
|
||||
action: str,
|
||||
) -> None:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=action,
|
||||
object_type="campaign_work_assignment",
|
||||
object_id=assignment.id,
|
||||
details={
|
||||
"campaign_id": assignment.campaign_id,
|
||||
"status": assignment.status,
|
||||
"assignee_type": assignment.assignee_type,
|
||||
"assignee_id": assignment.assignee_id,
|
||||
"assignee_resolution_state": assignment.assignee_resolution_state,
|
||||
"reference_kind": assignment.reference_kind,
|
||||
"reference_id": assignment.reference_id,
|
||||
"resource_revision": assignment.resource_revision,
|
||||
"task_mirror_status": assignment.task_mirror_status,
|
||||
"purpose_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
|
||||
|
||||
def _actor_label(principal: ApiPrincipal) -> str:
|
||||
return str(
|
||||
getattr(principal.user, "display_name", None)
|
||||
or getattr(principal.user, "email", None)
|
||||
or principal.user.id
|
||||
)[:255]
|
||||
|
||||
|
||||
def _assignment_response(assignment: CampaignWorkAssignment) -> CampaignWorkAssignmentResponse:
|
||||
reference = None
|
||||
if assignment.reference_kind and assignment.reference_id:
|
||||
reference = CampaignWorkAssignmentReferenceResponse(
|
||||
kind=assignment.reference_kind,
|
||||
id=assignment.reference_id,
|
||||
label=assignment.reference_label,
|
||||
)
|
||||
return CampaignWorkAssignmentResponse(
|
||||
id=assignment.id,
|
||||
campaign_id=assignment.campaign_id,
|
||||
purpose=assignment.purpose,
|
||||
status=assignment.status,
|
||||
due_at=assignment.due_at,
|
||||
assignee_type=assignment.assignee_type,
|
||||
assignee_id=assignment.assignee_id,
|
||||
assignee_label_snapshot=assignment.assignee_label_snapshot,
|
||||
assignee_current_label=assignment.assignee_current_label,
|
||||
assignee_resolution_state=assignment.assignee_resolution_state,
|
||||
resolution_provenance=dict(assignment.resolution_provenance or {}),
|
||||
resolution_checked_at=assignment.resolution_checked_at,
|
||||
assigned_by_user_id=assignment.assigned_by_user_id,
|
||||
assigned_by_label=assignment.assigned_by_label_snapshot,
|
||||
reference=reference,
|
||||
completed_at=assignment.completed_at,
|
||||
cancelled_at=assignment.cancelled_at,
|
||||
task_mirror_id=assignment.task_mirror_id,
|
||||
task_mirror_status=assignment.task_mirror_status,
|
||||
task_mirror_error=assignment.task_mirror_error,
|
||||
resource_revision=assignment.resource_revision,
|
||||
created_at=assignment.created_at,
|
||||
updated_at=assignment.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _event_response(event: CampaignWorkAssignmentEvent) -> CampaignWorkAssignmentEventResponse:
|
||||
return CampaignWorkAssignmentEventResponse(
|
||||
id=event.id,
|
||||
assignment_id=event.assignment_id,
|
||||
event_kind=event.event_kind,
|
||||
actor_user_id=event.actor_user_id,
|
||||
actor_label=event.actor_label_snapshot,
|
||||
status=event.status_snapshot,
|
||||
assignee_type=event.assignee_type_snapshot,
|
||||
assignee_id=event.assignee_id_snapshot,
|
||||
assignee_label=event.assignee_label_snapshot,
|
||||
resolution_state=event.resolution_state_snapshot,
|
||||
details=dict(event.details or {}),
|
||||
created_at=event.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _encode_cursor(item: CampaignWorkAssignment | CampaignWorkAssignmentEvent) -> str:
|
||||
created_at = item.created_at
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
payload = json.dumps(
|
||||
{"created_at": created_at.astimezone(UTC).isoformat(), "id": item.id},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(value: str) -> tuple[datetime, str]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
||||
created_at = datetime.fromisoformat(str(payload["created_at"]))
|
||||
item_id = str(payload["id"])
|
||||
if created_at.tzinfo is None or not item_id or len(item_id) > 36:
|
||||
raise ValueError
|
||||
return created_at, item_id
|
||||
except (KeyError, TypeError, ValueError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error) as exc:
|
||||
raise HTTPException(status_code=422, detail="Invalid assignment cursor.") from exc
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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,234 @@ 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
|
||||
|
||||
|
||||
CampaignWorkAssigneeType = Literal["account", "group", "organization_function"]
|
||||
CampaignWorkAssignmentStatus = Literal["open", "in_progress", "completed", "cancelled"]
|
||||
CampaignWorkAssigneeResolutionState = Literal[
|
||||
"resolved",
|
||||
"unavailable",
|
||||
"provider_unavailable",
|
||||
]
|
||||
|
||||
|
||||
class CampaignWorkAssigneeInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
type: CampaignWorkAssigneeType
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def strip_assignee_id(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
|
||||
class CampaignWorkAssignmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
purpose: str = Field(min_length=1, max_length=500)
|
||||
assignee: CampaignWorkAssigneeInput
|
||||
due_at: datetime | None = None
|
||||
reference: CampaignCollaborationReferenceInput | None = None
|
||||
mirror_to_tasks: bool = True
|
||||
|
||||
@field_validator("purpose")
|
||||
@classmethod
|
||||
def strip_assignment_purpose(cls, value: str) -> str:
|
||||
return value.strip()
|
||||
|
||||
@field_validator("due_at")
|
||||
@classmethod
|
||||
def require_due_timezone(cls, value: datetime | None) -> datetime | None:
|
||||
if value is not None and value.tzinfo is None:
|
||||
raise ValueError("Assignment due dates must include a timezone.")
|
||||
return value
|
||||
|
||||
|
||||
class CampaignWorkAssignmentReassignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
assignee: CampaignWorkAssigneeInput
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
mirror_to_tasks: bool = True
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_reassignment_reason(cls, value: str | None) -> str | None:
|
||||
clean = value.strip() if value is not None else None
|
||||
return clean or None
|
||||
|
||||
|
||||
class CampaignWorkAssignmentTransitionRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_revision: int = Field(ge=1)
|
||||
action: Literal["start", "complete", "cancel"]
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@field_validator("reason")
|
||||
@classmethod
|
||||
def strip_transition_reason(cls, value: str | None) -> str | None:
|
||||
clean = value.strip() if value is not None else None
|
||||
return clean or None
|
||||
|
||||
|
||||
class CampaignWorkAssignmentReferenceResponse(BaseModel):
|
||||
kind: CampaignCollaborationReferenceKind
|
||||
id: str
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class CampaignWorkAssignmentResponse(BaseModel):
|
||||
id: str
|
||||
campaign_id: str
|
||||
purpose: str
|
||||
status: CampaignWorkAssignmentStatus
|
||||
due_at: datetime | None = None
|
||||
assignee_type: CampaignWorkAssigneeType
|
||||
assignee_id: str
|
||||
assignee_label_snapshot: str
|
||||
assignee_current_label: str | None = None
|
||||
assignee_resolution_state: CampaignWorkAssigneeResolutionState
|
||||
resolution_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
resolution_checked_at: datetime
|
||||
assigned_by_user_id: str | None = None
|
||||
assigned_by_label: str
|
||||
reference: CampaignWorkAssignmentReferenceResponse | None = None
|
||||
completed_at: datetime | None = None
|
||||
cancelled_at: datetime | None = None
|
||||
task_mirror_id: str | None = None
|
||||
task_mirror_status: Literal["not_configured", "mirrored", "failed", "skipped"]
|
||||
task_mirror_error: str | None = None
|
||||
resource_revision: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CampaignWorkAssignmentListResponse(BaseModel):
|
||||
items: list[CampaignWorkAssignmentResponse]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class CampaignWorkAssignmentEventResponse(BaseModel):
|
||||
id: str
|
||||
assignment_id: str
|
||||
event_kind: str
|
||||
actor_user_id: str | None = None
|
||||
actor_label: str
|
||||
status: CampaignWorkAssignmentStatus
|
||||
assignee_type: CampaignWorkAssigneeType
|
||||
assignee_id: str
|
||||
assignee_label: str
|
||||
resolution_state: CampaignWorkAssigneeResolutionState
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CampaignWorkAssignmentHistoryResponse(BaseModel):
|
||||
items: list[CampaignWorkAssignmentEventResponse]
|
||||
next_cursor: str | None = None
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
class CampaignWorkAssignmentReconcileResponse(BaseModel):
|
||||
checked: int
|
||||
changed: int
|
||||
assignments: list[CampaignWorkAssignmentResponse]
|
||||
|
||||
|
||||
class CampaignLifecycleMutationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@ from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
@@ -46,6 +49,136 @@ GROUP_ID = "group-1"
|
||||
|
||||
|
||||
class CampaignAccessProviderTests(unittest.TestCase):
|
||||
def test_work_assignment_provenance_keeps_accountability_separate_from_access(self) -> None:
|
||||
session = _session()
|
||||
self.addCleanup(_close_session, session)
|
||||
_seed_access_subjects(session)
|
||||
campaign = Campaign(
|
||||
id="campaign-work",
|
||||
tenant_id=TENANT_ID,
|
||||
owner_user_id=OTHER_USER_ID,
|
||||
external_id="work",
|
||||
name="Work campaign",
|
||||
)
|
||||
checked_at = datetime.now(UTC)
|
||||
assignment = CampaignWorkAssignment(
|
||||
id="assignment-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
purpose="Not disclosed by provenance",
|
||||
status="open",
|
||||
assignee_type="account",
|
||||
assignee_id="account-1",
|
||||
assignee_label_snapshot="Subject",
|
||||
assignee_resolution_state="resolved",
|
||||
resolution_provenance={"policy_code": "assignment_does_not_grant_access"},
|
||||
resolution_checked_at=checked_at,
|
||||
assigned_by_user_id=OTHER_USER_ID,
|
||||
assigned_by_label_snapshot="Other user",
|
||||
)
|
||||
event = CampaignWorkAssignmentEvent(
|
||||
id="assignment-event-1",
|
||||
tenant_id=TENANT_ID,
|
||||
campaign_id=campaign.id,
|
||||
assignment_id=assignment.id,
|
||||
event_kind="assigned",
|
||||
actor_user_id=OTHER_USER_ID,
|
||||
actor_label_snapshot="Other user",
|
||||
status_snapshot="open",
|
||||
assignee_type_snapshot="account",
|
||||
assignee_id_snapshot="account-1",
|
||||
assignee_label_snapshot="Subject",
|
||||
resolution_state_snapshot="resolved",
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
campaign,
|
||||
assignment,
|
||||
event,
|
||||
CampaignShare(
|
||||
id="share-work",
|
||||
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:assignment:read"},
|
||||
group_ids={GROUP_ID},
|
||||
),
|
||||
resource_type="campaign_work_assignment",
|
||||
resource_id=assignment.id,
|
||||
action="campaigns:assignment:read",
|
||||
)
|
||||
work = next(item for item in items if item.source == "campaigns.work_assignment")
|
||||
self.assertEqual("accountability_does_not_grant_access", work.details["authorization_mode"])
|
||||
self.assertEqual(["campaigns:assignment:read"], work.details["permission_actions"])
|
||||
self.assertFalse(work.details["purpose_disclosed"])
|
||||
self.assertNotIn("Not disclosed", repr(items))
|
||||
self.assertTrue(any(item.id == "share-work" for item in items))
|
||||
|
||||
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 +1051,9 @@ def _session():
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
CampaignWorkAssignment.__table__,
|
||||
CampaignWorkAssignmentEvent.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -476,6 +476,18 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
||||
"campaign.report",
|
||||
"campaign.audit",
|
||||
"campaign.json",
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
"campaign.work",
|
||||
"campaign.work.create",
|
||||
"campaign.work.action.start",
|
||||
"campaign.work.action.complete",
|
||||
"campaign.work.action.reassign",
|
||||
"campaign.work.action.cancel",
|
||||
"campaign.work.history",
|
||||
}
|
||||
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
@@ -11,6 +11,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
AttachmentBlob,
|
||||
AttachmentInstance,
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
@@ -18,6 +19,8 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
CampaignWorkAssignmentEvent,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
@@ -100,6 +103,9 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
DataSubjectRequest.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignCollaborationEntry.__table__,
|
||||
CampaignWorkAssignment.__table__,
|
||||
CampaignWorkAssignmentEvent.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignIssue.__table__,
|
||||
@@ -337,6 +343,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 +431,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
postbox_attempt,
|
||||
print_attempt,
|
||||
self.share,
|
||||
collaboration,
|
||||
self.profile,
|
||||
blob,
|
||||
attachment,
|
||||
@@ -419,6 +441,7 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
self.session.commit()
|
||||
self.provider = CampaignDsarProvider()
|
||||
self.subject = DsarSubjectRef(
|
||||
account_id=self.account.id,
|
||||
membership_id=self.user.id,
|
||||
email="subject@example.test",
|
||||
)
|
||||
@@ -437,6 +460,54 @@ class CampaignDsarProviderTests(unittest.TestCase):
|
||||
{topic.id for topic in manifest.documentation},
|
||||
)
|
||||
|
||||
def test_search_includes_account_assignment_and_immutable_lifecycle_evidence(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
assignment = CampaignWorkAssignment(
|
||||
id="work-assignment-subject",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
purpose="Review the individualized notice",
|
||||
status="open",
|
||||
assignee_type="account",
|
||||
assignee_id=self.account.id,
|
||||
assignee_label_snapshot="Subject",
|
||||
assignee_resolution_state="resolved",
|
||||
resolution_provenance={"policy_code": "assignment_does_not_grant_access"},
|
||||
resolution_checked_at=now,
|
||||
assigned_by_user_id=self.other_user.id,
|
||||
assigned_by_label_snapshot="Other",
|
||||
)
|
||||
event = CampaignWorkAssignmentEvent(
|
||||
id="work-assignment-event-subject",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=self.campaign.id,
|
||||
assignment_id=assignment.id,
|
||||
event_kind="assigned",
|
||||
actor_user_id=self.other_user.id,
|
||||
actor_label_snapshot="Other",
|
||||
status_snapshot="open",
|
||||
assignee_type_snapshot="account",
|
||||
assignee_id_snapshot=self.account.id,
|
||||
assignee_label_snapshot="Subject",
|
||||
resolution_state_snapshot="resolved",
|
||||
)
|
||||
self.session.add_all((assignment, event))
|
||||
self.session.commit()
|
||||
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=self.subject,
|
||||
)
|
||||
work = next(item for item in records if item.resource_type == "campaign_work_assignment")
|
||||
history = next(item for item in records if item.resource_type == "campaign_work_assignment_event")
|
||||
|
||||
self.assertIn("assignee_id", work.data["match_fields"])
|
||||
self.assertEqual("Review the individualized notice", work.data["purpose"])
|
||||
self.assertFalse(work.immutable_evidence)
|
||||
self.assertTrue(history.immutable_evidence)
|
||||
self.assertIn("accountable institutional work history", history.retention_reason)
|
||||
|
||||
def test_search_is_tenant_scoped_minimized_and_recipient_specific(self) -> None:
|
||||
records = self.provider.search_subject(
|
||||
self.session,
|
||||
@@ -456,11 +527,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,7 +4,9 @@ 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.assignments import router as assignments_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 +28,8 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
workflow_routers = (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
assignments_router,
|
||||
collaboration_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
@@ -42,7 +46,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) == 93
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
@@ -53,6 +57,8 @@ 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")),
|
||||
(assignments_router, ("POST", "/campaigns/{campaign_id}/assignments")),
|
||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.19",
|
||||
"version": "0.1.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -35,7 +35,9 @@
|
||||
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
|
||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
|
||||
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs"
|
||||
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs",
|
||||
"test:campaign-collaboration": "node tests/campaign-collaboration-ui-structure.test.mjs",
|
||||
"test:campaign-work": "node tests/campaign-work-ui-structure.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -38,6 +38,106 @@ export type CampaignShare = {
|
||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
||||
|
||||
export type CampaignCollaborationReferenceKind =
|
||||
| "campaign_version"
|
||||
| "recipient_import_batch"
|
||||
| "attachment_rule"
|
||||
| "delivery_job"
|
||||
| "report";
|
||||
|
||||
export type CampaignCollaborationReference = {
|
||||
kind: CampaignCollaborationReferenceKind;
|
||||
id: string;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignCollaborationEntry = {
|
||||
id: string;
|
||||
campaign_id: string;
|
||||
actor_user_id?: string | null;
|
||||
actor_label: string;
|
||||
visibility: "collaborators" | "moderators";
|
||||
content?: string | null;
|
||||
content_sha256: string;
|
||||
mention_user_ids: string[];
|
||||
reference?: CampaignCollaborationReference | null;
|
||||
tombstone?: "withdrawn" | "redacted" | null;
|
||||
tombstone_reason?: string | null;
|
||||
withdrawn_at?: string | null;
|
||||
redacted_at?: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CampaignCollaborationListResponse = {
|
||||
items: CampaignCollaborationEntry[];
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type CampaignCollaborationCreate = {
|
||||
content: string;
|
||||
visibility?: CampaignCollaborationEntry["visibility"];
|
||||
reference?: Pick<CampaignCollaborationReference, "kind" | "id"> | null;
|
||||
mention_user_ids?: string[];
|
||||
};
|
||||
|
||||
export type CampaignWorkAssigneeType = "account" | "group" | "organization_function";
|
||||
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "cancelled";
|
||||
export type CampaignWorkAssignmentResolutionState = "resolved" | "unavailable" | "provider_unavailable";
|
||||
|
||||
export type CampaignWorkAssignment = {
|
||||
id: string;
|
||||
campaign_id: string;
|
||||
purpose: string;
|
||||
status: CampaignWorkAssignmentStatus;
|
||||
due_at?: string | null;
|
||||
assignee_type: CampaignWorkAssigneeType;
|
||||
assignee_id: string;
|
||||
assignee_label_snapshot: string;
|
||||
assignee_current_label?: string | null;
|
||||
assignee_resolution_state: CampaignWorkAssignmentResolutionState;
|
||||
resolution_provenance: Record<string, unknown>;
|
||||
resolution_checked_at: string;
|
||||
assigned_by_user_id?: string | null;
|
||||
assigned_by_label: string;
|
||||
reference?: CampaignCollaborationReference | null;
|
||||
completed_at?: string | null;
|
||||
cancelled_at?: string | null;
|
||||
task_mirror_id?: string | null;
|
||||
task_mirror_status: "not_configured" | "mirrored" | "failed" | "skipped";
|
||||
task_mirror_error?: string | null;
|
||||
resource_revision: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CampaignWorkAssignmentListResponse = {
|
||||
items: CampaignWorkAssignment[];
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type CampaignWorkAssignmentEvent = {
|
||||
id: string;
|
||||
assignment_id: string;
|
||||
event_kind: string;
|
||||
actor_user_id?: string | null;
|
||||
actor_label: string;
|
||||
status: CampaignWorkAssignmentStatus;
|
||||
assignee_type: CampaignWorkAssigneeType;
|
||||
assignee_id: string;
|
||||
assignee_label: string;
|
||||
resolution_state: CampaignWorkAssignmentResolutionState;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CampaignWorkAssignmentHistoryResponse = {
|
||||
items: CampaignWorkAssignmentEvent[];
|
||||
next_cursor?: string | null;
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type CampaignArchiveEncryptionPolicy = {
|
||||
available: boolean;
|
||||
allowed_password_encryption_methods: Array<"aes" | "zip_standard">;
|
||||
@@ -1763,6 +1863,165 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
||||
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
||||
}
|
||||
|
||||
export async function listCampaignCollaboration(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: {cursor?: string | null;limit?: number;} = {}
|
||||
): Promise<CampaignCollaborationListResponse> {
|
||||
const params = new URLSearchParams({ limit: String(options.limit ?? 25) });
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
return apiFetch<CampaignCollaborationListResponse>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCampaignCollaborationEntry(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: CampaignCollaborationCreate
|
||||
): Promise<CampaignCollaborationEntry> {
|
||||
return apiFetch<CampaignCollaborationEntry>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function withdrawCampaignCollaborationEntry(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
entryId: string
|
||||
): Promise<CampaignCollaborationEntry> {
|
||||
return apiFetch<CampaignCollaborationEntry>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration/${encodeURIComponent(entryId)}/withdraw`,
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function redactCampaignCollaborationEntry(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
entryId: string
|
||||
): Promise<CampaignCollaborationEntry> {
|
||||
return apiFetch<CampaignCollaborationEntry>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration/${encodeURIComponent(entryId)}/redact`,
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
);
|
||||
}
|
||||
|
||||
export function campaignCollaborationMentionProvider(
|
||||
settings: ApiSettings,
|
||||
campaignId: string
|
||||
): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/collaboration/mention-options`
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCampaignWorkAssignments(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: { cursor?: string | null; limit?: number; statuses?: CampaignWorkAssignmentStatus[] } = {}
|
||||
): Promise<CampaignWorkAssignmentListResponse> {
|
||||
const params = new URLSearchParams({ limit: String(options.limit ?? 50) });
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
for (const status of options.statuses ?? []) params.append("assignment_status", status);
|
||||
return apiFetch<CampaignWorkAssignmentListResponse>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCampaignWorkAssignment(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
payload: {
|
||||
purpose: string;
|
||||
assignee: { type: CampaignWorkAssigneeType; id: string };
|
||||
due_at?: string | null;
|
||||
reference?: Pick<CampaignCollaborationReference, "kind" | "id"> | null;
|
||||
mirror_to_tasks?: boolean;
|
||||
}
|
||||
): Promise<CampaignWorkAssignment> {
|
||||
return apiFetch<CampaignWorkAssignment>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function reassignCampaignWorkAssignment(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
assignmentId: string,
|
||||
payload: {
|
||||
expected_revision: number;
|
||||
assignee: { type: CampaignWorkAssigneeType; id: string };
|
||||
reason?: string | null;
|
||||
mirror_to_tasks?: boolean;
|
||||
}
|
||||
): Promise<CampaignWorkAssignment> {
|
||||
return apiFetch<CampaignWorkAssignment>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignmentId)}/reassign`,
|
||||
{ method: "POST", body: JSON.stringify(payload) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function transitionCampaignWorkAssignment(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
assignment: Pick<CampaignWorkAssignment, "id" | "resource_revision">,
|
||||
action: "start" | "complete" | "cancel"
|
||||
): Promise<CampaignWorkAssignment> {
|
||||
return apiFetch<CampaignWorkAssignment>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignment.id)}/transition`,
|
||||
{ method: "POST", body: JSON.stringify({ expected_revision: assignment.resource_revision, action }) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function listCampaignWorkAssignmentHistory(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
assignmentId: string,
|
||||
cursor?: string | null
|
||||
): Promise<CampaignWorkAssignmentHistoryResponse> {
|
||||
const params = new URLSearchParams({ limit: "50" });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
return apiFetch<CampaignWorkAssignmentHistoryResponse>(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/${encodeURIComponent(assignmentId)}/history?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function reconcileCampaignWorkAssignments(
|
||||
settings: ApiSettings,
|
||||
campaignId: string
|
||||
): Promise<{ checked: number; changed: number; assignments: CampaignWorkAssignment[] }> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/reconcile`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
export function campaignWorkAssignmentTargetProvider(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
assigneeType: CampaignWorkAssigneeType
|
||||
): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(
|
||||
settings,
|
||||
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/assignments/options`,
|
||||
{ assignee_type: assigneeType }
|
||||
);
|
||||
}
|
||||
|
||||
export function campaignShareTargetProvider(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { MessageSquare, ShieldCheck, UserRound } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
ReferenceMultiSelect,
|
||||
StatusBadge,
|
||||
hasScope
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
campaignCollaborationMentionProvider,
|
||||
createCampaignCollaborationEntry,
|
||||
listCampaignCollaboration,
|
||||
redactCampaignCollaborationEntry,
|
||||
withdrawCampaignCollaborationEntry,
|
||||
type CampaignCollaborationCreate,
|
||||
type CampaignCollaborationEntry,
|
||||
type CampaignCollaborationReferenceKind
|
||||
} from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
|
||||
type PendingTombstone = {
|
||||
action: "withdraw" | "redact";
|
||||
entry: CampaignCollaborationEntry;
|
||||
} | null;
|
||||
|
||||
export default function CampaignCollaborationPage({
|
||||
settings,
|
||||
auth,
|
||||
campaignId
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
campaignId: string;
|
||||
}) {
|
||||
const workspace = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [entries, setEntries] = useState<CampaignCollaborationEntry[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingThread, setLoadingThread] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [visibility, setVisibility] = useState<CampaignCollaborationEntry["visibility"]>("collaborators");
|
||||
const [mentionUserIds, setMentionUserIds] = useState<string[]>([]);
|
||||
const [referenceKind, setReferenceKind] = useState<CampaignCollaborationReferenceKind | "">("");
|
||||
const [referenceId, setReferenceId] = useState("");
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [pendingTombstone, setPendingTombstone] = useState<PendingTombstone>(null);
|
||||
const [tombstoneBusy, setTombstoneBusy] = useState(false);
|
||||
const canPost = hasScope(auth, "campaigns:discussion:post");
|
||||
const canModerate = hasScope(auth, "campaigns:discussion:moderate");
|
||||
const mentionProvider = useMemo(
|
||||
() => campaignCollaborationMentionProvider(settings, campaignId),
|
||||
[campaignId, settings]
|
||||
);
|
||||
|
||||
const loadThread = useCallback(async () => {
|
||||
setLoadingThread(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignCollaboration(settings, campaignId);
|
||||
setEntries(response.items);
|
||||
setNextCursor(response.next_cursor ?? null);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingThread(false);
|
||||
}
|
||||
}, [campaignId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadThread();
|
||||
}, [loadThread]);
|
||||
|
||||
async function reload() {
|
||||
setMessage("");
|
||||
await Promise.all([loadThread(), workspace.reload({ force: true })]);
|
||||
}
|
||||
|
||||
async function loadOlder() {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignCollaboration(settings, campaignId, {
|
||||
cursor: nextCursor
|
||||
});
|
||||
setEntries((current) => [
|
||||
...current,
|
||||
...response.items.filter((entry) => !current.some((item) => item.id === entry.id))
|
||||
]);
|
||||
setNextCursor(response.next_cursor ?? null);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function postEntry() {
|
||||
const cleanContent = content.trim();
|
||||
if (!cleanContent || posting || (referenceKind && !referenceId.trim())) return;
|
||||
setPosting(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const payload: CampaignCollaborationCreate = {
|
||||
content: cleanContent,
|
||||
visibility,
|
||||
mention_user_ids: mentionUserIds
|
||||
};
|
||||
if (referenceKind) {
|
||||
payload.reference = {
|
||||
kind: referenceKind,
|
||||
id: referenceId.trim()
|
||||
};
|
||||
}
|
||||
const created = await createCampaignCollaborationEntry(settings, campaignId, payload);
|
||||
setEntries((current) => [created, ...current.filter((entry) => entry.id !== created.id)]);
|
||||
setContent("");
|
||||
setVisibility("collaborators");
|
||||
setMentionUserIds([]);
|
||||
setReferenceKind("");
|
||||
setReferenceId("");
|
||||
setMessage("i18n:govoplan-campaign.collaboration_posted");
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTombstone() {
|
||||
if (!pendingTombstone || tombstoneBusy) return;
|
||||
setTombstoneBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = pendingTombstone.action === "withdraw"
|
||||
? await withdrawCampaignCollaborationEntry(settings, campaignId, pendingTombstone.entry.id)
|
||||
: await redactCampaignCollaborationEntry(settings, campaignId, pendingTombstone.entry.id);
|
||||
setEntries((current) => current.map((entry) => entry.id === updated.id ? updated : entry));
|
||||
setMessage(pendingTombstone.action === "withdraw"
|
||||
? "i18n:govoplan-campaign.collaboration_withdrawn"
|
||||
: "i18n:govoplan-campaign.collaboration_redacted");
|
||||
setPendingTombstone(null);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setTombstoneBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const referenceNeedsTypedId = Boolean(referenceKind && referenceKind !== "campaign_version");
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
mode="workspace"
|
||||
interfaceId="campaigns.page.activity"
|
||||
helpContextId="campaign.activity"
|
||||
helpModuleId="campaign"
|
||||
title="i18n:govoplan-campaign.collaboration"
|
||||
description="i18n:govoplan-campaign.collaboration_description"
|
||||
loading={loadingThread || workspace.loading}
|
||||
loadingLabel="i18n:govoplan-campaign.loading_collaboration"
|
||||
error={error || workspace.error}
|
||||
success={message}
|
||||
actions={
|
||||
<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void reload(),
|
||||
loading: loadingThread || workspace.loading
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DismissibleAlert tone="info" resetKey={campaignId}>
|
||||
i18n:govoplan-campaign.collaboration_audit_boundary
|
||||
</DismissibleAlert>
|
||||
|
||||
{canPost ? (
|
||||
<Card
|
||||
title="i18n:govoplan-campaign.new_collaboration_entry"
|
||||
interfaceId="campaigns.activity.composer"
|
||||
helpContextId="campaign.activity.composer"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
<div className="campaign-collaboration-composer">
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.comment"
|
||||
help="i18n:govoplan-campaign.comment_help"
|
||||
>
|
||||
<textarea
|
||||
rows={5}
|
||||
maxLength={8000}
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
disabled={posting}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="campaign-collaboration-counter" aria-live="polite">
|
||||
{content.length} / 8000
|
||||
</div>
|
||||
<div className="campaign-collaboration-options">
|
||||
{canModerate ? (
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.visibility"
|
||||
help="i18n:govoplan-campaign.visibility_help"
|
||||
>
|
||||
<select
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.target.value as CampaignCollaborationEntry["visibility"])}
|
||||
disabled={posting}
|
||||
>
|
||||
<option value="collaborators">i18n:govoplan-campaign.visibility_collaborators</option>
|
||||
<option value="moderators">i18n:govoplan-campaign.visibility_moderators</option>
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.reference_context"
|
||||
help="i18n:govoplan-campaign.reference_context_help"
|
||||
>
|
||||
<select
|
||||
value={referenceKind}
|
||||
onChange={(event) => {
|
||||
setReferenceKind(event.target.value as CampaignCollaborationReferenceKind | "");
|
||||
setReferenceId("");
|
||||
}}
|
||||
disabled={posting}
|
||||
>
|
||||
<option value="">i18n:govoplan-campaign.no_reference</option>
|
||||
<option value="campaign_version">i18n:govoplan-campaign.reference_campaign_version</option>
|
||||
<option value="recipient_import_batch">i18n:govoplan-campaign.reference_recipient_import</option>
|
||||
<option value="attachment_rule">i18n:govoplan-campaign.reference_attachment_rule</option>
|
||||
<option value="delivery_job">i18n:govoplan-campaign.reference_delivery_job</option>
|
||||
<option value="report">i18n:govoplan-campaign.reference_report</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{referenceKind === "campaign_version" ? (
|
||||
<FormField label="i18n:govoplan-campaign.campaign_version">
|
||||
<select
|
||||
value={referenceId}
|
||||
onChange={(event) => setReferenceId(event.target.value)}
|
||||
disabled={posting}
|
||||
>
|
||||
<option value="">i18n:govoplan-campaign.select_version</option>
|
||||
{workspace.data.versions.map((version) => (
|
||||
<option key={version.id} value={version.id}>
|
||||
Version {version.version_number}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
{referenceNeedsTypedId ? (
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.stable_reference_id"
|
||||
help="i18n:govoplan-campaign.stable_reference_id_help"
|
||||
>
|
||||
<input
|
||||
value={referenceId}
|
||||
maxLength={500}
|
||||
onChange={(event) => setReferenceId(event.target.value)}
|
||||
disabled={posting}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField
|
||||
label="i18n:govoplan-campaign.mentions"
|
||||
help="i18n:govoplan-campaign.mentions_help"
|
||||
>
|
||||
<ReferenceMultiSelect
|
||||
values={mentionUserIds}
|
||||
onChange={setMentionUserIds}
|
||||
provider={mentionProvider}
|
||||
disabled={posting}
|
||||
aria-label="Mention campaign collaborators"
|
||||
placeholder="i18n:govoplan-campaign.add_mention"
|
||||
searchLimit={20}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="button-row compact-actions campaign-collaboration-submit">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void postEntry()}
|
||||
disabled={posting || !content.trim() || Boolean(referenceKind && !referenceId.trim())}
|
||||
helpContextId="campaign.activity.action.post"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
<MessageSquare size={16} aria-hidden="true" />
|
||||
{posting ? "i18n:govoplan-campaign.posting" : "i18n:govoplan-campaign.post_comment"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<DismissibleAlert tone="info" resetKey={`${campaignId}:read-only`}>
|
||||
i18n:govoplan-campaign.collaboration_read_only
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<Card title="i18n:govoplan-campaign.discussion" interfaceId="campaigns.activity.thread">
|
||||
{entries.length === 0 ? (
|
||||
<p className="muted">i18n:govoplan-campaign.no_collaboration_entries</p>
|
||||
) : (
|
||||
<ol className="campaign-collaboration-thread" aria-label="Campaign collaboration thread">
|
||||
{entries.map((entry) => {
|
||||
const ownEntry = entry.actor_user_id === auth.user.id;
|
||||
return (
|
||||
<li key={entry.id} className="campaign-collaboration-entry">
|
||||
<article aria-labelledby={`campaign-collaboration-${entry.id}-actor`}>
|
||||
<header className="campaign-collaboration-entry-header">
|
||||
<span className="campaign-collaboration-actor" id={`campaign-collaboration-${entry.id}-actor`}>
|
||||
<UserRound size={16} aria-hidden="true" />
|
||||
{entry.actor_label}
|
||||
</span>
|
||||
<time dateTime={entry.created_at}>{formatDateTime(entry.created_at)}</time>
|
||||
{entry.visibility === "moderators" ? (
|
||||
<StatusBadge status="restricted" label="i18n:govoplan-campaign.moderators_only" />
|
||||
) : null}
|
||||
</header>
|
||||
{entry.tombstone ? (
|
||||
<div className="campaign-collaboration-tombstone" role="status">
|
||||
<ShieldCheck size={17} aria-hidden="true" />
|
||||
<span>
|
||||
{entry.tombstone === "redacted"
|
||||
? "i18n:govoplan-campaign.entry_redacted_tombstone"
|
||||
: "i18n:govoplan-campaign.entry_withdrawn_tombstone"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="campaign-collaboration-content">{entry.content}</p>
|
||||
)}
|
||||
{entry.reference ? (
|
||||
<p className="campaign-collaboration-reference">
|
||||
<strong>i18n:govoplan-campaign.reference_label</strong>{" "}
|
||||
{entry.reference.label || entry.reference.kind.replaceAll("_", " ")}
|
||||
<code>{entry.reference.id}</code>
|
||||
</p>
|
||||
) : null}
|
||||
{!entry.tombstone && (ownEntry || canModerate) ? (
|
||||
<div className="button-row compact-actions campaign-collaboration-entry-actions">
|
||||
{ownEntry && canPost ? (
|
||||
<Button
|
||||
onClick={() => setPendingTombstone({ action: "withdraw", entry })}
|
||||
helpContextId="campaign.activity.action.withdraw"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
i18n:govoplan-campaign.withdraw
|
||||
</Button>
|
||||
) : null}
|
||||
{canModerate ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => setPendingTombstone({ action: "redact", entry })}
|
||||
helpContextId="campaign.activity.action.redact"
|
||||
helpModuleId="campaign"
|
||||
>
|
||||
i18n:govoplan-campaign.redact
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{hasMore ? (
|
||||
<div className="button-row compact-actions campaign-collaboration-load-more">
|
||||
<Button onClick={() => void loadOlder()} disabled={loadingMore}>
|
||||
{loadingMore ? "i18n:govoplan-campaign.loading_older_entries" : "i18n:govoplan-campaign.load_older_entries"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingTombstone)}
|
||||
title={pendingTombstone?.action === "redact"
|
||||
? "i18n:govoplan-campaign.redact_collaboration_entry"
|
||||
: "i18n:govoplan-campaign.withdraw_collaboration_entry"}
|
||||
message={pendingTombstone?.action === "redact"
|
||||
? "i18n:govoplan-campaign.redact_collaboration_entry_confirmation"
|
||||
: "i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation"}
|
||||
confirmLabel={pendingTombstone?.action === "redact"
|
||||
? "i18n:govoplan-campaign.redact"
|
||||
: "i18n:govoplan-campaign.withdraw"}
|
||||
tone="danger"
|
||||
busy={tombstoneBusy}
|
||||
helpContextId={pendingTombstone?.action === "redact"
|
||||
? "campaign.activity.action.redact"
|
||||
: "campaign.activity.action.withdraw"}
|
||||
helpModuleId="campaign"
|
||||
onCancel={() => !tombstoneBusy && setPendingTombstone(null)}
|
||||
onConfirm={() => void applyTombstone()}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(value: string): string {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DateTimeField,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
PageActionBar,
|
||||
PageLayout,
|
||||
ReferenceSelect,
|
||||
StatusBadge,
|
||||
hasScope
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
campaignWorkAssignmentTargetProvider,
|
||||
createCampaignWorkAssignment,
|
||||
listCampaignWorkAssignmentHistory,
|
||||
listCampaignWorkAssignments,
|
||||
reassignCampaignWorkAssignment,
|
||||
reconcileCampaignWorkAssignments,
|
||||
transitionCampaignWorkAssignment,
|
||||
type CampaignWorkAssigneeType,
|
||||
type CampaignWorkAssignment,
|
||||
type CampaignWorkAssignmentEvent
|
||||
} from "../../api/campaigns";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
|
||||
type AssignmentDraft = {
|
||||
purpose: string;
|
||||
assigneeType: CampaignWorkAssigneeType;
|
||||
assigneeId: string;
|
||||
dueAt: string;
|
||||
versionId: string;
|
||||
mirrorToTasks: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: AssignmentDraft = {
|
||||
purpose: "",
|
||||
assigneeType: "account",
|
||||
assigneeId: "",
|
||||
dueAt: "",
|
||||
versionId: "",
|
||||
mirrorToTasks: true,
|
||||
reason: ""
|
||||
};
|
||||
|
||||
export default function CampaignWorkPage({
|
||||
settings,
|
||||
auth,
|
||||
campaignId
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
campaignId: string;
|
||||
}) {
|
||||
const workspace = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [assignments, setAssignments] = useState<CampaignWorkAssignment[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [busyId, setBusyId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [draft, setDraft] = useState<AssignmentDraft>(EMPTY_DRAFT);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [reassigning, setReassigning] = useState<CampaignWorkAssignment | null>(null);
|
||||
const [cancelling, setCancelling] = useState<CampaignWorkAssignment | null>(null);
|
||||
const [historyFor, setHistoryFor] = useState<CampaignWorkAssignment | null>(null);
|
||||
const [history, setHistory] = useState<CampaignWorkAssignmentEvent[]>([]);
|
||||
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
|
||||
const [historyHasMore, setHistoryHasMore] = useState(false);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const canManage = hasScope(auth, "campaigns:assignment:manage");
|
||||
const canComplete = hasScope(auth, "campaigns:assignment:complete");
|
||||
const targetProvider = useMemo(
|
||||
() => campaignWorkAssignmentTargetProvider(settings, campaignId, draft.assigneeType),
|
||||
[campaignId, draft.assigneeType, settings]
|
||||
);
|
||||
|
||||
const loadAssignments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignWorkAssignments(settings, campaignId);
|
||||
setAssignments(response.items);
|
||||
setNextCursor(response.next_cursor ?? null);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [campaignId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAssignments();
|
||||
}, [loadAssignments]);
|
||||
|
||||
function replaceAssignment(updated: CampaignWorkAssignment) {
|
||||
setAssignments((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
setMessage("");
|
||||
await Promise.all([loadAssignments(), workspace.reload({ force: true })]);
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignWorkAssignments(settings, campaignId, { cursor: nextCursor });
|
||||
setAssignments((current) => [
|
||||
...current,
|
||||
...response.items.filter((item) => !current.some((existing) => existing.id === item.id))
|
||||
]);
|
||||
setNextCursor(response.next_cursor ?? null);
|
||||
setHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAssignment() {
|
||||
if (!draft.purpose.trim() || !draft.assigneeId || busyId) return;
|
||||
setBusyId("create");
|
||||
setError("");
|
||||
try {
|
||||
const created = await createCampaignWorkAssignment(settings, campaignId, {
|
||||
purpose: draft.purpose.trim(),
|
||||
assignee: { type: draft.assigneeType, id: draft.assigneeId },
|
||||
due_at: draft.dueAt ? new Date(draft.dueAt).toISOString() : null,
|
||||
reference: draft.versionId ? { kind: "campaign_version", id: draft.versionId } : null,
|
||||
mirror_to_tasks: draft.mirrorToTasks
|
||||
});
|
||||
setAssignments((current) => [created, ...current]);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setCreateOpen(false);
|
||||
setMessage("Work assignment created. Access and ownership were not changed.");
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusyId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function reassign() {
|
||||
if (!reassigning || !draft.assigneeId || busyId) return;
|
||||
setBusyId(reassigning.id);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await reassignCampaignWorkAssignment(settings, campaignId, reassigning.id, {
|
||||
expected_revision: reassigning.resource_revision,
|
||||
assignee: { type: draft.assigneeType, id: draft.assigneeId },
|
||||
reason: draft.reason.trim() || null,
|
||||
mirror_to_tasks: draft.mirrorToTasks
|
||||
});
|
||||
replaceAssignment(updated);
|
||||
setReassigning(null);
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setMessage("Work reassigned; the previous target remains in history.");
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusyId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(assignment: CampaignWorkAssignment, action: "start" | "complete" | "cancel") {
|
||||
if (busyId) return;
|
||||
setBusyId(assignment.id);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
|
||||
replaceAssignment(updated);
|
||||
setCancelling(null);
|
||||
setMessage(`Work ${action === "start" ? "started" : action === "complete" ? "completed" : "cancelled"}.`);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusyId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcile() {
|
||||
if (busyId) return;
|
||||
setBusyId("reconcile");
|
||||
setError("");
|
||||
try {
|
||||
const result = await reconcileCampaignWorkAssignments(settings, campaignId);
|
||||
await loadAssignments();
|
||||
setMessage(`Checked ${result.checked} active assignments; ${result.changed} resolution state${result.changed === 1 ? "" : "s"} changed.`);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setBusyId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function openHistory(assignment: CampaignWorkAssignment) {
|
||||
setHistoryFor(assignment);
|
||||
setHistory([]);
|
||||
setHistoryLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await listCampaignWorkAssignmentHistory(settings, campaignId, assignment.id);
|
||||
setHistory(response.items);
|
||||
setHistoryCursor(response.next_cursor ?? null);
|
||||
setHistoryHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOlderHistory() {
|
||||
if (!historyFor || !historyCursor || historyLoading) return;
|
||||
setHistoryLoading(true);
|
||||
try {
|
||||
const response = await listCampaignWorkAssignmentHistory(settings, campaignId, historyFor.id, historyCursor);
|
||||
setHistory((current) => [...current, ...response.items]);
|
||||
setHistoryCursor(response.next_cursor ?? null);
|
||||
setHistoryHasMore(response.has_more);
|
||||
} catch (err) {
|
||||
setError(errorText(err));
|
||||
} finally {
|
||||
setHistoryLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function beginCreate() {
|
||||
setDraft({ ...EMPTY_DRAFT, versionId: workspace.data.campaign?.current_version_id ?? "" });
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
function beginReassign(assignment: CampaignWorkAssignment) {
|
||||
setDraft({
|
||||
...EMPTY_DRAFT,
|
||||
assigneeType: assignment.assignee_type,
|
||||
assigneeId: assignment.assignee_id
|
||||
});
|
||||
setReassigning(assignment);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
archetype="collection"
|
||||
mode="workspace"
|
||||
interfaceId="campaigns.page.work"
|
||||
helpContextId="campaign.work"
|
||||
helpModuleId="campaign"
|
||||
title="Campaign work"
|
||||
description="Assign accountable work without granting Campaign access or transferring ownership."
|
||||
loading={loading || workspace.loading}
|
||||
loadingLabel="Loading Campaign work…"
|
||||
error={error || workspace.error}
|
||||
success={message}
|
||||
actions={
|
||||
<PageActionBar
|
||||
variant="collection"
|
||||
refreshable
|
||||
reloadAction={{ onReload: () => void reload(), loading: loading || workspace.loading }}
|
||||
createAction={canManage ? <Button variant="primary" onClick={beginCreate} helpContextId="campaign.work.create" helpModuleId="campaign"><Plus size={16} aria-hidden="true" /> Add assignment</Button> : null}
|
||||
contextActions={canManage ? <Button onClick={() => void reconcile()} disabled={Boolean(busyId)}><RefreshCw size={16} aria-hidden="true" /> Reconcile assignees</Button> : null}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DismissibleAlert tone="info" resetKey={campaignId}>
|
||||
Assignment records responsibility only. Campaign sharing, ownership transfer, approval and Audit remain separate governed surfaces.
|
||||
</DismissibleAlert>
|
||||
|
||||
<Card title="Accountable work" interfaceId="campaigns.work.list" helpContextId="campaign.work" helpModuleId="campaign">
|
||||
{assignments.length === 0 ? (
|
||||
<p className="muted">No work has been assigned for this Campaign.</p>
|
||||
) : (
|
||||
<ol className="campaign-work-list" aria-label="Campaign work assignments">
|
||||
{assignments.map((assignment) => (
|
||||
<li key={assignment.id} className="campaign-work-item">
|
||||
<article>
|
||||
<header className="campaign-work-item-header">
|
||||
<div>
|
||||
<h3>{assignment.purpose}</h3>
|
||||
<p className="muted small-note">
|
||||
Assigned to <strong>{assignment.assignee_current_label || assignment.assignee_label_snapshot}</strong>
|
||||
{` · ${assignment.assignee_type.replace(/_/g, " ")}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="campaign-work-badges">
|
||||
<StatusBadge status={assignment.status} label={assignment.status.replace(/_/g, " ")} />
|
||||
<StatusBadge
|
||||
status={assignment.assignee_resolution_state === "resolved" ? "active" : "warning"}
|
||||
label={assignment.assignee_resolution_state.replace(/_/g, " ")}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<dl className="campaign-work-meta">
|
||||
<div><dt>Assigned by</dt><dd>{assignment.assigned_by_label}</dd></div>
|
||||
<div><dt>Due</dt><dd>{assignment.due_at ? formatDate(assignment.due_at) : "No due date"}</dd></div>
|
||||
<div><dt>Reference</dt><dd>{assignment.reference?.label ?? "Campaign"}</dd></div>
|
||||
<div><dt>Tasks mirror</dt><dd>{assignment.task_mirror_status.replace(/_/g, " ")}</dd></div>
|
||||
</dl>
|
||||
{assignment.assignee_resolution_state !== "resolved" ? (
|
||||
<DismissibleAlert tone="warning" resetKey={`${assignment.id}:${assignment.resource_revision}`}>
|
||||
This target is no longer available or cannot currently be resolved. History is retained; reassign it or restore the separate Campaign access/directory relationship.
|
||||
</DismissibleAlert>
|
||||
) : null}
|
||||
<div className="button-row compact-actions campaign-work-actions">
|
||||
<Button onClick={() => void openHistory(assignment)} disabled={busyId === assignment.id} helpContextId="campaign.work.history" helpModuleId="campaign">
|
||||
<History size={16} aria-hidden="true" /> History
|
||||
</Button>
|
||||
{canComplete && assignment.status === "open" ? (
|
||||
<Button onClick={() => void transition(assignment, "start")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
|
||||
<Play size={16} aria-hidden="true" /> Start
|
||||
</Button>
|
||||
) : null}
|
||||
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||
<Button variant="primary" onClick={() => void transition(assignment, "complete")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.complete" helpModuleId="campaign">
|
||||
<CheckCircle2 size={16} aria-hidden="true" /> Complete
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||
<Button onClick={() => beginReassign(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.reassign" helpModuleId="campaign">
|
||||
<UserRoundCog size={16} aria-hidden="true" /> Reassign
|
||||
</Button>
|
||||
) : null}
|
||||
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||
<span className="campaign-work-destructive-action">
|
||||
<Button variant="danger" onClick={() => setCancelling(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.cancel" helpModuleId="campaign">
|
||||
Cancel work
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{hasMore ? (
|
||||
<div className="button-row compact-actions campaign-work-load-more">
|
||||
<Button onClick={() => void loadMore()} disabled={loadingMore}>{loadingMore ? "Loading…" : "Load older work"}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Add Campaign work assignment"
|
||||
onClose={() => !busyId && setCreateOpen(false)}
|
||||
footer={<><Button onClick={() => setCreateOpen(false)} disabled={Boolean(busyId)}>Close</Button><Button variant="primary" onClick={() => void createAssignment()} disabled={Boolean(busyId) || !draft.purpose.trim() || !draft.assigneeId}>Create assignment</Button></>}
|
||||
>
|
||||
<AssignmentFields draft={draft} setDraft={setDraft} provider={targetProvider} versions={workspace.data.versions} busy={Boolean(busyId)} includePurpose />
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(reassigning)}
|
||||
title="Reassign Campaign work"
|
||||
onClose={() => !busyId && setReassigning(null)}
|
||||
footer={<><Button onClick={() => setReassigning(null)} disabled={Boolean(busyId)}>Close</Button><Button variant="primary" onClick={() => void reassign()} disabled={Boolean(busyId) || !draft.assigneeId}>Reassign</Button></>}
|
||||
>
|
||||
<AssignmentFields draft={draft} setDraft={setDraft} provider={targetProvider} versions={[]} busy={Boolean(busyId)} includeReason />
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(historyFor)}
|
||||
title={historyFor ? `History: ${historyFor.purpose}` : "Assignment history"}
|
||||
onClose={() => !historyLoading && setHistoryFor(null)}
|
||||
footer={<Button onClick={() => setHistoryFor(null)} disabled={historyLoading}>Close</Button>}
|
||||
>
|
||||
{historyLoading && history.length === 0 ? <p className="muted">Loading history…</p> : (
|
||||
<ol className="campaign-work-history">
|
||||
{history.map((event) => (
|
||||
<li key={event.id}>
|
||||
<div><StatusBadge status={event.status} /> <strong>{event.event_kind.replace(/_/g, " ")}</strong></div>
|
||||
<p>{event.assignee_label} · {event.resolution_state.replace(/_/g, " ")}</p>
|
||||
<small>{event.actor_label} · {formatDate(event.created_at)}</small>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{historyHasMore ? <Button onClick={() => void loadOlderHistory()} disabled={historyLoading}>Load older history</Button> : null}
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(cancelling)}
|
||||
title="Cancel assigned work?"
|
||||
message="The work will close without being completed. Its purpose, assignee and transition history remain as durable evidence."
|
||||
confirmLabel="Cancel work"
|
||||
tone="danger"
|
||||
busy={Boolean(busyId)}
|
||||
onCancel={() => setCancelling(null)}
|
||||
onConfirm={() => cancelling ? void transition(cancelling, "cancel") : undefined}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentFields({
|
||||
draft,
|
||||
setDraft,
|
||||
provider,
|
||||
versions,
|
||||
busy,
|
||||
includePurpose = false,
|
||||
includeReason = false
|
||||
}: {
|
||||
draft: AssignmentDraft;
|
||||
setDraft: (draft: AssignmentDraft) => void;
|
||||
provider: ReturnType<typeof campaignWorkAssignmentTargetProvider>;
|
||||
versions: Array<{ id: string; version_number: number }>;
|
||||
busy: boolean;
|
||||
includePurpose?: boolean;
|
||||
includeReason?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="campaign-work-form">
|
||||
{includePurpose ? (
|
||||
<FormField label="Purpose" help="Describe one bounded outcome. This text is retained in assignment history and may be mirrored to Tasks." helpContextId="campaign.work.create" helpModuleId="campaign">
|
||||
<textarea rows={3} maxLength={500} value={draft.purpose} disabled={busy} onChange={(event) => setDraft({ ...draft, purpose: event.target.value })} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="Assignee type" help="The selected target must already have Campaign access; assigning work never grants it.">
|
||||
<select value={draft.assigneeType} disabled={busy} onChange={(event) => setDraft({ ...draft, assigneeType: event.target.value as CampaignWorkAssigneeType, assigneeId: "" })}>
|
||||
<option value="account">Account</option>
|
||||
<option value="group">Group</option>
|
||||
<option value="organization_function">Organization function</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Assignee">
|
||||
<ReferenceSelect value={draft.assigneeId} onChange={(assigneeId) => setDraft({ ...draft, assigneeId })} provider={provider} disabled={busy} placeholder="Search authorized targets" />
|
||||
</FormField>
|
||||
{includePurpose ? (
|
||||
<FormField label="Due at" help="Optional; shown in Campaign work and an available Tasks mirror.">
|
||||
<DateTimeField value={draft.dueAt} onChange={(dueAt) => setDraft({ ...draft, dueAt })} disabled={busy} />
|
||||
</FormField>
|
||||
) : null}
|
||||
{includePurpose && versions.length > 0 ? (
|
||||
<FormField label="Campaign evidence reference" help="Optionally attach this work to one immutable Campaign version.">
|
||||
<select value={draft.versionId} disabled={busy} onChange={(event) => setDraft({ ...draft, versionId: event.target.value })}>
|
||||
<option value="">Campaign only</option>
|
||||
{versions.map((version) => <option key={version.id} value={version.id}>Version {version.version_number}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
) : null}
|
||||
{includeReason ? (
|
||||
<FormField label="Reason" help="Optional bounded context retained with the reassignment event.">
|
||||
<textarea rows={3} maxLength={500} value={draft.reason} disabled={busy} onChange={(event) => setDraft({ ...draft, reason: event.target.value })} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={draft.mirrorToTasks} disabled={busy} onChange={(event) => setDraft({ ...draft, mirrorToTasks: event.target.checked })} />
|
||||
Mirror into Tasks when the optional provider is available
|
||||
</label>
|
||||
<p className="muted small-note">Tasks is only a projection. Campaign remains authoritative, and no notification or mirror grants access.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
function errorText(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "Campaign work could not be updated.";
|
||||
}
|
||||
@@ -3,10 +3,11 @@ import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "re
|
||||
import {
|
||||
ConcurrencyConflictProvider,
|
||||
WorkspaceLayout,
|
||||
hasScope,
|
||||
useGuardedNavigate
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
|
||||
import SectionSidebar from "../../layout/SectionSidebar";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import SectionSidebar, { type CampaignWorkspaceNavigationSection } from "../../layout/SectionSidebar";
|
||||
|
||||
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
|
||||
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
|
||||
@@ -23,8 +24,10 @@ const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage"));
|
||||
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
|
||||
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
|
||||
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
|
||||
const CampaignCollaborationPage = lazy(() => import("./CampaignCollaborationPage"));
|
||||
const CampaignWorkPage = lazy(() => import("./CampaignWorkPage"));
|
||||
|
||||
const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
|
||||
overview: "",
|
||||
campaign: "recipients",
|
||||
"global-settings": "global-settings",
|
||||
@@ -38,6 +41,8 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
||||
"mail-policy": "mail-policy",
|
||||
review: "review",
|
||||
report: "report",
|
||||
activity: "activity",
|
||||
work: "work",
|
||||
audit: "audit",
|
||||
json: "json"
|
||||
};
|
||||
@@ -73,7 +78,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
|
||||
|
||||
function select(section: CampaignWorkspaceSection) {
|
||||
function select(section: CampaignWorkspaceNavigationSection) {
|
||||
const path = sectionPaths[section];
|
||||
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -87,7 +92,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
|
||||
return (
|
||||
<WorkspaceLayout
|
||||
primary={<SectionSidebar active={active} onSelect={select} />}
|
||||
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} canReadWork={hasScope(auth, "campaigns:assignment:read")} />}
|
||||
primaryLabel="Campaign sections"
|
||||
contentLabel="Campaign workspace"
|
||||
interfaceId="campaign.workspace"
|
||||
@@ -112,6 +117,8 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="activity" element={hasScope(auth, "campaigns:discussion:read") ? <CampaignCollaborationPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||
<Route path="work" element={hasScope(auth, "campaigns:assignment:read") ? <CampaignWorkPage settings={settings} auth={auth} campaignId={campaignId || ""} /> : <Navigate to="../" replace />} />
|
||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="json" element={<CampaignJsonView settings={settings} campaignId={campaignId || ""} />} />
|
||||
@@ -130,7 +137,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
);
|
||||
}
|
||||
|
||||
function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
function sectionFromPath(pathname: string): CampaignWorkspaceNavigationSection {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const section = segments[2];
|
||||
|
||||
@@ -148,6 +155,8 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
||||
if (section === "review") return "review";
|
||||
if (section === "send") return "review";
|
||||
if (section === "report" || section === "reports") return "report";
|
||||
if (section === "activity" || section === "collaboration") return "activity";
|
||||
if (section === "work" || section === "assignments") return "work";
|
||||
if (section === "audit") return "audit";
|
||||
if (section === "json") return "json";
|
||||
return "overview";
|
||||
|
||||
@@ -2,6 +2,52 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-campaign.collaboration": "Collaboration",
|
||||
"i18n:govoplan-campaign.collaboration_description": "Bounded human discussion linked to stable Campaign evidence.",
|
||||
"i18n:govoplan-campaign.loading_collaboration": "Loading campaign collaboration…",
|
||||
"i18n:govoplan-campaign.collaboration_audit_boundary": "This thread contains human discussion only. System events and durable audit evidence remain in the Audit surface and are not replaced by comments.",
|
||||
"i18n:govoplan-campaign.new_collaboration_entry": "New discussion entry",
|
||||
"i18n:govoplan-campaign.comment": "Comment",
|
||||
"i18n:govoplan-campaign.comment_help": "Posted text cannot be edited. Withdrawal or redaction removes the text and keeps an auditable tombstone.",
|
||||
"i18n:govoplan-campaign.visibility": "Visibility",
|
||||
"i18n:govoplan-campaign.visibility_help": "Collaborator entries are visible to discussion readers. Moderator entries require moderation authority.",
|
||||
"i18n:govoplan-campaign.visibility_collaborators": "Campaign collaborators",
|
||||
"i18n:govoplan-campaign.visibility_moderators": "Moderators only",
|
||||
"i18n:govoplan-campaign.reference_context": "Reference context",
|
||||
"i18n:govoplan-campaign.reference_context_help": "Optionally link the comment to stable Campaign evidence. The referenced version remains unchanged.",
|
||||
"i18n:govoplan-campaign.no_reference": "No reference",
|
||||
"i18n:govoplan-campaign.reference_campaign_version": "Campaign version",
|
||||
"i18n:govoplan-campaign.reference_recipient_import": "Recipient import batch",
|
||||
"i18n:govoplan-campaign.reference_attachment_rule": "Attachment rule",
|
||||
"i18n:govoplan-campaign.reference_delivery_job": "Delivery job",
|
||||
"i18n:govoplan-campaign.reference_report": "Report",
|
||||
"i18n:govoplan-campaign.campaign_version": "Campaign version",
|
||||
"i18n:govoplan-campaign.select_version": "Select a version",
|
||||
"i18n:govoplan-campaign.stable_reference_id": "Stable reference ID",
|
||||
"i18n:govoplan-campaign.stable_reference_id_help": "Use the ID shown by the referenced Campaign evidence. Version-bound references include the immutable version ID.",
|
||||
"i18n:govoplan-campaign.mentions": "Mentions",
|
||||
"i18n:govoplan-campaign.mentions_help": "Only active users who already have access to this Campaign can be mentioned.",
|
||||
"i18n:govoplan-campaign.add_mention": "Add a campaign collaborator",
|
||||
"i18n:govoplan-campaign.posting": "Posting…",
|
||||
"i18n:govoplan-campaign.post_comment": "Post comment",
|
||||
"i18n:govoplan-campaign.collaboration_posted": "The discussion entry was posted.",
|
||||
"i18n:govoplan-campaign.collaboration_withdrawn": "The discussion entry was withdrawn; its tombstone remains.",
|
||||
"i18n:govoplan-campaign.collaboration_redacted": "The discussion entry was redacted; its tombstone remains.",
|
||||
"i18n:govoplan-campaign.collaboration_read_only": "You may read this discussion but do not have the separate permission required to post.",
|
||||
"i18n:govoplan-campaign.discussion": "Discussion",
|
||||
"i18n:govoplan-campaign.no_collaboration_entries": "No human discussion has been recorded for this Campaign.",
|
||||
"i18n:govoplan-campaign.moderators_only": "Moderators only",
|
||||
"i18n:govoplan-campaign.entry_redacted_tombstone": "This entry was redacted by an authorized moderator. Its timestamp and evidence hash remain.",
|
||||
"i18n:govoplan-campaign.entry_withdrawn_tombstone": "This entry was withdrawn by its author. Its timestamp and evidence hash remain.",
|
||||
"i18n:govoplan-campaign.reference_label": "Reference:",
|
||||
"i18n:govoplan-campaign.withdraw": "Withdraw",
|
||||
"i18n:govoplan-campaign.redact": "Redact",
|
||||
"i18n:govoplan-campaign.loading_older_entries": "Loading older entries…",
|
||||
"i18n:govoplan-campaign.load_older_entries": "Load older entries",
|
||||
"i18n:govoplan-campaign.redact_collaboration_entry": "Redact discussion entry",
|
||||
"i18n:govoplan-campaign.withdraw_collaboration_entry": "Withdraw discussion entry",
|
||||
"i18n:govoplan-campaign.redact_collaboration_entry_confirmation": "Redact this text? The text will be removed while its tombstone and audit evidence remain.",
|
||||
"i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation": "Withdraw this text? The text will be removed while its tombstone and audit evidence remain.",
|
||||
"i18n:govoplan-campaign.unassigned_file_policy": "Unassigned file policy",
|
||||
"i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected",
|
||||
"i18n:govoplan-campaign.watched_sources": "Watched sources",
|
||||
@@ -1346,6 +1392,52 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-campaign.collaboration": "Zusammenarbeit",
|
||||
"i18n:govoplan-campaign.collaboration_description": "Begrenzte menschliche Diskussion mit Bezug auf stabile Kampagnennachweise.",
|
||||
"i18n:govoplan-campaign.loading_collaboration": "Kampagnenzusammenarbeit wird geladen…",
|
||||
"i18n:govoplan-campaign.collaboration_audit_boundary": "Dieser Verlauf enthält nur menschliche Diskussionen. Systemereignisse und dauerhafte Auditnachweise bleiben im Audit-Bereich und werden nicht durch Kommentare ersetzt.",
|
||||
"i18n:govoplan-campaign.new_collaboration_entry": "Neuer Diskussionseintrag",
|
||||
"i18n:govoplan-campaign.comment": "Kommentar",
|
||||
"i18n:govoplan-campaign.comment_help": "Veröffentlichter Text kann nicht bearbeitet werden. Rücknahme oder Schwärzung entfernt den Text und erhält einen auditierbaren Platzhalter.",
|
||||
"i18n:govoplan-campaign.visibility": "Sichtbarkeit",
|
||||
"i18n:govoplan-campaign.visibility_help": "Einträge für Mitwirkende sind für Diskussionsleser sichtbar. Moderationseinträge erfordern eine Moderationsberechtigung.",
|
||||
"i18n:govoplan-campaign.visibility_collaborators": "Kampagnenmitwirkende",
|
||||
"i18n:govoplan-campaign.visibility_moderators": "Nur Moderation",
|
||||
"i18n:govoplan-campaign.reference_context": "Referenzkontext",
|
||||
"i18n:govoplan-campaign.reference_context_help": "Der Kommentar kann optional mit einem stabilen Kampagnennachweis verknüpft werden. Die referenzierte Version bleibt unverändert.",
|
||||
"i18n:govoplan-campaign.no_reference": "Keine Referenz",
|
||||
"i18n:govoplan-campaign.reference_campaign_version": "Kampagnenversion",
|
||||
"i18n:govoplan-campaign.reference_recipient_import": "Empfänger-Importlauf",
|
||||
"i18n:govoplan-campaign.reference_attachment_rule": "Anlagenregel",
|
||||
"i18n:govoplan-campaign.reference_delivery_job": "Sendeauftrag",
|
||||
"i18n:govoplan-campaign.reference_report": "Bericht",
|
||||
"i18n:govoplan-campaign.campaign_version": "Kampagnenversion",
|
||||
"i18n:govoplan-campaign.select_version": "Version auswählen",
|
||||
"i18n:govoplan-campaign.stable_reference_id": "Stabile Referenz-ID",
|
||||
"i18n:govoplan-campaign.stable_reference_id_help": "Verwenden Sie die beim Kampagnennachweis angezeigte ID. Versionsgebundene Referenzen enthalten die unveränderliche Versions-ID.",
|
||||
"i18n:govoplan-campaign.mentions": "Erwähnungen",
|
||||
"i18n:govoplan-campaign.mentions_help": "Nur aktive Personen, die bereits Zugriff auf diese Kampagne haben, können erwähnt werden.",
|
||||
"i18n:govoplan-campaign.add_mention": "Kampagnenmitwirkende hinzufügen",
|
||||
"i18n:govoplan-campaign.posting": "Wird veröffentlicht…",
|
||||
"i18n:govoplan-campaign.post_comment": "Kommentar veröffentlichen",
|
||||
"i18n:govoplan-campaign.collaboration_posted": "Der Diskussionseintrag wurde veröffentlicht.",
|
||||
"i18n:govoplan-campaign.collaboration_withdrawn": "Der Diskussionseintrag wurde zurückgenommen; sein Platzhalter bleibt erhalten.",
|
||||
"i18n:govoplan-campaign.collaboration_redacted": "Der Diskussionseintrag wurde geschwärzt; sein Platzhalter bleibt erhalten.",
|
||||
"i18n:govoplan-campaign.collaboration_read_only": "Sie dürfen diese Diskussion lesen, besitzen aber nicht die separate Berechtigung zum Veröffentlichen.",
|
||||
"i18n:govoplan-campaign.discussion": "Diskussion",
|
||||
"i18n:govoplan-campaign.no_collaboration_entries": "Für diese Kampagne wurde noch keine menschliche Diskussion erfasst.",
|
||||
"i18n:govoplan-campaign.moderators_only": "Nur Moderation",
|
||||
"i18n:govoplan-campaign.entry_redacted_tombstone": "Dieser Eintrag wurde durch eine berechtigte Moderation geschwärzt. Zeitstempel und Nachweis-Hash bleiben erhalten.",
|
||||
"i18n:govoplan-campaign.entry_withdrawn_tombstone": "Dieser Eintrag wurde durch die verfassende Person zurückgenommen. Zeitstempel und Nachweis-Hash bleiben erhalten.",
|
||||
"i18n:govoplan-campaign.reference_label": "Referenz:",
|
||||
"i18n:govoplan-campaign.withdraw": "Zurücknehmen",
|
||||
"i18n:govoplan-campaign.redact": "Schwärzen",
|
||||
"i18n:govoplan-campaign.loading_older_entries": "Ältere Einträge werden geladen…",
|
||||
"i18n:govoplan-campaign.load_older_entries": "Ältere Einträge laden",
|
||||
"i18n:govoplan-campaign.redact_collaboration_entry": "Diskussionseintrag schwärzen",
|
||||
"i18n:govoplan-campaign.withdraw_collaboration_entry": "Diskussionseintrag zurücknehmen",
|
||||
"i18n:govoplan-campaign.redact_collaboration_entry_confirmation": "Diesen Text schwärzen? Der Text wird entfernt; Platzhalter und Auditnachweis bleiben erhalten.",
|
||||
"i18n:govoplan-campaign.withdraw_collaboration_entry_confirmation": "Diesen Text zurücknehmen? Der Text wird entfernt; Platzhalter und Auditnachweis bleiben erhalten.",
|
||||
"i18n:govoplan-campaign.unassigned_file_policy": "Richtlinie für nicht zugeordnete Dateien",
|
||||
"i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien",
|
||||
"i18n:govoplan-campaign.watched_sources": "Überwachte Quellen",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { CampaignWorkspaceSection } from "../types";
|
||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
||||
|
||||
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
export type CampaignWorkspaceNavigationSection = CampaignWorkspaceSection | "activity" | "work";
|
||||
|
||||
const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceNavigationSection>[] = [
|
||||
{
|
||||
items: [{ id: "overview", label: "i18n:govoplan-campaign.overview.0efc2e6b", primary: true }]
|
||||
},
|
||||
@@ -38,6 +40,8 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
title: "i18n:govoplan-campaign.report.7b8ddb90",
|
||||
items: [
|
||||
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
|
||||
{ id: "work", label: "Campaign work" },
|
||||
{ id: "activity", label: "i18n:govoplan-campaign.collaboration" },
|
||||
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
|
||||
|
||||
},
|
||||
@@ -49,10 +53,21 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
||||
|
||||
export default function SectionSidebar({
|
||||
active,
|
||||
onSelect
|
||||
|
||||
|
||||
|
||||
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
|
||||
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
|
||||
onSelect,
|
||||
canReadActivity,
|
||||
canReadWork
|
||||
}: {
|
||||
active: CampaignWorkspaceNavigationSection;
|
||||
onSelect: (section: CampaignWorkspaceNavigationSection) => void;
|
||||
canReadActivity: boolean;
|
||||
canReadWork: boolean;
|
||||
}) {
|
||||
const groups = campaignSubnav.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) =>
|
||||
(item.id !== "activity" || canReadActivity)
|
||||
&& (item.id !== "work" || canReadWork)
|
||||
)
|
||||
}));
|
||||
return <ModuleSubnav active={active} groups={groups} onSelect={onSelect} />;
|
||||
}
|
||||
|
||||
+15
-1
@@ -88,9 +88,23 @@ export const campaignModule: PlatformWebModule = {
|
||||
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
||||
version: "1.0.0",
|
||||
dependencies: ["access"],
|
||||
optionalDependencies: ["files", "mail"],
|
||||
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
|
||||
translations,
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "campaigns.page.work",
|
||||
moduleId: "campaigns",
|
||||
kind: "page",
|
||||
label: "Campaign work",
|
||||
order: 44
|
||||
},
|
||||
{
|
||||
id: "campaigns.page.activity",
|
||||
moduleId: "campaigns",
|
||||
kind: "page",
|
||||
label: "Campaign collaboration",
|
||||
order: 45
|
||||
},
|
||||
{
|
||||
id: "campaigns.widget.activity",
|
||||
moduleId: "campaigns",
|
||||
|
||||
@@ -2780,3 +2780,51 @@
|
||||
.campaign-residual-file-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.campaign-residual-file-wide { grid-column: auto; }
|
||||
}
|
||||
|
||||
.campaign-collaboration-composer { display: grid; gap: 10px; }
|
||||
.campaign-collaboration-composer textarea { width: 100%; resize: vertical; }
|
||||
.campaign-collaboration-counter { justify-self: end; color: var(--muted); font-size: var(--font-size-sm); }
|
||||
.campaign-collaboration-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.campaign-collaboration-options input,
|
||||
.campaign-collaboration-options select { width: 100%; }
|
||||
.campaign-collaboration-submit { justify-content: flex-end; }
|
||||
.campaign-collaboration-thread { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.campaign-collaboration-entry { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-bg); }
|
||||
.campaign-collaboration-entry article { display: grid; gap: 10px; padding: 14px; }
|
||||
.campaign-collaboration-entry-header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; color: var(--muted); font-size: var(--font-size-sm); }
|
||||
.campaign-collaboration-actor { display: inline-flex; align-items: center; gap: 7px; color: var(--text); font-weight: 650; }
|
||||
.campaign-collaboration-content { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.55; }
|
||||
.campaign-collaboration-reference { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin: 0; color: var(--muted); font-size: var(--font-size-sm); }
|
||||
.campaign-collaboration-reference code { overflow-wrap: anywhere; }
|
||||
.campaign-collaboration-tombstone { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 9px 11px; border: var(--border-line); border-radius: var(--radius-sm); color: var(--muted); background: var(--subtle-bg); font-style: italic; }
|
||||
.campaign-collaboration-entry-actions { justify-content: flex-end; padding-top: 4px; border-top: var(--border-line); }
|
||||
.campaign-collaboration-load-more { justify-content: center; margin-top: 14px; }
|
||||
.campaign-work-list,
|
||||
.campaign-work-history { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
|
||||
.campaign-work-item { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-bg); }
|
||||
.campaign-work-item article { display: grid; gap: 14px; padding: 16px; }
|
||||
.campaign-work-item-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.campaign-work-item-header h3 { margin: 0 0 4px; font-size: var(--font-size-md); }
|
||||
.campaign-work-item-header p { margin: 0; }
|
||||
.campaign-work-badges { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.campaign-work-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 0; }
|
||||
.campaign-work-meta div { min-width: 0; }
|
||||
.campaign-work-meta dt { color: var(--muted); font-size: var(--font-size-sm); }
|
||||
.campaign-work-meta dd { margin: 3px 0 0; overflow-wrap: anywhere; }
|
||||
.campaign-work-actions { align-items: center; padding-top: 4px; border-top: var(--border-line); }
|
||||
.campaign-work-destructive-action { display: inline-flex; margin-inline-start: auto; padding-inline-start: 16px; border-inline-start: var(--border-line); }
|
||||
.campaign-work-load-more { justify-content: center; margin-top: 14px; }
|
||||
.campaign-work-form { display: grid; gap: 14px; }
|
||||
.campaign-work-form textarea,
|
||||
.campaign-work-form select { width: 100%; }
|
||||
.campaign-work-history li { display: grid; gap: 5px; padding: 12px; border: var(--border-line); border-radius: var(--radius-sm); }
|
||||
.campaign-work-history p,
|
||||
.campaign-work-history small { margin: 0; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.campaign-collaboration-options { grid-template-columns: minmax(0, 1fr); }
|
||||
.campaign-collaboration-submit,
|
||||
.campaign-collaboration-entry-actions { justify-content: flex-start; }
|
||||
.campaign-work-meta { grid-template-columns: minmax(0, 1fr); }
|
||||
.campaign-work-destructive-action { width: 100%; margin-inline-start: 0; padding: 12px 0 0; border-inline-start: 0; border-top: var(--border-line); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const page = fs.readFileSync(
|
||||
path.join(root, "src/features/campaigns/CampaignCollaborationPage.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
const workspace = fs.readFileSync(
|
||||
path.join(root, "src/features/campaigns/CampaignWorkspace.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
const sidebar = fs.readFileSync(
|
||||
path.join(root, "src/layout/SectionSidebar.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
const api = fs.readFileSync(path.join(root, "src/api/campaigns.ts"), "utf8");
|
||||
|
||||
assert.match(page, /archetype="collection"/);
|
||||
assert.match(page, /variant="collection"/);
|
||||
assert.match(page, /refreshable/);
|
||||
assert.match(page, /ReferenceMultiSelect/);
|
||||
assert.match(page, /ConfirmDialog/);
|
||||
assert.match(page, /campaigns:discussion:post/);
|
||||
assert.match(page, /campaigns:discussion:moderate/);
|
||||
assert.match(page, /maxLength=\{8000\}/);
|
||||
assert.match(page, /<ol[^>]+aria-label="Campaign collaboration thread"/);
|
||||
assert.match(page, /entry\.tombstone/);
|
||||
assert.match(page, /collaboration_audit_boundary/);
|
||||
assert.doesNotMatch(page, /updateCampaignVersion|saveCampaignVersion/);
|
||||
|
||||
assert.match(workspace, /path="activity"/);
|
||||
assert.match(workspace, /CampaignCollaborationPage/);
|
||||
assert.match(sidebar, /canReadActivity/);
|
||||
assert.match(sidebar, /id: "activity"/);
|
||||
|
||||
assert.match(api, /\/collaboration\?/);
|
||||
assert.match(api, /\/withdraw/);
|
||||
assert.match(api, /\/redact/);
|
||||
assert.match(api, /\/collaboration\/mention-options/);
|
||||
|
||||
console.log("Campaign collaboration UI structure checks passed.");
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const page = readFileSync(new URL("../src/features/campaigns/CampaignWorkPage.tsx", import.meta.url), "utf8");
|
||||
const api = readFileSync(new URL("../src/api/campaigns.ts", import.meta.url), "utf8");
|
||||
const workspace = readFileSync(new URL("../src/features/campaigns/CampaignWorkspace.tsx", import.meta.url), "utf8");
|
||||
const sidebar = readFileSync(new URL("../src/layout/SectionSidebar.tsx", import.meta.url), "utf8");
|
||||
|
||||
for (const primitive of [
|
||||
"PageLayout",
|
||||
"PageActionBar",
|
||||
"Card",
|
||||
"FormField",
|
||||
"ReferenceSelect",
|
||||
"DateTimeField",
|
||||
"Dialog",
|
||||
"ConfirmDialog",
|
||||
"StatusBadge",
|
||||
"DismissibleAlert"
|
||||
]) {
|
||||
assert.match(page, new RegExp(`\\b${primitive}\\b`), `Campaign work should use centralized ${primitive}`);
|
||||
}
|
||||
|
||||
assert.match(page, /archetype="collection"/);
|
||||
assert.match(page, /refreshable/);
|
||||
assert.match(page, /createAction=/);
|
||||
assert.match(page, /campaign-work-destructive-action/);
|
||||
assert.match(page, /tone="danger"/);
|
||||
assert.match(page, /assignment records responsibility only/i);
|
||||
assert.match(page, /Campaign sharing, ownership transfer, approval and Audit remain separate/i);
|
||||
assert.match(page, /Reconcile assignees/);
|
||||
assert.match(page, /History/);
|
||||
|
||||
for (const contract of [
|
||||
"/assignments?",
|
||||
"/assignments/${encodeURIComponent(assignment.id)}/transition",
|
||||
"/assignments/${encodeURIComponent(assignmentId)}/reassign",
|
||||
"/assignments/${encodeURIComponent(assignmentId)}/history",
|
||||
"/assignments/reconcile",
|
||||
"/assignments/options"
|
||||
]) {
|
||||
assert.ok(api.includes(contract), `Campaign API should expose ${contract}`);
|
||||
}
|
||||
|
||||
assert.match(workspace, /path="work"/);
|
||||
assert.match(workspace, /campaigns:assignment:read/);
|
||||
assert.match(sidebar, /id: "work"/);
|
||||
assert.match(sidebar, /canReadWork/);
|
||||
|
||||
console.log("campaign work UI structure tests passed");
|
||||
Reference in New Issue
Block a user