Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b32427813 | ||
|
|
c21fb4cf7c | ||
|
|
b41f23c901 | ||
|
|
3934e7fedb | ||
|
|
1bd24f9b5b | ||
|
|
4f52f010ee | ||
|
|
2630498026 | ||
|
|
c2f083e5f6 | ||
|
|
5a21067e44 | ||
|
|
73cfad209a | ||
|
|
2a00d910df | ||
|
|
c846c249b8 | ||
|
|
69e6588a89 | ||
|
|
75e8f864a7 |
@@ -59,6 +59,32 @@ Files, Mail, Distribution Lists, Templates, Postbox, and Calendar are optional m
|
|||||||
|
|
||||||
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
|
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
|
||||||
|
|
||||||
|
Recurring schedules have two immutable modes. Manual mode remains the default
|
||||||
|
and prepares independent drafts without Mail. Autonomous mode is explicit and
|
||||||
|
Mail-only: it seals an already built and explicitly approved execution snapshot,
|
||||||
|
rechecks approval, policy, credential/transport revision, live SMTP health,
|
||||||
|
recipient and attachment evidence before each occurrence, and submits one
|
||||||
|
Mail-owned durable command per frozen message. Occurrence-scoped idempotency is
|
||||||
|
allocated before delivery. Accepted and outcome-unknown effects are never
|
||||||
|
retried automatically; uncertain or systemic failures pause the schedule,
|
||||||
|
notify its accountable operator, and retain non-secret recovery evidence.
|
||||||
|
Generated EML retention excludes source versions while an autonomous schedule
|
||||||
|
has a remaining occurrence, including while it is paused; once the schedule
|
||||||
|
finishes, already accepted Mail commands retain their own encrypted payload and
|
||||||
|
evidence under Mail policy.
|
||||||
|
|
||||||
|
Campaign versions can also be exported as versioned portable JSON packages and
|
||||||
|
imported as independently owned drafts. The privacy-safe export default is
|
||||||
|
metadata plus template/configuration. Recipients, attachment rules, aggregate
|
||||||
|
review state, and recipient-level delivery history are separate scopes with
|
||||||
|
their existing fine-grained permissions. Packages include source provenance,
|
||||||
|
scope/item/redaction manifests, and a SHA-256 integrity digest. They never
|
||||||
|
contain attachment bytes, transport secrets, credential references,
|
||||||
|
password-field values, local storage locators, shares, or ownership grants.
|
||||||
|
Import previews schema and checksum compatibility plus every created/skipped
|
||||||
|
domain. It clears deployment-bound Mail references and never replays locks,
|
||||||
|
approvals, review decisions, jobs, attempts, or sent state.
|
||||||
|
|
||||||
Public campaign, version, job, and report responses expose business data and
|
Public campaign, version, job, and report responses expose business data and
|
||||||
delivery evidence, but never process-local paths, storage-backend keys, or
|
delivery evidence, but never process-local paths, storage-backend keys, or
|
||||||
worker claim tokens. Operational troubleshooting uses the dedicated job
|
worker claim tokens. Operational troubleshooting uses the dedicated job
|
||||||
@@ -78,6 +104,9 @@ services can cooperate without importing campaign internals:
|
|||||||
- `campaigns.policyContext` for retention/policy provenance
|
- `campaigns.policyContext` for retention/policy provenance
|
||||||
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
||||||
- `campaigns.retention` for campaign-owned retention cleanup
|
- `campaigns.retention` for campaign-owned retention cleanup
|
||||||
|
- `privacy.dsar.campaigns` for tenant-scoped recipient, version, delivery,
|
||||||
|
report-projection, and artifact-metadata discovery plus governed erasure
|
||||||
|
planning
|
||||||
|
|
||||||
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
||||||
results only.
|
results only.
|
||||||
@@ -120,6 +149,11 @@ Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
|
|||||||
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
|
- [Campaign handbook](docs/CAMPAIGN_HANDBOOK.md) provides the adaptive user, process, governance, technical, and operations perspectives.
|
||||||
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
- [Campaign delivery runbook](docs/CAMPAIGN_DELIVERY_RUNBOOK.md) covers queueing, local vs Celery operation, retries, reconciliation, reports, and the live SMTP/IMAP test checklist.
|
||||||
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0–500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
|
- Immediate delivery is bounded to 25 exact eligible recipient jobs by default. Deployments may set `GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0–500), and tenants may narrow that ceiling through `campaign_delivery_policy.synchronous_send_max_recipients` in tenant settings.
|
||||||
|
- Immediate Mail delivery preflights the selected SMTP transport before the
|
||||||
|
first effect and reuses a healthy bounded connection through Mail. Review and
|
||||||
|
send reports the batch state, connection/reconnect counts, and paused count.
|
||||||
|
A systemic authentication, sender, or connectivity failure pauses remaining
|
||||||
|
jobs; correct and test the Mail profile before explicitly resuming them.
|
||||||
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
||||||
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
||||||
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ Before the first live send for a sender domain or mail-server profile:
|
|||||||
6. If a synchronous request is used, keep Review and send open: it polls the
|
6. If a synchronous request is used, keep Review and send open: it polls the
|
||||||
durable counters while the request runs. A rejection occurs before SMTP and
|
durable counters while the request runs. A rejection occurs before SMTP and
|
||||||
directs oversized runs to workers.
|
directs oversized runs to workers.
|
||||||
|
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
||||||
|
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
||||||
|
systemic transport failure stopped the remaining jobs before their SMTP
|
||||||
|
effect; test/correct the Mail profile and explicitly resume the queue.
|
||||||
|
|
||||||
## Outcome Handling
|
## Outcome Handling
|
||||||
|
|
||||||
@@ -88,6 +92,9 @@ unknown provider attempt merely to repair the other layer's state.
|
|||||||
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
||||||
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
||||||
intentionally includes permanent failures.
|
intentionally includes permanent failures.
|
||||||
|
- `paused` after a systemic SMTP failure: do not resume until the shared Mail
|
||||||
|
profile passes its connection test. Authentication, sender rejection, and
|
||||||
|
unavailable connectivity affect the batch rather than one recipient.
|
||||||
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
||||||
provider control panels, then reconcile as accepted or not sent.
|
provider control panels, then reconcile as accepted or not sent.
|
||||||
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
||||||
|
|||||||
+112
-2
@@ -45,7 +45,8 @@ Campaign owns:
|
|||||||
- message and attachment rules for a version;
|
- message and attachment rules for a version;
|
||||||
- validation, review, build, queue, and delivery-control state;
|
- validation, review, build, queue, and delivery-control state;
|
||||||
- the durable jobs and attempts needed to explain delivery outcomes; and
|
- 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:
|
Campaign does not own:
|
||||||
|
|
||||||
@@ -111,6 +112,61 @@ action.
|
|||||||
|
|
||||||
## User tasks
|
## 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.
|
||||||
|
|
||||||
|
### Assign accountable campaign work
|
||||||
|
|
||||||
|
Open **Work** to assign one bounded purpose to an account, group, or
|
||||||
|
organization function that already has Campaign access. Assignment records
|
||||||
|
responsibility only: it never creates a share, transfers ownership, or grants a
|
||||||
|
permission. Assignees may accept, complete, or reject their work; rejection is
|
||||||
|
distinct from administrative cancellation. Managers may reassign or cancel
|
||||||
|
open work, and every transition retains the expected revision, actor snapshot,
|
||||||
|
typed target, and append-only event history.
|
||||||
|
|
||||||
|
Workflow may create or reference a Campaign and open the same assignment through
|
||||||
|
the optional `campaigns.workOrchestration` capability. Those assignments pin the
|
||||||
|
Campaign version and store the Workflow instance, step, correlation, and
|
||||||
|
idempotency provenance. Campaign emits `campaign.work.changed` for assignment,
|
||||||
|
acceptance, start, reassignment, completion, rejection, and cancellation.
|
||||||
|
Workflow uses the assignment ID and event revision, rechecks current Campaign
|
||||||
|
access, and then resumes the matching durable external hand-off without browser
|
||||||
|
polling. A missing Tasks or Notifications capability only removes the optional
|
||||||
|
projection or notification. A missing Campaign provider, revoked Campaign
|
||||||
|
access, or stale event revision keeps the Workflow blocked and inspectable.
|
||||||
|
|
||||||
|
Campaign also contributes the opt-in **Accountable Campaign work hand-off**
|
||||||
|
Workflow template. It is deliberately not activated on installation. A
|
||||||
|
configurator must copy or activate it and supply either `campaign_id` or
|
||||||
|
`create_campaign`; unused optional input keys must be present with `null`
|
||||||
|
values. The template prepares the assignment idempotently, opens the exact
|
||||||
|
Campaign work URL, and waits for completion, rejection, cancellation, or the
|
||||||
|
configured timeout. Opening the link never completes the Workflow.
|
||||||
|
|
||||||
### Prepare a campaign
|
### Prepare a campaign
|
||||||
|
|
||||||
1. Create a campaign and confirm its owner or owning group.
|
1. Create a campaign and confirm its owner or owning group.
|
||||||
@@ -276,6 +332,32 @@ default.
|
|||||||
|
|
||||||
## Data and evidence model
|
## Data and evidence model
|
||||||
|
|
||||||
|
### Portable Campaign transfer
|
||||||
|
|
||||||
|
Campaign offers two reuse paths with different boundaries. **Copy campaign**
|
||||||
|
creates another campaign inside the same installation and can reuse selected
|
||||||
|
local shares, policies, and Mail profile references. **Export package** creates
|
||||||
|
a versioned JSON hand-off whose selected scopes can cross an installation
|
||||||
|
boundary; **Import package** always creates a separately owned draft.
|
||||||
|
|
||||||
|
The export dialog starts with only metadata and template/configuration. Add
|
||||||
|
recipients, attachment rules, review state, or delivery history only when the
|
||||||
|
handoff requires them and the destination and retention are approved. Recipient
|
||||||
|
and delivery scopes remain protected by recipient/report export permissions.
|
||||||
|
Transport secrets, credential references, password-field values, local storage
|
||||||
|
locators, and attachment bytes are always removed. The manifest records scope
|
||||||
|
counts and redactions, while the envelope carries source Campaign/version
|
||||||
|
provenance and a SHA-256 digest.
|
||||||
|
|
||||||
|
Import verifies format, scope, checksum, schema, and destination identity before
|
||||||
|
showing the plan. Editing the destination identity or selected scopes makes the
|
||||||
|
preview stale and requires a new check. The apply step clears source Mail
|
||||||
|
references, creates one editable draft, and stores a bounded source/package and
|
||||||
|
created/skipped receipt. Historical validation/build summaries, review state,
|
||||||
|
approvals, delivery jobs, attempts, and sent outcomes are never replayed. File
|
||||||
|
content is never embedded, so reconnect managed files and local Mail profiles,
|
||||||
|
then validate, build, review, and approve normally.
|
||||||
|
|
||||||
### Versions and snapshots
|
### Versions and snapshots
|
||||||
|
|
||||||
Editable campaign JSON is versioned. Build creates recipient jobs and an
|
Editable campaign JSON is versioned. Build creates recipient jobs and an
|
||||||
@@ -557,6 +639,35 @@ purpose, lawful basis, minimization, export control, and retention before the
|
|||||||
campaign starts; do not use Campaign as a substitute consent or address-master
|
campaign starts; do not use Campaign as a substitute consent or address-master
|
||||||
system.
|
system.
|
||||||
|
|
||||||
|
The Core data-subject-request workflow discovers Campaign through the optional
|
||||||
|
`privacy.dsar.campaigns` capability. After the request's email, membership, and
|
||||||
|
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, 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
|
||||||
|
the source for content that cannot safely be copied into the DSAR case.
|
||||||
|
|
||||||
|
Built, locked, published, terminal, delivered, or corrected records are
|
||||||
|
retained with an explicit reason and continue through Campaign's configured
|
||||||
|
retention and redaction process. Draft recipient content and user-owned
|
||||||
|
attachment content require coordinated manual review because copies may span
|
||||||
|
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. 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.
|
||||||
|
|
||||||
### Audit and destructive actions
|
### Audit and destructive actions
|
||||||
|
|
||||||
Material authoring, validation, locking, review, queueing, send, retry,
|
Material authoring, validation, locking, review, queueing, send, retry,
|
||||||
@@ -611,7 +722,6 @@ The following are part of the selected reference journey but are not implied by
|
|||||||
the current baseline:
|
the current baseline:
|
||||||
|
|
||||||
- the final audited **test / single send / single resend** semantics;
|
- the final audited **test / single send / single resend** semantics;
|
||||||
- reusable SMTP batch sessions and their measured throughput benefit;
|
|
||||||
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
||||||
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
|
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
|
||||||
- a fully packaged one-command Campaign reference composition with production
|
- a fully packaged one-command Campaign reference composition with production
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.18"
|
version = "0.1.27"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.28",
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CampaignArchiveEncryptionDecision,
|
||||||
|
CampaignArchiveEncryptionRequest,
|
||||||
|
campaign_archive_encryption_policy,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign
|
||||||
|
from govoplan_campaign.backend.runtime import get_registry
|
||||||
|
|
||||||
|
|
||||||
|
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
|
||||||
|
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignArchiveEncryptionError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EffectiveArchiveEncryptionPolicy:
|
||||||
|
available: bool
|
||||||
|
allowed_password_encryption_methods: frozenset[str]
|
||||||
|
allowed_password_delivery_channels: frozenset[str]
|
||||||
|
policy_hash: str
|
||||||
|
source_path: tuple[Mapping[str, Any], ...]
|
||||||
|
reason: str
|
||||||
|
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"available": self.available,
|
||||||
|
"allowed_password_encryption_methods": sorted(
|
||||||
|
self.allowed_password_encryption_methods
|
||||||
|
),
|
||||||
|
"allowed_password_delivery_channels": sorted(
|
||||||
|
self.allowed_password_delivery_channels
|
||||||
|
),
|
||||||
|
"policy_hash": self.policy_hash,
|
||||||
|
"source_path": [dict(item) for item in self.source_path],
|
||||||
|
"reason": self.reason,
|
||||||
|
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||||
|
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def effective_archive_encryption_policy(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
) -> EffectiveArchiveEncryptionPolicy:
|
||||||
|
provider = campaign_archive_encryption_policy(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
payload = {
|
||||||
|
"available": False,
|
||||||
|
"allowed_password_encryption_methods": ["aes"],
|
||||||
|
"allowed_password_delivery_channels": [
|
||||||
|
"in_person",
|
||||||
|
"letter",
|
||||||
|
"phone",
|
||||||
|
"separate_mail",
|
||||||
|
"sms",
|
||||||
|
],
|
||||||
|
"source_path": [
|
||||||
|
{
|
||||||
|
"scope_type": "system",
|
||||||
|
"scope_id": None,
|
||||||
|
"path": "system",
|
||||||
|
"label": "Secure local fallback",
|
||||||
|
"applied_fields": ["allowed_password_encryption_methods"],
|
||||||
|
"policy": {
|
||||||
|
"allowed_password_encryption_methods": ["aes"],
|
||||||
|
"policy_provider": "unavailable",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return EffectiveArchiveEncryptionPolicy(
|
||||||
|
available=False,
|
||||||
|
allowed_password_encryption_methods=frozenset({"aes"}),
|
||||||
|
allowed_password_delivery_channels=frozenset(
|
||||||
|
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||||
|
),
|
||||||
|
policy_hash=_hash(payload),
|
||||||
|
source_path=tuple(payload["source_path"]),
|
||||||
|
reason=(
|
||||||
|
"Policy is unavailable. AES remains available through the secure "
|
||||||
|
"local baseline; legacy ZipCrypto fails closed."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
owner_type: str | None = None
|
||||||
|
owner_id: str | None = None
|
||||||
|
if campaign.owner_group_id:
|
||||||
|
owner_type, owner_id = "group", campaign.owner_group_id
|
||||||
|
elif campaign.owner_user_id:
|
||||||
|
owner_type, owner_id = "user", campaign.owner_user_id
|
||||||
|
decision: CampaignArchiveEncryptionDecision = (
|
||||||
|
provider.resolve_campaign_archive_encryption(
|
||||||
|
session,
|
||||||
|
request=CampaignArchiveEncryptionRequest(
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
owner_type=owner_type, # type: ignore[arg-type]
|
||||||
|
owner_id=owner_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return EffectiveArchiveEncryptionPolicy(
|
||||||
|
available=True,
|
||||||
|
allowed_password_encryption_methods=frozenset(
|
||||||
|
decision.allowed_password_encryption_methods
|
||||||
|
),
|
||||||
|
allowed_password_delivery_channels=frozenset(
|
||||||
|
decision.allowed_password_delivery_channels
|
||||||
|
),
|
||||||
|
policy_hash=decision.policy_hash,
|
||||||
|
source_path=tuple(step.to_dict() for step in decision.source_path),
|
||||||
|
reason=decision.reason or "Effective archive-encryption policy resolved.",
|
||||||
|
diagnostics=decision.diagnostics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_archive_encryption_allowed(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
raw_json: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal | None = None,
|
||||||
|
) -> EffectiveArchiveEncryptionPolicy:
|
||||||
|
policy = effective_archive_encryption_policy(session, campaign)
|
||||||
|
for archive in _archive_configs(raw_json):
|
||||||
|
method = str(archive.get("method") or "aes")
|
||||||
|
if method not in policy.allowed_password_encryption_methods:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{_method_label(method)} is blocked. {policy.reason}"
|
||||||
|
)
|
||||||
|
if method == "zip_standard":
|
||||||
|
if not policy.available:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
"Legacy ZipCrypto cannot be used while Policy is unavailable."
|
||||||
|
)
|
||||||
|
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||||
|
)
|
||||||
|
if not archive.get("legacy_zipcrypto_acknowledged"):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
|
||||||
|
)
|
||||||
|
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
|
||||||
|
)
|
||||||
|
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
|
||||||
|
"legacy_zipcrypto_acknowledged_at"
|
||||||
|
):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
|
||||||
|
)
|
||||||
|
if archive.get("password_enabled"):
|
||||||
|
# Existing campaign revisions predate the explicit field. Their
|
||||||
|
# model default is the separate-mail channel; apply the same
|
||||||
|
# normalization before policy enforcement so saved revisions do
|
||||||
|
# not become unusable merely because the field was omitted.
|
||||||
|
channel = str(
|
||||||
|
archive.get("password_delivery_channel") or "separate_mail"
|
||||||
|
)
|
||||||
|
if channel not in policy.allowed_password_delivery_channels:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"Password-delivery channel {channel!r} is blocked by the effective policy."
|
||||||
|
)
|
||||||
|
return policy
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
session: Session,
|
||||||
|
campaign: Campaign,
|
||||||
|
current_raw_json: Mapping[str, Any],
|
||||||
|
candidate_raw_json: dict[str, Any] | None,
|
||||||
|
*,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||||
|
if candidate_raw_json is None:
|
||||||
|
return None, []
|
||||||
|
candidate = copy.deepcopy(candidate_raw_json)
|
||||||
|
current_by_id = {
|
||||||
|
str(item.get("id") or index): item
|
||||||
|
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||||
|
}
|
||||||
|
acknowledgements: list[dict[str, Any]] = []
|
||||||
|
policy = effective_archive_encryption_policy(session, campaign)
|
||||||
|
archives = _archive_configs(candidate)
|
||||||
|
for index, archive in enumerate(archives):
|
||||||
|
if str(archive.get("method") or "aes") != "zip_standard":
|
||||||
|
archive.pop("legacy_zipcrypto_acknowledged_by", None)
|
||||||
|
archive.pop("legacy_zipcrypto_acknowledged_at", None)
|
||||||
|
continue
|
||||||
|
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||||
|
)
|
||||||
|
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
|
||||||
|
)
|
||||||
|
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
|
||||||
|
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
|
||||||
|
raise CampaignArchiveEncryptionError(
|
||||||
|
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
|
||||||
|
)
|
||||||
|
key = str(archive.get("id") or index)
|
||||||
|
previous = current_by_id.get(key, {})
|
||||||
|
unchanged = (
|
||||||
|
previous.get("method") == "zip_standard"
|
||||||
|
and previous.get("legacy_zipcrypto_acknowledged") is True
|
||||||
|
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
|
||||||
|
and previous.get("legacy_zipcrypto_acknowledged_by")
|
||||||
|
and previous.get("legacy_zipcrypto_acknowledged_at")
|
||||||
|
)
|
||||||
|
if unchanged:
|
||||||
|
archive["legacy_zipcrypto_acknowledged_by"] = previous[
|
||||||
|
"legacy_zipcrypto_acknowledged_by"
|
||||||
|
]
|
||||||
|
archive["legacy_zipcrypto_acknowledged_at"] = previous[
|
||||||
|
"legacy_zipcrypto_acknowledged_at"
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
|
||||||
|
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
|
||||||
|
acknowledgements.append(
|
||||||
|
{
|
||||||
|
"archive_id": key,
|
||||||
|
"reason": reason,
|
||||||
|
"policy_hash": policy.policy_hash,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return candidate, acknowledgements
|
||||||
|
|
||||||
|
|
||||||
|
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
|
||||||
|
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
attachments = raw_json.get("attachments")
|
||||||
|
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
|
||||||
|
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
|
||||||
|
if isinstance(archives, list):
|
||||||
|
return [item for item in archives if isinstance(item, dict)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _method_label(method: str) -> str:
|
||||||
|
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(value: object) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CampaignArchiveEncryptionError",
|
||||||
|
"EffectiveArchiveEncryptionPolicy",
|
||||||
|
"LEGACY_ZIPCRYPTO_LABEL",
|
||||||
|
"LEGACY_ZIPCRYPTO_SCOPE",
|
||||||
|
"assert_archive_encryption_allowed",
|
||||||
|
"effective_archive_encryption_policy",
|
||||||
|
"has_password_archives",
|
||||||
|
"stamp_legacy_zipcrypto_acknowledgements",
|
||||||
|
]
|
||||||
@@ -76,6 +76,14 @@ class ZipPasswordScope(StrEnum):
|
|||||||
GLOBAL = "global"
|
GLOBAL = "global"
|
||||||
|
|
||||||
|
|
||||||
|
class ZipPasswordDeliveryChannel(StrEnum):
|
||||||
|
SEPARATE_MAIL = "separate_mail"
|
||||||
|
SMS = "sms"
|
||||||
|
LETTER = "letter"
|
||||||
|
PHONE = "phone"
|
||||||
|
IN_PERSON = "in_person"
|
||||||
|
|
||||||
|
|
||||||
class ZipPasswordMode(StrEnum):
|
class ZipPasswordMode(StrEnum):
|
||||||
NONE = "none"
|
NONE = "none"
|
||||||
DIRECT = "direct"
|
DIRECT = "direct"
|
||||||
@@ -349,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
|
|||||||
password_field: str | None = None
|
password_field: str | None = None
|
||||||
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
||||||
method: ZipMethod = ZipMethod.AES
|
method: ZipMethod = ZipMethod.AES
|
||||||
|
password_delivery_channel: ZipPasswordDeliveryChannel = (
|
||||||
|
ZipPasswordDeliveryChannel.SEPARATE_MAIL
|
||||||
|
)
|
||||||
|
legacy_zipcrypto_acknowledged: bool = False
|
||||||
|
legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000)
|
||||||
|
legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255)
|
||||||
|
legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80)
|
||||||
|
|
||||||
# Compatibility fields for campaigns created by the first single-archive
|
# Compatibility fields for campaigns created by the first single-archive
|
||||||
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
||||||
@@ -376,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
|
|||||||
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig":
|
||||||
|
if self.method != ZipMethod.ZIP_STANDARD:
|
||||||
|
return self
|
||||||
|
if not self.legacy_zipcrypto_acknowledged:
|
||||||
|
raise ValueError(
|
||||||
|
"Legacy ZipCrypto requires explicit acknowledgement of its weak encryption"
|
||||||
|
)
|
||||||
|
if len((self.legacy_zipcrypto_reason or "").strip()) < 10:
|
||||||
|
raise ValueError(
|
||||||
|
"Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class ZipCollectionConfig(StrictModel):
|
class ZipCollectionConfig(StrictModel):
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -13,19 +15,35 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
CampaignScheduleOccurrence,
|
CampaignScheduleOccurrence,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
JobBuildStatus,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.approval_gate import (
|
||||||
|
assert_campaign_approval,
|
||||||
|
campaign_approval_gate,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||||
|
from govoplan_campaign.backend.integrations import mail_integration
|
||||||
from govoplan_campaign.backend.persistence.campaigns import (
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
create_campaign_version_from_json,
|
create_campaign_version_from_json,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
|
||||||
|
from govoplan_campaign.backend.sending.jobs import (
|
||||||
|
_from_header_from_job,
|
||||||
|
_send_job_delivery_context,
|
||||||
|
_single_job_validation_allowed,
|
||||||
|
_synchronous_smtp_batch_manager,
|
||||||
|
)
|
||||||
from govoplan_core.audit.logging import audit_event
|
from govoplan_core.audit.logging import audit_event
|
||||||
|
|
||||||
|
|
||||||
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
||||||
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
||||||
|
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
|
||||||
|
|
||||||
|
|
||||||
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
||||||
@@ -96,6 +114,11 @@ def dispatch_due_campaign_schedules(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
observed_at = _as_utc(now or datetime.now(UTC))
|
observed_at = _as_utc(now or datetime.now(UTC))
|
||||||
|
refreshed = refresh_autonomous_schedule_outcomes(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
now=observed_at,
|
||||||
|
)
|
||||||
query = session.query(CampaignSchedule).filter(
|
query = session.query(CampaignSchedule).filter(
|
||||||
CampaignSchedule.active.is_(True),
|
CampaignSchedule.active.is_(True),
|
||||||
CampaignSchedule.next_fire_at.is_not(None),
|
CampaignSchedule.next_fire_at.is_not(None),
|
||||||
@@ -112,39 +135,95 @@ def dispatch_due_campaign_schedules(
|
|||||||
result: dict[str, object] = {
|
result: dict[str, object] = {
|
||||||
"selected": len(schedules),
|
"selected": len(schedules),
|
||||||
"prepared": 0,
|
"prepared": 0,
|
||||||
|
"autonomous_prepared": 0,
|
||||||
"failed": 0,
|
"failed": 0,
|
||||||
"completed": 0,
|
"completed": 0,
|
||||||
"coalesced": 0,
|
"coalesced": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"deferred": 0,
|
||||||
"campaign_ids": [],
|
"campaign_ids": [],
|
||||||
"operator_actions": [],
|
"operator_actions": [],
|
||||||
|
"refreshed": refreshed,
|
||||||
}
|
}
|
||||||
for schedule in schedules:
|
for schedule in schedules:
|
||||||
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
||||||
|
if schedule.delivery_mode == "autonomous" and _has_open_occurrence(
|
||||||
|
session, schedule_id=schedule.id
|
||||||
|
):
|
||||||
|
result["deferred"] = int(result["deferred"]) + 1
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
with session.begin_nested():
|
with session.begin_nested():
|
||||||
campaign, version, skipped = _prepare_occurrence(
|
if schedule.delivery_mode == "autonomous":
|
||||||
|
_occurrence, skipped = _prepare_autonomous_occurrence(
|
||||||
session,
|
session,
|
||||||
schedule=schedule,
|
schedule=schedule,
|
||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
observed_at=observed_at,
|
observed_at=observed_at,
|
||||||
)
|
)
|
||||||
|
campaign_id = schedule.campaign_id
|
||||||
|
result["autonomous_prepared"] = (
|
||||||
|
int(result["autonomous_prepared"]) + 1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
campaign, _version, skipped = _prepare_occurrence(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
campaign_id = campaign.id
|
||||||
result["prepared"] = int(result["prepared"]) + 1
|
result["prepared"] = int(result["prepared"]) + 1
|
||||||
result["coalesced"] = int(result["coalesced"]) + skipped
|
result["coalesced"] = int(result["coalesced"]) + skipped
|
||||||
result["campaign_ids"].append(campaign.id) # type: ignore[union-attr]
|
result["campaign_ids"].append(campaign_id) # type: ignore[union-attr]
|
||||||
if not schedule.active:
|
if not schedule.active:
|
||||||
result["completed"] = int(result["completed"]) + 1
|
result["completed"] = int(result["completed"]) + 1
|
||||||
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
|
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
|
||||||
|
session.expire_all()
|
||||||
|
recorded = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||||
|
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if recorded is not None:
|
||||||
|
result["duplicates"] = int(result["duplicates"]) + 1
|
||||||
|
if (
|
||||||
|
schedule.active
|
||||||
|
and schedule.next_fire_at is not None
|
||||||
|
and _as_utc(schedule.next_fire_at) == scheduled_for
|
||||||
|
and recorded.status not in {"failed", "uncertain"}
|
||||||
|
):
|
||||||
|
_advance_schedule(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
occurrence=recorded,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
sequence=schedule.occurrence_count + 1,
|
||||||
|
)
|
||||||
|
continue
|
||||||
session.add(
|
session.add(
|
||||||
CampaignScheduleOccurrence(
|
CampaignScheduleOccurrence(
|
||||||
tenant_id=schedule.tenant_id,
|
tenant_id=schedule.tenant_id,
|
||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
status="failed",
|
status="failed",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(
|
||||||
|
schedule.id, scheduled_for
|
||||||
|
),
|
||||||
error=str(exc)[:4000],
|
error=str(exc)[:4000],
|
||||||
|
recovery_state="failed",
|
||||||
|
evidence={"delivery_mode": schedule.delivery_mode},
|
||||||
|
last_checked_at=observed_at,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
schedule.active = False
|
schedule.active = False
|
||||||
schedule.last_error = str(exc)[:4000]
|
schedule.last_error = str(exc)[:4000]
|
||||||
|
schedule.last_outcome = "failed"
|
||||||
|
schedule.last_recovery_state = "operator_required"
|
||||||
schedule.resource_revision += 1
|
schedule.resource_revision += 1
|
||||||
session.add(schedule)
|
session.add(schedule)
|
||||||
result["failed"] = int(result["failed"]) + 1
|
result["failed"] = int(result["failed"]) + 1
|
||||||
@@ -153,8 +232,14 @@ def dispatch_due_campaign_schedules(
|
|||||||
"schedule_id": schedule.id,
|
"schedule_id": schedule.id,
|
||||||
"campaign_id": schedule.campaign_id,
|
"campaign_id": schedule.campaign_id,
|
||||||
"reason": "draft_preparation_failed",
|
"reason": "draft_preparation_failed",
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason="policy_or_systemic_preflight_failed",
|
||||||
|
)
|
||||||
session.flush()
|
session.flush()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -234,41 +319,26 @@ def _prepare_occurrence(
|
|||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
scheduled_for=scheduled_for,
|
scheduled_for=scheduled_for,
|
||||||
status="prepared",
|
status="prepared",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
|
||||||
generated_campaign_id=generated_campaign.id,
|
generated_campaign_id=generated_campaign.id,
|
||||||
generated_version_id=generated_version.id,
|
generated_version_id=generated_version.id,
|
||||||
|
recovery_state="none",
|
||||||
|
evidence={"delivery_mode": "manual"},
|
||||||
|
last_checked_at=observed_at,
|
||||||
)
|
)
|
||||||
session.add(occurrence)
|
session.add(occurrence)
|
||||||
schedule.occurrence_count = sequence
|
session.flush()
|
||||||
schedule.last_fired_at = scheduled_for
|
|
||||||
schedule.last_campaign_id = generated_campaign.id
|
schedule.last_campaign_id = generated_campaign.id
|
||||||
schedule.last_error = None
|
schedule.last_outcome = "prepared"
|
||||||
|
schedule.last_recovery_state = "none"
|
||||||
next_fire = next_schedule_fire(
|
coalesced = _advance_schedule(
|
||||||
scheduled_for,
|
session,
|
||||||
recurrence_kind=schedule.recurrence_kind,
|
schedule=schedule,
|
||||||
interval_count=schedule.interval_count,
|
occurrence=occurrence,
|
||||||
timezone_name=schedule.timezone,
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
sequence=sequence,
|
||||||
)
|
)
|
||||||
coalesced = 0
|
|
||||||
while next_fire is not None and next_fire <= observed_at:
|
|
||||||
next_fire = next_schedule_fire(
|
|
||||||
next_fire,
|
|
||||||
recurrence_kind=schedule.recurrence_kind,
|
|
||||||
interval_count=schedule.interval_count,
|
|
||||||
timezone_name=schedule.timezone,
|
|
||||||
)
|
|
||||||
coalesced += 1
|
|
||||||
if (
|
|
||||||
next_fire is None
|
|
||||||
or sequence >= schedule.max_occurrences
|
|
||||||
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
|
|
||||||
):
|
|
||||||
schedule.active = False
|
|
||||||
schedule.next_fire_at = None
|
|
||||||
else:
|
|
||||||
schedule.next_fire_at = next_fire
|
|
||||||
schedule.resource_revision += 1
|
|
||||||
session.add(schedule)
|
|
||||||
audit_event(
|
audit_event(
|
||||||
session,
|
session,
|
||||||
tenant_id=schedule.tenant_id,
|
tenant_id=schedule.tenant_id,
|
||||||
@@ -291,6 +361,535 @@ def _prepare_occurrence(
|
|||||||
return generated_campaign, generated_version, coalesced
|
return generated_campaign, generated_version, coalesced
|
||||||
|
|
||||||
|
|
||||||
|
def validate_autonomous_schedule_source(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Validate the exact immutable execution that an autonomous schedule reuses."""
|
||||||
|
|
||||||
|
gate = campaign_approval_gate(version)
|
||||||
|
if gate is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires an explicit Approval request for the built source version."
|
||||||
|
)
|
||||||
|
assert_campaign_approval(session, tenant_id=campaign.tenant_id, version=version)
|
||||||
|
snapshot = ensure_execution_snapshot(session, version)
|
||||||
|
snapshot_hash = str(version.execution_snapshot_hash or "")
|
||||||
|
if len(snapshot_hash) != 64:
|
||||||
|
raise RuntimeError("The approved Campaign execution snapshot is incomplete.")
|
||||||
|
jobs = _autonomous_source_jobs(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
mail = mail_integration()
|
||||||
|
if not mail.durable_delivery_available:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires Mail's durable delivery-command outbox."
|
||||||
|
)
|
||||||
|
if not snapshot.mail_profile_id or not snapshot.smtp_transport_revision:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The approved Campaign execution has no immutable Mail transport evidence."
|
||||||
|
)
|
||||||
|
summary = mail.campaign_profile_delivery_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
profile_id=snapshot.mail_profile_id,
|
||||||
|
smtp_server_id=snapshot.smtp_server_id,
|
||||||
|
smtp_credential_id=snapshot.smtp_credential_id,
|
||||||
|
)
|
||||||
|
if not summary.get("smtp_available"):
|
||||||
|
raise RuntimeError("The approved Campaign Mail transport is unavailable.")
|
||||||
|
if summary.get("smtp_transport_revision") != snapshot.smtp_transport_revision:
|
||||||
|
raise RuntimeError(
|
||||||
|
"The Campaign Mail transport changed after approval; rebuild and approve a new source version."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"execution_snapshot_hash": snapshot_hash,
|
||||||
|
"approval_request_id": str(gate.get("request_id") or ""),
|
||||||
|
"approval_subject_digest": str(gate.get("subject_digest") or ""),
|
||||||
|
"job_count": len(jobs),
|
||||||
|
"job_manifest_sha256": canonical_configuration_hash(
|
||||||
|
{"jobs": [{"id": job.id, "eml_sha256": job.eml_sha256} for job in jobs]}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _autonomous_source_jobs(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version: CampaignVersion,
|
||||||
|
) -> list[CampaignJob]:
|
||||||
|
jobs = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.tenant_id == tenant_id,
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.campaign_version_id == version.id,
|
||||||
|
)
|
||||||
|
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not jobs:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires a built source version with recipient jobs."
|
||||||
|
)
|
||||||
|
for job in jobs:
|
||||||
|
if job.build_status != JobBuildStatus.BUILT.value:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires every source message to be built."
|
||||||
|
)
|
||||||
|
if not _single_job_validation_allowed(version, job, include_warnings=True):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous delivery requires every source message to pass its reviewed recipient and attachment gates."
|
||||||
|
)
|
||||||
|
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Autonomous schedules currently support Mail-only delivery; use manual mode for hybrid, Postbox, or print delivery."
|
||||||
|
)
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_autonomous_occurrence(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
scheduled_for: datetime,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> tuple[CampaignScheduleOccurrence, int]:
|
||||||
|
existing = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||||
|
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
raise RuntimeError("Campaign schedule occurrence was already recorded")
|
||||||
|
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||||
|
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||||
|
campaign = session.get(Campaign, schedule.campaign_id)
|
||||||
|
version = session.get(CampaignVersion, schedule.source_version_id)
|
||||||
|
if campaign is None or campaign.tenant_id != schedule.tenant_id:
|
||||||
|
raise RuntimeError("Campaign schedule source is no longer available")
|
||||||
|
if version is None or version.campaign_id != campaign.id:
|
||||||
|
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||||
|
validation = validate_autonomous_schedule_source(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not schedule.approved_execution_snapshot_hash
|
||||||
|
or validation["execution_snapshot_hash"]
|
||||||
|
!= schedule.approved_execution_snapshot_hash
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"The approved Campaign execution changed after the autonomous schedule was created."
|
||||||
|
)
|
||||||
|
jobs = _autonomous_source_jobs(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
occurrence_key = _occurrence_idempotency_key(schedule.id, scheduled_for)
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
status="preparing",
|
||||||
|
idempotency_key=occurrence_key,
|
||||||
|
recovery_state="prepared",
|
||||||
|
evidence={
|
||||||
|
"delivery_mode": "autonomous",
|
||||||
|
"source_campaign_id": campaign.id,
|
||||||
|
"source_version_id": version.id,
|
||||||
|
"source_snapshot_hash": schedule.source_snapshot_hash,
|
||||||
|
**validation,
|
||||||
|
},
|
||||||
|
last_checked_at=observed_at,
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
contexts = {job.id: _send_job_delivery_context(session, job) for job in jobs}
|
||||||
|
with _synchronous_smtp_batch_manager(session, jobs=jobs, contexts=contexts):
|
||||||
|
pass
|
||||||
|
|
||||||
|
mail = mail_integration()
|
||||||
|
commands: list[dict[str, object]] = []
|
||||||
|
for job in jobs:
|
||||||
|
context = contexts[job.id]
|
||||||
|
if context.envelope_from is None or not context.envelope_recipients:
|
||||||
|
raise RuntimeError("A frozen Campaign message has no delivery envelope.")
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(context.message_bytes)
|
||||||
|
commands.append(
|
||||||
|
mail.submit_delivery_command(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
command_type="campaign_schedule_occurrence",
|
||||||
|
source_module="campaigns",
|
||||||
|
source_resource_type="campaign",
|
||||||
|
source_resource_id=campaign.id,
|
||||||
|
source_version_id=version.id,
|
||||||
|
idempotency_key=f"{occurrence_key}:{job.id}",
|
||||||
|
profile_id=context.snapshot.mail_profile_id,
|
||||||
|
message_bytes=context.message_bytes,
|
||||||
|
envelope_from=context.envelope_from,
|
||||||
|
envelope_recipients=context.envelope_recipients,
|
||||||
|
from_header=_from_header_from_job(job) or str(message.get("From") or ""),
|
||||||
|
expected_smtp_transport_revision=(
|
||||||
|
context.snapshot.smtp_transport_revision or ""
|
||||||
|
),
|
||||||
|
smtp_server_id=context.snapshot.smtp_server_id,
|
||||||
|
smtp_credential_id=context.snapshot.smtp_credential_id,
|
||||||
|
created_by_user_id=schedule.created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
occurrence.delivery_command_ids = [str(item["id"]) for item in commands]
|
||||||
|
occurrence.status = "prepared"
|
||||||
|
occurrence.recovery_state = "pending"
|
||||||
|
occurrence.evidence = {
|
||||||
|
**occurrence.evidence,
|
||||||
|
"command_count": len(commands),
|
||||||
|
"duplicate_command_count": sum(bool(item.get("duplicate")) for item in commands),
|
||||||
|
"command_status_counts": _status_counts(commands),
|
||||||
|
}
|
||||||
|
occurrence.last_checked_at = observed_at
|
||||||
|
sequence = schedule.occurrence_count + 1
|
||||||
|
schedule.last_campaign_id = campaign.id
|
||||||
|
schedule.last_outcome = "prepared"
|
||||||
|
schedule.last_recovery_state = "pending"
|
||||||
|
coalesced = _advance_schedule(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
occurrence=occurrence,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
observed_at=observed_at,
|
||||||
|
sequence=sequence,
|
||||||
|
)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
user_id=schedule.created_by_user_id,
|
||||||
|
action="campaign.schedule.delivery_prepared",
|
||||||
|
object_type="campaign_schedule_occurrence",
|
||||||
|
object_id=occurrence.id,
|
||||||
|
details={
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"source_version_id": version.id,
|
||||||
|
"scheduled_for": scheduled_for.isoformat(),
|
||||||
|
"occurrence_idempotency_key": occurrence_key,
|
||||||
|
"delivery_command_count": len(commands),
|
||||||
|
"execution_snapshot_hash": validation["execution_snapshot_hash"],
|
||||||
|
"approval_request_id": validation["approval_request_id"],
|
||||||
|
"coalesced_missed_intervals": coalesced,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
return occurrence, coalesced
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_schedule(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
occurrence: CampaignScheduleOccurrence,
|
||||||
|
scheduled_for: datetime,
|
||||||
|
observed_at: datetime,
|
||||||
|
sequence: int,
|
||||||
|
) -> int:
|
||||||
|
schedule.occurrence_count = sequence
|
||||||
|
schedule.last_fired_at = scheduled_for
|
||||||
|
schedule.last_error = None
|
||||||
|
next_fire = next_schedule_fire(
|
||||||
|
scheduled_for,
|
||||||
|
recurrence_kind=schedule.recurrence_kind,
|
||||||
|
interval_count=schedule.interval_count,
|
||||||
|
timezone_name=schedule.timezone,
|
||||||
|
)
|
||||||
|
coalesced = 0
|
||||||
|
while next_fire is not None and next_fire <= observed_at:
|
||||||
|
session.add(
|
||||||
|
CampaignScheduleOccurrence(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=next_fire,
|
||||||
|
status="superseded",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(schedule.id, next_fire),
|
||||||
|
recovery_state="superseded",
|
||||||
|
evidence={
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
|
"reason": "coalesced_missed_interval",
|
||||||
|
"superseded_by_occurrence_id": occurrence.id,
|
||||||
|
},
|
||||||
|
last_checked_at=observed_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
next_fire = next_schedule_fire(
|
||||||
|
next_fire,
|
||||||
|
recurrence_kind=schedule.recurrence_kind,
|
||||||
|
interval_count=schedule.interval_count,
|
||||||
|
timezone_name=schedule.timezone,
|
||||||
|
)
|
||||||
|
coalesced += 1
|
||||||
|
if (
|
||||||
|
next_fire is None
|
||||||
|
or sequence >= schedule.max_occurrences
|
||||||
|
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
|
||||||
|
):
|
||||||
|
schedule.active = False
|
||||||
|
schedule.next_fire_at = None
|
||||||
|
else:
|
||||||
|
schedule.next_fire_at = next_fire
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
session.add(schedule)
|
||||||
|
return coalesced
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_autonomous_schedule_outcomes(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
observed_at = _as_utc(now or datetime.now(UTC))
|
||||||
|
query = session.query(CampaignScheduleOccurrence).filter(
|
||||||
|
CampaignScheduleOccurrence.status.in_(("prepared", "uncertain")),
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(CampaignScheduleOccurrence.tenant_id == tenant_id)
|
||||||
|
counts = {
|
||||||
|
"checked": 0,
|
||||||
|
"accepted": 0,
|
||||||
|
"uncertain": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
}
|
||||||
|
mail = mail_integration()
|
||||||
|
if not mail.durable_delivery_available:
|
||||||
|
for occurrence in query.order_by(
|
||||||
|
CampaignScheduleOccurrence.created_at
|
||||||
|
).limit(250):
|
||||||
|
if not occurrence.delivery_command_ids:
|
||||||
|
continue
|
||||||
|
counts["checked"] += 1
|
||||||
|
counts["uncertain"] += 1
|
||||||
|
_mark_occurrence_uncertain(
|
||||||
|
session,
|
||||||
|
occurrence=occurrence,
|
||||||
|
observed_at=observed_at,
|
||||||
|
reason="mail_delivery_outbox_unavailable",
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
for occurrence in query.order_by(CampaignScheduleOccurrence.created_at).limit(250):
|
||||||
|
if not occurrence.delivery_command_ids:
|
||||||
|
continue
|
||||||
|
summaries: list[dict[str, object]] = []
|
||||||
|
try:
|
||||||
|
summaries = [
|
||||||
|
mail.delivery_command_summary(
|
||||||
|
session,
|
||||||
|
tenant_id=occurrence.tenant_id,
|
||||||
|
command_id=command_id,
|
||||||
|
)
|
||||||
|
for command_id in occurrence.delivery_command_ids
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
counts["checked"] += 1
|
||||||
|
counts["uncertain"] += 1
|
||||||
|
_mark_occurrence_uncertain(
|
||||||
|
session,
|
||||||
|
occurrence=occurrence,
|
||||||
|
observed_at=observed_at,
|
||||||
|
reason="mail_delivery_status_unavailable",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
counts["checked"] += 1
|
||||||
|
outcome, recovery_state = _aggregate_command_outcome(summaries)
|
||||||
|
previous_outcome = occurrence.status
|
||||||
|
previous_recovery_state = occurrence.recovery_state
|
||||||
|
occurrence.status = outcome
|
||||||
|
occurrence.recovery_state = recovery_state
|
||||||
|
occurrence.last_checked_at = observed_at
|
||||||
|
occurrence.evidence = {
|
||||||
|
**(occurrence.evidence or {}),
|
||||||
|
"command_status_counts": _status_counts(summaries),
|
||||||
|
"accepted_recipient_count": sum(
|
||||||
|
int(item.get("accepted_count") or 0) for item in summaries
|
||||||
|
),
|
||||||
|
"refused_recipient_count": sum(
|
||||||
|
int(item.get("refused_count") or 0) for item in summaries
|
||||||
|
),
|
||||||
|
"failure_codes": sorted(
|
||||||
|
{
|
||||||
|
str(item["failure_code"])
|
||||||
|
for item in summaries
|
||||||
|
if item.get("failure_code")
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||||
|
if schedule is not None:
|
||||||
|
schedule.last_outcome = outcome
|
||||||
|
schedule.last_recovery_state = recovery_state
|
||||||
|
transitioned_to_operator_required = (
|
||||||
|
outcome in {"uncertain", "failed"}
|
||||||
|
and (
|
||||||
|
previous_outcome != outcome
|
||||||
|
or previous_recovery_state != recovery_state
|
||||||
|
or schedule.active
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if transitioned_to_operator_required:
|
||||||
|
schedule.active = False
|
||||||
|
schedule.last_error = (
|
||||||
|
"Autonomous delivery needs operator review; automatic recurrence is paused."
|
||||||
|
)
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason=f"delivery_{outcome}",
|
||||||
|
)
|
||||||
|
session.add(schedule)
|
||||||
|
session.add(occurrence)
|
||||||
|
if outcome in counts:
|
||||||
|
counts[outcome] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _mark_occurrence_uncertain(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
occurrence: CampaignScheduleOccurrence,
|
||||||
|
observed_at: datetime,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
previous_outcome = occurrence.status
|
||||||
|
previous_recovery_state = occurrence.recovery_state
|
||||||
|
occurrence.status = "uncertain"
|
||||||
|
occurrence.recovery_state = "operator_required"
|
||||||
|
occurrence.last_checked_at = observed_at
|
||||||
|
occurrence.evidence = {
|
||||||
|
**(occurrence.evidence or {}),
|
||||||
|
"recovery_reason": reason,
|
||||||
|
}
|
||||||
|
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||||
|
if schedule is not None:
|
||||||
|
transitioned = (
|
||||||
|
previous_outcome != "uncertain"
|
||||||
|
or previous_recovery_state != "operator_required"
|
||||||
|
or schedule.active
|
||||||
|
)
|
||||||
|
schedule.active = False
|
||||||
|
schedule.last_outcome = "uncertain"
|
||||||
|
schedule.last_recovery_state = "operator_required"
|
||||||
|
schedule.last_error = (
|
||||||
|
"Autonomous delivery status is unavailable; automatic recurrence is paused."
|
||||||
|
)
|
||||||
|
if transitioned:
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
session.add(schedule)
|
||||||
|
session.add(occurrence)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_open_occurrence(session: Session, *, schedule_id: str) -> bool:
|
||||||
|
rows = (
|
||||||
|
session.query(CampaignScheduleOccurrence.delivery_command_ids)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule_id,
|
||||||
|
CampaignScheduleOccurrence.status == "prepared",
|
||||||
|
)
|
||||||
|
.limit(1000)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return any(bool(command_ids) for (command_ids,) in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_command_outcome(
|
||||||
|
summaries: list[dict[str, object]],
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
statuses = {str(item.get("status") or "") for item in summaries}
|
||||||
|
if statuses and statuses <= {"accepted", "reconciled_accepted"}:
|
||||||
|
return "accepted", "complete"
|
||||||
|
if statuses and statuses <= {"reconciled_not_accepted"}:
|
||||||
|
return "skipped", "reconciled"
|
||||||
|
if statuses & {"outcome_unknown", "in_progress"}:
|
||||||
|
return "uncertain", "operator_required"
|
||||||
|
if statuses & {"permanent_failure", "partially_refused", "reconciled_not_accepted"}:
|
||||||
|
return "failed", "operator_required"
|
||||||
|
return "prepared", "pending"
|
||||||
|
|
||||||
|
|
||||||
|
def _status_counts(items: list[dict[str, object]]) -> dict[str, int]:
|
||||||
|
result: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
status = str(item.get("status") or "unknown")
|
||||||
|
result[status] = result.get(status, 0) + 1
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _occurrence_idempotency_key(schedule_id: str, scheduled_for: datetime) -> str:
|
||||||
|
return f"campaign-schedule:{schedule_id}:{_as_utc(scheduled_for).isoformat()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_schedule_operator(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
from govoplan_core.core.notifications import (
|
||||||
|
NotificationDispatchRequest,
|
||||||
|
notification_dispatch_provider,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.runtime import get_registry
|
||||||
|
|
||||||
|
provider = notification_dispatch_provider(get_registry())
|
||||||
|
if provider is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
provider.enqueue_notification(
|
||||||
|
session,
|
||||||
|
NotificationDispatchRequest(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
source_module="campaigns",
|
||||||
|
source_resource_type="campaign_schedule",
|
||||||
|
source_resource_id=schedule.id,
|
||||||
|
event_kind="campaign.schedule.operator_required",
|
||||||
|
channel="inbox",
|
||||||
|
recipient_type="user" if schedule.created_by_user_id else None,
|
||||||
|
recipient_id=schedule.created_by_user_id,
|
||||||
|
subject=f"Campaign schedule paused: {schedule.name}",
|
||||||
|
body_text=(
|
||||||
|
"Autonomous Campaign delivery was paused before another occurrence. "
|
||||||
|
"Review its recovery evidence before resuming."
|
||||||
|
),
|
||||||
|
action_url=f"/campaigns/{schedule.campaign_id}",
|
||||||
|
priority=2,
|
||||||
|
payload={"schedule_id": schedule.id, "reason": reason},
|
||||||
|
),
|
||||||
|
enqueue_delivery=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
def _copy_snapshot_shares(
|
def _copy_snapshot_shares(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -360,4 +959,6 @@ __all__ = [
|
|||||||
"canonical_configuration_hash",
|
"canonical_configuration_hash",
|
||||||
"dispatch_due_campaign_schedules",
|
"dispatch_due_campaign_schedules",
|
||||||
"next_schedule_fire",
|
"next_schedule_fire",
|
||||||
|
"refresh_autonomous_schedule_outcomes",
|
||||||
|
"validate_autonomous_schedule_source",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,730 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.loader import validate_against_schema
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignIssue,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignVersion,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
|
||||||
|
from govoplan_campaign.backend.response_security import (
|
||||||
|
public_campaign_configuration,
|
||||||
|
public_campaign_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PORTABLE_CAMPAIGN_FORMAT = "govoplan.campaign-portable"
|
||||||
|
PORTABLE_CAMPAIGN_FORMAT_VERSION = "1.0"
|
||||||
|
PORTABLE_CAMPAIGN_SCOPE_ORDER = (
|
||||||
|
"metadata",
|
||||||
|
"template_config",
|
||||||
|
"recipients",
|
||||||
|
"attachments",
|
||||||
|
"review_state",
|
||||||
|
"delivery_history",
|
||||||
|
)
|
||||||
|
DEFAULT_PORTABLE_CAMPAIGN_SCOPES = ("metadata", "template_config")
|
||||||
|
OPERATIONAL_EVIDENCE_SCOPES = frozenset(("review_state", "delivery_history"))
|
||||||
|
_CONFIG_STRUCTURAL_KEYS = frozenset(
|
||||||
|
("version", "campaign", "recipients", "entries", "attachments")
|
||||||
|
)
|
||||||
|
_SENSITIVE_SETTING_FRAGMENTS = (
|
||||||
|
"api_key",
|
||||||
|
"credential",
|
||||||
|
"password",
|
||||||
|
"private_key",
|
||||||
|
"secret",
|
||||||
|
"token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignTransferError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CampaignImportInspection:
|
||||||
|
preview: dict[str, Any]
|
||||||
|
configuration: dict[str, Any] | None
|
||||||
|
portable_settings: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_sha256(value: object) -> str:
|
||||||
|
encoded = json.dumps(
|
||||||
|
value,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
allow_nan=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
return hashlib.sha256(encoded).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_transfer_scopes(scopes: Iterable[str]) -> tuple[str, ...]:
|
||||||
|
selected = set(scopes)
|
||||||
|
invalid = sorted(selected.difference(PORTABLE_CAMPAIGN_SCOPE_ORDER))
|
||||||
|
if invalid:
|
||||||
|
raise CampaignTransferError(
|
||||||
|
f"Unsupported campaign transfer scope(s): {', '.join(invalid)}"
|
||||||
|
)
|
||||||
|
if not selected:
|
||||||
|
raise CampaignTransferError("Select at least one campaign transfer scope.")
|
||||||
|
return tuple(scope for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER if scope in selected)
|
||||||
|
|
||||||
|
|
||||||
|
def build_campaign_portable_package(
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
version: CampaignVersion,
|
||||||
|
scopes: Iterable[str],
|
||||||
|
jobs: Iterable[CampaignJob] = (),
|
||||||
|
issues: Iterable[CampaignIssue] = (),
|
||||||
|
module_version: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
selected = normalize_transfer_scopes(scopes)
|
||||||
|
configuration = public_campaign_configuration(version.raw_json)
|
||||||
|
if not isinstance(configuration, dict):
|
||||||
|
raise CampaignTransferError("The campaign configuration is not portable JSON.")
|
||||||
|
configuration, password_redactions = _redact_password_field_values(configuration)
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
item_counts: dict[str, int] = {}
|
||||||
|
redactions: Counter[str] = Counter(password_redactions)
|
||||||
|
|
||||||
|
if "metadata" in selected:
|
||||||
|
payload["metadata"] = {
|
||||||
|
"external_id": campaign.external_id,
|
||||||
|
"name": campaign.name,
|
||||||
|
"description": campaign.description,
|
||||||
|
"source_status": campaign.status,
|
||||||
|
}
|
||||||
|
item_counts["metadata"] = 1
|
||||||
|
|
||||||
|
if "template_config" in selected:
|
||||||
|
settings, setting_redactions = _redact_sensitive_settings(
|
||||||
|
campaign.settings or {}
|
||||||
|
)
|
||||||
|
mail_policy, mail_policy_redactions = _redact_sensitive_settings(
|
||||||
|
campaign.mail_profile_policy or {}
|
||||||
|
)
|
||||||
|
template_configuration = {
|
||||||
|
key: copy.deepcopy(value)
|
||||||
|
for key, value in configuration.items()
|
||||||
|
if key not in _CONFIG_STRUCTURAL_KEYS
|
||||||
|
}
|
||||||
|
server = template_configuration.get("server")
|
||||||
|
if isinstance(server, dict):
|
||||||
|
for key in ("smtp_credential_id", "imap_credential_id"):
|
||||||
|
if server.pop(key, None) is not None:
|
||||||
|
redactions["deployment_credential_reference"] += 1
|
||||||
|
payload["template_config"] = {
|
||||||
|
"schema_version": version.schema_version,
|
||||||
|
"configuration": template_configuration,
|
||||||
|
"campaign_settings": settings,
|
||||||
|
"mail_profile_policy": mail_policy,
|
||||||
|
}
|
||||||
|
redactions.update(setting_redactions)
|
||||||
|
redactions.update(mail_policy_redactions)
|
||||||
|
item_counts["template_config"] = len(template_configuration)
|
||||||
|
|
||||||
|
if "recipients" in selected:
|
||||||
|
entries = copy.deepcopy(configuration.get("entries") or {})
|
||||||
|
_remove_entry_attachments(entries)
|
||||||
|
payload["recipients"] = {
|
||||||
|
"recipients": copy.deepcopy(configuration.get("recipients") or {}),
|
||||||
|
"entries": entries,
|
||||||
|
}
|
||||||
|
item_counts["recipients"] = _recipient_entry_count(entries)
|
||||||
|
|
||||||
|
if "attachments" in selected:
|
||||||
|
entry_attachments = _entry_attachment_projection(
|
||||||
|
configuration.get("entries")
|
||||||
|
)
|
||||||
|
payload["attachments"] = {
|
||||||
|
"configuration": copy.deepcopy(configuration.get("attachments") or {}),
|
||||||
|
"entry_attachments": entry_attachments,
|
||||||
|
"content_included": False,
|
||||||
|
}
|
||||||
|
item_counts["attachments"] = _attachment_rule_count(
|
||||||
|
payload["attachments"]
|
||||||
|
)
|
||||||
|
|
||||||
|
issue_rows = tuple(issues)
|
||||||
|
if "review_state" in selected:
|
||||||
|
review_state = _review_state_projection(version, issue_rows)
|
||||||
|
payload["review_state"] = review_state
|
||||||
|
item_counts["review_state"] = int(review_state["decision_count"])
|
||||||
|
|
||||||
|
job_rows = tuple(jobs)
|
||||||
|
if "delivery_history" in selected:
|
||||||
|
payload["delivery_history"] = {
|
||||||
|
"jobs": [_delivery_job_projection(job) for job in job_rows],
|
||||||
|
"counts": _delivery_counts(job_rows),
|
||||||
|
}
|
||||||
|
item_counts["delivery_history"] = len(job_rows)
|
||||||
|
|
||||||
|
exported_at = datetime.now(UTC)
|
||||||
|
package: dict[str, Any] = {
|
||||||
|
"format": PORTABLE_CAMPAIGN_FORMAT,
|
||||||
|
"format_version": PORTABLE_CAMPAIGN_FORMAT_VERSION,
|
||||||
|
"package_id": str(uuid4()),
|
||||||
|
"exported_at": exported_at.isoformat(),
|
||||||
|
"source": {
|
||||||
|
"module": "campaigns",
|
||||||
|
"module_version": module_version,
|
||||||
|
"tenant_ref_sha256": hashlib.sha256(
|
||||||
|
campaign.tenant_id.encode("utf-8")
|
||||||
|
).hexdigest(),
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_external_id": campaign.external_id,
|
||||||
|
"campaign_name": campaign.name,
|
||||||
|
"version_id": version.id,
|
||||||
|
"version_number": version.version_number,
|
||||||
|
"campaign_schema_version": version.schema_version,
|
||||||
|
},
|
||||||
|
"scopes": list(selected),
|
||||||
|
"manifest": {
|
||||||
|
"item_counts": item_counts,
|
||||||
|
"redactions": dict(sorted(redactions.items())),
|
||||||
|
"privacy_default_scopes": list(DEFAULT_PORTABLE_CAMPAIGN_SCOPES),
|
||||||
|
"attachments_are_references_only": True,
|
||||||
|
"operational_evidence_is_not_replayed": True,
|
||||||
|
"secrets_included": False,
|
||||||
|
},
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
package["integrity"] = {
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"package_sha256": canonical_sha256(package),
|
||||||
|
}
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_campaign_portable_package(
|
||||||
|
package: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
selected_scopes: Iterable[str] | None,
|
||||||
|
external_id: str,
|
||||||
|
name: str,
|
||||||
|
) -> CampaignImportInspection:
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
package_dict = copy.deepcopy(dict(package))
|
||||||
|
package_id = _optional_text(package_dict.get("package_id"))
|
||||||
|
format_version = _optional_text(package_dict.get("format_version"))
|
||||||
|
source = package_dict.get("source")
|
||||||
|
source_dict = copy.deepcopy(source) if isinstance(source, dict) else {}
|
||||||
|
integrity = package_dict.get("integrity")
|
||||||
|
expected_hash = (
|
||||||
|
_optional_text(integrity.get("package_sha256"))
|
||||||
|
if isinstance(integrity, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
hash_input = copy.deepcopy(package_dict)
|
||||||
|
hash_input.pop("integrity", None)
|
||||||
|
actual_hash = canonical_sha256(hash_input)
|
||||||
|
|
||||||
|
if package_dict.get("format") != PORTABLE_CAMPAIGN_FORMAT:
|
||||||
|
errors.append("The file is not a GovOPlaN portable Campaign package.")
|
||||||
|
if format_version != PORTABLE_CAMPAIGN_FORMAT_VERSION:
|
||||||
|
errors.append(
|
||||||
|
"The Campaign package format version is not supported by this installation."
|
||||||
|
)
|
||||||
|
if not package_id:
|
||||||
|
errors.append("The Campaign package has no package identifier.")
|
||||||
|
if not expected_hash or expected_hash != actual_hash:
|
||||||
|
errors.append("The Campaign package integrity checksum does not match its content.")
|
||||||
|
if not isinstance(integrity, dict) or integrity.get("algorithm") != "sha256":
|
||||||
|
errors.append("The Campaign package does not use the supported SHA-256 integrity algorithm.")
|
||||||
|
if not source_dict:
|
||||||
|
errors.append("The Campaign package has no source provenance.")
|
||||||
|
elif source_dict.get("campaign_schema_version") != "1.0":
|
||||||
|
errors.append("The Campaign configuration schema version is not supported by this installation.")
|
||||||
|
|
||||||
|
available: tuple[str, ...] = ()
|
||||||
|
try:
|
||||||
|
raw_scopes = package_dict.get("scopes")
|
||||||
|
if not isinstance(raw_scopes, list):
|
||||||
|
raise CampaignTransferError("The Campaign package has no valid scope list.")
|
||||||
|
available = normalize_transfer_scopes(str(item) for item in raw_scopes)
|
||||||
|
except CampaignTransferError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
selected = normalize_transfer_scopes(
|
||||||
|
available if selected_scopes is None else selected_scopes
|
||||||
|
)
|
||||||
|
except CampaignTransferError as exc:
|
||||||
|
errors.append(str(exc))
|
||||||
|
selected = ()
|
||||||
|
unavailable = sorted(set(selected).difference(available))
|
||||||
|
if unavailable:
|
||||||
|
errors.append(
|
||||||
|
f"Selected scope(s) are absent from the package: {', '.join(unavailable)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = package_dict.get("payload")
|
||||||
|
payload_dict = payload if isinstance(payload, dict) else {}
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
errors.append("The Campaign package has no valid payload object.")
|
||||||
|
if not isinstance(package_dict.get("manifest"), dict):
|
||||||
|
errors.append("The Campaign package has no valid manifest.")
|
||||||
|
for scope in available:
|
||||||
|
if scope not in payload_dict:
|
||||||
|
errors.append(f"The Campaign package payload is missing scope '{scope}'.")
|
||||||
|
elif not isinstance(payload_dict[scope], dict):
|
||||||
|
errors.append(f"The Campaign package scope '{scope}' is not a valid object.")
|
||||||
|
|
||||||
|
template_scope = payload_dict.get("template_config")
|
||||||
|
if (
|
||||||
|
"template_config" in available
|
||||||
|
and isinstance(template_scope, dict)
|
||||||
|
and template_scope.get("schema_version") != "1.0"
|
||||||
|
):
|
||||||
|
errors.append("The portable template/configuration schema version is not supported.")
|
||||||
|
|
||||||
|
configuration: dict[str, Any] | None = None
|
||||||
|
portable_settings: dict[str, Any] = {}
|
||||||
|
will_create: list[dict[str, Any]] = []
|
||||||
|
will_skip: list[dict[str, Any]] = []
|
||||||
|
if not errors:
|
||||||
|
configuration, portable_settings, created, skipped, materialize_warnings = (
|
||||||
|
_materialize_import(
|
||||||
|
payload_dict,
|
||||||
|
available=available,
|
||||||
|
selected=selected,
|
||||||
|
external_id=external_id,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
will_create.extend(created)
|
||||||
|
will_skip.extend(skipped)
|
||||||
|
warnings.extend(materialize_warnings)
|
||||||
|
try:
|
||||||
|
validate_against_schema(configuration)
|
||||||
|
except Exception as exc:
|
||||||
|
errors.append(f"The imported Campaign configuration is incompatible: {exc}")
|
||||||
|
configuration = None
|
||||||
|
|
||||||
|
manifest = package_dict.get("manifest")
|
||||||
|
if isinstance(manifest, dict) and manifest.get("redactions"):
|
||||||
|
warnings.append(
|
||||||
|
"The source export redacted sensitive or deployment-bound values; review the package manifest and reconfigure them locally."
|
||||||
|
)
|
||||||
|
|
||||||
|
preview = {
|
||||||
|
"compatible": not errors,
|
||||||
|
"package_id": package_id,
|
||||||
|
"package_sha256": actual_hash,
|
||||||
|
"format_version": format_version,
|
||||||
|
"source": source_dict,
|
||||||
|
"available_scopes": list(available),
|
||||||
|
"selected_scopes": list(selected),
|
||||||
|
"destination": {
|
||||||
|
"external_id": external_id,
|
||||||
|
"name": name,
|
||||||
|
"status": "draft",
|
||||||
|
},
|
||||||
|
"will_create": will_create,
|
||||||
|
"will_skip": will_skip,
|
||||||
|
"warnings": list(dict.fromkeys(warnings)),
|
||||||
|
"errors": list(dict.fromkeys(errors)),
|
||||||
|
}
|
||||||
|
return CampaignImportInspection(
|
||||||
|
preview=preview,
|
||||||
|
configuration=configuration,
|
||||||
|
portable_settings=portable_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _materialize_import(
|
||||||
|
payload: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
available: tuple[str, ...],
|
||||||
|
selected: tuple[str, ...],
|
||||||
|
external_id: str,
|
||||||
|
name: str,
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, Any],
|
||||||
|
dict[str, Any],
|
||||||
|
list[dict[str, Any]],
|
||||||
|
list[dict[str, Any]],
|
||||||
|
list[str],
|
||||||
|
]:
|
||||||
|
selected_set = set(selected)
|
||||||
|
configuration = minimal_campaign_json(external_id=external_id, name=name)
|
||||||
|
portable_settings: dict[str, Any] = {}
|
||||||
|
created: list[dict[str, Any]] = [
|
||||||
|
_plan_item("metadata", "campaign_draft", "A new Campaign draft and editable version will be created.", 1)
|
||||||
|
]
|
||||||
|
skipped: list[dict[str, Any]] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if "metadata" in selected_set and isinstance(metadata, dict):
|
||||||
|
description = metadata.get("description")
|
||||||
|
if isinstance(description, str):
|
||||||
|
configuration["campaign"]["description"] = description
|
||||||
|
|
||||||
|
template_payload = payload.get("template_config")
|
||||||
|
if "template_config" in selected_set and isinstance(template_payload, dict):
|
||||||
|
source_configuration = template_payload.get("configuration")
|
||||||
|
if isinstance(source_configuration, dict):
|
||||||
|
for key, value in source_configuration.items():
|
||||||
|
if key in _CONFIG_STRUCTURAL_KEYS:
|
||||||
|
continue
|
||||||
|
configuration[key] = copy.deepcopy(value)
|
||||||
|
source_server = configuration.get("server")
|
||||||
|
if isinstance(source_server, dict) and source_server:
|
||||||
|
configuration["server"] = {}
|
||||||
|
skipped.append(
|
||||||
|
_plan_item(
|
||||||
|
"template_config",
|
||||||
|
"deployment_bound_mail_profile",
|
||||||
|
"Mail profile and server references are not applied across installations; select local Mail resources after import.",
|
||||||
|
len(source_server),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
settings = template_payload.get("campaign_settings")
|
||||||
|
if isinstance(settings, dict):
|
||||||
|
portable_settings = copy.deepcopy(settings)
|
||||||
|
created.append(
|
||||||
|
_plan_item(
|
||||||
|
"template_config",
|
||||||
|
"editable_configuration",
|
||||||
|
"Portable fields, template, delivery settings, and validation policy will be applied to the draft.",
|
||||||
|
len(source_configuration),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
recipients_payload = payload.get("recipients")
|
||||||
|
if "recipients" in selected_set and isinstance(recipients_payload, dict):
|
||||||
|
recipients = recipients_payload.get("recipients")
|
||||||
|
entries = recipients_payload.get("entries")
|
||||||
|
if isinstance(recipients, dict):
|
||||||
|
configuration["recipients"] = copy.deepcopy(recipients)
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
configuration["entries"] = copy.deepcopy(entries)
|
||||||
|
created.append(
|
||||||
|
_plan_item(
|
||||||
|
"recipients",
|
||||||
|
"recipient_rows",
|
||||||
|
"Campaign-local recipient rows and source provenance will be copied into the draft.",
|
||||||
|
_recipient_entry_count(entries),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
attachments_payload = payload.get("attachments")
|
||||||
|
if "attachments" in selected_set and isinstance(attachments_payload, dict):
|
||||||
|
attachment_configuration = attachments_payload.get("configuration")
|
||||||
|
if isinstance(attachment_configuration, dict):
|
||||||
|
configuration["attachments"] = copy.deepcopy(attachment_configuration)
|
||||||
|
per_entry = attachments_payload.get("entry_attachments")
|
||||||
|
applied_entry_rules = 0
|
||||||
|
if "recipients" in selected_set and isinstance(per_entry, list):
|
||||||
|
inline = configuration.get("entries", {}).get("inline", [])
|
||||||
|
if isinstance(inline, list):
|
||||||
|
for item in per_entry:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
index = item.get("entry_index")
|
||||||
|
rules = item.get("attachments")
|
||||||
|
if (
|
||||||
|
isinstance(index, int)
|
||||||
|
and 0 <= index < len(inline)
|
||||||
|
and isinstance(inline[index], dict)
|
||||||
|
and isinstance(rules, list)
|
||||||
|
):
|
||||||
|
inline[index]["attachments"] = copy.deepcopy(rules)
|
||||||
|
applied_entry_rules += len(rules)
|
||||||
|
elif isinstance(per_entry, list) and per_entry:
|
||||||
|
skipped.append(
|
||||||
|
_plan_item(
|
||||||
|
"attachments",
|
||||||
|
"recipient_scope_required",
|
||||||
|
"Per-recipient attachment rules are skipped unless recipient rows are also imported.",
|
||||||
|
sum(
|
||||||
|
len(item.get("attachments") or [])
|
||||||
|
for item in per_entry
|
||||||
|
if isinstance(item, dict)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
created.append(
|
||||||
|
_plan_item(
|
||||||
|
"attachments",
|
||||||
|
"attachment_references",
|
||||||
|
"Portable attachment rules will be applied; file content is never embedded in the package.",
|
||||||
|
_attachment_rule_count(attachments_payload) - max(0, _entry_rule_count(per_entry) - applied_entry_rules),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
warnings.append(
|
||||||
|
"Attachment rules contain references only. Reconnect or upload the required files and validate the draft before use."
|
||||||
|
)
|
||||||
|
|
||||||
|
for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER:
|
||||||
|
if scope not in OPERATIONAL_EVIDENCE_SCOPES:
|
||||||
|
continue
|
||||||
|
if scope in selected_set:
|
||||||
|
item_count = _manifest_scope_count(payload.get(scope))
|
||||||
|
skipped.append(
|
||||||
|
_plan_item(
|
||||||
|
scope,
|
||||||
|
"operational_evidence_not_replayed",
|
||||||
|
"Historical review or delivery evidence remains in the source package and import receipt but is never replayed as live Campaign state.",
|
||||||
|
item_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for scope in available:
|
||||||
|
if scope not in selected_set:
|
||||||
|
skipped.append(
|
||||||
|
_plan_item(
|
||||||
|
scope,
|
||||||
|
"scope_not_selected",
|
||||||
|
"This available package scope was not selected for import.",
|
||||||
|
_manifest_scope_count(payload.get(scope)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
campaign_metadata = configuration.get("campaign")
|
||||||
|
if not isinstance(campaign_metadata, dict):
|
||||||
|
raise CampaignTransferError("The imported Campaign metadata is invalid.")
|
||||||
|
campaign_metadata.update({"id": external_id, "name": name, "mode": "draft"})
|
||||||
|
return configuration, portable_settings, created, skipped, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _review_state_projection(
|
||||||
|
version: CampaignVersion, issues: tuple[CampaignIssue, ...]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||||
|
review = editor_state.get("review_send")
|
||||||
|
review = review if isinstance(review, dict) else {}
|
||||||
|
decisions = [
|
||||||
|
item
|
||||||
|
for item in (review.get("issue_decisions") or [])
|
||||||
|
if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
decision_evidence = [
|
||||||
|
{
|
||||||
|
"decision": item.get("decision"),
|
||||||
|
"issue_codes": sorted(str(code) for code in item.get("issue_codes") or []),
|
||||||
|
"issue_fingerprint": item.get("issue_fingerprint"),
|
||||||
|
"message_sha256": item.get("message_sha256"),
|
||||||
|
"reason_recorded": bool(str(item.get("reason") or "").strip()),
|
||||||
|
}
|
||||||
|
for item in decisions
|
||||||
|
]
|
||||||
|
issue_counts = Counter(str(issue.severity) for issue in issues)
|
||||||
|
return {
|
||||||
|
"workflow_state": version.workflow_state,
|
||||||
|
"inspection_complete": bool(review.get("inspection_complete")),
|
||||||
|
"reviewed_message_count": len(review.get("reviewed_message_keys") or []),
|
||||||
|
"decision_count": len(decisions),
|
||||||
|
"decision_evidence_sha256": canonical_sha256(decision_evidence),
|
||||||
|
"issue_counts": dict(sorted(issue_counts.items())),
|
||||||
|
"validation_summary": public_campaign_payload(version.validation_summary or {}),
|
||||||
|
"build_summary": public_campaign_payload(version.build_summary or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_job_projection(job: CampaignJob) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"job_id": job.id,
|
||||||
|
"entry_index": job.entry_index,
|
||||||
|
"entry_id": job.entry_id,
|
||||||
|
"recipient_email": job.recipient_email,
|
||||||
|
"message_id_header": job.message_id_header,
|
||||||
|
"message_sha256": job.eml_sha256,
|
||||||
|
"build_status": job.build_status,
|
||||||
|
"validation_status": job.validation_status,
|
||||||
|
"queue_status": job.queue_status,
|
||||||
|
"send_status": job.send_status,
|
||||||
|
"postbox_status": job.postbox_status,
|
||||||
|
"print_status": job.print_status,
|
||||||
|
"imap_status": job.imap_status,
|
||||||
|
"attempt_count": job.attempt_count,
|
||||||
|
"sent_at": _isoformat(job.sent_at),
|
||||||
|
"outcome_unknown_at": _isoformat(job.outcome_unknown_at),
|
||||||
|
"delivery_provenance": public_campaign_payload(job.delivery_provenance or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _delivery_counts(jobs: tuple[CampaignJob, ...]) -> dict[str, dict[str, int]]:
|
||||||
|
return {
|
||||||
|
field: dict(
|
||||||
|
sorted(Counter(str(getattr(job, field) or "unknown") for job in jobs).items())
|
||||||
|
)
|
||||||
|
for field in ("validation_status", "queue_status", "send_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_sensitive_settings(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], Counter[str]]:
|
||||||
|
redactions: Counter[str] = Counter()
|
||||||
|
|
||||||
|
def visit(item: Any) -> Any:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for raw_key, child in item.items():
|
||||||
|
key = str(raw_key)
|
||||||
|
normalized = key.lower().replace("-", "_")
|
||||||
|
if any(fragment in normalized for fragment in _SENSITIVE_SETTING_FRAGMENTS):
|
||||||
|
redactions["sensitive_setting"] += 1
|
||||||
|
continue
|
||||||
|
result[key] = visit(child)
|
||||||
|
return result
|
||||||
|
if isinstance(item, list):
|
||||||
|
return [visit(child) for child in item]
|
||||||
|
return copy.deepcopy(item)
|
||||||
|
|
||||||
|
return visit(dict(value)), redactions
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_password_field_values(
|
||||||
|
configuration: dict[str, Any],
|
||||||
|
) -> tuple[dict[str, Any], Counter[str]]:
|
||||||
|
result = copy.deepcopy(configuration)
|
||||||
|
password_fields = {
|
||||||
|
str(field.get("name"))
|
||||||
|
for field in result.get("fields") or []
|
||||||
|
if isinstance(field, dict)
|
||||||
|
and field.get("type") == "password"
|
||||||
|
and field.get("name")
|
||||||
|
}
|
||||||
|
redactions: Counter[str] = Counter()
|
||||||
|
if not password_fields:
|
||||||
|
return result, redactions
|
||||||
|
global_values = result.get("global_values")
|
||||||
|
if isinstance(global_values, dict):
|
||||||
|
for key in password_fields:
|
||||||
|
if global_values.pop(key, None) is not None:
|
||||||
|
redactions["password_field_value"] += 1
|
||||||
|
entries = result.get("entries")
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
for entry in entries.get("inline") or []:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
fields = entry.get("fields")
|
||||||
|
if not isinstance(fields, dict):
|
||||||
|
continue
|
||||||
|
for key in password_fields:
|
||||||
|
if fields.pop(key, None) is not None:
|
||||||
|
redactions["password_field_value"] += 1
|
||||||
|
return result, redactions
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_entry_attachments(entries: Any) -> None:
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
return
|
||||||
|
for entry in entries.get("inline") or []:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
entry["attachments"] = []
|
||||||
|
defaults = entries.get("defaults")
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
defaults["attachments"] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_attachment_projection(entries: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
return []
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for index, entry in enumerate(entries.get("inline") or []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
rules = entry.get("attachments")
|
||||||
|
if isinstance(rules, list) and rules:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"entry_index": index,
|
||||||
|
"attachments": copy.deepcopy(rules),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _recipient_entry_count(entries: Any) -> int:
|
||||||
|
if not isinstance(entries, dict):
|
||||||
|
return 0
|
||||||
|
inline = entries.get("inline")
|
||||||
|
return len(inline) if isinstance(inline, list) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_rule_count(value: Any) -> int:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return 0
|
||||||
|
configuration = value.get("configuration")
|
||||||
|
global_rules = (
|
||||||
|
configuration.get("global") if isinstance(configuration, dict) else []
|
||||||
|
)
|
||||||
|
return (len(global_rules) if isinstance(global_rules, list) else 0) + _entry_rule_count(
|
||||||
|
value.get("entry_attachments")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_rule_count(value: Any) -> int:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return 0
|
||||||
|
return sum(
|
||||||
|
len(item.get("attachments") or [])
|
||||||
|
for item in value
|
||||||
|
if isinstance(item, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_scope_count(value: Any) -> int:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return 0
|
||||||
|
if isinstance(value.get("jobs"), list):
|
||||||
|
return len(value["jobs"])
|
||||||
|
if "decision_count" in value:
|
||||||
|
return int(value.get("decision_count") or 0)
|
||||||
|
if "entries" in value:
|
||||||
|
return _recipient_entry_count(value.get("entries"))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_item(
|
||||||
|
scope: str, code: str, summary: str, item_count: int | None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope": scope,
|
||||||
|
"code": code,
|
||||||
|
"summary": summary,
|
||||||
|
"item_count": item_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_text(value: object) -> str | None:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _isoformat(value: datetime | None) -> str | None:
|
||||||
|
return value.isoformat() if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CampaignImportInspection",
|
||||||
|
"CampaignTransferError",
|
||||||
|
"DEFAULT_PORTABLE_CAMPAIGN_SCOPES",
|
||||||
|
"OPERATIONAL_EVIDENCE_SCOPES",
|
||||||
|
"PORTABLE_CAMPAIGN_FORMAT",
|
||||||
|
"PORTABLE_CAMPAIGN_FORMAT_VERSION",
|
||||||
|
"PORTABLE_CAMPAIGN_SCOPE_ORDER",
|
||||||
|
"build_campaign_portable_package",
|
||||||
|
"canonical_sha256",
|
||||||
|
"inspect_campaign_portable_package",
|
||||||
|
"normalize_transfer_scopes",
|
||||||
|
]
|
||||||
@@ -28,11 +28,14 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
AttachmentInstance,
|
AttachmentInstance,
|
||||||
CampaignIssue,
|
CampaignIssue,
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignCollaborationEntry,
|
||||||
CampaignJob,
|
CampaignJob,
|
||||||
CampaignMessageAction,
|
CampaignMessageAction,
|
||||||
CampaignMessageActionAttempt,
|
CampaignMessageActionAttempt,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
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_RESOURCE_TYPES = {
|
||||||
"campaign",
|
"campaign",
|
||||||
"campaign_object",
|
"campaign_object",
|
||||||
@@ -51,6 +63,18 @@ CAMPAIGN_VERSION_RESOURCE_TYPES = {
|
|||||||
"campaign_version",
|
"campaign_version",
|
||||||
"campaigns: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_RESOURCE_TYPES = {
|
||||||
"campaign_delivery_job",
|
"campaign_delivery_job",
|
||||||
"campaign_job",
|
"campaign_job",
|
||||||
@@ -558,6 +582,131 @@ class CampaignAccessService(CampaignAccessProvider):
|
|||||||
return tuple(items)
|
return tuple(items)
|
||||||
if normalized_type in CAMPAIGN_RESOURCE_TYPES:
|
if normalized_type in CAMPAIGN_RESOURCE_TYPES:
|
||||||
campaign = session.get(Campaign, resource_id) # type: ignore[attr-defined]
|
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:
|
elif normalized_type in CAMPAIGN_SHARE_RESOURCE_TYPES:
|
||||||
share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined]
|
share = session.get(CampaignShare, resource_id) # type: ignore[attr-defined]
|
||||||
if share is None:
|
if share is None:
|
||||||
|
|||||||
@@ -168,6 +168,171 @@ class CampaignShare(Base, TimestampMixin):
|
|||||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
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__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"orchestration_idempotency_key",
|
||||||
|
name="uq_campaign_work_assignment_orchestration_key",
|
||||||
|
),
|
||||||
|
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)
|
||||||
|
orchestration_idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True, index=True
|
||||||
|
)
|
||||||
|
orchestration_request_sha256: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True
|
||||||
|
)
|
||||||
|
orchestration_correlation_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(128), nullable=True, index=True
|
||||||
|
)
|
||||||
|
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=True
|
||||||
|
)
|
||||||
|
workflow_step_id: Mapped[str | None] = mapped_column(
|
||||||
|
String(36), nullable=True, index=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):
|
class CampaignSchedule(Base, TimestampMixin):
|
||||||
__tablename__ = "campaign_schedules"
|
__tablename__ = "campaign_schedules"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@@ -192,6 +357,12 @@ class CampaignSchedule(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
delivery_mode: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default="manual",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
recurrence_kind: Mapped[str] = mapped_column(
|
recurrence_kind: Mapped[str] = mapped_column(
|
||||||
String(20),
|
String(20),
|
||||||
default="once",
|
default="once",
|
||||||
@@ -210,6 +381,9 @@ class CampaignSchedule(Base, TimestampMixin):
|
|||||||
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||||
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
approved_execution_snapshot_hash: Mapped[str | None] = mapped_column(
|
||||||
|
String(64), nullable=True, index=True
|
||||||
|
)
|
||||||
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
last_campaign_id: Mapped[str | None] = mapped_column(
|
last_campaign_id: Mapped[str | None] = mapped_column(
|
||||||
@@ -218,6 +392,10 @@ class CampaignSchedule(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
last_outcome: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||||
|
last_recovery_state: Mapped[str | None] = mapped_column(
|
||||||
|
String(30), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CampaignScheduleOccurrence(Base, TimestampMixin):
|
class CampaignScheduleOccurrence(Base, TimestampMixin):
|
||||||
@@ -240,6 +418,9 @@ class CampaignScheduleOccurrence(Base, TimestampMixin):
|
|||||||
)
|
)
|
||||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
|
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(
|
||||||
|
String(200), nullable=True, index=True
|
||||||
|
)
|
||||||
generated_campaign_id: Mapped[str | None] = mapped_column(
|
generated_campaign_id: Mapped[str | None] = mapped_column(
|
||||||
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
@@ -251,6 +432,16 @@ class CampaignScheduleOccurrence(Base, TimestampMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
delivery_command_ids: Mapped[list[str]] = mapped_column(
|
||||||
|
JSON, default=list, nullable=False
|
||||||
|
)
|
||||||
|
recovery_state: Mapped[str] = mapped_column(
|
||||||
|
String(30), default="none", nullable=False, index=True
|
||||||
|
)
|
||||||
|
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RecipientImportMappingProfile(Base, TimestampMixin):
|
class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||||
@@ -775,6 +966,8 @@ __all__ = [
|
|||||||
"CampaignVersion",
|
"CampaignVersion",
|
||||||
"CampaignVersionFlow",
|
"CampaignVersionFlow",
|
||||||
"CampaignVersionWorkflowState",
|
"CampaignVersionWorkflowState",
|
||||||
|
"CampaignWorkAssignment",
|
||||||
|
"CampaignWorkAssignmentEvent",
|
||||||
"ImapAppendAttempt",
|
"ImapAppendAttempt",
|
||||||
"IssueSeverity",
|
"IssueSeverity",
|
||||||
"JobBuildStatus",
|
"JobBuildStatus",
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ from govoplan_campaign.backend.delivery_policy import (
|
|||||||
CampaignDeliveryPolicyError,
|
CampaignDeliveryPolicyError,
|
||||||
effective_synchronous_send_policy,
|
effective_synchronous_send_policy,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.german_documentation import (
|
||||||
|
localize_documentation_topics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_CAMPAIGN_USER_SCOPES = (
|
_CAMPAIGN_USER_SCOPES = (
|
||||||
@@ -13,6 +16,8 @@ _CAMPAIGN_USER_SCOPES = (
|
|||||||
"campaigns:campaign:create",
|
"campaigns:campaign:create",
|
||||||
"campaigns:campaign:update",
|
"campaigns:campaign:update",
|
||||||
"campaigns:campaign:copy",
|
"campaigns:campaign:copy",
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"campaigns:campaign:import",
|
||||||
"campaigns:campaign:archive",
|
"campaigns:campaign:archive",
|
||||||
"campaigns:campaign:delete",
|
"campaigns:campaign:delete",
|
||||||
"campaigns:campaign:share",
|
"campaigns:campaign:share",
|
||||||
@@ -25,6 +30,12 @@ _CAMPAIGN_USER_SCOPES = (
|
|||||||
"campaigns:campaign:send",
|
"campaigns:campaign:send",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
"campaigns:campaign:reconcile",
|
"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:read",
|
||||||
"campaigns:recipient:write",
|
"campaigns:recipient:write",
|
||||||
"campaigns:recipient:import",
|
"campaigns:recipient:import",
|
||||||
@@ -50,6 +61,9 @@ _TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
|
|||||||
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
||||||
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
||||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||||
|
_TASKS_INTEGRATION = "tasks.commands"
|
||||||
|
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
|
||||||
|
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
|
||||||
|
|
||||||
|
|
||||||
def _workflow_topic(
|
def _workflow_topic(
|
||||||
@@ -114,7 +128,7 @@ def _workflow_topic(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CAMPAIGN_USER_DOCUMENTATION = (
|
CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.create-campaign",
|
topic_id="campaigns.workflow.create-campaign",
|
||||||
title="Create a campaign",
|
title="Create a campaign",
|
||||||
@@ -195,6 +209,131 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.transfer-campaign-package",
|
||||||
|
title="Export and import a portable Campaign package",
|
||||||
|
summary="Move selected Campaign configuration into a separately owned draft with an integrity check, compatibility preview, and explicit privacy scopes.",
|
||||||
|
body="Portable Campaign packages are versioned JSON envelopes. Export defaults to metadata plus template/configuration and excludes recipients, attachments, review state, and delivery history until they are explicitly selected. Recipient and delivery scopes require their existing fine-grained export permissions. Transport secrets, credential references, password-field values, local storage paths, and attachment bytes are not exported. Import verifies the SHA-256 package integrity, previews every scope that will be created or skipped, and always creates a new editable draft. Deployment-bound Mail references must be selected locally. Review, approval, and delivery evidence remains historical package provenance and is never replayed as live state.",
|
||||||
|
order=33,
|
||||||
|
audience=("campaign_manager", "campaign_configurator", "campaign_migration_operator"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:export"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign overview and Campaign list",
|
||||||
|
help_contexts=("campaign.overview", "campaigns.action.export-package", "campaigns.action.import-package"),
|
||||||
|
prerequisites=(
|
||||||
|
"You may export the source Campaign; importing additionally requires Campaign create and portable-import authority.",
|
||||||
|
"Recipient and delivery scopes have an approved purpose and destination and the corresponding recipient/report export permissions.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open the source Campaign overview, select Export package, and keep the privacy-safe metadata plus template/configuration default unless more data is necessary.",
|
||||||
|
"Select any additional recipient, attachment, review, or delivery scopes explicitly and download the integrity-protected JSON package to an approved location.",
|
||||||
|
"On the destination Campaign list select Import package, choose the file, and review compatibility, redactions, destination identity, created scopes, and skipped evidence.",
|
||||||
|
"Change the destination identity or selected scopes as needed, refresh the preview, and create the draft only when the preview is current and compatible.",
|
||||||
|
"Open the draft, reconnect local Mail and file resources, validate recipients and attachments, and complete ordinary review before any delivery.",
|
||||||
|
),
|
||||||
|
outcome="A separately owned Campaign draft containing only the selected portable configuration, with source/package provenance and no replayed operational state.",
|
||||||
|
verification="The destination is a new draft with a distinct ID; its settings retain the package ID, SHA-256, source and created/skipped receipt, while Audit records the matching export/import hashes without storing package content.",
|
||||||
|
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.export-delivery-report"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Portables Campaign-Paket exportieren und importieren",
|
||||||
|
"summary": "Ausgewaehlte Campaign-Konfiguration mit Integritaetspruefung, Kompatibilitaetsvorschau und expliziten Datenschutzumfaengen in einen eigenstaendigen Entwurf uebernehmen.",
|
||||||
|
"body": "Portable Campaign-Pakete sind versionierte JSON-Umschlaege. Der Export umfasst standardmaessig nur Metadaten sowie Vorlage und Konfiguration. Empfaenger, Anlagen, Pruefstatus und Zustellhistorie werden erst nach expliziter Auswahl aufgenommen und bleiben getrennt berechtigt. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, lokale Speicherpfade und Dateiinhalte werden nicht exportiert. Der Import prueft die SHA-256-Integritaet, zeigt alle erzeugten und uebersprungenen Umfaenge und erstellt immer einen neuen bearbeitbaren Entwurf. Mail-Verweise muessen lokal neu gewaehlt werden; historische Pruef-, Freigabe- und Zustellnachweise werden nie als aktiver Zustand wiedergegeben.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_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 accept, complete, or reject their own work; rejection is distinct from manager cancellation. Managers can also reassign or cancel it. Workflow-opened work additionally retains the correlation, idempotency, Workflow instance and step, exact Campaign version, and emits a common revision-bearing lifecycle event for assignment, acceptance, start, reassignment, completion, rejection, or cancellation. Workflow rechecks Campaign access before it resumes; the assignment itself never grants access. 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.reject",
|
||||||
|
"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.",
|
||||||
|
"Accept and complete your own assignment, reject it explicitly when it cannot be taken on, 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. For Workflow-opened work, follow the focused assignment link and verify the exact terminal event and revision resume only the pinned Workflow step. 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. Zugewiesene Personen können Arbeit annehmen, abschließen oder ausdrücklich ablehnen; Ablehnung bleibt von einer administrativen Stornierung getrennt. Durch Workflow eröffnete Arbeit bewahrt Korrelation, Idempotenz, Workflow-Instanz und -Schritt sowie die genaue Kampagnenversion und erzeugt revisionsgebundene Lebenszyklusereignisse. Workflow prüft den Kampagnenzugriff vor der Fortsetzung erneut. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.reuse-content-library",
|
topic_id="campaigns.workflow.reuse-content-library",
|
||||||
title="Reuse Campaign content through Templates",
|
title="Reuse Campaign content through Templates",
|
||||||
@@ -236,9 +375,9 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
),
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.schedule-drafts",
|
topic_id="campaigns.workflow.schedule-drafts",
|
||||||
title="Schedule bounded recurring campaign drafts",
|
title="Schedule bounded manual or autonomous campaigns",
|
||||||
summary="Prepare fresh campaign drafts at a future time without bypassing validation, review, approval, or delivery controls.",
|
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
|
||||||
body="A Campaign schedule stores an integrity-sealed snapshot of the selected version, campaign policy, Mail profile policy, and optional shares, plus a bounded one-time, daily, weekly, or monthly recurrence. Each due occurrence creates a separately owned draft and an occurrence record. Missed intervals are coalesced instead of producing a catch-up storm. A schedule never validates, approves, queues, retries, or sends a campaign, and a preparation failure pauses it for operator review. Pause and resume reject stale browser state.",
|
body="Every schedule is bounded, timezone-aware, and fixed to either manual or autonomous mode. Manual mode stores an integrity-sealed configuration snapshot and prepares a separately owned draft per due occurrence without requiring Mail. Autonomous mode never rebuilds or silently changes approved content: it requires a built Mail-only source version with an explicit valid Approval request, seals its execution-snapshot hash, and rechecks approval, policy, credential selection, SMTP transport revision and live transport health, recipient gates, attachment evidence, and snapshot integrity before every occurrence. It then creates one Mail-owned durable command per frozen message with occurrence-scoped idempotency before delivery. Mail never automatically retries accepted or outcome-unknown effects. Campaign records prepared, accepted, uncertain, failed, skipped, and superseded recovery evidence; uncertain, policy, configuration, and systemic failures pause the recurrence and notify the accountable operator. Missed intervals are coalesced instead of causing a catch-up storm, and pause/resume rejects stale browser state.",
|
||||||
order=34,
|
order=34,
|
||||||
audience=("campaign_manager", "campaign_author", "operator"),
|
audience=("campaign_manager", "campaign_author", "operator"),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
||||||
@@ -249,24 +388,26 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
"Choose the exact campaign version whose configuration should seed future drafts.",
|
"Choose the exact campaign version whose configuration should seed future drafts.",
|
||||||
"Recipient data and active shares require their corresponding read or share authority.",
|
"Recipient data and active shares require their corresponding read or share authority.",
|
||||||
"A worker and scheduler process must be running for automatic due-time preparation.",
|
"A worker and scheduler process must be running for automatic due-time preparation.",
|
||||||
|
"Autonomous mode additionally requires campaigns:campaign:queue, campaigns:campaign:send, mail:profile:use, Mail's durable outbox, and an explicitly approved built source version.",
|
||||||
),
|
),
|
||||||
steps=(
|
steps=(
|
||||||
"Open the campaign overview and choose Schedule.",
|
"Open the campaign overview and choose Schedule.",
|
||||||
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
||||||
"Select which configuration domains may be copied and create the schedule.",
|
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
|
||||||
"Review each generated draft independently before validating, building, approving, and sending it.",
|
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
|
||||||
"Pause the schedule when the approved plan changes; a failed occurrence is paused automatically and remains visible as evidence.",
|
"For autonomous mode, confirm that the selected version is built, Mail-only, and explicitly approved; the API rejects missing or stale evidence.",
|
||||||
|
"Review next occurrence, last outcome, and recovery state. Resolve uncertain or failed Mail commands explicitly before creating or resuming a replacement schedule.",
|
||||||
),
|
),
|
||||||
outcome="A bounded sequence of independent campaign drafts with durable schedule and occurrence evidence.",
|
outcome="A bounded sequence of manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
|
||||||
verification="The Schedules section shows the next occurrence and generated count; each prepared occurrence links to a distinct draft with no delivery jobs or outcomes.",
|
verification="The Schedules section shows mode, next occurrence, last outcome, recovery state, and any automatic pause; manual occurrences link to distinct drafts while autonomous occurrences retain Mail command identifiers and non-secret outcome totals.",
|
||||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
translations={
|
translations={
|
||||||
"de": {
|
"de": {
|
||||||
"title": "Begrenzte wiederkehrende Kampagnenentwürfe planen",
|
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
|
||||||
"summary": "Künftige Kampagnenentwürfe vorbereiten, ohne Validierung, Prüfung, Freigabe oder Versandkontrollen zu umgehen.",
|
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
|
||||||
"body": "Ein Kampagnenzeitplan speichert einen integritätsgesicherten Stand der ausgewählten Version, Kampagnenrichtlinie, Mail-Profilrichtlinie und optionalen Freigaben sowie eine begrenzte einmalige, tägliche, wöchentliche oder monatliche Wiederholung. Jede fällige Ausführung erzeugt einen eigenständigen Entwurf und einen Ausführungsnachweis. Verpasste Intervalle werden zusammengefasst, statt unkontrolliert nachgeholt zu werden. Der Zeitplan validiert, genehmigt, startet, wiederholt oder versendet niemals eine Kampagne; ein Fehler pausiert ihn zur betrieblichen Prüfung. Pausieren und Fortsetzen weisen veraltete Browserstände zurück.",
|
"body": "Jeder Zeitplan ist begrenzt, zeitzonenfest und dauerhaft manuell oder autonom. Der manuelle Modus erzeugt eigenständige Entwürfe und funktioniert ohne Mail. Der autonome Modus verlangt eine gebaute, ausschließlich per Mail versendete und ausdrücklich freigegebene Quellversion. Vor jeder Ausführung werden Freigabe, Richtlinie, Zugangsdatenauswahl, Transportrevision und -erreichbarkeit, Empfänger, Anlagen und Snapshot-Integrität erneut geprüft. Pro eingefrorener Nachricht entsteht vor dem Versand ein dauerhafter Mail-Auftrag mit ausführungsspezifischem Idempotenzschlüssel. Angenommene oder unklare Ergebnisse werden nie automatisch wiederholt; unklare oder systemische Fehler pausieren den Zeitplan und benachrichtigen Verantwortliche. Verpasste Intervalle werden zusammengefasst und als Nachweis erhalten.",
|
||||||
"outcome": "Eine begrenzte Folge eigenständiger Kampagnenentwürfe mit dauerhaftem Zeitplan- und Ausführungsnachweis.",
|
"outcome": "Eine begrenzte Folge manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
|
||||||
"verification": "Der Abschnitt Zeitpläne zeigt die nächste Ausführung und die Zahl erzeugter Entwürfe; jede Ausführung verweist auf einen eigenen Entwurf ohne Versandaufträge oder Ergebnisse.",
|
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -556,7 +697,9 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
verification="The Campaign job shows accepted delivery, a mirrored Calendar event ID, and the current attendee status; repeated mailbox ingestion does not duplicate the response effect.",
|
verification="The Campaign job shows accepted delivery, a mirrored Calendar event ID, and the current attendee status; repeated mailbox ingestion does not duplicate the response effect.",
|
||||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.view-delivery-report"),
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.view-delivery-report"),
|
||||||
related_modules=("mail", "calendar"),
|
related_modules=("mail", "calendar"),
|
||||||
limitations=("Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests."),
|
limitations=(
|
||||||
|
"Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests.",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.queue-delivery",
|
topic_id="campaigns.workflow.queue-delivery",
|
||||||
@@ -811,7 +954,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
verification="Show archived displays the same version number and original workflow state with its archival timestamp.",
|
verification="Show archived displays the same version number and original workflow state with its archival timestamp.",
|
||||||
related_topic_ids=("campaigns.workflow.archive-campaign", "campaigns.workflow.view-delivery-report"),
|
related_topic_ids=("campaigns.workflow.archive-campaign", "campaigns.workflow.view-delivery-report"),
|
||||||
),
|
),
|
||||||
)
|
))
|
||||||
|
|
||||||
|
|
||||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||||
@@ -878,12 +1021,17 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
|
|||||||
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
|
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
|
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
|
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:export",), "Export privacy-scoped portable Campaign packages.")
|
||||||
|
_append_if(capabilities, principal, ("campaigns:campaign:import", "campaigns:campaign:create"), "Preview and import compatible portable Campaign packages as new drafts.", require_all=True)
|
||||||
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
|
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
|
||||||
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
|
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
|
||||||
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
|
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
_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: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: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:
|
if mail_available:
|
||||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
_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.")
|
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||||
@@ -991,6 +1139,17 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
|||||||
else:
|
else:
|
||||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
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(
|
calendar_available = _integration_available(
|
||||||
registry,
|
registry,
|
||||||
_CALENDAR_INVITATION_INTEGRATION,
|
_CALENDAR_INVITATION_INTEGRATION,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,244 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import DocumentationTopic
|
||||||
|
|
||||||
|
|
||||||
|
_TRANSLATIONS = {
|
||||||
|
"campaigns.workflow.create-campaign": {
|
||||||
|
"title": "Eine Kampagne anlegen",
|
||||||
|
"summary": "Eine gesteuerte Kampagne als bearbeitbaren Entwurf beginnen und Zweck sowie Eigentum vor Zustelldaten festlegen.",
|
||||||
|
"body": (
|
||||||
|
"Eine neue Kampagne beginnt mit einer bearbeitbaren Arbeitsversion. Die Aktionsleiste zeigt gespeichert, ungespeichert oder speichernd; Verwerfen steht direkt vor Speichern, und beim Verlassen eines geänderten Entwurfs greift der zentrale Speichern-oder-Verwerfen-Schutz. Destruktive Lebenszyklusaktionen sind von gewöhnlichen Aktionen getrennt. Das Anlegen gewährt keinen Zugriff auf Mail-Profile, verwaltete Dateien, Adressquellen oder Zustellaktionen; diese bleiben eigenständig autorisiert."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.create-editable-successor": {
|
||||||
|
"title": "Eine bearbeitbare Nachfolgeversion anlegen",
|
||||||
|
"summary": "Nach einer dauerhaften oder zustellungsbedingten Sperre weiterarbeiten, ohne die bewahrte Version umzuschreiben.",
|
||||||
|
"body": (
|
||||||
|
"„Bearbeitbare Kopie anlegen“ erzeugt die nächste Arbeitsversion der Kampagne. Validierungssperren und vorübergehende Benutzersperren werden dagegen an der bestehenden Version aufgehoben und dürfen keine parallelen Entwürfe erzeugen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.import-address-source": {
|
||||||
|
"title": "Eine Adressquelle importieren",
|
||||||
|
"summary": "Ein erlaubtes wiederverwendbares Adressbuch oder eine Liste als nachvollziehbaren versionierten Snapshot in die Kampagne kopieren.",
|
||||||
|
"body": (
|
||||||
|
"Campaign folgt der Adressquelle nicht live. Es speichert die ausgewählte Quellrevision und warnt bei einer neueren Revision. Eine erneute Übernahme ist deshalb immer eine ausdrückliche Aktion der verfassenden Person."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.import-distribution-list": {
|
||||||
|
"title": "Eine Verteilerliste übernehmen",
|
||||||
|
"summary": "Eine wiederverwendbare Zielgruppe auflösen, Kanal- und Policy-Entscheidungen prüfen und einen unveränderlichen Snapshot in die aktuelle Version kopieren.",
|
||||||
|
"body": (
|
||||||
|
"Eine Verteilerliste bleibt in ihrem verantwortlichen Modul live und versioniert. Campaign friert genau eine Auflösung ein; spätere Listen- oder Provideränderungen erzeugen nur eine Driftwarnung und schreiben gespeicherte Empfänger niemals um."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.import-recipients": {
|
||||||
|
"title": "Empfänger importieren",
|
||||||
|
"summary": "Text-, CSV- oder Tabellendaten mit Quellprovenienz in geprüfte kampagnenlokale Empfängerzeilen überführen.",
|
||||||
|
"body": (
|
||||||
|
"Der Import kopiert gültige Zeilen in die bearbeitbare Kampagnenversion. Ungültige Zeilen bleiben in der Vorschau sichtbar, statt still zu verschwinden. Spätere Änderungen der Quelldatei ändern die gespeicherte Kampagne nicht automatisch."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.prepare-printable-delivery": {
|
||||||
|
"title": "Eine druckbare Zustellung vorbereiten",
|
||||||
|
"summary": "Eine veröffentlichte Ausgabevorlage wählen, ein deterministisches Artefakt bauen und Route sowie Hash-Nachweis vor Post- oder Hauspostzustellung prüfen.",
|
||||||
|
"body": (
|
||||||
|
"Druckbare Zustellung ist optional und anbieterneutral. Campaign friert die Routenentscheidungen je Empfänger ein, während Templates Kompatibilität und Rendering verantwortet; Files kann das erzeugte Artefakt verwalten. Eine geordnete Ausweichroute wird nur nach bestätigter Ablehnung vor Annahme verwendet, niemals nach einer angenommenen oder im Ergebnis unbekannten digitalen Wirkung."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.use-managed-attachments": {
|
||||||
|
"title": "Verwaltete Dateien als Kampagnenanhänge verwenden",
|
||||||
|
"summary": "Gesteuerte Dateiversionen wählen, Regelzuordnungen prüfen und exakt verwendete Dateien im Build-Nachweis bewahren.",
|
||||||
|
"body": (
|
||||||
|
"Verwaltete Anhänge bleiben Eigentum von Files. Campaign speichert gesteuerte Referenzen und eingefrorene Build-Nachweise; es übernimmt keine Files-Administrationsbefugnis und akzeptiert keine beliebigen Serverpfade."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.queue-delivery": {
|
||||||
|
"title": "Eine Zustellung einreihen",
|
||||||
|
"summary": "Einen exakt geprüften Build in die Worker-Warteschlange stellen und Empfängerzustände sowie Wiederholungsschutz bewahren.",
|
||||||
|
"body": (
|
||||||
|
"Das Einreihen ist eine kontrollierte Zustandsänderung, kein Zustellnachweis. Gewöhnliche Stapel sollen Hintergrund-Worker verwenden. Angenommene und im Ergebnis unbekannte Wirkungen bleiben vor blinder Wiederholung geschützt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.send-calendar-invitations": {
|
||||||
|
"title": "Personalisierte Kalendereinladungen senden",
|
||||||
|
"summary": "Je Empfänger eine iCalendar-Anfrage einfrieren, über Mail zustellen und aktuelle Antworten aus Calendar prüfen.",
|
||||||
|
"body": (
|
||||||
|
"Campaign verantwortet Empfängerauflösung, exakte Einladungsanfrage, Zustellnachweis und Bericht. Calendar verantwortet gespiegeltes VEVENT und Antwortstatus. Der Spiegel entsteht erst, nachdem ein Kanal die Nachricht angenommen hat; ein Calendar-Fehler schreibt angenommenen Mail-Nachweis nie um. Mail kann METHOD:REPLY-Teile aus einer konfigurierten IMAP-Quelle für Zustellstatus weiterreichen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.send-small-controlled-run": {
|
||||||
|
"title": "Einen kleinen kontrollierten Lauf sofort senden",
|
||||||
|
"summary": "Geeignete Aufträge nur nach bewusster Bestätigung synchron ausführen, dass die geprüfte Kampagne klein genug ist.",
|
||||||
|
"body": (
|
||||||
|
"„Jetzt senden“ ist durch die wirksame maximale Anzahl von Empfängeraufträgen aus Deployment und Mandant geschützt. Der Server zählt den exakt gespeicherten geeigneten Build, lehnt einen zu großen oder leeren Lauf vor SMTP ab und prüft jede Nachricht sowie die Mail-Profilrevision vor der ersten Providerwirkung."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.view-aggregate-delivery-report": {
|
||||||
|
"title": "Aggregierte Kampagnenergebnisse prüfen",
|
||||||
|
"summary": "Datenschutzgeschützte Summen ohne Empfängerzeilen, Nachrichteninhalte, Zustelldiagnosen oder Exportbefugnis einsehen.",
|
||||||
|
"body": (
|
||||||
|
"Die aggregierte Berichtssicht zeigt nur freigegebene fachliche Kampagnenergebnisse. Positive Zellen unterhalb des konfigurierten Schwellwerts werden zusammen mit einem ergänzenden Wert oder erforderlichenfalls dem Nenner unterdrückt, damit kleine Gruppen nicht durch Subtraktion rekonstruiert werden können."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.view-delivery-report": {
|
||||||
|
"title": "Detaillierte Zustellergebnisse prüfen",
|
||||||
|
"summary": "Zustellsummen und empfängerbezogene Auftragsnachweise in der aktuellen Campaign-Berichtsoberfläche einsehen.",
|
||||||
|
"body": (
|
||||||
|
"Der empfängerbezogene Bericht erfordert Lesezugriff auf Kampagne, Bericht und Empfänger. Infrastrukturdiagnosen bleiben getrennt autorisiert; der Server prüft jede direkte Detailroute unabhängig von der Oberfläche."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.export-delivery-report": {
|
||||||
|
"title": "Zustellergebnisse exportieren",
|
||||||
|
"summary": "Einen autorisierten CSV-Snapshot empfängerbezogener Zustellergebnisse für kontrollierte Weiterverwendung herunterladen.",
|
||||||
|
"body": (
|
||||||
|
"Ein Berichtsexport enthält personenbezogene Daten und Zustellnachweise. Er ist entsprechend dem Kampagnenzweck sowie den geltenden Export- und Aufbewahrungsrichtlinien zu speichern, zu übertragen, aufzubewahren und zu löschen."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.share-campaign": {
|
||||||
|
"title": "Eine Kampagne freigeben",
|
||||||
|
"summary": "Einer Person oder Gruppe ausdrücklichen Lese- oder Schreibzugriff auf eine Kampagne geben, ohne Plattformberechtigungen auszuweiten.",
|
||||||
|
"body": (
|
||||||
|
"Eine Freigabe kann den Zugriff nur innerhalb der bestehenden Rolle auf die ausgewählte Kampagne eingrenzen. Sie gewährt niemals Mail-Profilnutzung, Files-Befugnisse, mandantenweiten Empfängerzugriff oder eine fehlende Campaign-Aktion."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.archive-campaign": {
|
||||||
|
"title": "Eine Kampagne archivieren",
|
||||||
|
"summary": "Eine abgeschlossene Kampagne aus der aktiven Arbeit entfernen und Versionen, Ergebnisse sowie Audit-Nachweise bewahren.",
|
||||||
|
"body": (
|
||||||
|
"Eine Kampagne darf erst archiviert werden, nachdem eingereihte, sendende und im Ergebnis unbekannte Arbeiten geklärt sind. Archivierung bewahrt Nachweise und ist für jede Kampagne mit Build-, Sperr- oder Zustellhistorie die richtige Lebenszyklusaktion."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.admin.collaboration-governance": {
|
||||||
|
"title": "Campaign-Zusammenarbeit und Aufbewahrung steuern",
|
||||||
|
"summary": "Diskussionszugriff getrennt von Kampagnenbearbeitung konfigurieren und auditierbare Moderations-Tombstones bewahren.",
|
||||||
|
"body": (
|
||||||
|
"Campaign-Zusammenarbeit verwendet neben dem Lesezugriff auf die Kampagne getrennte Berechtigungen zum Lesen, Schreiben und Moderieren. Die integrierte Managerrolle darf moderieren; Prüf- und Senderollen dürfen lesen und schreiben, ohne Bearbeitungsrechte zu erhalten. Eine Lesefreigabe genügt als übergeordnete Ressourcengewährung; Kommentare werten sie nicht auf. Nur für Moderationen sichtbare Inhalte werden serverseitig gefiltert. Beiträge besitzen keine Bearbeitungs-API. Rückzug und Schwärzung entfernen die Anzeige, erhalten jedoch stabilen Eintrag, SHA-256-Nachweis, Akteursnapshot, Zeitpunkt, typisierte Referenz, Tombstone und begrenztes Audit-Ereignis. Erwähnt werden dürfen nur aktive Personen mit Eigentums- oder Freigabezugriff. Optionale Notifications erhalten inhaltsfreie Hinweise; Providerfehler macht Notifications nicht zur Pflichtabhängigkeit. Institutionelle Aufbewahrungs- und Datenschutzrichtlinien müssen Kollaborationszeilen und Audit-Nachweise gemeinsam behandeln. Kommentare sind weder Freigaben noch Workflow-Übergänge oder Systemereignisse."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.delete-untouched-draft": {
|
||||||
|
"title": "Einen unberührten Kampagnenentwurf löschen",
|
||||||
|
"summary": "Einen Entwurf ohne geschützte Build-, Sperr-, Veröffentlichungs-, Snapshot- oder Zustellnachweise sofort entfernen.",
|
||||||
|
"body": (
|
||||||
|
"Löschen ist bewusst enger als Archivieren. Es markiert einen geeigneten Entwurf als gelöscht und erzeugt einen Audit-Eintrag. Eine Kampagne mit bereits aufbewahrungspflichtigen Nachweisen kann dadurch nicht entfernt werden."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.privacy.data-subject-requests": {
|
||||||
|
"title": "Campaign-Daten in einer Datenschutzanfrage prüfen",
|
||||||
|
"summary": "Empfänger-, Kollaborations-, Versions-, Zustell-, Berichts- und Artefaktmetadaten ermitteln, ohne unveränderliche Nachweise umzuschreiben.",
|
||||||
|
"body": (
|
||||||
|
"Der Campaign-DSAR-Anbieter sucht im wirksamen Mandanten nach normalisierter Empfänger-E-Mail, direkten Mitgliedschaftsreferenzen und namensraumbezogenen Campaign-Kennungen. Er isoliert passende Inline-Empfängerfelder und Auftragsmetadaten und meldet gebaute Versionen, Zustellversuche, Postbox- und Druckergebnisse, Korrekturen, empfängerbezogene Berichte, Nachrichtendigests, Anhangsmetadaten und betroffene Kollaboration. Eigener Beitragstext wird ausgegeben; fremder Text nicht allein wegen einer Erwähnung. EML-Bytes, Objekt- oder lokale Pfade, Providerziele, Worker-Claims, Idempotenzdaten, Geheimnisse, Zugangsdaten und fremde Empfängeradressen bleiben ausgeschlossen. Gebaute, gesperrte, veröffentlichte, abgeschlossene, zugestellte, korrigierte, zurückgezogene oder geschwärzte Datensätze bleiben begründet erhalten. Tombstones, Hashwerte und Audit-Nachweise sind unveränderlich. Entwurfsempfänger und benutzereigene Anhänge benötigen koordinierte manuelle Prüfung. Der Provider kann ein persönliches Import-Mappingprofil idempotent löschen und eine aktive Freigabe für die betroffene Person widerrufen; zugestellte Nachweise und erzeugte Artefakte werden nie direkt gelöscht."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.archive-historical-version": {
|
||||||
|
"title": "Eine historische Kampagnenversion archivieren",
|
||||||
|
"summary": "Eine nicht aktuelle Version aus der Standardhistorie ausblenden, ohne aufbewahrte Nachweise zu ändern oder zu löschen.",
|
||||||
|
"body": (
|
||||||
|
"Die Archivierung einer historischen Version betrifft nur ihre Darstellung. Ursprünglicher Workflow-Zustand, Konfiguration, Berichte, Zustellergebnisse und Audit-Nachweise bleiben für autorisierte Personen lesbar und werden bei eingeblendeten archivierten Versionen mitgeführt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.search.campaigns": {
|
||||||
|
"title": "Autorisierte Kampagnen durchsuchen",
|
||||||
|
"summary": "Kampagnenidentität und Lebenszyklusmetadaten für die berechtigungsbewusste Plattformsuche bereitstellen.",
|
||||||
|
"body": (
|
||||||
|
"Wenn Search installiert ist, trägt Campaign aktuelle Namen, externe Kennungen, Beschreibungen und Lebenszykluszustände bei. Vor einem Ergebnis werden Mandant, Eigentum, Gruppeneigentum, ausdrückliche Freigaben, Widerruf, Löschung und Campaign-Leseberechtigung erneut geprüft. Bestätigte Kampagnen- und Freigabeänderungen aktualisieren den abgeleiteten Index über den dauerhaften Plattform-Ereignispfad; ein Neuaufbau verändert keine Campaign-Nachweise."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.postbox-delivery": {
|
||||||
|
"title": "Campaign-Nachrichten an Postboxen zustellen",
|
||||||
|
"summary": "Je Empfängerzeile eine oder mehrere exakte oder organisationsabgeleitete Postboxen allein oder neben Mail adressieren.",
|
||||||
|
"body": (
|
||||||
|
"Konfiguriert werden kampagnenweite Ziele und optionale Ergänzungen oder Ersetzungen je Zeile. Abgeleitete Ziele lösen eine veröffentlichte Postbox-Vorlage mit Organisationseinheit, Funktion und optionalen Kontextwerten auf; Werte dürfen aus Campaign-Feldern stammen. Ziele werden beim Build eingefroren. Ein Ausweichen zum zweiten Kanal erfolgt nur nach bestätigter Ablehnung vor Annahme; angenommene oder im Ergebnis unbekannte Wirkungen lösen kein Fallback aus."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.mail-profile-user-journey": {
|
||||||
|
"title": "Ein Mail-Profil für die Kampagnenzustellung wählen",
|
||||||
|
"summary": "Campaign referenziert ein autorisiertes Mail-Profil und speichert niemals SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
||||||
|
"body": (
|
||||||
|
"In den Mail-Einstellungen der Kampagne wird ein verfügbares Profil ausgewählt, über Mail getestet und gespeichert. Validierung und Zustellung prüfen die Profilberechtigung erneut. Eine geänderte Transportidentität erfordert neue Validierung und neuen Build."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.mail-profile-governance": {
|
||||||
|
"title": "Campaign-zu-Mail-Profilreferenzen steuern",
|
||||||
|
"summary": "Mail besitzt Transportdefinitionen und verschlüsselte Zugangsdaten; Campaign nur die Profilreferenz und Zustellnachweise.",
|
||||||
|
"body": (
|
||||||
|
"Kampagnenverfassende erhalten mail:profile:use; verfügbare Profile werden über Mail-Policy begrenzt und wirksame Zugangsdatenvererbung bleibt aktiv. Inline-Transportfelder werden abgelehnt. Altbestände bleiben unverändert, bis eine ausdrückliche auditierte Profilmigration eine bearbeitbare Version erzeugt oder aktualisiert."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.mail-profile-operations": {
|
||||||
|
"title": "Profilbasierte Kampagnenzustellung betreiben",
|
||||||
|
"summary": "Worker autorisieren und lösen Mail-Profile bei Ausführung neu auf; Campaign bewahrt nur undurchsichtige Mail-Revisionen und Ergebnisse.",
|
||||||
|
"body": (
|
||||||
|
"Ein Altsnapshot, unautorisiertes oder inaktives Profil, Referenzkonflikt oder eine geänderte SMTP-/IMAP-Revision stoppt die Zustellung. Synchrone Stapel prüfen DNS, Verbindung, TLS und Authentifizierung vor der ersten Wirkung, verwenden eine begrenzte gesunde SMTP-Verbindung wieder und verbinden bei Alterung neu. Oberfläche und Bericht zeigen Stapel-, Verbindungs-, Wiederverbindungs-, Fehler- und Pausenzahlen. Systemische Authentifizierungs-, Absender- oder Verbindungsfehler pausieren übrige Aufträge mit stabilem Grund; das Profil ist zu korrigieren und zu testen, bevor ausdrücklich fortgesetzt wird. Verbindungsverlust nach Beginn von DATA bleibt ergebnisoffen und wird nicht automatisch wiederholt. Der Datensatz wird bewahrt, Profilwahl korrigiert, erneut validiert und gebaut und erst dann neu eingereiht. Reine Passwortrotation kopiert keine Geheimnisse nach Campaign. Unsichere SMTP-/IMAP-Wirkungen bleiben bis zum evidenzbasierten Betriebsabgleich blockiert. Wird Campaign nach Annahme eines Auftrags für den Mandanten unzugänglich, bleibt er unangetastet und wird als Betriebsaktion gemeldet."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.prepare-validate-and-build": {
|
||||||
|
"title": "Eine Kampagne vorbereiten, validieren und bauen",
|
||||||
|
"summary": "Gesteuerte Empfänger-, Vorlagen-, Anhangs- und Mail-Profil-Eingaben in exakte Nachrichten zur Prüfung überführen.",
|
||||||
|
"body": (
|
||||||
|
"Jede Eingabe wird in ihrer verantwortlichen Oberfläche vorbereitet, alle blockierenden Validierungsprobleme werden gelöst und exakte Empfängernachrichten vor der Prüfung gebaut. Empfängerzeilen können als eine ausdrücklich bestätigte Entwurfsänderung gesammelt aktiviert oder deaktiviert werden; Speichern erzeugt normale Versionsnachweise und verwirft veraltete Validierungs-, Build- und Prüfzustände. Passwortfelder verwenden den zentralen sicheren Generator, dessen Vorschlag erst nach „Passwort verwenden“ übernommen wird. Campaign friert Empfänger- und Anhangsnachweise für die ausgewählte Version ein; spätere Quelländerungen ändern den Build nicht. Kennzahlen bieten nur dann einen benannten Drill-down, wenn eine autorisierte Quellsammlung, gefilterte Prüftabelle, Anhangsvorschau oder ein Bericht eine Handlung ermöglicht. Datenschutzunterdrückte Aggregate bleiben nicht interaktiv. Ist Templates installiert, besitzt dessen einziger Navigationseintrag die wiederverwendbare Bibliothek; kampagnenspezifische Komposition bleibt im Arbeitsbereich."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.complete-review": {
|
||||||
|
"title": "Die Kampagnenprüfung abschließen",
|
||||||
|
"summary": "Kritische Blocker lösen, einzelne Nachrichten entscheiden und unkritische Punkte für genau einen Build bestätigen.",
|
||||||
|
"body": (
|
||||||
|
"Der Prüfabschluss bleibt an aktuellen Build-Token, geprüfte Nachrichtenschlüssel, dokumentierte Problementscheidungen und Nachrichtennachweise gebunden. Ausdrückliche Aktionen auf handlungsfähigen Empfänger-, Anhangs-, Validierungs- und Prüfkennzahlen öffnen Quellseite, Nachweisvorschau oder gefilterte Nachrichtentabelle. Reine Information und datenschutzunterdrückte Werte werden nicht zu versteckten Klickzielen. Änderungen an Empfängern, Inhalt, Anhängen, Eigentümerkontext oder nicht geheimer Transportidentität erfordern erneut Validierung, Build und Prüfung."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.retry-and-reconcile": {
|
||||||
|
"title": "Fehler wiederholen und unsichere Wirkungen abgleichen",
|
||||||
|
"summary": "Sicher wiederholbare Fehler von Mail-, Postbox- oder IMAP-Wirkungen mit unbekanntem Ergebnis trennen.",
|
||||||
|
"body": (
|
||||||
|
"Eine Wiederholung erzeugt neuen Versuchsnachweis und ist nur für ausdrücklich geeignete Zustände zulässig. Unbekannte Mail-, Postbox- oder IMAP-Wirkungen dürfen nie blind wiederholt werden. Externe Nachweise sind zu prüfen und der betroffene Kanal vor dem Fortsetzen abzugleichen. Angenommene Mail-Versuche und Postbox-Ziele bleiben bei Teilwiederholungen unveränderlich; die Reparatur von „Gesendet“ versendet angenommene Mail nicht erneut."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.reference.composition-assurance": {
|
||||||
|
"title": "Die Campaign-Referenzkomposition absichern",
|
||||||
|
"summary": "Campaign nur mit abgestimmten Verträgen, rollensicheren Oberflächen, dauerhaften Wirkungsnachweisen, optionaler Modultrennung und wiederherstellbaren Daten freigeben.",
|
||||||
|
"body": (
|
||||||
|
"Campaign ist nur dann Referenzkomposition, wenn Core, Mail, Files, Addresses, Worker, Speicher, Policies und Dokumentation in genau der installierten Kombination geprüft sind. Gewöhnliche Lesende sehen Fachzustand statt Pfaden, Speicherschlüsseln, Worker-Claims oder rohen Providerdiagnosen; Diagnose- und Exportbefugnis bleiben getrennt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.reference.shared-build-artifacts": {
|
||||||
|
"title": "Gemeinsam genutzte Campaign-Build-Artefakte betreiben",
|
||||||
|
"summary": "Erzeugte Nachrichten in gemeinsamem Objektspeicher ablegen und vor der Zustellung verifizieren.",
|
||||||
|
"body": (
|
||||||
|
"Campaign speichert erzeugte EML unter undurchsichtigen gemeinsamen Objektschlüsseln und protokolliert erwartete Größe, SHA-256-Digest und Message-ID je Auftrag. Worker auf anderen Knoten prüfen diesen Nachweis vor Zustellung. Vor Objekt- oder Files-Ausgaben zeichnet eine lease-gebundene Core-Recovery-Operation Quelle, validierte Version und reserviertes Präfix auf, prüft das Objekt und erneuert die Sperre vor dem Fach-Commit. Eine getrennte auftragsgebundene Operation erfasst vor realer Mail-, Postbox- oder Druckwirkung unveränderliche Nachrichten- und Empfängerdigests und verifiziert später den autoritativen Kanalversuch. Ablehnung, Annahme, unbekanntes Ergebnis und Recovery-Bedarf bleiben unterscheidbar. Objektfehler weisen Kompensation nach; Files-Ausgaben und unsichere Bereinigung bleiben Vorwärts-Recovery. Aufbewahrung ändert Locator kontrolliert und prüft Abwesenheit unabhängig. Ein reiner Betriebsabgleich inventarisiert begrenzte Mandantenpräfixe, schützt aktive Builds und mindestens 24 Stunden Karenz und löscht nur weiterhin unreferenzierte Objekte. Laufzeitobjektschlüssel sind keine Fachdaten."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.archive-encryption-governance": {
|
||||||
|
"title": "Passwortgeschützte ZIP-Anhänge gesteuert verwenden",
|
||||||
|
"summary": "Standardmäßig AES einsetzen und schwaches Windows-kompatibles ZipCrypto nur mit Policy, Berechtigung, Bestätigung und Nachweis wählen.",
|
||||||
|
"body": (
|
||||||
|
"Campaign löst Archivverschlüsselung über Policy auf System-, Mandanten-, Eigentümer- und Kampagnenebene auf. Passwortgeschützte Archive verwenden AES, außer die vollständig vererbte Richtlinie erlaubt Legacy ZipCrypto ausdrücklich und die handelnde Person besitzt campaigns:archive:use_legacy_zipcrypto. Die Legacy-Auswahl benötigt eine begründete Bestätigung. Passwörter erscheinen weder im Campaign-Nachweis noch in der Nachricht und müssen über den getrennt ausgewählten, per Policy erlaubten Kanal übermittelt werden. Jeder Build friert Archiv- und Mitglied-Hashes, Implementierungsversion, Policy-Hash und -Quellpfad, bestätigende Person, Begründung, Zeitpunkt und Build-Identität ein. Eine später strengere Policy blockiert Einreihen und Senden bis zum Neubau; nach Fehlern wird nie von AES auf ZipCrypto zurückgefallen. Temporärer Klartext und Archive bleiben im begrenzten Build-Verzeichnis und werden nach Erfolg oder Fehler entfernt."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"campaigns.workflow.link-exact-campaign-to-case": {
|
||||||
|
"title": "Eine exakte Kampagnenreferenz mit einem aktiven Fall verknüpfen",
|
||||||
|
"summary": "Eine autorisierte Kampagne und ihre aktuelle unveränderliche Version über Quick Access zurückgeben, ohne Kampagneninhalt zu kopieren.",
|
||||||
|
"body": (
|
||||||
|
"Ist ein Fall das aktive Objekt, stellt Campaigns in Quick Access eine begrenzte Auswahl bereit. Die normale Kampagnenliste prüft Mandant, Eigentum, Gruppe, Freigaben und Administrationszugriff vor der Anzeige. Die Auswahl liefert über den versionierten Ergebnisvertrag nur Eigentümermodul, stabile Kampagnen-ID, aktuelle Versions-ID, Anzeigetext, Mandant und Eigentümerroute. Cases verwirft den Anzeigetext und speichert keine Empfänger-, Nachrichten-, Anhangs-, Zustell-, Berichts- oder Konfigurationsinhalte. Beim Öffnen prüft Campaigns den Zugriff erneut. Deaktivierung, Widerruf oder Entfernung lässt daher nur eine nicht verfügbare historische Fallreferenz zurück und macht Fallzugriff nie zu Kampagnenzugriff."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def localize_documentation_topics(
|
||||||
|
topics: Iterable[DocumentationTopic],
|
||||||
|
) -> tuple[DocumentationTopic, ...]:
|
||||||
|
localized: list[DocumentationTopic] = []
|
||||||
|
for topic in topics:
|
||||||
|
german = _TRANSLATIONS.get(topic.id)
|
||||||
|
if german is None:
|
||||||
|
localized.append(topic)
|
||||||
|
continue
|
||||||
|
translations = {
|
||||||
|
locale: dict(value) for locale, value in topic.translations.items()
|
||||||
|
}
|
||||||
|
translations["de"] = {**translations.get("de", {}), **german}
|
||||||
|
localized.append(replace(topic, translations=translations))
|
||||||
|
return tuple(localized)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -74,11 +74,21 @@ class SmtpConfigurationError(RuntimeError):
|
|||||||
|
|
||||||
class SmtpSendError(RuntimeError):
|
class SmtpSendError(RuntimeError):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, message: str, *, temporary: bool = False, outcome_unknown: bool = False
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
temporary: bool = False,
|
||||||
|
outcome_unknown: bool = False,
|
||||||
|
systemic: bool = False,
|
||||||
|
reason_code: str | None = None,
|
||||||
|
phase: str = "send",
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.temporary = temporary
|
self.temporary = temporary
|
||||||
self.outcome_unknown = outcome_unknown
|
self.outcome_unknown = outcome_unknown
|
||||||
|
self.systemic = systemic
|
||||||
|
self.reason_code = reason_code
|
||||||
|
self.phase = phase
|
||||||
|
|
||||||
|
|
||||||
class ImapConfigurationError(RuntimeError):
|
class ImapConfigurationError(RuntimeError):
|
||||||
@@ -298,6 +308,9 @@ class MailCampaignIntegration:
|
|||||||
str(exc),
|
str(exc),
|
||||||
temporary=bool(getattr(exc, "temporary", False)),
|
temporary=bool(getattr(exc, "temporary", False)),
|
||||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
|
systemic=bool(getattr(exc, "systemic", False)),
|
||||||
|
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||||
|
phase=str(getattr(exc, "phase", "send") or "send"),
|
||||||
) from exc
|
) from exc
|
||||||
except getattr(
|
except getattr(
|
||||||
delegate, "SmtpConfigurationError", SmtpConfigurationError
|
delegate, "SmtpConfigurationError", SmtpConfigurationError
|
||||||
@@ -306,6 +319,30 @@ class MailCampaignIntegration:
|
|||||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
raise MailProfileError(str(exc)) from exc
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def campaign_smtp_batch(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
|
||||||
|
delegate = self._require()
|
||||||
|
method = getattr(delegate, "campaign_smtp_batch", None)
|
||||||
|
if not callable(method):
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with method(*args, **kwargs) as state:
|
||||||
|
yield state
|
||||||
|
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||||
|
raise SmtpSendError(
|
||||||
|
str(exc),
|
||||||
|
temporary=bool(getattr(exc, "temporary", False)),
|
||||||
|
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||||
|
systemic=bool(getattr(exc, "systemic", False)),
|
||||||
|
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||||
|
phase=str(getattr(exc, "phase", "preflight") or "preflight"),
|
||||||
|
) from exc
|
||||||
|
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||||
|
raise SmtpConfigurationError(str(exc)) from exc
|
||||||
|
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||||
|
raise MailProfileError(str(exc)) from exc
|
||||||
|
|
||||||
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
||||||
delegate = self._require()
|
delegate = self._require()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_campaign.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -14,6 +17,7 @@ from govoplan_core.core.campaigns import (
|
|||||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||||
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
|
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
|
||||||
from govoplan_core.core.module_guards import (
|
from govoplan_core.core.module_guards import (
|
||||||
@@ -36,6 +40,7 @@ from govoplan_core.core.modules import (
|
|||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
ProductAreaContribution,
|
ProductAreaContribution,
|
||||||
|
QuickAccessTool,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||||
@@ -49,6 +54,10 @@ from govoplan_core.core.templates import (
|
|||||||
CAPABILITY_TEMPLATE_RENDERER,
|
CAPABILITY_TEMPLATE_RENDERER,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
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.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -65,11 +74,25 @@ from govoplan_campaign.backend.documentation import (
|
|||||||
CAMPAIGN_USER_DOCUMENTATION,
|
CAMPAIGN_USER_DOCUMENTATION,
|
||||||
documentation_topics,
|
documentation_topics,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.german_documentation import (
|
||||||
|
localize_documentation_topics,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
|
||||||
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||||
|
from govoplan_campaign.backend.workflow_definitions import (
|
||||||
|
campaign_workflow_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
register_campaign_change_tracking()
|
register_campaign_change_tracking()
|
||||||
|
|
||||||
|
|
||||||
|
def _dsar_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_campaign.backend.dsar_provider import CampaignDsarProvider
|
||||||
|
|
||||||
|
return CampaignDsarProvider()
|
||||||
|
|
||||||
|
|
||||||
def _permission(
|
def _permission(
|
||||||
scope: str, label: str, description: str, category: str
|
scope: str, label: str, description: str, category: str
|
||||||
) -> PermissionDefinition:
|
) -> PermissionDefinition:
|
||||||
@@ -93,6 +116,42 @@ PERMISSIONS = (
|
|||||||
"Open campaign metadata, versions and permitted message summaries.",
|
"Open campaign metadata, versions and permitted message summaries.",
|
||||||
"Campaigns",
|
"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, including Workflow-opened hand-offs.",
|
||||||
|
"Campaign work",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:assignment:complete",
|
||||||
|
"Complete assigned campaign work",
|
||||||
|
"Accept, complete, or reject campaign work assigned to the current account, group, or organization function.",
|
||||||
|
"Campaign work",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:create",
|
"campaigns:campaign:create",
|
||||||
"Create campaigns",
|
"Create campaigns",
|
||||||
@@ -111,10 +170,22 @@ PERMISSIONS = (
|
|||||||
"Create campaigns or working versions from existing campaigns.",
|
"Create campaigns or working versions from existing campaigns.",
|
||||||
"Campaigns",
|
"Campaigns",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"Export portable campaigns",
|
||||||
|
"Create integrity-protected portable Campaign packages with explicitly selected data scopes.",
|
||||||
|
"Campaigns",
|
||||||
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:campaign:import",
|
||||||
|
"Import portable campaigns",
|
||||||
|
"Preview and create new Campaign drafts from compatible portable packages.",
|
||||||
|
"Campaigns",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:schedule",
|
"campaigns:campaign:schedule",
|
||||||
"Schedule campaign drafts",
|
"Schedule campaigns",
|
||||||
"Prepare fresh campaign drafts at a governed time or bounded recurrence.",
|
"Prepare manual drafts or opt in to approved autonomous Mail delivery at a governed time or bounded recurrence.",
|
||||||
"Campaigns",
|
"Campaigns",
|
||||||
),
|
),
|
||||||
_permission(
|
_permission(
|
||||||
@@ -159,6 +230,12 @@ PERMISSIONS = (
|
|||||||
"Build exact messages and attachment evidence.",
|
"Build exact messages and attachment evidence.",
|
||||||
"Campaigns",
|
"Campaigns",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:archive:use_legacy_zipcrypto",
|
||||||
|
"Use legacy ZipCrypto",
|
||||||
|
"Explicitly select weak Windows-compatible ZipCrypto when the effective policy permits it.",
|
||||||
|
"Campaign governance",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:review",
|
"campaigns:campaign:review",
|
||||||
"Complete campaign review",
|
"Complete campaign review",
|
||||||
@@ -283,10 +360,18 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:create",
|
"campaigns:campaign:create",
|
||||||
"campaigns:campaign:update",
|
"campaigns:campaign:update",
|
||||||
"campaigns:campaign:copy",
|
"campaigns:campaign:copy",
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"campaigns:campaign:import",
|
||||||
"campaigns:campaign:schedule",
|
"campaigns:campaign:schedule",
|
||||||
"campaigns:campaign:validate",
|
"campaigns:campaign:validate",
|
||||||
"campaigns:campaign:build",
|
"campaigns:campaign:build",
|
||||||
"campaigns:ownership:accept_group",
|
"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:read",
|
||||||
"campaigns:recipient:write",
|
"campaigns:recipient:write",
|
||||||
"campaigns:recipient:import",
|
"campaigns:recipient:import",
|
||||||
@@ -301,6 +386,10 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:read",
|
"campaigns:campaign:read",
|
||||||
"campaigns:campaign:validate",
|
"campaigns:campaign:validate",
|
||||||
"campaigns:campaign:review",
|
"campaigns:campaign:review",
|
||||||
|
"campaigns:discussion:read",
|
||||||
|
"campaigns:discussion:post",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
),
|
),
|
||||||
@@ -317,6 +406,10 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:send",
|
"campaigns:campaign:send",
|
||||||
"campaigns:campaign:retry",
|
"campaigns:campaign:retry",
|
||||||
"campaigns:campaign:reconcile",
|
"campaigns:campaign:reconcile",
|
||||||
|
"campaigns:discussion:read",
|
||||||
|
"campaigns:discussion:post",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
"campaigns:diagnostic:read",
|
"campaigns:diagnostic:read",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
@@ -370,7 +463,8 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.18",
|
version="0.1.27",
|
||||||
|
workflow_definitions=campaign_workflow_definitions(module_version="0.1.27"),
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -378,6 +472,10 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
CAPABILITY_APPROVAL_REQUESTS,
|
CAPABILITY_APPROVAL_REQUESTS,
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
|
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
|
||||||
|
CAPABILITY_ORGANIZATION_DIRECTORY,
|
||||||
|
CAPABILITY_TASK_COMMANDS,
|
||||||
),
|
),
|
||||||
optional_dependencies=(
|
optional_dependencies=(
|
||||||
"files",
|
"files",
|
||||||
@@ -391,18 +489,26 @@ manifest = ModuleManifest(
|
|||||||
"approvals",
|
"approvals",
|
||||||
"reporting",
|
"reporting",
|
||||||
"search",
|
"search",
|
||||||
|
"organizations",
|
||||||
|
"idm",
|
||||||
|
"tasks",
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.schedules", version="0.1.0"),
|
ModuleInterfaceProvider(name="campaigns.schedules", version="0.2.0"),
|
||||||
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name="campaigns.work_orchestration",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
ModuleInterfaceProvider(
|
ModuleInterfaceProvider(
|
||||||
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceProvider(name=CAMPAIGN_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -501,6 +607,30 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
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(
|
ModuleInterfaceRequirement(
|
||||||
name="search.source",
|
name="search.source",
|
||||||
version_min="1.0.0",
|
version_min="1.0.0",
|
||||||
@@ -590,11 +720,27 @@ manifest = ModuleManifest(
|
|||||||
"campaigns.route.operator-redirect",
|
"campaigns.route.operator-redirect",
|
||||||
OPERATOR_QUEUE_SURFACE_ID,
|
OPERATOR_QUEUE_SURFACE_ID,
|
||||||
REPORTS_SURFACE_ID,
|
REPORTS_SURFACE_ID,
|
||||||
|
"campaigns.page.work",
|
||||||
|
"campaigns.page.activity",
|
||||||
),
|
),
|
||||||
order=40,
|
order=40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
view_surfaces=(
|
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(
|
ViewSurface(
|
||||||
id="campaigns.widget.activity",
|
id="campaigns.widget.activity",
|
||||||
module_id="campaigns",
|
module_id="campaigns",
|
||||||
@@ -602,6 +748,32 @@ manifest = ModuleManifest(
|
|||||||
label="Campaign activity widget",
|
label="Campaign activity widget",
|
||||||
order=50,
|
order=50,
|
||||||
),
|
),
|
||||||
|
ViewSurface(
|
||||||
|
id="campaigns.quick_access.campaigns",
|
||||||
|
module_id="campaigns",
|
||||||
|
kind="quick_access",
|
||||||
|
label="Campaign selection",
|
||||||
|
order=55,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
quick_access_tools=(
|
||||||
|
QuickAccessTool(
|
||||||
|
id="campaigns.select",
|
||||||
|
module_id="campaigns",
|
||||||
|
category_id="campaigns",
|
||||||
|
label="Campaigns",
|
||||||
|
description="Select an authorized exact Campaign for the active Case.",
|
||||||
|
surface_id="campaigns.quick_access.campaigns",
|
||||||
|
icon="campaign",
|
||||||
|
full_page_path="/campaigns",
|
||||||
|
required_any=("campaigns:campaign:read",),
|
||||||
|
order=20,
|
||||||
|
modes=("select",),
|
||||||
|
availability="active_object",
|
||||||
|
accepted_reference_kinds=("cases.case",),
|
||||||
|
returned_reference_kinds=("campaigns.campaign",),
|
||||||
|
help_context_id="campaigns.quick_access.campaigns",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migration_spec=MigrationSpec(
|
migration_spec=MigrationSpec(
|
||||||
@@ -614,6 +786,7 @@ manifest = ModuleManifest(
|
|||||||
campaign_models.CampaignSchedule,
|
campaign_models.CampaignSchedule,
|
||||||
campaign_models.CampaignScheduleOccurrence,
|
campaign_models.CampaignScheduleOccurrence,
|
||||||
campaign_models.CampaignShare,
|
campaign_models.CampaignShare,
|
||||||
|
campaign_models.CampaignCollaborationEntry,
|
||||||
campaign_models.RecipientImportMappingProfile,
|
campaign_models.RecipientImportMappingProfile,
|
||||||
campaign_models.CampaignVersion,
|
campaign_models.CampaignVersion,
|
||||||
campaign_models.CampaignJob,
|
campaign_models.CampaignJob,
|
||||||
@@ -636,6 +809,7 @@ manifest = ModuleManifest(
|
|||||||
campaign_models.CampaignSchedule,
|
campaign_models.CampaignSchedule,
|
||||||
campaign_models.CampaignScheduleOccurrence,
|
campaign_models.CampaignScheduleOccurrence,
|
||||||
campaign_models.CampaignShare,
|
campaign_models.CampaignShare,
|
||||||
|
campaign_models.CampaignCollaborationEntry,
|
||||||
campaign_models.RecipientImportMappingProfile,
|
campaign_models.RecipientImportMappingProfile,
|
||||||
campaign_models.CampaignVersion,
|
campaign_models.CampaignVersion,
|
||||||
campaign_models.CampaignJob,
|
campaign_models.CampaignJob,
|
||||||
@@ -651,8 +825,227 @@ manifest = ModuleManifest(
|
|||||||
label="Campaigns",
|
label="Campaigns",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=localize_documentation_topics((
|
||||||
*CAMPAIGN_USER_DOCUMENTATION,
|
*CAMPAIGN_USER_DOCUMENTATION,
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.workflow.link-exact-campaign-to-case",
|
||||||
|
title="Link an exact Campaign reference to an active Case",
|
||||||
|
summary=(
|
||||||
|
"Return an authorized Campaign and current immutable version through "
|
||||||
|
"Quick Access without copying campaign content."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"When a Case is the active object, Campaigns contributes a bounded "
|
||||||
|
"Quick Access selector. The normal Campaign list endpoint applies the "
|
||||||
|
"current actor's tenant, owner, group, share, and administration access "
|
||||||
|
"before candidates appear. Selecting a Campaign returns only its owner "
|
||||||
|
"module, stable Campaign ID, current version ID, display label, tenant, "
|
||||||
|
"and owner route through the versioned result contract. Cases discards "
|
||||||
|
"the label and stores no recipient, message, attachment, delivery, report, "
|
||||||
|
"or campaign configuration content. Opening the reference enters Campaigns "
|
||||||
|
"and rechecks current access. Campaigns disabled, access revoked, or the "
|
||||||
|
"source removed therefore leaves only an unavailable historical Case "
|
||||||
|
"reference; it never turns Case access into Campaign access."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("campaign_manager", "case_manager", "operator", "module_admin"),
|
||||||
|
related_modules=("cases", "quick_access"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "cases"),
|
||||||
|
required_scopes=("campaigns:campaign:read",),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Campaigns",
|
||||||
|
href="/campaigns",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": ["campaigns.quick_access.campaigns"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.admin.portable-transfer-governance",
|
||||||
|
title="Govern portable Campaign export and import",
|
||||||
|
summary="Separate configuration portability from recipient and delivery-data export, and verify every import as a new draft.",
|
||||||
|
body=(
|
||||||
|
"Portable Campaign export and import use separate campaign-level permissions. The built-in Campaign manager can move configuration, but recipient rows additionally require recipient read/export on export and recipient write/import on import. Review-state export requires report read; recipient-level delivery history requires report export plus recipient read/export. The UI and API default export to metadata plus template/configuration only. Every package records its format, source Campaign/version, selected scopes, item counts, redaction counts, and SHA-256 integrity digest. Campaign removes transport secrets, credential-envelope references, password-field values, infrastructure paths, and attachment bytes. Import fails closed on format, checksum, schema, scope, or destination-ID conflicts; its preview identifies every created and skipped domain. It always creates a separately owned draft, clears deployment-bound Mail references, and never recreates shares, locks, approvals, review decisions, delivery jobs, attempts, or sent state. The destination retains a bounded import receipt and matching Audit evidence. Operators must govern downloaded package storage and deletion outside GovOPlaN according to the selected data scopes."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("module_admin", "security_reviewer", "privacy_officer", "campaign_manager"),
|
||||||
|
order=40,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=(
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"campaigns:campaign:import",
|
||||||
|
"access:roles:manage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Campaign handbook",
|
||||||
|
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "files", "mail"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Portablen Campaign-Export und -Import steuern",
|
||||||
|
"summary": "Konfigurationsportabilitaet vom Export von Empfaenger- und Zustelldaten trennen und jeden Import als neuen Entwurf pruefen.",
|
||||||
|
"body": (
|
||||||
|
"Portabler Campaign-Export und -Import verwenden getrennte Campaign-Berechtigungen. Empfaengerzeilen erfordern beim Export zusaetzlich Empfaenger-Lese- und Exportrecht sowie beim Import Empfaenger-Schreib- und Importrecht. Pruefstatus erfordert Berichtsleserecht; Zustellhistorie erfordert Berichtsexport sowie Empfaenger-Lese- und Exportrecht. Standardmaessig werden nur Metadaten sowie Vorlage und Konfiguration exportiert. Jedes Paket enthaelt Format, Quelle, ausgewaehlte Umfaenge, Zaehler, Redaktionen und SHA-256-Integritaet. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, Infrastrukturpfade und Dateiinhalte werden entfernt. Der Import schlaegt bei Format-, Pruefsummen-, Schema-, Umfangs- oder Kennungskonflikten geschlossen fehl und erstellt immer einen eigenstaendigen Entwurf. Freigaben, Sperren, Genehmigungen, Pruefentscheidungen, Zustellauftraege und Sendezustaende werden nie wiedergegeben."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "configuration",
|
||||||
|
"route": "/campaigns",
|
||||||
|
"screen": "Campaign portable transfer",
|
||||||
|
"help_contexts": [
|
||||||
|
"campaigns.action.export-package",
|
||||||
|
"campaigns.action.import-package",
|
||||||
|
],
|
||||||
|
"permission_scopes": [
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"campaigns:campaign:import",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
],
|
||||||
|
"privacy_default_scopes": ["metadata", "template_config"],
|
||||||
|
"verification": "Export the default scopes as a Campaign manager, verify a recipient scope is denied without recipient-export, tamper with the JSON and verify preview rejects it, then import a valid package and confirm a new draft plus matching Audit hashes without jobs or approval state.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
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, 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, 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",),
|
||||||
|
audience=("privacy_officer", "campaign_manager", "records_manager", "operator"),
|
||||||
|
order=42,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns", "access"),
|
||||||
|
any_scopes=(
|
||||||
|
"access:privacy:read",
|
||||||
|
"access:privacy:manage",
|
||||||
|
"access:privacy:erase",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Data-subject requests",
|
||||||
|
href="/admin?section=tenant-data-subject-requests",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Campaign handbook",
|
||||||
|
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "files", "mail", "postbox", "reporting"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=tenant-data-subject-requests",
|
||||||
|
"screen": "Data-subject requests",
|
||||||
|
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||||
|
"prerequisites": [
|
||||||
|
"The privacy request and recipient selectors have been independently authorized and corroborated.",
|
||||||
|
"The reviewer understands the effective Campaign retention policy and delivery-evidence obligations.",
|
||||||
|
],
|
||||||
|
"steps": [
|
||||||
|
"Run the Campaign provider search and review recipient, version, job, attempt, report-projection, and artifact dispositions.",
|
||||||
|
"Inspect matching draft content manually and keep every evidence retention reason with the case decision.",
|
||||||
|
"Execute only an approved user-owned mapping deletion or subject-targeted share revocation.",
|
||||||
|
"Use Campaign retention and artifact reconciliation for approved content redaction or expiry; do not mutate delivered evidence ad hoc.",
|
||||||
|
],
|
||||||
|
"limitations": [
|
||||||
|
"Generated EML bytes and attachment content are not embedded in the JSON export; authorized Campaign or Files review paths remain authoritative.",
|
||||||
|
"Draft recipient erasure is manual until a coordinated version/job/artifact rewrite contract can prove that no partial copy remains.",
|
||||||
|
],
|
||||||
|
"outcome": "Campaign personal data receives an explicit retained, review, revoke, or delete disposition without weakening delivery evidence.",
|
||||||
|
"verification": "Confirm matching recipients are isolated, no locator or credential material appears, report counts derive from the same matched jobs, and repeated reversible actions are unchanged.",
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.access.child-evidence",
|
id="campaigns.access.child-evidence",
|
||||||
title="Explain access to Campaign child evidence",
|
title="Explain access to Campaign child evidence",
|
||||||
@@ -907,7 +1300,7 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.mail-profile-operations",
|
id="campaigns.mail-profile-operations",
|
||||||
title="Operate profile-backed campaign delivery",
|
title="Operate profile-backed campaign delivery",
|
||||||
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
||||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Synchronous Mail batches preflight DNS, connectivity, TLS, and authentication before their first effect, reuse a bounded healthy SMTP connection, and reconnect before a later message when the old connection is stale. Review and send shows batch, connection, reconnect, failure, and pause counts. A systemic authentication, sender, or connectivity failure pauses remaining queued jobs with a stable reason code; correct and test the Mail profile before explicitly resuming. A connection loss after DATA begins stays outcome-unknown and is never replayed automatically. Preserve a stopped record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||||
@@ -1025,6 +1418,33 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="campaigns.archive-encryption-governance",
|
||||||
|
title="Use governed password-protected ZIP attachments",
|
||||||
|
summary="Use AES by default and select weak Windows-compatible ZipCrypto only with explicit policy, permission, acknowledgement, and evidence.",
|
||||||
|
body=(
|
||||||
|
"Campaign resolves archive encryption through Policy across system, tenant, owner user or group, and campaign scopes. Password-protected archives use AES unless the complete inherited policy permits Legacy ZipCrypto — Windows-compatible, weak encryption and the actor has campaigns:archive:use_legacy_zipcrypto. A legacy selection requires a reasoned acknowledgement. Passwords are never included in Campaign evidence or the campaign message and must be conveyed through the separately selected, policy-allowed channel. Each build freezes the archive and member hashes, implementation version, policy hash and source path, acknowledgement actor, reason and time, and build identity. A more restrictive later policy blocks queueing and sending until the campaign is rebuilt; Campaign never falls back from AES to ZipCrypto after an error. Temporary plaintext and archive material is confined to the bounded build directory and removed after success or failure."
|
||||||
|
),
|
||||||
|
documentation_types=("user", "admin"),
|
||||||
|
audience=("campaign_manager", "campaign_reviewer", "policy_admin"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=(
|
||||||
|
"campaigns:campaign:update",
|
||||||
|
"campaigns:campaign:review",
|
||||||
|
"admin:policies:read",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("policy", "audit", "access"),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/campaigns/{campaign_id}/files",
|
||||||
|
"screen": "Campaign attachments",
|
||||||
|
"help_contexts": ["campaign.archive-encryption"],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.workflow.complete-review",
|
id="campaigns.workflow.complete-review",
|
||||||
title="Inspect built messages and complete review",
|
title="Inspect built messages and complete review",
|
||||||
@@ -1240,7 +1660,7 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
)),
|
||||||
documentation_providers=(documentation_topics,),
|
documentation_providers=(documentation_topics,),
|
||||||
ownership_providers=(
|
ownership_providers=(
|
||||||
OwnershipProviderRegistration(
|
OwnershipProviderRegistration(
|
||||||
@@ -1276,12 +1696,27 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
fromlist=["retention_capability"],
|
fromlist=["retention_capability"],
|
||||||
).retention_capability(context),
|
).retention_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.work_orchestration",
|
||||||
|
fromlist=["SqlCampaignWorkOrchestrationProvider"],
|
||||||
|
).SqlCampaignWorkOrchestrationProvider(registry=context.registry),
|
||||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
||||||
"govoplan_campaign.backend.reports.provider",
|
"govoplan_campaign.backend.reports.provider",
|
||||||
fromlist=["CampaignAggregateReportProvider"],
|
fromlist=["CampaignAggregateReportProvider"],
|
||||||
).CampaignAggregateReportProvider(),
|
).CampaignAggregateReportProvider(),
|
||||||
|
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
capability_documentation={
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: CapabilityDocumentation(
|
||||||
|
label="Campaign work orchestration",
|
||||||
|
summary=(
|
||||||
|
"Creates or references Campaign work idempotently and exposes "
|
||||||
|
"revision-bearing lifecycle events without granting access."
|
||||||
|
),
|
||||||
|
contract_version="1.0",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("campaign_manager", "workflow_designer", "module_admin"),
|
||||||
|
),
|
||||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||||
label="Campaign aggregate report provider",
|
label="Campaign aggregate report provider",
|
||||||
summary=(
|
summary=(
|
||||||
@@ -1292,6 +1727,13 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "reporting_analyst", "privacy_officer"),
|
audience=("user", "reporting_analyst", "privacy_officer"),
|
||||||
),
|
),
|
||||||
|
CAMPAIGN_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Campaign data-subject request provider",
|
||||||
|
summary="Finds isolated recipient and Campaign evidence metadata and classifies governed erasure actions.",
|
||||||
|
contract_version="0.1.0",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("privacy_officer", "campaign_manager", "records_manager"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
operational_check_providers=(
|
operational_check_providers=(
|
||||||
OperationalCheckProviderRegistration(
|
OperationalCheckProviderRegistration(
|
||||||
@@ -1339,5 +1781,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import mimetypes
|
|||||||
import re
|
import re
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import make_msgid, formatdate
|
from email.utils import make_msgid, formatdate
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -40,7 +40,10 @@ from govoplan_campaign.backend.campaign.models import (
|
|||||||
effective_delivery_channel_policy,
|
effective_delivery_channel_policy,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
from govoplan_campaign.backend.services.zip_service import (
|
||||||
|
create_zip_archive,
|
||||||
|
zip_archive_evidence,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.template_rendering import (
|
from govoplan_campaign.backend.template_rendering import (
|
||||||
find_unresolved_placeholders as _find_unresolved_placeholders,
|
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||||
render_template as _render_template,
|
render_template as _render_template,
|
||||||
@@ -93,6 +96,7 @@ class _MimeBuildResult:
|
|||||||
build_status: BuildStatus
|
build_status: BuildStatus
|
||||||
validation_status: MessageValidationStatus
|
validation_status: MessageValidationStatus
|
||||||
attachment_count: int
|
attachment_count: int
|
||||||
|
archive_evidence: list[dict[str, object]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -372,8 +376,9 @@ def _attach_files(
|
|||||||
resolution: EntryAttachmentResolution,
|
resolution: EntryAttachmentResolution,
|
||||||
values: dict[str, Any],
|
values: dict[str, Any],
|
||||||
work_dir: Path,
|
work_dir: Path,
|
||||||
) -> int:
|
) -> tuple[int, list[dict[str, object]]]:
|
||||||
attached_count = 0
|
attached_count = 0
|
||||||
|
evidence: list[dict[str, object]] = []
|
||||||
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
||||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||||
used_message_filenames: set[str] = set()
|
used_message_filenames: set[str] = set()
|
||||||
@@ -429,13 +434,38 @@ def _attach_files(
|
|||||||
password,
|
password,
|
||||||
archive.method.value,
|
archive.method.value,
|
||||||
)
|
)
|
||||||
|
archive_record = zip_archive_evidence(
|
||||||
|
archive_path,
|
||||||
|
members,
|
||||||
|
password_protected=bool(password),
|
||||||
|
method=archive.method.value,
|
||||||
|
)
|
||||||
|
archive_record.update(
|
||||||
|
{
|
||||||
|
"archive_id": archive.id,
|
||||||
|
"filename": filename,
|
||||||
|
"password_delivery_channel": (
|
||||||
|
archive.password_delivery_channel.value if password else None
|
||||||
|
),
|
||||||
|
"legacy_acknowledgement": (
|
||||||
|
{
|
||||||
|
"actor_id": archive.legacy_zipcrypto_acknowledged_by,
|
||||||
|
"reason": archive.legacy_zipcrypto_reason,
|
||||||
|
"recorded_at": archive.legacy_zipcrypto_acknowledged_at,
|
||||||
|
}
|
||||||
|
if archive.method.value == "zip_standard"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
evidence.append(archive_record)
|
||||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||||
attached_count += 1
|
attached_count += 1
|
||||||
for attachment in archive_attachments.get(archive.id, []):
|
for attachment in archive_attachments.get(archive.id, []):
|
||||||
attachment.zip_filename = filename
|
attachment.zip_filename = filename
|
||||||
|
|
||||||
return attached_count
|
return attached_count, evidence
|
||||||
|
|
||||||
def _imap_initial_status(
|
def _imap_initial_status(
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
@@ -527,6 +557,7 @@ def _message_draft(
|
|||||||
imap_status: ImapStatus | None = None,
|
imap_status: ImapStatus | None = None,
|
||||||
subject: str | None = None,
|
subject: str | None = None,
|
||||||
attachment_count: int = 0,
|
attachment_count: int = 0,
|
||||||
|
archive_evidence: list[dict[str, object]] | None = None,
|
||||||
issues: list[MessageIssue] | None = None,
|
issues: list[MessageIssue] | None = None,
|
||||||
eml_path: str | None = None,
|
eml_path: str | None = None,
|
||||||
eml_size: int | None = None,
|
eml_size: int | None = None,
|
||||||
@@ -566,6 +597,7 @@ def _message_draft(
|
|||||||
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
||||||
attachment_count=attachment_count,
|
attachment_count=attachment_count,
|
||||||
attachments=_attachment_summaries(context.resolution),
|
attachments=_attachment_summaries(context.resolution),
|
||||||
|
archive_evidence=archive_evidence or [],
|
||||||
issues=issues if issues is not None else context.issues,
|
issues=issues if issues is not None else context.issues,
|
||||||
eml_path=eml_path,
|
eml_path=eml_path,
|
||||||
eml_size_bytes=eml_size,
|
eml_size_bytes=eml_size,
|
||||||
@@ -763,7 +795,7 @@ def _build_mime_message(
|
|||||||
_populate_message_body(message, rendered)
|
_populate_message_body(message, rendered)
|
||||||
if work_dir is None:
|
if work_dir is None:
|
||||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
||||||
attachment_count = _attach_files(
|
attachment_count, archive_evidence = _attach_files(
|
||||||
message=message,
|
message=message,
|
||||||
config=config,
|
config=config,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
@@ -789,6 +821,7 @@ def _build_mime_message(
|
|||||||
build_status=BuildStatus.BUILT,
|
build_status=BuildStatus.BUILT,
|
||||||
validation_status=context.validation_status,
|
validation_status=context.validation_status,
|
||||||
attachment_count=attachment_count,
|
attachment_count=attachment_count,
|
||||||
|
archive_evidence=archive_evidence,
|
||||||
)
|
)
|
||||||
except ZipBuildError as exc:
|
except ZipBuildError as exc:
|
||||||
context.issues.append(
|
context.issues.append(
|
||||||
@@ -893,6 +926,7 @@ def build_entry_message(
|
|||||||
validation_status=mime_result.validation_status,
|
validation_status=mime_result.validation_status,
|
||||||
subject=rendered.subject,
|
subject=rendered.subject,
|
||||||
attachment_count=mime_result.attachment_count,
|
attachment_count=mime_result.attachment_count,
|
||||||
|
archive_evidence=mime_result.archive_evidence,
|
||||||
eml_path=eml_path,
|
eml_path=eml_path,
|
||||||
eml_size=eml_size,
|
eml_size=eml_size,
|
||||||
)
|
)
|
||||||
@@ -1097,6 +1131,7 @@ def _build_residual_file_message(
|
|||||||
validation_status=mime_result.validation_status,
|
validation_status=mime_result.validation_status,
|
||||||
subject=rendered.subject,
|
subject=rendered.subject,
|
||||||
attachment_count=mime_result.attachment_count,
|
attachment_count=mime_result.attachment_count,
|
||||||
|
archive_evidence=mime_result.archive_evidence,
|
||||||
eml_path=eml_path,
|
eml_path=eml_path,
|
||||||
eml_size=eml_size,
|
eml_size=eml_size,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ class MessageDraft(BaseModel):
|
|||||||
|
|
||||||
attachment_count: int = 0
|
attachment_count: int = 0
|
||||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||||
|
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
|
||||||
issues: list[MessageIssue] = Field(default_factory=list)
|
issues: list[MessageIssue] = Field(default_factory=list)
|
||||||
|
|
||||||
eml_path: str | None = None
|
eml_path: str | None = None
|
||||||
|
|||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
"""add governed autonomous Campaign schedule evidence
|
||||||
|
|
||||||
|
revision = "b6c7d8e9f0a1"
|
||||||
|
down_revision = "a5b6c7d8e9f0"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "b6c7d8e9f0a1"
|
||||||
|
down_revision = "a5b6c7d8e9f0"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("delivery_mode", sa.String(length=20), nullable=False, server_default="manual"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedules_delivery_mode",
|
||||||
|
"campaign_schedules",
|
||||||
|
["delivery_mode"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("approved_execution_snapshot_hash", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||||
|
"campaign_schedules",
|
||||||
|
["approved_execution_snapshot_hash"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("last_outcome", sa.String(length=30), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column("last_recovery_state", sa.String(length=30), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("idempotency_key", sa.String(length=200), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
["idempotency_key"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("delivery_command_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("recovery_state", sa.String(length=30), nullable=False, server_default="none"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_campaign_schedule_occurrences_recovery_state",
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
["recovery_state"],
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "last_checked_at")
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "evidence")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_campaign_schedule_occurrences_recovery_state",
|
||||||
|
table_name="campaign_schedule_occurrences",
|
||||||
|
)
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "recovery_state")
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "delivery_command_ids")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||||
|
table_name="campaign_schedule_occurrences",
|
||||||
|
)
|
||||||
|
op.drop_column("campaign_schedule_occurrences", "idempotency_key")
|
||||||
|
op.drop_column("campaign_schedules", "last_recovery_state")
|
||||||
|
op.drop_column("campaign_schedules", "last_outcome")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||||
|
table_name="campaign_schedules",
|
||||||
|
)
|
||||||
|
op.drop_column("campaign_schedules", "approved_execution_snapshot_hash")
|
||||||
|
op.drop_index("ix_campaign_schedules_delivery_mode", table_name="campaign_schedules")
|
||||||
|
op.drop_column("campaign_schedules", "delivery_mode")
|
||||||
+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")
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
"""add durable Campaign work orchestration provenance
|
||||||
|
|
||||||
|
revision = "f3c7a9d2e6b1"
|
||||||
|
down_revision = "d8e9f0a1b2c3"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f3c7a9d2e6b1"
|
||||||
|
down_revision = "d8e9f0a1b2c3"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
_COLUMN_SPECS = (
|
||||||
|
("orchestration_idempotency_key", sa.String(length=255)),
|
||||||
|
("orchestration_request_sha256", sa.String(length=64)),
|
||||||
|
("orchestration_correlation_id", sa.String(length=128)),
|
||||||
|
("workflow_instance_id", sa.String(length=36)),
|
||||||
|
("workflow_step_id", sa.String(length=36)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_work_assignments"):
|
||||||
|
return
|
||||||
|
existing = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_columns("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||||
|
for name, column_type in _COLUMN_SPECS:
|
||||||
|
if name not in existing:
|
||||||
|
batch.add_column(sa.Column(name, column_type, nullable=True))
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
indexes = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_indexes("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
for name, columns in (
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||||
|
["orchestration_idempotency_key"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||||
|
["orchestration_correlation_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_workflow_instance_id",
|
||||||
|
["workflow_instance_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"ix_campaign_work_assignments_workflow_step_id",
|
||||||
|
["workflow_step_id"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"uq_campaign_work_assignment_orchestration_key",
|
||||||
|
["tenant_id", "orchestration_idempotency_key"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
if name not in indexes:
|
||||||
|
op.create_index(
|
||||||
|
name,
|
||||||
|
"campaign_work_assignments",
|
||||||
|
columns,
|
||||||
|
unique=name.startswith("uq_"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_work_assignments"):
|
||||||
|
return
|
||||||
|
indexes = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_indexes("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
for name in (
|
||||||
|
"uq_campaign_work_assignment_orchestration_key",
|
||||||
|
"ix_campaign_work_assignments_workflow_step_id",
|
||||||
|
"ix_campaign_work_assignments_workflow_instance_id",
|
||||||
|
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||||
|
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||||
|
):
|
||||||
|
if name in indexes:
|
||||||
|
op.drop_index(name, table_name="campaign_work_assignments")
|
||||||
|
existing = {
|
||||||
|
item["name"]
|
||||||
|
for item in sa.inspect(op.get_bind()).get_columns(
|
||||||
|
"campaign_work_assignments"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||||
|
for name, _column_type in reversed(_COLUMN_SPECS):
|
||||||
|
if name in existing:
|
||||||
|
batch.drop_column(name)
|
||||||
@@ -40,6 +40,10 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
JobSendStatus,
|
JobSendStatus,
|
||||||
JobValidationStatus,
|
JobValidationStatus,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.campaign.loader import (
|
from govoplan_campaign.backend.campaign.loader import (
|
||||||
load_campaign_json,
|
load_campaign_json,
|
||||||
validate_against_schema,
|
validate_against_schema,
|
||||||
@@ -657,6 +661,15 @@ def validate_campaign_version(
|
|||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Campaign version is not accessible for this tenant"
|
"Campaign version is not accessible for this tenant"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
archive_policy = assert_archive_encryption_allowed(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
raise CampaignPersistenceError(str(exc)) from exc
|
||||||
_ensure_current_campaign_version(campaign, version, action="validate")
|
_ensure_current_campaign_version(campaign, version, action="validate")
|
||||||
if _version_is_user_locked(version) or version.workflow_state in {
|
if _version_is_user_locked(version) or version.workflow_state in {
|
||||||
CampaignVersionWorkflowState.QUEUED.value,
|
CampaignVersionWorkflowState.QUEUED.value,
|
||||||
@@ -734,6 +747,7 @@ def validate_campaign_version(
|
|||||||
"warning_count": report.warning_count,
|
"warning_count": report.warning_count,
|
||||||
"validated_at": datetime.now(UTC).isoformat(),
|
"validated_at": datetime.now(UTC).isoformat(),
|
||||||
"validated_by_user_id": user_id,
|
"validated_by_user_id": user_id,
|
||||||
|
"archive_encryption_policy": archive_policy.to_dict(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
version.validation_summary = report_json
|
version.validation_summary = report_json
|
||||||
@@ -1430,6 +1444,11 @@ def _store_execution_snapshot(
|
|||||||
delivery=config.delivery,
|
delivery=config.delivery,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
build_summary=build_summary,
|
build_summary=build_summary,
|
||||||
|
archive_encryption=(
|
||||||
|
build_summary.get("archive_encryption")
|
||||||
|
if isinstance(build_summary.get("archive_encryption"), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
version.execution_snapshot = snapshot
|
version.execution_snapshot = snapshot
|
||||||
version.execution_snapshot_hash = snapshot_hash
|
version.execution_snapshot_hash = snapshot_hash
|
||||||
@@ -1615,6 +1634,15 @@ def build_campaign_version(
|
|||||||
raise CampaignPersistenceError(
|
raise CampaignPersistenceError(
|
||||||
"Campaign version is not accessible for this tenant"
|
"Campaign version is not accessible for this tenant"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
archive_policy = assert_archive_encryption_allowed(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
raise CampaignPersistenceError(str(exc)) from exc
|
||||||
_ensure_current_campaign_version(campaign, version, action="build")
|
_ensure_current_campaign_version(campaign, version, action="build")
|
||||||
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
||||||
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
||||||
@@ -1749,6 +1777,25 @@ def build_campaign_version(
|
|||||||
)
|
)
|
||||||
report_json = _campaign_build_report(result, files)
|
report_json = _campaign_build_report(result, files)
|
||||||
report_json["built_by_user_id"] = user_id
|
report_json["built_by_user_id"] = user_id
|
||||||
|
archive_records = [
|
||||||
|
{
|
||||||
|
**archive,
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_version_id": version.id,
|
||||||
|
"build_token": report_json["build_token"],
|
||||||
|
"built_at": report_json["built_at"],
|
||||||
|
"policy_hash": archive_policy.policy_hash,
|
||||||
|
"policy_source_path": [
|
||||||
|
dict(item) for item in archive_policy.source_path
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for message in result.report.messages
|
||||||
|
for archive in message.archive_evidence
|
||||||
|
]
|
||||||
|
report_json["archive_encryption"] = {
|
||||||
|
"policy": archive_policy.to_dict(),
|
||||||
|
"archives": archive_records,
|
||||||
|
}
|
||||||
if resolved_print_outputs_by_index:
|
if resolved_print_outputs_by_index:
|
||||||
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
||||||
report_json["print_output"] = {
|
report_json["print_output"] = {
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ _SEND_NOW_RESULT_KEYS = (
|
|||||||
"failed_count",
|
"failed_count",
|
||||||
"outcome_unknown_count",
|
"outcome_unknown_count",
|
||||||
"skipped_count",
|
"skipped_count",
|
||||||
|
"paused_count",
|
||||||
"preflight_count",
|
"preflight_count",
|
||||||
|
"batch_state",
|
||||||
|
"batch_pause_reason_code",
|
||||||
|
"smtp_connection_count",
|
||||||
|
"smtp_reconnect_count",
|
||||||
"delivery_mode",
|
"delivery_mode",
|
||||||
"dry_run",
|
"dry_run",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,7 +31,13 @@ from govoplan_core.core.object_storage import (
|
|||||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.settings import settings as core_settings
|
from govoplan_core.settings import settings as core_settings
|
||||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
CampaignJob,
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignVersion,
|
||||||
|
JobImapStatus,
|
||||||
|
JobQueueStatus,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.runtime import get_settings
|
from govoplan_campaign.backend.runtime import get_settings
|
||||||
|
|
||||||
FINAL_VERSION_STATES = {
|
FINAL_VERSION_STATES = {
|
||||||
@@ -351,6 +357,18 @@ def _apply_eml_retention(
|
|||||||
"delete_failed": 0,
|
"delete_failed": 0,
|
||||||
"recovery_blocked": 0,
|
"recovery_blocked": 0,
|
||||||
"skipped_not_final": 0,
|
"skipped_not_final": 0,
|
||||||
|
"skipped_schedule_source": 0,
|
||||||
|
}
|
||||||
|
protected_source_versions = {
|
||||||
|
str(version_id)
|
||||||
|
for (version_id,) in (
|
||||||
|
session.query(CampaignSchedule.source_version_id)
|
||||||
|
.filter(
|
||||||
|
CampaignSchedule.delivery_mode == "autonomous",
|
||||||
|
CampaignSchedule.next_fire_at.is_not(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
jobs = (
|
jobs = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
@@ -359,6 +377,9 @@ def _apply_eml_retention(
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
|
if getattr(job, "campaign_version_id", None) in protected_source_versions:
|
||||||
|
result["skipped_schedule_source"] += 1
|
||||||
|
continue
|
||||||
policy = policy_for_campaign_id(job.campaign_id)
|
policy = policy_for_campaign_id(job.campaign_id)
|
||||||
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
||||||
if not _is_before_cutoff(job.updated_at, cutoff):
|
if not _is_before_cutoff(job.updated_at, cutoff):
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|||||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||||
campaign_mail_profile_id,
|
campaign_mail_profile_id,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignIssue,
|
CampaignIssue,
|
||||||
@@ -388,7 +392,9 @@ def _update_campaign_version_detail_response(
|
|||||||
autosave: bool,
|
autosave: bool,
|
||||||
audit_action: str,
|
audit_action: str,
|
||||||
) -> CampaignVersionDetailResponse:
|
) -> CampaignVersionDetailResponse:
|
||||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
campaign = _get_campaign_for_principal(
|
||||||
|
session, campaign_id, principal, write=True
|
||||||
|
)
|
||||||
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||||
if payload.base_revision is None:
|
if payload.base_revision is None:
|
||||||
error = MissingPreconditionError(
|
error = MissingPreconditionError(
|
||||||
@@ -421,9 +427,40 @@ def _update_campaign_version_detail_response(
|
|||||||
) from exc
|
) from exc
|
||||||
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||||
_require_permission(principal, "campaigns:recipient:write")
|
_require_permission(principal, "campaigns:recipient:write")
|
||||||
|
acknowledgements: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
payload.campaign_json, acknowledgements = (
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
current_version.raw_json
|
||||||
|
if isinstance(current_version.raw_json, dict)
|
||||||
|
else {},
|
||||||
|
payload.campaign_json,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.archive_encryption_denied",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version_id,
|
||||||
|
details={"campaign_id": campaign_id, "reason": str(exc)},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=(
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
if "Missing scope:" in str(exc)
|
||||||
|
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
),
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||||
try:
|
try:
|
||||||
return _campaign_version_detail_response(
|
result = _campaign_version_detail_response(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
campaign_id,
|
campaign_id,
|
||||||
@@ -462,9 +499,23 @@ def _update_campaign_version_detail_response(
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||||
|
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
||||||
},
|
},
|
||||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
)
|
)
|
||||||
|
for acknowledgement in acknowledgements:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.legacy_zipcrypto_acknowledged",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version_id,
|
||||||
|
details={"campaign_id": campaign_id, **acknowledgement},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if acknowledgements:
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
except RevisionConflictError as exc:
|
except RevisionConflictError as exc:
|
||||||
session.rollback()
|
session.rollback()
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
|
|||||||
@@ -3,20 +3,26 @@ from __future__ import annotations
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from govoplan_campaign.backend.routes.attachments import router as attachments_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.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.delivery import router as delivery_router
|
||||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||||
from govoplan_campaign.backend.routes.schedules import router as schedules_router
|
from govoplan_campaign.backend.routes.schedules import router as schedules_router
|
||||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||||
|
from govoplan_campaign.backend.routes.transfers import router as transfers_router
|
||||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
for workflow_router in (
|
for workflow_router in (
|
||||||
operations_router,
|
operations_router,
|
||||||
|
transfers_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
assignments_router,
|
||||||
|
collaboration_router,
|
||||||
versions_router,
|
versions_router,
|
||||||
jobs_router,
|
jobs_router,
|
||||||
reports_router,
|
reports_router,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -130,9 +130,22 @@ from govoplan_campaign.backend.route_support import (
|
|||||||
_write_current_version_snapshot_if_available,
|
_write_current_version_snapshot_if_available,
|
||||||
bounded_query_rows as _bounded_query_rows,
|
bounded_query_rows as _bounded_query_rows,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
effective_archive_encryption_policy,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{campaign_id}/archive-encryption-policy")
|
||||||
|
def campaign_archive_encryption_policy(
|
||||||
|
campaign_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
return effective_archive_encryption_policy(session, campaign).to_dict()
|
||||||
|
|
||||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_campaign.backend.campaign.scheduling import (
|
from govoplan_campaign.backend.campaign.scheduling import (
|
||||||
campaign_schedule_source_snapshot,
|
campaign_schedule_source_snapshot,
|
||||||
canonical_configuration_hash,
|
canonical_configuration_hash,
|
||||||
|
validate_autonomous_schedule_source,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
@@ -80,6 +81,10 @@ def create_campaign_schedule(
|
|||||||
_require_permission(principal, "campaigns:recipient:read")
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
if payload.include_shares:
|
if payload.include_shares:
|
||||||
_require_permission(principal, "campaigns:campaign:share")
|
_require_permission(principal, "campaigns:campaign:share")
|
||||||
|
if payload.delivery_mode == "autonomous":
|
||||||
|
_require_permission(principal, "campaigns:campaign:queue")
|
||||||
|
_require_permission(principal, "campaigns:campaign:send")
|
||||||
|
_require_permission(principal, "mail:profile:use")
|
||||||
source_version = (
|
source_version = (
|
||||||
session.query(CampaignVersion)
|
session.query(CampaignVersion)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -129,12 +134,27 @@ def create_campaign_schedule(
|
|||||||
for item in source_shares
|
for item in source_shares
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
autonomous_evidence: dict[str, object] | None = None
|
||||||
|
if payload.delivery_mode == "autonomous":
|
||||||
|
try:
|
||||||
|
autonomous_evidence = validate_autonomous_schedule_source(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=source_version,
|
||||||
|
)
|
||||||
|
except (RuntimeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
snapshot["autonomous_delivery"] = autonomous_evidence
|
||||||
schedule = CampaignSchedule(
|
schedule = CampaignSchedule(
|
||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
source_version_id=source_version.id,
|
source_version_id=source_version.id,
|
||||||
created_by_user_id=principal.user.id,
|
created_by_user_id=principal.user.id,
|
||||||
name=payload.name.strip(),
|
name=payload.name.strip(),
|
||||||
|
delivery_mode=payload.delivery_mode,
|
||||||
recurrence_kind=payload.recurrence_kind,
|
recurrence_kind=payload.recurrence_kind,
|
||||||
interval_count=payload.interval_count,
|
interval_count=payload.interval_count,
|
||||||
timezone=payload.timezone,
|
timezone=payload.timezone,
|
||||||
@@ -151,6 +171,11 @@ def create_campaign_schedule(
|
|||||||
},
|
},
|
||||||
source_snapshot=snapshot,
|
source_snapshot=snapshot,
|
||||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||||
|
approved_execution_snapshot_hash=(
|
||||||
|
str(autonomous_evidence["execution_snapshot_hash"])
|
||||||
|
if autonomous_evidence is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
source_base_path=source_version.source_base_path,
|
source_base_path=source_version.source_base_path,
|
||||||
)
|
)
|
||||||
session.add(schedule)
|
session.add(schedule)
|
||||||
@@ -169,7 +194,19 @@ def create_campaign_schedule(
|
|||||||
"starts_at": schedule.starts_at.isoformat(),
|
"starts_at": schedule.starts_at.isoformat(),
|
||||||
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
||||||
"max_occurrences": schedule.max_occurrences,
|
"max_occurrences": schedule.max_occurrences,
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
"delivery_started": False,
|
"delivery_started": False,
|
||||||
|
"autonomous_delivery_opted_in": (
|
||||||
|
schedule.delivery_mode == "autonomous"
|
||||||
|
),
|
||||||
|
"approved_execution_snapshot_hash": (
|
||||||
|
schedule.approved_execution_snapshot_hash
|
||||||
|
),
|
||||||
|
"approval_request_id": (
|
||||||
|
autonomous_evidence.get("approval_request_id")
|
||||||
|
if autonomous_evidence is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -206,6 +243,23 @@ def set_campaign_schedule_state(
|
|||||||
status_code=status.HTTP_409_CONFLICT,
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
detail="A completed campaign schedule cannot be resumed.",
|
detail="A completed campaign schedule cannot be resumed.",
|
||||||
)
|
)
|
||||||
|
if payload.active:
|
||||||
|
unresolved = (
|
||||||
|
session.query(CampaignScheduleOccurrence.id)
|
||||||
|
.filter(
|
||||||
|
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||||
|
CampaignScheduleOccurrence.status == "uncertain",
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if unresolved is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=(
|
||||||
|
"Reconcile the autonomous delivery outcome in Mail before "
|
||||||
|
"resuming this schedule."
|
||||||
|
),
|
||||||
|
)
|
||||||
schedule.active = payload.active
|
schedule.active = payload.active
|
||||||
schedule.last_error = None if payload.active else schedule.last_error
|
schedule.last_error = None if payload.active else schedule.last_error
|
||||||
schedule.resource_revision += 1
|
schedule.resource_revision += 1
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from importlib import metadata
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.transfers import (
|
||||||
|
CampaignImportInspection,
|
||||||
|
CampaignTransferError,
|
||||||
|
build_campaign_portable_package,
|
||||||
|
inspect_campaign_portable_package,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignIssue,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignVersion,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.persistence.campaigns import (
|
||||||
|
create_campaign_version_from_json,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.route_support import (
|
||||||
|
_campaign_response_context,
|
||||||
|
_get_campaign_for_principal,
|
||||||
|
_require_permission,
|
||||||
|
_write_current_version_snapshot_if_available,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignExportRequest,
|
||||||
|
CampaignImportApplyRequest,
|
||||||
|
CampaignImportApplyResponse,
|
||||||
|
CampaignImportPreviewRequest,
|
||||||
|
CampaignImportPreviewResponse,
|
||||||
|
CampaignPortablePackageResponse,
|
||||||
|
CampaignResponse,
|
||||||
|
CampaignVersionResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["campaigns"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/campaigns/{campaign_id}/versions/{version_id}/exports",
|
||||||
|
response_model=CampaignPortablePackageResponse,
|
||||||
|
)
|
||||||
|
def export_campaign_package(
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str,
|
||||||
|
payload: CampaignExportRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("campaigns:campaign:export")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
version = (
|
||||||
|
session.query(CampaignVersion)
|
||||||
|
.filter(
|
||||||
|
CampaignVersion.id == version_id,
|
||||||
|
CampaignVersion.campaign_id == campaign.id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if version is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Campaign version not found",
|
||||||
|
)
|
||||||
|
scopes = set(payload.scopes)
|
||||||
|
if "recipients" in scopes:
|
||||||
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
|
_require_permission(principal, "campaigns:recipient:export")
|
||||||
|
if "review_state" in scopes:
|
||||||
|
_require_permission(principal, "campaigns:report:read")
|
||||||
|
if "delivery_history" in scopes:
|
||||||
|
_require_permission(principal, "campaigns:report:export")
|
||||||
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
|
_require_permission(principal, "campaigns:recipient:export")
|
||||||
|
|
||||||
|
jobs = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(CampaignJob.campaign_version_id == version.id)
|
||||||
|
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||||
|
.all()
|
||||||
|
if "delivery_history" in scopes
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
issues = (
|
||||||
|
session.query(CampaignIssue)
|
||||||
|
.filter(CampaignIssue.campaign_version_id == version.id)
|
||||||
|
.order_by(CampaignIssue.id.asc())
|
||||||
|
.all()
|
||||||
|
if "review_state" in scopes
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
package = build_campaign_portable_package(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
scopes=payload.scopes,
|
||||||
|
jobs=jobs,
|
||||||
|
issues=issues,
|
||||||
|
module_version=_module_version(),
|
||||||
|
)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.portable_export_created",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version.id,
|
||||||
|
details={
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"package_id": package["package_id"],
|
||||||
|
"package_sha256": package["integrity"]["package_sha256"],
|
||||||
|
"format_version": package["format_version"],
|
||||||
|
"scopes": package["scopes"],
|
||||||
|
"item_counts": package["manifest"]["item_counts"],
|
||||||
|
"redactions": package["manifest"]["redactions"],
|
||||||
|
},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
except CampaignTransferError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/campaign-transfers/imports/preview",
|
||||||
|
response_model=CampaignImportPreviewResponse,
|
||||||
|
)
|
||||||
|
def preview_campaign_import(
|
||||||
|
payload: CampaignImportPreviewRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("campaigns:campaign:import")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_require_permission(principal, "campaigns:campaign:create")
|
||||||
|
inspection = _inspect_import_request(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
package=payload.package,
|
||||||
|
selected_scopes=payload.selected_scopes,
|
||||||
|
external_id=payload.external_id,
|
||||||
|
name=payload.name,
|
||||||
|
)
|
||||||
|
return inspection.preview
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/campaign-transfers/imports",
|
||||||
|
response_model=CampaignImportApplyResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def import_campaign_package(
|
||||||
|
payload: CampaignImportApplyRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_scope("campaigns:campaign:import")
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_require_permission(principal, "campaigns:campaign:create")
|
||||||
|
inspection = _inspect_import_request(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
package=payload.package,
|
||||||
|
selected_scopes=payload.selected_scopes,
|
||||||
|
external_id=payload.external_id,
|
||||||
|
name=payload.name,
|
||||||
|
)
|
||||||
|
_require_import_scope_permissions(principal, inspection)
|
||||||
|
preview = inspection.preview
|
||||||
|
if payload.expected_package_sha256 != preview.get("package_sha256"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="The Campaign package changed after preview. Preview it again before importing.",
|
||||||
|
)
|
||||||
|
if not preview["compatible"] or inspection.configuration is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail={
|
||||||
|
"message": "The Campaign package is not compatible.",
|
||||||
|
"errors": preview["errors"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
destination = preview["destination"]
|
||||||
|
package_id = str(preview["package_id"])
|
||||||
|
package_sha256 = str(preview["package_sha256"])
|
||||||
|
receipt = {
|
||||||
|
"package_id": package_id,
|
||||||
|
"package_sha256": package_sha256,
|
||||||
|
"format_version": preview["format_version"],
|
||||||
|
"source": copy.deepcopy(preview["source"]),
|
||||||
|
"selected_scopes": list(preview["selected_scopes"]),
|
||||||
|
"created": copy.deepcopy(preview["will_create"]),
|
||||||
|
"skipped": copy.deepcopy(preview["will_skip"]),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
campaign, version = create_campaign_version_from_json(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
raw_json=inspection.configuration,
|
||||||
|
source_filename=f"{package_id}.govoplan-campaign.json",
|
||||||
|
source_base_path=None,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
campaign.settings = {
|
||||||
|
**inspection.portable_settings,
|
||||||
|
"portable_import": receipt,
|
||||||
|
}
|
||||||
|
session.add(campaign)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.portable_import_applied",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign.id,
|
||||||
|
details={
|
||||||
|
"version_id": version.id,
|
||||||
|
"external_id": destination["external_id"],
|
||||||
|
"package_id": package_id,
|
||||||
|
"package_sha256": package_sha256,
|
||||||
|
"format_version": preview["format_version"],
|
||||||
|
"selected_scopes": preview["selected_scopes"],
|
||||||
|
"created_codes": [item["code"] for item in preview["will_create"]],
|
||||||
|
"skipped_codes": [item["code"] for item in preview["will_skip"]],
|
||||||
|
},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
session.refresh(campaign)
|
||||||
|
session.refresh(version)
|
||||||
|
_write_current_version_snapshot_if_available(version)
|
||||||
|
except Exception:
|
||||||
|
session.rollback()
|
||||||
|
raise
|
||||||
|
return CampaignImportApplyResponse(
|
||||||
|
campaign=CampaignResponse.model_validate(campaign),
|
||||||
|
version=CampaignVersionResponse.model_validate(
|
||||||
|
version,
|
||||||
|
context=_campaign_response_context(principal),
|
||||||
|
),
|
||||||
|
receipt=receipt,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _inspect_import_request(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
package: dict[str, Any],
|
||||||
|
selected_scopes: list[str] | None,
|
||||||
|
external_id: str | None,
|
||||||
|
name: str | None,
|
||||||
|
) -> CampaignImportInspection:
|
||||||
|
source = package.get("source")
|
||||||
|
source = source if isinstance(source, dict) else {}
|
||||||
|
metadata_payload = package.get("payload")
|
||||||
|
metadata_payload = metadata_payload if isinstance(metadata_payload, dict) else {}
|
||||||
|
metadata_scope = metadata_payload.get("metadata")
|
||||||
|
metadata_scope = metadata_scope if isinstance(metadata_scope, dict) else {}
|
||||||
|
source_external_id = str(
|
||||||
|
metadata_scope.get("external_id")
|
||||||
|
or source.get("campaign_external_id")
|
||||||
|
or "campaign"
|
||||||
|
)
|
||||||
|
destination_external_id = _portable_import_external_id(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
source_external_id=source_external_id,
|
||||||
|
requested=external_id,
|
||||||
|
)
|
||||||
|
destination_name = str(
|
||||||
|
name
|
||||||
|
or metadata_scope.get("name")
|
||||||
|
or source.get("campaign_name")
|
||||||
|
or "Imported campaign"
|
||||||
|
).strip()
|
||||||
|
if not destination_name:
|
||||||
|
destination_name = "Imported campaign"
|
||||||
|
inspection = inspect_campaign_portable_package(
|
||||||
|
package,
|
||||||
|
selected_scopes=selected_scopes,
|
||||||
|
external_id=destination_external_id,
|
||||||
|
name=destination_name,
|
||||||
|
)
|
||||||
|
if _campaign_external_id_exists(
|
||||||
|
session, principal.tenant_id, destination_external_id
|
||||||
|
):
|
||||||
|
inspection.preview["compatible"] = False
|
||||||
|
inspection.preview["errors"].append(
|
||||||
|
"The destination Campaign ID already exists in this tenant."
|
||||||
|
)
|
||||||
|
return CampaignImportInspection(
|
||||||
|
preview=inspection.preview,
|
||||||
|
configuration=None,
|
||||||
|
portable_settings=inspection.portable_settings,
|
||||||
|
)
|
||||||
|
return inspection
|
||||||
|
|
||||||
|
|
||||||
|
def _portable_import_external_id(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
source_external_id: str,
|
||||||
|
requested: str | None,
|
||||||
|
) -> str:
|
||||||
|
if requested is not None:
|
||||||
|
candidate = requested.strip()
|
||||||
|
if not candidate:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Campaign ID cannot be empty.",
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
stem = f"{source_external_id[:238]}-import"
|
||||||
|
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
|
||||||
|
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
|
||||||
|
if not _campaign_external_id_exists(session, tenant_id, candidate):
|
||||||
|
return candidate
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="No available Campaign import identifier could be generated.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_external_id_exists(
|
||||||
|
session: Session, tenant_id: str, external_id: str
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
session.query(Campaign.id)
|
||||||
|
.filter(
|
||||||
|
Campaign.tenant_id == tenant_id,
|
||||||
|
Campaign.external_id == external_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_import_scope_permissions(
|
||||||
|
principal: ApiPrincipal, inspection: CampaignImportInspection
|
||||||
|
) -> None:
|
||||||
|
selected = set(inspection.preview.get("selected_scopes") or [])
|
||||||
|
if "recipients" in selected:
|
||||||
|
_require_permission(principal, "campaigns:recipient:import")
|
||||||
|
_require_permission(principal, "campaigns:recipient:write")
|
||||||
|
|
||||||
|
|
||||||
|
def _module_version() -> str:
|
||||||
|
try:
|
||||||
|
return metadata.version("govoplan-campaign")
|
||||||
|
except metadata.PackageNotFoundError:
|
||||||
|
return "development"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"export_campaign_package",
|
||||||
|
"import_campaign_package",
|
||||||
|
"preview_campaign_import",
|
||||||
|
"router",
|
||||||
|
]
|
||||||
@@ -770,6 +770,14 @@ def validate_version(
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
if _is_archive_encryption_denial(exc):
|
||||||
|
_audit_archive_encryption_denial(
|
||||||
|
session, principal, version_id=version_id, error=exc
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
@@ -883,6 +891,9 @@ def build_version(
|
|||||||
"attachment_reuse": _attachment_reuse_audit_evidence(
|
"attachment_reuse": _attachment_reuse_audit_evidence(
|
||||||
result.get("attachment_reuse")
|
result.get("attachment_reuse")
|
||||||
),
|
),
|
||||||
|
"archive_encryption": _archive_encryption_audit_evidence(
|
||||||
|
result.get("archive_encryption")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -891,6 +902,18 @@ def build_version(
|
|||||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||||
)
|
)
|
||||||
except CampaignPersistenceError as exc:
|
except CampaignPersistenceError as exc:
|
||||||
|
if _is_archive_encryption_denial(exc):
|
||||||
|
_audit_archive_encryption_denial(
|
||||||
|
session, principal, version_id=version_id, error=exc
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=(
|
||||||
|
status.HTTP_403_FORBIDDEN
|
||||||
|
if "Missing scope:" in str(exc)
|
||||||
|
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||||
|
),
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
@@ -915,6 +938,38 @@ def build_version(
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _is_archive_encryption_denial(error: Exception) -> bool:
|
||||||
|
message = str(error).casefold()
|
||||||
|
return any(
|
||||||
|
marker in message
|
||||||
|
for marker in (
|
||||||
|
"archive-encryption",
|
||||||
|
"archive encryption",
|
||||||
|
"zipcrypto",
|
||||||
|
"password-delivery channel",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_archive_encryption_denial(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
version_id: str,
|
||||||
|
error: Exception,
|
||||||
|
) -> None:
|
||||||
|
session.rollback()
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.archive_encryption_denied",
|
||||||
|
object_type="campaign_version",
|
||||||
|
object_id=version_id,
|
||||||
|
details={"reason": str(error)},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
|
def _residual_file_audit_evidence(value: object) -> dict[str, object]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -945,6 +1000,25 @@ def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_encryption_audit_evidence(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
policy = value.get("policy")
|
||||||
|
archives = [
|
||||||
|
item for item in (value.get("archives") or []) if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"policy_hash": policy.get("policy_hash")
|
||||||
|
if isinstance(policy, dict)
|
||||||
|
else None,
|
||||||
|
"archive_count": len(archives),
|
||||||
|
"legacy_zipcrypto_count": sum(
|
||||||
|
1 for item in archives if item.get("method") == "zip_standard"
|
||||||
|
),
|
||||||
|
"archive_sha256": [item.get("archive_sha256") for item in archives],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _review_decision_audit_evidence(
|
def _review_decision_audit_evidence(
|
||||||
version: CampaignVersion,
|
version: CampaignVersion,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
|||||||
@@ -1625,6 +1625,44 @@
|
|||||||
],
|
],
|
||||||
"default": "aes"
|
"default": "aes"
|
||||||
},
|
},
|
||||||
|
"password_delivery_channel": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"separate_mail",
|
||||||
|
"sms",
|
||||||
|
"letter",
|
||||||
|
"phone",
|
||||||
|
"in_person"
|
||||||
|
],
|
||||||
|
"default": "separate_mail"
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_acknowledged": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_reason": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 1000
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_acknowledged_by": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 255,
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
|
"legacy_zipcrypto_acknowledged_at": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"maxLength": 80,
|
||||||
|
"readOnly": true
|
||||||
|
},
|
||||||
"password_mode": {
|
"password_mode": {
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
|
|||||||
@@ -42,6 +42,240 @@ class CampaignUpdateRequest(BaseModel):
|
|||||||
description: str | None = None
|
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",
|
||||||
|
"rejected",
|
||||||
|
"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["accept", "start", "complete", "reject", "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):
|
class CampaignLifecycleMutationRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -64,6 +298,7 @@ class CampaignScheduleCreateRequest(BaseModel):
|
|||||||
|
|
||||||
source_version_id: str = Field(min_length=1, max_length=36)
|
source_version_id: str = Field(min_length=1, max_length=36)
|
||||||
name: str = Field(min_length=1, max_length=255)
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
delivery_mode: Literal["manual", "autonomous"] = "manual"
|
||||||
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
|
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
|
||||||
interval_count: int = Field(default=1, ge=1, le=365)
|
interval_count: int = Field(default=1, ge=1, le=365)
|
||||||
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
||||||
@@ -107,9 +342,14 @@ class CampaignScheduleOccurrenceResponse(BaseModel):
|
|||||||
schedule_id: str
|
schedule_id: str
|
||||||
scheduled_for: datetime
|
scheduled_for: datetime
|
||||||
status: str
|
status: str
|
||||||
|
idempotency_key: str | None = None
|
||||||
generated_campaign_id: str | None = None
|
generated_campaign_id: str | None = None
|
||||||
generated_version_id: str | None = None
|
generated_version_id: str | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
|
delivery_command_ids: list[str] = Field(default_factory=list)
|
||||||
|
recovery_state: str = "none"
|
||||||
|
evidence: dict[str, object] = Field(default_factory=dict)
|
||||||
|
last_checked_at: datetime | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
@@ -120,6 +360,7 @@ class CampaignScheduleResponse(BaseModel):
|
|||||||
campaign_id: str
|
campaign_id: str
|
||||||
source_version_id: str
|
source_version_id: str
|
||||||
name: str
|
name: str
|
||||||
|
delivery_mode: str
|
||||||
recurrence_kind: str
|
recurrence_kind: str
|
||||||
interval_count: int
|
interval_count: int
|
||||||
timezone: str
|
timezone: str
|
||||||
@@ -133,6 +374,8 @@ class CampaignScheduleResponse(BaseModel):
|
|||||||
last_fired_at: datetime | None = None
|
last_fired_at: datetime | None = None
|
||||||
last_campaign_id: str | None = None
|
last_campaign_id: str | None = None
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
|
last_outcome: str | None = None
|
||||||
|
last_recovery_state: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
|
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
|
||||||
@@ -352,6 +595,96 @@ class CampaignCreateResponse(BaseModel):
|
|||||||
version: CampaignVersionResponse
|
version: CampaignVersionResponse
|
||||||
|
|
||||||
|
|
||||||
|
CampaignTransferScope = Literal[
|
||||||
|
"metadata",
|
||||||
|
"template_config",
|
||||||
|
"recipients",
|
||||||
|
"attachments",
|
||||||
|
"review_state",
|
||||||
|
"delivery_history",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignExportRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
scopes: list[CampaignTransferScope] = Field(
|
||||||
|
default_factory=lambda: ["metadata", "template_config"],
|
||||||
|
min_length=1,
|
||||||
|
max_length=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("scopes")
|
||||||
|
@classmethod
|
||||||
|
def normalize_scopes(
|
||||||
|
cls, value: list[CampaignTransferScope]
|
||||||
|
) -> list[CampaignTransferScope]:
|
||||||
|
return list(dict.fromkeys(value))
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignPortablePackageResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
format: Literal["govoplan.campaign-portable"]
|
||||||
|
format_version: str
|
||||||
|
package_id: str
|
||||||
|
exported_at: str
|
||||||
|
source: dict[str, Any]
|
||||||
|
scopes: list[CampaignTransferScope]
|
||||||
|
manifest: dict[str, Any]
|
||||||
|
payload: dict[str, Any]
|
||||||
|
integrity: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignImportPreviewRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
package: dict[str, Any]
|
||||||
|
selected_scopes: list[CampaignTransferScope] | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=6,
|
||||||
|
)
|
||||||
|
external_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("selected_scopes")
|
||||||
|
@classmethod
|
||||||
|
def normalize_selected_scopes(
|
||||||
|
cls, value: list[CampaignTransferScope] | None
|
||||||
|
) -> list[CampaignTransferScope] | None:
|
||||||
|
return list(dict.fromkeys(value)) if value is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignImportApplyRequest(CampaignImportPreviewRequest):
|
||||||
|
expected_package_sha256: str = Field(min_length=64, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignTransferPlanItem(BaseModel):
|
||||||
|
scope: CampaignTransferScope
|
||||||
|
code: str
|
||||||
|
summary: str
|
||||||
|
item_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignImportPreviewResponse(BaseModel):
|
||||||
|
compatible: bool
|
||||||
|
package_id: str | None = None
|
||||||
|
package_sha256: str | None = None
|
||||||
|
format_version: str | None = None
|
||||||
|
source: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
available_scopes: list[CampaignTransferScope] = Field(default_factory=list)
|
||||||
|
selected_scopes: list[CampaignTransferScope] = Field(default_factory=list)
|
||||||
|
destination: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
will_create: list[CampaignTransferPlanItem] = Field(default_factory=list)
|
||||||
|
will_skip: list[CampaignTransferPlanItem] = Field(default_factory=list)
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignImportApplyResponse(CampaignCreateResponse):
|
||||||
|
receipt: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class CampaignListResponse(BaseModel):
|
class CampaignListResponse(BaseModel):
|
||||||
campaigns: list[CampaignResponse]
|
campaigns: list[CampaignResponse]
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ from pydantic import BaseModel, ConfigDict
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.campaign.models import (
|
from govoplan_campaign.backend.campaign.models import (
|
||||||
DeliveryChannelPolicy,
|
DeliveryChannelPolicy,
|
||||||
DeliveryConfig,
|
DeliveryConfig,
|
||||||
@@ -22,8 +26,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|||||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
||||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||||
|
|
||||||
SNAPSHOT_VERSION = "8"
|
SNAPSHOT_VERSION = "9"
|
||||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
|
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
|
||||||
|
|
||||||
|
|
||||||
class ExecutionSnapshotError(RuntimeError):
|
class ExecutionSnapshotError(RuntimeError):
|
||||||
@@ -57,6 +61,7 @@ class ExecutionSnapshot(BaseModel):
|
|||||||
queueable_job_count: int = 0
|
queueable_job_count: int = 0
|
||||||
job_manifest_sha256: str | None = None
|
job_manifest_sha256: str | None = None
|
||||||
effective_policy_sha256: str | None = None
|
effective_policy_sha256: str | None = None
|
||||||
|
archive_encryption: dict[str, Any] | None = None
|
||||||
smtp_transport_revision: str | None = None
|
smtp_transport_revision: str | None = None
|
||||||
imap_transport_revision: str | None = None
|
imap_transport_revision: str | None = None
|
||||||
uses_mail: bool = True
|
uses_mail: bool = True
|
||||||
@@ -263,6 +268,7 @@ def create_execution_snapshot(
|
|||||||
imap_credential_id: str | None = None,
|
imap_credential_id: str | None = None,
|
||||||
jobs: Iterable[CampaignJob] = (),
|
jobs: Iterable[CampaignJob] = (),
|
||||||
build_summary: dict[str, Any] | None = None,
|
build_summary: dict[str, Any] | None = None,
|
||||||
|
archive_encryption: dict[str, Any] | None = None,
|
||||||
) -> tuple[dict[str, Any], str]:
|
) -> tuple[dict[str, Any], str]:
|
||||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||||
job_list = list(jobs)
|
job_list = list(jobs)
|
||||||
@@ -311,6 +317,7 @@ def create_execution_snapshot(
|
|||||||
delivery,
|
delivery,
|
||||||
snapshot_version=SNAPSHOT_VERSION,
|
snapshot_version=SNAPSHOT_VERSION,
|
||||||
),
|
),
|
||||||
|
archive_encryption=archive_encryption,
|
||||||
smtp_transport_revision=smtp_transport_revision,
|
smtp_transport_revision=smtp_transport_revision,
|
||||||
imap_transport_revision=imap_transport_revision,
|
imap_transport_revision=imap_transport_revision,
|
||||||
uses_mail=uses_mail,
|
uses_mail=uses_mail,
|
||||||
@@ -355,6 +362,39 @@ def _assert_snapshot_matches_persisted_inputs(
|
|||||||
"Revalidate and rebuild the campaign before delivery."
|
"Revalidate and rebuild the campaign before delivery."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
|
if campaign is None:
|
||||||
|
raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists")
|
||||||
|
try:
|
||||||
|
current_archive_policy = assert_archive_encryption_allowed(
|
||||||
|
session,
|
||||||
|
campaign,
|
||||||
|
raw_json,
|
||||||
|
)
|
||||||
|
except CampaignArchiveEncryptionError as exc:
|
||||||
|
raise ExecutionSnapshotError(str(exc)) from exc
|
||||||
|
archive_snapshot = snapshot.archive_encryption
|
||||||
|
configured_archives = (
|
||||||
|
((raw_json.get("attachments") or {}).get("zip") or {}).get("archives")
|
||||||
|
if isinstance(raw_json.get("attachments"), dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if configured_archives and not isinstance(archive_snapshot, dict):
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"Execution snapshot has no governed archive-encryption evidence; rebuild before delivery."
|
||||||
|
)
|
||||||
|
if isinstance(archive_snapshot, dict):
|
||||||
|
frozen_policy = archive_snapshot.get("policy")
|
||||||
|
frozen_hash = (
|
||||||
|
frozen_policy.get("policy_hash")
|
||||||
|
if isinstance(frozen_policy, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if frozen_hash != current_archive_policy.policy_hash:
|
||||||
|
raise ExecutionSnapshotError(
|
||||||
|
"The effective archive-encryption policy changed after build. Revalidate and rebuild before delivery."
|
||||||
|
)
|
||||||
|
|
||||||
if effect_job is not None:
|
if effect_job is not None:
|
||||||
if effect_job.campaign_version_id != version.id:
|
if effect_job.campaign_version_id != version.id:
|
||||||
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
||||||
@@ -494,6 +534,12 @@ def ensure_execution_snapshot(
|
|||||||
delivery=config.delivery,
|
delivery=config.delivery,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||||
|
archive_encryption=(
|
||||||
|
version.build_summary.get("archive_encryption")
|
||||||
|
if isinstance(version.build_summary, dict)
|
||||||
|
and isinstance(version.build_summary.get("archive_encryption"), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
version.execution_snapshot = payload
|
version.execution_snapshot = payload
|
||||||
version.execution_snapshot_hash = digest
|
version.execution_snapshot_hash = digest
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from email import policy
|
from email import policy
|
||||||
@@ -225,6 +226,11 @@ class SendCampaignNowResult:
|
|||||||
failed_count: int
|
failed_count: int
|
||||||
outcome_unknown_count: int
|
outcome_unknown_count: int
|
||||||
skipped_count: int
|
skipped_count: int
|
||||||
|
paused_count: int = 0
|
||||||
|
batch_state: str = "not_started"
|
||||||
|
batch_pause_reason_code: str | None = None
|
||||||
|
smtp_connection_count: int = 0
|
||||||
|
smtp_reconnect_count: int = 0
|
||||||
preflight_count: int = 0
|
preflight_count: int = 0
|
||||||
synchronous_send_policy: dict[str, Any] | None = None
|
synchronous_send_policy: dict[str, Any] | None = None
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
@@ -239,7 +245,12 @@ class SendCampaignNowResult:
|
|||||||
"failed_count": self.failed_count,
|
"failed_count": self.failed_count,
|
||||||
"outcome_unknown_count": self.outcome_unknown_count,
|
"outcome_unknown_count": self.outcome_unknown_count,
|
||||||
"skipped_count": self.skipped_count,
|
"skipped_count": self.skipped_count,
|
||||||
|
"paused_count": self.paused_count,
|
||||||
"preflight_count": self.preflight_count,
|
"preflight_count": self.preflight_count,
|
||||||
|
"batch_state": self.batch_state,
|
||||||
|
"batch_pause_reason_code": self.batch_pause_reason_code,
|
||||||
|
"smtp_connection_count": self.smtp_connection_count,
|
||||||
|
"smtp_reconnect_count": self.smtp_reconnect_count,
|
||||||
"delivery_mode": "synchronous",
|
"delivery_mode": "synchronous",
|
||||||
"synchronous_send_policy": self.synchronous_send_policy or {},
|
"synchronous_send_policy": self.synchronous_send_policy or {},
|
||||||
"dry_run": self.dry_run,
|
"dry_run": self.dry_run,
|
||||||
@@ -1070,18 +1081,27 @@ def send_campaign_now(
|
|||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
policy=synchronous_policy,
|
policy=synchronous_policy,
|
||||||
)
|
)
|
||||||
# Queue state and its inbox notification become durable only after every
|
|
||||||
# message and the selected transport revision have passed preflight. This
|
|
||||||
# preserves late-ack recovery without leaving rejected work eligible for a
|
|
||||||
# background worker.
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
failed_count = 0
|
failed_count = 0
|
||||||
outcome_unknown_count = 0
|
outcome_unknown_count = 0
|
||||||
skipped_after_queue = 0
|
skipped_after_queue = 0
|
||||||
for job in jobs:
|
attempted_count = 0
|
||||||
|
paused_count = 0
|
||||||
|
pause_reason_code: str | None = None
|
||||||
|
batch_state = "ready"
|
||||||
|
batch_manager = _synchronous_smtp_batch_manager(
|
||||||
|
session,
|
||||||
|
jobs=jobs,
|
||||||
|
contexts=delivery_contexts,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with batch_manager as smtp_batch:
|
||||||
|
# Queue state becomes durable only after local and SMTP
|
||||||
|
# DNS/connectivity/TLS/auth preflight succeeds.
|
||||||
|
session.commit()
|
||||||
|
for index, job in enumerate(jobs):
|
||||||
|
attempted_count += 1
|
||||||
try:
|
try:
|
||||||
result = _deliver_job_with_recovery(
|
result = _deliver_job_with_recovery(
|
||||||
session,
|
session,
|
||||||
@@ -1098,20 +1118,54 @@ def send_campaign_now(
|
|||||||
outcome_unknown_count += 1
|
outcome_unknown_count += 1
|
||||||
else:
|
else:
|
||||||
skipped_after_queue += 1
|
skipped_after_queue += 1
|
||||||
except Exception as exc: # keep sending other jobs and return per-job details
|
except Exception as exc:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
||||||
|
if isinstance(exc, SmtpSendError) and exc.systemic:
|
||||||
|
pause_reason_code = exc.reason_code or "smtp_systemic_failure"
|
||||||
|
paused_count = _pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session,
|
||||||
|
campaign_id=job.campaign_id,
|
||||||
|
exclude_job_id=job.id,
|
||||||
|
reason_code=pause_reason_code,
|
||||||
|
)
|
||||||
|
batch_state = "paused"
|
||||||
|
for remaining in jobs[index + 1 :]:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"job_id": remaining.id,
|
||||||
|
"status": "paused",
|
||||||
|
"message": "Batch paused after a systemic SMTP failure.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
break
|
||||||
|
smtp_connection_count = int(getattr(smtp_batch, "connection_count", 0) or 0)
|
||||||
|
smtp_reconnect_count = int(getattr(smtp_batch, "reconnect_count", 0) or 0)
|
||||||
|
except (MailProfileError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
|
||||||
|
session.rollback()
|
||||||
|
reason_code = str(getattr(exc, "reason_code", "") or "smtp_batch_preflight_failed")
|
||||||
|
raise SynchronousSendRejected(
|
||||||
|
"SMTP batch preflight could not validate DNS, connectivity, TLS, and authentication; no message was sent.",
|
||||||
|
reason=reason_code,
|
||||||
|
eligible_count=len(jobs),
|
||||||
|
policy=synchronous_policy,
|
||||||
|
) from exc
|
||||||
|
|
||||||
return SendCampaignNowResult(
|
return SendCampaignNowResult(
|
||||||
campaign_id=campaign.id,
|
campaign_id=campaign.id,
|
||||||
version_id=version.id,
|
version_id=version.id,
|
||||||
attempted_count=len(jobs),
|
attempted_count=attempted_count,
|
||||||
sent_count=sent_count,
|
sent_count=sent_count,
|
||||||
failed_count=failed_count,
|
failed_count=failed_count,
|
||||||
outcome_unknown_count=outcome_unknown_count,
|
outcome_unknown_count=outcome_unknown_count,
|
||||||
skipped_count=queue_result.skipped_count
|
skipped_count=queue_result.skipped_count
|
||||||
+ queue_result.blocked_count
|
+ queue_result.blocked_count
|
||||||
+ skipped_after_queue,
|
+ skipped_after_queue,
|
||||||
|
paused_count=paused_count,
|
||||||
|
batch_state=batch_state,
|
||||||
|
batch_pause_reason_code=pause_reason_code,
|
||||||
|
smtp_connection_count=smtp_connection_count,
|
||||||
|
smtp_reconnect_count=smtp_reconnect_count,
|
||||||
preflight_count=len(delivery_contexts),
|
preflight_count=len(delivery_contexts),
|
||||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||||
dry_run=False,
|
dry_run=False,
|
||||||
@@ -1193,6 +1247,100 @@ def _preflight_synchronous_send_batch(
|
|||||||
return contexts
|
return contexts
|
||||||
|
|
||||||
|
|
||||||
|
def _synchronous_smtp_batch_manager(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
jobs: list[CampaignJob],
|
||||||
|
contexts: dict[str, _SendJobDeliveryContext],
|
||||||
|
):
|
||||||
|
mail_items = [
|
||||||
|
(job, contexts[job.id])
|
||||||
|
for job in jobs
|
||||||
|
if DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
|
||||||
|
]
|
||||||
|
if not mail_items:
|
||||||
|
return nullcontext(None)
|
||||||
|
first_job, first_context = mail_items[0]
|
||||||
|
envelope_froms = {str(context.envelope_from or "") for _job, context in mail_items}
|
||||||
|
transport_keys = {
|
||||||
|
(
|
||||||
|
context.snapshot.mail_profile_id,
|
||||||
|
context.snapshot.smtp_transport_revision,
|
||||||
|
context.snapshot.smtp_server_id,
|
||||||
|
context.snapshot.smtp_credential_id,
|
||||||
|
)
|
||||||
|
for _job, context in mail_items
|
||||||
|
}
|
||||||
|
if len(envelope_froms) != 1 or "" in envelope_froms or len(transport_keys) != 1:
|
||||||
|
raise SynchronousSendRejected(
|
||||||
|
"A synchronous SMTP batch requires one frozen sender and transport selection.",
|
||||||
|
reason="smtp_batch_transport_mismatch",
|
||||||
|
eligible_count=len(jobs),
|
||||||
|
)
|
||||||
|
recipients = sorted(
|
||||||
|
{
|
||||||
|
recipient
|
||||||
|
for _job, context in mail_items
|
||||||
|
for recipient in context.envelope_recipients
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return mail_integration().campaign_smtp_batch(
|
||||||
|
session,
|
||||||
|
tenant_id=first_job.tenant_id,
|
||||||
|
campaign_id=first_job.campaign_id,
|
||||||
|
profile_id=first_context.snapshot.mail_profile_id,
|
||||||
|
envelope_from=str(first_context.envelope_from),
|
||||||
|
envelope_recipients=recipients,
|
||||||
|
from_header=_from_header_from_job(first_job),
|
||||||
|
expected_smtp_transport_revision=first_context.snapshot.smtp_transport_revision or "",
|
||||||
|
smtp_server_id=first_context.snapshot.smtp_server_id,
|
||||||
|
smtp_credential_id=first_context.snapshot.smtp_credential_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign_id: str,
|
||||||
|
exclude_job_id: str,
|
||||||
|
reason_code: str,
|
||||||
|
) -> int:
|
||||||
|
reason = f"SMTP batch paused after systemic failure ({reason_code[:80]})."
|
||||||
|
changed = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.campaign_id == campaign_id,
|
||||||
|
CampaignJob.id != exclude_job_id,
|
||||||
|
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||||
|
CampaignJob.send_status.in_(
|
||||||
|
[JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.update(
|
||||||
|
{
|
||||||
|
CampaignJob.queue_status: JobQueueStatus.PAUSED.value,
|
||||||
|
CampaignJob.last_error: reason,
|
||||||
|
},
|
||||||
|
synchronize_session=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
campaign = session.get(Campaign, campaign_id)
|
||||||
|
if changed and campaign is not None:
|
||||||
|
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||||
|
session.add(campaign)
|
||||||
|
audit_event(
|
||||||
|
session,
|
||||||
|
tenant_id=campaign.tenant_id if campaign is not None else None,
|
||||||
|
user_id=None,
|
||||||
|
action="campaign.smtp_batch_paused",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign_id,
|
||||||
|
details={"reason_code": reason_code[:80], "paused_count": int(changed)},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return int(changed)
|
||||||
|
|
||||||
|
|
||||||
def enqueue_existing_queued_jobs(
|
def enqueue_existing_queued_jobs(
|
||||||
session: Session, *, tenant_id: str, campaign_id: str
|
session: Session, *, tenant_id: str, campaign_id: str
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -3652,6 +3800,13 @@ def _send_claimed_mail_only_job(
|
|||||||
outcome_unknown = _record_smtp_send_error(
|
outcome_unknown = _record_smtp_send_error(
|
||||||
session, job=job, attempt=attempt, exc=exc
|
session, job=job, attempt=attempt, exc=exc
|
||||||
)
|
)
|
||||||
|
if exc.systemic:
|
||||||
|
_pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session,
|
||||||
|
campaign_id=job.campaign_id,
|
||||||
|
exclude_job_id=job.id,
|
||||||
|
reason_code=exc.reason_code or "smtp_systemic_failure",
|
||||||
|
)
|
||||||
if outcome_unknown is not None:
|
if outcome_unknown is not None:
|
||||||
return outcome_unknown
|
return outcome_unknown
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import binascii
|
import binascii
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
from importlib import metadata
|
||||||
import secrets
|
import secrets
|
||||||
import stat
|
import stat
|
||||||
import struct
|
import struct
|
||||||
@@ -49,6 +51,8 @@ def create_zip_archive(
|
|||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
members = _normalized_members(files)
|
members = _normalized_members(files)
|
||||||
if password:
|
if password:
|
||||||
|
if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}:
|
||||||
|
raise ValueError(f"Unsupported password-encryption method: {method}")
|
||||||
if method == ZIP_METHOD_STANDARD:
|
if method == ZIP_METHOD_STANDARD:
|
||||||
_create_zipcrypto_archive(output_path, members, password)
|
_create_zipcrypto_archive(output_path, members, password)
|
||||||
return output_path
|
return output_path
|
||||||
@@ -61,6 +65,51 @@ def create_zip_archive(
|
|||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def zip_archive_evidence(
|
||||||
|
output_path: Path,
|
||||||
|
members: Iterable[Path | ArchiveMember],
|
||||||
|
*,
|
||||||
|
password_protected: bool,
|
||||||
|
method: str,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return password-free, content-addressed evidence for one built archive."""
|
||||||
|
|
||||||
|
normalized = _normalized_members(members)
|
||||||
|
archive_bytes = output_path.read_bytes()
|
||||||
|
if password_protected and method == ZIP_METHOD_AES:
|
||||||
|
try:
|
||||||
|
implementation_version = metadata.version("pyzipper")
|
||||||
|
except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer
|
||||||
|
implementation_version = "unknown"
|
||||||
|
implementation = "pyzipper"
|
||||||
|
archive_format = "WinZip AES"
|
||||||
|
elif password_protected and method == ZIP_METHOD_STANDARD:
|
||||||
|
implementation = "govoplan-campaign.zipcrypto"
|
||||||
|
implementation_version = "1"
|
||||||
|
archive_format = "Legacy ZipCrypto"
|
||||||
|
else:
|
||||||
|
implementation = "python.zipfile"
|
||||||
|
implementation_version = "stdlib"
|
||||||
|
archive_format = "ZIP (unencrypted)"
|
||||||
|
return {
|
||||||
|
"format": archive_format,
|
||||||
|
"method": method if password_protected else "none",
|
||||||
|
"password_protected": password_protected,
|
||||||
|
"implementation": implementation,
|
||||||
|
"implementation_version": implementation_version,
|
||||||
|
"archive_sha256": hashlib.sha256(archive_bytes).hexdigest(),
|
||||||
|
"archive_size_bytes": len(archive_bytes),
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"name": archive_name,
|
||||||
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||||
|
"size_bytes": path.stat().st_size,
|
||||||
|
}
|
||||||
|
for path, archive_name in normalized
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
||||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,778 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import replace
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.persistence.versions import create_minimal_campaign
|
||||||
|
from govoplan_campaign.backend.route_support import _get_campaign_for_principal
|
||||||
|
from govoplan_campaign.backend.routes.assignments import (
|
||||||
|
_actor_label,
|
||||||
|
_mirror_assignment_to_tasks,
|
||||||
|
_notify_assignment,
|
||||||
|
_record_event,
|
||||||
|
_require_resolved_assignee,
|
||||||
|
_resolve_assignee,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignWorkAssigneeInput
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||||
|
from govoplan_core.core.automation import (
|
||||||
|
ActionDefinition,
|
||||||
|
ActionExecutionRequest,
|
||||||
|
ActionExecutionResult,
|
||||||
|
ActionPreview,
|
||||||
|
EffectDefinition,
|
||||||
|
EffectPreview,
|
||||||
|
ObservedEffect,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.campaigns import (
|
||||||
|
CampaignWorkHandoffInspection,
|
||||||
|
CampaignWorkHandoffRef,
|
||||||
|
CampaignWorkHandoffRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
|
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||||
|
from govoplan_core.security.time import utc_now
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_KEY = "campaigns.work.prepare"
|
||||||
|
ASSIGNMENT_EFFECT = "campaigns.work.assignment_created"
|
||||||
|
CAMPAIGN_EFFECT = "campaigns.work.campaign_created"
|
||||||
|
|
||||||
|
|
||||||
|
class SqlCampaignWorkOrchestrationProvider:
|
||||||
|
"""Campaign-owned adapter used through optional Core capabilities only."""
|
||||||
|
|
||||||
|
def __init__(self, *, registry: object | None = None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def action_definitions(self) -> tuple[ActionDefinition, ...]:
|
||||||
|
return (
|
||||||
|
ActionDefinition(
|
||||||
|
action_key=ACTION_KEY,
|
||||||
|
owner_module="campaigns",
|
||||||
|
description=(
|
||||||
|
"Reference or create a Campaign and open one authorization-neutral "
|
||||||
|
"accountable work hand-off."
|
||||||
|
),
|
||||||
|
input_schema_ref="govoplan/campaigns/work-handoff.v1",
|
||||||
|
required_scopes=(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
),
|
||||||
|
policy_checks=(
|
||||||
|
"campaign access is checked independently of assignment",
|
||||||
|
"the assignee must already have Campaign access",
|
||||||
|
"the expected Campaign revision must still be current",
|
||||||
|
),
|
||||||
|
risk_level="moderate",
|
||||||
|
reversibility="compensatable",
|
||||||
|
expected_effect_keys=(ASSIGNMENT_EFFECT, CAMPAIGN_EFFECT),
|
||||||
|
idempotency_strategy="caller_supplied",
|
||||||
|
audit_event_types=(
|
||||||
|
"campaign.assignment.created",
|
||||||
|
"campaign.created_minimal",
|
||||||
|
),
|
||||||
|
preview_required=True,
|
||||||
|
recovery_mode="atomic",
|
||||||
|
recovery_verification=(
|
||||||
|
"resolve the assignment by tenant and orchestration idempotency key",
|
||||||
|
"verify the exact Campaign version and assignment revisions",
|
||||||
|
"confirm the assigned principal still has independent Campaign access",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def effect_definitions(self) -> tuple[EffectDefinition, ...]:
|
||||||
|
return (
|
||||||
|
EffectDefinition(
|
||||||
|
effect_key=ASSIGNMENT_EFFECT,
|
||||||
|
owner_module="campaigns",
|
||||||
|
operation="created",
|
||||||
|
description="Create an accountable Campaign work assignment.",
|
||||||
|
resource_types=("campaign_work_assignment",),
|
||||||
|
audit_event_types=("campaign.assignment.created",),
|
||||||
|
compensation_hint="Cancel the open assignment through Campaign work.",
|
||||||
|
),
|
||||||
|
EffectDefinition(
|
||||||
|
effect_key=CAMPAIGN_EFFECT,
|
||||||
|
owner_module="campaigns",
|
||||||
|
operation="created",
|
||||||
|
description="Create a minimal Campaign draft when no campaign is referenced.",
|
||||||
|
resource_types=("campaign", "campaign_version"),
|
||||||
|
audit_event_types=("campaign.created_minimal",),
|
||||||
|
compensation_hint=(
|
||||||
|
"Delete the untouched draft under the normal Campaign lifecycle policy."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def preview_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ActionExecutionRequest,
|
||||||
|
) -> ActionPreview:
|
||||||
|
if request.action_key != ACTION_KEY:
|
||||||
|
return _blocked_preview("The Campaign work action is not supported.")
|
||||||
|
try:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
handoff = _request(request)
|
||||||
|
_preview_handoff(sql_session, api_principal, handoff)
|
||||||
|
except (HTTPException, TypeError, ValueError) as exc:
|
||||||
|
return _blocked_preview(_message(exc))
|
||||||
|
creating = handoff.campaign_id is None
|
||||||
|
effects = [
|
||||||
|
EffectPreview(
|
||||||
|
effect_key=ASSIGNMENT_EFFECT,
|
||||||
|
summary="Open one revision-bearing Campaign work assignment.",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if creating:
|
||||||
|
effects.insert(
|
||||||
|
0,
|
||||||
|
EffectPreview(
|
||||||
|
effect_key=CAMPAIGN_EFFECT,
|
||||||
|
summary="Create one minimal Campaign draft and initial version.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ActionPreview(
|
||||||
|
action_key=ACTION_KEY,
|
||||||
|
allowed=True,
|
||||||
|
summary=(
|
||||||
|
"Create a Campaign draft and open accountable work."
|
||||||
|
if creating
|
||||||
|
else "Reference the current Campaign revision and open accountable work."
|
||||||
|
),
|
||||||
|
risk_level="moderate",
|
||||||
|
reversibility="compensatable",
|
||||||
|
effects=tuple(effects),
|
||||||
|
policy_provenance=(
|
||||||
|
{
|
||||||
|
"code": "campaign_assignment_does_not_grant_access",
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"campaign_access_rechecked_on_resume": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
preview_ref=f"campaign-work-preview:{_request_hash(handoff)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_action(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ActionExecutionRequest,
|
||||||
|
) -> ActionExecutionResult:
|
||||||
|
if request.action_key != ACTION_KEY:
|
||||||
|
raise ValueError("The Campaign work action is not supported.")
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
handoff = _request(request)
|
||||||
|
ref = self.prepare_handoff(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
request=handoff,
|
||||||
|
)
|
||||||
|
effects = [
|
||||||
|
ObservedEffect(
|
||||||
|
effect_key=ASSIGNMENT_EFFECT,
|
||||||
|
operation="created",
|
||||||
|
resource_ref=ref.assignment_ref,
|
||||||
|
summary=(
|
||||||
|
"Reused the existing idempotent Campaign work assignment."
|
||||||
|
if ref.replayed
|
||||||
|
else "Created the Campaign work assignment."
|
||||||
|
),
|
||||||
|
metadata={"replayed": ref.replayed},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if not ref.replayed and handoff.campaign_id is None:
|
||||||
|
effects.insert(
|
||||||
|
0,
|
||||||
|
ObservedEffect(
|
||||||
|
effect_key=CAMPAIGN_EFFECT,
|
||||||
|
operation="created",
|
||||||
|
resource_ref=ref.campaign_ref,
|
||||||
|
summary="Created the minimal Campaign draft.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return ActionExecutionResult(
|
||||||
|
state="completed",
|
||||||
|
output=_ref_payload(ref),
|
||||||
|
observed_effects=tuple(effects),
|
||||||
|
audit_event_refs=(
|
||||||
|
str(ref.provenance["audit_event_ref"]),
|
||||||
|
)
|
||||||
|
if ref.provenance.get("audit_event_ref")
|
||||||
|
else (),
|
||||||
|
)
|
||||||
|
|
||||||
|
def prepare_handoff(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
) -> CampaignWorkHandoffRef:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
if api_principal.tenant_id != request.tenant_id:
|
||||||
|
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||||
|
request_hash = _request_hash(request)
|
||||||
|
existing = (
|
||||||
|
sql_session.query(CampaignWorkAssignment)
|
||||||
|
.filter(
|
||||||
|
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||||
|
CampaignWorkAssignment.orchestration_idempotency_key
|
||||||
|
== request.idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.orchestration_request_sha256 != request_hash:
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign hand-off idempotency key was already used for "
|
||||||
|
"different input."
|
||||||
|
)
|
||||||
|
campaign = _get_campaign_for_principal(
|
||||||
|
sql_session,
|
||||||
|
existing.campaign_id,
|
||||||
|
api_principal,
|
||||||
|
)
|
||||||
|
return _handoff_ref(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=existing,
|
||||||
|
registry=self._registry,
|
||||||
|
replayed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
campaign, version, created = _campaign_and_version(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
request,
|
||||||
|
create=True,
|
||||||
|
)
|
||||||
|
resolution = _resolve_assignee(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignee=CampaignWorkAssigneeInput(
|
||||||
|
type=request.assignee_kind,
|
||||||
|
id=request.assignee_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_require_resolved_assignee(resolution)
|
||||||
|
now = utc_now()
|
||||||
|
assignment = CampaignWorkAssignment(
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
reference_kind="campaign_version",
|
||||||
|
reference_id=version.id,
|
||||||
|
reference_label=f"Campaign version {version.version_number}",
|
||||||
|
purpose=request.purpose.strip(),
|
||||||
|
status="open",
|
||||||
|
due_at=request.due_at,
|
||||||
|
assignee_type=request.assignee_kind,
|
||||||
|
assignee_id=request.assignee_id.strip(),
|
||||||
|
assignee_label_snapshot=(resolution.label or request.assignee_id)[:500],
|
||||||
|
assignee_current_label=resolution.label,
|
||||||
|
assignee_resolution_state=resolution.state,
|
||||||
|
resolution_provenance={
|
||||||
|
**resolution.provenance,
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
"expected_campaign_revision": request.expected_campaign_revision,
|
||||||
|
},
|
||||||
|
resolution_checked_at=now,
|
||||||
|
assigned_by_user_id=api_principal.user.id,
|
||||||
|
assigned_by_label_snapshot=_actor_label(api_principal),
|
||||||
|
orchestration_idempotency_key=request.idempotency_key,
|
||||||
|
orchestration_request_sha256=request_hash,
|
||||||
|
orchestration_correlation_id=request.correlation_id,
|
||||||
|
workflow_instance_id=request.workflow_instance_id,
|
||||||
|
workflow_step_id=request.workflow_step_id,
|
||||||
|
)
|
||||||
|
sql_session.add(assignment)
|
||||||
|
sql_session.flush()
|
||||||
|
_record_event(
|
||||||
|
sql_session,
|
||||||
|
assignment=assignment,
|
||||||
|
principal=api_principal,
|
||||||
|
event_kind="assigned",
|
||||||
|
details={
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if request.mirror_to_tasks:
|
||||||
|
_mirror_assignment_to_tasks(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=assignment,
|
||||||
|
principal=api_principal,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assignment.task_mirror_status = "skipped"
|
||||||
|
_notify_assignment(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=assignment,
|
||||||
|
event_kind="assigned",
|
||||||
|
)
|
||||||
|
audit_ref = audit_from_principal(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
action="campaign.assignment.created",
|
||||||
|
object_type="campaign_work_assignment",
|
||||||
|
object_id=assignment.id,
|
||||||
|
details={
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_version_id": version.id,
|
||||||
|
"campaign_revision": version.edit_revision,
|
||||||
|
"resource_revision": assignment.resource_revision,
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"purpose_disclosed": False,
|
||||||
|
},
|
||||||
|
correlation_id=request.correlation_id,
|
||||||
|
causation_id=request.workflow_step_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
audit_from_principal(
|
||||||
|
sql_session,
|
||||||
|
api_principal,
|
||||||
|
action="campaign.created_minimal",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign.id,
|
||||||
|
details={
|
||||||
|
"version_id": version.id,
|
||||||
|
"external_id": campaign.external_id,
|
||||||
|
"source": "workflow",
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
},
|
||||||
|
correlation_id=request.correlation_id,
|
||||||
|
causation_id=request.workflow_step_id,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
sql_session.flush()
|
||||||
|
ref = _handoff_ref(
|
||||||
|
sql_session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignment=assignment,
|
||||||
|
registry=self._registry,
|
||||||
|
)
|
||||||
|
return replace(
|
||||||
|
ref,
|
||||||
|
provenance={**dict(ref.provenance), "audit_event_ref": audit_ref.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
def inspect_handoff(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
assignment_id: str,
|
||||||
|
expected_revision: int | None = None,
|
||||||
|
) -> CampaignWorkHandoffInspection:
|
||||||
|
try:
|
||||||
|
sql_session, api_principal = _context(session, principal)
|
||||||
|
except TypeError as exc:
|
||||||
|
return CampaignWorkHandoffInspection(allowed=False, reason=str(exc))
|
||||||
|
if api_principal.tenant_id != tenant_id:
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
reason="Campaign hand-off tenant does not match the principal.",
|
||||||
|
provenance={"code": "campaign_handoff_tenant_mismatch"},
|
||||||
|
)
|
||||||
|
assignment = sql_session.get(CampaignWorkAssignment, assignment_id)
|
||||||
|
if assignment is None or assignment.tenant_id != tenant_id:
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
reason="Campaign work assignment is unavailable.",
|
||||||
|
provenance={"code": "campaign_handoff_missing"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_get_campaign_for_principal(
|
||||||
|
sql_session,
|
||||||
|
assignment.campaign_id,
|
||||||
|
api_principal,
|
||||||
|
)
|
||||||
|
except HTTPException as exc:
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
reason=_message(exc),
|
||||||
|
provenance={
|
||||||
|
"code": "campaign_handoff_access_revoked",
|
||||||
|
"campaign_id": assignment.campaign_id,
|
||||||
|
"assignment_does_not_grant_access": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
expected_revision is not None
|
||||||
|
and assignment.resource_revision != expected_revision
|
||||||
|
):
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=False,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
action_url=_action_url(assignment),
|
||||||
|
assignment_ref=_assignment_ref(assignment),
|
||||||
|
reason="Campaign work assignment revision changed; reload its event.",
|
||||||
|
provenance={
|
||||||
|
"code": "campaign_handoff_revision_conflict",
|
||||||
|
"expected_revision": expected_revision,
|
||||||
|
"current_revision": assignment.resource_revision,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return CampaignWorkHandoffInspection(
|
||||||
|
allowed=True,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
action_url=_action_url(assignment),
|
||||||
|
assignment_ref=_assignment_ref(assignment),
|
||||||
|
provenance={
|
||||||
|
"code": "campaign_handoff_access_rechecked",
|
||||||
|
"campaign_id": assignment.campaign_id,
|
||||||
|
"assignment_does_not_grant_access": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _context(
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[Session, ApiPrincipal]:
|
||||||
|
if not isinstance(session, Session):
|
||||||
|
raise TypeError("Campaign work orchestration requires a SQLAlchemy Session.")
|
||||||
|
if not isinstance(principal, ApiPrincipal):
|
||||||
|
raise TypeError("Campaign work orchestration requires an API principal.")
|
||||||
|
return session, principal
|
||||||
|
|
||||||
|
|
||||||
|
def _request(request: ActionExecutionRequest) -> CampaignWorkHandoffRequest:
|
||||||
|
value = request.input
|
||||||
|
assignee = value.get("assignee")
|
||||||
|
if not isinstance(assignee, Mapping):
|
||||||
|
raise ValueError("Campaign work hand-offs require an assignee object.")
|
||||||
|
create = value.get("create_campaign")
|
||||||
|
if create is not None and not isinstance(create, Mapping):
|
||||||
|
raise ValueError("Campaign creation input must be an object.")
|
||||||
|
due_at = _date(value.get("due_at"))
|
||||||
|
return CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
idempotency_key=request.idempotency_key,
|
||||||
|
purpose=str(value.get("purpose") or ""),
|
||||||
|
assignee_kind=str(assignee.get("kind") or ""), # type: ignore[arg-type]
|
||||||
|
assignee_id=str(assignee.get("id") or ""),
|
||||||
|
campaign_id=_optional(value.get("campaign_id")),
|
||||||
|
create_external_id=_optional(create.get("external_id")) if create else None,
|
||||||
|
create_name=_optional(create.get("name")) if create else None,
|
||||||
|
create_description=(
|
||||||
|
_optional(create.get("description")) if create else None
|
||||||
|
),
|
||||||
|
expected_campaign_revision=_integer(
|
||||||
|
value.get("expected_campaign_revision")
|
||||||
|
),
|
||||||
|
due_at=due_at,
|
||||||
|
mirror_to_tasks=bool(value.get("mirror_to_tasks", True)),
|
||||||
|
correlation_id=request.invocation.correlation_id,
|
||||||
|
workflow_instance_id=_reference_id(
|
||||||
|
request.metadata.get("workflow_instance_ref"),
|
||||||
|
"workflow-instance:",
|
||||||
|
),
|
||||||
|
workflow_step_id=_reference_id(
|
||||||
|
request.metadata.get("workflow_step_ref"),
|
||||||
|
"workflow-step:",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _preview_handoff(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
) -> None:
|
||||||
|
if principal.tenant_id != request.tenant_id:
|
||||||
|
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||||
|
for scope in (
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
):
|
||||||
|
if not has_scope(principal, scope):
|
||||||
|
raise ValueError(f"Campaign work hand-off requires {scope}.")
|
||||||
|
existing = (
|
||||||
|
session.query(CampaignWorkAssignment)
|
||||||
|
.filter(
|
||||||
|
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||||
|
CampaignWorkAssignment.orchestration_idempotency_key
|
||||||
|
== request.idempotency_key,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if existing.orchestration_request_sha256 != _request_hash(request):
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign hand-off idempotency key was already used for different input."
|
||||||
|
)
|
||||||
|
_get_campaign_for_principal(session, existing.campaign_id, principal)
|
||||||
|
return
|
||||||
|
if request.campaign_id is None:
|
||||||
|
if request.assignee_kind != "account" or (
|
||||||
|
request.assignee_id != principal.account_id
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"A newly created Campaign can initially be assigned only to its "
|
||||||
|
"creating account; share it explicitly before assigning other principals."
|
||||||
|
)
|
||||||
|
duplicate = (
|
||||||
|
session.query(Campaign.id)
|
||||||
|
.filter(
|
||||||
|
Campaign.tenant_id == request.tenant_id,
|
||||||
|
Campaign.external_id == request.create_external_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if duplicate is not None:
|
||||||
|
raise ValueError("Campaign external ID already exists for this tenant.")
|
||||||
|
if request.expected_campaign_revision not in {None, 1}:
|
||||||
|
raise ValueError("A new Campaign starts at revision one.")
|
||||||
|
return
|
||||||
|
campaign, _version, _created = _campaign_and_version(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request,
|
||||||
|
create=False,
|
||||||
|
)
|
||||||
|
resolution = _resolve_assignee(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
assignee=CampaignWorkAssigneeInput(
|
||||||
|
type=request.assignee_kind,
|
||||||
|
id=request.assignee_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_require_resolved_assignee(resolution)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_and_version(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
request: CampaignWorkHandoffRequest,
|
||||||
|
*,
|
||||||
|
create: bool,
|
||||||
|
) -> tuple[Campaign, CampaignVersion, bool]:
|
||||||
|
if request.campaign_id is None:
|
||||||
|
if not create:
|
||||||
|
raise ValueError("Campaign creation is not available during preview.")
|
||||||
|
campaign, version = create_minimal_campaign(
|
||||||
|
session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
external_id=str(request.create_external_id),
|
||||||
|
name=str(request.create_name),
|
||||||
|
description=request.create_description,
|
||||||
|
current_flow="create",
|
||||||
|
current_step="basics",
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
return campaign, version, True
|
||||||
|
campaign = _get_campaign_for_principal(
|
||||||
|
session,
|
||||||
|
request.campaign_id,
|
||||||
|
principal,
|
||||||
|
)
|
||||||
|
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||||
|
if version is None or version.campaign_id != campaign.id:
|
||||||
|
raise ValueError("The Campaign current version is unavailable.")
|
||||||
|
if (
|
||||||
|
request.expected_campaign_revision is not None
|
||||||
|
and version.edit_revision != request.expected_campaign_revision
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Campaign revision changed; reload the Campaign before opening work."
|
||||||
|
)
|
||||||
|
return campaign, version, False
|
||||||
|
|
||||||
|
|
||||||
|
def _handoff_ref(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
campaign: Campaign,
|
||||||
|
assignment: CampaignWorkAssignment,
|
||||||
|
registry: object | None,
|
||||||
|
replayed: bool = False,
|
||||||
|
) -> CampaignWorkHandoffRef:
|
||||||
|
version = session.get(CampaignVersion, assignment.campaign_version_id)
|
||||||
|
if version is None or version.campaign_id != campaign.id:
|
||||||
|
raise ValueError("The pinned Campaign hand-off version is unavailable.")
|
||||||
|
return CampaignWorkHandoffRef(
|
||||||
|
tenant_id=assignment.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
campaign_revision=version.edit_revision,
|
||||||
|
assignment_id=assignment.id,
|
||||||
|
assignment_revision=assignment.resource_revision,
|
||||||
|
status=assignment.status, # type: ignore[arg-type]
|
||||||
|
action_url=_action_url(assignment),
|
||||||
|
campaign_ref=(
|
||||||
|
f"campaign:{campaign.id}:version:{version.id}:r{version.edit_revision}"
|
||||||
|
),
|
||||||
|
assignment_ref=_assignment_ref(assignment),
|
||||||
|
replayed=replayed,
|
||||||
|
optional_capabilities={
|
||||||
|
"tasks": _has_capability(registry, CAPABILITY_TASK_COMMANDS),
|
||||||
|
"notifications": _has_capability(
|
||||||
|
registry,
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
provenance={
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"campaign_access_checked": True,
|
||||||
|
"workflow_instance_id": assignment.workflow_instance_id,
|
||||||
|
"workflow_step_id": assignment.workflow_step_id,
|
||||||
|
"correlation_id": assignment.orchestration_correlation_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ref_payload(ref: CampaignWorkHandoffRef) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"campaign_id": ref.campaign_id,
|
||||||
|
"campaign_version_id": ref.campaign_version_id,
|
||||||
|
"campaign_revision": ref.campaign_revision,
|
||||||
|
"assignment_id": ref.assignment_id,
|
||||||
|
"assignment_revision": ref.assignment_revision,
|
||||||
|
"status": ref.status,
|
||||||
|
"action_url": ref.action_url,
|
||||||
|
"campaign_ref": ref.campaign_ref,
|
||||||
|
"assignment_ref": ref.assignment_ref,
|
||||||
|
"event_type": ref.event_type,
|
||||||
|
"replayed": ref.replayed,
|
||||||
|
"optional_capabilities": dict(ref.optional_capabilities),
|
||||||
|
"provenance": dict(ref.provenance),
|
||||||
|
"outcome": "success",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _request_hash(request: CampaignWorkHandoffRequest) -> str:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": request.tenant_id,
|
||||||
|
"purpose": request.purpose.strip(),
|
||||||
|
"assignee_kind": request.assignee_kind,
|
||||||
|
"assignee_id": request.assignee_id.strip(),
|
||||||
|
"campaign_id": request.campaign_id,
|
||||||
|
"create_external_id": request.create_external_id,
|
||||||
|
"create_name": request.create_name,
|
||||||
|
"create_description": request.create_description,
|
||||||
|
"expected_campaign_revision": request.expected_campaign_revision,
|
||||||
|
"due_at": request.due_at.isoformat() if request.due_at else None,
|
||||||
|
"mirror_to_tasks": request.mirror_to_tasks,
|
||||||
|
"correlation_id": request.correlation_id,
|
||||||
|
"workflow_instance_id": request.workflow_instance_id,
|
||||||
|
"workflow_step_id": request.workflow_step_id,
|
||||||
|
}
|
||||||
|
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _blocked_preview(reason: str) -> ActionPreview:
|
||||||
|
return ActionPreview(
|
||||||
|
action_key=ACTION_KEY,
|
||||||
|
allowed=False,
|
||||||
|
summary=reason,
|
||||||
|
risk_level="moderate",
|
||||||
|
reversibility="compensatable",
|
||||||
|
blockers=(reason,),
|
||||||
|
policy_provenance=(
|
||||||
|
{
|
||||||
|
"code": "campaign_work_handoff_blocked",
|
||||||
|
"reason": reason,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _message(exc: Exception) -> str:
|
||||||
|
if isinstance(exc, HTTPException):
|
||||||
|
detail = exc.detail
|
||||||
|
if isinstance(detail, Mapping):
|
||||||
|
return str(detail.get("explanation") or detail.get("code") or detail)
|
||||||
|
return str(detail)
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(value: object) -> datetime | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Campaign hand-off due date must use ISO 8601.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _integer(value: object) -> int | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, bool):
|
||||||
|
raise ValueError("Campaign revisions must be integers.")
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Campaign revisions must be integers.") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _optional(value: object) -> str | None:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
return candidate or None
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_id(value: object, prefix: str) -> str | None:
|
||||||
|
candidate = str(value or "").strip()
|
||||||
|
return candidate.removeprefix(prefix) or None if candidate.startswith(prefix) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _assignment_ref(assignment: CampaignWorkAssignment) -> str:
|
||||||
|
return f"campaign-work-assignment:{assignment.id}:r{assignment.resource_revision}"
|
||||||
|
|
||||||
|
|
||||||
|
def _action_url(assignment: CampaignWorkAssignment) -> str:
|
||||||
|
return (
|
||||||
|
f"/campaigns/{assignment.campaign_id}/work"
|
||||||
|
f"?assignment={assignment.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_capability(registry: object | None, name: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
registry is not None
|
||||||
|
and hasattr(registry, "has_capability")
|
||||||
|
and registry.has_capability(name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["ACTION_KEY", "SqlCampaignWorkOrchestrationProvider"]
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
|
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_workflow_definitions(
|
||||||
|
*,
|
||||||
|
module_version: str,
|
||||||
|
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||||
|
"""Return opt-in Campaign workflow templates owned by this module."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
WorkflowDefinitionContribution(
|
||||||
|
origin_module_id="campaigns",
|
||||||
|
origin_module_version=module_version,
|
||||||
|
definition_key="accountable-campaign-work-handoff",
|
||||||
|
name="Accountable Campaign work hand-off",
|
||||||
|
description=(
|
||||||
|
"Create or reference a Campaign, assign bounded work, and wait "
|
||||||
|
"for its revision-bearing completion, rejection, cancellation, "
|
||||||
|
"or timeout event."
|
||||||
|
),
|
||||||
|
graph=_campaign_work_handoff_graph(),
|
||||||
|
definition_kind="template",
|
||||||
|
scope_type="system",
|
||||||
|
inherit_to_lower_scopes=True,
|
||||||
|
allow_start=True,
|
||||||
|
allow_reuse=True,
|
||||||
|
allow_automation=False,
|
||||||
|
execution_mode="guided",
|
||||||
|
activate_on_install=False,
|
||||||
|
required_capabilities=(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,),
|
||||||
|
required_interfaces=("campaigns.work_orchestration",),
|
||||||
|
metadata={
|
||||||
|
"domain": "campaigns.accountable_work",
|
||||||
|
"state_owner": "campaigns",
|
||||||
|
"template_requires_configuration": True,
|
||||||
|
},
|
||||||
|
policy_metadata={
|
||||||
|
"assignment_authorization_neutral": True,
|
||||||
|
"campaign_access_rechecked_on_resume": True,
|
||||||
|
"navigation_does_not_complete_work": True,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_work_handoff_graph() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "workflow.start.manual",
|
||||||
|
"label": "Campaign work requested",
|
||||||
|
"position": {"x": 20, "y": 140},
|
||||||
|
"config": {
|
||||||
|
"input_schema_ref": "govoplan/campaigns/work-handoff.v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "prepare",
|
||||||
|
"type": "workflow.capability",
|
||||||
|
"label": "Prepare Campaign work",
|
||||||
|
"position": {"x": 250, "y": 140},
|
||||||
|
"config": {
|
||||||
|
"capability": CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
|
"operation": "campaigns.work.prepare",
|
||||||
|
"input_mapping": {
|
||||||
|
"campaign_id": "$input.campaign_id",
|
||||||
|
"create_campaign": "$input.create_campaign",
|
||||||
|
"expected_campaign_revision": (
|
||||||
|
"$input.expected_campaign_revision"
|
||||||
|
),
|
||||||
|
"purpose": "$input.purpose",
|
||||||
|
"assignee": "$input.assignee",
|
||||||
|
"due_at": "$input.due_at",
|
||||||
|
"mirror_to_tasks": "$input.mirror_to_tasks",
|
||||||
|
},
|
||||||
|
"idempotency_key": "workflow-step",
|
||||||
|
"failure_policy": "manual",
|
||||||
|
"view_surface_ids": ["campaigns.page.work"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "campaign_work",
|
||||||
|
"type": "workflow.external_handoff",
|
||||||
|
"label": "Complete Campaign work",
|
||||||
|
"position": {"x": 510, "y": 140},
|
||||||
|
"config": {
|
||||||
|
"provider_capability": (
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
|
),
|
||||||
|
"event_type": "campaign.work.changed",
|
||||||
|
"event_filter": {
|
||||||
|
"payload": {
|
||||||
|
"assignment_id": (
|
||||||
|
"$steps.prepare.execution.output.assignment_id"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"outcome_path": "payload.outcome",
|
||||||
|
"terminal_outcomes": {
|
||||||
|
"completed": "completed",
|
||||||
|
"rejected": "rejected",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
},
|
||||||
|
"observed_outcomes": [
|
||||||
|
"assigned",
|
||||||
|
"accepted",
|
||||||
|
"started",
|
||||||
|
"reassigned",
|
||||||
|
],
|
||||||
|
"external_id": (
|
||||||
|
"$steps.prepare.execution.output.assignment_id"
|
||||||
|
),
|
||||||
|
"expected_revision": (
|
||||||
|
"$steps.prepare.execution.output.assignment_revision"
|
||||||
|
),
|
||||||
|
"action_url": "$steps.prepare.execution.output.action_url",
|
||||||
|
"immutable_ref": (
|
||||||
|
"$steps.prepare.execution.output.assignment_ref"
|
||||||
|
),
|
||||||
|
"optional_capabilities": (
|
||||||
|
"$steps.prepare.execution.output.optional_capabilities"
|
||||||
|
),
|
||||||
|
"timeout_after": "$input.timeout_after",
|
||||||
|
"view_surface_ids": ["campaigns.page.work"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "completed",
|
||||||
|
"type": "workflow.end.completed",
|
||||||
|
"label": "Campaign work completed",
|
||||||
|
"position": {"x": 790, "y": 20},
|
||||||
|
"config": {"output_mapping": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rejected",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Campaign work rejected",
|
||||||
|
"position": {"x": 790, "y": 120},
|
||||||
|
"config": {"reason": "Campaign work was rejected"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cancelled",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Campaign work cancelled",
|
||||||
|
"position": {"x": 790, "y": 220},
|
||||||
|
"config": {"reason": "Campaign work was cancelled"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "timed_out",
|
||||||
|
"type": "workflow.end.cancelled",
|
||||||
|
"label": "Campaign work timed out",
|
||||||
|
"position": {"x": 790, "y": 320},
|
||||||
|
"config": {"reason": "Campaign work timed out"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"id": "start-prepare", "source": "start", "target": "prepare"},
|
||||||
|
{
|
||||||
|
"id": "prepare-work",
|
||||||
|
"source": "prepare",
|
||||||
|
"source_port": "success",
|
||||||
|
"target": "campaign_work",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-completed",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "completed",
|
||||||
|
"target": "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-rejected",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "rejected",
|
||||||
|
"target": "rejected",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-cancelled",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "cancelled",
|
||||||
|
"target": "cancelled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "work-timeout",
|
||||||
|
"source": "campaign_work",
|
||||||
|
"source_port": "timed_out",
|
||||||
|
"target": "timed_out",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"notation": "govoplan.workflow.native",
|
||||||
|
"domain": "campaigns.accountable_work",
|
||||||
|
"configuration_notes": (
|
||||||
|
"Provide either campaign_id or create_campaign and explicit null "
|
||||||
|
"values for unused optional inputs."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["campaign_workflow_definitions"]
|
||||||
@@ -21,12 +21,15 @@ from govoplan_campaign.backend.db.models import (
|
|||||||
AttachmentBlob,
|
AttachmentBlob,
|
||||||
AttachmentInstance,
|
AttachmentInstance,
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignCollaborationEntry,
|
||||||
CampaignIssue,
|
CampaignIssue,
|
||||||
CampaignJob,
|
CampaignJob,
|
||||||
CampaignMessageAction,
|
CampaignMessageAction,
|
||||||
CampaignMessageActionAttempt,
|
CampaignMessageActionAttempt,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
ImapAppendAttempt,
|
ImapAppendAttempt,
|
||||||
PostboxDeliveryAttempt,
|
PostboxDeliveryAttempt,
|
||||||
PrintOutputAttempt,
|
PrintOutputAttempt,
|
||||||
@@ -46,6 +49,136 @@ GROUP_ID = "group-1"
|
|||||||
|
|
||||||
|
|
||||||
class CampaignAccessProviderTests(unittest.TestCase):
|
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:
|
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
||||||
session = _session()
|
session = _session()
|
||||||
self.addCleanup(_close_session, session)
|
self.addCleanup(_close_session, session)
|
||||||
@@ -918,6 +1051,9 @@ def _session():
|
|||||||
Group.__table__,
|
Group.__table__,
|
||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
|
CampaignCollaborationEntry.__table__,
|
||||||
|
CampaignWorkAssignment.__table__,
|
||||||
|
CampaignWorkAssignmentEvent.__table__,
|
||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
CampaignJob.__table__,
|
CampaignJob.__table__,
|
||||||
CampaignIssue.__table__,
|
CampaignIssue.__table__,
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.archive_encryption import (
|
||||||
|
CampaignArchiveEncryptionError,
|
||||||
|
LEGACY_ZIPCRYPTO_SCOPE,
|
||||||
|
assert_archive_encryption_allowed,
|
||||||
|
effective_archive_encryption_policy,
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.policy import (
|
||||||
|
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||||
|
CampaignArchiveEncryptionDecision,
|
||||||
|
PolicySourceStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _PolicyProvider:
|
||||||
|
def __init__(self, methods: set[str]) -> None:
|
||||||
|
self.methods = methods
|
||||||
|
|
||||||
|
def resolve_campaign_archive_encryption(self, session=None, *, request):
|
||||||
|
del session, request
|
||||||
|
return CampaignArchiveEncryptionDecision(
|
||||||
|
allowed_password_encryption_methods=frozenset(self.methods),
|
||||||
|
allowed_password_delivery_channels=frozenset(
|
||||||
|
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||||
|
),
|
||||||
|
policy_hash="f" * 64,
|
||||||
|
source_path=(
|
||||||
|
PolicySourceStep(
|
||||||
|
scope_type="system",
|
||||||
|
label="System archive-encryption policy",
|
||||||
|
applied_fields=("allowed_password_encryption_methods",),
|
||||||
|
policy={"allowed_password_encryption_methods": sorted(self.methods)},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
|
||||||
|
|
||||||
|
def capability(self, name: str):
|
||||||
|
return self.provider if self.has_capability(name) else None
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:")
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
external_id="example",
|
||||||
|
name="Example",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
|
||||||
|
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
|
||||||
|
policy = effective_archive_encryption_policy(self.session, self.campaign)
|
||||||
|
self.assertFalse(policy.available)
|
||||||
|
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
_raw_archive("aes"),
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
_raw_archive("zip_standard", stamped=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
|
||||||
|
raw = _raw_archive("aes")
|
||||||
|
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.archive_encryption.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
decision = assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
raw,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"separate_mail",
|
||||||
|
decision.allowed_password_delivery_channels,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
|
||||||
|
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
|
||||||
|
candidate = _raw_archive("zip_standard")
|
||||||
|
candidate["attachments"]["zip"]["archives"][0].update(
|
||||||
|
{
|
||||||
|
"legacy_zipcrypto_acknowledged": True,
|
||||||
|
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
|
||||||
|
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
|
||||||
|
stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
{},
|
||||||
|
candidate,
|
||||||
|
principal=_principal(set()),
|
||||||
|
)
|
||||||
|
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
{},
|
||||||
|
candidate,
|
||||||
|
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||||
|
)
|
||||||
|
archive = stamped["attachments"]["zip"]["archives"][0]
|
||||||
|
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
|
||||||
|
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
|
||||||
|
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
|
||||||
|
assert_archive_encryption_allowed(
|
||||||
|
self.session,
|
||||||
|
self.campaign,
|
||||||
|
stamped,
|
||||||
|
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
|
||||||
|
archive = {
|
||||||
|
"id": "archive-1",
|
||||||
|
"method": method,
|
||||||
|
"password_enabled": True,
|
||||||
|
"password_delivery_channel": "separate_mail",
|
||||||
|
"legacy_zipcrypto_acknowledged": method == "zip_standard",
|
||||||
|
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
|
||||||
|
if method == "zip_standard"
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
if stamped:
|
||||||
|
archive.update(
|
||||||
|
{
|
||||||
|
"legacy_zipcrypto_acknowledged_by": "user-1",
|
||||||
|
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import nullcontext
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from unittest.mock import patch
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from sqlalchemy import Column, String, Table, create_engine
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
@@ -15,6 +17,7 @@ from govoplan_campaign.backend.campaign.scheduling import (
|
|||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
CampaignSchedule,
|
CampaignSchedule,
|
||||||
CampaignScheduleOccurrence,
|
CampaignScheduleOccurrence,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
@@ -71,6 +74,7 @@ class TestCampaignScheduling:
|
|||||||
groups,
|
groups,
|
||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
CampaignSchedule.__table__,
|
CampaignSchedule.__table__,
|
||||||
CampaignScheduleOccurrence.__table__,
|
CampaignScheduleOccurrence.__table__,
|
||||||
@@ -238,3 +242,289 @@ class TestCampaignScheduling:
|
|||||||
assert generated.settings == {"retention": "sealed"}
|
assert generated.settings == {"retention": "sealed"}
|
||||||
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
||||||
assert schedule.resource_revision == 2
|
assert schedule.resource_revision == 2
|
||||||
|
|
||||||
|
def test_autonomous_occurrences_allocate_commands_once_and_complete_bound(self):
|
||||||
|
context = SimpleNamespace(
|
||||||
|
snapshot=SimpleNamespace(
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="transport-1",
|
||||||
|
smtp_server_id="smtp-1",
|
||||||
|
smtp_credential_id="credential-1",
|
||||||
|
),
|
||||||
|
message_bytes=b"From: Sender <sender@example.test>\r\nTo: one@example.test\r\n\r\nHello",
|
||||||
|
envelope_from="sender@example.test",
|
||||||
|
envelope_recipients=["one@example.test"],
|
||||||
|
)
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
)
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = True
|
||||||
|
mail.delivery_command_summary.return_value = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "accepted",
|
||||||
|
"accepted_count": 1,
|
||||||
|
"refused_count": 0,
|
||||||
|
"failure_code": None,
|
||||||
|
}
|
||||||
|
mail.submit_delivery_command.side_effect = [
|
||||||
|
{"id": "command-1", "status": "pending", "duplicate": False},
|
||||||
|
{"id": "command-2", "status": "pending", "duplicate": False},
|
||||||
|
]
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
schedule.approved_execution_snapshot_hash = "a" * 64
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
validation = {
|
||||||
|
"execution_snapshot_hash": "a" * 64,
|
||||||
|
"approval_request_id": "approval-1",
|
||||||
|
"approval_subject_digest": "b" * 64,
|
||||||
|
"job_count": 1,
|
||||||
|
"job_manifest_sha256": "c" * 64,
|
||||||
|
}
|
||||||
|
patches = (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.validate_autonomous_schedule_source",
|
||||||
|
return_value=validation,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._autonomous_source_jobs",
|
||||||
|
return_value=[job],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._send_job_delivery_context",
|
||||||
|
return_value=context,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._synchronous_smtp_batch_manager",
|
||||||
|
return_value=nullcontext(None),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
),
|
||||||
|
patch("govoplan_campaign.backend.campaign.scheduling.audit_event"),
|
||||||
|
)
|
||||||
|
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
|
||||||
|
first = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
second = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert first["autonomous_prepared"] == 1
|
||||||
|
assert second["autonomous_prepared"] == 1
|
||||||
|
assert schedule.active is False
|
||||||
|
occurrences = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.order_by(CampaignScheduleOccurrence.scheduled_for)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
assert [item.status for item in occurrences] == ["accepted", "prepared"]
|
||||||
|
assert [item.delivery_command_ids for item in occurrences] == [
|
||||||
|
["command-1"],
|
||||||
|
["command-2"],
|
||||||
|
]
|
||||||
|
assert len({item.idempotency_key for item in occurrences}) == 2
|
||||||
|
assert [item.recovery_state for item in occurrences] == [
|
||||||
|
"complete",
|
||||||
|
"pending",
|
||||||
|
]
|
||||||
|
assert occurrences[0].evidence["source_campaign_id"] == "campaign-1"
|
||||||
|
assert occurrences[0].evidence["source_version_id"] == "version-1"
|
||||||
|
assert (
|
||||||
|
occurrences[0].evidence["source_snapshot_hash"]
|
||||||
|
== schedule.source_snapshot_hash
|
||||||
|
)
|
||||||
|
assert mail.submit_delivery_command.call_count == 2
|
||||||
|
|
||||||
|
def test_autonomous_unknown_outcome_pauses_without_resubmission(self):
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = True
|
||||||
|
mail.delivery_command_summary.return_value = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "outcome_unknown",
|
||||||
|
"accepted_count": 0,
|
||||||
|
"refused_count": 0,
|
||||||
|
"failure_code": "smtp_outcome_unknown",
|
||||||
|
}
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="occurrence-1",
|
||||||
|
delivery_command_ids=["command-1"],
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.commit()
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
), patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
|
||||||
|
) as notify:
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 6, 9, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
assert result["refreshed"]["uncertain"] == 1
|
||||||
|
assert result["selected"] == 0
|
||||||
|
assert occurrence.status == "uncertain"
|
||||||
|
assert occurrence.recovery_state == "operator_required"
|
||||||
|
assert schedule.active is False
|
||||||
|
assert schedule.last_outcome == "uncertain"
|
||||||
|
notify.assert_called_once()
|
||||||
|
mail.submit_delivery_command.assert_not_called()
|
||||||
|
|
||||||
|
def test_autonomous_pending_occurrence_defers_the_next_delivery(self):
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = True
|
||||||
|
mail.delivery_command_summary.return_value = {
|
||||||
|
"id": "command-1",
|
||||||
|
"status": "pending",
|
||||||
|
"accepted_count": 0,
|
||||||
|
"refused_count": 0,
|
||||||
|
"failure_code": None,
|
||||||
|
}
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
session.add(
|
||||||
|
CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="occurrence-1",
|
||||||
|
delivery_command_ids=["command-1"],
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
):
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["refreshed"]["checked"] == 1
|
||||||
|
assert result["deferred"] == 1
|
||||||
|
assert result["autonomous_prepared"] == 0
|
||||||
|
assert schedule.active is True
|
||||||
|
assert schedule.occurrence_count == 0
|
||||||
|
mail.submit_delivery_command.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_mail_recovery_capability_pauses_an_open_occurrence(self):
|
||||||
|
mail = Mock()
|
||||||
|
mail.durable_delivery_available = False
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.delivery_mode = "autonomous"
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=datetime(2026, 8, 6, 8, tzinfo=UTC),
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="occurrence-1",
|
||||||
|
delivery_command_ids=["command-1"],
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.mail_integration",
|
||||||
|
return_value=mail,
|
||||||
|
), patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling._notify_schedule_operator"
|
||||||
|
) as notify:
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["refreshed"]["uncertain"] == 1
|
||||||
|
assert occurrence.status == "uncertain"
|
||||||
|
assert occurrence.evidence["recovery_reason"] == (
|
||||||
|
"mail_delivery_outbox_unavailable"
|
||||||
|
)
|
||||||
|
assert schedule.active is False
|
||||||
|
notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_autonomous_source_requires_an_explicit_approval(self):
|
||||||
|
with self.SessionLocal() as session, patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.campaign_approval_gate",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
campaign = session.get(Campaign, "campaign-1")
|
||||||
|
version = session.get(CampaignVersion, "version-1")
|
||||||
|
assert campaign is not None and version is not None
|
||||||
|
from govoplan_campaign.backend.campaign.scheduling import (
|
||||||
|
validate_autonomous_schedule_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_autonomous_schedule_source(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "explicit Approval request" in str(exc)
|
||||||
|
else: # pragma: no cover - defensive assertion
|
||||||
|
raise AssertionError("Autonomous source validation unexpectedly passed")
|
||||||
|
|
||||||
|
def test_duplicate_occurrence_recovers_schedule_without_another_effect(self):
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
recorded = CampaignScheduleOccurrence(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=schedule.next_fire_at,
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key="existing-key",
|
||||||
|
recovery_state="pending",
|
||||||
|
)
|
||||||
|
session.add(recorded)
|
||||||
|
session.commit()
|
||||||
|
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||||
|
result = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
assert result["duplicates"] == 1
|
||||||
|
assert result["failed"] == 0
|
||||||
|
assert schedule.occurrence_count == 1
|
||||||
|
assert schedule.next_fire_at == datetime(2026, 8, 8, 8, tzinfo=UTC)
|
||||||
|
assert session.query(CampaignScheduleOccurrence).count() == 1
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from govoplan_campaign.backend.campaign.transfers import (
|
||||||
|
DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||||
|
build_campaign_portable_package,
|
||||||
|
canonical_sha256,
|
||||||
|
inspect_campaign_portable_package,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignIssue,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
|
||||||
|
from govoplan_campaign.backend.routes.transfers import (
|
||||||
|
export_campaign_package,
|
||||||
|
import_campaign_package,
|
||||||
|
preview_campaign_import,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignExportRequest,
|
||||||
|
CampaignImportApplyRequest,
|
||||||
|
CampaignImportPreviewRequest,
|
||||||
|
CampaignPortablePackageResponse,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
def _source() -> tuple[Campaign, CampaignVersion]:
|
||||||
|
raw_json = minimal_campaign_json(
|
||||||
|
external_id="monthly-notice",
|
||||||
|
name="Monthly notice",
|
||||||
|
description="Portable source",
|
||||||
|
)
|
||||||
|
raw_json["fields"] = [
|
||||||
|
{"name": "case_id", "type": "string"},
|
||||||
|
{"name": "private_code", "type": "password"},
|
||||||
|
]
|
||||||
|
raw_json["global_values"] = {
|
||||||
|
"office": "Permits",
|
||||||
|
"private_code": "must-not-leave-the-source",
|
||||||
|
}
|
||||||
|
raw_json["server"] = {
|
||||||
|
"mail_profile_id": "mail-profile-source",
|
||||||
|
"smtp_server_id": "smtp-source",
|
||||||
|
"smtp_credential_id": "credential-source",
|
||||||
|
}
|
||||||
|
raw_json["template"] = {
|
||||||
|
"subject": "Case {{case_id}}",
|
||||||
|
"text": "Hello",
|
||||||
|
"html": None,
|
||||||
|
}
|
||||||
|
raw_json["attachments"]["global"] = [
|
||||||
|
{"base_dir": ".", "file_filter": "notice.pdf", "required": True}
|
||||||
|
]
|
||||||
|
raw_json["entries"]["inline"] = [
|
||||||
|
{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "person@example.test"}],
|
||||||
|
"fields": {
|
||||||
|
"case_id": "A-1",
|
||||||
|
"private_code": "recipient-secret",
|
||||||
|
},
|
||||||
|
"attachments": [
|
||||||
|
{"base_dir": ".", "file_filter": "A-1.pdf", "required": True}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-source",
|
||||||
|
tenant_id="tenant-source",
|
||||||
|
external_id="monthly-notice",
|
||||||
|
name="Monthly notice",
|
||||||
|
description="Portable source",
|
||||||
|
status="completed",
|
||||||
|
settings={
|
||||||
|
"retention_days": 90,
|
||||||
|
"provider_token": "must-not-export",
|
||||||
|
},
|
||||||
|
mail_profile_policy={
|
||||||
|
"profile_id": "mail-profile-source",
|
||||||
|
"credential_id": "credential-source",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-source",
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_number=4,
|
||||||
|
raw_json=raw_json,
|
||||||
|
schema_version="1.0",
|
||||||
|
workflow_state="completed",
|
||||||
|
validation_summary={"ok": True, "error_count": 0},
|
||||||
|
build_summary={"built_count": 1},
|
||||||
|
editor_state={
|
||||||
|
"review_send": {
|
||||||
|
"inspection_complete": True,
|
||||||
|
"reviewed_message_keys": ["message-1"],
|
||||||
|
"issue_decisions": [
|
||||||
|
{
|
||||||
|
"decision": "accept",
|
||||||
|
"issue_codes": ["attachment_warning"],
|
||||||
|
"issue_fingerprint": "fingerprint-1",
|
||||||
|
"message_sha256": "a" * 64,
|
||||||
|
"reason": "Verified manually",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return campaign, version
|
||||||
|
|
||||||
|
|
||||||
|
def _job(campaign: Campaign, version: CampaignVersion) -> CampaignJob:
|
||||||
|
return CampaignJob(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
entry_index=0,
|
||||||
|
entry_id="recipient-1",
|
||||||
|
recipient_email="person@example.test",
|
||||||
|
message_id_header="<message@example.test>",
|
||||||
|
eml_sha256="b" * 64,
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
queue_status="cancelled",
|
||||||
|
send_status="smtp_accepted",
|
||||||
|
postbox_status="not_requested",
|
||||||
|
print_status="not_requested",
|
||||||
|
imap_status="appended",
|
||||||
|
attempt_count=1,
|
||||||
|
delivery_provenance={"route": "mail", "storage_key": "hidden"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_privacy_default_export_is_configuration_only_and_redacts_secrets() -> None:
|
||||||
|
campaign, version = _source()
|
||||||
|
|
||||||
|
package = build_campaign_portable_package(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||||
|
module_version="0.1.24",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert package["scopes"] == ["metadata", "template_config"]
|
||||||
|
assert set(package["payload"]) == {"metadata", "template_config"}
|
||||||
|
assert package["manifest"]["secrets_included"] is False
|
||||||
|
assert package["manifest"]["redactions"] == {
|
||||||
|
"deployment_credential_reference": 1,
|
||||||
|
"password_field_value": 2,
|
||||||
|
"sensitive_setting": 2,
|
||||||
|
}
|
||||||
|
template = package["payload"]["template_config"]
|
||||||
|
assert "private_code" not in template["configuration"]["global_values"]
|
||||||
|
assert "smtp_credential_id" not in template["configuration"]["server"]
|
||||||
|
assert "provider_token" not in template["campaign_settings"]
|
||||||
|
assert "credential_id" not in template["mail_profile_policy"]
|
||||||
|
serialized = CampaignPortablePackageResponse.model_validate(package).model_dump(
|
||||||
|
mode="json"
|
||||||
|
)
|
||||||
|
assert inspect_campaign_portable_package(
|
||||||
|
serialized,
|
||||||
|
selected_scopes=None,
|
||||||
|
external_id="serialized-import",
|
||||||
|
name="Serialized import",
|
||||||
|
).preview["compatible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_export_import_applies_configuration_but_never_replays_evidence() -> None:
|
||||||
|
campaign, version = _source()
|
||||||
|
package = build_campaign_portable_package(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
scopes=(
|
||||||
|
"metadata",
|
||||||
|
"template_config",
|
||||||
|
"recipients",
|
||||||
|
"attachments",
|
||||||
|
"review_state",
|
||||||
|
"delivery_history",
|
||||||
|
),
|
||||||
|
jobs=(_job(campaign, version),),
|
||||||
|
issues=(
|
||||||
|
CampaignIssue(
|
||||||
|
id="issue-1",
|
||||||
|
tenant_id=campaign.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
severity="warning",
|
||||||
|
code="attachment_warning",
|
||||||
|
message="Review attachment",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
module_version="0.1.24",
|
||||||
|
)
|
||||||
|
|
||||||
|
inspection = inspect_campaign_portable_package(
|
||||||
|
package,
|
||||||
|
selected_scopes=None,
|
||||||
|
external_id="monthly-notice-import",
|
||||||
|
name="Imported monthly notice",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inspection.preview["compatible"] is True
|
||||||
|
assert inspection.configuration is not None
|
||||||
|
assert inspection.configuration["campaign"] == {
|
||||||
|
"id": "monthly-notice-import",
|
||||||
|
"name": "Imported monthly notice",
|
||||||
|
"description": "Portable source",
|
||||||
|
"mode": "draft",
|
||||||
|
}
|
||||||
|
assert inspection.configuration["server"] == {}
|
||||||
|
assert inspection.configuration["entries"]["inline"][0]["to"] == [
|
||||||
|
{"email": "person@example.test"}
|
||||||
|
]
|
||||||
|
assert inspection.configuration["entries"]["inline"][0]["attachments"][0][
|
||||||
|
"file_filter"
|
||||||
|
] == "A-1.pdf"
|
||||||
|
assert "private_code" not in inspection.configuration["entries"]["inline"][0][
|
||||||
|
"fields"
|
||||||
|
]
|
||||||
|
skipped_codes = {item["code"] for item in inspection.preview["will_skip"]}
|
||||||
|
assert skipped_codes == {
|
||||||
|
"deployment_bound_mail_profile",
|
||||||
|
"operational_evidence_not_replayed",
|
||||||
|
}
|
||||||
|
assert package["payload"]["review_state"]["decision_count"] == 1
|
||||||
|
assert package["payload"]["delivery_history"]["jobs"][0][
|
||||||
|
"recipient_email"
|
||||||
|
] == "person@example.test"
|
||||||
|
assert "storage_key" not in package["payload"]["delivery_history"]["jobs"][0][
|
||||||
|
"delivery_provenance"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_preview_reports_unselected_recipient_attachment_rules() -> None:
|
||||||
|
campaign, version = _source()
|
||||||
|
package = build_campaign_portable_package(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
scopes=("metadata", "attachments", "recipients"),
|
||||||
|
module_version="0.1.24",
|
||||||
|
)
|
||||||
|
|
||||||
|
inspection = inspect_campaign_portable_package(
|
||||||
|
package,
|
||||||
|
selected_scopes=("metadata", "attachments"),
|
||||||
|
external_id="attachment-import",
|
||||||
|
name="Attachment import",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inspection.preview["compatible"] is True
|
||||||
|
skipped = {item["code"]: item for item in inspection.preview["will_skip"]}
|
||||||
|
assert skipped["recipient_scope_required"]["item_count"] == 1
|
||||||
|
assert skipped["scope_not_selected"]["scope"] == "recipients"
|
||||||
|
assert inspection.configuration is not None
|
||||||
|
assert inspection.configuration["entries"]["inline"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_preview_fails_closed_when_package_is_tampered() -> None:
|
||||||
|
campaign, version = _source()
|
||||||
|
package = build_campaign_portable_package(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||||
|
module_version="0.1.24",
|
||||||
|
)
|
||||||
|
tampered = copy.deepcopy(package)
|
||||||
|
tampered["payload"]["metadata"]["name"] = "Tampered"
|
||||||
|
|
||||||
|
inspection = inspect_campaign_portable_package(
|
||||||
|
tampered,
|
||||||
|
selected_scopes=None,
|
||||||
|
external_id="tampered-import",
|
||||||
|
name="Tampered",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inspection.preview["compatible"] is False
|
||||||
|
assert inspection.configuration is None
|
||||||
|
assert any("integrity checksum" in error for error in inspection.preview["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_preview_rejects_unsupported_campaign_schema_even_with_valid_checksum() -> None:
|
||||||
|
campaign, version = _source()
|
||||||
|
package = build_campaign_portable_package(
|
||||||
|
campaign=campaign,
|
||||||
|
version=version,
|
||||||
|
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
|
||||||
|
module_version="0.1.24",
|
||||||
|
)
|
||||||
|
package["source"]["campaign_schema_version"] = "2.0"
|
||||||
|
package["integrity"]["package_sha256"] = canonical_package_hash(package)
|
||||||
|
|
||||||
|
inspection = inspect_campaign_portable_package(
|
||||||
|
package,
|
||||||
|
selected_scopes=None,
|
||||||
|
external_id="future-import",
|
||||||
|
name="Future import",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert inspection.preview["compatible"] is False
|
||||||
|
assert any("schema version" in error for error in inspection.preview["errors"])
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_package_hash(package: dict[str, object]) -> str:
|
||||||
|
content = copy.deepcopy(package)
|
||||||
|
content.pop("integrity", None)
|
||||||
|
return canonical_sha256(content)
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
tenant_id = "tenant-1"
|
||||||
|
api_key = None
|
||||||
|
|
||||||
|
def __init__(self, *scopes: str) -> None:
|
||||||
|
self.user = SimpleNamespace(id="user-1", display_name="Importer")
|
||||||
|
self.scopes = frozenset(scopes)
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes or "tenant:*" in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def route_session() -> Session:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
access_users = Base.metadata.tables.get("access_users")
|
||||||
|
if access_users is None:
|
||||||
|
access_users = Table(
|
||||||
|
"access_users",
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
access_groups = Base.metadata.tables.get("access_groups")
|
||||||
|
if access_groups is None:
|
||||||
|
access_groups = Table(
|
||||||
|
"access_groups",
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[
|
||||||
|
access_users,
|
||||||
|
access_groups,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignShare.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
|
CampaignIssue.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||||
|
database = session_factory()
|
||||||
|
user_values = {"id": "user-1"}
|
||||||
|
if "tenant_id" in access_users.c:
|
||||||
|
user_values.update(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
email="user-1@example.test",
|
||||||
|
)
|
||||||
|
database.execute(access_users.insert().values(**user_values))
|
||||||
|
raw_json = minimal_campaign_json(external_id="source", name="Source")
|
||||||
|
raw_json["entries"]["inline"] = [
|
||||||
|
{"id": "one", "to": [{"email": "one@example.test"}]}
|
||||||
|
]
|
||||||
|
source = Campaign(
|
||||||
|
id="source-campaign",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
created_by_user_id="user-1",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
external_id="source",
|
||||||
|
name="Source",
|
||||||
|
status="draft",
|
||||||
|
current_version_id="source-version",
|
||||||
|
)
|
||||||
|
source_version = CampaignVersion(
|
||||||
|
id="source-version",
|
||||||
|
campaign_id=source.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json=raw_json,
|
||||||
|
)
|
||||||
|
database.add_all((source, source_version))
|
||||||
|
database.commit()
|
||||||
|
try:
|
||||||
|
yield database
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_route_enforces_recipient_export_scope(route_session: Session) -> None:
|
||||||
|
principal = _Principal(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as denied:
|
||||||
|
export_campaign_package(
|
||||||
|
"source-campaign",
|
||||||
|
"source-version",
|
||||||
|
CampaignExportRequest(scopes=["metadata", "recipients"]),
|
||||||
|
session=route_session,
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert denied.value.status_code == 403
|
||||||
|
assert denied.value.detail == "Missing scope: campaigns:recipient:export"
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_preview_and_apply_routes_keep_matching_provenance(
|
||||||
|
route_session: Session,
|
||||||
|
) -> None:
|
||||||
|
exporter = _Principal(
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:export",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:recipient:export",
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
|
||||||
|
active_session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.routes.transfers.audit_from_principal",
|
||||||
|
side_effect=commit_audit,
|
||||||
|
):
|
||||||
|
package = export_campaign_package(
|
||||||
|
"source-campaign",
|
||||||
|
"source-version",
|
||||||
|
CampaignExportRequest(scopes=["metadata", "template_config", "recipients"]),
|
||||||
|
session=route_session,
|
||||||
|
principal=exporter,
|
||||||
|
)
|
||||||
|
|
||||||
|
limited_importer = _Principal(
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:import",
|
||||||
|
)
|
||||||
|
limited_preview = preview_campaign_import(
|
||||||
|
CampaignImportPreviewRequest(package=package),
|
||||||
|
session=route_session,
|
||||||
|
principal=limited_importer,
|
||||||
|
)
|
||||||
|
assert limited_preview["compatible"] is True
|
||||||
|
assert "recipients" in limited_preview["selected_scopes"]
|
||||||
|
|
||||||
|
importer = _Principal(
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:campaign:import",
|
||||||
|
"campaigns:recipient:write",
|
||||||
|
"campaigns:recipient:import",
|
||||||
|
)
|
||||||
|
preview = preview_campaign_import(
|
||||||
|
CampaignImportPreviewRequest(package=package),
|
||||||
|
session=route_session,
|
||||||
|
principal=importer,
|
||||||
|
)
|
||||||
|
assert preview["compatible"] is True
|
||||||
|
assert preview["destination"]["external_id"] == "source-import"
|
||||||
|
|
||||||
|
def create_import(active_session: Session, **kwargs):
|
||||||
|
raw_json = kwargs["raw_json"]
|
||||||
|
destination = Campaign(
|
||||||
|
id="imported-campaign",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
created_by_user_id="user-1",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
external_id=raw_json["campaign"]["id"],
|
||||||
|
name=raw_json["campaign"]["name"],
|
||||||
|
status="draft",
|
||||||
|
current_version_id="imported-version",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="imported-version",
|
||||||
|
campaign_id=destination.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json=raw_json,
|
||||||
|
)
|
||||||
|
active_session.add_all((destination, version))
|
||||||
|
active_session.flush()
|
||||||
|
return destination, version
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.transfers.create_campaign_version_from_json",
|
||||||
|
side_effect=create_import,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.transfers.audit_from_principal",
|
||||||
|
side_effect=commit_audit,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.transfers._write_current_version_snapshot_if_available"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = import_campaign_package(
|
||||||
|
CampaignImportApplyRequest(
|
||||||
|
package=package,
|
||||||
|
selected_scopes=preview["selected_scopes"],
|
||||||
|
external_id=preview["destination"]["external_id"],
|
||||||
|
name=preview["destination"]["name"],
|
||||||
|
expected_package_sha256=preview["package_sha256"],
|
||||||
|
),
|
||||||
|
session=route_session,
|
||||||
|
principal=importer,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.campaign.external_id == "source-import"
|
||||||
|
assert response.receipt["package_id"] == package["package_id"]
|
||||||
|
assert response.receipt["package_sha256"] == package["integrity"]["package_sha256"]
|
||||||
|
imported = route_session.get(Campaign, "imported-campaign")
|
||||||
|
assert imported is not None
|
||||||
|
assert imported.settings["portable_import"]["package_id"] == package["package_id"]
|
||||||
|
assert route_session.query(CampaignJob).filter_by(campaign_id=imported.id).count() == 0
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
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_campaign.backend.work_orchestration import (
|
||||||
|
SqlCampaignWorkOrchestrationProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import GroupRef, PrincipalRef, UserRef
|
||||||
|
from govoplan_core.core.campaigns import CampaignWorkHandoffRequest
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.events import EventBus, event_bus_context
|
||||||
|
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 _api_principal(user_id: str, account_id: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=user_id,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
scopes=frozenset(
|
||||||
|
{
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:campaign:create",
|
||||||
|
"campaigns:assignment:read",
|
||||||
|
"campaigns:assignment:manage",
|
||||||
|
"campaigns:assignment:complete",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id=account_id),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=user_id,
|
||||||
|
display_name=f"User {user_id}",
|
||||||
|
email=f"{user_id}@example.test",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_workflow_provider_is_idempotent_emits_typed_events_and_rechecks_access(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
registry = _Registry(_Tasks(), _Notifications())
|
||||||
|
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
|
||||||
|
manager = _api_principal("user-1", "account-1")
|
||||||
|
assignee = _api_principal("user-2", "account-2")
|
||||||
|
request = CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
expected_campaign_revision=1,
|
||||||
|
idempotency_key="workflow-handoff-1",
|
||||||
|
purpose="Review the Campaign evidence",
|
||||||
|
assignee_kind="account",
|
||||||
|
assignee_id="account-2",
|
||||||
|
correlation_id="workflow-correlation-1",
|
||||||
|
workflow_instance_id="workflow-instance-1",
|
||||||
|
workflow_step_id="workflow-step-1",
|
||||||
|
)
|
||||||
|
bus = EventBus()
|
||||||
|
events = []
|
||||||
|
bus.subscribe("campaign.work.changed", events.append)
|
||||||
|
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.get_registry",
|
||||||
|
return_value=registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
|
||||||
|
return_value=SimpleNamespace(id="audit-workflow-1"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments.audit_from_principal",
|
||||||
|
side_effect=_commit_audit,
|
||||||
|
),
|
||||||
|
event_bus_context(bus),
|
||||||
|
):
|
||||||
|
created = provider.prepare_handoff(session, manager, request=request)
|
||||||
|
session.commit()
|
||||||
|
replayed = provider.prepare_handoff(session, manager, request=request)
|
||||||
|
accepted = transition_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.assignment_id,
|
||||||
|
CampaignWorkAssignmentTransitionRequest(
|
||||||
|
expected_revision=1,
|
||||||
|
action="accept",
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
)
|
||||||
|
completed = transition_campaign_work_assignment(
|
||||||
|
"campaign-1",
|
||||||
|
created.assignment_id,
|
||||||
|
CampaignWorkAssignmentTransitionRequest(
|
||||||
|
expected_revision=2,
|
||||||
|
action="complete",
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.replayed is False
|
||||||
|
assert replayed.replayed is True
|
||||||
|
assert created.assignment_id == replayed.assignment_id
|
||||||
|
assert created.campaign_ref == "campaign:campaign-1:version:version-1:r1"
|
||||||
|
assert created.assignment_ref.endswith(":r1")
|
||||||
|
assert created.optional_capabilities == {"tasks": True, "notifications": True}
|
||||||
|
assert session.query(CampaignWorkAssignment).filter(
|
||||||
|
CampaignWorkAssignment.orchestration_idempotency_key
|
||||||
|
== "workflow-handoff-1"
|
||||||
|
).count() == 1
|
||||||
|
assert accepted.status == "in_progress"
|
||||||
|
assert completed.status == "completed"
|
||||||
|
assert [event.payload["outcome"] for event in events] == [
|
||||||
|
"assigned",
|
||||||
|
"accepted",
|
||||||
|
"completed",
|
||||||
|
]
|
||||||
|
assert [event.payload["assignment_revision"] for event in events] == [1, 2, 3]
|
||||||
|
assert all(event.correlation_id == "workflow-correlation-1" for event in events)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.route_support._access_directory",
|
||||||
|
return_value=directory,
|
||||||
|
):
|
||||||
|
allowed = provider.inspect_handoff(
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
assignment_id=created.assignment_id,
|
||||||
|
expected_revision=3,
|
||||||
|
)
|
||||||
|
share = session.get(CampaignShare, "share-1")
|
||||||
|
assert share is not None
|
||||||
|
share.revoked_at = completed.updated_at
|
||||||
|
session.flush()
|
||||||
|
revoked = provider.inspect_handoff(
|
||||||
|
session,
|
||||||
|
assignee,
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
assignment_id=created.assignment_id,
|
||||||
|
expected_revision=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert allowed.allowed is True
|
||||||
|
assert revoked.allowed is False
|
||||||
|
assert revoked.provenance["code"] == "campaign_handoff_access_revoked"
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_provider_can_create_self_assigned_campaign_and_rejects_stale_revision(
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
directory = _Directory()
|
||||||
|
registry = _Registry()
|
||||||
|
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
|
||||||
|
manager = _api_principal("user-1", "account-1")
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments._access_directory",
|
||||||
|
return_value=directory,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.assignments.get_registry",
|
||||||
|
return_value=registry,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
|
||||||
|
return_value=SimpleNamespace(id="audit-workflow-create"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
created = provider.prepare_handoff(
|
||||||
|
session,
|
||||||
|
manager,
|
||||||
|
request=CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
create_external_id="workflow-created",
|
||||||
|
create_name="Workflow-created Campaign",
|
||||||
|
idempotency_key="workflow-create-1",
|
||||||
|
purpose="Prepare the Campaign",
|
||||||
|
assignee_kind="account",
|
||||||
|
assignee_id="account-1",
|
||||||
|
workflow_instance_id="workflow-instance-create",
|
||||||
|
workflow_step_id="workflow-step-create",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session.get(Campaign, created.campaign_id).external_id == "workflow-created"
|
||||||
|
version = session.get(CampaignVersion, "version-1")
|
||||||
|
assert version is not None
|
||||||
|
version.edit_revision = 2
|
||||||
|
with pytest.raises(ValueError, match="Campaign revision changed"):
|
||||||
|
provider.prepare_handoff(
|
||||||
|
session,
|
||||||
|
manager,
|
||||||
|
request=CampaignWorkHandoffRequest(
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
expected_campaign_revision=1,
|
||||||
|
idempotency_key="workflow-stale-1",
|
||||||
|
purpose="Review stale Campaign",
|
||||||
|
assignee_kind="account",
|
||||||
|
assignee_id="account-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")
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_orchestration_migration_is_repeatable() -> None:
|
||||||
|
assignments = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
|
||||||
|
)
|
||||||
|
orchestration = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"f3c7a9d2e6b1_v0123_campaign_work_orchestration"
|
||||||
|
)
|
||||||
|
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(assignments, "op", Operations(context)):
|
||||||
|
assignments.upgrade()
|
||||||
|
with patch.object(orchestration, "op", Operations(context)):
|
||||||
|
orchestration.upgrade()
|
||||||
|
orchestration.upgrade()
|
||||||
|
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_columns("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
indexes = {
|
||||||
|
item["name"]
|
||||||
|
for item in inspector.get_indexes("campaign_work_assignments")
|
||||||
|
}
|
||||||
|
assert {
|
||||||
|
"orchestration_idempotency_key",
|
||||||
|
"orchestration_request_sha256",
|
||||||
|
"orchestration_correlation_id",
|
||||||
|
"workflow_instance_id",
|
||||||
|
"workflow_step_id",
|
||||||
|
}.issubset(columns)
|
||||||
|
assert "uq_campaign_work_assignment_orchestration_key" in indexes
|
||||||
|
|
||||||
|
with patch.object(orchestration, "op", Operations(context)):
|
||||||
|
orchestration.downgrade()
|
||||||
|
assert "workflow_instance_id" not in {
|
||||||
|
item["name"]
|
||||||
|
for item in inspect(connection).get_columns(
|
||||||
|
"campaign_work_assignments"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -465,6 +465,8 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
|||||||
"campaign.template",
|
"campaign.template",
|
||||||
"campaign.template.content-library",
|
"campaign.template.content-library",
|
||||||
"campaigns.action.schedule-drafts",
|
"campaigns.action.schedule-drafts",
|
||||||
|
"campaigns.action.export-package",
|
||||||
|
"campaigns.action.import-package",
|
||||||
"campaign.attachments",
|
"campaign.attachments",
|
||||||
"campaign.attachments.reuse-policy",
|
"campaign.attachments.reuse-policy",
|
||||||
"campaign.attachments.residual-files",
|
"campaign.attachments.residual-files",
|
||||||
@@ -476,6 +478,19 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
|||||||
"campaign.report",
|
"campaign.report",
|
||||||
"campaign.audit",
|
"campaign.audit",
|
||||||
"campaign.json",
|
"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.reject",
|
||||||
|
"campaign.work.action.reassign",
|
||||||
|
"campaign.work.action.cancel",
|
||||||
|
"campaign.work.history",
|
||||||
}
|
}
|
||||||
|
|
||||||
assert len(ids) == len(set(ids))
|
assert len(ids) == len(set(ids))
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_documentation_has_complete_german_reference_copy() -> None:
|
||||||
|
for topic in get_manifest().documentation:
|
||||||
|
german = topic.translations.get("de", {})
|
||||||
|
assert all(german.get(field, "").strip() for field in ("title", "summary", "body")), topic.id
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
AttachmentBlob,
|
||||||
|
AttachmentInstance,
|
||||||
|
Campaign,
|
||||||
|
CampaignCollaborationEntry,
|
||||||
|
CampaignIssue,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignMessageAction,
|
||||||
|
CampaignMessageActionAttempt,
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
CampaignWorkAssignment,
|
||||||
|
CampaignWorkAssignmentEvent,
|
||||||
|
ImapAppendAttempt,
|
||||||
|
PostboxDeliveryAttempt,
|
||||||
|
PrintOutputAttempt,
|
||||||
|
RecipientImportMappingProfile,
|
||||||
|
SendAttempt,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.dsar_provider import (
|
||||||
|
CAMPAIGN_DSAR_CAPABILITY,
|
||||||
|
CampaignDsarProvider,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.manifest import manifest
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.privacy.dsar_workflow import (
|
||||||
|
DataSubjectRequest,
|
||||||
|
create_data_subject_request,
|
||||||
|
execute_data_subject_erasure,
|
||||||
|
plan_data_subject_erasure,
|
||||||
|
search_data_subject_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: CampaignDsarProvider,
|
||||||
|
*,
|
||||||
|
campaign_active: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.campaign_active = campaign_active
|
||||||
|
|
||||||
|
def capability_names(self):
|
||||||
|
return (CAMPAIGN_DSAR_CAPABILITY,)
|
||||||
|
|
||||||
|
def capability_owner(self, name):
|
||||||
|
self._assert_capability(name)
|
||||||
|
return "campaigns"
|
||||||
|
|
||||||
|
def tenant_entitlement_resolver(self):
|
||||||
|
campaign_active = self.campaign_active
|
||||||
|
|
||||||
|
class _Resolver:
|
||||||
|
@staticmethod
|
||||||
|
def resolve(session, tenant_id):
|
||||||
|
del session, tenant_id
|
||||||
|
return type(
|
||||||
|
"State",
|
||||||
|
(),
|
||||||
|
{"effective_modules": ("campaigns",) if campaign_active else ()},
|
||||||
|
)()
|
||||||
|
|
||||||
|
return _Resolver()
|
||||||
|
|
||||||
|
def require_tenant_capability(self, name, session, **kwargs):
|
||||||
|
del session, kwargs
|
||||||
|
self._assert_capability(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
def manifests(self):
|
||||||
|
return (type("Manifest", (), {"id": "campaigns"})(),)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assert_capability(name: str) -> None:
|
||||||
|
if name != CAMPAIGN_DSAR_CAPABILITY:
|
||||||
|
raise KeyError(name)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignDsarProviderTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite:///:memory:", future=True)
|
||||||
|
Base.metadata.create_all(
|
||||||
|
bind=self.engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
DataSubjectRequest.__table__,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignShare.__table__,
|
||||||
|
CampaignCollaborationEntry.__table__,
|
||||||
|
CampaignWorkAssignment.__table__,
|
||||||
|
CampaignWorkAssignmentEvent.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
|
CampaignIssue.__table__,
|
||||||
|
AttachmentBlob.__table__,
|
||||||
|
AttachmentInstance.__table__,
|
||||||
|
SendAttempt.__table__,
|
||||||
|
CampaignMessageAction.__table__,
|
||||||
|
CampaignMessageActionAttempt.__table__,
|
||||||
|
ImapAppendAttempt.__table__,
|
||||||
|
PostboxDeliveryAttempt.__table__,
|
||||||
|
PrintOutputAttempt.__table__,
|
||||||
|
RecipientImportMappingProfile.__table__,
|
||||||
|
CampaignSchedule.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.session = sessionmaker(bind=self.engine, future=True)()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
self.account = Account(
|
||||||
|
id="account-1",
|
||||||
|
email="subject@example.test",
|
||||||
|
normalized_email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
other_account = Account(
|
||||||
|
id="account-2",
|
||||||
|
email="other@example.test",
|
||||||
|
normalized_email="other@example.test",
|
||||||
|
display_name="Other",
|
||||||
|
)
|
||||||
|
self.user = User(
|
||||||
|
id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=self.account.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
display_name="Subject",
|
||||||
|
)
|
||||||
|
self.other_user = User(
|
||||||
|
id="membership-2",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id=other_account.id,
|
||||||
|
email="other@example.test",
|
||||||
|
display_name="Other",
|
||||||
|
)
|
||||||
|
self.campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
created_by_user_id=self.other_user.id,
|
||||||
|
owner_user_id=self.other_user.id,
|
||||||
|
external_id="privacy-notice",
|
||||||
|
name="Privacy notice",
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
self.version = CampaignVersion(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
workflow_state="built",
|
||||||
|
execution_snapshot_hash="a" * 64,
|
||||||
|
raw_json={
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "entry-subject",
|
||||||
|
"to": [
|
||||||
|
{
|
||||||
|
"email": "subject@example.test",
|
||||||
|
"name": "Subject Person",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cc": [
|
||||||
|
{
|
||||||
|
"email": "other@example.test",
|
||||||
|
"name": "Unrelated person",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": "private-rendered-body-do-not-export",
|
||||||
|
"password": "inline-secret-do-not-export",
|
||||||
|
"case_reference": "CASE-SUBJECT-1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "entry-other",
|
||||||
|
"to": [{"email": "other@example.test"}],
|
||||||
|
"private_value": "other-recipient-data-do-not-export",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.draft_version = CampaignVersion(
|
||||||
|
id="version-draft",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
version_number=2,
|
||||||
|
workflow_state="editing",
|
||||||
|
raw_json={
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "entry-draft-subject",
|
||||||
|
"to": [{"email": "subject@example.test"}],
|
||||||
|
"case_reference": "CASE-DRAFT-1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.job = CampaignJob(
|
||||||
|
id="job-subject",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
campaign_version_id=self.version.id,
|
||||||
|
entry_index=0,
|
||||||
|
entry_id="entry-subject",
|
||||||
|
recipient_email="Subject@Example.Test",
|
||||||
|
subject="Your governed notice",
|
||||||
|
eml_storage_key="private/eml/key-do-not-export",
|
||||||
|
eml_local_path="/private/message-do-not-export.eml",
|
||||||
|
eml_size_bytes=512,
|
||||||
|
eml_sha256="b" * 64,
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
queue_status="completed",
|
||||||
|
send_status="smtp_accepted",
|
||||||
|
postbox_status="accepted",
|
||||||
|
print_status="accepted",
|
||||||
|
imap_status="appended",
|
||||||
|
attempt_count=1,
|
||||||
|
queued_at=now,
|
||||||
|
sent_at=now,
|
||||||
|
claim_token="job-claim-do-not-export",
|
||||||
|
resolved_recipients={
|
||||||
|
"from": {"email": "sender@example.test"},
|
||||||
|
"to": [{"email": "subject@example.test", "name": "Subject"}],
|
||||||
|
"cc": [{"email": "other@example.test", "name": "Other"}],
|
||||||
|
"legacy": ["other@example.test", "subject@example.test"],
|
||||||
|
},
|
||||||
|
resolved_attachments=[
|
||||||
|
{"storage_key": "resolved-attachment-key-do-not-export"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
other_job = CampaignJob(
|
||||||
|
id="job-other",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
campaign_version_id=self.version.id,
|
||||||
|
entry_index=1,
|
||||||
|
entry_id="entry-other",
|
||||||
|
recipient_email="other@example.test",
|
||||||
|
subject="Other person's message",
|
||||||
|
build_status="built",
|
||||||
|
validation_status="ready",
|
||||||
|
)
|
||||||
|
tenant_two_campaign = Campaign(
|
||||||
|
id="campaign-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
external_id="other-tenant",
|
||||||
|
name="Other tenant data do not export",
|
||||||
|
)
|
||||||
|
tenant_two_version = CampaignVersion(
|
||||||
|
id="version-tenant-2",
|
||||||
|
campaign_id=tenant_two_campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json={
|
||||||
|
"entries": {"inline": [{"to": [{"email": "subject@example.test"}]}]}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
tenant_two_job = CampaignJob(
|
||||||
|
id="job-tenant-2",
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
campaign_id=tenant_two_campaign.id,
|
||||||
|
campaign_version_id=tenant_two_version.id,
|
||||||
|
entry_index=0,
|
||||||
|
recipient_email="subject@example.test",
|
||||||
|
)
|
||||||
|
self.issue = CampaignIssue(
|
||||||
|
id="issue-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
campaign_version_id=self.version.id,
|
||||||
|
job_id=self.job.id,
|
||||||
|
severity="warning",
|
||||||
|
code="delivery_warning",
|
||||||
|
message="issue-detail-do-not-export",
|
||||||
|
source="private-source-do-not-export",
|
||||||
|
behavior="review",
|
||||||
|
)
|
||||||
|
send_attempt = SendAttempt(
|
||||||
|
id="send-attempt-1",
|
||||||
|
job_id=self.job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
status="accepted",
|
||||||
|
claim_token="attempt-claim-do-not-export",
|
||||||
|
smtp_status_code=250,
|
||||||
|
smtp_response="smtp-response-do-not-export",
|
||||||
|
error_message="transport-detail-do-not-export",
|
||||||
|
started_at=now,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
postbox_attempt = PostboxDeliveryAttempt(
|
||||||
|
id="postbox-attempt-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
job_id=self.job.id,
|
||||||
|
target_key="target-key-do-not-export",
|
||||||
|
target_index=0,
|
||||||
|
attempt_number=1,
|
||||||
|
idempotency_key="postbox-idempotency-do-not-export",
|
||||||
|
status="accepted",
|
||||||
|
target_snapshot={"private": "snapshot-do-not-export"},
|
||||||
|
provider_delivery_id="delivery-1",
|
||||||
|
provider_message_id="message-1",
|
||||||
|
postbox_id="postbox-1",
|
||||||
|
address="subject@example.test",
|
||||||
|
evidence={"private": "postbox-evidence-do-not-export"},
|
||||||
|
started_at=now,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
print_attempt = PrintOutputAttempt(
|
||||||
|
id="print-attempt-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
job_id=self.job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
idempotency_key="print-idempotency-do-not-export",
|
||||||
|
status="accepted",
|
||||||
|
render_id="render-1",
|
||||||
|
artifact_sha256="c" * 64,
|
||||||
|
evidence={"private": "print-evidence-do-not-export"},
|
||||||
|
started_at=now,
|
||||||
|
finished_at=now,
|
||||||
|
)
|
||||||
|
self.share = CampaignShare(
|
||||||
|
id="share-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
target_type="user",
|
||||||
|
target_id=self.user.id,
|
||||||
|
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",
|
||||||
|
owner_user_id=self.user.id,
|
||||||
|
name="Subject mapping",
|
||||||
|
column_count=2,
|
||||||
|
headers=["email", "case_reference"],
|
||||||
|
normalized_headers=["email", "case_reference"],
|
||||||
|
ordered_header_fingerprint="d" * 64,
|
||||||
|
unordered_header_fingerprint="e" * 64,
|
||||||
|
delimiter=";",
|
||||||
|
header_rows=1,
|
||||||
|
quoted=True,
|
||||||
|
value_separators=",;|",
|
||||||
|
mappings=[
|
||||||
|
{"header": "email", "field": "to.0.email"},
|
||||||
|
{"secret": "profile-secret-do-not-export"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
blob = AttachmentBlob(
|
||||||
|
id="blob-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
sha256="f" * 64,
|
||||||
|
size_bytes=42,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
storage_bucket="private-bucket-do-not-export",
|
||||||
|
storage_key="private-attachment-key-do-not-export",
|
||||||
|
)
|
||||||
|
attachment = AttachmentInstance(
|
||||||
|
id="attachment-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
owner_user_id=self.other_user.id,
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
blob_id=blob.id,
|
||||||
|
logical_name="notice",
|
||||||
|
filename="notice.pdf",
|
||||||
|
tags=["notice"],
|
||||||
|
metadata_={"secret": "attachment-secret-do-not-export"},
|
||||||
|
)
|
||||||
|
schedule = CampaignSchedule(
|
||||||
|
id="schedule-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=self.campaign.id,
|
||||||
|
source_version_id=self.version.id,
|
||||||
|
created_by_user_id=self.other_user.id,
|
||||||
|
name="Recurring privacy notice",
|
||||||
|
delivery_mode="manual",
|
||||||
|
recurrence_kind="monthly",
|
||||||
|
starts_at=now,
|
||||||
|
next_fire_at=now,
|
||||||
|
max_occurrences=12,
|
||||||
|
source_snapshot={"private": "schedule-snapshot-do-not-export"},
|
||||||
|
source_snapshot_hash="1" * 64,
|
||||||
|
)
|
||||||
|
self.session.add_all(
|
||||||
|
[
|
||||||
|
self.account,
|
||||||
|
other_account,
|
||||||
|
self.user,
|
||||||
|
self.other_user,
|
||||||
|
self.campaign,
|
||||||
|
self.version,
|
||||||
|
self.draft_version,
|
||||||
|
self.job,
|
||||||
|
other_job,
|
||||||
|
tenant_two_campaign,
|
||||||
|
tenant_two_version,
|
||||||
|
tenant_two_job,
|
||||||
|
self.issue,
|
||||||
|
send_attempt,
|
||||||
|
postbox_attempt,
|
||||||
|
print_attempt,
|
||||||
|
self.share,
|
||||||
|
collaboration,
|
||||||
|
self.profile,
|
||||||
|
blob,
|
||||||
|
attachment,
|
||||||
|
schedule,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
self.provider = CampaignDsarProvider()
|
||||||
|
self.subject = DsarSubjectRef(
|
||||||
|
account_id=self.account.id,
|
||||||
|
membership_id=self.user.id,
|
||||||
|
email="subject@example.test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_manifest_publishes_protocol_conforming_provider(self) -> None:
|
||||||
|
provided_names = {item.name for item in manifest.provides_interfaces}
|
||||||
|
self.assertIn(CAMPAIGN_DSAR_CAPABILITY, provided_names)
|
||||||
|
provider = manifest.capability_factories[CAMPAIGN_DSAR_CAPABILITY](None)
|
||||||
|
self.assertIsInstance(provider, DsarProvider)
|
||||||
|
self.assertIn(
|
||||||
|
"campaigns.privacy.data-subject-requests",
|
||||||
|
{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,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
resource_types = {record.resource_type for record in records}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"campaign",
|
||||||
|
"campaign_version",
|
||||||
|
"campaign_recipient_job",
|
||||||
|
"campaign_message_artifact",
|
||||||
|
"campaign_delivery_issue",
|
||||||
|
"campaign_send_attempt",
|
||||||
|
"campaign_postbox_attempt",
|
||||||
|
"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
|
||||||
|
if record.resource_type == "campaign_report_projection"
|
||||||
|
)
|
||||||
|
self.assertEqual(1, report.data["matched_job_count"])
|
||||||
|
artifact = next(
|
||||||
|
record
|
||||||
|
for record in records
|
||||||
|
if record.resource_type == "campaign_message_artifact"
|
||||||
|
)
|
||||||
|
self.assertEqual("b" * 64, artifact.data["sha256"])
|
||||||
|
self.assertEqual(512, artifact.data["size_bytes"])
|
||||||
|
|
||||||
|
serialized = repr([record.to_dict() for record in records])
|
||||||
|
for hidden in (
|
||||||
|
"job-tenant-2",
|
||||||
|
"Other tenant data do not export",
|
||||||
|
"job-other",
|
||||||
|
"other@example.test",
|
||||||
|
"Unrelated person",
|
||||||
|
"other-recipient-data-do-not-export",
|
||||||
|
"private-rendered-body-do-not-export",
|
||||||
|
"inline-secret-do-not-export",
|
||||||
|
"private/eml/key-do-not-export",
|
||||||
|
"/private/message-do-not-export.eml",
|
||||||
|
"resolved-attachment-key-do-not-export",
|
||||||
|
"job-claim-do-not-export",
|
||||||
|
"issue-detail-do-not-export",
|
||||||
|
"private-source-do-not-export",
|
||||||
|
"attempt-claim-do-not-export",
|
||||||
|
"smtp-response-do-not-export",
|
||||||
|
"transport-detail-do-not-export",
|
||||||
|
"target-key-do-not-export",
|
||||||
|
"postbox-idempotency-do-not-export",
|
||||||
|
"snapshot-do-not-export",
|
||||||
|
"postbox-evidence-do-not-export",
|
||||||
|
"print-idempotency-do-not-export",
|
||||||
|
"print-evidence-do-not-export",
|
||||||
|
"profile-secret-do-not-export",
|
||||||
|
"private-bucket-do-not-export",
|
||||||
|
"private-attachment-key-do-not-export",
|
||||||
|
"attachment-secret-do-not-export",
|
||||||
|
"schedule-snapshot-do-not-export",
|
||||||
|
):
|
||||||
|
self.assertNotIn(hidden, serialized)
|
||||||
|
|
||||||
|
def test_conflicting_email_references_fail_closed_for_recipient_data(self) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=DsarSubjectRef(
|
||||||
|
email="subject@example.test",
|
||||||
|
external_references={"campaign.email": "other@example.test"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual((), records)
|
||||||
|
|
||||||
|
def test_plan_retains_evidence_and_limits_execution_to_reversible_data(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
|
||||||
|
kinds = {action.kind for action in actions}
|
||||||
|
self.assertTrue({"retain", "manual_review", "revoke", "delete"}.issubset(kinds))
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
action.action_id
|
||||||
|
== "campaigns:retain:campaign_recipient_job:job-subject"
|
||||||
|
for action in actions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
action.action_id == "campaigns:review:campaign_version:version-draft"
|
||||||
|
for action in actions
|
||||||
|
)
|
||||||
|
)
|
||||||
|
executable_ids = {action.action_id for action in actions if action.executable}
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"campaigns:revoke:campaign_share:share-1",
|
||||||
|
"campaigns:delete:recipient_import_mapping_profile:mapping-1",
|
||||||
|
},
|
||||||
|
executable_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_execution_is_revalidated_tenant_bound_and_idempotent(self) -> None:
|
||||||
|
actions = self._executable_actions()
|
||||||
|
|
||||||
|
wrong_tenant = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-2",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-wrong-tenant",
|
||||||
|
)
|
||||||
|
self.assertEqual({"blocked"}, {result.status for result in wrong_tenant})
|
||||||
|
|
||||||
|
first = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"executed"}, {result.status for result in first})
|
||||||
|
self.session.flush()
|
||||||
|
self.assertIsNotNone(self.share.revoked_at)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.session.get(RecipientImportMappingProfile, self.profile.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
repeated = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=actions,
|
||||||
|
request_id="dsar-1",
|
||||||
|
)
|
||||||
|
self.assertEqual({"unchanged"}, {result.status for result in repeated})
|
||||||
|
|
||||||
|
def test_execution_blocks_when_mapping_owner_changed_after_planning(self) -> None:
|
||||||
|
delete_action = next(
|
||||||
|
action
|
||||||
|
for action in self._executable_actions()
|
||||||
|
if action.resource_type == "recipient_import_mapping_profile"
|
||||||
|
)
|
||||||
|
self.profile.owner_user_id = self.other_user.id
|
||||||
|
self.session.flush()
|
||||||
|
|
||||||
|
result = self.provider.execute_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
actions=(delete_action,),
|
||||||
|
request_id="dsar-stale",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("blocked", result[0].status)
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.session.get(RecipientImportMappingProfile, self.profile.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_workflow_discovers_active_provider_and_skips_it_when_disabled(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
request = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-CAMPAIGN-1",
|
||||||
|
request_kind="access_and_erasure",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Respond to an authorized privacy request.",
|
||||||
|
legal_basis="Article 15 and 17 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
registry = _Registry(self.provider)
|
||||||
|
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=registry,
|
||||||
|
row=request,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
self.assertEqual("searched", request.status)
|
||||||
|
self.assertEqual(["campaigns"], request.coverage["covered_modules"])
|
||||||
|
self.assertEqual([], request.coverage["modules_without_provider"])
|
||||||
|
plan_data_subject_erasure(
|
||||||
|
self.session,
|
||||||
|
registry=registry,
|
||||||
|
row=request,
|
||||||
|
expected_revision=2,
|
||||||
|
)
|
||||||
|
executable_ids = [
|
||||||
|
action["action_id"]
|
||||||
|
for action in request.erasure_plan["actions"]
|
||||||
|
if action["executable"]
|
||||||
|
]
|
||||||
|
execute_data_subject_erasure(
|
||||||
|
self.session,
|
||||||
|
registry=registry,
|
||||||
|
row=request,
|
||||||
|
expected_revision=3,
|
||||||
|
action_ids=executable_ids,
|
||||||
|
)
|
||||||
|
self.assertEqual("completed", request.status)
|
||||||
|
|
||||||
|
disabled = create_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
reference="DSAR-CAMPAIGN-DISABLED",
|
||||||
|
request_kind="access",
|
||||||
|
subject=self.subject,
|
||||||
|
purpose="Verify disabled-module coverage.",
|
||||||
|
legal_basis="Article 15 GDPR",
|
||||||
|
due_at=None,
|
||||||
|
requested_by_account_id="privacy-officer",
|
||||||
|
)
|
||||||
|
search_data_subject_request(
|
||||||
|
self.session,
|
||||||
|
registry=_Registry(self.provider, campaign_active=False),
|
||||||
|
row=disabled,
|
||||||
|
expected_revision=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, disabled.search_result["record_count"])
|
||||||
|
self.assertEqual(
|
||||||
|
[CAMPAIGN_DSAR_CAPABILITY],
|
||||||
|
disabled.coverage["inactive_provider_capabilities"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _executable_actions(self):
|
||||||
|
records = self.provider.search_subject(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
)
|
||||||
|
actions = self.provider.plan_erasure(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
subject=self.subject,
|
||||||
|
records=records,
|
||||||
|
)
|
||||||
|
return tuple(action for action in actions if action.executable)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -64,6 +64,9 @@ class _Session:
|
|||||||
def query(self, _model):
|
def query(self, _model):
|
||||||
return _Query(self.jobs)
|
return _Query(self.jobs)
|
||||||
|
|
||||||
|
def get(self, _model, _identifier):
|
||||||
|
return SimpleNamespace(id="campaign-1")
|
||||||
|
|
||||||
|
|
||||||
def _snapshotted_version(job: SimpleNamespace):
|
def _snapshotted_version(job: SimpleNamespace):
|
||||||
version = SimpleNamespace(
|
version = SimpleNamespace(
|
||||||
@@ -87,9 +90,15 @@ def _snapshotted_version(job: SimpleNamespace):
|
|||||||
|
|
||||||
|
|
||||||
def _ensure(session: _Session, version) -> None:
|
def _ensure(session: _Session, version) -> None:
|
||||||
with patch(
|
with (
|
||||||
|
patch(
|
||||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
return_value=SimpleNamespace(available=False),
|
return_value=SimpleNamespace(available=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed",
|
||||||
|
return_value=SimpleNamespace(policy_hash="archive-policy"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
||||||
|
|
||||||
@@ -125,12 +134,19 @@ def test_effect_check_verifies_only_the_claimed_job_in_constant_time() -> None:
|
|||||||
job = _job()
|
job = _job()
|
||||||
version = _snapshotted_version(job)
|
version = _snapshotted_version(job)
|
||||||
session = SimpleNamespace(
|
session = SimpleNamespace(
|
||||||
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job")
|
query=lambda *_args: pytest.fail("per-effect validation must not rescan every campaign job"),
|
||||||
|
get=lambda *_args: SimpleNamespace(id="campaign-1"),
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
|
patch(
|
||||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
return_value=SimpleNamespace(available=False),
|
return_value=SimpleNamespace(available=False),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.sending.execution.assert_archive_encryption_allowed",
|
||||||
|
return_value=SimpleNamespace(policy_hash="archive-policy"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
ensure_execution_snapshot(
|
ensure_execution_snapshot(
|
||||||
session, # type: ignore[arg-type]
|
session, # type: ignore[arg-type]
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
|
|||||||
delivery=DeliveryConfig(),
|
delivery=DeliveryConfig(),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert payload["snapshot_version"] == "8"
|
assert payload["snapshot_version"] == "9"
|
||||||
assert payload["mail_profile_id"] == "profile-1"
|
assert payload["mail_profile_id"] == "profile-1"
|
||||||
assert "smtp" not in payload
|
assert "smtp" not in payload
|
||||||
assert "imap" not in payload
|
assert "imap" not in payload
|
||||||
|
|||||||
@@ -73,3 +73,21 @@ def test_reusable_template_library_is_not_owned_by_campaign() -> None:
|
|||||||
assert "/templates" not in {item.path for item in manifest.nav_items}
|
assert "/templates" not in {item.path for item in manifest.nav_items}
|
||||||
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
|
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
|
||||||
assert "/templates" not in {route.path for route in manifest.frontend.routes}
|
assert "/templates" not in {route.path for route in manifest.frontend.routes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_quick_access_selects_exact_campaigns_only_for_active_cases() -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
assert manifest.frontend is not None
|
||||||
|
|
||||||
|
tool = next(
|
||||||
|
item
|
||||||
|
for item in manifest.frontend.quick_access_tools
|
||||||
|
if item.id == "campaigns.select"
|
||||||
|
)
|
||||||
|
assert tool.availability == "active_object"
|
||||||
|
assert tool.accepted_reference_kinds == ("cases.case",)
|
||||||
|
assert tool.returned_reference_kinds == ("campaigns.campaign",)
|
||||||
|
assert tool.modes == ("select",)
|
||||||
|
assert "campaigns.workflow.link-exact-campaign-to-case" in {
|
||||||
|
topic.id for topic in manifest.documentation
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,3 +138,39 @@ def test_eml_retention_removes_only_terminal_artifact(tmp_path) -> None:
|
|||||||
assert job.eml_local_path is None
|
assert job.eml_local_path is None
|
||||||
assert job.eml_storage_key is None
|
assert job.eml_storage_key is None
|
||||||
session.add.assert_called_once_with(job)
|
session.add.assert_called_once_with(job)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eml_retention_preserves_unfinished_autonomous_schedule_source(tmp_path) -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
eml_path = tmp_path / "approved-source.eml"
|
||||||
|
eml_path.write_bytes(b"approved message")
|
||||||
|
job = SimpleNamespace(
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
updated_at=now - timedelta(days=10),
|
||||||
|
queue_status="draft",
|
||||||
|
send_status="smtp_accepted",
|
||||||
|
imap_status="appended",
|
||||||
|
eml_local_path=str(eml_path),
|
||||||
|
eml_storage_key=None,
|
||||||
|
)
|
||||||
|
schedule_query = MagicMock()
|
||||||
|
schedule_query.filter.return_value.all.return_value = [("version-1",)]
|
||||||
|
job_query = MagicMock()
|
||||||
|
job_query.filter.return_value.order_by.return_value.all.return_value = [job]
|
||||||
|
session = MagicMock()
|
||||||
|
session.query.side_effect = [schedule_query, job_query]
|
||||||
|
policy = SimpleNamespace(generated_eml_retention_days=1)
|
||||||
|
|
||||||
|
result = _apply_eml_retention(
|
||||||
|
session,
|
||||||
|
dry_run=False,
|
||||||
|
now=now,
|
||||||
|
policy_for_campaign_id=lambda _campaign_id: policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["skipped_schedule_source"] == 1
|
||||||
|
assert result["metadata_cleared"] == 0
|
||||||
|
assert eml_path.exists()
|
||||||
|
assert job.eml_local_path == str(eml_path)
|
||||||
|
session.add.assert_not_called()
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ from collections import Counter
|
|||||||
|
|
||||||
from govoplan_campaign.backend.router import router
|
from govoplan_campaign.backend.router import router
|
||||||
from govoplan_campaign.backend.routes.attachments import router as attachments_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.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.delivery import router as delivery_router
|
||||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||||
from govoplan_campaign.backend.routes.schedules import router as schedules_router
|
from govoplan_campaign.backend.routes.schedules import router as schedules_router
|
||||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||||
|
from govoplan_campaign.backend.routes.transfers import router as transfers_router
|
||||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +28,10 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
|||||||
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||||
workflow_routers = (
|
workflow_routers = (
|
||||||
operations_router,
|
operations_router,
|
||||||
|
transfers_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
assignments_router,
|
||||||
|
collaboration_router,
|
||||||
versions_router,
|
versions_router,
|
||||||
jobs_router,
|
jobs_router,
|
||||||
reports_router,
|
reports_router,
|
||||||
@@ -42,7 +48,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
|||||||
actual = _operation_keys(router)
|
actual = _operation_keys(router)
|
||||||
|
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
assert len(actual) == 80
|
assert len(actual) == 96
|
||||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +59,12 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
|
|||||||
("POST", "/campaigns/operations/artifacts/reconcile"),
|
("POST", "/campaigns/operations/artifacts/reconcile"),
|
||||||
),
|
),
|
||||||
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||||
|
(
|
||||||
|
transfers_router,
|
||||||
|
("POST", "/campaign-transfers/imports/preview"),
|
||||||
|
),
|
||||||
|
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
|
||||||
|
(assignments_router, ("POST", "/campaigns/{campaign_id}/assignments")),
|
||||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
|||||||
failed_count=1,
|
failed_count=1,
|
||||||
outcome_unknown_count=0,
|
outcome_unknown_count=0,
|
||||||
skipped_count=0,
|
skipped_count=0,
|
||||||
|
paused_count=1,
|
||||||
|
batch_state="paused",
|
||||||
|
batch_pause_reason_code="smtp_authentication_failed",
|
||||||
|
smtp_connection_count=1,
|
||||||
|
smtp_reconnect_count=0,
|
||||||
preflight_count=2,
|
preflight_count=2,
|
||||||
synchronous_send_policy={
|
synchronous_send_policy={
|
||||||
"max_recipient_jobs": 25,
|
"max_recipient_jobs": 25,
|
||||||
@@ -113,7 +118,12 @@ def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() ->
|
|||||||
"failed_count",
|
"failed_count",
|
||||||
"outcome_unknown_count",
|
"outcome_unknown_count",
|
||||||
"skipped_count",
|
"skipped_count",
|
||||||
|
"paused_count",
|
||||||
"preflight_count",
|
"preflight_count",
|
||||||
|
"batch_state",
|
||||||
|
"batch_pause_reason_code",
|
||||||
|
"smtp_connection_count",
|
||||||
|
"smtp_reconnect_count",
|
||||||
"delivery_mode",
|
"delivery_mode",
|
||||||
"dry_run",
|
"dry_run",
|
||||||
"synchronous_send_policy",
|
"synchronous_send_policy",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
JobBuildStatus,
|
JobBuildStatus,
|
||||||
@@ -17,6 +17,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
|||||||
_select_campaign_jobs_for_queue,
|
_select_campaign_jobs_for_queue,
|
||||||
_send_claimed_campaign_job,
|
_send_claimed_campaign_job,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.integrations import SmtpSendError
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
@@ -220,6 +221,62 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
|||||||
self.assertTrue(session.rolled_back)
|
self.assertTrue(session.rolled_back)
|
||||||
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
|
self.assertIn("Automatic retry is stopped", mark_unknown.call_args.kwargs["reason"])
|
||||||
|
|
||||||
|
def test_systemic_smtp_failure_pauses_remaining_campaign_jobs(self):
|
||||||
|
job = SimpleNamespace(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
campaign_version_id="version-1",
|
||||||
|
delivery_channel_policy="mail",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
snapshot=SimpleNamespace(
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_server_id=None,
|
||||||
|
smtp_credential_id=None,
|
||||||
|
smtp_transport_revision="revision-1",
|
||||||
|
delivery=SimpleNamespace(rate_limit=SimpleNamespace(messages_per_minute=60)),
|
||||||
|
),
|
||||||
|
message_bytes=b"message",
|
||||||
|
envelope_from="sender@example.test",
|
||||||
|
envelope_recipients=["recipient@example.test"],
|
||||||
|
)
|
||||||
|
|
||||||
|
class Mail:
|
||||||
|
def wait_for_rate_limit(self, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def send_campaign_email_bytes(self, *_args, **_kwargs):
|
||||||
|
raise SmtpSendError(
|
||||||
|
"SMTP authentication failed.",
|
||||||
|
systemic=True,
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=SimpleNamespace(attempt_number=1)),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._record_smtp_send_error", return_value=None),
|
||||||
|
patch("govoplan_campaign.backend.sending.jobs._pause_jobs_after_systemic_smtp_failure", return_value=4) as pause,
|
||||||
|
self.assertRaises(SmtpSendError),
|
||||||
|
):
|
||||||
|
_send_claimed_campaign_job(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
job=job, # type: ignore[arg-type]
|
||||||
|
claim_token="claim-1",
|
||||||
|
context=context, # type: ignore[arg-type]
|
||||||
|
use_rate_limit=False,
|
||||||
|
enqueue_imap_task=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
pause.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
exclude_job_id="job-1",
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@ from govoplan_campaign.backend.sending.jobs import (
|
|||||||
QueueCampaignResult,
|
QueueCampaignResult,
|
||||||
SynchronousSendRejected,
|
SynchronousSendRejected,
|
||||||
_ensure_synchronous_send_count_allowed,
|
_ensure_synchronous_send_count_allowed,
|
||||||
|
_pause_jobs_after_systemic_smtp_failure,
|
||||||
_preflight_synchronous_send_batch,
|
_preflight_synchronous_send_batch,
|
||||||
|
_synchronous_smtp_batch_manager,
|
||||||
queue_campaign_jobs,
|
queue_campaign_jobs,
|
||||||
send_campaign_now,
|
send_campaign_now,
|
||||||
synchronous_send_candidate_jobs,
|
synchronous_send_candidate_jobs,
|
||||||
@@ -364,6 +367,72 @@ def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
|
|||||||
provider.send_campaign_email_bytes.assert_not_called()
|
provider.send_campaign_email_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_batch_manager_preflights_combined_recipients_and_frozen_transport() -> None:
|
||||||
|
snapshot = SimpleNamespace(
|
||||||
|
uses_mail=True,
|
||||||
|
mail_profile_id="profile-1",
|
||||||
|
smtp_transport_revision="revision-1",
|
||||||
|
smtp_server_id="server-1",
|
||||||
|
smtp_credential_id="credential-1",
|
||||||
|
)
|
||||||
|
jobs = [
|
||||||
|
SimpleNamespace(
|
||||||
|
id="one",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
delivery_channel_policy="mail",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
id="two",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
delivery_channel_policy="mail",
|
||||||
|
resolved_recipients={"from": {"email": "sender@example.test"}},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
contexts = {
|
||||||
|
"one": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["one@example.test"]),
|
||||||
|
"two": SimpleNamespace(snapshot=snapshot, envelope_from="sender@example.test", envelope_recipients=["two@example.test", "one@example.test"]),
|
||||||
|
}
|
||||||
|
state = SimpleNamespace(connection_count=1, reconnect_count=0)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def batch(_session, **kwargs):
|
||||||
|
batch.kwargs = kwargs
|
||||||
|
yield state
|
||||||
|
|
||||||
|
provider = SimpleNamespace(campaign_smtp_batch=batch)
|
||||||
|
with patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider):
|
||||||
|
with _synchronous_smtp_batch_manager(object(), jobs=jobs, contexts=contexts) as opened: # type: ignore[arg-type]
|
||||||
|
assert opened is state
|
||||||
|
|
||||||
|
assert batch.kwargs["envelope_recipients"] == ["one@example.test", "two@example.test"]
|
||||||
|
assert batch.kwargs["expected_smtp_transport_revision"] == "revision-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_systemic_failure_pauses_only_remaining_queued_jobs() -> None:
|
||||||
|
session = Mock()
|
||||||
|
session.query.return_value.filter.return_value.update.return_value = 3
|
||||||
|
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1", status="sending")
|
||||||
|
session.get.return_value = campaign
|
||||||
|
with patch("govoplan_campaign.backend.sending.jobs.audit_event") as audit:
|
||||||
|
paused = _pause_jobs_after_systemic_smtp_failure(
|
||||||
|
session,
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
exclude_job_id="failed-job",
|
||||||
|
reason_code="smtp_authentication_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert paused == 3
|
||||||
|
assert campaign.status == "ready_to_queue"
|
||||||
|
session.commit.assert_called_once_with()
|
||||||
|
assert audit.call_args.kwargs["details"] == {
|
||||||
|
"reason_code": "smtp_authentication_failed",
|
||||||
|
"paused_count": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
|
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
|
||||||
session = Mock()
|
session = Mock()
|
||||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
from govoplan_campaign.backend.workflow_definitions import (
|
||||||
|
campaign_workflow_definitions,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_work_handoff_is_an_opt_in_reusable_template() -> None:
|
||||||
|
contribution = campaign_workflow_definitions(module_version="0.1.23")[0]
|
||||||
|
|
||||||
|
assert contribution.origin_module_id == "campaigns"
|
||||||
|
assert contribution.definition_kind == "template"
|
||||||
|
assert contribution.activate_on_install is False
|
||||||
|
assert contribution.allow_reuse is True
|
||||||
|
assert contribution.required_capabilities == (
|
||||||
|
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||||
|
)
|
||||||
|
assert contribution.policy_metadata["assignment_authorization_neutral"] is True
|
||||||
|
|
||||||
|
nodes = {
|
||||||
|
str(node["id"]): node
|
||||||
|
for node in contribution.graph["nodes"] # type: ignore[index]
|
||||||
|
}
|
||||||
|
prepare = nodes["prepare"]
|
||||||
|
handoff = nodes["campaign_work"]
|
||||||
|
assert prepare["config"]["operation"] == "campaigns.work.prepare" # type: ignore[index]
|
||||||
|
assert handoff["type"] == "workflow.external_handoff"
|
||||||
|
assert handoff["config"]["event_type"] == "campaign.work.changed" # type: ignore[index]
|
||||||
|
assert handoff["config"]["terminal_outcomes"] == { # type: ignore[index]
|
||||||
|
"completed": "completed",
|
||||||
|
"rejected": "rejected",
|
||||||
|
"cancelled": "cancelled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_contributes_the_current_campaign_work_template() -> None:
|
||||||
|
manifest = get_manifest()
|
||||||
|
|
||||||
|
assert len(manifest.workflow_definitions) == 1
|
||||||
|
assert manifest.workflow_definitions[0].origin_module_version == manifest.version
|
||||||
@@ -10,7 +10,10 @@ try:
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
pyzipper = None
|
pyzipper = None
|
||||||
|
|
||||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
from govoplan_campaign.backend.services.zip_service import (
|
||||||
|
create_zip_archive,
|
||||||
|
zip_archive_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ZipServiceTests(unittest.TestCase):
|
class ZipServiceTests(unittest.TestCase):
|
||||||
@@ -28,6 +31,20 @@ class ZipServiceTests(unittest.TestCase):
|
|||||||
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
self.assertEqual(info.compress_type, zipfile.ZIP_DEFLATED)
|
||||||
self.assertTrue(info.flag_bits & 0x1)
|
self.assertTrue(info.flag_bits & 0x1)
|
||||||
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
|
self.assertEqual(archive.read("message.txt", pwd=b"secret"), b"Hello Windows ZIP")
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
archive.read("message.txt", pwd=b"wrong-password")
|
||||||
|
|
||||||
|
evidence = zip_archive_evidence(
|
||||||
|
output,
|
||||||
|
[(source, "message.txt")],
|
||||||
|
password_protected=True,
|
||||||
|
method="zip_standard",
|
||||||
|
)
|
||||||
|
self.assertEqual("Legacy ZipCrypto", evidence["format"])
|
||||||
|
self.assertEqual("govoplan-campaign.zipcrypto", evidence["implementation"])
|
||||||
|
self.assertNotIn("password", evidence)
|
||||||
|
self.assertEqual(64, len(str(evidence["archive_sha256"])))
|
||||||
|
self.assertEqual(64, len(str(evidence["members"][0]["sha256"])))
|
||||||
|
|
||||||
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
|
@unittest.skipIf(pyzipper is None, "pyzipper is not installed")
|
||||||
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
|
def test_aes_password_zip_keeps_aes_encryption(self) -> None:
|
||||||
@@ -63,6 +80,18 @@ class ZipServiceTests(unittest.TestCase):
|
|||||||
self.assertFalse(info.flag_bits & 0x1)
|
self.assertFalse(info.flag_bits & 0x1)
|
||||||
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
|
self.assertEqual(archive.read("message.txt"), b"Plain ZIP")
|
||||||
|
|
||||||
|
def test_unknown_password_method_fails_without_downgrade_or_output(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
source = root / "source.txt"
|
||||||
|
source.write_text("No downgrade", encoding="utf-8")
|
||||||
|
output = root / "unknown.zip"
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "Unsupported"):
|
||||||
|
create_zip_archive(output, [source], "secret", "unknown")
|
||||||
|
|
||||||
|
self.assertFalse(output.exists())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.27",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"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: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:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||||
"test:accessibility-contract": "node tests/accessibility-contract.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": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
+386
-1
@@ -38,6 +38,124 @@ export type CampaignShare = {
|
|||||||
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
export type CampaignShareTarget = {id: string;name: string;secondary?: string | null;};
|
||||||
export type CampaignShareTargets = {users: CampaignShareTarget[];groups: CampaignShareTarget[];};
|
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" | "rejected" | "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">;
|
||||||
|
allowed_password_delivery_channels: Array<"separate_mail" | "sms" | "letter" | "phone" | "in_person">;
|
||||||
|
policy_hash: string;
|
||||||
|
source_path: Array<{
|
||||||
|
scope_type: string;
|
||||||
|
scope_id?: string | null;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
applied_fields: string[];
|
||||||
|
policy: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
reason: string;
|
||||||
|
diagnostics: Array<Record<string, unknown>>;
|
||||||
|
legacy_label: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignUpdatePayload = {
|
export type CampaignUpdatePayload = {
|
||||||
external_id?: string | null;
|
external_id?: string | null;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
@@ -116,14 +234,65 @@ export type CampaignCopyOptions = {
|
|||||||
include_mail_profile: boolean;
|
include_mail_profile: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignTransferScope =
|
||||||
|
| "metadata"
|
||||||
|
| "template_config"
|
||||||
|
| "recipients"
|
||||||
|
| "attachments"
|
||||||
|
| "review_state"
|
||||||
|
| "delivery_history";
|
||||||
|
|
||||||
|
export type CampaignPortablePackage = {
|
||||||
|
format: "govoplan.campaign-portable";
|
||||||
|
format_version: string;
|
||||||
|
package_id: string;
|
||||||
|
exported_at: string;
|
||||||
|
source: Record<string, unknown>;
|
||||||
|
scopes: CampaignTransferScope[];
|
||||||
|
manifest: Record<string, unknown>;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
integrity: { algorithm: string; package_sha256: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignTransferPlanItem = {
|
||||||
|
scope: CampaignTransferScope;
|
||||||
|
code: string;
|
||||||
|
summary: string;
|
||||||
|
item_count?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignImportPreview = {
|
||||||
|
compatible: boolean;
|
||||||
|
package_id?: string | null;
|
||||||
|
package_sha256?: string | null;
|
||||||
|
format_version?: string | null;
|
||||||
|
source: Record<string, unknown>;
|
||||||
|
available_scopes: CampaignTransferScope[];
|
||||||
|
selected_scopes: CampaignTransferScope[];
|
||||||
|
destination: { external_id?: string; name?: string; status?: string };
|
||||||
|
will_create: CampaignTransferPlanItem[];
|
||||||
|
will_skip: CampaignTransferPlanItem[];
|
||||||
|
warnings: string[];
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignImportApplyResponse = CampaignCreateResponse & {
|
||||||
|
receipt: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignScheduleOccurrence = {
|
export type CampaignScheduleOccurrence = {
|
||||||
id: string;
|
id: string;
|
||||||
schedule_id: string;
|
schedule_id: string;
|
||||||
scheduled_for: string;
|
scheduled_for: string;
|
||||||
status: "prepared" | "failed" | string;
|
status: "prepared" | "accepted" | "uncertain" | "failed" | "skipped" | "superseded" | string;
|
||||||
|
idempotency_key?: string | null;
|
||||||
generated_campaign_id?: string | null;
|
generated_campaign_id?: string | null;
|
||||||
generated_version_id?: string | null;
|
generated_version_id?: string | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
delivery_command_ids: string[];
|
||||||
|
recovery_state: string;
|
||||||
|
evidence: Record<string, unknown>;
|
||||||
|
last_checked_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +301,7 @@ export type CampaignSchedule = {
|
|||||||
campaign_id: string;
|
campaign_id: string;
|
||||||
source_version_id: string;
|
source_version_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
delivery_mode: "manual" | "autonomous";
|
||||||
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
||||||
interval_count: number;
|
interval_count: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -145,6 +315,8 @@ export type CampaignSchedule = {
|
|||||||
last_fired_at?: string | null;
|
last_fired_at?: string | null;
|
||||||
last_campaign_id?: string | null;
|
last_campaign_id?: string | null;
|
||||||
last_error?: string | null;
|
last_error?: string | null;
|
||||||
|
last_outcome?: string | null;
|
||||||
|
last_recovery_state?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
occurrences: CampaignScheduleOccurrence[];
|
occurrences: CampaignScheduleOccurrence[];
|
||||||
@@ -153,6 +325,7 @@ export type CampaignSchedule = {
|
|||||||
export type CampaignScheduleCreate = {
|
export type CampaignScheduleCreate = {
|
||||||
source_version_id: string;
|
source_version_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
delivery_mode: CampaignSchedule["delivery_mode"];
|
||||||
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
||||||
interval_count: number;
|
interval_count: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
@@ -1141,6 +1314,16 @@ export async function getCampaign(settings: ApiSettings, campaignId: string): Pr
|
|||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCampaignArchiveEncryptionPolicy(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string
|
||||||
|
): Promise<CampaignArchiveEncryptionPolicy> {
|
||||||
|
return apiFetch<CampaignArchiveEncryptionPolicy>(
|
||||||
|
settings,
|
||||||
|
`/api/v1/campaigns/${encodeURIComponent(campaignId)}/archive-encryption-policy`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCampaignMetadata(
|
export async function updateCampaignMetadata(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
@@ -1202,6 +1385,49 @@ options: CampaignCopyOptions)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportCampaignPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
versionId: string,
|
||||||
|
scopes: CampaignTransferScope[])
|
||||||
|
: Promise<CampaignPortablePackage> {
|
||||||
|
return apiFetch<CampaignPortablePackage>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/exports`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ scopes })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewCampaignImport(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {
|
||||||
|
package: Record<string, unknown>;
|
||||||
|
selected_scopes?: CampaignTransferScope[] | null;
|
||||||
|
external_id?: string;
|
||||||
|
name?: string;
|
||||||
|
})
|
||||||
|
: Promise<CampaignImportPreview> {
|
||||||
|
return apiFetch<CampaignImportPreview>(settings, "/api/v1/campaign-transfers/imports/preview", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importCampaignPackage(
|
||||||
|
settings: ApiSettings,
|
||||||
|
payload: {
|
||||||
|
package: Record<string, unknown>;
|
||||||
|
selected_scopes: CampaignTransferScope[];
|
||||||
|
external_id?: string;
|
||||||
|
name?: string;
|
||||||
|
expected_package_sha256: string;
|
||||||
|
})
|
||||||
|
: Promise<CampaignImportApplyResponse> {
|
||||||
|
return apiFetch<CampaignImportApplyResponse>(settings, "/api/v1/campaign-transfers/imports", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCampaignSchedules(
|
export async function listCampaignSchedules(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string)
|
campaignId: string)
|
||||||
@@ -1726,6 +1952,165 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
|||||||
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
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: "accept" | "start" | "complete" | "reject" | "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(
|
export function campaignShareTargetProvider(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { MetricGrid } from "@govoplan/core-webui";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
import { useGuardedNavigate, usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability, type FilesFileSpace } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
|
import {
|
||||||
|
getCampaignArchiveEncryptionPolicy,
|
||||||
|
type CampaignArchiveEncryptionPolicy
|
||||||
|
} from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
@@ -22,14 +26,25 @@ import { updateNested } from "./utils/draftEditor";
|
|||||||
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
import { AttachmentRulesDataGrid } from "./components/AttachmentRulesOverlay";
|
||||||
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
import TemplateExpressionEditorDialog from "./components/TemplateExpressionEditorDialog";
|
||||||
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
import { countIndividualAttachmentRules, countIndividualAttachmentRulesForBasePath, createAttachmentBasePath, ensureAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, createAttachmentZipArchive, parseManagedAttachmentSource, removeIndividualAttachmentRulesForBasePath, summarizeAttachmentRules, type AttachmentBasePath, type AttachmentZipArchive, type AttachmentZipCollection } from "./utils/attachments";
|
||||||
import { insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
import { hasScope, insertAfter, moveArrayItem, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
import { getDraftFields, humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
import { buildTemplatePreviewContext, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||||
|
|
||||||
type PathChooserState = {index: number;};
|
type PathChooserState = {index: number;};
|
||||||
type IndividualDisableState = {index: number;usageCount: number;};
|
type IndividualDisableState = {index: number;usageCount: number;};
|
||||||
|
|
||||||
export default function AttachmentsDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
const UNAVAILABLE_ARCHIVE_POLICY: CampaignArchiveEncryptionPolicy = {
|
||||||
|
available: false,
|
||||||
|
allowed_password_encryption_methods: ["aes"],
|
||||||
|
allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"],
|
||||||
|
policy_hash: "",
|
||||||
|
source_path: [],
|
||||||
|
reason: "Archive-encryption policy is loading. Legacy ZipCrypto remains blocked.",
|
||||||
|
diagnostics: [],
|
||||||
|
legacy_label: "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AttachmentsDataPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||||
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
|
||||||
@@ -41,6 +56,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
|
const [fileSpaces, setFileSpaces] = useState<FilesFileSpace[]>([]);
|
||||||
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
const [individualDisable, setIndividualDisable] = useState<IndividualDisableState | null>(null);
|
||||||
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
const [zipNameEditorIndex, setZipNameEditorIndex] = useState<number | null>(null);
|
||||||
|
const [archivePolicy, setArchivePolicy] = useState<CampaignArchiveEncryptionPolicy>(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||||
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
|
||||||
@@ -70,7 +86,15 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
|
() => zipConfig.enabled ? validateZipArchiveNames(zipConfig.archives) : EMPTY_ZIP_ARCHIVE_NAME_VALIDATION,
|
||||||
[zipConfig.archives, zipConfig.enabled]
|
[zipConfig.archives, zipConfig.enabled]
|
||||||
);
|
);
|
||||||
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message;
|
const canUseLegacyZipCrypto = hasScope(auth, "campaigns:archive:use_legacy_zipcrypto");
|
||||||
|
const legacyZipCryptoAllowed = archivePolicy.available && archivePolicy.allowed_password_encryption_methods.includes("zip_standard");
|
||||||
|
const legacyConfigurationInvalid = zipConfig.archives.some((archive) =>
|
||||||
|
archive.method === "zip_standard" && (
|
||||||
|
!legacyZipCryptoAllowed || !canUseLegacyZipCrypto ||
|
||||||
|
!archive.legacy_zipcrypto_acknowledged || archive.legacy_zipcrypto_reason.trim().length < 10
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const canSave = dirty && !locked && Boolean(draft) && !zipArchiveNameValidation.message && !legacyConfigurationInvalid;
|
||||||
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
const globalSummary = useMemo(() => summarizeAttachmentRules(globalRules), [globalRules]);
|
||||||
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
const individualRulesCount = useMemo(() => countIndividualAttachmentRules(displayDraft.entries), [displayDraft.entries]);
|
||||||
const attachmentPreviewEntry = useMemo(
|
const attachmentPreviewEntry = useMemo(
|
||||||
@@ -94,6 +118,22 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [listManagedFileSpaces, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setArchivePolicy(UNAVAILABLE_ARCHIVE_POLICY);
|
||||||
|
void getCampaignArchiveEncryptionPolicy(settings, campaignId)
|
||||||
|
.then((policy) => { if (!cancelled) setArchivePolicy(policy); })
|
||||||
|
.catch((cause) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setArchivePolicy({
|
||||||
|
...UNAVAILABLE_ARCHIVE_POLICY,
|
||||||
|
reason: cause instanceof Error ? cause.message : String(cause)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
function patchBasePaths(paths: AttachmentBasePath[]) {
|
function patchBasePaths(paths: AttachmentBasePath[]) {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
const normalized = ensureAttachmentBasePaths(paths);
|
const normalized = ensureAttachmentBasePaths(paths);
|
||||||
@@ -385,6 +425,11 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
<Card title="i18n:govoplan-campaign.zip_attachments.6b58ed68" collapsible>
|
||||||
|
<DismissibleAlert tone={legacyZipCryptoAllowed ? "warning" : "info"} dismissible={false} compact>
|
||||||
|
<strong>{archivePolicy.legacy_label}</strong>: {archivePolicy.reason}
|
||||||
|
{archivePolicy.source_path.length > 0 && <> Source: {archivePolicy.source_path.map((step) => step.label).join(" → ")}.</>}
|
||||||
|
{!canUseLegacyZipCrypto && <> Your account does not have the dedicated legacy-encryption permission.</>}
|
||||||
|
</DismissibleAlert>
|
||||||
<div className="attachment-zip-master-toggle">
|
<div className="attachment-zip-master-toggle">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
label="i18n:govoplan-campaign.enable_zip_attachments.6077075b"
|
||||||
@@ -403,6 +448,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
|
invalidNameIndexes: zipArchiveNameValidation.invalidIndexes,
|
||||||
onEditName: setZipNameEditorIndex,
|
onEditName: setZipNameEditorIndex,
|
||||||
passwordFields,
|
passwordFields,
|
||||||
|
legacyAllowed: legacyZipCryptoAllowed,
|
||||||
|
canUseLegacy: canUseLegacyZipCrypto,
|
||||||
|
allowedDeliveryChannels: archivePolicy.allowed_password_delivery_channels,
|
||||||
patchArchive: patchZipArchive,
|
patchArchive: patchZipArchive,
|
||||||
setStandard: setStandardZipArchive,
|
setStandard: setStandardZipArchive,
|
||||||
addArchive: addZipArchive,
|
addArchive: addZipArchive,
|
||||||
@@ -517,6 +565,9 @@ type ZipArchiveColumnContext = {
|
|||||||
invalidNameIndexes: ReadonlySet<number>;
|
invalidNameIndexes: ReadonlySet<number>;
|
||||||
onEditName: (index: number) => void;
|
onEditName: (index: number) => void;
|
||||||
passwordFields: ReturnType<typeof getDraftFields>;
|
passwordFields: ReturnType<typeof getDraftFields>;
|
||||||
|
legacyAllowed: boolean;
|
||||||
|
canUseLegacy: boolean;
|
||||||
|
allowedDeliveryChannels: CampaignArchiveEncryptionPolicy["allowed_password_delivery_channels"];
|
||||||
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
|
patchArchive: (index: number, patch: Partial<AttachmentZipArchive>) => void;
|
||||||
setStandard: (index: number) => void;
|
setStandard: (index: number) => void;
|
||||||
addArchive: (afterIndex?: number) => void;
|
addArchive: (afterIndex?: number) => void;
|
||||||
@@ -524,7 +575,7 @@ type ZipArchiveColumnContext = {
|
|||||||
removeArchive: (index: number) => void;
|
removeArchive: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName, passwordFields, legacyAllowed, canUseLegacy, allowedDeliveryChannels, patchArchive, setStandard, addArchive, moveArchive, removeArchive }: ZipArchiveColumnContext): DataGridColumn<AttachmentZipArchive>[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
|
id: "name", header: "i18n:govoplan-campaign.archive_name.6310f9e1", width: "minmax(360px, 1fr)", maxWidth: 640, resizable: true, sortable: true, filterable: true, sticky: "start",
|
||||||
@@ -557,18 +608,73 @@ function zipArchiveColumns({ disabled, archives, invalidNameIndexes, onEditName,
|
|||||||
value: (archive) => archive.password_enabled ? "protected" : "none"
|
value: (archive) => archive.password_enabled ? "protected" : "none"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "method", header: "ZIP mode", width: 280, sortable: true, filterable: true,
|
id: "method", header: "Encryption", width: 360, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "aes", label: "AES" }, { value: "zip_standard", label: "Win-compatible" }] },
|
columnType: "from-list", list: { options: [{ value: "aes", label: "AES (strong, default)" }, { value: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption" }] },
|
||||||
render: (archive, index) =>
|
render: (archive, index) =>
|
||||||
<ToggleSwitch
|
<select
|
||||||
label="Win-compatible"
|
value={archive.method}
|
||||||
checked={archive.method === "zip_standard"}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
help="Win-compatible ZIP uses the legacy ZipCrypto format so password-protected archives can be opened with Windows Explorer. Use AES when recipients can use 7-Zip, NanaZip, WinRAR, or another AES-capable ZIP tool."
|
aria-label="Archive password encryption"
|
||||||
onChange={(checked) => patchArchive(index, { method: checked ? "zip_standard" : "aes" })} />,
|
data-help-context-id="campaign.archive-encryption"
|
||||||
|
data-help-module-id="campaigns"
|
||||||
|
onChange={(event) => {
|
||||||
|
const method = event.target.value === "zip_standard" ? "zip_standard" : "aes";
|
||||||
|
patchArchive(index, method === "zip_standard" ? {
|
||||||
|
method,
|
||||||
|
legacy_zipcrypto_acknowledged: false,
|
||||||
|
legacy_zipcrypto_reason: ""
|
||||||
|
} : {
|
||||||
|
method,
|
||||||
|
legacy_zipcrypto_acknowledged: false,
|
||||||
|
legacy_zipcrypto_reason: ""
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<option value="aes">AES (strong, default)</option>
|
||||||
|
<option value="zip_standard" disabled={!legacyAllowed || !canUseLegacy}>Legacy ZipCrypto — Windows-compatible, weak encryption</option>
|
||||||
|
</select>,
|
||||||
|
|
||||||
value: (archive) => archive.method
|
value: (archive) => archive.method
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "password_delivery_channel", header: "Password delivery", width: 230, sortable: true, filterable: true,
|
||||||
|
render: (archive, index) =>
|
||||||
|
<select
|
||||||
|
value={archive.password_delivery_channel}
|
||||||
|
disabled={disabled || !archive.password_enabled}
|
||||||
|
aria-label="Separate password-delivery channel"
|
||||||
|
data-help-context-id="campaign.archive-encryption"
|
||||||
|
data-help-module-id="campaigns"
|
||||||
|
onChange={(event) => patchArchive(index, { password_delivery_channel: event.target.value as AttachmentZipArchive["password_delivery_channel"] })}>
|
||||||
|
{(["separate_mail", "sms", "letter", "phone", "in_person"] as const).map((channel) =>
|
||||||
|
<option key={channel} value={channel} disabled={!allowedDeliveryChannels.includes(channel)}>{passwordDeliveryChannelLabel(channel)}</option>
|
||||||
|
)}
|
||||||
|
</select>,
|
||||||
|
value: (archive) => archive.password_delivery_channel
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "legacy_acknowledgement", header: "Legacy acknowledgement", width: 380,
|
||||||
|
render: (archive, index) => archive.method === "zip_standard" ?
|
||||||
|
<div className="campaign-legacy-zipcrypto-acknowledgement">
|
||||||
|
<ToggleSwitch
|
||||||
|
label="I acknowledge that ZipCrypto encryption is weak"
|
||||||
|
helpContextId="campaign.archive-encryption"
|
||||||
|
helpModuleId="campaigns"
|
||||||
|
checked={archive.legacy_zipcrypto_acknowledged}
|
||||||
|
disabled={disabled || !legacyAllowed || !canUseLegacy}
|
||||||
|
onChange={(checked) => patchArchive(index, { legacy_zipcrypto_acknowledged: checked })} />
|
||||||
|
<input
|
||||||
|
value={archive.legacy_zipcrypto_reason}
|
||||||
|
disabled={disabled || !archive.legacy_zipcrypto_acknowledged}
|
||||||
|
minLength={10}
|
||||||
|
maxLength={1000}
|
||||||
|
placeholder="Operational reason (at least 10 characters)"
|
||||||
|
aria-label="Reason for weak legacy encryption"
|
||||||
|
data-help-context-id="campaign.archive-encryption"
|
||||||
|
data-help-module-id="campaigns"
|
||||||
|
onChange={(event) => patchArchive(index, { legacy_zipcrypto_reason: event.target.value })} />
|
||||||
|
</div> : <span>Not required for AES</span>,
|
||||||
|
value: (archive) => archive.legacy_zipcrypto_reason
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
id: "password_field", header: "i18n:govoplan-campaign.password_field.a1fc8a1c", width: 230, sortable: true, filterable: true,
|
||||||
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
columnType: "from-list", list: { options: [{ value: "", label: "i18n:govoplan-campaign.no_field.1fe00ed4" }, ...passwordFields.map((field) => ({ value: field.name, label: field.label || field.name }))] },
|
||||||
@@ -685,6 +791,16 @@ function uniqueStrings(values: string[]): string[] {
|
|||||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function passwordDeliveryChannelLabel(channel: AttachmentZipArchive["password_delivery_channel"]): string {
|
||||||
|
return {
|
||||||
|
separate_mail: "Separate email (never this campaign message)",
|
||||||
|
sms: "SMS",
|
||||||
|
letter: "Letter",
|
||||||
|
phone: "Telephone",
|
||||||
|
in_person: "In person"
|
||||||
|
}[channel];
|
||||||
|
}
|
||||||
|
|
||||||
type AttachmentSourceColumnContext = {
|
type AttachmentSourceColumnContext = {
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
basePaths: AttachmentBasePath[];
|
basePaths: AttachmentBasePath[];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,18 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink, Upload } from "lucide-react";
|
||||||
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
|
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { PageActionBar, PageLayout, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
import { DismissibleAlert, PageActionBar, PageLayout, TableActionGroup, ToggleSwitch, hasScope, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
import {
|
||||||
|
createNewCampaign,
|
||||||
|
importCampaignPackage,
|
||||||
|
listCampaignsDelta,
|
||||||
|
previewCampaignImport,
|
||||||
|
type CampaignDeltaResponse,
|
||||||
|
type CampaignImportPreview,
|
||||||
|
type CampaignTransferScope
|
||||||
|
} from "../../api/campaigns";
|
||||||
import type { CampaignListItem } from "../../types";
|
import type { CampaignListItem } from "../../types";
|
||||||
|
|
||||||
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
|
export default function CampaignListPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
@@ -20,6 +30,16 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
|
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
|
||||||
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
|
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [importPackage, setImportPackage] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [importPreview, setImportPreview] = useState<CampaignImportPreview | null>(null);
|
||||||
|
const [importScopes, setImportScopes] = useState<CampaignTransferScope[]>([]);
|
||||||
|
const [importIdentity, setImportIdentity] = useState({ external_id: "", name: "" });
|
||||||
|
const [importPreviewStale, setImportPreviewStale] = useState(false);
|
||||||
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
|
const [importError, setImportError] = useState("");
|
||||||
|
const canImport = hasScope(auth, "campaigns:campaign:import") && hasScope(auth, "campaigns:campaign:create");
|
||||||
|
const canImportRecipients = hasScope(auth, "campaigns:recipient:import") && hasScope(auth, "campaigns:recipient:write");
|
||||||
|
|
||||||
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
|
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -60,6 +80,101 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openImport() {
|
||||||
|
setImportPackage(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
setImportScopes([]);
|
||||||
|
setImportIdentity({ external_id: "", name: "" });
|
||||||
|
setImportPreviewStale(false);
|
||||||
|
setImportError("");
|
||||||
|
setImportOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readImportFile(file: File | undefined) {
|
||||||
|
if (!file) return;
|
||||||
|
setImportBusy(true);
|
||||||
|
setImportError("");
|
||||||
|
try {
|
||||||
|
if (file.size > 25 * 1024 * 1024) throw new Error("Campaign packages larger than 25 MB must be reviewed and imported through a governed integration.");
|
||||||
|
const parsed: unknown = JSON.parse(await file.text());
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Select a JSON object exported as a portable Campaign package.");
|
||||||
|
const packageData = parsed as Record<string, unknown>;
|
||||||
|
const preview = await previewCampaignImport(settings, { package: packageData });
|
||||||
|
const allowedScopes = preview.selected_scopes.filter((scope) => scope !== "recipients" || canImportRecipients);
|
||||||
|
setImportPackage(packageData);
|
||||||
|
setImportPreview(preview);
|
||||||
|
setImportScopes(allowedScopes);
|
||||||
|
setImportIdentity({
|
||||||
|
external_id: preview.destination.external_id ?? "",
|
||||||
|
name: preview.destination.name ?? ""
|
||||||
|
});
|
||||||
|
setImportPreviewStale(allowedScopes.length !== preview.selected_scopes.length);
|
||||||
|
} catch (err) {
|
||||||
|
setImportPackage(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
setImportError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchImportIdentity(key: "external_id" | "name", value: string) {
|
||||||
|
setImportIdentity((current) => ({ ...current, [key]: value }));
|
||||||
|
setImportPreviewStale(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleImportScope(scope: CampaignTransferScope, checked: boolean) {
|
||||||
|
setImportScopes((current) => checked
|
||||||
|
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
|
||||||
|
: current.filter((item) => item !== scope));
|
||||||
|
setImportPreviewStale(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshImportPreview() {
|
||||||
|
if (!importPackage || importBusy || importScopes.length === 0) return;
|
||||||
|
setImportBusy(true);
|
||||||
|
setImportError("");
|
||||||
|
try {
|
||||||
|
const preview = await previewCampaignImport(settings, {
|
||||||
|
package: importPackage,
|
||||||
|
selected_scopes: importScopes,
|
||||||
|
external_id: importIdentity.external_id.trim() || undefined,
|
||||||
|
name: importIdentity.name.trim() || undefined
|
||||||
|
});
|
||||||
|
setImportPreview(preview);
|
||||||
|
setImportIdentity({
|
||||||
|
external_id: preview.destination.external_id ?? importIdentity.external_id,
|
||||||
|
name: preview.destination.name ?? importIdentity.name
|
||||||
|
});
|
||||||
|
setImportPreviewStale(false);
|
||||||
|
} catch (err) {
|
||||||
|
setImportError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyImport() {
|
||||||
|
if (!importPackage || !importPreview?.compatible || !importPreview.package_sha256 || importPreviewStale || importBusy) return;
|
||||||
|
setImportBusy(true);
|
||||||
|
setImportError("");
|
||||||
|
try {
|
||||||
|
const created = await importCampaignPackage(settings, {
|
||||||
|
package: importPackage,
|
||||||
|
selected_scopes: importScopes,
|
||||||
|
external_id: importIdentity.external_id.trim() || undefined,
|
||||||
|
name: importIdentity.name.trim() || undefined,
|
||||||
|
expected_package_sha256: importPreview.package_sha256
|
||||||
|
});
|
||||||
|
setImportOpen(false);
|
||||||
|
navigate(`/campaigns/${created.campaign.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setImportError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setImportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCampaignDeltaWatermark(null);
|
setCampaignDeltaWatermark(null);
|
||||||
load(null);
|
load(null);
|
||||||
@@ -141,7 +256,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (<>
|
||||||
<PageLayout
|
<PageLayout
|
||||||
archetype="collection"
|
archetype="collection"
|
||||||
mode="workspace"
|
mode="workspace"
|
||||||
@@ -154,9 +269,15 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
variant="collection"
|
variant="collection"
|
||||||
refreshable
|
refreshable
|
||||||
reloadAction={{ onReload: () => void load(null), loading }}
|
reloadAction={{ onReload: () => void load(null), loading }}
|
||||||
createAction={<Button variant="primary" onClick={create} disabled={creating}>
|
createAction={<>
|
||||||
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
{canImport && <Button onClick={openImport} disabled={creating || importBusy}>
|
||||||
|
<Upload size={16} aria-hidden="true" />
|
||||||
|
Import package
|
||||||
</Button>}
|
</Button>}
|
||||||
|
<Button variant="primary" onClick={create} disabled={creating}>
|
||||||
|
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||||
|
</Button>
|
||||||
|
</>}
|
||||||
/>}
|
/>}
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -185,7 +306,75 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
}
|
}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</Card>
|
</Card>
|
||||||
</PageLayout>);
|
</PageLayout>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={importOpen}
|
||||||
|
title="Import portable Campaign package"
|
||||||
|
className="campaign-copy-dialog campaign-import-dialog"
|
||||||
|
helpContextId="campaigns.action.import-package"
|
||||||
|
closeDisabled={importBusy}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => setImportOpen(false)} disabled={importBusy}>Cancel</Button>
|
||||||
|
{importPackage && <Button onClick={() => void refreshImportPreview()} disabled={importBusy || importScopes.length === 0 || !importPreviewStale}>
|
||||||
|
{importBusy ? "Checking..." : "Refresh preview"}
|
||||||
|
</Button>}
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void applyImport()}
|
||||||
|
disabled={importBusy || importPreviewStale || !importPreview?.compatible || !importPreview.package_sha256}>
|
||||||
|
{importBusy ? "Importing..." : "Create draft"}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="campaign-copy-form">
|
||||||
|
<DismissibleAlert tone="info" resetKey="campaign-portable-import-safety">
|
||||||
|
Import always creates a new draft. Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state.
|
||||||
|
</DismissibleAlert>
|
||||||
|
<FormField label="Portable Campaign package" help="Select a .govoplan-campaign.json file. Packages are integrity-checked before any draft is created.">
|
||||||
|
<input type="file" accept="application/json,.json,.govoplan-campaign.json" disabled={importBusy} onChange={(event) => void readImportFile(event.target.files?.[0])} />
|
||||||
|
</FormField>
|
||||||
|
{importError && <div className="inline-alert is-error" role="alert">{importError}</div>}
|
||||||
|
{importPreview && <>
|
||||||
|
<div className="campaign-copy-identity">
|
||||||
|
<FormField label="Campaign name">
|
||||||
|
<input value={importIdentity.name} disabled={importBusy} onChange={(event) => patchImportIdentity("name", event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Campaign ID">
|
||||||
|
<input value={importIdentity.external_id} disabled={importBusy} onChange={(event) => patchImportIdentity("external_id", event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="campaign-copy-options">
|
||||||
|
{importPreview.available_scopes.map((scope) => <div className="campaign-copy-option" key={scope}>
|
||||||
|
<div>
|
||||||
|
<strong>{transferScopeLabel(scope)}</strong>
|
||||||
|
<small>{transferScopeDescription(scope)}</small>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={`Import ${transferScopeLabel(scope)}`}
|
||||||
|
checked={importScopes.includes(scope)}
|
||||||
|
disabled={importBusy || scope === "recipients" && !canImportRecipients}
|
||||||
|
onChange={(checked) => toggleImportScope(scope, checked)} />
|
||||||
|
</div>)}
|
||||||
|
</div>
|
||||||
|
{importPreviewStale && <p className="muted small-note">Identity or scope choices changed. Refresh the preview before importing.</p>}
|
||||||
|
{!importPreview.compatible && <div className="inline-alert is-error" role="alert">
|
||||||
|
<strong>This package cannot be imported.</strong>
|
||||||
|
<ul>{importPreview.errors.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</div>}
|
||||||
|
{importPreview.warnings.length > 0 && <div className="inline-alert is-warning">
|
||||||
|
<strong>Review before import</strong>
|
||||||
|
<ul>{importPreview.warnings.map((item) => <li key={item}>{item}</li>)}</ul>
|
||||||
|
</div>}
|
||||||
|
<div className="campaign-import-plan">
|
||||||
|
<ImportPlan title="Will create" items={importPreview.will_create} />
|
||||||
|
<ImportPlan title="Will skip" items={importPreview.will_skip} />
|
||||||
|
</div>
|
||||||
|
<p className="muted mono-small">Package {importPreview.package_id ?? "unknown"} · SHA-256 {importPreview.package_sha256 ?? "unavailable"}</p>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +383,40 @@ function shortId(value: string): string {
|
|||||||
return `${value.slice(0, 12)}…${value.slice(-6)}`;
|
return `${value.slice(0, 12)}…${value.slice(-6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ImportPlan({ title, items }: {title: string;items: CampaignImportPreview["will_create"];}) {
|
||||||
|
return <div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{items.length === 0
|
||||||
|
? <p className="muted">Nothing.</p>
|
||||||
|
: <ul>{items.map((item) => <li key={`${item.scope}:${item.code}`}>
|
||||||
|
<strong>{transferScopeLabel(item.scope)}</strong>: {item.summary}
|
||||||
|
{typeof item.item_count === "number" ? ` (${item.item_count})` : ""}
|
||||||
|
</li>)}</ul>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferScopeLabel(scope: CampaignTransferScope): string {
|
||||||
|
return ({
|
||||||
|
metadata: "Metadata",
|
||||||
|
template_config: "Template and configuration",
|
||||||
|
recipients: "Recipients",
|
||||||
|
attachments: "Attachments",
|
||||||
|
review_state: "Review state",
|
||||||
|
delivery_history: "Delivery history"
|
||||||
|
} satisfies Record<CampaignTransferScope, string>)[scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferScopeDescription(scope: CampaignTransferScope): string {
|
||||||
|
return ({
|
||||||
|
metadata: "Identity and source description for the new draft.",
|
||||||
|
template_config: "Portable fields, templates, policies, and delivery settings.",
|
||||||
|
recipients: "Campaign-local recipient rows and import provenance.",
|
||||||
|
attachments: "Attachment rules and references; never file content.",
|
||||||
|
review_state: "Historical evidence retained in the package, never replayed.",
|
||||||
|
delivery_history: "Historical outcomes retained in the package, never replayed."
|
||||||
|
} satisfies Record<CampaignTransferScope, string>)[scope];
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value?: string): string {
|
function formatDateTime(value?: string): string {
|
||||||
return formatPlatformDateTime(value);
|
return formatPlatformDateTime(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export default function CampaignModulePage({
|
|||||||
? <OperatorQueuePage settings={settings} auth={auth} />
|
? <OperatorQueuePage settings={settings} auth={auth} />
|
||||||
: active === "reports"
|
: active === "reports"
|
||||||
? <AggregateReportsPage settings={settings} />
|
? <AggregateReportsPage settings={settings} />
|
||||||
: <CampaignListPage settings={settings} />}
|
: <CampaignListPage settings={settings} auth={auth} />}
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { MetricGrid } from "@govoplan/core-webui";
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
import { Archive, CalendarClock, Copy, Download, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { ApiSettings, AuthInfo } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import { FormGrid, Button } from "@govoplan/core-webui";
|
import { FormGrid, Button } from "@govoplan/core-webui";
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
copyCampaign,
|
copyCampaign,
|
||||||
createCampaignSchedule,
|
createCampaignSchedule,
|
||||||
deleteCampaign,
|
deleteCampaign,
|
||||||
|
exportCampaignPackage,
|
||||||
getCampaignLifecyclePolicy,
|
getCampaignLifecyclePolicy,
|
||||||
lockCampaignVersionPermanently,
|
lockCampaignVersionPermanently,
|
||||||
lockCampaignVersionTemporarily,
|
lockCampaignVersionTemporarily,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
type CampaignScheduleCreate,
|
type CampaignScheduleCreate,
|
||||||
type CampaignLifecyclePolicy,
|
type CampaignLifecyclePolicy,
|
||||||
type CampaignCopyOptions,
|
type CampaignCopyOptions,
|
||||||
|
type CampaignTransferScope,
|
||||||
type CampaignVersionDetail,
|
type CampaignVersionDetail,
|
||||||
type CampaignVersionListItem } from
|
type CampaignVersionListItem } from
|
||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
@@ -49,6 +51,7 @@ import {
|
|||||||
summaryValue } from
|
summaryValue } from
|
||||||
"./utils/campaignView";
|
"./utils/campaignView";
|
||||||
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
|
||||||
|
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||||
|
|
||||||
const campaignModeOptions = ["draft", "test", "send"];
|
const campaignModeOptions = ["draft", "test", "send"];
|
||||||
type LockAction = "temporary" | "unlock" | "permanent";
|
type LockAction = "temporary" | "unlock" | "permanent";
|
||||||
@@ -68,6 +71,7 @@ const defaultCopyOptions: CampaignCopyOptions = {
|
|||||||
include_policies: true,
|
include_policies: true,
|
||||||
include_mail_profile: true
|
include_mail_profile: true
|
||||||
};
|
};
|
||||||
|
const defaultExportScopes: CampaignTransferScope[] = ["metadata", "template_config"];
|
||||||
|
|
||||||
function defaultScheduleDraft(): CampaignScheduleCreate {
|
function defaultScheduleDraft(): CampaignScheduleCreate {
|
||||||
const start = new Date(Date.now() + 60 * 60 * 1000);
|
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
@@ -75,6 +79,7 @@ function defaultScheduleDraft(): CampaignScheduleCreate {
|
|||||||
return {
|
return {
|
||||||
source_version_id: "",
|
source_version_id: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
delivery_mode: "manual",
|
||||||
recurrence_kind: "once",
|
recurrence_kind: "once",
|
||||||
interval_count: 1,
|
interval_count: 1,
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
@@ -103,6 +108,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||||
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
||||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||||
|
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||||
|
const [exportScopes, setExportScopes] = useState<CampaignTransferScope[]>(defaultExportScopes);
|
||||||
|
const [exportBusy, setExportBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
||||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||||
@@ -112,7 +120,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
||||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
|
||||||
|
const canExport = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:export");
|
||||||
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
|
||||||
|
const canAutonomousSchedule = canSchedule
|
||||||
|
&& hasScope(auth, "campaigns:campaign:queue")
|
||||||
|
&& hasScope(auth, "campaigns:campaign:send")
|
||||||
|
&& hasScope(auth, "mail:profile:use");
|
||||||
|
const canExportRecipients = hasScope(auth, "campaigns:recipient:read") && hasScope(auth, "campaigns:recipient:export");
|
||||||
|
const canExportReview = hasScope(auth, "campaigns:report:read");
|
||||||
|
const canExportDelivery = canExportRecipients && hasScope(auth, "campaigns:report:export");
|
||||||
|
|
||||||
function openSection(section: string, fragment = "") {
|
function openSection(section: string, fragment = "") {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -317,7 +333,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
});
|
});
|
||||||
setSchedules((current) => [created, ...current]);
|
setSchedules((current) => [created, ...current]);
|
||||||
setScheduleDialogOpen(false);
|
setScheduleDialogOpen(false);
|
||||||
setMessage("Campaign schedule created. Each occurrence prepares a fresh draft for review; it does not send automatically.");
|
setMessage(created.delivery_mode === "autonomous"
|
||||||
|
? "Autonomous schedule created from the exact approved source execution."
|
||||||
|
: "Manual schedule created. Each occurrence prepares a fresh draft for review.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -345,6 +363,36 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleExportScope(scope: CampaignTransferScope, checked: boolean) {
|
||||||
|
setExportScopes((current) => checked
|
||||||
|
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
|
||||||
|
: current.filter((item) => item !== scope));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPortablePackage() {
|
||||||
|
if (!campaign || !data.currentVersion || exportBusy || exportScopes.length === 0) return;
|
||||||
|
setExportBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const portablePackage = await exportCampaignPackage(
|
||||||
|
settings,
|
||||||
|
campaign.id,
|
||||||
|
data.currentVersion.id,
|
||||||
|
exportScopes
|
||||||
|
);
|
||||||
|
downloadJson(
|
||||||
|
`${safeFileStem(campaign.external_id || campaign.name)}-v${data.currentVersion.version_number ?? 1}.govoplan-campaign.json`,
|
||||||
|
portablePackage
|
||||||
|
);
|
||||||
|
setExportDialogOpen(false);
|
||||||
|
setMessage("Portable Campaign package downloaded. Keep recipient or delivery packages in an approved location.");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setExportBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
archetype="editor"
|
archetype="editor"
|
||||||
@@ -357,7 +405,16 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
actions={<PageActionBar
|
actions={<PageActionBar
|
||||||
variant="editor"
|
variant="editor"
|
||||||
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
|
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(), loading }}
|
||||||
primaryActions={<>
|
primaryActions={<>
|
||||||
|
{canExport && <Button
|
||||||
|
onClick={() => setExportDialogOpen(true)}
|
||||||
|
disabled={loading || savingIdentity || identityDirty || exportBusy}
|
||||||
|
disabledReason={identityDirty ? "Save or discard overview changes before exporting." : undefined}>
|
||||||
|
<Download size={16} aria-hidden="true" />
|
||||||
|
Export package
|
||||||
|
</Button>}
|
||||||
{canCopy && data.currentVersion && <Button
|
{canCopy && data.currentVersion && <Button
|
||||||
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
|
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
|
||||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||||
@@ -376,6 +433,8 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
destructiveActions={<>
|
destructiveActions={<>
|
||||||
{canDelete && <Button
|
{canDelete && <Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
helpContextId="campaign.overview"
|
||||||
|
helpModuleId="campaigns"
|
||||||
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
||||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||||
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
|
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
|
||||||
@@ -433,17 +492,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<CalendarClock size={16} aria-hidden="true" />
|
<CalendarClock size={16} aria-hidden="true" />
|
||||||
Add schedule
|
Add schedule
|
||||||
</Button> : undefined}>
|
</Button> : undefined}>
|
||||||
<p className="muted small-note">Due occurrences prepare independent campaign drafts. Validation, review, approval, and delivery are never started by the schedule.</p>
|
<p className="muted small-note">Manual schedules prepare independent drafts. Opt-in autonomous schedules reuse the exact approved build through Mail's durable delivery outbox.</p>
|
||||||
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
{schedules.length === 0 ? <p className="muted">No schedules configured.</p> : <div className="campaign-schedule-list">
|
||||||
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
{schedules.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
||||||
<div className="campaign-schedule-main">
|
<div className="campaign-schedule-main">
|
||||||
<strong>{schedule.name}</strong>
|
<strong>{schedule.name}</strong>
|
||||||
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} prepared</span>
|
<span>{scheduleCadence(schedule)} · {schedule.occurrence_count}/{schedule.max_occurrences} occurrences</span>
|
||||||
|
<span><StatusBadge status={schedule.delivery_mode} /> {schedule.delivery_mode === "autonomous" ? "Approved source delivery" : "Draft preparation"}</span>
|
||||||
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
{schedule.next_fire_at && <span>Next: {formatDateTime(schedule.next_fire_at)}</span>}
|
||||||
|
{schedule.last_outcome && <span>Last outcome: <StatusBadge status={schedule.last_outcome} /> · recovery {schedule.last_recovery_state ?? "none"}</span>}
|
||||||
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
{schedule.last_error && <span className="danger-text">Paused: {schedule.last_error}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>Open latest draft</Link>}
|
{schedule.last_campaign_id && <Link className="btn btn-secondary" to={`/campaigns/${schedule.last_campaign_id}`}>{schedule.delivery_mode === "autonomous" ? "Open approved source" : "Open latest draft"}</Link>}
|
||||||
{schedule.next_fire_at && <Button
|
{schedule.next_fire_at && <Button
|
||||||
iconOnly
|
iconOnly
|
||||||
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||||
@@ -571,9 +632,37 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={exportDialogOpen}
|
||||||
|
title="Export portable Campaign package"
|
||||||
|
className="campaign-copy-dialog"
|
||||||
|
helpContextId="campaigns.action.export-package"
|
||||||
|
closeDisabled={exportBusy}
|
||||||
|
onClose={() => setExportDialogOpen(false)}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => setExportDialogOpen(false)} disabled={exportBusy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void exportPortablePackage()} disabled={exportBusy || exportScopes.length === 0}>
|
||||||
|
{exportBusy ? "Preparing package..." : "Download package"}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="campaign-copy-form">
|
||||||
|
<p className="muted small-note">
|
||||||
|
Configuration-only is the privacy-safe default. The package contains JSON and attachment references, never file content, credentials, or transport secrets.
|
||||||
|
</p>
|
||||||
|
<div className="campaign-copy-options">
|
||||||
|
<CopyOption label="Metadata" detail="Campaign identity, description, and source status." checked={exportScopes.includes("metadata")} onChange={(checked) => toggleExportScope("metadata", checked)} />
|
||||||
|
<CopyOption label="Template and configuration" detail="Fields, template, validation and delivery settings. Deployment-bound Mail credentials are excluded." checked={exportScopes.includes("template_config")} onChange={(checked) => toggleExportScope("template_config", checked)} />
|
||||||
|
<CopyOption label="Recipients" detail="Recipient rows and import provenance. This can contain personal data and needs recipient-export authority." checked={exportScopes.includes("recipients")} disabled={!canExportRecipients} onChange={(checked) => toggleExportScope("recipients", checked)} />
|
||||||
|
<CopyOption label="Attachments" detail="Global and per-recipient attachment rules. File bytes are not embedded." checked={exportScopes.includes("attachments")} onChange={(checked) => toggleExportScope("attachments", checked)} />
|
||||||
|
<CopyOption label="Review state" detail="Aggregate validation, build, issue, and review evidence. Imports retain provenance but never replay approval state." checked={exportScopes.includes("review_state")} disabled={!canExportReview} onChange={(checked) => toggleExportScope("review_state", checked)} />
|
||||||
|
<CopyOption label="Delivery history" detail="Recipient-level delivery outcomes and safe provenance. Imports never recreate sent state." checked={exportScopes.includes("delivery_history")} disabled={!canExportDelivery} onChange={(checked) => toggleExportScope("delivery_history", checked)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={scheduleDialogOpen}
|
open={scheduleDialogOpen}
|
||||||
title="Schedule campaign drafts"
|
title="Schedule campaign"
|
||||||
className="campaign-schedule-dialog"
|
className="campaign-schedule-dialog"
|
||||||
helpContextId="campaigns.action.schedule-drafts"
|
helpContextId="campaigns.action.schedule-drafts"
|
||||||
closeDisabled={scheduleBusy}
|
closeDisabled={scheduleBusy}
|
||||||
@@ -584,11 +673,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
||||||
</Button>
|
</Button>
|
||||||
</>}>
|
</>}>
|
||||||
<DismissibleAlert tone="info" resetKey="campaign-schedule-safety">
|
<DismissibleAlert tone="info" resetKey={`campaign-schedule-safety-${scheduleDraft.delivery_mode}`}>
|
||||||
A schedule creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically.
|
{scheduleDraft.delivery_mode === "autonomous"
|
||||||
|
? "Autonomous delivery is opt-in. It requires a built, explicitly approved Mail-only source version and rechecks approval, policy, credentials, transport health, recipients, attachments, and snapshot integrity before each occurrence. Unknown outcomes pause the schedule and are never retried automatically."
|
||||||
|
: "Manual mode creates a fresh draft at each due time. It never validates, approves, queues, or sends messages automatically and remains available without Mail."}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
<div className="campaign-schedule-form">
|
<div className="campaign-schedule-form">
|
||||||
<FormField label="Draft name">
|
<FormField label="Execution mode" help="Mode is fixed for this schedule. Create a new schedule when the approved delivery plan changes.">
|
||||||
|
<select value={scheduleDraft.delivery_mode} onChange={(event) => setScheduleDraft((current) => ({ ...current, delivery_mode: event.target.value as CampaignSchedule["delivery_mode"] }))}>
|
||||||
|
<option value="manual">Manual draft preparation</option>
|
||||||
|
<option value="autonomous" disabled={!canAutonomousSchedule}>Autonomous approved delivery</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Schedule name">
|
||||||
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
<input value={scheduleDraft.name} onChange={(event) => setScheduleDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="First occurrence">
|
<FormField label="First occurrence">
|
||||||
@@ -618,13 +715,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="campaign-copy-options">
|
{scheduleDraft.delivery_mode === "manual" && <div className="campaign-copy-options">
|
||||||
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
<CopyOption label="Recipients" detail="Recipient rows and values used to prepare each draft." checked={scheduleDraft.include_recipients} disabled={!hasScope(auth, "campaigns:recipient:read")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_recipients: checked }))} />
|
||||||
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
<CopyOption label="Files" detail="Attachment rules and Files references; generated evidence is never copied." checked={scheduleDraft.include_files} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_files: checked }))} />
|
||||||
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
<CopyOption label="Shares" detail="Current active shares are sealed when the schedule is created." checked={scheduleDraft.include_shares} disabled={!hasScope(auth, "campaigns:campaign:share")} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_shares: checked }))} />
|
||||||
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
<CopyOption label="Policies" detail="Campaign and validation policy configuration." checked={scheduleDraft.include_policies} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_policies: checked }))} />
|
||||||
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
<CopyOption label="Mail profile" detail="Reusable Mail profile references; credentials remain Mail-owned." checked={scheduleDraft.include_mail_profile} onChange={(checked) => setScheduleDraft((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||||
</div>
|
</div>}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { Megaphone, X } from "lucide-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DashboardWidgetList,
|
||||||
|
DismissibleAlert,
|
||||||
|
LoadingFrame,
|
||||||
|
useDashboardWidgetData,
|
||||||
|
type QuickAccessToolRenderContext
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
|
import { listCampaigns } from "../../api/campaigns";
|
||||||
|
|
||||||
|
|
||||||
|
type Props = Pick<
|
||||||
|
QuickAccessToolRenderContext,
|
||||||
|
"settings" | "launchContext" | "complete" | "cancel"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default function CampaignQuickAccess({
|
||||||
|
settings,
|
||||||
|
launchContext,
|
||||||
|
complete,
|
||||||
|
cancel
|
||||||
|
}: Props) {
|
||||||
|
const load = useCallback(
|
||||||
|
async () => (await listCampaigns(settings))
|
||||||
|
.filter((campaign) => Boolean(campaign.current_version_id))
|
||||||
|
.slice(0, 12),
|
||||||
|
[settings]
|
||||||
|
);
|
||||||
|
const { data, loading, error } = useDashboardWidgetData(load, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingFrame loading={loading} label="Loading authorized Campaigns">
|
||||||
|
<p className="muted small-note">
|
||||||
|
Select an exact Campaign version for {launchContext.activeObject?.label}.
|
||||||
|
Campaign content remains in Campaigns and access is checked again when opened.
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
|
<DashboardWidgetList
|
||||||
|
emptyText="No authorized versioned Campaign is available."
|
||||||
|
items={(data ?? []).map((campaign) => ({
|
||||||
|
id: campaign.id,
|
||||||
|
title: campaign.name,
|
||||||
|
detail: campaign.description || campaign.status,
|
||||||
|
meta: campaign.status,
|
||||||
|
leading: <Megaphone size={17} aria-hidden="true" />,
|
||||||
|
onClick: () => complete({
|
||||||
|
contractVersion: "1",
|
||||||
|
outcome: "completed",
|
||||||
|
action: "selected",
|
||||||
|
reference: {
|
||||||
|
ownerModule: "campaigns",
|
||||||
|
kind: "campaign",
|
||||||
|
objectId: campaign.id,
|
||||||
|
tenantId: launchContext.tenantId,
|
||||||
|
label: campaign.name,
|
||||||
|
version: campaign.current_version_id!,
|
||||||
|
path: `/campaigns/${encodeURIComponent(campaign.id)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="dashboard-contribution-footer">
|
||||||
|
<Button onClick={() => cancel("user")}>
|
||||||
|
<X size={15} aria-hidden="true" /> Cancel selection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -537,7 +537,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
<Button onClick={() => void retryFailedSynchronously(failedRowsOnPage)} disabled={!version || Boolean(busyAction) || failedRowsOnPage.length === 0}>
|
||||||
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
{busyAction === "retry-sync-page" ? "Sending failed jobs..." : `Retry failed on this page now (${failedRowsOnPage.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
<Button helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</ContentGrid>
|
</ContentGrid>
|
||||||
@@ -591,7 +591,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
footer={
|
footer={
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
<Button onClick={() => setEmailOpen(false)} disabled={busyAction === "email"}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||||
<Button variant="primary" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
|
<Button variant="primary" helpContextId="campaign.report" helpModuleId="campaigns" onClick={() => void sendReportEmail()} disabled={!emailRecipients.trim() || busyAction === "email"}>i18n:govoplan-campaign.send_report.a5b32af9</Button>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
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 [searchParams] = useSearchParams();
|
||||||
|
const requestedAssignmentId = searchParams.get("assignment");
|
||||||
|
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 [rejecting, setRejecting] = 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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestedAssignmentId || loading) return;
|
||||||
|
document
|
||||||
|
.getElementById(`campaign-assignment-${requestedAssignmentId}`)
|
||||||
|
?.focus({ preventScroll: false });
|
||||||
|
}, [assignments, loading, requestedAssignmentId]);
|
||||||
|
|
||||||
|
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: "accept" | "start" | "complete" | "reject" | "cancel") {
|
||||||
|
if (busyId) return;
|
||||||
|
setBusyId(assignment.id);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
|
||||||
|
replaceAssignment(updated);
|
||||||
|
setCancelling(null);
|
||||||
|
setRejecting(null);
|
||||||
|
const resultLabel = {
|
||||||
|
accept: "accepted",
|
||||||
|
start: "started",
|
||||||
|
complete: "completed",
|
||||||
|
reject: "rejected",
|
||||||
|
cancel: "cancelled"
|
||||||
|
}[action];
|
||||||
|
setMessage(`Work ${resultLabel}.`);
|
||||||
|
} 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}
|
||||||
|
id={`campaign-assignment-${assignment.id}`}
|
||||||
|
className={`campaign-work-item${assignment.id === requestedAssignmentId ? " is-focused" : ""}`}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<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, "accept")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
|
||||||
|
<Play size={16} aria-hidden="true" /> Accept
|
||||||
|
</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}
|
||||||
|
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
|
||||||
|
<span className="campaign-work-destructive-action">
|
||||||
|
<Button variant="danger" helpContextId="campaign.work.action.reject" helpModuleId="campaigns" onClick={() => setRejecting(assignment)} disabled={Boolean(busyId)}>
|
||||||
|
Reject work
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
) : 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(rejecting)}
|
||||||
|
title="Reject assigned work?"
|
||||||
|
message="The work will close as rejected, separately from cancellation. Its purpose, assignee and transition history remain durable evidence."
|
||||||
|
confirmLabel="Reject work"
|
||||||
|
tone="danger"
|
||||||
|
busy={Boolean(busyId)}
|
||||||
|
onCancel={() => setRejecting(null)}
|
||||||
|
onConfirm={() => rejecting ? void transition(rejecting, "reject") : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 {
|
import {
|
||||||
ConcurrencyConflictProvider,
|
ConcurrencyConflictProvider,
|
||||||
WorkspaceLayout,
|
WorkspaceLayout,
|
||||||
|
hasScope,
|
||||||
useGuardedNavigate
|
useGuardedNavigate
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import type { ApiSettings, AuthInfo, CampaignWorkspaceSection } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import SectionSidebar from "../../layout/SectionSidebar";
|
import SectionSidebar, { type CampaignWorkspaceNavigationSection } from "../../layout/SectionSidebar";
|
||||||
|
|
||||||
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
|
const CampaignOverviewPage = lazy(() => import("./CampaignOverviewPage"));
|
||||||
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
|
const CampaignFieldsPage = lazy(() => import("./CampaignFieldsPage"));
|
||||||
@@ -23,8 +24,10 @@ const WizardDirectoryPage = lazy(() => import("./wizard/WizardDirectoryPage"));
|
|||||||
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
|
const CampaignJsonView = lazy(() => import("./CampaignJsonView"));
|
||||||
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
|
const CampaignReportPage = lazy(() => import("./CampaignReportPage"));
|
||||||
const CampaignAuditPage = lazy(() => import("./CampaignAuditPage"));
|
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: "",
|
overview: "",
|
||||||
campaign: "recipients",
|
campaign: "recipients",
|
||||||
"global-settings": "global-settings",
|
"global-settings": "global-settings",
|
||||||
@@ -38,6 +41,8 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
|||||||
"mail-policy": "mail-policy",
|
"mail-policy": "mail-policy",
|
||||||
review: "review",
|
review: "review",
|
||||||
report: "report",
|
report: "report",
|
||||||
|
activity: "activity",
|
||||||
|
work: "work",
|
||||||
audit: "audit",
|
audit: "audit",
|
||||||
json: "json"
|
json: "json"
|
||||||
};
|
};
|
||||||
@@ -73,7 +78,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
|
||||||
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
|
}, [location.pathname, location.search, navigate, selectedVersionId, urlVersionId]);
|
||||||
|
|
||||||
function select(section: CampaignWorkspaceSection) {
|
function select(section: CampaignWorkspaceNavigationSection) {
|
||||||
const path = sectionPaths[section];
|
const path = sectionPaths[section];
|
||||||
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
|
const pathname = path ? `/campaigns/${campaignId}/${path}` : `/campaigns/${campaignId}`;
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
@@ -87,7 +92,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceLayout
|
<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"
|
primaryLabel="Campaign sections"
|
||||||
contentLabel="Campaign workspace"
|
contentLabel="Campaign workspace"
|
||||||
interfaceId="campaign.workspace"
|
interfaceId="campaign.workspace"
|
||||||
@@ -100,7 +105,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
<Route path="recipient-data" element={<Navigate to="../recipients" replace />} />
|
||||||
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="template" element={<TemplateDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="files" element={<AttachmentsDataPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="files" element={<AttachmentsDataPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||||
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
<Route path="attachments" element={<Navigate to="../files" replace />} />
|
||||||
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
<Route path="mail-settings" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="settings" />} />
|
||||||
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
<Route path="mail-policy" element={<MailSettingsPage settings={settings} campaignId={campaignId || ""} view="policy" />} />
|
||||||
@@ -112,6 +117,8 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
<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="reports" element={<Navigate to="../report" replace />} />
|
||||||
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
<Route path="audit" element={<CampaignAuditPage settings={settings} campaignId={campaignId || ""} />} />
|
||||||
<Route path="json" element={<CampaignJsonView 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 segments = pathname.split("/").filter(Boolean);
|
||||||
const section = segments[2];
|
const section = segments[2];
|
||||||
|
|
||||||
@@ -148,6 +155,8 @@ function sectionFromPath(pathname: string): CampaignWorkspaceSection {
|
|||||||
if (section === "review") return "review";
|
if (section === "review") return "review";
|
||||||
if (section === "send") return "review";
|
if (section === "send") return "review";
|
||||||
if (section === "report" || section === "reports") return "report";
|
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 === "audit") return "audit";
|
||||||
if (section === "json") return "json";
|
if (section === "json") return "json";
|
||||||
return "overview";
|
return "overview";
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
<FormGrid columns={2} collapseAt="wide" className="">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Send individualized invitations"
|
label="Send individualized invitations"
|
||||||
|
helpContextId="campaign.global-settings"
|
||||||
|
helpModuleId="campaigns"
|
||||||
checked={getBool(calendarInvitation, "enabled")}
|
checked={getBool(calendarInvitation, "enabled")}
|
||||||
disabled={locked || !calendarCatalog.available || !mailModuleInstalled}
|
disabled={locked || !calendarCatalog.available || !mailModuleInstalled}
|
||||||
onChange={(checked) => patch(["delivery", "calendar_invitation", "enabled"], checked)}
|
onChange={(checked) => patch(["delivery", "calendar_invitation", "enabled"], checked)}
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||||
actions={<div className="button-row compact-actions">
|
actions={<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
||||||
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
|
<Button variant="primary" helpContextId="campaign.server-settings" helpModuleId="campaigns" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
|
||||||
</div>}>
|
</div>}>
|
||||||
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
|
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
|
||||||
<FormGrid columns={2} collapseAt="wide" className="">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
@@ -399,8 +399,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="SMTP credential">
|
<FormField label="SMTP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||||
<select value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
||||||
<option value="">No credential</option>
|
<option value="">No credential</option>
|
||||||
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||||
@@ -413,8 +413,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="IMAP credential">
|
<FormField label="IMAP credential" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||||
<select value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
<select data-help-context-id="campaign.server-settings" data-help-module-id="campaigns" value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
||||||
<option value="">No credential</option>
|
<option value="">No credential</option>
|
||||||
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||||
@@ -476,8 +476,8 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<FormField label="Username">
|
<FormField label="Username">
|
||||||
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Password">
|
<FormField label="Password" helpContextId="campaign.server-settings" helpModuleId="campaigns">
|
||||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
|
<PasswordField helpContextId="campaign.server-settings" helpModuleId="campaigns" value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormGrid>
|
</FormGrid>
|
||||||
<FormGrid columns={2} collapseAt="standard" className="">
|
<FormGrid columns={2} collapseAt="standard" className="">
|
||||||
|
|||||||
@@ -932,10 +932,11 @@ export default function ReviewSendPage({
|
|||||||
const sent = result.sent_count ?? 0;
|
const sent = result.sent_count ?? 0;
|
||||||
const failed = result.failed_count ?? 0;
|
const failed = result.failed_count ?? 0;
|
||||||
const unknown = result.outcome_unknown_count ?? 0;
|
const unknown = result.outcome_unknown_count ?? 0;
|
||||||
|
const paused = result.paused_count ?? 0;
|
||||||
setMessage(
|
setMessage(
|
||||||
effectiveDryRun ?
|
effectiveDryRun ?
|
||||||
"i18n:govoplan-campaign.dry_run_finished_no_message_was_sent.c026c6cd" :
|
"i18n:govoplan-campaign.dry_run_finished_no_message_was_sent.c026c6cd" :
|
||||||
`Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}.`
|
`Send finished. SMTP accepted ${String(sent)} message(s), failed ${String(failed)}, outcome unknown ${String(unknown)}, paused ${String(paused)}.`
|
||||||
);
|
);
|
||||||
setSendConfirmOpen(false);
|
setSendConfirmOpen(false);
|
||||||
await reload();
|
await reload();
|
||||||
@@ -1637,7 +1638,7 @@ export default function ReviewSendPage({
|
|||||||
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.mock_delivery_uses_the_mail_module_development_m.9d645a5f</p>
|
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.mock_delivery_uses_the_mail_module_development_m.9d645a5f</p>
|
||||||
}
|
}
|
||||||
<div className="toggle-row mock-send-options">
|
<div className="toggle-row mock-send-options">
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.require_mock_before_real_send.a1ccc330" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
|
<ToggleSwitch label="i18n:govoplan-campaign.require_mock_before_real_send.a1ccc330" helpContextId="campaign.review-send" helpModuleId="campaigns" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
|
<ToggleSwitch label="i18n:govoplan-campaign.show_captured_mock_mailbox.019e1e79" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.clear_mock_mailbox_first.7627dcad" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
|
<ToggleSwitch label="i18n:govoplan-campaign.clear_mock_mailbox_first.7627dcad" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.append_mock_sent_copy.49c2cf08" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
|
<ToggleSwitch label="i18n:govoplan-campaign.append_mock_sent_copy.49c2cf08" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
|
||||||
@@ -1765,6 +1766,13 @@ export default function ReviewSendPage({
|
|||||||
{sendResult &&
|
{sendResult &&
|
||||||
<div className="review-flow-data-section">
|
<div className="review-flow-data-section">
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.attempted.a9eb9c90 {String(sendResult.attempted_count ?? "—")}i18n:govoplan-campaign.smtp_accepted.a5d0dccc {String(sendResult.sent_count ?? "—")}i18n:govoplan-campaign.failed.fac9f871 {String(sendResult.failed_count ?? "—")}i18n:govoplan-campaign.outcome_unknown.4383023a {String(sendResult.outcome_unknown_count ?? 0)}i18n:govoplan-campaign.skipped.6b98496c {String(sendResult.skipped_count ?? "—")}.</p>
|
<p className="muted small-note">i18n:govoplan-campaign.attempted.a9eb9c90 {String(sendResult.attempted_count ?? "—")}i18n:govoplan-campaign.smtp_accepted.a5d0dccc {String(sendResult.sent_count ?? "—")}i18n:govoplan-campaign.failed.fac9f871 {String(sendResult.failed_count ?? "—")}i18n:govoplan-campaign.outcome_unknown.4383023a {String(sendResult.outcome_unknown_count ?? 0)}i18n:govoplan-campaign.skipped.6b98496c {String(sendResult.skipped_count ?? "—")}.</p>
|
||||||
|
<p className="muted small-note">
|
||||||
|
SMTP batch: {humanize(String(sendResult.batch_state ?? "not_started"))} · connections {String(sendResult.smtp_connection_count ?? 0)} · reconnects {String(sendResult.smtp_reconnect_count ?? 0)} · paused {String(sendResult.paused_count ?? 0)}.
|
||||||
|
</p>
|
||||||
|
{sendResult.batch_state === "paused" &&
|
||||||
|
<DismissibleAlert tone="warning" resetKey={String(sendResult.batch_pause_reason_code ?? "smtp_systemic_failure")}>
|
||||||
|
Remaining messages were paused before SMTP after a systemic transport failure ({String(sendResult.batch_pause_reason_code ?? "smtp_systemic_failure")}). Review the Mail profile, then resume the campaign queue.
|
||||||
|
</DismissibleAlert>}
|
||||||
{sendResultRows.length > 0 &&
|
{sendResultRows.length > 0 &&
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-workflow-send-results`}
|
id={`campaign-${campaignId}-workflow-send-results`}
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export default function CampaignAccessCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions">{canProposeOwnership ? <Button onClick={openOwnerDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Transfer ownership</Button> : null}{canRequestOwnership ? <Button onClick={openRequestDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Request ownership</Button> : null}<Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={!canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
|
<Card title="i18n:govoplan-campaign.ownership_and_sharing.867283c0" actions={<div className="button-row compact-actions">{canProposeOwnership ? <Button helpContextId="campaign.overview" helpModuleId="campaigns" onClick={openOwnerDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Transfer ownership</Button> : null}{canRequestOwnership ? <Button onClick={openRequestDialog}><ArrowRightLeft size={16} aria-hidden="true" /> Request ownership</Button> : null}<Button onClick={() => setShareOpen(true)} disabled={available !== true}>i18n:govoplan-campaign.share.09ca55ca</Button><Button onClick={() => void openAccessExplanation()} disabled={!canExplainResourceAccess}><KeyRound size={16} aria-hidden="true" /> i18n:govoplan-campaign.explain_access.4d5fac37</Button></div>}>
|
||||||
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
|
<p><strong>i18n:govoplan-campaign.owner.719379ae</strong> {ownerLabel}</p>
|
||||||
<p className="muted small-note">Ownership changes require acceptance by the new owner. They never transfer private encryption keys. A completed transfer clears owner-scoped mail profile selection and requires revalidation.</p>
|
<p className="muted small-note">Ownership changes require acceptance by the new owner. They never transfer private encryption keys. A completed transfer clears owner-scoped mail profile selection and requires revalidation.</p>
|
||||||
{ownershipTransfers.length > 0 ? (
|
{ownershipTransfers.length > 0 ? (
|
||||||
@@ -489,7 +489,7 @@ export default function CampaignAccessCard({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Transfer campaign ownership" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId || ownerType === currentOwnerType && ownerId === currentOwnerId}>Propose transfer</Button></>}>
|
<Dialog open={ownerOpen} className="campaign-access-dialog" title="Transfer campaign ownership" helpContextId="campaign.overview" helpModuleId="campaigns" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" helpContextId="campaign.overview" helpModuleId="campaigns" onClick={() => void saveOwner()} disabled={busy || !ownerId || ownerType === currentOwnerType && ownerId === currentOwnerId}>Propose transfer</Button></>}>
|
||||||
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.owner.89ff3122">
|
<FormField label="i18n:govoplan-campaign.owner.89ff3122">
|
||||||
<ReferenceSelect
|
<ReferenceSelect
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export type AttachmentZipArchive = {
|
|||||||
password_field: string;
|
password_field: string;
|
||||||
password_scope: AttachmentZipPasswordScope;
|
password_scope: AttachmentZipPasswordScope;
|
||||||
method: "aes" | "zip_standard";
|
method: "aes" | "zip_standard";
|
||||||
|
password_delivery_channel: "separate_mail" | "sms" | "letter" | "phone" | "in_person";
|
||||||
|
legacy_zipcrypto_acknowledged: boolean;
|
||||||
|
legacy_zipcrypto_reason: string;
|
||||||
|
legacy_zipcrypto_acknowledged_by?: string;
|
||||||
|
legacy_zipcrypto_acknowledged_at?: string;
|
||||||
// Read-only compatibility values retained when normalizing older campaigns.
|
// Read-only compatibility values retained when normalizing older campaigns.
|
||||||
password_mode?: "none" | "direct" | "field" | "template";
|
password_mode?: "none" | "direct" | "field" | "template";
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -32,7 +37,10 @@ export function createAttachmentZipArchive(name = "attachments.zip", standard =
|
|||||||
password_enabled: false,
|
password_enabled: false,
|
||||||
password_field: "",
|
password_field: "",
|
||||||
password_scope: "local",
|
password_scope: "local",
|
||||||
method: "aes"
|
method: "aes",
|
||||||
|
password_delivery_channel: "separate_mail",
|
||||||
|
legacy_zipcrypto_acknowledged: false,
|
||||||
|
legacy_zipcrypto_reason: ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +60,11 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
|||||||
password_field: getText(archive, "password_field"),
|
password_field: getText(archive, "password_field"),
|
||||||
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
||||||
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||||
|
password_delivery_channel: normalizePasswordDeliveryChannel(getText(archive, "password_delivery_channel", "separate_mail")),
|
||||||
|
legacy_zipcrypto_acknowledged: getBool(archive, "legacy_zipcrypto_acknowledged"),
|
||||||
|
legacy_zipcrypto_reason: getText(archive, "legacy_zipcrypto_reason"),
|
||||||
|
...(getText(archive, "legacy_zipcrypto_acknowledged_by") ? { legacy_zipcrypto_acknowledged_by: getText(archive, "legacy_zipcrypto_acknowledged_by") } : {}),
|
||||||
|
...(getText(archive, "legacy_zipcrypto_acknowledged_at") ? { legacy_zipcrypto_acknowledged_at: getText(archive, "legacy_zipcrypto_acknowledged_at") } : {}),
|
||||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||||
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
||||||
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
||||||
@@ -76,6 +89,9 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
|||||||
password_field: getText(zip, "password_field"),
|
password_field: getText(zip, "password_field"),
|
||||||
password_scope: "local",
|
password_scope: "local",
|
||||||
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
||||||
|
password_delivery_channel: normalizePasswordDeliveryChannel(getText(zip, "password_delivery_channel", "separate_mail")),
|
||||||
|
legacy_zipcrypto_acknowledged: getBool(zip, "legacy_zipcrypto_acknowledged"),
|
||||||
|
legacy_zipcrypto_reason: getText(zip, "legacy_zipcrypto_reason"),
|
||||||
...(legacyMode ? { password_mode: legacyMode } : {}),
|
...(legacyMode ? { password_mode: legacyMode } : {}),
|
||||||
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
||||||
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
||||||
@@ -83,6 +99,13 @@ export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipC
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePasswordDeliveryChannel(value: string): AttachmentZipArchive["password_delivery_channel"] {
|
||||||
|
if (["sms", "letter", "phone", "in_person"].includes(value)) {
|
||||||
|
return value as AttachmentZipArchive["password_delivery_channel"];
|
||||||
|
}
|
||||||
|
return "separate_mail";
|
||||||
|
}
|
||||||
|
|
||||||
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
||||||
const zip = asRecord(rule.zip);
|
const zip = asRecord(rule.zip);
|
||||||
const archiveId = getText(zip, "archive_id");
|
const archiveId = getText(zip, "archive_id");
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function getText(record: Record<string, unknown>, key: string, fallback =
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadJson(filename: string, data: Record<string, unknown>) {
|
export function downloadJson(filename: string, data: unknown) {
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
|
|||||||
@@ -2,6 +2,52 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"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_file_policy": "Unassigned file policy",
|
||||||
"i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected",
|
"i18n:govoplan-campaign.unassigned_files_detected": "Unassigned files detected",
|
||||||
"i18n:govoplan-campaign.watched_sources": "Watched sources",
|
"i18n:govoplan-campaign.watched_sources": "Watched sources",
|
||||||
@@ -1346,6 +1392,52 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||||
},
|
},
|
||||||
"de": {
|
"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_file_policy": "Richtlinie für nicht zugeordnete Dateien",
|
||||||
"i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien",
|
"i18n:govoplan-campaign.unassigned_files_detected": "Erkannte nicht zugeordnete Dateien",
|
||||||
"i18n:govoplan-campaign.watched_sources": "Überwachte Quellen",
|
"i18n:govoplan-campaign.watched_sources": "Überwachte Quellen",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { CampaignWorkspaceSection } from "../types";
|
import type { CampaignWorkspaceSection } from "../types";
|
||||||
import { ModuleSubnav, type ModuleSubnavGroup } from "@govoplan/core-webui";
|
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 }]
|
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",
|
title: "i18n:govoplan-campaign.report.7b8ddb90",
|
||||||
items: [
|
items: [
|
||||||
{ id: "report", label: "i18n:govoplan-campaign.report.ee45c303" },
|
{ 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" }]
|
{ id: "audit", label: "i18n:govoplan-campaign.audit_log.3cfc5f1c" }]
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -49,10 +53,21 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
|
|||||||
|
|
||||||
export default function SectionSidebar({
|
export default function SectionSidebar({
|
||||||
active,
|
active,
|
||||||
onSelect
|
onSelect,
|
||||||
|
canReadActivity,
|
||||||
|
canReadWork
|
||||||
|
}: {
|
||||||
}: {active: CampaignWorkspaceSection;onSelect: (section: CampaignWorkspaceSection) => void;}) {
|
active: CampaignWorkspaceNavigationSection;
|
||||||
return <ModuleSubnav active={active} groups={campaignSubnav} onSelect={onSelect} />;
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -6,10 +6,12 @@ import {
|
|||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
type AuthInfo,
|
type AuthInfo,
|
||||||
type DashboardWidgetsUiCapability,
|
type DashboardWidgetsUiCapability,
|
||||||
type PlatformWebModule
|
type PlatformWebModule,
|
||||||
|
type QuickAccessToolsUiCapability
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { getCampaign } from "./api/campaigns";
|
import { getCampaign } from "./api/campaigns";
|
||||||
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
|
import CampaignActivityWidget from "./features/campaigns/CampaignActivityWidget";
|
||||||
|
import CampaignQuickAccess from "./features/campaigns/CampaignQuickAccess";
|
||||||
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
|
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "./features/operator/operatorQueueAccess";
|
||||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||||
import { campaignWizardDirectories } from "./features/campaigns/wizard/directory";
|
import { campaignWizardDirectories } from "./features/campaigns/wizard/directory";
|
||||||
@@ -72,15 +74,37 @@ const campaignDashboardWidgets: DashboardWidgetsUiCapability = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
const campaignQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
id: "campaigns.select",
|
||||||
|
render: (context) => createElement(CampaignQuickAccess, context)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
export const campaignModule: PlatformWebModule = {
|
export const campaignModule: PlatformWebModule = {
|
||||||
id: "campaigns",
|
id: "campaigns",
|
||||||
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
label: "i18n:govoplan-campaign.campaigns.01a23a28",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: ["access"],
|
dependencies: ["access"],
|
||||||
optionalDependencies: ["files", "mail"],
|
optionalDependencies: ["files", "mail", "notifications", "organizations", "idm", "tasks"],
|
||||||
translations,
|
translations,
|
||||||
viewSurfaces: [
|
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",
|
id: "campaigns.widget.activity",
|
||||||
moduleId: "campaigns",
|
moduleId: "campaigns",
|
||||||
@@ -100,6 +124,7 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"dashboard.widgets": campaignDashboardWidgets,
|
"dashboard.widgets": campaignDashboardWidgets,
|
||||||
|
"quickAccess.tools": campaignQuickAccessTools,
|
||||||
"wizard.directories": campaignWizardDirectories
|
"wizard.directories": campaignWizardDirectories
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2025,6 +2025,16 @@
|
|||||||
padding-bottom: 7px;
|
padding-bottom: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.campaign-legacy-zipcrypto-acknowledgement {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.campaign-legacy-zipcrypto-acknowledgement input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.attachment-zip-name-button {
|
.attachment-zip-name-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -2716,10 +2726,15 @@
|
|||||||
.campaign-copy-option strong, .campaign-copy-option small { display: block; }
|
.campaign-copy-option strong, .campaign-copy-option small { display: block; }
|
||||||
.campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; }
|
.campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; }
|
||||||
.campaign-copy-option.is-disabled { opacity: .62; }
|
.campaign-copy-option.is-disabled { opacity: .62; }
|
||||||
|
.campaign-import-plan { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.campaign-import-plan h3 { margin: 0 0 8px; font-size: 1rem; }
|
||||||
|
.campaign-import-plan ul { margin: 0; padding-left: 20px; }
|
||||||
|
.campaign-import-plan li + li { margin-top: 6px; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.campaign-copy-identity { grid-template-columns: 1fr; }
|
.campaign-copy-identity { grid-template-columns: 1fr; }
|
||||||
.campaign-copy-option { grid-template-columns: 1fr; }
|
.campaign-copy-option { grid-template-columns: 1fr; }
|
||||||
|
.campaign-import-plan { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); }
|
.campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); }
|
||||||
@@ -2770,3 +2785,52 @@
|
|||||||
.campaign-residual-file-form { grid-template-columns: minmax(0, 1fr); }
|
.campaign-residual-file-form { grid-template-columns: minmax(0, 1fr); }
|
||||||
.campaign-residual-file-wide { grid-column: auto; }
|
.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.is-focused { box-shadow: var(--focus-ring-strong); }
|
||||||
|
.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.");
|
||||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
||||||
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
|
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
|
||||||
|
const campaignList = readFileSync("src/features/campaigns/CampaignListPage.tsx", "utf8");
|
||||||
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
|
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
|
||||||
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
|
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
|
||||||
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
|
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
|
||||||
@@ -21,11 +22,28 @@ assert.match(overview, /include_files/);
|
|||||||
assert.match(overview, /include_shares/);
|
assert.match(overview, /include_shares/);
|
||||||
assert.match(overview, /include_policies/);
|
assert.match(overview, /include_policies/);
|
||||||
assert.match(overview, /include_mail_profile/);
|
assert.match(overview, /include_mail_profile/);
|
||||||
|
assert.match(overview, /delivery_mode: "manual"/);
|
||||||
|
assert.match(overview, /Autonomous approved delivery/);
|
||||||
|
assert.match(overview, /campaigns:campaign:send/);
|
||||||
|
assert.match(overview, /mail:profile:use/);
|
||||||
|
assert.match(overview, /schedule\.last_outcome/);
|
||||||
|
assert.match(overview, /schedule\.last_recovery_state/);
|
||||||
|
assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/);
|
||||||
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
|
||||||
|
assert.match(overview, /defaultExportScopes: CampaignTransferScope\[\] = \["metadata", "template_config"\]/);
|
||||||
|
assert.match(overview, /hasScope\(auth, "campaigns:campaign:export"\)/);
|
||||||
|
assert.match(overview, /await exportCampaignPackage/);
|
||||||
|
assert.match(overview, /credentials, or transport secrets/);
|
||||||
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
||||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
||||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
|
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
|
||||||
|
assert.match(api, /\/api\/v1\/campaign-transfers\/imports\/preview/);
|
||||||
|
assert.match(api, /expected_package_sha256/);
|
||||||
|
assert.match(campaignList, /hasScope\(auth, "campaigns:campaign:import"\)/);
|
||||||
|
assert.match(campaignList, /setImportPreviewStale\(true\)/);
|
||||||
|
assert.match(campaignList, /Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state/);
|
||||||
|
assert.match(campaignList, /expected_package_sha256: importPreview\.package_sha256/);
|
||||||
assert.match(recipients, /requestBulkActivation\(true\)/);
|
assert.match(recipients, /requestBulkActivation\(true\)/);
|
||||||
assert.match(recipients, /requestBulkActivation\(false\)/);
|
assert.match(recipients, /requestBulkActivation\(false\)/);
|
||||||
assert.match(recipients, /const count = inlineEntries\.filter/);
|
assert.match(recipients, /const count = inlineEntries\.filter/);
|
||||||
|
|||||||
@@ -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