Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f083e5f6 | ||
|
|
5a21067e44 | ||
|
|
73cfad209a | ||
|
|
2a00d910df | ||
|
|
c846c249b8 | ||
|
|
69e6588a89 | ||
|
|
75e8f864a7 | ||
|
|
d5b874c469 | ||
|
|
5c27527725 | ||
|
|
112ef9dc31 | ||
|
|
f4fe534ee1 | ||
|
|
b6af7665f4 | ||
|
|
14e94873a9 | ||
|
|
8e2f9d743d | ||
|
|
039ce35e78 | ||
|
|
7bc5e4a35c | ||
|
|
06f773e4eb | ||
|
|
9a2f13bc9a | ||
|
|
cf8d7fab11 | ||
|
|
68459fde15 | ||
|
|
c2efd6b7bd | ||
|
|
696f8f6385 | ||
|
|
6562484d32 | ||
|
|
2db99eaf6a |
@@ -59,6 +59,20 @@ 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.
|
||||||
|
|
||||||
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 +92,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 +137,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.
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
# Campaign Accessibility Review
|
# Campaign Accessibility Review
|
||||||
|
|
||||||
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
|
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
|
||||||
alert, and segmented-control components. Core owns focus trapping, focus return,
|
alert, and segmented-control components. Its page frames use Core `PageLayout`
|
||||||
Escape handling, labels, disabled state, and keyboard behavior for those shared
|
and its full-canvas navigation/content shells use `WorkspaceLayout`, so pane
|
||||||
|
scrolling, sticky headings, action collapse, narrow-layout behavior, and help
|
||||||
|
scope remain platform-owned. Core also owns focus trapping, focus return,
|
||||||
|
Escape handling, labels, disabled state, and keyboard behavior for the shared
|
||||||
primitives.
|
primitives.
|
||||||
|
|
||||||
## Repeatable review matrix
|
## Repeatable review matrix
|
||||||
|
|||||||
@@ -10,19 +10,60 @@ and they must identify that inheritance explicitly.
|
|||||||
- Campaign version
|
- Campaign version
|
||||||
- Campaign delivery job / built message
|
- Campaign delivery job / built message
|
||||||
- Computed Campaign report, identified by Campaign, version, and report kind
|
- Computed Campaign report, identified by Campaign, version, and report kind
|
||||||
|
- Recipient row, identified by `<version UUID>:<job UUID>`
|
||||||
|
- Frozen recipient source snapshot, identified by its Campaign-version UUID
|
||||||
|
- Campaign attachment binding and version-bound frozen attachment resolution
|
||||||
|
- Persisted validation issue, version-bound review decision, and attachment-policy override
|
||||||
|
- SMTP, IMAP append, Postbox, and printable-output attempts
|
||||||
|
- Message action, message-action attempt, and job reconciliation decision
|
||||||
|
- Campaign share and Core-owned Campaign ownership-transfer record
|
||||||
|
- Independently user-owned recipient import mapping profile
|
||||||
|
- Saved recipient import execution, identified by `<version UUID>:<import UUID>`
|
||||||
|
- Persisted validation, build, execution-snapshot, and review evidence, identified
|
||||||
|
by `<version UUID>:<artifact kind>`
|
||||||
|
|
||||||
## Planned Slices
|
All persisted child IDs are random UUIDs. Embedded build/review children use a
|
||||||
|
version UUID plus a random job UUID, so callers cannot enumerate a recipient
|
||||||
|
index or infer an address. A version mismatch is reported as a stale reference.
|
||||||
|
Missing and cross-tenant children use the same non-disclosing not-found
|
||||||
|
provenance. Explanations never include recipient addresses, source rows,
|
||||||
|
filenames, object locators, transport responses, worker claims, target
|
||||||
|
snapshots, diagnostic text, or reconciliation notes.
|
||||||
|
|
||||||
1. Recipient rows and imported recipient-source snapshots
|
## Permission matrix
|
||||||
2. Attachment bindings and frozen attachment resolutions
|
|
||||||
3. Validation issues, review decisions, and attachment-policy overrides
|
| Evidence | Parent boundary | Further restriction |
|
||||||
4. Delivery attempts, IMAP append attempts, Postbox attempts, and
|
| --- | --- | --- |
|
||||||
reconciliation decisions
|
| Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
|
||||||
5. Campaign shares and ownership-transfer records
|
| Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
|
||||||
6. Import mapping profiles and import executions
|
| Delivery status | Campaign read/owner/share | `campaigns:report:read` |
|
||||||
7. Reusable Campaign templates and template revisions when the template
|
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
|
||||||
library becomes persistent
|
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
|
||||||
8. Export packages and protocol/report artifacts
|
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
|
||||||
|
| Share or ownership transfer | Campaign governance | Campaign share, transfer-participant, group-acceptance, or recovery authority; content access remains a separate decision |
|
||||||
|
| Import mapping profile | Independent user owner | `campaigns:recipient:import`; no Campaign share is inherited |
|
||||||
|
| Import execution | Campaign read/owner/share | Recipient read and import authority |
|
||||||
|
| Persisted protocol artifact | Campaign read/owner/share | Recipient, review, report, diagnostic, or export authority appropriate to the artifact |
|
||||||
|
|
||||||
|
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
|
||||||
|
after provider acceptance. Their explanation therefore remains available when
|
||||||
|
an optional provider module is later disabled. A missing attempt reports only
|
||||||
|
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
|
||||||
|
from hidden data.
|
||||||
|
|
||||||
|
## Optional and unsupported owner boundaries
|
||||||
|
|
||||||
|
Reusable templates and template revisions are independently governed by the
|
||||||
|
optional Templates module; Campaign never treats a Campaign share as a template
|
||||||
|
grant. Durable export packages are independently governed by the optional
|
||||||
|
Reporting module. Asking the Campaign provider to explain either class therefore
|
||||||
|
fails closed with `independently_governed_by_optional_module` and
|
||||||
|
`unavailable_or_hidden`. The response does not reveal whether the optional
|
||||||
|
module is absent, the object does not exist, or the caller cannot see it.
|
||||||
|
|
||||||
|
Campaign reports generated on demand remain non-persisted, version-bound
|
||||||
|
resources. Their explanation names the report kind and its Campaign/version
|
||||||
|
parent, and keeps report read, export, and diagnostic permissions distinct.
|
||||||
|
|
||||||
Each child explanation must include:
|
Each child explanation must include:
|
||||||
|
|
||||||
@@ -38,3 +79,9 @@ Delivery attempts, review decisions, reports, and exports can contain more
|
|||||||
sensitive evidence than the Campaign summary. Their read and diagnostic/export
|
sensitive evidence than the Campaign summary. Their read and diagnostic/export
|
||||||
permissions therefore remain independently enforceable even when the parent
|
permissions therefore remain independently enforceable even when the parent
|
||||||
Campaign is readable.
|
Campaign is readable.
|
||||||
|
|
||||||
|
Import explanations include only the stable import identity, source type,
|
||||||
|
opaque source identity, source revision, and whether additional provenance was
|
||||||
|
recorded. They never return imported rows, filenames, column mappings, or source
|
||||||
|
metadata. Mapping-profile explanations expose only non-reversible header
|
||||||
|
fingerprints and shape information; headers and mappings remain hidden.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,32 @@ 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.
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -557,6 +584,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 +667,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
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.15"
|
version = "0.1.20"
|
||||||
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.15",
|
"govoplan-core>=0.1.18",
|
||||||
"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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections import defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.models import (
|
||||||
|
AttachmentReuseAction,
|
||||||
|
AttachmentReuseAllowance,
|
||||||
|
AttachmentReusePolicy,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.messages.models import MessageDraft, MessageIssue
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _AttachmentUse:
|
||||||
|
message: MessageDraft
|
||||||
|
message_key: str
|
||||||
|
recipient_key: tuple[str, ...]
|
||||||
|
source_identity: str
|
||||||
|
file_name: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AttachmentReuseEvaluation:
|
||||||
|
report: dict[str, object]
|
||||||
|
issues_by_entry_index: dict[int, list[MessageIssue]]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_attachment_reuse(
|
||||||
|
messages: list[MessageDraft],
|
||||||
|
*,
|
||||||
|
policy: AttachmentReusePolicy,
|
||||||
|
) -> AttachmentReuseEvaluation:
|
||||||
|
"""Evaluate repeated resolved-file use without exposing source paths.
|
||||||
|
|
||||||
|
A use is one resolved file occurrence in one attachment rule. The same
|
||||||
|
source file can therefore be detected both across built messages and when
|
||||||
|
two rules add it to one message. Allowed findings remain in the build
|
||||||
|
protocol; policy violations additionally become recipient-level issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
uses_by_source: dict[str, list[_AttachmentUse]] = defaultdict(list)
|
||||||
|
for message in messages:
|
||||||
|
if not message.active:
|
||||||
|
continue
|
||||||
|
message_key = str(message.entry_id or message.entry_index)
|
||||||
|
recipient_key = _recipient_key(message, fallback=message_key)
|
||||||
|
for attachment in message.attachments:
|
||||||
|
for match in attachment.matches:
|
||||||
|
source_identity = _source_identity(match)
|
||||||
|
uses_by_source[source_identity].append(
|
||||||
|
_AttachmentUse(
|
||||||
|
message=message,
|
||||||
|
message_key=message_key,
|
||||||
|
recipient_key=recipient_key,
|
||||||
|
source_identity=source_identity,
|
||||||
|
file_name=Path(match).name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
findings: list[dict[str, object]] = []
|
||||||
|
issues_by_entry_index: dict[int, list[MessageIssue]] = defaultdict(list)
|
||||||
|
affected_entry_indexes: set[int] = set()
|
||||||
|
allowed_count = 0
|
||||||
|
violation_count = 0
|
||||||
|
|
||||||
|
for source_identity, uses in sorted(uses_by_source.items()):
|
||||||
|
if len(uses) < 2:
|
||||||
|
continue
|
||||||
|
fingerprint = hashlib.sha256(source_identity.encode("utf-8")).hexdigest()
|
||||||
|
message_keys = {item.message_key for item in uses}
|
||||||
|
recipient_keys = {item.recipient_key for item in uses}
|
||||||
|
allowed, explanation = _is_allowed(
|
||||||
|
policy,
|
||||||
|
message_count=len(message_keys),
|
||||||
|
recipient_count=len(recipient_keys),
|
||||||
|
)
|
||||||
|
disposition = "allowed" if allowed else policy.action.value
|
||||||
|
finding = {
|
||||||
|
"file_fingerprint": fingerprint,
|
||||||
|
"file_name": uses[0].file_name,
|
||||||
|
"use_count": len(uses),
|
||||||
|
"message_count": len(message_keys),
|
||||||
|
"recipient_count": len(recipient_keys),
|
||||||
|
"disposition": disposition,
|
||||||
|
"explanation": explanation,
|
||||||
|
}
|
||||||
|
findings.append(finding)
|
||||||
|
if allowed:
|
||||||
|
allowed_count += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
violation_count += 1
|
||||||
|
behavior = _issue_behavior(policy.action)
|
||||||
|
severity = (
|
||||||
|
"error" if policy.action == AttachmentReuseAction.BLOCK else "warning"
|
||||||
|
)
|
||||||
|
for use in _unique_message_uses(uses):
|
||||||
|
affected_entry_indexes.add(use.message.entry_index)
|
||||||
|
issues_by_entry_index[use.message.entry_index].append(
|
||||||
|
MessageIssue(
|
||||||
|
severity=severity,
|
||||||
|
code="duplicate_attachment_reuse",
|
||||||
|
message=(
|
||||||
|
f"Attachment {use.file_name!r} is reused {len(uses)} times "
|
||||||
|
f"across {len(message_keys)} built message(s); the configured "
|
||||||
|
f"policy requires {disposition}."
|
||||||
|
),
|
||||||
|
behavior=behavior,
|
||||||
|
source="attachments:reuse_policy",
|
||||||
|
details={
|
||||||
|
**finding,
|
||||||
|
"policy": policy.model_dump(mode="json"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AttachmentReuseEvaluation(
|
||||||
|
report={
|
||||||
|
"contract_version": "1",
|
||||||
|
"policy": policy.model_dump(mode="json"),
|
||||||
|
"duplicate_file_count": len(findings),
|
||||||
|
"allowed_file_count": allowed_count,
|
||||||
|
"violation_file_count": violation_count,
|
||||||
|
"affected_message_count": len(affected_entry_indexes),
|
||||||
|
"findings": findings,
|
||||||
|
},
|
||||||
|
issues_by_entry_index=dict(issues_by_entry_index),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _source_identity(value: str) -> str:
|
||||||
|
return str(Path(value).resolve(strict=False))
|
||||||
|
|
||||||
|
|
||||||
|
def _recipient_key(message: MessageDraft, *, fallback: str) -> tuple[str, ...]:
|
||||||
|
addresses = message.to or message.bcc or message.cc
|
||||||
|
normalized = sorted(
|
||||||
|
{
|
||||||
|
item.email.strip().casefold()
|
||||||
|
for item in addresses
|
||||||
|
if item.email and item.email.strip()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return tuple(normalized) if normalized else (f"entry:{fallback}",)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed(
|
||||||
|
policy: AttachmentReusePolicy,
|
||||||
|
*,
|
||||||
|
message_count: int,
|
||||||
|
recipient_count: int,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
if policy.action == AttachmentReuseAction.ALLOW:
|
||||||
|
return True, "The campaign policy explicitly allows attachment reuse."
|
||||||
|
if (
|
||||||
|
policy.allow_within == AttachmentReuseAllowance.SAME_MESSAGE
|
||||||
|
and message_count == 1
|
||||||
|
):
|
||||||
|
return True, "Reuse is confined to one built message as allowed by policy."
|
||||||
|
if (
|
||||||
|
policy.allow_within == AttachmentReuseAllowance.SAME_RECIPIENT
|
||||||
|
and recipient_count == 1
|
||||||
|
):
|
||||||
|
return True, "Reuse is confined to one recipient as allowed by policy."
|
||||||
|
return False, (
|
||||||
|
"Reuse crosses the configured allowance and is handled by the "
|
||||||
|
f"{policy.action.value} policy."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_behavior(action: AttachmentReuseAction) -> str:
|
||||||
|
if action == AttachmentReuseAction.REVIEW:
|
||||||
|
return "ask"
|
||||||
|
return action.value
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_message_uses(uses: list[_AttachmentUse]) -> list[_AttachmentUse]:
|
||||||
|
unique: dict[int, _AttachmentUse] = {}
|
||||||
|
for use in uses:
|
||||||
|
unique.setdefault(use.message.entry_index, use)
|
||||||
|
return list(unique.values())
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_COPY_OPTIONS: dict[str, bool] = {
|
||||||
|
"include_recipients": True,
|
||||||
|
"include_files": True,
|
||||||
|
"include_shares": False,
|
||||||
|
"include_policies": True,
|
||||||
|
"include_mail_profile": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_copy_configuration(
|
||||||
|
source: Mapping[str, object],
|
||||||
|
options: Mapping[str, object],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Return an editable configuration copy without operational evidence."""
|
||||||
|
|
||||||
|
selected = {**DEFAULT_COPY_OPTIONS, **dict(options)}
|
||||||
|
raw_json = copy.deepcopy(dict(source))
|
||||||
|
if not selected["include_recipients"]:
|
||||||
|
raw_json["recipients"] = {}
|
||||||
|
raw_json["entries"] = {"inline": [], "imports": []}
|
||||||
|
if not selected["include_files"]:
|
||||||
|
raw_json["attachments"] = {}
|
||||||
|
entries = raw_json.get("entries")
|
||||||
|
if isinstance(entries, dict):
|
||||||
|
inline = entries.get("inline")
|
||||||
|
if isinstance(inline, list):
|
||||||
|
for entry in inline:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
entry["attachments"] = []
|
||||||
|
entry["combine_attachments"] = True
|
||||||
|
if not selected["include_policies"]:
|
||||||
|
raw_json["validation_policy"] = {}
|
||||||
|
if not selected["include_mail_profile"]:
|
||||||
|
raw_json["server"] = {}
|
||||||
|
return raw_json
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["DEFAULT_COPY_OPTIONS", "campaign_copy_configuration"]
|
||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignJob,
|
CampaignJob,
|
||||||
|
CampaignSchedule,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,7 @@ from govoplan_core.auth import ApiPrincipal, has_scope
|
|||||||
|
|
||||||
|
|
||||||
POLICY_ID = "campaign.lifecycle"
|
POLICY_ID = "campaign.lifecycle"
|
||||||
POLICY_VERSION = "1"
|
POLICY_VERSION = "2"
|
||||||
|
|
||||||
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
|
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
|
||||||
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
|
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
|
||||||
@@ -104,6 +105,12 @@ def campaign_lifecycle_policy(
|
|||||||
.order_by(CampaignShare.id.asc())
|
.order_by(CampaignShare.id.asc())
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
schedules = (
|
||||||
|
session.query(CampaignSchedule)
|
||||||
|
.filter(CampaignSchedule.campaign_id == campaign.id)
|
||||||
|
.order_by(CampaignSchedule.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
selected_version = next(
|
selected_version = next(
|
||||||
(version for version in versions if version.id == version_id),
|
(version for version in versions if version.id == version_id),
|
||||||
None,
|
None,
|
||||||
@@ -116,6 +123,10 @@ def campaign_lifecycle_policy(
|
|||||||
"id": campaign.id,
|
"id": campaign.id,
|
||||||
"status": campaign.status,
|
"status": campaign.status,
|
||||||
"current_version_id": campaign.current_version_id,
|
"current_version_id": campaign.current_version_id,
|
||||||
|
"settings_sha256": _canonical_hash(campaign.settings or {}),
|
||||||
|
"mail_profile_policy_sha256": _canonical_hash(
|
||||||
|
campaign.mail_profile_policy or {}
|
||||||
|
),
|
||||||
"updated_at": _timestamp(campaign.updated_at),
|
"updated_at": _timestamp(campaign.updated_at),
|
||||||
},
|
},
|
||||||
"versions": [
|
"versions": [
|
||||||
@@ -129,6 +140,7 @@ def campaign_lifecycle_policy(
|
|||||||
"published_at": _timestamp(version.published_at),
|
"published_at": _timestamp(version.published_at),
|
||||||
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
|
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
|
||||||
"archived_at": _timestamp(version.archived_at),
|
"archived_at": _timestamp(version.archived_at),
|
||||||
|
"configuration_sha256": _canonical_hash(version.raw_json or {}),
|
||||||
"updated_at": _timestamp(version.updated_at),
|
"updated_at": _timestamp(version.updated_at),
|
||||||
}
|
}
|
||||||
for version in versions
|
for version in versions
|
||||||
@@ -145,13 +157,34 @@ def campaign_lifecycle_policy(
|
|||||||
}
|
}
|
||||||
for job in jobs
|
for job in jobs
|
||||||
],
|
],
|
||||||
"active_share_ids": [share.id for share in shares],
|
"active_shares": [
|
||||||
|
{
|
||||||
|
"id": share.id,
|
||||||
|
"target_type": share.target_type,
|
||||||
|
"target_id": share.target_id,
|
||||||
|
"permission": share.permission,
|
||||||
|
"updated_at": _timestamp(share.updated_at),
|
||||||
|
}
|
||||||
|
for share in shares
|
||||||
|
],
|
||||||
|
"schedules": [
|
||||||
|
{
|
||||||
|
"id": schedule.id,
|
||||||
|
"active": schedule.active,
|
||||||
|
"resource_revision": schedule.resource_revision,
|
||||||
|
"next_fire_at": _timestamp(schedule.next_fire_at),
|
||||||
|
"occurrence_count": schedule.occurrence_count,
|
||||||
|
"updated_at": _timestamp(schedule.updated_at),
|
||||||
|
}
|
||||||
|
for schedule in schedules
|
||||||
|
],
|
||||||
"selected_version_id": version_id,
|
"selected_version_id": version_id,
|
||||||
}
|
}
|
||||||
token = _canonical_hash(snapshot)
|
token = _canonical_hash(snapshot)
|
||||||
|
|
||||||
active_delivery = any(_active_delivery(job) for job in jobs)
|
active_delivery = any(_active_delivery(job) for job in jobs)
|
||||||
protected_versions = any(_protected_version(version) for version in versions)
|
protected_versions = any(_protected_version(version) for version in versions)
|
||||||
|
active_schedules = any(schedule.active for schedule in schedules)
|
||||||
|
|
||||||
archive = LifecycleDecision(True)
|
archive = LifecycleDecision(True)
|
||||||
if not has_scope(principal, "campaigns:campaign:archive"):
|
if not has_scope(principal, "campaigns:campaign:archive"):
|
||||||
@@ -163,6 +196,11 @@ def campaign_lifecycle_policy(
|
|||||||
False,
|
False,
|
||||||
"Active or uncertain delivery must be resolved before archiving.",
|
"Active or uncertain delivery must be resolved before archiving.",
|
||||||
)
|
)
|
||||||
|
elif active_schedules:
|
||||||
|
archive = LifecycleDecision(
|
||||||
|
False,
|
||||||
|
"Pause active Campaign schedules before archiving.",
|
||||||
|
)
|
||||||
|
|
||||||
delete = LifecycleDecision(True)
|
delete = LifecycleDecision(True)
|
||||||
if not has_scope(principal, "campaigns:campaign:delete"):
|
if not has_scope(principal, "campaigns:campaign:delete"):
|
||||||
@@ -184,12 +222,15 @@ def campaign_lifecycle_policy(
|
|||||||
False,
|
False,
|
||||||
"Revoke active campaign shares before deleting the untouched draft.",
|
"Revoke active campaign shares before deleting the untouched draft.",
|
||||||
)
|
)
|
||||||
|
elif schedules:
|
||||||
|
delete = LifecycleDecision(
|
||||||
|
False,
|
||||||
|
"Campaigns with schedule evidence must be archived instead of deleted.",
|
||||||
|
)
|
||||||
|
|
||||||
copy = LifecycleDecision(True)
|
copy = LifecycleDecision(True)
|
||||||
if not has_scope(principal, "campaigns:campaign:copy"):
|
if not has_scope(principal, "campaigns:campaign:copy"):
|
||||||
copy = LifecycleDecision(False, "Missing campaign copy permission.")
|
copy = LifecycleDecision(False, "Missing campaign copy permission.")
|
||||||
elif not has_scope(principal, "campaigns:recipient:read"):
|
|
||||||
copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.")
|
|
||||||
elif version_id is not None and selected_version is None:
|
elif version_id is not None and selected_version is None:
|
||||||
copy = LifecycleDecision(False, "The selected source version does not exist.")
|
copy = LifecycleDecision(False, "The selected source version does not exist.")
|
||||||
|
|
||||||
@@ -220,9 +261,10 @@ def campaign_lifecycle_policy(
|
|||||||
"campaign_state",
|
"campaign_state",
|
||||||
"retained_evidence",
|
"retained_evidence",
|
||||||
"active_delivery",
|
"active_delivery",
|
||||||
|
"scheduled_automation",
|
||||||
"optimistic_concurrency",
|
"optimistic_concurrency",
|
||||||
),
|
),
|
||||||
"evidence_retention": "Versions, delivery outcomes, reports, and audit records are never deleted by archival.",
|
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -468,6 +497,48 @@ class AttachmentBasePathConfig(StrictModel):
|
|||||||
source: str | None = None
|
source: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResidualFileMode(StrEnum):
|
||||||
|
NONE = "none"
|
||||||
|
REPORT = "report"
|
||||||
|
ATTACH = "attach"
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentReuseAction(StrEnum):
|
||||||
|
ALLOW = "allow"
|
||||||
|
WARN = "warn"
|
||||||
|
REVIEW = "review"
|
||||||
|
BLOCK = "block"
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentReuseAllowance(StrEnum):
|
||||||
|
NONE = "none"
|
||||||
|
SAME_RECIPIENT = "same_recipient"
|
||||||
|
SAME_MESSAGE = "same_message"
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentReusePolicy(StrictModel):
|
||||||
|
action: AttachmentReuseAction = AttachmentReuseAction.ALLOW
|
||||||
|
allow_within: AttachmentReuseAllowance = AttachmentReuseAllowance.NONE
|
||||||
|
|
||||||
|
|
||||||
|
class ResidualFileDispositionConfig(StrictModel):
|
||||||
|
mode: ResidualFileMode = ResidualFileMode.NONE
|
||||||
|
recipient: RecipientConfig | None = None
|
||||||
|
subject: str = "Unassigned files in campaign {{local:campaign_name}}"
|
||||||
|
text: str = (
|
||||||
|
"The campaign build found {{local:residual_file_count}} file(s) that "
|
||||||
|
"were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def require_recipient_for_routing(self) -> "ResidualFileDispositionConfig":
|
||||||
|
if self.mode != ResidualFileMode.NONE and self.recipient is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Residual-file report or attachment routing requires a recipient."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class AttachmentConfig(StrictModel):
|
class AttachmentConfig(StrictModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
label: str | None = None
|
label: str | None = None
|
||||||
@@ -509,6 +580,12 @@ class AttachmentsConfig(StrictModel):
|
|||||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||||
missing_behavior: Behavior = Behavior.WARN
|
missing_behavior: Behavior = Behavior.WARN
|
||||||
ambiguous_behavior: Behavior = Behavior.ASK
|
ambiguous_behavior: Behavior = Behavior.ASK
|
||||||
|
reuse_policy: AttachmentReusePolicy = Field(
|
||||||
|
default_factory=AttachmentReusePolicy
|
||||||
|
)
|
||||||
|
residual_files: ResidualFileDispositionConfig = Field(
|
||||||
|
default_factory=ResidualFileDispositionConfig
|
||||||
|
)
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
||||||
|
|||||||
@@ -0,0 +1,964 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import calendar
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignScheduleOccurrence,
|
||||||
|
CampaignShare,
|
||||||
|
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 (
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
||||||
|
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
||||||
|
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
||||||
|
return hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=True,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_schedule_source_snapshot(
|
||||||
|
*,
|
||||||
|
configuration: Mapping[str, object],
|
||||||
|
campaign_settings: Mapping[str, object],
|
||||||
|
mail_profile_policy: Mapping[str, object],
|
||||||
|
shares: list[Mapping[str, object]],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Seal every selected source domain so worker execution cannot drift."""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": SCHEDULE_SOURCE_SCHEMA,
|
||||||
|
"configuration": copy.deepcopy(dict(configuration)),
|
||||||
|
"campaign_settings": copy.deepcopy(dict(campaign_settings)),
|
||||||
|
"mail_profile_policy": copy.deepcopy(dict(mail_profile_policy)),
|
||||||
|
"shares": [copy.deepcopy(dict(item)) for item in shares],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def next_schedule_fire(
|
||||||
|
scheduled_for: datetime,
|
||||||
|
*,
|
||||||
|
recurrence_kind: str,
|
||||||
|
interval_count: int,
|
||||||
|
timezone_name: str,
|
||||||
|
) -> datetime | None:
|
||||||
|
if recurrence_kind == "once":
|
||||||
|
return None
|
||||||
|
if recurrence_kind not in RECURRENCE_KINDS:
|
||||||
|
raise ValueError(f"Unsupported campaign recurrence: {recurrence_kind}")
|
||||||
|
if interval_count < 1:
|
||||||
|
raise ValueError("Campaign recurrence interval must be positive")
|
||||||
|
try:
|
||||||
|
zone = ZoneInfo(timezone_name)
|
||||||
|
except ZoneInfoNotFoundError as exc:
|
||||||
|
raise ValueError(f"Unknown campaign schedule timezone: {timezone_name}") from exc
|
||||||
|
local = _as_utc(scheduled_for).astimezone(zone)
|
||||||
|
if recurrence_kind == "daily":
|
||||||
|
upcoming = local + timedelta(days=interval_count)
|
||||||
|
elif recurrence_kind == "weekly":
|
||||||
|
upcoming = local + timedelta(weeks=interval_count)
|
||||||
|
else:
|
||||||
|
month_index = local.year * 12 + local.month - 1 + interval_count
|
||||||
|
year, month_offset = divmod(month_index, 12)
|
||||||
|
month = month_offset + 1
|
||||||
|
day = min(local.day, calendar.monthrange(year, month)[1])
|
||||||
|
upcoming = local.replace(year=year, month=month, day=day)
|
||||||
|
return upcoming.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_due_campaign_schedules(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
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(
|
||||||
|
CampaignSchedule.active.is_(True),
|
||||||
|
CampaignSchedule.next_fire_at.is_not(None),
|
||||||
|
CampaignSchedule.next_fire_at <= observed_at,
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.filter(CampaignSchedule.tenant_id == tenant_id)
|
||||||
|
schedules = (
|
||||||
|
query.order_by(CampaignSchedule.next_fire_at.asc(), CampaignSchedule.id.asc())
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
.limit(max(1, min(limit, 250)))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"selected": len(schedules),
|
||||||
|
"prepared": 0,
|
||||||
|
"autonomous_prepared": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"completed": 0,
|
||||||
|
"coalesced": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"deferred": 0,
|
||||||
|
"campaign_ids": [],
|
||||||
|
"operator_actions": [],
|
||||||
|
"refreshed": refreshed,
|
||||||
|
}
|
||||||
|
for schedule in schedules:
|
||||||
|
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:
|
||||||
|
with session.begin_nested():
|
||||||
|
if schedule.delivery_mode == "autonomous":
|
||||||
|
_occurrence, skipped = _prepare_autonomous_occurrence(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
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["coalesced"] = int(result["coalesced"]) + skipped
|
||||||
|
result["campaign_ids"].append(campaign_id) # type: ignore[union-attr]
|
||||||
|
if not schedule.active:
|
||||||
|
result["completed"] = int(result["completed"]) + 1
|
||||||
|
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(
|
||||||
|
CampaignScheduleOccurrence(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
status="failed",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(
|
||||||
|
schedule.id, scheduled_for
|
||||||
|
),
|
||||||
|
error=str(exc)[:4000],
|
||||||
|
recovery_state="failed",
|
||||||
|
evidence={"delivery_mode": schedule.delivery_mode},
|
||||||
|
last_checked_at=observed_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
schedule.active = False
|
||||||
|
schedule.last_error = str(exc)[:4000]
|
||||||
|
schedule.last_outcome = "failed"
|
||||||
|
schedule.last_recovery_state = "operator_required"
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
session.add(schedule)
|
||||||
|
result["failed"] = int(result["failed"]) + 1
|
||||||
|
result["operator_actions"].append( # type: ignore[union-attr]
|
||||||
|
{
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"campaign_id": schedule.campaign_id,
|
||||||
|
"reason": "draft_preparation_failed",
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_notify_schedule_operator(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
reason="policy_or_systemic_preflight_failed",
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_occurrence(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
scheduled_for: datetime,
|
||||||
|
observed_at: datetime,
|
||||||
|
) -> tuple[Campaign, CampaignVersion, 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")
|
||||||
|
|
||||||
|
source_campaign = session.get(Campaign, schedule.campaign_id)
|
||||||
|
if source_campaign is None or source_campaign.tenant_id != schedule.tenant_id:
|
||||||
|
raise RuntimeError("Campaign schedule source is no longer available")
|
||||||
|
source_version = session.get(CampaignVersion, schedule.source_version_id)
|
||||||
|
if source_version is None or source_version.campaign_id != source_campaign.id:
|
||||||
|
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||||
|
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||||
|
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||||
|
snapshot = _schedule_snapshot(schedule.source_snapshot)
|
||||||
|
|
||||||
|
sequence = schedule.occurrence_count + 1
|
||||||
|
external_id = _scheduled_external_id(
|
||||||
|
source_campaign.external_id,
|
||||||
|
schedule.id,
|
||||||
|
sequence,
|
||||||
|
)
|
||||||
|
local_date = scheduled_for.astimezone(ZoneInfo(schedule.timezone)).date().isoformat()
|
||||||
|
generated_name = f"{schedule.name} - {local_date}"
|
||||||
|
raw_json = campaign_copy_configuration(
|
||||||
|
snapshot["configuration"],
|
||||||
|
schedule.copy_options,
|
||||||
|
)
|
||||||
|
metadata = raw_json.get("campaign")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
raise RuntimeError("Campaign schedule snapshot has no campaign metadata")
|
||||||
|
metadata["id"] = external_id
|
||||||
|
metadata["name"] = generated_name
|
||||||
|
metadata["mode"] = "draft"
|
||||||
|
|
||||||
|
generated_campaign, generated_version = create_campaign_version_from_json(
|
||||||
|
session,
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
user_id=schedule.created_by_user_id,
|
||||||
|
raw_json=raw_json,
|
||||||
|
source_filename=None,
|
||||||
|
source_base_path=schedule.source_base_path,
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
if bool(schedule.copy_options.get("include_policies", True)):
|
||||||
|
generated_campaign.settings = copy.deepcopy(snapshot["campaign_settings"])
|
||||||
|
if bool(schedule.copy_options.get("include_mail_profile", True)):
|
||||||
|
generated_campaign.mail_profile_policy = copy.deepcopy(
|
||||||
|
snapshot["mail_profile_policy"]
|
||||||
|
)
|
||||||
|
if bool(schedule.copy_options.get("include_shares", False)):
|
||||||
|
_copy_snapshot_shares(
|
||||||
|
session,
|
||||||
|
schedule=schedule,
|
||||||
|
generated_campaign=generated_campaign,
|
||||||
|
shares=snapshot["shares"],
|
||||||
|
)
|
||||||
|
|
||||||
|
occurrence = CampaignScheduleOccurrence(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
scheduled_for=scheduled_for,
|
||||||
|
status="prepared",
|
||||||
|
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
|
||||||
|
generated_campaign_id=generated_campaign.id,
|
||||||
|
generated_version_id=generated_version.id,
|
||||||
|
recovery_state="none",
|
||||||
|
evidence={"delivery_mode": "manual"},
|
||||||
|
last_checked_at=observed_at,
|
||||||
|
)
|
||||||
|
session.add(occurrence)
|
||||||
|
session.flush()
|
||||||
|
schedule.last_campaign_id = generated_campaign.id
|
||||||
|
schedule.last_outcome = "prepared"
|
||||||
|
schedule.last_recovery_state = "none"
|
||||||
|
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.draft_prepared",
|
||||||
|
object_type="campaign_schedule",
|
||||||
|
object_id=schedule.id,
|
||||||
|
details={
|
||||||
|
"source_campaign_id": source_campaign.id,
|
||||||
|
"source_version_id": source_version.id,
|
||||||
|
"scheduled_for": scheduled_for.isoformat(),
|
||||||
|
"generated_campaign_id": generated_campaign.id,
|
||||||
|
"generated_version_id": generated_version.id,
|
||||||
|
"occurrence": sequence,
|
||||||
|
"coalesced_missed_intervals": coalesced,
|
||||||
|
"delivery_started": False,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
generated_campaign: Campaign,
|
||||||
|
shares: object,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(shares, list):
|
||||||
|
raise RuntimeError("Campaign schedule share snapshot is invalid")
|
||||||
|
for source in shares:
|
||||||
|
if not isinstance(source, Mapping):
|
||||||
|
raise RuntimeError("Campaign schedule share snapshot is invalid")
|
||||||
|
target_type = str(source.get("target_type") or "")
|
||||||
|
target_id = str(source.get("target_id") or "")
|
||||||
|
permission = str(source.get("permission") or "read")
|
||||||
|
if not target_type or not target_id:
|
||||||
|
raise RuntimeError("Campaign schedule share snapshot is incomplete")
|
||||||
|
session.add(
|
||||||
|
CampaignShare(
|
||||||
|
tenant_id=schedule.tenant_id,
|
||||||
|
campaign_id=generated_campaign.id,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=target_id,
|
||||||
|
permission=permission,
|
||||||
|
created_by_user_id=schedule.created_by_user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_snapshot(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, Mapping) or value.get("schema") != SCHEDULE_SOURCE_SCHEMA:
|
||||||
|
raise RuntimeError("Campaign schedule source snapshot schema is invalid")
|
||||||
|
configuration = value.get("configuration")
|
||||||
|
settings = value.get("campaign_settings")
|
||||||
|
mail_policy = value.get("mail_profile_policy")
|
||||||
|
shares = value.get("shares")
|
||||||
|
if (
|
||||||
|
not isinstance(configuration, Mapping)
|
||||||
|
or not isinstance(settings, Mapping)
|
||||||
|
or not isinstance(mail_policy, Mapping)
|
||||||
|
or not isinstance(shares, list)
|
||||||
|
):
|
||||||
|
raise RuntimeError("Campaign schedule source snapshot is incomplete")
|
||||||
|
return {
|
||||||
|
"configuration": dict(configuration),
|
||||||
|
"campaign_settings": dict(settings),
|
||||||
|
"mail_profile_policy": dict(mail_policy),
|
||||||
|
"shares": shares,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scheduled_external_id(source: str, schedule_id: str, sequence: int) -> str:
|
||||||
|
suffix = f"-scheduled-{schedule_id[:8]}-{sequence}"
|
||||||
|
return f"{source[:255 - len(suffix)]}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime) -> datetime:
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
return value.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"RECURRENCE_KINDS",
|
||||||
|
"SCHEDULE_SOURCE_SCHEMA",
|
||||||
|
"campaign_schedule_source_snapshot",
|
||||||
|
"canonical_configuration_hash",
|
||||||
|
"dispatch_due_campaign_schedules",
|
||||||
|
"next_schedule_fire",
|
||||||
|
"refresh_autonomous_schedule_outcomes",
|
||||||
|
"validate_autonomous_schedule_source",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -168,6 +168,172 @@ 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 CampaignSchedule(Base, TimestampMixin):
|
||||||
|
__tablename__ = "campaign_schedules"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_campaign_schedules_due", "tenant_id", "active", "next_fire_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
source_version_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
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(
|
||||||
|
String(20),
|
||||||
|
default="once",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
interval_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
|
||||||
|
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
max_occurrences: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
|
occurrence_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||||
|
resource_revision: Mapped[int] = mapped_column(Integer, default=1, 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_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)
|
||||||
|
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_campaign_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=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):
|
||||||
|
__tablename__ = "campaign_schedule_occurrences"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"schedule_id",
|
||||||
|
"scheduled_for",
|
||||||
|
name="uq_campaign_schedule_occurrence",
|
||||||
|
),
|
||||||
|
Index("ix_campaign_schedule_occurrences_schedule", "schedule_id", "scheduled_for"),
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
schedule_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("campaign_schedules.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
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(
|
||||||
|
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
generated_version_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("campaign_versions.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=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):
|
||||||
__tablename__ = "campaign_recipient_import_mapping_profiles"
|
__tablename__ = "campaign_recipient_import_mapping_profiles"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ _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:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:recipient:write",
|
"campaigns:recipient:write",
|
||||||
"campaigns:recipient:import",
|
"campaigns:recipient:import",
|
||||||
@@ -46,6 +49,7 @@ _ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
|||||||
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
|
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
|
||||||
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
|
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
|
||||||
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
|
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
|
||||||
|
_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"
|
||||||
@@ -73,6 +77,7 @@ def _workflow_topic(
|
|||||||
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||||
related_modules: tuple[str, ...] = (),
|
related_modules: tuple[str, ...] = (),
|
||||||
limitations: tuple[str, ...] = (),
|
limitations: tuple[str, ...] = (),
|
||||||
|
translations: dict[str, dict[str, str]] | None = None,
|
||||||
) -> DocumentationTopic:
|
) -> DocumentationTopic:
|
||||||
metadata: dict[str, object] = {
|
metadata: dict[str, object] = {
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
@@ -106,6 +111,7 @@ def _workflow_topic(
|
|||||||
links=links,
|
links=links,
|
||||||
related_modules=related_modules,
|
related_modules=related_modules,
|
||||||
unlocks=(outcome,),
|
unlocks=(outcome,),
|
||||||
|
translations=translations or {},
|
||||||
source_module_id="campaigns",
|
source_module_id="campaigns",
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
)
|
)
|
||||||
@@ -116,7 +122,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
topic_id="campaigns.workflow.create-campaign",
|
topic_id="campaigns.workflow.create-campaign",
|
||||||
title="Create a campaign",
|
title="Create a campaign",
|
||||||
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
||||||
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
|
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
|
||||||
order=30,
|
order=30,
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||||
@@ -128,7 +134,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
"Open Campaigns and select New campaign.",
|
"Open Campaigns and select New campaign.",
|
||||||
"Use the creation wizard to enter a clear name, identifier, and purpose.",
|
"Use the creation wizard to enter a clear name, identifier, and purpose.",
|
||||||
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
|
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
|
||||||
"Continue through the preparation sections and save the editable working version.",
|
"Continue through the preparation sections; use the stable Discard and Save actions while the bar reports the draft state.",
|
||||||
),
|
),
|
||||||
outcome="An owned campaign draft with an editable working version.",
|
outcome="An owned campaign draft with an editable working version.",
|
||||||
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
|
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
|
||||||
@@ -163,26 +169,155 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
topic_id="campaigns.workflow.copy-campaign",
|
topic_id="campaigns.workflow.copy-campaign",
|
||||||
title="Copy a campaign into a new draft",
|
title="Copy a campaign into a new draft",
|
||||||
summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.",
|
summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.",
|
||||||
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a generated identifier and one editable version. Delivery jobs, outcomes, explicit shares, locks, and audit evidence stay exclusively with the source campaign.",
|
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a chosen or generated identifier and one editable version. Recipients, attachment rules, active shares, campaign policies, and the Mail profile reference are explicit independent choices. Delivery jobs, outcomes, locks, reports, and audit evidence always stay exclusively with the source campaign.",
|
||||||
order=32,
|
order=32,
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy"),
|
||||||
route="/campaigns/{campaign_id}",
|
route="/campaigns/{campaign_id}",
|
||||||
screen="Campaign overview",
|
screen="Campaign overview",
|
||||||
help_contexts=("campaign.overview",),
|
help_contexts=("campaign.overview",),
|
||||||
prerequisites=(
|
prerequisites=(
|
||||||
"You may read the selected campaign and its recipient configuration.",
|
"You may read the selected campaign. Copying recipient data additionally requires recipient-read authority.",
|
||||||
"You may create campaign copies in the active tenant.",
|
"You may create campaign copies in the active tenant.",
|
||||||
),
|
),
|
||||||
steps=(
|
steps=(
|
||||||
"Open the campaign overview and choose the current or a historical source version.",
|
"Open the campaign overview and choose the current or a historical source version.",
|
||||||
"Choose Copy campaign or Copy as new campaign and review the evidence-isolation consequence.",
|
"Choose Copy campaign or Copy as new campaign, enter the new identity, and select recipients, files, shares, policies, and Mail profile independently.",
|
||||||
|
"If a choice is unavailable, obtain the corresponding recipient-read or campaign-share authority or leave that content excluded.",
|
||||||
"Confirm while the lifecycle state token is current; reload if another actor changed the source state.",
|
"Confirm while the lifecycle state token is current; reload if another actor changed the source state.",
|
||||||
"Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.",
|
"Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.",
|
||||||
),
|
),
|
||||||
outcome="A new editable campaign draft containing configuration from the selected version and no copied operational evidence.",
|
outcome="A new editable campaign draft containing only the explicitly selected configuration and no copied operational evidence.",
|
||||||
verification="The destination has a distinct campaign ID and owner, one editable version, and no source jobs, outcomes, shares, or locks.",
|
verification="The destination has a distinct campaign ID and owner, one editable version, the selected configuration domains, and no source jobs, outcomes, locks, reports, or audit evidence.",
|
||||||
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
|
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Kampagne als neuen Entwurf kopieren",
|
||||||
|
"summary": "Eine ausgewählte Kampagnenversion als Konfiguration wiederverwenden, ohne Betriebs- oder Auditnachweise zu kopieren.",
|
||||||
|
"body": "Kampagne kopieren erzeugt eine eigenständige Kampagne mit eigener Kennung und einer bearbeitbaren Version. Empfänger, Dateiregeln, aktive Freigaben, Kampagnenrichtlinien und die Mailprofil-Referenz werden unabhängig ausgewählt. Sendeaufträge, Ergebnisse, Sperren, Berichte und Auditnachweise verbleiben immer ausschließlich bei der Quellkampagne.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.collaborate-on-campaign",
|
||||||
|
title="Discuss campaign work without changing its evidence",
|
||||||
|
summary="Use the governed collaboration thread for human discussion linked to stable Campaign evidence.",
|
||||||
|
body="Collaboration is governed independently from Campaign editing. A discussion reader still needs access to the parent Campaign; posting and moderation use separate permissions. Posted text is append-only. Authors can withdraw their own entry and moderators can redact an entry, but both actions leave the actor, timestamp, reference, evidence hash, tombstone, and audit record. A comment may reference a Campaign version, recipient import batch, attachment rule, delivery job, or report. The reference never edits the historical version. Mentions create an in-app notification only when Notifications is available and only for active users who already have Campaign access. Human discussion is not system state and never replaces Audit evidence.",
|
||||||
|
order=33,
|
||||||
|
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:discussion:read"),
|
||||||
|
route="/campaigns/{campaign_id}/activity",
|
||||||
|
screen="Campaign collaboration",
|
||||||
|
help_contexts=(
|
||||||
|
"campaign.activity",
|
||||||
|
"campaign.activity.composer",
|
||||||
|
"campaign.activity.action.post",
|
||||||
|
"campaign.activity.action.withdraw",
|
||||||
|
"campaign.activity.action.redact",
|
||||||
|
),
|
||||||
|
prerequisites=(
|
||||||
|
"You can read the Campaign and its discussion.",
|
||||||
|
"Posting requires the separate campaign discussion-post permission.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Collaboration in the selected Campaign workspace.",
|
||||||
|
"Optionally select a stable version or enter the stable ID of another supported Campaign evidence reference.",
|
||||||
|
"Mention only collaborators who already have access, then post the bounded comment.",
|
||||||
|
"Withdraw your own mistaken entry or ask an authorized moderator to redact content that must no longer be displayed.",
|
||||||
|
"Use Tenant audit for system events and durable action evidence; do not treat discussion as workflow state.",
|
||||||
|
),
|
||||||
|
outcome="An attributable human discussion entry that does not mutate Campaign versions or impersonate audit evidence.",
|
||||||
|
verification="Reload Collaboration, follow the typed reference, and confirm any withdrawal or redaction appears as a tombstone while the referenced version remains unchanged.",
|
||||||
|
required_capabilities=(),
|
||||||
|
related_modules=("notifications", "audit"),
|
||||||
|
limitations=(
|
||||||
|
"Notifications is optional; the discussion remains available when mention delivery is not configured.",
|
||||||
|
"Comments do not approve, validate, build, queue, send, or otherwise transition a Campaign.",
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Kampagnenarbeit besprechen, ohne Nachweise zu verändern",
|
||||||
|
"summary": "Den geregelten Diskussionsverlauf für menschliche Abstimmung mit stabilen Kampagnennachweisen verwenden.",
|
||||||
|
"body": "Die Zusammenarbeit wird unabhängig von der Kampagnenbearbeitung berechtigt. Lesende benötigen weiterhin Zugriff auf die übergeordnete Kampagne; Veröffentlichung und Moderation verwenden eigene Berechtigungen. Veröffentlichter Text ist unveränderlich. Verfassende können eigene Einträge zurücknehmen, Moderierende können Einträge schwärzen. Dabei bleiben Person, Zeitstempel, Referenz, Nachweis-Hash, Platzhalter und Auditnachweis erhalten. Kommentare können Kampagnenversionen, Empfänger-Importläufe, Anlagenregeln, Sendeaufträge oder Berichte referenzieren, ohne historische Versionen zu verändern. Erwähnungen erzeugen nur bei verfügbarem Benachrichtigungsmodul eine interne Benachrichtigung und nur für aktive Personen mit bestehendem Kampagnenzugriff. Diskussion ist kein Systemzustand und ersetzt keinen Auditnachweis.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.reuse-content-library",
|
||||||
|
title="Reuse Campaign content through Templates",
|
||||||
|
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
|
||||||
|
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
|
||||||
|
order=33,
|
||||||
|
audience=("campaign_manager", "campaign_author"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
|
||||||
|
required_modules=("campaigns", "templates"),
|
||||||
|
required_capabilities=(
|
||||||
|
_TEMPLATE_CATALOG_INTEGRATION,
|
||||||
|
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION,
|
||||||
|
),
|
||||||
|
route="/campaigns/{campaign_id}/template",
|
||||||
|
screen="Campaign template",
|
||||||
|
help_contexts=("campaign.template", "campaign.template.content-library"),
|
||||||
|
prerequisites=(
|
||||||
|
"The Campaign version is editable.",
|
||||||
|
"Templates is active and you may read its library; saving additionally requires Template write authority.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Template and choose Load from library to search content visible in the active Templates scope.",
|
||||||
|
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by a complete Campaign part.",
|
||||||
|
"Review placeholders and save the Campaign draft normally.",
|
||||||
|
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
|
||||||
|
"Open Templates to review, revise, and publish shared content.",
|
||||||
|
),
|
||||||
|
outcome="Reusable content remains centrally versioned while each Campaign records its own deliberate draft changes.",
|
||||||
|
verification="The Campaign draft shows the inserted content, and saved library content appears in Templates as an unpublished revision with Campaign provenance.",
|
||||||
|
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
|
||||||
|
related_modules=("templates",),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Kampagneninhalte über Templates wiederverwenden",
|
||||||
|
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
|
||||||
|
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.schedule-drafts",
|
||||||
|
title="Schedule bounded manual or autonomous campaigns",
|
||||||
|
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
|
||||||
|
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,
|
||||||
|
audience=("campaign_manager", "campaign_author", "operator"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
||||||
|
route="/campaigns/{campaign_id}",
|
||||||
|
screen="Campaign overview",
|
||||||
|
help_contexts=("campaign.overview", "campaigns.action.schedule-drafts"),
|
||||||
|
prerequisites=(
|
||||||
|
"Choose the exact campaign version whose configuration should seed future drafts.",
|
||||||
|
"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.",
|
||||||
|
"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=(
|
||||||
|
"Open the campaign overview and choose Schedule.",
|
||||||
|
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
||||||
|
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
|
||||||
|
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
|
||||||
|
"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 manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
|
||||||
|
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"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
|
||||||
|
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
|
||||||
|
"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 manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
|
||||||
|
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.import-recipients",
|
topic_id="campaigns.workflow.import-recipients",
|
||||||
@@ -366,6 +501,75 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
),
|
),
|
||||||
related_modules=("files",),
|
related_modules=("files",),
|
||||||
),
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.control-attachment-reuse",
|
||||||
|
title="Control repeated campaign attachment use",
|
||||||
|
summary="Choose whether one resolved file may be reused, produces a warning, requires a reasoned review decision, or blocks delivery.",
|
||||||
|
body="Attachment reuse is a campaign-owned policy. The action can allow and record every repeated file, warn, require explicit review, or block affected messages. An optional exception permits reuse confined to one recipient or one built message. Every repeated-file finding is represented in the build protocol by a path-safe fingerprint, display filename, use count, message count, disposition, and explanation. Review decisions require a reason, bind to the exact message and build fingerprint, and remain available in campaign protocol and audit evidence. Changing the policy requires a new validation and build; it never rewrites historical evidence.",
|
||||||
|
order=35,
|
||||||
|
audience=("campaign_manager", "campaign_author", "campaign_reviewer", "administrator"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build", "campaigns:campaign:review"),
|
||||||
|
route="/campaigns/{campaign_id}/files",
|
||||||
|
screen="Attachments",
|
||||||
|
help_contexts=("campaign.attachments", "campaign.attachments.reuse-policy"),
|
||||||
|
prerequisites=(
|
||||||
|
"Decide which repeated use is acceptable for the campaign's purpose and recipients.",
|
||||||
|
"The current campaign version is editable when changing the policy.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Attachments and choose Allow, Warn, Require explicit review, or Block delivery.",
|
||||||
|
"Optionally allow reuse only within the same recipient or the same built message.",
|
||||||
|
"Save, validate, and build the campaign, then inspect the repeated-file summary and affected messages.",
|
||||||
|
"For Review findings, open every affected message and record an explicit reason; for Block findings, correct the rules or policy and rebuild.",
|
||||||
|
),
|
||||||
|
outcome="A campaign build whose repeated attachment use is governed and reviewable under an explicit policy.",
|
||||||
|
verification="Review and send shows the configured action and boundary, repeated-file counts and dispositions; affected messages show warning, review, or blocked state as configured.",
|
||||||
|
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Wiederholte Verwendung von Kampagnenanhängen steuern",
|
||||||
|
"summary": "Festlegen, ob dieselbe aufgelöste Datei wiederverwendet werden darf, eine Warnung erzeugt, eine begründete Prüfentscheidung erfordert oder den Versand sperrt.",
|
||||||
|
"body": "Die Wiederverwendung von Anhängen wird durch eine kampagneneigene Richtlinie gesteuert. Die Aktion kann jede Wiederverwendung zulassen und protokollieren, warnen, eine ausdrückliche Prüfung verlangen oder betroffene Nachrichten sperren. Optional darf die Wiederverwendung innerhalb desselben Empfängers oder derselben erzeugten Nachricht ausgenommen werden. Jeder Befund erscheint mit pfadsicherem Fingerabdruck, Anzeigename, Verwendungs- und Nachrichtenanzahl, Ergebnis und Begründung im Build-Protokoll. Prüfentscheidungen benötigen eine Begründung, sind an Nachricht und Build-Fingerabdruck gebunden und bleiben in Protokoll und Auditnachweis erhalten. Eine Richtlinienänderung erfordert eine neue Validierung und einen neuen Build und verändert keine historischen Nachweise.",
|
||||||
|
"outcome": "Ein Kampagnen-Build, dessen wiederholte Anhangsverwendung durch eine ausdrückliche Richtlinie gesteuert und prüfbar ist.",
|
||||||
|
"verification": "Prüfen und senden zeigt Aktion, Ausnahmegrenze, Anzahlen und Ergebnisse; betroffene Nachrichten tragen entsprechend Warn-, Prüf- oder Sperrstatus.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_workflow_topic(
|
||||||
|
topic_id="campaigns.workflow.route-unassigned-files",
|
||||||
|
title="Review and route unassigned campaign files",
|
||||||
|
summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.",
|
||||||
|
body="Campaign compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.",
|
||||||
|
order=35,
|
||||||
|
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
|
||||||
|
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
|
||||||
|
route="/campaigns/{campaign_id}/files",
|
||||||
|
screen="Attachments",
|
||||||
|
help_contexts=("campaign.attachments", "campaign.attachments.residual-files"),
|
||||||
|
prerequisites=(
|
||||||
|
"Enable Unsent on every attachment source that must be checked.",
|
||||||
|
"Configure the ordinary warning or blocking policy even if no routed message is wanted.",
|
||||||
|
"For routing, provide a reviewed recipient, subject, and report body.",
|
||||||
|
),
|
||||||
|
steps=(
|
||||||
|
"Open Attachments and set the unassigned-file action to warning only, report, or report with attachments.",
|
||||||
|
"Save and build the campaign; Campaign compares resolved recipient files with every file in the watched sources.",
|
||||||
|
"Open the residual-file row in Review and send, inspect its exact list and any attached files, and record the review decision.",
|
||||||
|
"Queue or send it through the same controlled Campaign lifecycle, or correct the attachment rules and rebuild instead.",
|
||||||
|
),
|
||||||
|
outcome="Every watched file is either assigned, deliberately reported, or represented by a visible policy finding.",
|
||||||
|
verification="The build contains no hidden residual set: it shows either the configured warning/blocking issue or one needs-review row with residual-file provenance and the configured recipient.",
|
||||||
|
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten",
|
||||||
|
"summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.",
|
||||||
|
"body": "Campaign vergleicht überwachte Anhangsquellen mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.",
|
||||||
|
"outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.",
|
||||||
|
"verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
_workflow_topic(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.send-calendar-invitations",
|
topic_id="campaigns.workflow.send-calendar-invitations",
|
||||||
title="Send individualized calendar invitations",
|
title="Send individualized calendar invitations",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -38,9 +38,12 @@ from govoplan_core.core.postbox import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.templates import (
|
from govoplan_core.core.templates import (
|
||||||
CAPABILITY_TEMPLATE_CATALOG,
|
CAPABILITY_TEMPLATE_CATALOG,
|
||||||
|
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
CAPABILITY_TEMPLATE_RENDERER,
|
CAPABILITY_TEMPLATE_RENDERER,
|
||||||
TemplateCatalogProvider,
|
TemplateCatalogProvider,
|
||||||
TemplateCompatibility,
|
TemplateCompatibility,
|
||||||
|
TemplateContentDraftRequest,
|
||||||
|
TemplateContentLibraryProvider,
|
||||||
TemplateRef,
|
TemplateRef,
|
||||||
TemplateRenderRequest,
|
TemplateRenderRequest,
|
||||||
TemplateRenderResult,
|
TemplateRenderResult,
|
||||||
@@ -56,6 +59,7 @@ POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
|
|||||||
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
|
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
|
||||||
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
|
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
|
||||||
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
|
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
|
||||||
|
TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY
|
||||||
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
|
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
|
||||||
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
|
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
|
||||||
|
|
||||||
@@ -70,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):
|
||||||
@@ -294,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
|
||||||
@@ -302,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:
|
||||||
@@ -527,6 +568,7 @@ class TemplatesCampaignIntegration:
|
|||||||
self,
|
self,
|
||||||
catalog_delegate: object | None = None,
|
catalog_delegate: object | None = None,
|
||||||
renderer_delegate: object | None = None,
|
renderer_delegate: object | None = None,
|
||||||
|
content_library_delegate: object | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._catalog = (
|
self._catalog = (
|
||||||
catalog_delegate
|
catalog_delegate
|
||||||
@@ -538,11 +580,24 @@ class TemplatesCampaignIntegration:
|
|||||||
if isinstance(renderer_delegate, TemplateRendererProvider)
|
if isinstance(renderer_delegate, TemplateRendererProvider)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
self._content_library = (
|
||||||
|
content_library_delegate
|
||||||
|
if isinstance(content_library_delegate, TemplateContentLibraryProvider)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self) -> bool:
|
def available(self) -> bool:
|
||||||
return self._catalog is not None and self._renderer is not None
|
return self._catalog is not None and self._renderer is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_available(self) -> bool:
|
||||||
|
return self._catalog is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_writable(self) -> bool:
|
||||||
|
return self._content_library is not None
|
||||||
|
|
||||||
def list_templates(
|
def list_templates(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
@@ -563,6 +618,43 @@ class TemplatesCampaignIntegration:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def list_content_templates(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str = "",
|
||||||
|
limit: int = 100,
|
||||||
|
) -> tuple[TemplateRef, ...]:
|
||||||
|
if self._catalog is None:
|
||||||
|
return ()
|
||||||
|
return tuple(
|
||||||
|
self._catalog.list_templates(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
usage="campaign.content",
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_content_draft(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: TemplateContentDraftRequest,
|
||||||
|
) -> TemplateRef:
|
||||||
|
if self._content_library is None:
|
||||||
|
raise TemplateOutputUnavailable(
|
||||||
|
"Saving reusable Campaign content requires the Templates content-library capability."
|
||||||
|
)
|
||||||
|
return self._content_library.create_content_draft(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
def check_compatibility(
|
def check_compatibility(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
@@ -799,6 +891,7 @@ def templates_integration() -> TemplatesCampaignIntegration:
|
|||||||
return TemplatesCampaignIntegration(
|
return TemplatesCampaignIntegration(
|
||||||
capability(TEMPLATE_CATALOG_CAPABILITY),
|
capability(TEMPLATE_CATALOG_CAPABILITY),
|
||||||
capability(TEMPLATE_RENDERER_CAPABILITY),
|
capability(TEMPLATE_RENDERER_CAPABILITY),
|
||||||
|
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from govoplan_core.core.campaigns import (
|
|||||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
|
||||||
CAPABILITY_CAMPAIGNS_RETENTION,
|
CAPABILITY_CAMPAIGNS_RETENTION,
|
||||||
|
CAPABILITY_CAMPAIGNS_SCHEDULES,
|
||||||
)
|
)
|
||||||
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 (
|
||||||
@@ -34,6 +35,8 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
ProductAreaContribution,
|
||||||
|
QuickAccessTool,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||||
@@ -43,9 +46,11 @@ from govoplan_core.core.distribution_lists import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.templates import (
|
from govoplan_core.core.templates import (
|
||||||
CAPABILITY_TEMPLATE_CATALOG,
|
CAPABILITY_TEMPLATE_CATALOG,
|
||||||
|
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
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.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
|
||||||
@@ -62,11 +67,19 @@ from govoplan_campaign.backend.documentation import (
|
|||||||
CAMPAIGN_USER_DOCUMENTATION,
|
CAMPAIGN_USER_DOCUMENTATION,
|
||||||
documentation_topics,
|
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
|
||||||
|
|
||||||
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:
|
||||||
@@ -90,6 +103,24 @@ 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(
|
_permission(
|
||||||
"campaigns:campaign:create",
|
"campaigns:campaign:create",
|
||||||
"Create campaigns",
|
"Create campaigns",
|
||||||
@@ -108,6 +139,12 @@ PERMISSIONS = (
|
|||||||
"Create campaigns or working versions from existing campaigns.",
|
"Create campaigns or working versions from existing campaigns.",
|
||||||
"Campaigns",
|
"Campaigns",
|
||||||
),
|
),
|
||||||
|
_permission(
|
||||||
|
"campaigns:campaign:schedule",
|
||||||
|
"Schedule campaigns",
|
||||||
|
"Prepare manual drafts or opt in to approved autonomous Mail delivery at a governed time or bounded recurrence.",
|
||||||
|
"Campaigns",
|
||||||
|
),
|
||||||
_permission(
|
_permission(
|
||||||
"campaigns:campaign:archive",
|
"campaigns:campaign:archive",
|
||||||
"Archive campaigns",
|
"Archive campaigns",
|
||||||
@@ -150,6 +187,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",
|
||||||
@@ -274,9 +317,13 @@ ROLE_TEMPLATES = (
|
|||||||
"campaigns:campaign:create",
|
"campaigns:campaign:create",
|
||||||
"campaigns:campaign:update",
|
"campaigns:campaign:update",
|
||||||
"campaigns:campaign:copy",
|
"campaigns:campaign:copy",
|
||||||
|
"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:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:recipient:write",
|
"campaigns:recipient:write",
|
||||||
"campaigns:recipient:import",
|
"campaigns:recipient:import",
|
||||||
@@ -291,6 +338,8 @@ 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:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
),
|
),
|
||||||
@@ -307,6 +356,8 @@ 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:diagnostic:read",
|
"campaigns:diagnostic:read",
|
||||||
"campaigns:recipient:read",
|
"campaigns:recipient:read",
|
||||||
"campaigns:report:read",
|
"campaigns:report:read",
|
||||||
@@ -360,7 +411,7 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.15",
|
version="0.1.20",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -368,6 +419,7 @@ manifest = ModuleManifest(
|
|||||||
optional_capabilities=(
|
optional_capabilities=(
|
||||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||||
CAPABILITY_APPROVAL_REQUESTS,
|
CAPABILITY_APPROVAL_REQUESTS,
|
||||||
|
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||||
),
|
),
|
||||||
optional_dependencies=(
|
optional_dependencies=(
|
||||||
"files",
|
"files",
|
||||||
@@ -385,6 +437,7 @@ manifest = ModuleManifest(
|
|||||||
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.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"),
|
||||||
@@ -392,6 +445,7 @@ manifest = ModuleManifest(
|
|||||||
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(
|
||||||
@@ -454,6 +508,12 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name=CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||||
|
version_min="0.1.18",
|
||||||
|
version_max_exclusive="0.2.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name=CAPABILITY_CALENDAR_INVITATIONS,
|
name=CAPABILITY_CALENDAR_INVITATIONS,
|
||||||
version_min="0.2.0",
|
version_min="0.2.0",
|
||||||
@@ -484,6 +544,12 @@ 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(
|
ModuleInterfaceRequirement(
|
||||||
name="search.source",
|
name="search.source",
|
||||||
version_min="1.0.0",
|
version_min="1.0.0",
|
||||||
@@ -560,7 +626,32 @@ manifest = ModuleManifest(
|
|||||||
order=20,
|
order=20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="communication",
|
||||||
|
module_id="campaigns",
|
||||||
|
label="i18n:govoplan-core.product_area.communication",
|
||||||
|
icon="mail",
|
||||||
|
description="i18n:govoplan-core.product_area.communication_description",
|
||||||
|
surface_ids=(
|
||||||
|
"campaigns.nav.campaigns",
|
||||||
|
"campaigns.route.campaigns",
|
||||||
|
"campaigns.route.operator-redirect",
|
||||||
|
OPERATOR_QUEUE_SURFACE_ID,
|
||||||
|
REPORTS_SURFACE_ID,
|
||||||
|
"campaigns.page.activity",
|
||||||
|
),
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
|
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",
|
||||||
@@ -568,6 +659,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(
|
||||||
@@ -577,7 +694,10 @@ manifest = ModuleManifest(
|
|||||||
retirement_supported=True,
|
retirement_supported=True,
|
||||||
retirement_provider=drop_table_retirement_provider(
|
retirement_provider=drop_table_retirement_provider(
|
||||||
campaign_models.Campaign,
|
campaign_models.Campaign,
|
||||||
|
campaign_models.CampaignSchedule,
|
||||||
|
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,
|
||||||
@@ -597,7 +717,10 @@ manifest = ModuleManifest(
|
|||||||
uninstall_guard_providers=(
|
uninstall_guard_providers=(
|
||||||
persistent_table_uninstall_guard(
|
persistent_table_uninstall_guard(
|
||||||
campaign_models.Campaign,
|
campaign_models.Campaign,
|
||||||
|
campaign_models.CampaignSchedule,
|
||||||
|
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,
|
||||||
@@ -615,6 +738,237 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
*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.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(
|
||||||
|
id="campaigns.access.child-evidence",
|
||||||
|
title="Explain access to Campaign child evidence",
|
||||||
|
summary="Trace governance, import, recipient, attachment, protocol, delivery, and reconciliation access without disclosing the protected payload.",
|
||||||
|
body=(
|
||||||
|
"Campaign child explanations first identify the parent Campaign and immutable version, then state whether owner, "
|
||||||
|
"group, share, or tenant administration provides the inherited boundary. Recipient evidence additionally requires "
|
||||||
|
"recipient-read authority; review and attachment overrides require review and diagnostic authority; delivery status, "
|
||||||
|
"diagnostics, exports, and reconciliation remain separately permissioned. Version-bound children use a version UUID "
|
||||||
|
"and random job UUID. Missing, cross-tenant, and stale references return non-disclosing provenance. Explanations expose "
|
||||||
|
"bounded state and hashes only, never recipient addresses, source rows, filenames, storage locators, transport responses, "
|
||||||
|
"worker claims, provider targets, or operator notes. Persisted Mail, Postbox, and printable attempt evidence remains "
|
||||||
|
"explainable after an optional provider is disabled; an absent child reports only unavailable-or-hidden. Campaign share "
|
||||||
|
"and ownership-transfer records describe governance visibility separately from the content grant. Saved imports expose "
|
||||||
|
"only their stable source identity and revision, while independently user-owned mapping profiles never inherit a Campaign "
|
||||||
|
"share. Persisted validation, build, snapshot, and review artifacts use typed version references. Reusable templates and "
|
||||||
|
"durable export packages stay with their optional owning modules and fail closed when Campaign is asked to explain them. "
|
||||||
|
"The shared explanation dialog evaluates the signed-in user by default. Policy may permit a tenant-bounded selected-user "
|
||||||
|
"administrator diagnostic; Access returns only permitted subject metadata and records every cross-user explanation in audit evidence."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("administrator", "security_reviewer", "campaign_operator"),
|
||||||
|
order=43,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("campaigns",),
|
||||||
|
any_scopes=(
|
||||||
|
"campaigns:diagnostic:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"admin:users:read",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Campaign access-explanation coverage",
|
||||||
|
href="govoplan-campaign/docs/ACCESS_EXPLANATION_COVERAGE.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("access", "audit", "mail", "policy", "postbox", "templates"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Zugriff auf untergeordnete Campaign-Nachweise erklaeren",
|
||||||
|
"summary": "Zugriff auf Governance-, Import-, Empfaenger-, Anlagen-, Protokoll-, Zustell- und Abgleichnachweise ohne Offenlegung der geschuetzten Inhalte nachvollziehen.",
|
||||||
|
"body": (
|
||||||
|
"Zugriffserklaerungen fuer untergeordnete Campaign-Nachweise nennen zuerst die uebergeordnete Campaign und "
|
||||||
|
"die unveraenderliche Version. Danach zeigen sie, ob Eigentum, Gruppe, Freigabe oder Mandantenadministration "
|
||||||
|
"die geerbte Grenze begruendet. Empfaengernachweise erfordern zusaetzlich Leserecht fuer Empfaenger; Pruef- und "
|
||||||
|
"Anlagenausnahmen erfordern Pruef- und Diagnoserecht. Zustellstatus, Diagnostik, Export und Abgleich bleiben getrennt "
|
||||||
|
"berechtigt. Versionsgebundene Nachweise verwenden Versions-UUID und zufaellige Auftrags-UUID. Fehlende, mandantenfremde "
|
||||||
|
"oder veraltete Verweise liefern keine geschuetzten Daten. Adressen, Quellzeilen, Dateinamen, Speicherorte, Transportantworten, "
|
||||||
|
"Worker-Claims, Anbieterziele und Bediennotizen werden nie offengelegt. Dauerhafte Mail-, Postbox- und Drucknachweise bleiben "
|
||||||
|
"auch nach Deaktivierung eines optionalen Anbieters erklaerbar; ein fehlender Nachweis meldet nur nicht verfuegbar oder verborgen."
|
||||||
|
" Campaign-Freigaben und Eigentumsuebertragungen trennen Governance-Sichtbarkeit vom Inhaltszugriff. Gespeicherte Importe "
|
||||||
|
"nennen nur stabile Quellidentitaet und Revision; benutzereigene Zuordnungsprofile erben keine Campaign-Freigabe. "
|
||||||
|
"Validierungs-, Build-, Snapshot- und Pruefnachweise verwenden typisierte Versionsverweise. Wiederverwendbare Vorlagen "
|
||||||
|
"und dauerhafte Exportpakete bleiben bei ihren optionalen Eigentuemer-Modulen und werden sonst geschlossen behandelt."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": [
|
||||||
|
"campaign.access",
|
||||||
|
"campaign.import",
|
||||||
|
"campaign.report",
|
||||||
|
"campaign.operator-queue",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="campaigns.search.campaigns",
|
id="campaigns.search.campaigns",
|
||||||
title="Search authorized campaigns",
|
title="Search authorized campaigns",
|
||||||
@@ -796,7 +1150,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"),
|
||||||
@@ -844,7 +1198,7 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.workflow.prepare-validate-and-build",
|
id="campaigns.workflow.prepare-validate-and-build",
|
||||||
title="Prepare, validate, and build a campaign",
|
title="Prepare, validate, and build a campaign",
|
||||||
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
||||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. Summary metrics expose a named drill-down only when an authorized source collection, filtered review table, attachment preview, or report helps the user inspect and act on the count. Privacy-suppressed aggregate reports remain non-interactive because an unsuppressed subgroup would violate their disclosure boundary. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
@@ -914,11 +1268,38 @@ 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",
|
||||||
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
|
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
|
||||||
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
|
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Use the explicit actions on actionable recipient, attachment, validation, and review metrics to reveal the corresponding source page, evidence preview, or filtered built-message table. Informational and privacy-suppressed measures do not become hidden click targets. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
|
||||||
layer="configured",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_reviewer",),
|
audience=("campaign_reviewer",),
|
||||||
@@ -1149,6 +1530,10 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
fromlist=["delivery_tasks_capability"],
|
fromlist=["delivery_tasks_capability"],
|
||||||
).delivery_tasks_capability(context),
|
).delivery_tasks_capability(context),
|
||||||
|
CAPABILITY_CAMPAIGNS_SCHEDULES: lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.capabilities",
|
||||||
|
fromlist=["schedules_capability"],
|
||||||
|
).schedules_capability(context),
|
||||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
|
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT: lambda context: __import__(
|
||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
fromlist=["mail_policy_context_capability"],
|
fromlist=["mail_policy_context_capability"],
|
||||||
@@ -1165,6 +1550,7 @@ manifest = ModuleManifest(
|
|||||||
"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={
|
||||||
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||||
@@ -1177,6 +1563,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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -19,16 +19,19 @@ from govoplan_campaign.backend.attachments.resolver import (
|
|||||||
effective_send_without_attachments_behavior,
|
effective_send_without_attachments_behavior,
|
||||||
resolve_entry_attachments,
|
resolve_entry_attachments,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.attachments.reuse import evaluate_attachment_reuse
|
||||||
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
|
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
|
||||||
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||||
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
|
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
|
||||||
from govoplan_campaign.backend.campaign.models import (
|
from govoplan_campaign.backend.campaign.models import (
|
||||||
Behavior,
|
Behavior,
|
||||||
|
AttachmentConfig,
|
||||||
BuildStatus,
|
BuildStatus,
|
||||||
CampaignConfig,
|
CampaignConfig,
|
||||||
EntryConfig,
|
EntryConfig,
|
||||||
MissingAddressBehavior,
|
MissingAddressBehavior,
|
||||||
RecipientConfig,
|
RecipientConfig,
|
||||||
|
ResidualFileMode,
|
||||||
SendStatus,
|
SendStatus,
|
||||||
TemplateBodyMode,
|
TemplateBodyMode,
|
||||||
ZipArchiveConfig,
|
ZipArchiveConfig,
|
||||||
@@ -37,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,
|
||||||
@@ -90,6 +96,14 @@ 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)
|
||||||
|
class _ResidualFileGroup:
|
||||||
|
source_name: str
|
||||||
|
directory: Path
|
||||||
|
files: list[Path]
|
||||||
|
|
||||||
|
|
||||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||||
@@ -362,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()
|
||||||
@@ -419,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,
|
||||||
@@ -517,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,
|
||||||
@@ -556,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,
|
||||||
@@ -753,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,
|
||||||
@@ -779,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(
|
||||||
@@ -883,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,
|
||||||
)
|
)
|
||||||
@@ -890,17 +934,13 @@ def build_entry_message(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _unsent_attachment_issues(
|
def _residual_attachment_files(
|
||||||
*,
|
*,
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
built_messages: list[BuiltMessage],
|
built_messages: list[BuiltMessage],
|
||||||
attachment_match_index: AttachmentMatchIndex | None = None,
|
attachment_match_index: AttachmentMatchIndex | None = None,
|
||||||
) -> list[MessageIssue]:
|
) -> list[_ResidualFileGroup]:
|
||||||
behavior = config.validation_policy.unsent_attachment_files.value
|
|
||||||
if behavior == Behavior.CONTINUE.value:
|
|
||||||
return []
|
|
||||||
|
|
||||||
matched_files = {
|
matched_files = {
|
||||||
Path(match).resolve()
|
Path(match).resolve()
|
||||||
for built in built_messages
|
for built in built_messages
|
||||||
@@ -908,7 +948,7 @@ def _unsent_attachment_issues(
|
|||||||
for match in attachment.matches
|
for match in attachment.matches
|
||||||
}
|
}
|
||||||
|
|
||||||
issues: list[MessageIssue] = []
|
groups: list[_ResidualFileGroup] = []
|
||||||
for base_path in config.attachments.base_paths:
|
for base_path in config.attachments.base_paths:
|
||||||
if not base_path.unsent_warning:
|
if not base_path.unsent_warning:
|
||||||
continue
|
continue
|
||||||
@@ -922,20 +962,216 @@ def _unsent_attachment_issues(
|
|||||||
unsent = [path for path in all_files if path not in matched_files]
|
unsent = [path for path in all_files if path not in matched_files]
|
||||||
if not unsent:
|
if not unsent:
|
||||||
continue
|
continue
|
||||||
|
groups.append(
|
||||||
|
_ResidualFileGroup(
|
||||||
|
source_name=base_path.name,
|
||||||
|
directory=directory,
|
||||||
|
files=unsent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def _unsent_attachment_issues(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
residual_groups: list[_ResidualFileGroup],
|
||||||
|
) -> list[MessageIssue]:
|
||||||
|
behavior = config.validation_policy.unsent_attachment_files.value
|
||||||
|
if (
|
||||||
|
behavior == Behavior.CONTINUE.value
|
||||||
|
or config.attachments.residual_files.mode != ResidualFileMode.NONE
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
issues: list[MessageIssue] = []
|
||||||
|
for group in residual_groups:
|
||||||
|
unsent = group.files
|
||||||
|
directory = group.directory
|
||||||
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
|
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
|
||||||
if len(unsent) > 10:
|
if len(unsent) > 10:
|
||||||
shown += f", … (+{len(unsent) - 10} more)"
|
shown += f", … (+{len(unsent) - 10} more)"
|
||||||
issues.append(
|
issues.append(
|
||||||
_issue_from_behavior(
|
_issue_from_behavior(
|
||||||
code="unsent_attachment_files",
|
code="unsent_attachment_files",
|
||||||
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
|
message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}",
|
||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
source=f"attachments:{base_path.name}",
|
source=f"attachments:{group.source_name}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return issues
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _build_residual_file_message(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
campaign_file: Path,
|
||||||
|
residual_groups: list[_ResidualFileGroup],
|
||||||
|
entry_index: int,
|
||||||
|
output_dir: Path | None,
|
||||||
|
write_eml: bool,
|
||||||
|
work_dir: Path,
|
||||||
|
attachment_match_index: AttachmentMatchIndex,
|
||||||
|
) -> BuiltMessage | None:
|
||||||
|
disposition = config.attachments.residual_files
|
||||||
|
if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None:
|
||||||
|
return None
|
||||||
|
files = sorted({path.resolve() for group in residual_groups for path in group.files})
|
||||||
|
if not files:
|
||||||
|
return None
|
||||||
|
file_lines = [
|
||||||
|
f"{group.source_name}: {path.relative_to(group.directory)}"
|
||||||
|
for group in residual_groups
|
||||||
|
for path in group.files
|
||||||
|
]
|
||||||
|
entry = EntryConfig(
|
||||||
|
id="__residual_files__",
|
||||||
|
to=[disposition.recipient],
|
||||||
|
merge_to=False,
|
||||||
|
combine_attachments=True,
|
||||||
|
fields={
|
||||||
|
"campaign_name": config.campaign.name,
|
||||||
|
"residual_file_count": len(files),
|
||||||
|
"residual_file_list": "\n".join(file_lines),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
residual_config = config.model_copy(deep=True)
|
||||||
|
residual_config.attachments.global_ = []
|
||||||
|
residual_config.attachments.zip.enabled = False
|
||||||
|
if disposition.mode == ResidualFileMode.ATTACH:
|
||||||
|
residual_config.attachments.global_ = [
|
||||||
|
AttachmentConfig(
|
||||||
|
id=f"residual-file-{index}",
|
||||||
|
label=path.name,
|
||||||
|
base_dir=str(path.parent),
|
||||||
|
file_filter=path.name,
|
||||||
|
required=True,
|
||||||
|
allow_multiple=False,
|
||||||
|
)
|
||||||
|
for index, path in enumerate(files, start=1)
|
||||||
|
]
|
||||||
|
context = _entry_message_context(
|
||||||
|
config=residual_config,
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
attachment_match_index=attachment_match_index,
|
||||||
|
)
|
||||||
|
context.validation_status = _validate_required_sender(
|
||||||
|
context.senders,
|
||||||
|
context.issues,
|
||||||
|
context.validation_status,
|
||||||
|
)
|
||||||
|
context.validation_status = _validate_required_recipients(
|
||||||
|
residual_config,
|
||||||
|
context.recipients,
|
||||||
|
context.issues,
|
||||||
|
context.validation_status,
|
||||||
|
)
|
||||||
|
values = build_template_values(residual_config, entry)
|
||||||
|
rendered = _RenderedMessageTemplate(
|
||||||
|
subject=_render_template(disposition.subject, values, keep_missing=True),
|
||||||
|
text_body=_render_template(disposition.text, values, keep_missing=True),
|
||||||
|
html_body=None,
|
||||||
|
body_mode=TemplateBodyMode.TEXT.value,
|
||||||
|
values=values,
|
||||||
|
)
|
||||||
|
context.validation_status = _validate_rendered_template(
|
||||||
|
residual_config,
|
||||||
|
rendered,
|
||||||
|
context.issues,
|
||||||
|
context.validation_status,
|
||||||
|
)
|
||||||
|
context.issues.append(
|
||||||
|
MessageIssue(
|
||||||
|
severity="warning",
|
||||||
|
code="residual_attachment_disposition",
|
||||||
|
message=(
|
||||||
|
f"{len(files)} unassigned file(s) are routed as a reviewed "
|
||||||
|
f"{disposition.mode.value} message."
|
||||||
|
),
|
||||||
|
behavior="ask",
|
||||||
|
source="attachments:residual_files",
|
||||||
|
details={
|
||||||
|
"mode": disposition.mode.value,
|
||||||
|
"file_count": len(files),
|
||||||
|
"source_count": len(residual_groups),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if context.validation_status not in {
|
||||||
|
MessageValidationStatus.BLOCKED,
|
||||||
|
MessageValidationStatus.EXCLUDED,
|
||||||
|
}:
|
||||||
|
context.validation_status = MessageValidationStatus.NEEDS_REVIEW
|
||||||
|
mime_result = _build_mime_message(
|
||||||
|
config=residual_config,
|
||||||
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
output_dir=output_dir,
|
||||||
|
work_dir=work_dir,
|
||||||
|
context=context,
|
||||||
|
rendered=rendered,
|
||||||
|
)
|
||||||
|
eml_path: str | None = None
|
||||||
|
eml_size: int | None = None
|
||||||
|
if write_eml and output_dir is not None and mime_result.message is not None:
|
||||||
|
eml_path, eml_size = _write_eml(
|
||||||
|
mime_result.message,
|
||||||
|
output_dir,
|
||||||
|
entry,
|
||||||
|
entry_index,
|
||||||
|
)
|
||||||
|
return BuiltMessage(
|
||||||
|
draft=_message_draft(
|
||||||
|
config=residual_config,
|
||||||
|
entry=entry,
|
||||||
|
entry_index=entry_index,
|
||||||
|
context=context,
|
||||||
|
build_status=mime_result.build_status,
|
||||||
|
validation_status=mime_result.validation_status,
|
||||||
|
subject=rendered.subject,
|
||||||
|
attachment_count=mime_result.attachment_count,
|
||||||
|
archive_evidence=mime_result.archive_evidence,
|
||||||
|
eml_path=eml_path,
|
||||||
|
eml_size=eml_size,
|
||||||
|
),
|
||||||
|
mime=mime_result.message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _residual_file_disposition_evidence(
|
||||||
|
*,
|
||||||
|
config: CampaignConfig,
|
||||||
|
residual_groups: list[_ResidualFileGroup],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
disposition = config.attachments.residual_files
|
||||||
|
behavior = config.validation_policy.unsent_attachment_files.value
|
||||||
|
if disposition.mode == ResidualFileMode.REPORT:
|
||||||
|
action = "route_report"
|
||||||
|
elif disposition.mode == ResidualFileMode.ATTACH:
|
||||||
|
action = "route_with_files"
|
||||||
|
elif behavior == Behavior.BLOCK.value:
|
||||||
|
action = "block"
|
||||||
|
elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}:
|
||||||
|
action = "ignore"
|
||||||
|
else:
|
||||||
|
action = "review"
|
||||||
|
recipient = (
|
||||||
|
disposition.recipient.model_dump(mode="json", by_alias=True)
|
||||||
|
if disposition.recipient is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"contract_version": "1",
|
||||||
|
"action": action,
|
||||||
|
"routing_mode": disposition.mode.value,
|
||||||
|
"validation_behavior": behavior,
|
||||||
|
"watched_source_count": len(residual_groups),
|
||||||
|
"residual_file_count": sum(len(group.files) for group in residual_groups),
|
||||||
|
"recipient": recipient,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
|
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
|
||||||
if not issues:
|
if not issues:
|
||||||
return
|
return
|
||||||
@@ -949,6 +1185,28 @@ def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: lis
|
|||||||
status = _apply_behavior(status, issue.behavior)
|
status = _apply_behavior(status, issue.behavior)
|
||||||
built.draft.validation_status = status
|
built.draft.validation_status = status
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_attachment_reuse_policy(
|
||||||
|
config: CampaignConfig,
|
||||||
|
built_messages: list[BuiltMessage],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
evaluation = evaluate_attachment_reuse(
|
||||||
|
[built.draft for built in built_messages],
|
||||||
|
policy=config.attachments.reuse_policy,
|
||||||
|
)
|
||||||
|
for built in built_messages:
|
||||||
|
issues = evaluation.issues_by_entry_index.get(built.draft.entry_index, [])
|
||||||
|
if not issues:
|
||||||
|
continue
|
||||||
|
built.draft.issues.extend(issues)
|
||||||
|
for issue in issues:
|
||||||
|
if issue.behavior:
|
||||||
|
built.draft.validation_status = _apply_behavior(
|
||||||
|
built.draft.validation_status,
|
||||||
|
issue.behavior,
|
||||||
|
)
|
||||||
|
return evaluation.report
|
||||||
|
|
||||||
def build_campaign_messages(
|
def build_campaign_messages(
|
||||||
config: CampaignConfig,
|
config: CampaignConfig,
|
||||||
*,
|
*,
|
||||||
@@ -978,15 +1236,35 @@ def build_campaign_messages(
|
|||||||
for index, entry in enumerate(entries, start=1)
|
for index, entry in enumerate(entries, start=1)
|
||||||
if entry.active
|
if entry.active
|
||||||
]
|
]
|
||||||
|
residual_groups = _residual_attachment_files(
|
||||||
|
config=config,
|
||||||
|
campaign_file=campaign_path,
|
||||||
|
built_messages=built_messages,
|
||||||
|
attachment_match_index=attachment_match_index,
|
||||||
|
)
|
||||||
_apply_campaign_level_issues(
|
_apply_campaign_level_issues(
|
||||||
built_messages,
|
built_messages,
|
||||||
_unsent_attachment_issues(
|
_unsent_attachment_issues(
|
||||||
config=config,
|
config=config,
|
||||||
campaign_file=campaign_path,
|
residual_groups=residual_groups,
|
||||||
built_messages=built_messages,
|
|
||||||
attachment_match_index=attachment_match_index,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
residual_message = _build_residual_file_message(
|
||||||
|
config=config,
|
||||||
|
campaign_file=campaign_path,
|
||||||
|
residual_groups=residual_groups,
|
||||||
|
entry_index=len(entries) + 1,
|
||||||
|
output_dir=output_path,
|
||||||
|
write_eml=write_eml,
|
||||||
|
work_dir=work_dir,
|
||||||
|
attachment_match_index=attachment_match_index,
|
||||||
|
)
|
||||||
|
if residual_message is not None:
|
||||||
|
built_messages.append(residual_message)
|
||||||
|
attachment_reuse = _apply_attachment_reuse_policy(
|
||||||
|
config,
|
||||||
|
built_messages,
|
||||||
|
)
|
||||||
|
|
||||||
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
|
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
|
||||||
report = CampaignBuildReport(
|
report = CampaignBuildReport(
|
||||||
@@ -1000,5 +1278,10 @@ def build_campaign_messages(
|
|||||||
duration_ms=(time.perf_counter() - started) * 1000,
|
duration_ms=(time.perf_counter() - started) * 1000,
|
||||||
rules_resolved=rules_resolved,
|
rules_resolved=rules_resolved,
|
||||||
),
|
),
|
||||||
|
attachment_reuse=attachment_reuse,
|
||||||
|
residual_file_disposition=_residual_file_disposition_evidence(
|
||||||
|
config=config,
|
||||||
|
residual_groups=residual_groups,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -117,6 +118,8 @@ class CampaignBuildReport(BaseModel):
|
|||||||
inactive_entries_count: int = 0
|
inactive_entries_count: int = 0
|
||||||
messages: list[MessageDraft] = Field(default_factory=list)
|
messages: list[MessageDraft] = Field(default_factory=list)
|
||||||
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
|
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
|
||||||
|
attachment_reuse: dict[str, object] = Field(default_factory=dict)
|
||||||
|
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def built_count(self) -> int:
|
def built_count(self) -> int:
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
"""add campaign schedule optimistic-concurrency revisions
|
||||||
|
|
||||||
|
Revision ID: a5b6c7d8e9f0
|
||||||
|
Revises: f4a5b6c7d8e9
|
||||||
|
Create Date: 2026-08-07 12:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
|
_migration = import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"a5b6c7d8e9f0_v0120_campaign_schedule_revisions"
|
||||||
|
)
|
||||||
|
revision = _migration.revision
|
||||||
|
down_revision = _migration.down_revision
|
||||||
|
branch_labels = _migration.branch_labels
|
||||||
|
depends_on = _migration.depends_on
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_migration.upgrade()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_migration.downgrade()
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
"""add durable campaign schedules and occurrence evidence
|
||||||
|
|
||||||
|
Revision ID: f4a5b6c7d8e9
|
||||||
|
Revises: e3c8f4a5b6d7
|
||||||
|
Create Date: 2026-08-07 10:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
|
||||||
|
|
||||||
|
_migration = import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"f4a5b6c7d8e9_v0119_campaign_schedules"
|
||||||
|
)
|
||||||
|
revision = _migration.revision
|
||||||
|
down_revision = _migration.down_revision
|
||||||
|
branch_labels = _migration.branch_labels
|
||||||
|
depends_on = _migration.depends_on
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
_migration.upgrade()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_migration.downgrade()
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
"""add campaign schedule optimistic-concurrency revisions
|
||||||
|
|
||||||
|
Revision ID: a5b6c7d8e9f0
|
||||||
|
Revises: f4a5b6c7d8e9
|
||||||
|
Create Date: 2026-08-07 12:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "a5b6c7d8e9f0"
|
||||||
|
down_revision = "f4a5b6c7d8e9"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if inspector.has_table("campaign_schedules"):
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||||
|
if "resource_revision" not in columns:
|
||||||
|
op.add_column(
|
||||||
|
"campaign_schedules",
|
||||||
|
sa.Column(
|
||||||
|
"resource_revision",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if inspector.has_table("campaign_schedules"):
|
||||||
|
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||||
|
if "resource_revision" in columns:
|
||||||
|
op.drop_column("campaign_schedules", "resource_revision")
|
||||||
+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")
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
"""add durable campaign schedules and occurrence evidence
|
||||||
|
|
||||||
|
Revision ID: f4a5b6c7d8e9
|
||||||
|
Revises: e3c8f4a5b6d7
|
||||||
|
Create Date: 2026-08-07 10:00:00.000000
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "f4a5b6c7d8e9"
|
||||||
|
down_revision = "e3c8f4a5b6d7"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_schedules"):
|
||||||
|
op.create_table(
|
||||||
|
"campaign_schedules",
|
||||||
|
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("source_version_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("recurrence_kind", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("interval_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("timezone", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("max_occurrences", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("occurrence_count", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("active", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("copy_options", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("source_snapshot", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("source_snapshot_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("source_base_path", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("last_fired_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_campaign_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["source_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||||
|
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["last_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_campaign_schedules_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_campaign_schedules_campaign_id", ["campaign_id"]),
|
||||||
|
("ix_campaign_schedules_source_version_id", ["source_version_id"]),
|
||||||
|
("ix_campaign_schedules_created_by_user_id", ["created_by_user_id"]),
|
||||||
|
("ix_campaign_schedules_recurrence_kind", ["recurrence_kind"]),
|
||||||
|
("ix_campaign_schedules_next_fire_at", ["next_fire_at"]),
|
||||||
|
("ix_campaign_schedules_active", ["active"]),
|
||||||
|
("ix_campaign_schedules_source_snapshot_hash", ["source_snapshot_hash"]),
|
||||||
|
("ix_campaign_schedules_last_campaign_id", ["last_campaign_id"]),
|
||||||
|
("ix_campaign_schedules_due", ["tenant_id", "active", "next_fire_at"]),
|
||||||
|
):
|
||||||
|
op.create_index(name, "campaign_schedules", columns, unique=False)
|
||||||
|
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if not inspector.has_table("campaign_schedule_occurrences"):
|
||||||
|
op.create_table(
|
||||||
|
"campaign_schedule_occurrences",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("schedule_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("status", sa.String(length=30), nullable=False),
|
||||||
|
sa.Column("generated_campaign_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("generated_version_id", sa.String(length=36), nullable=True),
|
||||||
|
sa.Column("error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["schedule_id"], ["campaign_schedules.id"], ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(["generated_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||||
|
sa.ForeignKeyConstraint(["generated_version_id"], ["campaign_versions.id"], ondelete="SET NULL"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("schedule_id", "scheduled_for", name="uq_campaign_schedule_occurrence"),
|
||||||
|
)
|
||||||
|
for name, columns in (
|
||||||
|
("ix_campaign_schedule_occurrences_tenant_id", ["tenant_id"]),
|
||||||
|
("ix_campaign_schedule_occurrences_schedule_id", ["schedule_id"]),
|
||||||
|
("ix_campaign_schedule_occurrences_status", ["status"]),
|
||||||
|
("ix_campaign_schedule_occurrences_generated_campaign_id", ["generated_campaign_id"]),
|
||||||
|
("ix_campaign_schedule_occurrences_generated_version_id", ["generated_version_id"]),
|
||||||
|
("ix_campaign_schedule_occurrences_schedule", ["schedule_id", "scheduled_for"]),
|
||||||
|
):
|
||||||
|
op.create_index(name, "campaign_schedule_occurrences", columns, unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if inspector.has_table("campaign_schedule_occurrences"):
|
||||||
|
op.drop_table("campaign_schedule_occurrences")
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if inspector.has_table("campaign_schedules"):
|
||||||
|
op.drop_table("campaign_schedules")
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ 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.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.sharing import router as sharing_router
|
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||||
|
|
||||||
@@ -16,9 +18,11 @@ router = APIRouter()
|
|||||||
for workflow_router in (
|
for workflow_router in (
|
||||||
operations_router,
|
operations_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
collaboration_router,
|
||||||
versions_router,
|
versions_router,
|
||||||
jobs_router,
|
jobs_router,
|
||||||
reports_router,
|
reports_router,
|
||||||
|
schedules_router,
|
||||||
sharing_router,
|
sharing_router,
|
||||||
delivery_router,
|
delivery_router,
|
||||||
attachments_router,
|
attachments_router,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import copy
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import re
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
@@ -14,6 +15,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
CampaignCreateResponse,
|
CampaignCreateResponse,
|
||||||
CampaignCreateMinimalRequest,
|
CampaignCreateMinimalRequest,
|
||||||
CampaignCopyRequest,
|
CampaignCopyRequest,
|
||||||
|
CampaignContentLibrarySaveRequest,
|
||||||
CampaignLifecycleMutationRequest,
|
CampaignLifecycleMutationRequest,
|
||||||
CampaignLifecyclePolicyResponse,
|
CampaignLifecyclePolicyResponse,
|
||||||
CampaignAddressLookupCandidate,
|
CampaignAddressLookupCandidate,
|
||||||
@@ -42,6 +44,11 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||||
from govoplan_core.audit.logging import audit_from_principal
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.core.templates import (
|
||||||
|
TemplateContentDraftRequest,
|
||||||
|
TemplateFieldRequirement,
|
||||||
|
TemplateRef,
|
||||||
|
)
|
||||||
from govoplan_core.core.change_sequence import (
|
from govoplan_core.core.change_sequence import (
|
||||||
decode_sequence_watermark,
|
decode_sequence_watermark,
|
||||||
encode_sequence_watermark,
|
encode_sequence_watermark,
|
||||||
@@ -60,6 +67,7 @@ from govoplan_campaign.backend.change_tracking import (
|
|||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
RecipientImportMappingProfile,
|
RecipientImportMappingProfile,
|
||||||
)
|
)
|
||||||
@@ -70,12 +78,14 @@ from govoplan_campaign.backend.campaign.lifecycle import (
|
|||||||
assert_lifecycle_state_token,
|
assert_lifecycle_state_token,
|
||||||
campaign_lifecycle_policy,
|
campaign_lifecycle_policy,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||||
from govoplan_campaign.backend.integrations import (
|
from govoplan_campaign.backend.integrations import (
|
||||||
calendar_integration,
|
calendar_integration,
|
||||||
PostboxDeliveryUnavailable,
|
PostboxDeliveryUnavailable,
|
||||||
postbox_integration,
|
postbox_integration,
|
||||||
templates_integration,
|
templates_integration,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.template_rendering import find_unresolved_placeholders
|
||||||
from govoplan_core.db.session import get_session
|
from govoplan_core.db.session import get_session
|
||||||
from govoplan_core.core.distribution_lists import (
|
from govoplan_core.core.distribution_lists import (
|
||||||
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
|
||||||
@@ -120,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"
|
||||||
|
|
||||||
@@ -214,6 +237,13 @@ def _campaign_copy_external_id(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_copy_configuration(
|
||||||
|
source: dict[str, object],
|
||||||
|
payload: CampaignCopyRequest,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return campaign_copy_configuration(source, payload.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=CampaignCreateResponse)
|
@router.post("", response_model=CampaignCreateResponse)
|
||||||
def create_campaign(
|
def create_campaign(
|
||||||
payload: CampaignCreateRequest,
|
payload: CampaignCreateRequest,
|
||||||
@@ -924,6 +954,229 @@ def campaign_print_templates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
|
||||||
|
revision = template.revision
|
||||||
|
revision_metadata = dict(revision.metadata) if revision else {}
|
||||||
|
raw_targets = revision_metadata.get("campaign_targets")
|
||||||
|
targets = (
|
||||||
|
[str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}]
|
||||||
|
if isinstance(raw_targets, (list, tuple))
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
if not targets and revision is not None:
|
||||||
|
if template.template_type == "email":
|
||||||
|
targets = ["subject", "text", "html"]
|
||||||
|
else:
|
||||||
|
if revision.content_text:
|
||||||
|
targets.append("text")
|
||||||
|
if revision.content_html:
|
||||||
|
targets.append("html")
|
||||||
|
kind = str(revision_metadata.get("campaign_kind") or "").strip()
|
||||||
|
if kind not in {"fragment", "campaign_part"}:
|
||||||
|
kind = "fragment" if template.template_type == "content_fragment" else "campaign_part"
|
||||||
|
return {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"description": template.description,
|
||||||
|
"template_type": template.template_type,
|
||||||
|
"kind": kind,
|
||||||
|
"status": template.status,
|
||||||
|
"scope_type": template.scope_type,
|
||||||
|
"scope_id": template.scope_id,
|
||||||
|
"read_only": template.read_only,
|
||||||
|
"current_revision": template.current_revision,
|
||||||
|
"revision": revision.revision if revision else template.current_revision,
|
||||||
|
"revision_id": revision.id if revision else template.current_revision_id,
|
||||||
|
"locale": revision.locale if revision else None,
|
||||||
|
"published": bool(template.published_revision_id),
|
||||||
|
"targets": list(dict.fromkeys(targets)),
|
||||||
|
"subject": revision_metadata.get("campaign_subject"),
|
||||||
|
"text": revision.content_text if revision else None,
|
||||||
|
"html": revision.content_html if revision else None,
|
||||||
|
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
|
||||||
|
"required_fields": [
|
||||||
|
dataclasses.asdict(field) for field in revision.required_fields
|
||||||
|
] if revision else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_content_required_fields(
|
||||||
|
payload: CampaignContentLibrarySaveRequest,
|
||||||
|
) -> tuple[TemplateFieldRequirement, ...]:
|
||||||
|
if payload.kind == "fragment":
|
||||||
|
values = (
|
||||||
|
payload.subject if payload.target == "subject"
|
||||||
|
else payload.html if payload.target == "html"
|
||||||
|
else payload.text,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
values = (
|
||||||
|
payload.subject,
|
||||||
|
payload.text if payload.body_mode != "html" else None,
|
||||||
|
payload.html if payload.body_mode != "text" else None,
|
||||||
|
)
|
||||||
|
paths = {
|
||||||
|
path
|
||||||
|
for value in values
|
||||||
|
for key in find_unresolved_placeholders(value)
|
||||||
|
if (path := _campaign_content_requirement_path(key)) is not None
|
||||||
|
}
|
||||||
|
return tuple(
|
||||||
|
TemplateFieldRequirement(path=path, label=path)
|
||||||
|
for path in sorted(paths)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _campaign_content_requirement_path(key: str) -> str | None:
|
||||||
|
path = key.replace("local::", "local.", 1).replace("global::", "global.", 1)
|
||||||
|
path = re.sub(r"\[([1-9][0-9]*)\]", r".\1", path)
|
||||||
|
return path if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]*", path) else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{campaign_id}/content-library")
|
||||||
|
def campaign_content_library(
|
||||||
|
campaign_id: str,
|
||||||
|
query: str = Query(default="", min_length=0, max_length=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
integration = templates_integration()
|
||||||
|
if not integration.content_available:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"writable": False,
|
||||||
|
"reason": "The Templates content library is not active.",
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
templates = integration.list_content_templates(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
query=query,
|
||||||
|
limit=250,
|
||||||
|
)
|
||||||
|
except PermissionError as exc:
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"writable": False,
|
||||||
|
"reason": str(exc),
|
||||||
|
"items": [],
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"writable": integration.content_writable,
|
||||||
|
"items": [_campaign_content_library_item(template) for template in templates],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED)
|
||||||
|
def save_campaign_content_library_item(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: CampaignContentLibrarySaveRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||||
|
):
|
||||||
|
campaign = _get_campaign_for_principal(
|
||||||
|
session,
|
||||||
|
campaign_id,
|
||||||
|
principal,
|
||||||
|
write=True,
|
||||||
|
)
|
||||||
|
integration = templates_integration()
|
||||||
|
if not integration.content_writable:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="The Templates content-library capability is not active.",
|
||||||
|
)
|
||||||
|
target = payload.target if payload.kind == "fragment" else None
|
||||||
|
content_text = (
|
||||||
|
payload.subject
|
||||||
|
if target == "subject"
|
||||||
|
else payload.text
|
||||||
|
if target == "text"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
content_html = payload.html if target == "html" else None
|
||||||
|
if payload.kind == "campaign_part":
|
||||||
|
content_text = payload.text
|
||||||
|
content_html = payload.html
|
||||||
|
metadata: dict[str, object] = {
|
||||||
|
"campaign_kind": payload.kind,
|
||||||
|
"campaign_targets": (
|
||||||
|
[target]
|
||||||
|
if target
|
||||||
|
else [
|
||||||
|
field
|
||||||
|
for field, value in (
|
||||||
|
("subject", payload.subject),
|
||||||
|
("text", payload.text),
|
||||||
|
("html", payload.html),
|
||||||
|
)
|
||||||
|
if value and value.strip()
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"campaign_body_mode": payload.body_mode,
|
||||||
|
"source_module": "campaigns",
|
||||||
|
"source_campaign_id": campaign.id,
|
||||||
|
}
|
||||||
|
if payload.kind == "campaign_part" and payload.subject:
|
||||||
|
metadata["campaign_subject"] = payload.subject
|
||||||
|
try:
|
||||||
|
template = integration.create_content_draft(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
request=TemplateContentDraftRequest(
|
||||||
|
name=payload.name,
|
||||||
|
description=payload.description,
|
||||||
|
template_type=(
|
||||||
|
"content_fragment" if payload.kind == "fragment" else "email"
|
||||||
|
),
|
||||||
|
usages=("campaign.content",),
|
||||||
|
content_text=content_text,
|
||||||
|
content_html=content_html,
|
||||||
|
locale=payload.locale,
|
||||||
|
required_fields=_campaign_content_required_fields(payload),
|
||||||
|
scope_type=("user" if payload.visibility == "personal" else "tenant"),
|
||||||
|
scope_id=(
|
||||||
|
principal.account_id if payload.visibility == "personal" else None
|
||||||
|
),
|
||||||
|
metadata=metadata,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.content_library_saved",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign.id,
|
||||||
|
details={
|
||||||
|
"template_id": template.id,
|
||||||
|
"template_revision_id": (
|
||||||
|
template.revision.id if template.revision else None
|
||||||
|
),
|
||||||
|
"kind": payload.kind,
|
||||||
|
"target": payload.target,
|
||||||
|
"visibility": payload.visibility,
|
||||||
|
},
|
||||||
|
commit=False,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
except PermissionError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return {"template": _campaign_content_library_item(template)}
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{campaign_id}/recipient-address-sources/snapshot",
|
"/{campaign_id}/recipient-address-sources/snapshot",
|
||||||
response_model=CampaignRecipientAddressSourceSnapshotResponse,
|
response_model=CampaignRecipientAddressSourceSnapshotResponse,
|
||||||
@@ -1652,7 +1905,10 @@ def copy_campaign(
|
|||||||
action="copy_campaign",
|
action="copy_campaign",
|
||||||
version_id=payload.source_version_id,
|
version_id=payload.source_version_id,
|
||||||
)
|
)
|
||||||
_require_permission(principal, "campaigns:recipient:read")
|
if payload.include_recipients:
|
||||||
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
|
if payload.include_shares:
|
||||||
|
_require_permission(principal, "campaigns:campaign:share")
|
||||||
source_version = (
|
source_version = (
|
||||||
session.query(CampaignVersion)
|
session.query(CampaignVersion)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -1674,7 +1930,7 @@ def copy_campaign(
|
|||||||
requested=payload.external_id,
|
requested=payload.external_id,
|
||||||
)
|
)
|
||||||
name = (payload.name or f"{source_campaign.name} (copy)").strip()
|
name = (payload.name or f"{source_campaign.name} (copy)").strip()
|
||||||
raw_json = copy.deepcopy(source_version.raw_json)
|
raw_json = _campaign_copy_configuration(source_version.raw_json, payload)
|
||||||
campaign_metadata = raw_json.get("campaign")
|
campaign_metadata = raw_json.get("campaign")
|
||||||
if not isinstance(campaign_metadata, dict):
|
if not isinstance(campaign_metadata, dict):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -1696,6 +1952,36 @@ def copy_campaign(
|
|||||||
source_base_path=source_version.source_base_path,
|
source_base_path=source_version.source_base_path,
|
||||||
commit=False,
|
commit=False,
|
||||||
)
|
)
|
||||||
|
if payload.include_policies:
|
||||||
|
campaign.settings = copy.deepcopy(source_campaign.settings or {})
|
||||||
|
if payload.include_mail_profile:
|
||||||
|
campaign.mail_profile_policy = copy.deepcopy(
|
||||||
|
source_campaign.mail_profile_policy or {}
|
||||||
|
)
|
||||||
|
copied_share_count = 0
|
||||||
|
if payload.include_shares:
|
||||||
|
source_shares = (
|
||||||
|
session.query(CampaignShare)
|
||||||
|
.filter(
|
||||||
|
CampaignShare.tenant_id == principal.tenant_id,
|
||||||
|
CampaignShare.campaign_id == source_campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(CampaignShare.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for source_share in source_shares:
|
||||||
|
session.add(
|
||||||
|
CampaignShare(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
target_type=source_share.target_type,
|
||||||
|
target_id=source_share.target_id,
|
||||||
|
permission=source_share.permission,
|
||||||
|
created_by_user_id=principal.user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
copied_share_count = len(source_shares)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -1707,6 +1993,14 @@ def copy_campaign(
|
|||||||
"source_version_id": source_version.id,
|
"source_version_id": source_version.id,
|
||||||
"destination_version_id": version.id,
|
"destination_version_id": version.id,
|
||||||
"copied_evidence": False,
|
"copied_evidence": False,
|
||||||
|
"copy_options": {
|
||||||
|
"recipients": payload.include_recipients,
|
||||||
|
"files": payload.include_files,
|
||||||
|
"shares": payload.include_shares,
|
||||||
|
"policies": payload.include_policies,
|
||||||
|
"mail_profile": payload.include_mail_profile,
|
||||||
|
},
|
||||||
|
"copied_share_count": copied_share_count,
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.scheduling import (
|
||||||
|
campaign_schedule_source_snapshot,
|
||||||
|
canonical_configuration_hash,
|
||||||
|
validate_autonomous_schedule_source,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignScheduleOccurrence,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.route_support import (
|
||||||
|
_get_campaign_for_principal,
|
||||||
|
_require_permission,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignScheduleCreateRequest,
|
||||||
|
CampaignScheduleListResponse,
|
||||||
|
CampaignScheduleOccurrenceResponse,
|
||||||
|
CampaignScheduleResponse,
|
||||||
|
CampaignScheduleStateRequest,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||||
|
from govoplan_core.audit.logging import audit_from_principal
|
||||||
|
from govoplan_core.db.session import get_session
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{campaign_id}/schedules",
|
||||||
|
response_model=CampaignScheduleListResponse,
|
||||||
|
)
|
||||||
|
def list_campaign_schedules(
|
||||||
|
campaign_id: str,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||||
|
):
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
schedules = (
|
||||||
|
session.query(CampaignSchedule)
|
||||||
|
.filter(
|
||||||
|
CampaignSchedule.tenant_id == principal.tenant_id,
|
||||||
|
CampaignSchedule.campaign_id == campaign_id,
|
||||||
|
)
|
||||||
|
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
occurrences = _occurrences_by_schedule(session, schedules)
|
||||||
|
return CampaignScheduleListResponse(
|
||||||
|
items=[
|
||||||
|
_schedule_response(item, occurrences.get(item.id, []))
|
||||||
|
for item in schedules
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{campaign_id}/schedules",
|
||||||
|
response_model=CampaignScheduleResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
def create_campaign_schedule(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: CampaignScheduleCreateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||||
|
):
|
||||||
|
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
_require_permission(principal, "campaigns:campaign:copy")
|
||||||
|
if payload.include_recipients:
|
||||||
|
_require_permission(principal, "campaigns:recipient:read")
|
||||||
|
if payload.include_shares:
|
||||||
|
_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 = (
|
||||||
|
session.query(CampaignVersion)
|
||||||
|
.filter(
|
||||||
|
CampaignVersion.id == payload.source_version_id,
|
||||||
|
CampaignVersion.campaign_id == campaign.id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if source_version is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Campaign version not found")
|
||||||
|
starts_at = payload.starts_at.astimezone(UTC)
|
||||||
|
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Campaign schedules cannot start in the past.",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ZoneInfo(payload.timezone)
|
||||||
|
except ZoneInfoNotFoundError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail="Unknown campaign schedule timezone.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
source_shares = (
|
||||||
|
session.query(CampaignShare)
|
||||||
|
.filter(
|
||||||
|
CampaignShare.tenant_id == principal.tenant_id,
|
||||||
|
CampaignShare.campaign_id == campaign.id,
|
||||||
|
CampaignShare.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(CampaignShare.id.asc())
|
||||||
|
.all()
|
||||||
|
if payload.include_shares
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
snapshot = campaign_schedule_source_snapshot(
|
||||||
|
configuration=source_version.raw_json,
|
||||||
|
campaign_settings=campaign.settings or {},
|
||||||
|
mail_profile_policy=campaign.mail_profile_policy or {},
|
||||||
|
shares=[
|
||||||
|
{
|
||||||
|
"target_type": item.target_type,
|
||||||
|
"target_id": item.target_id,
|
||||||
|
"permission": item.permission,
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
source_version_id=source_version.id,
|
||||||
|
created_by_user_id=principal.user.id,
|
||||||
|
name=payload.name.strip(),
|
||||||
|
delivery_mode=payload.delivery_mode,
|
||||||
|
recurrence_kind=payload.recurrence_kind,
|
||||||
|
interval_count=payload.interval_count,
|
||||||
|
timezone=payload.timezone,
|
||||||
|
starts_at=starts_at,
|
||||||
|
next_fire_at=starts_at,
|
||||||
|
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
|
||||||
|
max_occurrences=payload.max_occurrences,
|
||||||
|
copy_options={
|
||||||
|
"include_recipients": payload.include_recipients,
|
||||||
|
"include_files": payload.include_files,
|
||||||
|
"include_shares": payload.include_shares,
|
||||||
|
"include_policies": payload.include_policies,
|
||||||
|
"include_mail_profile": payload.include_mail_profile,
|
||||||
|
},
|
||||||
|
source_snapshot=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,
|
||||||
|
)
|
||||||
|
session.add(schedule)
|
||||||
|
session.flush()
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.schedule.created",
|
||||||
|
object_type="campaign_schedule",
|
||||||
|
object_id=schedule.id,
|
||||||
|
details={
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"source_version_id": source_version.id,
|
||||||
|
"recurrence_kind": schedule.recurrence_kind,
|
||||||
|
"interval_count": schedule.interval_count,
|
||||||
|
"starts_at": schedule.starts_at.isoformat(),
|
||||||
|
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
||||||
|
"max_occurrences": schedule.max_occurrences,
|
||||||
|
"delivery_mode": schedule.delivery_mode,
|
||||||
|
"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,
|
||||||
|
)
|
||||||
|
session.refresh(schedule)
|
||||||
|
return _schedule_response(schedule, [])
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{campaign_id}/schedules/{schedule_id}",
|
||||||
|
response_model=CampaignScheduleResponse,
|
||||||
|
)
|
||||||
|
def set_campaign_schedule_state(
|
||||||
|
campaign_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
payload: CampaignScheduleStateRequest,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||||
|
):
|
||||||
|
_get_campaign_for_principal(session, campaign_id, principal)
|
||||||
|
schedule = _schedule_for_campaign(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if schedule.resource_revision != payload.base_revision:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Campaign schedule changed. Reload it before changing its state.",
|
||||||
|
)
|
||||||
|
if payload.active and schedule.next_fire_at is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
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.last_error = None if payload.active else schedule.last_error
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
session.add(schedule)
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action=(
|
||||||
|
"campaign.schedule.resumed"
|
||||||
|
if payload.active
|
||||||
|
else "campaign.schedule.paused"
|
||||||
|
),
|
||||||
|
object_type="campaign_schedule",
|
||||||
|
object_id=schedule.id,
|
||||||
|
details={"campaign_id": campaign_id},
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
session.refresh(schedule)
|
||||||
|
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
|
||||||
|
return _schedule_response(schedule, occurrences)
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_for_campaign(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> CampaignSchedule:
|
||||||
|
query = session.query(CampaignSchedule)
|
||||||
|
if for_update:
|
||||||
|
query = query.with_for_update()
|
||||||
|
schedule = (
|
||||||
|
query
|
||||||
|
.filter(
|
||||||
|
CampaignSchedule.id == schedule_id,
|
||||||
|
CampaignSchedule.tenant_id == tenant_id,
|
||||||
|
CampaignSchedule.campaign_id == campaign_id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if schedule is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Campaign schedule not found")
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
|
||||||
|
def _occurrences_by_schedule(
|
||||||
|
session: Session,
|
||||||
|
schedules: list[CampaignSchedule],
|
||||||
|
) -> dict[str, list[CampaignScheduleOccurrence]]:
|
||||||
|
ids = [item.id for item in schedules]
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
rows = (
|
||||||
|
session.query(CampaignScheduleOccurrence)
|
||||||
|
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
|
||||||
|
.order_by(
|
||||||
|
CampaignScheduleOccurrence.scheduled_for.desc(),
|
||||||
|
CampaignScheduleOccurrence.id.asc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
|
||||||
|
for row in rows:
|
||||||
|
grouped.setdefault(row.schedule_id, []).append(row)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def _schedule_response(
|
||||||
|
schedule: CampaignSchedule,
|
||||||
|
occurrences: list[CampaignScheduleOccurrence],
|
||||||
|
) -> CampaignScheduleResponse:
|
||||||
|
response = CampaignScheduleResponse.model_validate(schedule)
|
||||||
|
return response.model_copy(
|
||||||
|
update={
|
||||||
|
"occurrences": [
|
||||||
|
CampaignScheduleOccurrenceResponse.model_validate(item)
|
||||||
|
for item in occurrences
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -594,6 +594,7 @@ def set_version_review_state(
|
|||||||
"inspection_complete": payload.inspection_complete,
|
"inspection_complete": payload.inspection_complete,
|
||||||
"reviewed_message_count": len(payload.reviewed_message_keys),
|
"reviewed_message_count": len(payload.reviewed_message_keys),
|
||||||
"issue_decision_count": len(payload.issue_decisions),
|
"issue_decision_count": len(payload.issue_decisions),
|
||||||
|
"issue_decisions": _review_decision_audit_evidence(version),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -769,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
|
||||||
@@ -876,6 +885,15 @@ def build_version(
|
|||||||
"write_eml": write_eml,
|
"write_eml": write_eml,
|
||||||
"built_count": result.get("built_count"),
|
"built_count": result.get("built_count"),
|
||||||
"recovery_operation_id": recovery_start.operation_id,
|
"recovery_operation_id": recovery_start.operation_id,
|
||||||
|
"residual_file_disposition": _residual_file_audit_evidence(
|
||||||
|
result.get("residual_file_disposition")
|
||||||
|
),
|
||||||
|
"attachment_reuse": _attachment_reuse_audit_evidence(
|
||||||
|
result.get("attachment_reuse")
|
||||||
|
),
|
||||||
|
"archive_encryption": _archive_encryption_audit_evidence(
|
||||||
|
result.get("archive_encryption")
|
||||||
|
),
|
||||||
},
|
},
|
||||||
commit=True,
|
commit=True,
|
||||||
)
|
)
|
||||||
@@ -884,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
|
||||||
@@ -906,3 +936,121 @@ def build_version(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||||
) 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]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
key: value.get(key)
|
||||||
|
for key in (
|
||||||
|
"contract_version",
|
||||||
|
"action",
|
||||||
|
"routing_mode",
|
||||||
|
"validation_behavior",
|
||||||
|
"watched_source_count",
|
||||||
|
"residual_file_count",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_reuse_audit_evidence(value: object) -> dict[str, object]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
policy = value.get("policy")
|
||||||
|
return {
|
||||||
|
"contract_version": value.get("contract_version"),
|
||||||
|
"policy": dict(policy) if isinstance(policy, dict) else {},
|
||||||
|
"duplicate_file_count": value.get("duplicate_file_count"),
|
||||||
|
"allowed_file_count": value.get("allowed_file_count"),
|
||||||
|
"violation_file_count": value.get("violation_file_count"),
|
||||||
|
"affected_message_count": value.get("affected_message_count"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
version: CampaignVersion,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||||
|
review_state = editor_state.get("review_send")
|
||||||
|
if not isinstance(review_state, dict):
|
||||||
|
return {}
|
||||||
|
raw_decisions = review_state.get("issue_decisions")
|
||||||
|
decisions = [item for item in raw_decisions or [] if isinstance(item, dict)]
|
||||||
|
evidence = [
|
||||||
|
{
|
||||||
|
"decision": item.get("decision"),
|
||||||
|
"issue_codes": sorted(
|
||||||
|
str(code) for code in item.get("issue_codes") or [] if code
|
||||||
|
),
|
||||||
|
"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
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"count": len(evidence),
|
||||||
|
"with_reason_count": sum(
|
||||||
|
1 for item in evidence if item["reason_recorded"]
|
||||||
|
),
|
||||||
|
"issue_codes": sorted(
|
||||||
|
{
|
||||||
|
code
|
||||||
|
for item in evidence
|
||||||
|
for code in item["issue_codes"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"evidence_sha256": _canonical_sha256(evidence),
|
||||||
|
}
|
||||||
|
|||||||
@@ -371,6 +371,13 @@
|
|||||||
"warn"
|
"warn"
|
||||||
],
|
],
|
||||||
"default": "ask"
|
"default": "ask"
|
||||||
|
},
|
||||||
|
"reuse_policy": {
|
||||||
|
"$ref": "#/$defs/attachment_reuse_policy",
|
||||||
|
"description": "Controls whether resolving the same source file more than once is allowed, warned, reviewed, or blocked, with an optional same-recipient or same-message allowance."
|
||||||
|
},
|
||||||
|
"residual_files": {
|
||||||
|
"$ref": "#/$defs/residual_file_disposition"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -1455,6 +1462,53 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"attachment_reuse_policy": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"action": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["allow", "warn", "review", "block"],
|
||||||
|
"default": "allow",
|
||||||
|
"description": "Action when one resolved source file is used more than once outside the configured allowance. Review creates an explicit, reasoned review decision."
|
||||||
|
},
|
||||||
|
"allow_within": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "same_recipient", "same_message"],
|
||||||
|
"default": "none",
|
||||||
|
"description": "Optional exception that permits reuse confined to one recipient or one built message."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"default": {"action": "allow", "allow_within": "none"}
|
||||||
|
},
|
||||||
|
"residual_file_disposition": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["none", "report", "attach"],
|
||||||
|
"default": "none",
|
||||||
|
"description": "Keep normal warning policy, prepare a reviewed report message, or prepare a reviewed message with the residual files attached."
|
||||||
|
},
|
||||||
|
"recipient": {
|
||||||
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/recipient" },
|
||||||
|
{ "type": "null" }
|
||||||
|
],
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
"subject": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "Unassigned files in campaign {{local:campaign_name}}"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"default": { "mode": "none", "recipient": null }
|
||||||
|
},
|
||||||
"zip_config": {
|
"zip_config": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1571,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",
|
||||||
|
|||||||
@@ -61,6 +61,16 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"fields": {
|
"fields": {
|
||||||
|
"/attachments/reuse_policy/action": {
|
||||||
|
"label": "Duplicate-file action",
|
||||||
|
"control": "select",
|
||||||
|
"description": "Allow and record, warn, require a reasoned review decision, or block repeated use of the same resolved file."
|
||||||
|
},
|
||||||
|
"/attachments/reuse_policy/allow_within": {
|
||||||
|
"label": "Allowed reuse boundary",
|
||||||
|
"control": "select",
|
||||||
|
"description": "Optionally exempt reuse confined to one recipient or one built message."
|
||||||
|
},
|
||||||
"/attachments/global[]/message_filename_template": {
|
"/attachments/global[]/message_filename_template": {
|
||||||
"label": "Direct attachment filename",
|
"label": "Direct attachment filename",
|
||||||
"control": "text",
|
"control": "text",
|
||||||
|
|||||||
@@ -42,6 +42,97 @@ 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
|
||||||
|
|
||||||
|
|
||||||
class CampaignLifecycleMutationRequest(BaseModel):
|
class CampaignLifecycleMutationRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -52,6 +143,135 @@ class CampaignCopyRequest(CampaignLifecycleMutationRequest):
|
|||||||
source_version_id: str = Field(min_length=1, max_length=36)
|
source_version_id: str = Field(min_length=1, max_length=36)
|
||||||
external_id: str | None = Field(default=None, min_length=1, max_length=255)
|
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)
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
include_recipients: bool = True
|
||||||
|
include_files: bool = True
|
||||||
|
include_shares: bool = False
|
||||||
|
include_policies: bool = True
|
||||||
|
include_mail_profile: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignScheduleCreateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
source_version_id: str = Field(min_length=1, max_length=36)
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
delivery_mode: Literal["manual", "autonomous"] = "manual"
|
||||||
|
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
|
||||||
|
interval_count: int = Field(default=1, ge=1, le=365)
|
||||||
|
timezone: str = Field(default="UTC", min_length=1, max_length=100)
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
max_occurrences: int = Field(default=1, ge=1, le=1000)
|
||||||
|
include_recipients: bool = True
|
||||||
|
include_files: bool = True
|
||||||
|
include_shares: bool = False
|
||||||
|
include_policies: bool = True
|
||||||
|
include_mail_profile: bool = True
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_schedule(self) -> "CampaignScheduleCreateRequest":
|
||||||
|
if self.starts_at.tzinfo is None:
|
||||||
|
raise ValueError("Campaign schedule start must include a timezone.")
|
||||||
|
if self.ends_at is not None:
|
||||||
|
if self.ends_at.tzinfo is None:
|
||||||
|
raise ValueError("Campaign schedule end must include a timezone.")
|
||||||
|
if self.ends_at <= self.starts_at:
|
||||||
|
raise ValueError("Campaign schedule end must be after its start.")
|
||||||
|
if self.recurrence_kind == "once":
|
||||||
|
self.max_occurrences = 1
|
||||||
|
self.interval_count = 1
|
||||||
|
elif self.max_occurrences < 2:
|
||||||
|
raise ValueError("A recurring campaign schedule needs at least two occurrences.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignScheduleStateRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
active: bool
|
||||||
|
base_revision: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignScheduleOccurrenceResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
schedule_id: str
|
||||||
|
scheduled_for: datetime
|
||||||
|
status: str
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
generated_campaign_id: str | None = None
|
||||||
|
generated_version_id: 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
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignScheduleResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
campaign_id: str
|
||||||
|
source_version_id: str
|
||||||
|
name: str
|
||||||
|
delivery_mode: str
|
||||||
|
recurrence_kind: str
|
||||||
|
interval_count: int
|
||||||
|
timezone: str
|
||||||
|
starts_at: datetime
|
||||||
|
next_fire_at: datetime | None = None
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
max_occurrences: int
|
||||||
|
occurrence_count: int
|
||||||
|
active: bool
|
||||||
|
resource_revision: int
|
||||||
|
last_fired_at: datetime | None = None
|
||||||
|
last_campaign_id: str | None = None
|
||||||
|
last_error: str | None = None
|
||||||
|
last_outcome: str | None = None
|
||||||
|
last_recovery_state: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignScheduleListResponse(BaseModel):
|
||||||
|
items: list[CampaignScheduleResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignContentLibrarySaveRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=300)
|
||||||
|
description: str | None = Field(default=None, max_length=4000)
|
||||||
|
kind: Literal["fragment", "campaign_part"]
|
||||||
|
target: Literal["subject", "text", "html"] | None = None
|
||||||
|
subject: str | None = Field(default=None, max_length=1000)
|
||||||
|
text: str | None = Field(default=None, max_length=1_000_000)
|
||||||
|
html: str | None = Field(default=None, max_length=2_000_000)
|
||||||
|
body_mode: Literal["text", "html", "both"] = "both"
|
||||||
|
locale: str = Field(default="de", min_length=2, max_length=35)
|
||||||
|
visibility: Literal["personal", "tenant"] = "personal"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_content(self) -> "CampaignContentLibrarySaveRequest":
|
||||||
|
if self.kind == "fragment" and self.target is None:
|
||||||
|
raise ValueError("A content fragment requires a target field.")
|
||||||
|
values = {
|
||||||
|
"subject": self.subject,
|
||||||
|
"text": self.text,
|
||||||
|
"html": self.html,
|
||||||
|
}
|
||||||
|
if self.kind == "fragment":
|
||||||
|
selected = values[self.target or "text"]
|
||||||
|
if not selected or not selected.strip():
|
||||||
|
raise ValueError("The selected fragment field is empty.")
|
||||||
|
elif not any(value and value.strip() for value in (self.text, self.html)):
|
||||||
|
raise ValueError("A campaign part requires text or HTML body content.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class CampaignCreateMinimalRequest(BaseModel):
|
class CampaignCreateMinimalRequest(BaseModel):
|
||||||
|
|||||||
@@ -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,48 +1081,91 @@ 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
|
||||||
try:
|
paused_count = 0
|
||||||
result = _deliver_job_with_recovery(
|
pause_reason_code: str | None = None
|
||||||
session,
|
batch_state = "ready"
|
||||||
job=job,
|
batch_manager = _synchronous_smtp_batch_manager(
|
||||||
context=delivery_contexts[job.id],
|
session,
|
||||||
use_rate_limit=use_rate_limit,
|
jobs=jobs,
|
||||||
enqueue_imap_task=enqueue_imap_task,
|
contexts=delivery_contexts,
|
||||||
)
|
)
|
||||||
result_dict = result.as_dict()
|
try:
|
||||||
results.append(result_dict)
|
with batch_manager as smtp_batch:
|
||||||
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
# Queue state becomes durable only after local and SMTP
|
||||||
sent_count += 1
|
# DNS/connectivity/TLS/auth preflight succeeds.
|
||||||
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
session.commit()
|
||||||
outcome_unknown_count += 1
|
for index, job in enumerate(jobs):
|
||||||
else:
|
attempted_count += 1
|
||||||
skipped_after_queue += 1
|
try:
|
||||||
except Exception as exc: # keep sending other jobs and return per-job details
|
result = _deliver_job_with_recovery(
|
||||||
failed_count += 1
|
session,
|
||||||
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
job=job,
|
||||||
|
context=delivery_contexts[job.id],
|
||||||
|
use_rate_limit=use_rate_limit,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
result_dict = result.as_dict()
|
||||||
|
results.append(result_dict)
|
||||||
|
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
||||||
|
sent_count += 1
|
||||||
|
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
|
||||||
|
outcome_unknown_count += 1
|
||||||
|
else:
|
||||||
|
skipped_after_queue += 1
|
||||||
|
except Exception as exc:
|
||||||
|
failed_count += 1
|
||||||
|
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."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -10,17 +12,31 @@ from govoplan_access.backend.db.models import Account, Group, User
|
|||||||
from govoplan_campaign.backend.capabilities import (
|
from govoplan_campaign.backend.capabilities import (
|
||||||
CampaignAccessService,
|
CampaignAccessService,
|
||||||
CampaignOwnershipService,
|
CampaignOwnershipService,
|
||||||
|
campaign_import_execution_resource_id,
|
||||||
|
campaign_protocol_artifact_resource_id,
|
||||||
campaign_report_resource_id,
|
campaign_report_resource_id,
|
||||||
|
campaign_version_child_resource_id,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
AttachmentBlob,
|
||||||
|
AttachmentInstance,
|
||||||
Campaign,
|
Campaign,
|
||||||
|
CampaignCollaborationEntry,
|
||||||
|
CampaignIssue,
|
||||||
CampaignJob,
|
CampaignJob,
|
||||||
|
CampaignMessageAction,
|
||||||
|
CampaignMessageActionAttempt,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
ImapAppendAttempt,
|
||||||
|
PostboxDeliveryAttempt,
|
||||||
|
PrintOutputAttempt,
|
||||||
|
RecipientImportMappingProfile,
|
||||||
|
SendAttempt,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import PrincipalRef
|
from govoplan_core.core.access import PrincipalRef
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransferError
|
from govoplan_core.core.ownership import OwnershipSubjectRef, OwnershipTransfer, OwnershipTransferError
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +47,61 @@ GROUP_ID = "group-1"
|
|||||||
|
|
||||||
|
|
||||||
class CampaignAccessProviderTests(unittest.TestCase):
|
class CampaignAccessProviderTests(unittest.TestCase):
|
||||||
|
def test_collaboration_access_is_explained_independently_from_campaign_edit(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-collaboration",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=OTHER_USER_ID,
|
||||||
|
external_id="collaboration",
|
||||||
|
name="Collaboration campaign",
|
||||||
|
)
|
||||||
|
entry = CampaignCollaborationEntry(
|
||||||
|
id="discussion-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
actor_user_id=OTHER_USER_ID,
|
||||||
|
actor_label_snapshot="Other user",
|
||||||
|
visibility="collaborators",
|
||||||
|
content="Not disclosed by provenance",
|
||||||
|
content_sha256="a" * 64,
|
||||||
|
)
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
campaign,
|
||||||
|
entry,
|
||||||
|
CampaignShare(
|
||||||
|
id="share-collaboration",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
target_type="group",
|
||||||
|
target_id=GROUP_ID,
|
||||||
|
permission="read",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
items = CampaignAccessService().explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
_principal(
|
||||||
|
scopes={"campaigns:campaign:read", "campaigns:discussion:post"},
|
||||||
|
group_ids={GROUP_ID},
|
||||||
|
),
|
||||||
|
resource_type="campaign_collaboration_entry",
|
||||||
|
resource_id=entry.id,
|
||||||
|
action="campaigns:discussion:post",
|
||||||
|
)
|
||||||
|
|
||||||
|
discussion = next(item for item in items if item.source == "campaigns.collaboration_entry")
|
||||||
|
self.assertEqual(["campaigns:discussion:post"], discussion.details["permission_actions"])
|
||||||
|
self.assertFalse(discussion.details["content_disclosed"])
|
||||||
|
self.assertTrue(any(item.id == "share-collaboration" for item in items))
|
||||||
|
self.assertNotIn("campaigns:campaign:update", repr(discussion.details))
|
||||||
|
self.assertNotIn("Not disclosed", repr(items))
|
||||||
|
|
||||||
def test_campaign_access_provider_explains_owner_share_admin_and_missing_resources(self) -> None:
|
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)
|
||||||
@@ -181,6 +252,513 @@ class CampaignAccessProviderTests(unittest.TestCase):
|
|||||||
self.assertEqual(report.details["report_kind"], "delivery")
|
self.assertEqual(report.details["report_kind"], "delivery")
|
||||||
self.assertFalse(report.details["persisted"])
|
self.assertFalse(report.details["persisted"])
|
||||||
|
|
||||||
|
def test_recipient_attachment_and_review_children_are_bounded_and_versioned(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-sensitive",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=OTHER_USER_ID,
|
||||||
|
external_id="sensitive",
|
||||||
|
name="Sensitive child evidence",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-sensitive",
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_number=4,
|
||||||
|
raw_json={},
|
||||||
|
execution_snapshot={"secret_source_rows": ["do-not-disclose"]},
|
||||||
|
execution_snapshot_hash="a" * 64,
|
||||||
|
)
|
||||||
|
job = CampaignJob(
|
||||||
|
id="job-sensitive",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
entry_index=7,
|
||||||
|
recipient_email="hidden-recipient@example.test",
|
||||||
|
execution_input_sha256="b" * 64,
|
||||||
|
resolved_attachments=[{"filename": "hidden-file.pdf", "storage_key": "secret/key"}],
|
||||||
|
issues_snapshot=[{
|
||||||
|
"code": "attachment_override_required",
|
||||||
|
"source": "attachments.policy",
|
||||||
|
"behavior": "ask",
|
||||||
|
"message": "hidden validation detail",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
version.editor_state = {
|
||||||
|
"review_send": {
|
||||||
|
"issue_decisions": [{
|
||||||
|
"job_id": job.id,
|
||||||
|
"review_key": "opaque-review-key",
|
||||||
|
"decision": "accept",
|
||||||
|
"reason": "hidden operator reason",
|
||||||
|
"decided_at": "2026-08-19T10:00:00+00:00",
|
||||||
|
"issue_fingerprint": "c" * 64,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issue = CampaignIssue(
|
||||||
|
id="issue-sensitive",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
job_id=job.id,
|
||||||
|
severity="warning",
|
||||||
|
code="attachment_override_required",
|
||||||
|
message="hidden validation detail",
|
||||||
|
source="hidden/source/path",
|
||||||
|
behavior="ask",
|
||||||
|
)
|
||||||
|
blob = AttachmentBlob(
|
||||||
|
id="blob-sensitive",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
sha256="d" * 64,
|
||||||
|
size_bytes=123,
|
||||||
|
storage_bucket="secret-bucket",
|
||||||
|
storage_key="secret/object",
|
||||||
|
)
|
||||||
|
attachment = AttachmentInstance(
|
||||||
|
id="attachment-sensitive",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=OTHER_USER_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
blob_id=blob.id,
|
||||||
|
filename="hidden-file.pdf",
|
||||||
|
)
|
||||||
|
session.add_all([
|
||||||
|
campaign,
|
||||||
|
version,
|
||||||
|
job,
|
||||||
|
issue,
|
||||||
|
blob,
|
||||||
|
attachment,
|
||||||
|
CampaignShare(
|
||||||
|
id="share-sensitive",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
target_type="group",
|
||||||
|
target_id=GROUP_ID,
|
||||||
|
permission="read",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = CampaignAccessService()
|
||||||
|
principal = _principal(
|
||||||
|
scopes={
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:recipient:read",
|
||||||
|
"campaigns:campaign:review",
|
||||||
|
"campaigns:diagnostic:read",
|
||||||
|
},
|
||||||
|
group_ids={GROUP_ID},
|
||||||
|
)
|
||||||
|
version_child_id = campaign_version_child_resource_id(
|
||||||
|
version_id=version.id,
|
||||||
|
child_id=job.id,
|
||||||
|
)
|
||||||
|
cases = (
|
||||||
|
("campaign_recipient_source_snapshot", version.id, "campaigns.recipient_source_snapshot"),
|
||||||
|
("campaign_recipient", version_child_id, "campaigns.recipient"),
|
||||||
|
("campaign_attachment_binding", attachment.id, "campaigns.attachment_binding"),
|
||||||
|
("campaign_attachment_resolution", version_child_id, "campaigns.attachment_resolution"),
|
||||||
|
("campaign_validation_issue", issue.id, "campaigns.validation_issue"),
|
||||||
|
("campaign_review_decision", version_child_id, "campaigns.review_decision"),
|
||||||
|
("campaign_attachment_override", version_child_id, "campaigns.attachment_override"),
|
||||||
|
)
|
||||||
|
for resource_type, resource_id, source in cases:
|
||||||
|
items = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
child = next(item for item in items if item.source == source)
|
||||||
|
self.assertEqual(campaign.id, child.details["campaign_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"resource_type": "campaign", "resource_id": campaign.id},
|
||||||
|
child.details["authorization_inherited_from"],
|
||||||
|
)
|
||||||
|
self.assertEqual("inherited_and_further_restricted", child.details["authorization_mode"])
|
||||||
|
self.assertTrue(any(item.id == "share-sensitive" for item in items))
|
||||||
|
serialized = json.dumps(child.details, sort_keys=True)
|
||||||
|
for hidden in (
|
||||||
|
"hidden-recipient@example.test",
|
||||||
|
"hidden-file.pdf",
|
||||||
|
"secret/object",
|
||||||
|
"secret/key",
|
||||||
|
"hidden validation detail",
|
||||||
|
"hidden operator reason",
|
||||||
|
"hidden/source/path",
|
||||||
|
"do-not-disclose",
|
||||||
|
):
|
||||||
|
self.assertNotIn(hidden, serialized)
|
||||||
|
|
||||||
|
denied_items = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
_principal(scopes=set()),
|
||||||
|
resource_type="campaign_recipient",
|
||||||
|
resource_id=version_child_id,
|
||||||
|
action="campaigns:recipient:read",
|
||||||
|
)
|
||||||
|
denied_child = next(item for item in denied_items if item.source == "campaigns.recipient")
|
||||||
|
self.assertIn("campaigns:recipient:read", denied_child.details["permission_actions"])
|
||||||
|
self.assertFalse(any(item.kind in {"owner", "share", "policy"} for item in denied_items))
|
||||||
|
|
||||||
|
missing = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
resource_type="campaign_recipient",
|
||||||
|
resource_id=f"{version.id}:missing-job",
|
||||||
|
action="campaigns:recipient:read",
|
||||||
|
)
|
||||||
|
self.assertEqual("campaigns.not_found", missing[0].source)
|
||||||
|
self.assertNotIn("hidden-recipient", json.dumps(missing[0].details))
|
||||||
|
|
||||||
|
stale = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
resource_type="campaign_review_decision",
|
||||||
|
resource_id=f"stale-version:{job.id}",
|
||||||
|
action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
self.assertEqual("stale_version_reference", stale[0].details["reason"])
|
||||||
|
|
||||||
|
hidden = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
_principal(tenant_id="tenant-2"),
|
||||||
|
resource_type="campaign_validation_issue",
|
||||||
|
resource_id=issue.id,
|
||||||
|
action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
self.assertEqual("campaigns.not_found", hidden[0].source)
|
||||||
|
self.assertNotIn(issue.code, json.dumps(hidden[0].details))
|
||||||
|
|
||||||
|
def test_delivery_evidence_explanations_separate_permissions_and_hide_transport_data(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-delivery-evidence",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=USER_ID,
|
||||||
|
external_id="delivery-evidence",
|
||||||
|
name="Delivery evidence",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-delivery-evidence",
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json={},
|
||||||
|
)
|
||||||
|
job = CampaignJob(
|
||||||
|
id="job-delivery-evidence",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
entry_index=1,
|
||||||
|
recipient_email="hidden-delivery-recipient@example.test",
|
||||||
|
send_status="sent",
|
||||||
|
postbox_status="accepted",
|
||||||
|
imap_status="appended",
|
||||||
|
last_error="hidden reconciliation note",
|
||||||
|
)
|
||||||
|
send_attempt = SendAttempt(
|
||||||
|
id="send-attempt-evidence",
|
||||||
|
job_id=job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
status="reconciled_not_sent",
|
||||||
|
claim_token="hidden-claim-token",
|
||||||
|
smtp_response="hidden-smtp-response",
|
||||||
|
)
|
||||||
|
imap_attempt = ImapAppendAttempt(
|
||||||
|
id="imap-attempt-evidence",
|
||||||
|
job_id=job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
status="reconciled_imap_appended",
|
||||||
|
folder="Hidden/Sent",
|
||||||
|
error_message="hidden imap note",
|
||||||
|
)
|
||||||
|
postbox_attempt = PostboxDeliveryAttempt(
|
||||||
|
id="postbox-attempt-evidence",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
job_id=job.id,
|
||||||
|
target_key="opaque-target",
|
||||||
|
target_index=0,
|
||||||
|
attempt_number=1,
|
||||||
|
idempotency_key="opaque-postbox-key",
|
||||||
|
status="accepted",
|
||||||
|
target_snapshot={"address": "hidden postbox address"},
|
||||||
|
evidence={
|
||||||
|
"operator_reconciliation": {
|
||||||
|
"decision": "accepted",
|
||||||
|
"note": "hidden postbox note",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print_attempt = PrintOutputAttempt(
|
||||||
|
id="print-attempt-evidence",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
job_id=job.id,
|
||||||
|
attempt_number=1,
|
||||||
|
idempotency_key="opaque-print-key",
|
||||||
|
status="accepted",
|
||||||
|
render_id="hidden-render-id",
|
||||||
|
evidence={"storage_key": "hidden print locator"},
|
||||||
|
)
|
||||||
|
message_action = CampaignMessageAction(
|
||||||
|
id="message-action-evidence",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
job_id=job.id,
|
||||||
|
kind="retry",
|
||||||
|
idempotency_key="opaque-action-key",
|
||||||
|
canonical_request_hash="e" * 64,
|
||||||
|
reason="hidden action reason",
|
||||||
|
message_sha256="f" * 64,
|
||||||
|
recipient_manifest_sha256="1" * 64,
|
||||||
|
recipient_count=1,
|
||||||
|
prior_send_status="failed",
|
||||||
|
prior_attempt_count=1,
|
||||||
|
status="completed",
|
||||||
|
)
|
||||||
|
action_attempt = CampaignMessageActionAttempt(
|
||||||
|
id="message-action-attempt-evidence",
|
||||||
|
action_id=message_action.id,
|
||||||
|
attempt_number=1,
|
||||||
|
status="completed",
|
||||||
|
started_at=datetime.now(UTC),
|
||||||
|
diagnostic_summary="hidden diagnostic summary",
|
||||||
|
)
|
||||||
|
session.add_all([
|
||||||
|
campaign,
|
||||||
|
version,
|
||||||
|
job,
|
||||||
|
send_attempt,
|
||||||
|
imap_attempt,
|
||||||
|
postbox_attempt,
|
||||||
|
print_attempt,
|
||||||
|
message_action,
|
||||||
|
action_attempt,
|
||||||
|
])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = CampaignAccessService()
|
||||||
|
principal = _principal(scopes={
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
"campaigns:report:export",
|
||||||
|
"campaigns:diagnostic:read",
|
||||||
|
"campaigns:campaign:reconcile",
|
||||||
|
})
|
||||||
|
cases = (
|
||||||
|
("campaign_send_attempt", send_attempt.id, "campaigns.send_attempt"),
|
||||||
|
("campaign_imap_append_attempt", imap_attempt.id, "campaigns.imap_append_attempt"),
|
||||||
|
("campaign_postbox_attempt", postbox_attempt.id, "campaigns.postbox_attempt"),
|
||||||
|
("campaign_print_attempt", print_attempt.id, "campaigns.print_attempt"),
|
||||||
|
("campaign_message_action", message_action.id, "campaigns.message_action"),
|
||||||
|
("campaign_message_action_attempt", action_attempt.id, "campaigns.message_action_attempt"),
|
||||||
|
)
|
||||||
|
for resource_type, resource_id, source in cases:
|
||||||
|
items = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
resource_type=resource_type,
|
||||||
|
resource_id=resource_id,
|
||||||
|
action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
child = next(item for item in items if item.source == source)
|
||||||
|
self.assertEqual(campaign.id, child.details["campaign_id"])
|
||||||
|
self.assertEqual(version.id, child.details["campaign_version_id"])
|
||||||
|
self.assertEqual(job.id, child.details["job_id"])
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"read": ["campaigns:campaign:read"],
|
||||||
|
"report": ["campaigns:report:read"],
|
||||||
|
"diagnostic": ["campaigns:diagnostic:read"],
|
||||||
|
"export": ["campaigns:report:export"],
|
||||||
|
},
|
||||||
|
child.details["permission_classes"],
|
||||||
|
)
|
||||||
|
serialized = json.dumps(child.details, sort_keys=True)
|
||||||
|
for hidden_value in (
|
||||||
|
"hidden-delivery-recipient@example.test",
|
||||||
|
"hidden-claim-token",
|
||||||
|
"hidden-smtp-response",
|
||||||
|
"Hidden/Sent",
|
||||||
|
"hidden imap note",
|
||||||
|
"hidden postbox address",
|
||||||
|
"hidden postbox note",
|
||||||
|
"hidden-render-id",
|
||||||
|
"hidden print locator",
|
||||||
|
"hidden action reason",
|
||||||
|
"hidden diagnostic summary",
|
||||||
|
"hidden reconciliation note",
|
||||||
|
):
|
||||||
|
self.assertNotIn(hidden_value, serialized)
|
||||||
|
|
||||||
|
reconciliation = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
resource_type="campaign_reconciliation_decision",
|
||||||
|
resource_id=job.id,
|
||||||
|
action="campaigns:campaign:reconcile",
|
||||||
|
)
|
||||||
|
decision = next(item for item in reconciliation if item.source == "campaigns.reconciliation_decision")
|
||||||
|
self.assertEqual(["smtp", "imap", "postbox"], decision.details["channels"])
|
||||||
|
self.assertTrue(decision.details["evidence_note_recorded"])
|
||||||
|
self.assertFalse(decision.details["evidence_note_disclosed"])
|
||||||
|
|
||||||
|
unavailable = service.explain_resource_provenance(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
resource_type="campaign_postbox_attempt",
|
||||||
|
resource_id="missing-postbox-attempt",
|
||||||
|
action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
self.assertEqual("postbox", unavailable[0].details["optional_module"])
|
||||||
|
self.assertEqual("unavailable_or_hidden", unavailable[0].details["evidence_availability"])
|
||||||
|
self.assertNotIn("hidden postbox address", json.dumps(unavailable[0].details))
|
||||||
|
|
||||||
|
def test_governance_import_and_protocol_explanation_matrix(self) -> None:
|
||||||
|
session = _session()
|
||||||
|
self.addCleanup(_close_session, session)
|
||||||
|
_seed_access_subjects(session)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-governed-children", tenant_id=TENANT_ID,
|
||||||
|
owner_user_id=USER_ID, external_id="governed-children", name="Governed children",
|
||||||
|
)
|
||||||
|
share = CampaignShare(
|
||||||
|
id="share-governed-child", tenant_id=TENANT_ID, campaign_id=campaign.id,
|
||||||
|
target_type="user", target_id=OTHER_USER_ID, permission="read",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-governed-children", campaign_id=campaign.id, version_number=2,
|
||||||
|
raw_json={"entries": {"inline": [], "imports": [{
|
||||||
|
"id": "import-stable-id", "imported_at": "2026-08-20T08:00:00+00:00",
|
||||||
|
"mode": "replace", "source_type": "addresses", "source_id": "address-source-42",
|
||||||
|
"source_revision": "revision-7",
|
||||||
|
"source_provenance": {"secret_source_row": "do-not-disclose-row"},
|
||||||
|
"filename": "hidden-import.csv",
|
||||||
|
"mapping": [{"header": "Secret column", "field_name": "secret"}],
|
||||||
|
}]}},
|
||||||
|
validation_summary={"secret_issue": "do-not-disclose-validation"},
|
||||||
|
build_summary={"secret_recipient": "do-not-disclose-build"},
|
||||||
|
execution_snapshot={"rows": ["do-not-disclose-snapshot"]},
|
||||||
|
execution_snapshot_hash="a" * 64, execution_snapshot_at=now,
|
||||||
|
editor_state={"review_send": {"issue_decisions": [
|
||||||
|
{"reason": "do-not-disclose-review-reason"}
|
||||||
|
]}},
|
||||||
|
)
|
||||||
|
profile = RecipientImportMappingProfile(
|
||||||
|
id="mapping-profile-governed-child", tenant_id=TENANT_ID, owner_user_id=USER_ID,
|
||||||
|
name="Hidden profile name", column_count=2,
|
||||||
|
headers=["Secret A", "Secret B"], normalized_headers=["secret a", "secret b"],
|
||||||
|
ordered_header_fingerprint="b" * 64, unordered_header_fingerprint="c" * 64,
|
||||||
|
delimiter=";", header_rows=1, quoted=True, value_separators=",;|",
|
||||||
|
mappings=[{"header": "Secret A", "field": "hidden"}],
|
||||||
|
)
|
||||||
|
transfer = OwnershipTransfer(
|
||||||
|
id="transfer-governed-child", tenant_id=TENANT_ID,
|
||||||
|
resource_module="campaigns", resource_type="campaign", resource_id=campaign.id,
|
||||||
|
kind="transfer", status="pending_target",
|
||||||
|
current_owner_type="user", current_owner_id=USER_ID,
|
||||||
|
target_owner_type="user", target_owner_id=OTHER_USER_ID,
|
||||||
|
initiated_by_type="user", initiated_by_id=USER_ID,
|
||||||
|
reason="do-not-disclose-transfer-reason", required_approvals=1,
|
||||||
|
approvals=[], decisions=[], idempotency_key="transfer-idempotency",
|
||||||
|
canonical_request_hash="d" * 64, expires_at=now + timedelta(days=1),
|
||||||
|
revision=3, metadata_={"secret": "do-not-disclose-transfer-metadata"},
|
||||||
|
)
|
||||||
|
session.add_all([campaign, share, version, profile, transfer])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
service = CampaignAccessService()
|
||||||
|
principal = _principal(scopes={
|
||||||
|
"campaigns:campaign:read", "campaigns:campaign:share",
|
||||||
|
"campaigns:recipient:read", "campaigns:recipient:import",
|
||||||
|
"campaigns:campaign:review", "campaigns:diagnostic:read",
|
||||||
|
"campaigns:report:read",
|
||||||
|
})
|
||||||
|
import_id = campaign_import_execution_resource_id(
|
||||||
|
version_id=version.id, import_id="import-stable-id",
|
||||||
|
)
|
||||||
|
protocol_ids = [
|
||||||
|
campaign_protocol_artifact_resource_id(version_id=version.id, artifact_kind=kind)
|
||||||
|
for kind in ("validation", "build", "execution_snapshot", "review")
|
||||||
|
]
|
||||||
|
cases = [
|
||||||
|
("campaign_share", share.id, "campaigns.share_record"),
|
||||||
|
("campaign_ownership_transfer", transfer.id, "campaigns.ownership_transfer"),
|
||||||
|
("campaign_import_execution", import_id, "campaigns.import_execution"),
|
||||||
|
*(("campaign_protocol_artifact", item, "campaigns.protocol_artifact") for item in protocol_ids),
|
||||||
|
]
|
||||||
|
hidden_values = (
|
||||||
|
OTHER_USER_ID, "do-not-disclose-row", "hidden-import.csv", "Secret column",
|
||||||
|
"do-not-disclose-validation", "do-not-disclose-build", "do-not-disclose-snapshot",
|
||||||
|
"do-not-disclose-review-reason", "do-not-disclose-transfer-reason",
|
||||||
|
"do-not-disclose-transfer-metadata",
|
||||||
|
)
|
||||||
|
for resource_type, resource_id, source in cases:
|
||||||
|
items = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type=resource_type, resource_id=resource_id,
|
||||||
|
action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
child = next(item for item in items if item.source == source)
|
||||||
|
self.assertEqual(campaign.id, child.details["campaign_id"])
|
||||||
|
serialized = json.dumps(child.details, sort_keys=True)
|
||||||
|
for hidden_value in hidden_values:
|
||||||
|
self.assertNotIn(hidden_value, serialized)
|
||||||
|
|
||||||
|
import_item = next(item for item in service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type="campaign_import_execution",
|
||||||
|
resource_id=import_id, action="campaigns:recipient:import",
|
||||||
|
) if item.source == "campaigns.import_execution")
|
||||||
|
self.assertEqual("address-source-42", import_item.details["source_id"])
|
||||||
|
self.assertEqual("revision-7", import_item.details["source_revision"])
|
||||||
|
self.assertFalse(import_item.details["source_rows_disclosed"])
|
||||||
|
|
||||||
|
profile_items = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type="campaign_import_mapping_profile",
|
||||||
|
resource_id=profile.id, action="campaigns:recipient:import",
|
||||||
|
)
|
||||||
|
profile_item = next(item for item in profile_items if item.source == "campaigns.import_mapping_profile")
|
||||||
|
self.assertEqual("independently_owned", profile_item.details["authorization_mode"])
|
||||||
|
self.assertTrue(any(item.kind == "owner" for item in profile_items))
|
||||||
|
self.assertNotIn("Secret A", json.dumps(profile_item.details))
|
||||||
|
self.assertNotIn("Hidden profile name", json.dumps(profile_item.to_dict()))
|
||||||
|
|
||||||
|
for resource_type, optional_module in (
|
||||||
|
("campaign_template", "templates"),
|
||||||
|
("campaign_template_revision", "templates"),
|
||||||
|
("campaign_export_package", "reporting"),
|
||||||
|
):
|
||||||
|
unavailable = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type=resource_type,
|
||||||
|
resource_id="opaque-optional-resource", action="campaigns:diagnostic:read",
|
||||||
|
)[0]
|
||||||
|
self.assertEqual("campaigns.not_found", unavailable.source)
|
||||||
|
self.assertEqual(optional_module, unavailable.details["optional_module"])
|
||||||
|
self.assertEqual("independently_governed_by_optional_module", unavailable.details["reason"])
|
||||||
|
self.assertEqual("unavailable_or_hidden", unavailable.details["evidence_availability"])
|
||||||
|
|
||||||
|
unsupported = service.explain_resource_provenance(
|
||||||
|
session, principal, resource_type="campaign_protocol_artifact",
|
||||||
|
resource_id=f"{version.id}:unsupported", action="campaigns:diagnostic:read",
|
||||||
|
)
|
||||||
|
self.assertEqual("unsupported_artifact_kind", unsupported[0].details["reason"])
|
||||||
|
cross_tenant = service.explain_resource_provenance(
|
||||||
|
session, _principal(tenant_id="tenant-2"),
|
||||||
|
resource_type="campaign_import_mapping_profile", resource_id=profile.id,
|
||||||
|
action="campaigns:recipient:import",
|
||||||
|
)
|
||||||
|
self.assertEqual("campaigns.not_found", cross_tenant[0].source)
|
||||||
|
|
||||||
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
|
def test_campaign_ownership_provider_requires_group_acceptance_authority(self) -> None:
|
||||||
session = _session()
|
session = _session()
|
||||||
self.addCleanup(_close_session, session)
|
self.addCleanup(_close_session, session)
|
||||||
@@ -396,8 +974,20 @@ def _session():
|
|||||||
Group.__table__,
|
Group.__table__,
|
||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
|
CampaignCollaborationEntry.__table__,
|
||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
CampaignJob.__table__,
|
CampaignJob.__table__,
|
||||||
|
CampaignIssue.__table__,
|
||||||
|
AttachmentBlob.__table__,
|
||||||
|
AttachmentInstance.__table__,
|
||||||
|
SendAttempt.__table__,
|
||||||
|
CampaignMessageAction.__table__,
|
||||||
|
CampaignMessageActionAttempt.__table__,
|
||||||
|
ImapAppendAttempt.__table__,
|
||||||
|
PostboxDeliveryAttempt.__table__,
|
||||||
|
PrintOutputAttempt.__table__,
|
||||||
|
RecipientImportMappingProfile.__table__,
|
||||||
|
OwnershipTransfer.__table__,
|
||||||
ChangeSequenceEntry.__table__,
|
ChangeSequenceEntry.__table__,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -421,11 +1011,18 @@ def _seed_access_subjects(session) -> None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
def _principal(*, scopes: set[str] | None = None, group_ids: set[str] | None = None) -> PrincipalRef:
|
def _principal(
|
||||||
|
*,
|
||||||
|
scopes: set[str] | None = None,
|
||||||
|
group_ids: set[str] | None = None,
|
||||||
|
tenant_id: str = TENANT_ID,
|
||||||
|
) -> PrincipalRef:
|
||||||
return PrincipalRef(
|
return PrincipalRef(
|
||||||
account_id="account-1",
|
account_id="account-1",
|
||||||
membership_id=USER_ID,
|
membership_id=USER_ID,
|
||||||
tenant_id=TENANT_ID,
|
tenant_id=tenant_id,
|
||||||
scopes=frozenset(scopes or {"campaigns:campaign:read"}),
|
scopes=frozenset(
|
||||||
|
{"campaigns:campaign:read"} if scopes is None else scopes
|
||||||
|
),
|
||||||
group_ids=frozenset(group_ids or set()),
|
group_ids=frozenset(group_ids or set()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -240,6 +240,7 @@ def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
|
|||||||
def test_competing_node_cannot_acquire_cleanup_authority(
|
def test_competing_node_cannot_acquire_cleanup_authority(
|
||||||
recovery_session_factory,
|
recovery_session_factory,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
lease_observed_at = datetime.now(timezone.utc)
|
||||||
with recovery_session_factory() as session:
|
with recovery_session_factory() as session:
|
||||||
claim = acquire_lease(
|
claim = acquire_lease(
|
||||||
session,
|
session,
|
||||||
@@ -248,7 +249,7 @@ def test_competing_node_cannot_acquire_cleanup_authority(
|
|||||||
holder_node_id="node-other",
|
holder_node_id="node-other",
|
||||||
holder_incarnation="run-other",
|
holder_incarnation="run-other",
|
||||||
ttl_seconds=900,
|
ttl_seconds=900,
|
||||||
now=NOW,
|
now=lease_observed_at,
|
||||||
)
|
)
|
||||||
assert claim is not None
|
assert claim is not None
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import zipfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||||
|
from govoplan_campaign.backend.campaign.loader import validate_against_schema
|
||||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||||
|
|
||||||
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
|||||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _attachment_reuse_config(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
allow_within: str = "none",
|
||||||
|
recipient_emails: tuple[str, ...] = (
|
||||||
|
"first@example.org",
|
||||||
|
"second@example.org",
|
||||||
|
),
|
||||||
|
duplicate_rules: bool = False,
|
||||||
|
) -> CampaignConfig:
|
||||||
|
rules = [{
|
||||||
|
"id": "shared-file",
|
||||||
|
"base_dir": "documents",
|
||||||
|
"file_filter": "shared.pdf",
|
||||||
|
"required": True,
|
||||||
|
}]
|
||||||
|
if duplicate_rules:
|
||||||
|
rules.append({**rules[0], "id": "shared-file-again"})
|
||||||
|
return CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {
|
||||||
|
"id": f"reuse-{action}-{allow_within}",
|
||||||
|
"name": "Attachment reuse",
|
||||||
|
"mode": "test",
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"profile_capabilities": {"smtp_available": True},
|
||||||
|
},
|
||||||
|
"recipients": {
|
||||||
|
"from": {"email": "sender@example.org", "type": "to"},
|
||||||
|
"allow_individual_to": True,
|
||||||
|
},
|
||||||
|
"template": {"subject": "Subject", "text": "Body"},
|
||||||
|
"attachments": {
|
||||||
|
"global": rules,
|
||||||
|
"reuse_policy": {
|
||||||
|
"action": action,
|
||||||
|
"allow_within": allow_within,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": f"recipient-{index}",
|
||||||
|
"to": [{"email": email, "type": "to"}],
|
||||||
|
}
|
||||||
|
for index, email in enumerate(recipient_emails, start=1)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"validation_policy": {
|
||||||
|
"missing_email": "block",
|
||||||
|
"template_error": "block",
|
||||||
|
},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
})
|
||||||
|
|
||||||
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
|
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp)
|
root = Path(tmp)
|
||||||
@@ -275,6 +334,264 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
|||||||
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||||
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||||
|
|
||||||
|
def test_attachment_reuse_action_controls_message_validation(self) -> None:
|
||||||
|
expected = {
|
||||||
|
"allow": ("ready", None, 0, 1),
|
||||||
|
"warn": ("warning", "warn", 1, 0),
|
||||||
|
"review": ("needs_review", "ask", 1, 0),
|
||||||
|
"block": ("blocked", "block", 1, 0),
|
||||||
|
}
|
||||||
|
for action, (status, behavior, violation_count, allowed_count) in expected.items():
|
||||||
|
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
documents = root / "documents"
|
||||||
|
documents.mkdir()
|
||||||
|
(documents / "shared.pdf").write_bytes(b"shared")
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = self._attachment_reuse_config(action=action)
|
||||||
|
|
||||||
|
result = build_campaign_messages(
|
||||||
|
config,
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
output_dir=root / "out",
|
||||||
|
write_eml=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[status, status],
|
||||||
|
[message.validation_status.value for message in result.report.messages],
|
||||||
|
)
|
||||||
|
report = result.report.attachment_reuse
|
||||||
|
self.assertEqual(1, report["duplicate_file_count"])
|
||||||
|
self.assertEqual(violation_count, report["violation_file_count"])
|
||||||
|
self.assertEqual(allowed_count, report["allowed_file_count"])
|
||||||
|
finding = report["findings"][0]
|
||||||
|
self.assertEqual("shared.pdf", finding["file_name"])
|
||||||
|
self.assertNotIn(str(root), str(report))
|
||||||
|
issues = [
|
||||||
|
issue
|
||||||
|
for message in result.report.messages
|
||||||
|
for issue in message.issues
|
||||||
|
if issue.code == "duplicate_attachment_reuse"
|
||||||
|
]
|
||||||
|
if behavior is None:
|
||||||
|
self.assertEqual([], issues)
|
||||||
|
else:
|
||||||
|
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
|
||||||
|
self.assertEqual(
|
||||||
|
{"action": action, "allow_within": "none"},
|
||||||
|
issues[0].details["policy"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
|
||||||
|
cases = (
|
||||||
|
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
|
||||||
|
("same_message", ("same@example.org",), True, 2),
|
||||||
|
)
|
||||||
|
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
|
||||||
|
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
documents = root / "documents"
|
||||||
|
documents.mkdir()
|
||||||
|
(documents / "shared.pdf").write_bytes(b"shared")
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
|
||||||
|
result = build_campaign_messages(
|
||||||
|
self._attachment_reuse_config(
|
||||||
|
action="block",
|
||||||
|
allow_within=allow_within,
|
||||||
|
recipient_emails=recipients,
|
||||||
|
duplicate_rules=duplicate_rules,
|
||||||
|
),
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
output_dir=root / "out",
|
||||||
|
write_eml=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
["ready"] * len(recipients),
|
||||||
|
[message.validation_status.value for message in result.report.messages],
|
||||||
|
)
|
||||||
|
report = result.report.attachment_reuse
|
||||||
|
self.assertEqual(1, report["allowed_file_count"])
|
||||||
|
self.assertEqual(0, report["violation_file_count"])
|
||||||
|
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
|
||||||
|
|
||||||
|
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
|
||||||
|
config = self._attachment_reuse_config(
|
||||||
|
action="review",
|
||||||
|
allow_within="same_recipient",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = config.model_dump(
|
||||||
|
mode="json",
|
||||||
|
by_alias=True,
|
||||||
|
exclude_none=True,
|
||||||
|
exclude_defaults=True,
|
||||||
|
)
|
||||||
|
payload["server"].pop("profile_capabilities", None)
|
||||||
|
|
||||||
|
validate_against_schema(payload)
|
||||||
|
|
||||||
|
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
|
||||||
|
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
|
||||||
|
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
watched = root / "watched"
|
||||||
|
watched.mkdir()
|
||||||
|
(watched / "assigned.txt").write_text("assigned", encoding="utf-8")
|
||||||
|
(watched / "residual.txt").write_text("residual", encoding="utf-8")
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": f"residual-{mode}", "name": "Monthly import", "mode": "test"},
|
||||||
|
"fields": [],
|
||||||
|
"global_values": {},
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"profile_capabilities": {"smtp_available": True},
|
||||||
|
},
|
||||||
|
"recipients": {
|
||||||
|
"from": {"email": "sender@example.org", "type": "to"},
|
||||||
|
"allow_individual_to": True,
|
||||||
|
},
|
||||||
|
"template": {"subject": "Normal message", "text": "Normal body"},
|
||||||
|
"attachments": {
|
||||||
|
"base_paths": [{
|
||||||
|
"id": "watched",
|
||||||
|
"name": "Watched folder",
|
||||||
|
"path": "watched",
|
||||||
|
"unsent_warning": True,
|
||||||
|
}],
|
||||||
|
"global": [{
|
||||||
|
"id": "assigned",
|
||||||
|
"base_path_id": "watched",
|
||||||
|
"base_dir": "watched",
|
||||||
|
"file_filter": "assigned.txt",
|
||||||
|
"required": True,
|
||||||
|
}],
|
||||||
|
"residual_files": {
|
||||||
|
"mode": mode,
|
||||||
|
"recipient": {"email": "operator@example.org", "name": "Operator"},
|
||||||
|
"subject": "Residual files for {{local:campaign_name}}",
|
||||||
|
"text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"entries": {"inline": [{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||||
|
}]},
|
||||||
|
"validation_policy": {
|
||||||
|
"missing_email": "block",
|
||||||
|
"template_error": "block",
|
||||||
|
"unsent_attachment_files": "block",
|
||||||
|
},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = build_campaign_messages(
|
||||||
|
config,
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
output_dir=root / "out",
|
||||||
|
write_eml=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(len(result.report.messages), 2)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"contract_version": "1",
|
||||||
|
"action": "route_report" if mode == "report" else "route_with_files",
|
||||||
|
"routing_mode": mode,
|
||||||
|
"validation_behavior": "block",
|
||||||
|
"watched_source_count": 1,
|
||||||
|
"residual_file_count": 1,
|
||||||
|
"recipient": {
|
||||||
|
"email": "operator@example.org",
|
||||||
|
"name": "Operator",
|
||||||
|
"type": "to",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
result.report.residual_file_disposition,
|
||||||
|
)
|
||||||
|
normal, residual = result.report.messages
|
||||||
|
self.assertEqual(normal.validation_status.value, "ready")
|
||||||
|
self.assertEqual(residual.entry_id, "__residual_files__")
|
||||||
|
self.assertEqual(residual.validation_status.value, "needs_review")
|
||||||
|
self.assertEqual(residual.to[0].email, "operator@example.org")
|
||||||
|
self.assertEqual(residual.subject, "Residual files for Monthly import")
|
||||||
|
self.assertEqual(residual.attachment_count, expected_attachment_count)
|
||||||
|
self.assertIn(
|
||||||
|
"residual_attachment_disposition",
|
||||||
|
{issue.code for issue in residual.issues},
|
||||||
|
)
|
||||||
|
self.assertNotIn(
|
||||||
|
"unsent_attachment_files",
|
||||||
|
{issue.code for message in result.report.messages for issue in message.issues},
|
||||||
|
)
|
||||||
|
mime = result.built_messages[1].mime
|
||||||
|
self.assertIsNotNone(mime)
|
||||||
|
self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content())
|
||||||
|
filenames = [part.get_filename() for part in mime.iter_attachments()]
|
||||||
|
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
|
||||||
|
|
||||||
|
def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None:
|
||||||
|
for behavior, action in (("block", "block"), ("continue", "ignore")):
|
||||||
|
with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
watched = root / "watched"
|
||||||
|
watched.mkdir()
|
||||||
|
(watched / "residual.txt").write_text("residual", encoding="utf-8")
|
||||||
|
campaign_file = root / "campaign.json"
|
||||||
|
campaign_file.write_text("{}", encoding="utf-8")
|
||||||
|
config = CampaignConfig.model_validate({
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": f"residual-{behavior}", "name": "Residual", "mode": "test"},
|
||||||
|
"fields": [],
|
||||||
|
"global_values": {},
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"profile_capabilities": {"smtp_available": True},
|
||||||
|
},
|
||||||
|
"recipients": {
|
||||||
|
"from": {"email": "sender@example.org", "type": "to"},
|
||||||
|
"allow_individual_to": True,
|
||||||
|
},
|
||||||
|
"template": {"subject": "Normal", "text": "Body"},
|
||||||
|
"attachments": {
|
||||||
|
"base_paths": [{
|
||||||
|
"id": "watched",
|
||||||
|
"name": "Watched folder",
|
||||||
|
"path": "watched",
|
||||||
|
"unsent_warning": True,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
"entries": {"inline": [{
|
||||||
|
"id": "recipient-1",
|
||||||
|
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||||
|
}]},
|
||||||
|
"validation_policy": {"unsent_attachment_files": behavior},
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = build_campaign_messages(
|
||||||
|
config,
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
output_dir=root / "out",
|
||||||
|
write_eml=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(action, result.report.residual_file_disposition["action"])
|
||||||
|
self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"])
|
||||||
|
issue_codes = {
|
||||||
|
issue.code
|
||||||
|
for message in result.report.messages
|
||||||
|
for issue in message.issues
|
||||||
|
}
|
||||||
|
self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ from govoplan_campaign.backend.persistence.campaigns import (
|
|||||||
_verify_build_storage_manifest,
|
_verify_build_storage_manifest,
|
||||||
_verify_storage_keys_absent,
|
_verify_storage_keys_absent,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan
|
from govoplan_campaign.backend.routes.versions import (
|
||||||
|
_attachment_reuse_audit_evidence,
|
||||||
|
_campaign_build_recovery_plan,
|
||||||
|
_residual_file_audit_evidence,
|
||||||
|
_review_decision_audit_evidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
|
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
|
||||||
@@ -34,6 +39,68 @@ def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() ->
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_residual_file_audit_evidence_omits_recipient_identity() -> None:
|
||||||
|
assert _residual_file_audit_evidence({
|
||||||
|
"contract_version": "1",
|
||||||
|
"action": "route_report",
|
||||||
|
"routing_mode": "report",
|
||||||
|
"validation_behavior": "warn",
|
||||||
|
"watched_source_count": 2,
|
||||||
|
"residual_file_count": 3,
|
||||||
|
"recipient": {"email": "operator@example.org"},
|
||||||
|
}) == {
|
||||||
|
"contract_version": "1",
|
||||||
|
"action": "route_report",
|
||||||
|
"routing_mode": "report",
|
||||||
|
"validation_behavior": "warn",
|
||||||
|
"watched_source_count": 2,
|
||||||
|
"residual_file_count": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_reuse_audit_evidence_keeps_policy_but_not_file_names() -> None:
|
||||||
|
assert _attachment_reuse_audit_evidence({
|
||||||
|
"contract_version": "1",
|
||||||
|
"policy": {"action": "review", "allow_within": "same_recipient"},
|
||||||
|
"duplicate_file_count": 4,
|
||||||
|
"allowed_file_count": 1,
|
||||||
|
"violation_file_count": 3,
|
||||||
|
"affected_message_count": 5,
|
||||||
|
"findings": [{"file_name": "personal.pdf"}],
|
||||||
|
}) == {
|
||||||
|
"contract_version": "1",
|
||||||
|
"policy": {"action": "review", "allow_within": "same_recipient"},
|
||||||
|
"duplicate_file_count": 4,
|
||||||
|
"allowed_file_count": 1,
|
||||||
|
"violation_file_count": 3,
|
||||||
|
"affected_message_count": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decision_audit_evidence_is_aggregate_and_integrity_sealed() -> None:
|
||||||
|
version = type("Version", (), {
|
||||||
|
"editor_state": {
|
||||||
|
"review_send": {
|
||||||
|
"issue_decisions": [{
|
||||||
|
"decision": "accept",
|
||||||
|
"reason": "Approved exception",
|
||||||
|
"issue_codes": ["duplicate_attachment_reuse"],
|
||||||
|
"issue_fingerprint": "f" * 64,
|
||||||
|
"message_sha256": "m" * 64,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
evidence = _review_decision_audit_evidence(version)
|
||||||
|
|
||||||
|
assert evidence["count"] == 1
|
||||||
|
assert evidence["with_reason_count"] == 1
|
||||||
|
assert evidence["issue_codes"] == ["duplicate_attachment_reuse"]
|
||||||
|
assert len(str(evidence["evidence_sha256"])) == 64
|
||||||
|
assert "Approved exception" not in str(evidence)
|
||||||
|
|
||||||
|
|
||||||
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
|
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
|
||||||
storage = LocalFilesystemStorageBackend(tmp_path)
|
storage = LocalFilesystemStorageBackend(tmp_path)
|
||||||
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
|
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic.migration import MigrationContext
|
||||||
|
from alembic.operations import Operations
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
from govoplan_access.backend.db.models import Account, Group, User
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignCollaborationEntry,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.routes.collaboration import (
|
||||||
|
create_campaign_collaboration_entry,
|
||||||
|
list_campaign_collaboration,
|
||||||
|
redact_campaign_collaboration_entry,
|
||||||
|
withdraw_campaign_collaboration_entry,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import (
|
||||||
|
CampaignCollaborationCreateRequest,
|
||||||
|
CampaignCollaborationModerationRequest,
|
||||||
|
CampaignCollaborationReferenceInput,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.access import GroupRef, UserRef
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
|
TENANT_ID = "tenant-1"
|
||||||
|
|
||||||
|
|
||||||
|
class _Principal:
|
||||||
|
tenant_id = TENANT_ID
|
||||||
|
api_key = None
|
||||||
|
|
||||||
|
def __init__(self, user_id: str, *scopes: str) -> None:
|
||||||
|
self.user = SimpleNamespace(
|
||||||
|
id=user_id,
|
||||||
|
display_name=f"User {user_id}",
|
||||||
|
email=f"{user_id}@example.test",
|
||||||
|
)
|
||||||
|
self.scopes = frozenset(scopes)
|
||||||
|
|
||||||
|
def has(self, scope: str) -> bool:
|
||||||
|
return scope in self.scopes or "tenant:*" in self.scopes
|
||||||
|
|
||||||
|
|
||||||
|
class _Directory:
|
||||||
|
users = (
|
||||||
|
UserRef(id="user-1", account_id="account-1", tenant_id=TENANT_ID, display_name="Author"),
|
||||||
|
UserRef(id="user-2", account_id="account-2", tenant_id=TENANT_ID, display_name="Collaborator"),
|
||||||
|
UserRef(id="user-3", account_id="account-3", tenant_id=TENANT_ID, display_name="Unrelated"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def users_for_tenant(self, tenant_id: str):
|
||||||
|
return self.users if tenant_id == TENANT_ID else ()
|
||||||
|
|
||||||
|
def groups_for_user(self, user_id: str, *, tenant_id: str):
|
||||||
|
if tenant_id == TENANT_ID and user_id == "user-2":
|
||||||
|
return (GroupRef(id="group-1", tenant_id=TENANT_ID, name="Collaborators"),)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
class _Notifications:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def enqueue_notification(self, _session, request, *, enqueue_delivery: bool = True):
|
||||||
|
self.requests.append((request, enqueue_delivery))
|
||||||
|
return {"id": f"notification-{len(self.requests)}"}
|
||||||
|
|
||||||
|
|
||||||
|
class _UnavailableNotifications:
|
||||||
|
def enqueue_notification(self, _session, _request, *, enqueue_delivery: bool = True):
|
||||||
|
raise RuntimeError("Notifications is temporarily unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def session() -> Session:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
engine,
|
||||||
|
tables=[
|
||||||
|
Account.__table__,
|
||||||
|
User.__table__,
|
||||||
|
Group.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
|
CampaignShare.__table__,
|
||||||
|
CampaignCollaborationEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
|
||||||
|
database = session_factory()
|
||||||
|
database.add_all(
|
||||||
|
[
|
||||||
|
Account(id=f"account-{number}", email=f"user-{number}@example.test", normalized_email=f"user-{number}@example.test")
|
||||||
|
for number in range(1, 4)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database.flush()
|
||||||
|
database.add_all(
|
||||||
|
[
|
||||||
|
User(id=f"user-{number}", tenant_id=TENANT_ID, account_id=f"account-{number}", email=f"user-{number}@example.test")
|
||||||
|
for number in range(1, 4)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database.add(Group(id="group-1", tenant_id=TENANT_ID, slug="collaborators", name="Collaborators"))
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
owner_user_id="user-1",
|
||||||
|
external_id="campaign-1",
|
||||||
|
name="Campaign One",
|
||||||
|
current_version_id="version-1",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json={
|
||||||
|
"version": "1.0",
|
||||||
|
"entries": {"imports": [{"id": "import-1", "source_type": "csv"}]},
|
||||||
|
"attachments": {"global": [{"label": "Notice"}]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
database.add_all(
|
||||||
|
[
|
||||||
|
campaign,
|
||||||
|
version,
|
||||||
|
CampaignShare(
|
||||||
|
id="share-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
target_type="group",
|
||||||
|
target_id="group-1",
|
||||||
|
permission="read",
|
||||||
|
),
|
||||||
|
CampaignJob(
|
||||||
|
id="job-1",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
campaign_version_id=version.id,
|
||||||
|
entry_index=0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
try:
|
||||||
|
yield database
|
||||||
|
finally:
|
||||||
|
database.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(user_id: str = "user-1", *, moderate: bool = False) -> _Principal:
|
||||||
|
scopes = [
|
||||||
|
"campaigns:campaign:read",
|
||||||
|
"campaigns:discussion:read",
|
||||||
|
"campaigns:discussion:post",
|
||||||
|
]
|
||||||
|
if moderate:
|
||||||
|
scopes.append("campaigns:discussion:moderate")
|
||||||
|
return _Principal(user_id, *scopes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_is_append_only_references_a_version_and_notifies_authorized_mentions(session: Session) -> None:
|
||||||
|
notifications = _Notifications()
|
||||||
|
original_version = dict(session.get(CampaignVersion, "version-1").raw_json)
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=notifications),
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit) as audit,
|
||||||
|
):
|
||||||
|
response = create_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignCollaborationCreateRequest(
|
||||||
|
content="Please review the frozen version.",
|
||||||
|
mention_user_ids=["user-2", "user-1", "user-2"],
|
||||||
|
reference=CampaignCollaborationReferenceInput(
|
||||||
|
kind="campaign_version",
|
||||||
|
id="version-1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_principal(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.content == "Please review the frozen version."
|
||||||
|
assert response.reference is not None
|
||||||
|
assert response.reference.label == "Version 1"
|
||||||
|
assert response.mention_user_ids == ["user-2"]
|
||||||
|
assert session.get(CampaignVersion, "version-1").raw_json == original_version
|
||||||
|
assert len(notifications.requests) == 1
|
||||||
|
notification, enqueue_delivery = notifications.requests[0]
|
||||||
|
assert notification.recipient_id == "user-2"
|
||||||
|
assert notification.payload["content_disclosed"] is False
|
||||||
|
assert enqueue_delivery is False
|
||||||
|
details = audit.call_args.kwargs["details"]
|
||||||
|
assert details["content_disclosed"] is False
|
||||||
|
assert "Please review" not in repr(details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mentions_reject_users_without_campaign_access(session: Session) -> None:
|
||||||
|
with patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()):
|
||||||
|
with pytest.raises(HTTPException) as raised:
|
||||||
|
create_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignCollaborationCreateRequest(content="No leak", mention_user_ids=["user-3"]),
|
||||||
|
session,
|
||||||
|
_principal(),
|
||||||
|
)
|
||||||
|
assert raised.value.status_code == 422
|
||||||
|
assert session.query(CampaignCollaborationEntry).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_notification_failure_does_not_block_or_roll_back_collaboration(session: Session) -> None:
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration._access_directory", return_value=_Directory()),
|
||||||
|
patch(
|
||||||
|
"govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider",
|
||||||
|
return_value=_UnavailableNotifications(),
|
||||||
|
),
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
response = create_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignCollaborationCreateRequest(
|
||||||
|
content="This discussion entry must survive an optional integration outage.",
|
||||||
|
mention_user_ids=["user-2"],
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_principal(),
|
||||||
|
)
|
||||||
|
|
||||||
|
stored = session.get(CampaignCollaborationEntry, response.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored.content == response.content
|
||||||
|
assert stored.mention_user_ids == ["user-2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collaboration_migration_is_repeatable_and_creates_thread_index() -> None:
|
||||||
|
migration = importlib.import_module(
|
||||||
|
"govoplan_campaign.backend.migrations.versions."
|
||||||
|
"c7d8e9f0a1b2_v0120_campaign_collaboration"
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
with engine.begin() as connection:
|
||||||
|
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
|
||||||
|
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"CREATE TABLE campaign_versions ("
|
||||||
|
"id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context = MigrationContext.configure(connection)
|
||||||
|
with patch.object(migration, "op", Operations(context)):
|
||||||
|
migration.upgrade()
|
||||||
|
migration.upgrade()
|
||||||
|
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns = {
|
||||||
|
column["name"]
|
||||||
|
for column in inspector.get_columns("campaign_collaboration_entries")
|
||||||
|
}
|
||||||
|
indexes = {
|
||||||
|
index["name"]
|
||||||
|
for index in inspector.get_indexes("campaign_collaboration_entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert {
|
||||||
|
"campaign_id",
|
||||||
|
"campaign_version_id",
|
||||||
|
"content_sha256",
|
||||||
|
"mention_user_ids",
|
||||||
|
"withdrawn_at",
|
||||||
|
"redacted_at",
|
||||||
|
}.issubset(columns)
|
||||||
|
assert "ix_campaign_collaboration_entries_thread" in indexes
|
||||||
|
|
||||||
|
with patch.object(migration, "op", Operations(context)):
|
||||||
|
migration.downgrade()
|
||||||
|
assert not inspect(connection).has_table("campaign_collaboration_entries")
|
||||||
|
|
||||||
|
|
||||||
|
def test_visibility_pagination_withdrawal_and_redaction_leave_tombstones(session: Session) -> None:
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
public_entry = create_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignCollaborationCreateRequest(content="Visible discussion"),
|
||||||
|
session,
|
||||||
|
_principal(),
|
||||||
|
)
|
||||||
|
moderator_entry = create_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignCollaborationCreateRequest(content="Restricted discussion", visibility="moderators"),
|
||||||
|
session,
|
||||||
|
_principal(moderate=True),
|
||||||
|
)
|
||||||
|
ordinary = list_campaign_collaboration("campaign-1", 1, None, session, _principal())
|
||||||
|
moderated = list_campaign_collaboration("campaign-1", 1, None, session, _principal(moderate=True))
|
||||||
|
withdrawn = withdraw_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
public_entry.id,
|
||||||
|
CampaignCollaborationModerationRequest(reason="Posted in error"),
|
||||||
|
session,
|
||||||
|
_principal(),
|
||||||
|
)
|
||||||
|
redacted = redact_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
moderator_entry.id,
|
||||||
|
CampaignCollaborationModerationRequest(reason="Contains restricted material"),
|
||||||
|
session,
|
||||||
|
_principal(moderate=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [entry.id for entry in ordinary.items] == [public_entry.id]
|
||||||
|
assert ordinary.has_more is False
|
||||||
|
assert [entry.id for entry in moderated.items] == [moderator_entry.id]
|
||||||
|
assert moderated.has_more is True
|
||||||
|
assert moderated.next_cursor
|
||||||
|
second_page = list_campaign_collaboration(
|
||||||
|
"campaign-1",
|
||||||
|
1,
|
||||||
|
moderated.next_cursor,
|
||||||
|
session,
|
||||||
|
_principal(moderate=True),
|
||||||
|
)
|
||||||
|
assert [entry.id for entry in second_page.items] == [public_entry.id]
|
||||||
|
assert withdrawn.tombstone == "withdrawn"
|
||||||
|
assert withdrawn.content is None
|
||||||
|
assert redacted.tombstone == "redacted"
|
||||||
|
assert redacted.content is None
|
||||||
|
assert session.get(CampaignCollaborationEntry, public_entry.id).content is None
|
||||||
|
assert session.get(CampaignCollaborationEntry, redacted.id).content_sha256 == redacted.content_sha256
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("kind", "reference_id"),
|
||||||
|
[
|
||||||
|
("recipient_import_batch", "version-1:import-1"),
|
||||||
|
("attachment_rule", "version-1:attachments.global[0]"),
|
||||||
|
("delivery_job", "job-1"),
|
||||||
|
("report", "campaign-1:version-1:delivery"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_supported_reference_contexts_are_validated(session: Session, kind: str, reference_id: str) -> None:
|
||||||
|
with (
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.notification_dispatch_provider", return_value=None),
|
||||||
|
patch("govoplan_campaign.backend.routes.collaboration.audit_from_principal", side_effect=_commit_audit),
|
||||||
|
):
|
||||||
|
response = create_campaign_collaboration_entry(
|
||||||
|
"campaign-1",
|
||||||
|
CampaignCollaborationCreateRequest(
|
||||||
|
content="Reference context",
|
||||||
|
reference=CampaignCollaborationReferenceInput(kind=kind, id=reference_id), # type: ignore[arg-type]
|
||||||
|
),
|
||||||
|
session,
|
||||||
|
_principal(),
|
||||||
|
)
|
||||||
|
assert response.reference is not None
|
||||||
|
assert response.reference.kind == kind
|
||||||
|
assert response.reference.id == reference_id
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration
|
||||||
|
from govoplan_campaign.backend.routes.campaigns import (
|
||||||
|
_campaign_content_library_item,
|
||||||
|
_campaign_content_required_fields,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest
|
||||||
|
from govoplan_core.core.templates import (
|
||||||
|
TemplateContentDraftRequest,
|
||||||
|
TemplateRef,
|
||||||
|
TemplateRevisionRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Catalog:
|
||||||
|
def __init__(self, template: TemplateRef) -> None:
|
||||||
|
self.template = template
|
||||||
|
self.calls: list[dict[str, object]] = []
|
||||||
|
|
||||||
|
def list_templates(self, session, principal, **kwargs):
|
||||||
|
del session, principal
|
||||||
|
self.calls.append(kwargs)
|
||||||
|
return (self.template,)
|
||||||
|
|
||||||
|
def get_template(self, session, principal, **kwargs):
|
||||||
|
del session, principal, kwargs
|
||||||
|
return self.template
|
||||||
|
|
||||||
|
def check_compatibility(self, session, principal, **kwargs):
|
||||||
|
del session, principal, kwargs
|
||||||
|
raise AssertionError("Compatibility is not needed for raw reusable content.")
|
||||||
|
|
||||||
|
|
||||||
|
class _ContentLibrary:
|
||||||
|
def __init__(self, template: TemplateRef) -> None:
|
||||||
|
self.template = template
|
||||||
|
self.request: TemplateContentDraftRequest | None = None
|
||||||
|
|
||||||
|
def create_content_draft(self, session, principal, *, request):
|
||||||
|
del session, principal
|
||||||
|
self.request = request
|
||||||
|
return self.template
|
||||||
|
|
||||||
|
|
||||||
|
def _template_ref() -> TemplateRef:
|
||||||
|
revision = TemplateRevisionRef(
|
||||||
|
id="revision-1",
|
||||||
|
template_id="template-1",
|
||||||
|
revision=3,
|
||||||
|
definition_hash="a" * 64,
|
||||||
|
template_type="content_fragment",
|
||||||
|
usages=("campaign.content",),
|
||||||
|
locale="de",
|
||||||
|
required_fields=(),
|
||||||
|
output_profiles=(),
|
||||||
|
content_text="Mit freundlichen Grussen",
|
||||||
|
metadata={
|
||||||
|
"campaign_kind": "fragment",
|
||||||
|
"campaign_targets": ["subject"],
|
||||||
|
},
|
||||||
|
published_at=datetime.now(tz=UTC),
|
||||||
|
)
|
||||||
|
return TemplateRef(
|
||||||
|
id="template-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
name="Closing",
|
||||||
|
slug="closing",
|
||||||
|
template_type="content_fragment",
|
||||||
|
status="published",
|
||||||
|
current_revision=3,
|
||||||
|
current_revision_id=revision.id,
|
||||||
|
published_revision_id=revision.id,
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_content_integration_filters_usage_and_delegates_draft_creation() -> None:
|
||||||
|
template = _template_ref()
|
||||||
|
catalog = _Catalog(template)
|
||||||
|
writer = _ContentLibrary(template)
|
||||||
|
integration = TemplatesCampaignIntegration(catalog, None, writer)
|
||||||
|
|
||||||
|
listed = integration.list_content_templates(object(), object(), query="close")
|
||||||
|
request = TemplateContentDraftRequest(
|
||||||
|
name="Closing",
|
||||||
|
template_type="content_fragment",
|
||||||
|
usages=("campaign.content",),
|
||||||
|
content_text="Regards",
|
||||||
|
)
|
||||||
|
created = integration.create_content_draft(
|
||||||
|
object(),
|
||||||
|
object(),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert listed == (template,)
|
||||||
|
assert catalog.calls == [
|
||||||
|
{"query": "close", "usage": "campaign.content", "limit": 100}
|
||||||
|
]
|
||||||
|
assert writer.request is request
|
||||||
|
assert created is template
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_content_payload_keeps_revision_and_declared_target() -> None:
|
||||||
|
payload = _campaign_content_library_item(_template_ref())
|
||||||
|
|
||||||
|
assert payload["kind"] == "fragment"
|
||||||
|
assert payload["targets"] == ["subject"]
|
||||||
|
assert payload["text"] == "Mit freundlichen Grussen"
|
||||||
|
assert payload["published"] is True
|
||||||
|
assert payload["revision"] == 3
|
||||||
|
assert payload["required_fields"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_content_required_fields_are_normalized_and_deduplicated() -> None:
|
||||||
|
payload = CampaignContentLibrarySaveRequest(
|
||||||
|
name="Greeting",
|
||||||
|
kind="campaign_part",
|
||||||
|
subject="Hello {{ local:display_name }}",
|
||||||
|
text="Reference ${global.case_reference}; hello {{local::display_name}}",
|
||||||
|
html="<p>{{fields.salutation}} {{local:to[2].email}} {{unsupported namespace}}</p>",
|
||||||
|
)
|
||||||
|
|
||||||
|
requirements = _campaign_content_required_fields(payload)
|
||||||
|
|
||||||
|
assert [item.path for item in requirements] == [
|
||||||
|
"global.case_reference",
|
||||||
|
"local.display_name",
|
||||||
|
"local.to.2.email",
|
||||||
|
"salutation",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_content_save_request_rejects_empty_selected_fragment() -> None:
|
||||||
|
try:
|
||||||
|
CampaignContentLibrarySaveRequest(
|
||||||
|
name="Empty",
|
||||||
|
kind="fragment",
|
||||||
|
target="html",
|
||||||
|
text="Only text",
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "selected fragment field is empty" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected empty target validation to fail")
|
||||||
@@ -13,10 +13,12 @@ from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_poli
|
|||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignJob,
|
CampaignJob,
|
||||||
|
CampaignSchedule,
|
||||||
CampaignShare,
|
CampaignShare,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.routes.campaigns import (
|
from govoplan_campaign.backend.routes.campaigns import (
|
||||||
|
_campaign_copy_configuration,
|
||||||
archive_campaign_version,
|
archive_campaign_version,
|
||||||
copy_campaign,
|
copy_campaign,
|
||||||
delete_draft_campaign,
|
delete_draft_campaign,
|
||||||
@@ -26,6 +28,7 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
CampaignLifecycleMutationRequest,
|
CampaignLifecycleMutationRequest,
|
||||||
)
|
)
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
|
||||||
|
|
||||||
class _Principal:
|
class _Principal:
|
||||||
@@ -65,6 +68,8 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
CampaignVersion.__table__,
|
CampaignVersion.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
CampaignJob.__table__,
|
CampaignJob.__table__,
|
||||||
|
CampaignSchedule.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.SessionLocal = sessionmaker(
|
self.SessionLocal = sessionmaker(
|
||||||
@@ -73,7 +78,14 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
expire_on_commit=False,
|
expire_on_commit=False,
|
||||||
)
|
)
|
||||||
with self.SessionLocal() as session:
|
with self.SessionLocal() as session:
|
||||||
session.execute(access_users.insert().values(id="user-1"))
|
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",
|
||||||
|
)
|
||||||
|
session.execute(access_users.insert().values(**user_values))
|
||||||
campaign = Campaign(
|
campaign = Campaign(
|
||||||
id="campaign-1",
|
id="campaign-1",
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -153,6 +165,50 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
|
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
|
||||||
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
|
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
|
||||||
|
|
||||||
|
def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None:
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
session.add(
|
||||||
|
CampaignSchedule(
|
||||||
|
id="schedule-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id="campaign-1",
|
||||||
|
source_version_id="version-2",
|
||||||
|
created_by_user_id="user-1",
|
||||||
|
name="Recurring draft",
|
||||||
|
recurrence_kind="daily",
|
||||||
|
interval_count=1,
|
||||||
|
timezone="UTC",
|
||||||
|
starts_at=datetime(2026, 8, 8, tzinfo=UTC),
|
||||||
|
next_fire_at=datetime(2026, 8, 8, tzinfo=UTC),
|
||||||
|
max_occurrences=2,
|
||||||
|
copy_options={},
|
||||||
|
source_snapshot={"schema": "test"},
|
||||||
|
source_snapshot_hash="a" * 64,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
active_policy = self._policy(session)
|
||||||
|
self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"])
|
||||||
|
self.assertIn(
|
||||||
|
"Pause active",
|
||||||
|
active_policy["actions"]["archive_campaign"]["reason"],
|
||||||
|
)
|
||||||
|
self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"])
|
||||||
|
self.assertIn(
|
||||||
|
"schedule evidence",
|
||||||
|
active_policy["actions"]["delete_campaign"]["reason"],
|
||||||
|
)
|
||||||
|
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.active = False
|
||||||
|
schedule.resource_revision += 1
|
||||||
|
session.commit()
|
||||||
|
paused_policy = self._policy(session)
|
||||||
|
self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"])
|
||||||
|
self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"])
|
||||||
|
|
||||||
def test_stale_delete_token_is_rejected(self) -> None:
|
def test_stale_delete_token_is_rejected(self) -> None:
|
||||||
with self.SessionLocal() as session:
|
with self.SessionLocal() as session:
|
||||||
policy = self._policy(session)
|
policy = self._policy(session)
|
||||||
@@ -203,6 +259,10 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
|
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
|
||||||
with self.SessionLocal() as session:
|
with self.SessionLocal() as session:
|
||||||
|
source_campaign = session.get(Campaign, "campaign-1")
|
||||||
|
assert source_campaign is not None
|
||||||
|
source_campaign.settings = {"retention": "source-policy"}
|
||||||
|
source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"}
|
||||||
session.add_all(
|
session.add_all(
|
||||||
(
|
(
|
||||||
CampaignShare(
|
CampaignShare(
|
||||||
@@ -269,6 +329,8 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
CampaignCopyRequest(
|
CampaignCopyRequest(
|
||||||
source_version_id="version-2",
|
source_version_id="version-2",
|
||||||
expected_state_token=policy["state_token"],
|
expected_state_token=policy["state_token"],
|
||||||
|
include_policies=False,
|
||||||
|
include_mail_profile=True,
|
||||||
),
|
),
|
||||||
session=session,
|
session=session,
|
||||||
principal=self.principal,
|
principal=self.principal,
|
||||||
@@ -277,6 +339,13 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
|
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
|
||||||
self.assertEqual(response.campaign.owner_user_id, "user-1")
|
self.assertEqual(response.campaign.owner_user_id, "user-1")
|
||||||
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
|
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
|
||||||
|
copied_campaign = session.get(Campaign, "campaign-copy")
|
||||||
|
assert copied_campaign is not None
|
||||||
|
self.assertEqual({}, copied_campaign.settings)
|
||||||
|
self.assertEqual(
|
||||||
|
{"profile_id": "mail-profile-1"},
|
||||||
|
copied_campaign.mail_profile_policy,
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(CampaignJob.campaign_id == "campaign-copy")
|
.filter(CampaignJob.campaign_id == "campaign-copy")
|
||||||
@@ -290,6 +359,64 @@ class CampaignLifecycleTests(unittest.TestCase):
|
|||||||
0,
|
0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_copy_choices_reset_only_selected_configuration_domains(self) -> None:
|
||||||
|
source = {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "campaign-1", "name": "Campaign"},
|
||||||
|
"fields": [{"name": "case_id"}],
|
||||||
|
"global_values": {"sender": "Office"},
|
||||||
|
"recipients": {"to": [{"email": "team@example.test"}]},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "one",
|
||||||
|
"to": [{"email": "person@example.test"}],
|
||||||
|
"attachments": [{"base_dir": ".", "file_filter": "one.pdf"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"template": {"subject": "Hello", "text": "Body"},
|
||||||
|
"attachments": {
|
||||||
|
"global": [{"base_dir": ".", "file_filter": "global.pdf"}]
|
||||||
|
},
|
||||||
|
"validation_policy": {"missing_required_attachment": "warn"},
|
||||||
|
"server": {"mail_profile_id": "profile-1"},
|
||||||
|
"delivery": {"rate_limit": {"messages_per_minute": 20}},
|
||||||
|
}
|
||||||
|
|
||||||
|
copied = _campaign_copy_configuration(
|
||||||
|
source,
|
||||||
|
CampaignCopyRequest(
|
||||||
|
source_version_id="version-2",
|
||||||
|
expected_state_token="a" * 64,
|
||||||
|
include_recipients=True,
|
||||||
|
include_files=False,
|
||||||
|
include_policies=False,
|
||||||
|
include_mail_profile=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf")
|
||||||
|
self.assertEqual({}, copied["attachments"])
|
||||||
|
self.assertEqual([], copied["entries"]["inline"][0]["attachments"])
|
||||||
|
self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"])
|
||||||
|
self.assertEqual({}, copied["validation_policy"])
|
||||||
|
self.assertEqual({}, copied["server"])
|
||||||
|
self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"])
|
||||||
|
|
||||||
|
def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None:
|
||||||
|
principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy")
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
campaign = session.get(Campaign, "campaign-1")
|
||||||
|
assert campaign is not None
|
||||||
|
policy = campaign_lifecycle_policy(
|
||||||
|
session,
|
||||||
|
campaign=campaign,
|
||||||
|
principal=principal,
|
||||||
|
version_id="version-2",
|
||||||
|
)
|
||||||
|
self.assertTrue(policy["actions"]["copy_campaign"]["allowed"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from sqlalchemy import Column, String, Table, create_engine
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign.scheduling import (
|
||||||
|
campaign_schedule_source_snapshot,
|
||||||
|
canonical_configuration_hash,
|
||||||
|
dispatch_due_campaign_schedules,
|
||||||
|
next_schedule_fire,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.db.models import (
|
||||||
|
Campaign,
|
||||||
|
CampaignJob,
|
||||||
|
CampaignSchedule,
|
||||||
|
CampaignScheduleOccurrence,
|
||||||
|
CampaignShare,
|
||||||
|
CampaignVersion,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
|
|
||||||
|
|
||||||
|
def _access_table(name: str) -> Table:
|
||||||
|
existing = Base.metadata.tables.get(name)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
return Table(
|
||||||
|
name,
|
||||||
|
Base.metadata,
|
||||||
|
Column("id", String(36), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_generated_campaign(session: Session, **kwargs):
|
||||||
|
raw = kwargs["raw_json"]
|
||||||
|
metadata = raw["campaign"]
|
||||||
|
campaign = Campaign(
|
||||||
|
tenant_id=kwargs["tenant_id"],
|
||||||
|
created_by_user_id=kwargs["user_id"],
|
||||||
|
owner_user_id=kwargs["user_id"],
|
||||||
|
external_id=metadata["id"],
|
||||||
|
name=metadata["name"],
|
||||||
|
status="draft",
|
||||||
|
)
|
||||||
|
session.add(campaign)
|
||||||
|
session.flush()
|
||||||
|
version = CampaignVersion(
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
raw_json=raw,
|
||||||
|
)
|
||||||
|
session.add(version)
|
||||||
|
session.flush()
|
||||||
|
campaign.current_version_id = version.id
|
||||||
|
return campaign, version
|
||||||
|
|
||||||
|
|
||||||
|
class TestCampaignScheduling:
|
||||||
|
def setup_method(self):
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
users = _access_table("access_users")
|
||||||
|
groups = _access_table("access_groups")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=[
|
||||||
|
users,
|
||||||
|
groups,
|
||||||
|
Campaign.__table__,
|
||||||
|
CampaignVersion.__table__,
|
||||||
|
CampaignJob.__table__,
|
||||||
|
CampaignShare.__table__,
|
||||||
|
CampaignSchedule.__table__,
|
||||||
|
CampaignScheduleOccurrence.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.SessionLocal = sessionmaker(
|
||||||
|
bind=self.engine,
|
||||||
|
class_=Session,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
configuration = {
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {"id": "source", "name": "Monthly notice"},
|
||||||
|
}
|
||||||
|
snapshot = campaign_schedule_source_snapshot(
|
||||||
|
configuration=configuration,
|
||||||
|
campaign_settings={"retention": "sealed"},
|
||||||
|
mail_profile_policy={"profile_id": "profile-1"},
|
||||||
|
shares=[],
|
||||||
|
)
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
user_values = {"id": "user-1"}
|
||||||
|
if "tenant_id" in users.c:
|
||||||
|
user_values.update(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
email="user-1@example.test",
|
||||||
|
)
|
||||||
|
session.execute(users.insert().values(**user_values))
|
||||||
|
campaign = Campaign(
|
||||||
|
id="campaign-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
created_by_user_id="user-1",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
external_id="source",
|
||||||
|
name="Monthly notice",
|
||||||
|
status="sent",
|
||||||
|
current_version_id="version-1",
|
||||||
|
)
|
||||||
|
version = CampaignVersion(
|
||||||
|
id="version-1",
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_number=1,
|
||||||
|
workflow_state="completed",
|
||||||
|
raw_json=configuration,
|
||||||
|
)
|
||||||
|
schedule = CampaignSchedule(
|
||||||
|
id="schedule-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
source_version_id=version.id,
|
||||||
|
created_by_user_id="user-1",
|
||||||
|
name="Monthly notice",
|
||||||
|
recurrence_kind="daily",
|
||||||
|
interval_count=1,
|
||||||
|
timezone="Europe/Berlin",
|
||||||
|
starts_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
next_fire_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
max_occurrences=2,
|
||||||
|
copy_options={
|
||||||
|
"include_recipients": True,
|
||||||
|
"include_files": True,
|
||||||
|
"include_shares": False,
|
||||||
|
"include_policies": True,
|
||||||
|
"include_mail_profile": True,
|
||||||
|
},
|
||||||
|
source_snapshot=snapshot,
|
||||||
|
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||||
|
)
|
||||||
|
session.add_all((campaign, version, schedule))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_due_occurrences_prepare_distinct_drafts_and_complete_bound(self):
|
||||||
|
with self.SessionLocal() as session, patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
|
||||||
|
side_effect=lambda *args, **kwargs: _create_generated_campaign(session, **kwargs),
|
||||||
|
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||||
|
first = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
assert first["prepared"] == 1
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
assert schedule.active is True
|
||||||
|
assert schedule.occurrence_count == 1
|
||||||
|
assert schedule.next_fire_at is not None
|
||||||
|
|
||||||
|
second = dispatch_due_campaign_schedules(
|
||||||
|
session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
assert second["prepared"] == 1
|
||||||
|
assert schedule.active is False
|
||||||
|
assert schedule.next_fire_at is None
|
||||||
|
occurrences = session.query(CampaignScheduleOccurrence).all()
|
||||||
|
assert len(occurrences) == 2
|
||||||
|
assert len({item.generated_campaign_id for item in occurrences}) == 2
|
||||||
|
assert session.get(Campaign, "campaign-1").status == "sent"
|
||||||
|
|
||||||
|
def test_snapshot_integrity_failure_pauses_schedule_for_operator(self):
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
schedule.source_snapshot["configuration"]["campaign"]["name"] = "Tampered"
|
||||||
|
flag_modified(schedule, "source_snapshot")
|
||||||
|
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["failed"] == 1
|
||||||
|
assert schedule.active is False
|
||||||
|
assert "integrity" in (schedule.last_error or "")
|
||||||
|
occurrence = session.query(CampaignScheduleOccurrence).one()
|
||||||
|
assert occurrence.status == "failed"
|
||||||
|
|
||||||
|
def test_monthly_recurrence_clamps_end_of_month(self):
|
||||||
|
result = next_schedule_fire(
|
||||||
|
datetime(2026, 1, 31, 9, tzinfo=UTC),
|
||||||
|
recurrence_kind="monthly",
|
||||||
|
interval_count=1,
|
||||||
|
timezone_name="UTC",
|
||||||
|
)
|
||||||
|
assert result == datetime(2026, 2, 28, 9, tzinfo=UTC)
|
||||||
|
|
||||||
|
def test_occurrence_uses_sealed_policy_state_and_advances_revision(self):
|
||||||
|
with self.SessionLocal() as session:
|
||||||
|
source = session.get(Campaign, "campaign-1")
|
||||||
|
assert source is not None
|
||||||
|
source.settings = {"retention": "changed-after-scheduling"}
|
||||||
|
source.mail_profile_policy = {"profile_id": "profile-2"}
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
|
||||||
|
side_effect=lambda *args, **kwargs: _create_generated_campaign(
|
||||||
|
session,
|
||||||
|
**kwargs,
|
||||||
|
),
|
||||||
|
), 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["prepared"] == 1
|
||||||
|
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||||
|
assert schedule is not None
|
||||||
|
generated = session.get(Campaign, schedule.last_campaign_id)
|
||||||
|
assert generated is not None
|
||||||
|
assert generated.settings == {"retention": "sealed"}
|
||||||
|
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
||||||
|
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
|
||||||
@@ -78,6 +78,53 @@ def test_complete_review_workflow_documents_each_attention_class() -> None:
|
|||||||
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
|
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_reuse_workflow_documents_policy_and_evidence() -> None:
|
||||||
|
topic = next(
|
||||||
|
item
|
||||||
|
for item in CAMPAIGN_USER_DOCUMENTATION
|
||||||
|
if item.id == "campaigns.workflow.control-attachment-reuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = "\n".join(
|
||||||
|
(
|
||||||
|
topic.summary,
|
||||||
|
topic.body,
|
||||||
|
*topic.metadata["steps"],
|
||||||
|
topic.metadata["verification"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert "allow" in rendered.lower()
|
||||||
|
assert "warn" in rendered.lower()
|
||||||
|
assert "review" in rendered.lower()
|
||||||
|
assert "block" in rendered.lower()
|
||||||
|
assert "fingerprint" in rendered.lower()
|
||||||
|
assert "reason" in rendered.lower()
|
||||||
|
assert topic.metadata["help_contexts"] == [
|
||||||
|
"campaign.attachments",
|
||||||
|
"campaign.attachments.reuse-policy",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_access_evidence_reference_documents_independent_owner_boundaries() -> None:
|
||||||
|
from govoplan_campaign.backend.manifest import get_manifest
|
||||||
|
|
||||||
|
topic = next(
|
||||||
|
item for item in get_manifest().documentation
|
||||||
|
if item.id == "campaigns.access.child-evidence"
|
||||||
|
)
|
||||||
|
rendered = f"{topic.summary}\n{topic.body}".lower()
|
||||||
|
for expected in (
|
||||||
|
"governance visibility",
|
||||||
|
"mapping profiles",
|
||||||
|
"validation",
|
||||||
|
"reusable templates",
|
||||||
|
"export packages",
|
||||||
|
"fail closed",
|
||||||
|
):
|
||||||
|
assert expected in rendered
|
||||||
|
assert "campaign.import" in topic.metadata["help_contexts"]
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||||
assert _topics({"docs:documentation:read"}) == ()
|
assert _topics({"docs:documentation:read"}) == ()
|
||||||
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||||
@@ -416,7 +463,11 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
|||||||
"campaign.settings",
|
"campaign.settings",
|
||||||
"campaign.fields",
|
"campaign.fields",
|
||||||
"campaign.template",
|
"campaign.template",
|
||||||
"campaign.attachments",
|
"campaign.template.content-library",
|
||||||
|
"campaigns.action.schedule-drafts",
|
||||||
|
"campaign.attachments",
|
||||||
|
"campaign.attachments.reuse-policy",
|
||||||
|
"campaign.attachments.residual-files",
|
||||||
"campaign.recipients",
|
"campaign.recipients",
|
||||||
"campaign.recipient-data",
|
"campaign.recipient-data",
|
||||||
"campaign.server-settings",
|
"campaign.server-settings",
|
||||||
@@ -424,8 +475,13 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
|||||||
"campaign.review-send",
|
"campaign.review-send",
|
||||||
"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",
|
||||||
|
}
|
||||||
|
|
||||||
assert len(ids) == len(set(ids))
|
assert len(ids) == len(set(ids))
|
||||||
assert "single resend" not in rendered_static
|
assert "single resend" not in rendered_static
|
||||||
|
|||||||
@@ -0,0 +1,734 @@
|
|||||||
|
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,
|
||||||
|
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__,
|
||||||
|
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(
|
||||||
|
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_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 (
|
||||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
patch(
|
||||||
return_value=SimpleNamespace(available=False),
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
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 (
|
||||||
"govoplan_campaign.backend.sending.execution.files_integration",
|
patch(
|
||||||
return_value=SimpleNamespace(available=False),
|
"govoplan_campaign.backend.sending.execution.files_integration",
|
||||||
|
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
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ def test_operator_queue_is_an_integrated_campaign_view() -> None:
|
|||||||
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
|
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
|
||||||
|
|
||||||
routes = {route.path: route for route in manifest.frontend.routes}
|
routes = {route.path: route for route in manifest.frontend.routes}
|
||||||
assert "/operator" not in routes
|
legacy_redirect = routes["/operator"]
|
||||||
|
assert legacy_redirect.component == "OperatorQueueRedirect"
|
||||||
|
assert legacy_redirect.surface_id == "campaigns.route.operator-redirect"
|
||||||
queue = routes["/campaigns/queue"]
|
queue = routes["/campaigns/queue"]
|
||||||
assert queue.component == "OperatorQueuePage"
|
assert queue.component == "OperatorQueuePage"
|
||||||
assert queue.required_all == ("campaigns:campaign:read",)
|
assert queue.required_all == ("campaigns:campaign:read",)
|
||||||
@@ -71,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()
|
||||||
|
|||||||
@@ -92,6 +92,43 @@ def test_attachment_block_cannot_be_overridden_by_review_decision() -> None:
|
|||||||
assert decisions == []
|
assert decisions == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_attachment_reuse_review_requires_reason_and_captures_policy() -> None:
|
||||||
|
job = _job(
|
||||||
|
issues=[{
|
||||||
|
"code": "duplicate_attachment_reuse",
|
||||||
|
"behavior": "ask",
|
||||||
|
"source": "attachments:reuse_policy",
|
||||||
|
"details": {
|
||||||
|
"file_fingerprint": "f" * 64,
|
||||||
|
"policy": {"action": "review", "allow_within": "none"},
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CampaignPersistenceError, match="require an explicit reason"):
|
||||||
|
_normalize_review_issue_decisions(
|
||||||
|
[job],
|
||||||
|
[],
|
||||||
|
user_id="reviewer-1",
|
||||||
|
build_token="build-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
decisions = _normalize_review_issue_decisions(
|
||||||
|
[job],
|
||||||
|
[{
|
||||||
|
"job_id": job.id,
|
||||||
|
"decision": "accept",
|
||||||
|
"reason": "The shared statutory notice is intentionally identical.",
|
||||||
|
}],
|
||||||
|
user_id="reviewer-1",
|
||||||
|
build_token="build-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert decisions[0]["issue_codes"] == ["duplicate_attachment_reuse"]
|
||||||
|
assert decisions[0]["reason"] == "The shared statutory notice is intentionally identical."
|
||||||
|
assert len(decisions[0]["issue_fingerprint"]) == 64
|
||||||
|
|
||||||
|
|
||||||
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
|
def _job(*, issues: list[dict[str, object]]) -> SimpleNamespace:
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id="job-1",
|
id="job-1",
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ 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.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.sharing import router as sharing_router
|
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||||
|
|
||||||
@@ -25,9 +27,11 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
|||||||
workflow_routers = (
|
workflow_routers = (
|
||||||
operations_router,
|
operations_router,
|
||||||
campaigns_router,
|
campaigns_router,
|
||||||
|
collaboration_router,
|
||||||
versions_router,
|
versions_router,
|
||||||
jobs_router,
|
jobs_router,
|
||||||
reports_router,
|
reports_router,
|
||||||
|
schedules_router,
|
||||||
sharing_router,
|
sharing_router,
|
||||||
delivery_router,
|
delivery_router,
|
||||||
attachments_router,
|
attachments_router,
|
||||||
@@ -40,7 +44,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) == 72
|
assert len(actual) == 86
|
||||||
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]
|
||||||
|
|
||||||
|
|
||||||
@@ -51,9 +55,11 @@ 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")),
|
||||||
|
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
|
||||||
(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")),
|
||||||
|
(schedules_router, ("GET", "/campaigns/{campaign_id}/schedules")),
|
||||||
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
||||||
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from govoplan_core.core.search import (
|
|||||||
SearchBackfillRequest,
|
SearchBackfillRequest,
|
||||||
SearchResourceReference,
|
SearchResourceReference,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ class CampaignSearchSourceTests(unittest.TestCase):
|
|||||||
Group.__table__,
|
Group.__table__,
|
||||||
Campaign.__table__,
|
Campaign.__table__,
|
||||||
CampaignShare.__table__,
|
CampaignShare.__table__,
|
||||||
|
ChangeSequenceEntry.__table__,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self.session = Session(self.engine)
|
self.session = Session(self.engine)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+5
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.15",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.15",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||||
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
|
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js && node tests/content-library-ui-structure.test.mjs",
|
||||||
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
|
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
|
||||||
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
|
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
|
||||||
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
|
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
|
|||||||
+314
-4
@@ -5,11 +5,15 @@ import {
|
|||||||
type ReferenceOptionProvider
|
type ReferenceOptionProvider
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
||||||
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
|
export {
|
||||||
|
fetchResourceAccessExplanation,
|
||||||
|
fetchResourceAccessExplanationSubjects
|
||||||
|
} from "@govoplan/core-webui";
|
||||||
export type {
|
export type {
|
||||||
AccessDecisionProvenanceItem,
|
AccessDecisionProvenanceItem,
|
||||||
ResourceAccessExplanationUser as AccessExplanationUser,
|
ResourceAccessExplanationUser as AccessExplanationUser,
|
||||||
ResourceAccessExplanationResponse
|
ResourceAccessExplanationResponse,
|
||||||
|
ResourceAccessExplanationSubjectsResponse
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
|
|
||||||
export type CampaignListResponse =
|
export type CampaignListResponse =
|
||||||
@@ -34,6 +38,67 @@ 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 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;
|
||||||
@@ -102,6 +167,75 @@ export type CampaignLifecyclePolicy = {
|
|||||||
provenance: Record<string, unknown>;
|
provenance: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignCopyOptions = {
|
||||||
|
name?: string;
|
||||||
|
external_id?: string;
|
||||||
|
include_recipients: boolean;
|
||||||
|
include_files: boolean;
|
||||||
|
include_shares: boolean;
|
||||||
|
include_policies: boolean;
|
||||||
|
include_mail_profile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignScheduleOccurrence = {
|
||||||
|
id: string;
|
||||||
|
schedule_id: string;
|
||||||
|
scheduled_for: string;
|
||||||
|
status: "prepared" | "accepted" | "uncertain" | "failed" | "skipped" | "superseded" | string;
|
||||||
|
idempotency_key?: string | null;
|
||||||
|
generated_campaign_id?: string | null;
|
||||||
|
generated_version_id?: string | null;
|
||||||
|
error?: string | null;
|
||||||
|
delivery_command_ids: string[];
|
||||||
|
recovery_state: string;
|
||||||
|
evidence: Record<string, unknown>;
|
||||||
|
last_checked_at?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignSchedule = {
|
||||||
|
id: string;
|
||||||
|
campaign_id: string;
|
||||||
|
source_version_id: string;
|
||||||
|
name: string;
|
||||||
|
delivery_mode: "manual" | "autonomous";
|
||||||
|
recurrence_kind: "once" | "daily" | "weekly" | "monthly";
|
||||||
|
interval_count: number;
|
||||||
|
timezone: string;
|
||||||
|
starts_at: string;
|
||||||
|
next_fire_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
max_occurrences: number;
|
||||||
|
occurrence_count: number;
|
||||||
|
active: boolean;
|
||||||
|
resource_revision: number;
|
||||||
|
last_fired_at?: string | null;
|
||||||
|
last_campaign_id?: string | null;
|
||||||
|
last_error?: string | null;
|
||||||
|
last_outcome?: string | null;
|
||||||
|
last_recovery_state?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
occurrences: CampaignScheduleOccurrence[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignScheduleCreate = {
|
||||||
|
source_version_id: string;
|
||||||
|
name: string;
|
||||||
|
delivery_mode: CampaignSchedule["delivery_mode"];
|
||||||
|
recurrence_kind: CampaignSchedule["recurrence_kind"];
|
||||||
|
interval_count: number;
|
||||||
|
timezone: string;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at?: string | null;
|
||||||
|
max_occurrences: number;
|
||||||
|
include_recipients: boolean;
|
||||||
|
include_files: boolean;
|
||||||
|
include_shares: boolean;
|
||||||
|
include_policies: boolean;
|
||||||
|
include_mail_profile: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||||
raw_json: Record<string, unknown>;
|
raw_json: Record<string, unknown>;
|
||||||
campaign_json?: Record<string, unknown>;
|
campaign_json?: Record<string, unknown>;
|
||||||
@@ -431,6 +565,57 @@ export type CampaignPrintTemplatesResponse = {
|
|||||||
templates: CampaignPrintTemplate[];
|
templates: CampaignPrintTemplate[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CampaignContentLibraryTarget = "subject" | "text" | "html";
|
||||||
|
|
||||||
|
export type CampaignContentLibraryItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
template_type: string;
|
||||||
|
kind: "fragment" | "campaign_part";
|
||||||
|
status: string;
|
||||||
|
scope_type: "tenant" | "group" | "user";
|
||||||
|
scope_id?: string | null;
|
||||||
|
read_only: boolean;
|
||||||
|
current_revision: number;
|
||||||
|
revision: number;
|
||||||
|
revision_id: string;
|
||||||
|
locale?: string | null;
|
||||||
|
published: boolean;
|
||||||
|
targets: CampaignContentLibraryTarget[];
|
||||||
|
subject?: string | null;
|
||||||
|
text?: string | null;
|
||||||
|
html?: string | null;
|
||||||
|
body_mode: "text" | "html" | "both";
|
||||||
|
required_fields: Array<{
|
||||||
|
path: string;
|
||||||
|
value_type: "string" | "integer" | "number" | "boolean" | "date" | "datetime" | "object" | "array";
|
||||||
|
label?: string | null;
|
||||||
|
required: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignContentLibraryResponse = {
|
||||||
|
available: boolean;
|
||||||
|
writable: boolean;
|
||||||
|
reason?: string | null;
|
||||||
|
items: CampaignContentLibraryItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampaignContentLibrarySaveInput = {
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
kind: "fragment" | "campaign_part";
|
||||||
|
target?: CampaignContentLibraryTarget | null;
|
||||||
|
subject?: string | null;
|
||||||
|
text?: string | null;
|
||||||
|
html?: string | null;
|
||||||
|
body_mode: "text" | "html" | "both";
|
||||||
|
locale: string;
|
||||||
|
visibility: "personal" | "tenant";
|
||||||
|
};
|
||||||
|
|
||||||
export type CampaignRecipientSnapshotItem = {
|
export type CampaignRecipientSnapshotItem = {
|
||||||
contact_id: string;
|
contact_id: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
@@ -991,6 +1176,26 @@ query = "")
|
|||||||
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
|
return apiFetch<CampaignPrintTemplatesResponse>(settings, `/api/v1/campaigns/${campaignId}/print-templates${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCampaignContentLibrary(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
query = "")
|
||||||
|
: Promise<CampaignContentLibraryResponse> {
|
||||||
|
const suffix = query.trim() ? `?query=${encodeURIComponent(query.trim())}` : "";
|
||||||
|
return apiFetch<CampaignContentLibraryResponse>(settings, `/api/v1/campaigns/${campaignId}/content-library${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCampaignContentLibraryItem(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
payload: CampaignContentLibrarySaveInput)
|
||||||
|
: Promise<{template: CampaignContentLibraryItem;}> {
|
||||||
|
return apiFetch<{template: CampaignContentLibraryItem;}>(settings, `/api/v1/campaigns/${campaignId}/content-library`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function snapshotCampaignRecipientAddressSource(
|
export async function snapshotCampaignRecipientAddressSource(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
@@ -1006,6 +1211,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,
|
||||||
@@ -1052,17 +1267,53 @@ export async function copyCampaign(
|
|||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
sourceVersionId: string,
|
sourceVersionId: string,
|
||||||
expectedStateToken: string)
|
expectedStateToken: string,
|
||||||
|
options: CampaignCopyOptions)
|
||||||
: Promise<CampaignCreateResponse> {
|
: Promise<CampaignCreateResponse> {
|
||||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
|
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
source_version_id: sourceVersionId,
|
source_version_id: sourceVersionId,
|
||||||
expected_state_token: expectedStateToken
|
expected_state_token: expectedStateToken,
|
||||||
|
...options,
|
||||||
|
name: options.name?.trim() || undefined,
|
||||||
|
external_id: options.external_id?.trim() || undefined
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCampaignSchedules(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string)
|
||||||
|
: Promise<CampaignSchedule[]> {
|
||||||
|
const response = await apiFetch<{items: CampaignSchedule[]}>(settings, `/api/v1/campaigns/${campaignId}/schedules`);
|
||||||
|
return response.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCampaignSchedule(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
payload: CampaignScheduleCreate)
|
||||||
|
: Promise<CampaignSchedule> {
|
||||||
|
return apiFetch<CampaignSchedule>(settings, `/api/v1/campaigns/${campaignId}/schedules`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setCampaignScheduleState(
|
||||||
|
settings: ApiSettings,
|
||||||
|
campaignId: string,
|
||||||
|
scheduleId: string,
|
||||||
|
active: boolean,
|
||||||
|
baseRevision: number)
|
||||||
|
: Promise<CampaignSchedule> {
|
||||||
|
return apiFetch<CampaignSchedule>(settings, `/api/v1/campaigns/${campaignId}/schedules/${scheduleId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ active, base_revision: baseRevision })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function archiveCampaignVersion(
|
export async function archiveCampaignVersion(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
@@ -1555,6 +1806,65 @@ 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 function campaignShareTargetProvider(
|
export function campaignShareTargetProvider(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string,
|
campaignId: string,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
|
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 { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
@@ -20,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");
|
||||||
@@ -39,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({
|
||||||
@@ -56,13 +74,27 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
|
||||||
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
|
||||||
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
|
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
|
||||||
|
const reusePolicy = asRecord(attachments.reuse_policy);
|
||||||
|
const reuseAction = ["allow", "warn", "review", "block"].includes(String(reusePolicy.action)) ? String(reusePolicy.action) : "allow";
|
||||||
|
const reuseAllowance = ["same_recipient", "same_message"].includes(String(reusePolicy.allow_within)) ? String(reusePolicy.allow_within) : "none";
|
||||||
|
const residualFiles = asRecord(attachments.residual_files);
|
||||||
|
const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none";
|
||||||
|
const residualRecipient = asRecord(residualFiles.recipient);
|
||||||
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
|
const filenameFieldOptions = useMemo(() => buildZipFilenameFieldOptions(displayDraft), [displayDraft]);
|
||||||
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
|
const passwordFields = useMemo(() => getDraftFields(displayDraft).filter((field) => field.type === "password"), [displayDraft]);
|
||||||
const zipArchiveNameValidation = useMemo(
|
const zipArchiveNameValidation = useMemo(
|
||||||
() => 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(
|
||||||
@@ -86,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);
|
||||||
@@ -96,6 +144,28 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchResidualFiles(next: Record<string, unknown>) {
|
||||||
|
if (locked) return;
|
||||||
|
patch(["attachments", "residual_files"], {
|
||||||
|
mode: "none",
|
||||||
|
recipient: null,
|
||||||
|
subject: "Unassigned files in campaign {{local:campaign_name}}",
|
||||||
|
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}",
|
||||||
|
...residualFiles,
|
||||||
|
...next
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchReusePolicy(next: Record<string, unknown>) {
|
||||||
|
if (locked) return;
|
||||||
|
patch(["attachments", "reuse_policy"], {
|
||||||
|
action: "allow",
|
||||||
|
allow_within: "none",
|
||||||
|
...reusePolicy,
|
||||||
|
...next
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
|
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
|
||||||
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
|
||||||
}
|
}
|
||||||
@@ -229,35 +299,53 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
|
patchZipCollection({ ...zipConfig, archives: moveArrayItem(zipConfig.archives, index, targetIndex) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revealAttachmentSection(id: string) {
|
||||||
|
const section = document.getElementById(id);
|
||||||
|
if (!section) return;
|
||||||
|
section.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
section.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
section.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRecipients() {
|
||||||
|
const query = version?.id ? `?version=${encodeURIComponent(version.id)}` : "";
|
||||||
|
navigate(`/campaigns/${campaignId}/recipients${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="editor"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.attachments.6771ade6</PageTitle>
|
title="i18n:govoplan-campaign.attachments.6771ade6"
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error}
|
||||||
{filesModuleInstalled && <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button>}
|
actions={<PageActionBar
|
||||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
variant="editor"
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
</div>
|
contextActions={filesModuleInstalled ? <Button onClick={() => navigate("/files")}>i18n:govoplan-campaign.manage_files.90a419f7</Button> : undefined}
|
||||||
</div>
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current editor blocker before saving." : undefined }}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
/>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
notices={(localError || locked) ? <>
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
</> : undefined}
|
||||||
|
>
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
<div className="metric-grid attachment-statistics-grid">
|
<MetricGrid>
|
||||||
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" />
|
<MetricCard label="i18n:govoplan-campaign.base_paths.edf35a97" value={basePaths.length} tone="neutral" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Attachment sources" }), onActivate: () => revealAttachmentSection("campaign-attachment-sources") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" />
|
<MetricCard label="i18n:govoplan-campaign.global_attachments.438263a1" value={`${globalSummary.direct} / ${globalSummary.rules}`} tone="info" detail="direct / rules" drilldown={{ label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealAttachmentSection("campaign-global-attachments") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" />
|
<MetricCard label="i18n:govoplan-campaign.per_recipient_patterns.53da30da" value={individualRulesCount} tone="neutral" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: openRecipients }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
|
<MetricCard label="i18n:govoplan-campaign.upload_support.1c54931c" value={managedFilesAvailable ? "Files" : "Manual"} tone={managedFilesAvailable ? "good" : filesModuleInstalled ? "warning" : "neutral"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
<Card id="campaign-attachment-sources" tabIndex={-1} title="i18n:govoplan-campaign.attachment_sources.8ef0a6ce">
|
||||||
<div className="admin-table-surface attachment-sources-table-surface">
|
<div className="admin-table-surface attachment-sources-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-attachment-sources`}
|
id={`campaign-${campaignId}-attachment-sources`}
|
||||||
@@ -271,7 +359,77 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Attachment reuse policy" collapsible>
|
||||||
|
<div className="campaign-residual-file-form">
|
||||||
|
<FormField label="Duplicate-file action" help="Applied when the same resolved source file appears more than once outside the configured allowance.">
|
||||||
|
<select value={reuseAction} disabled={locked} onChange={(event) => patchReusePolicy({ action: event.target.value })}>
|
||||||
|
<option value="allow">Allow and record</option>
|
||||||
|
<option value="warn">Allow with warning</option>
|
||||||
|
<option value="review">Require explicit review</option>
|
||||||
|
<option value="block">Block delivery</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Allowed reuse boundary" help="A boundary is an exception to the selected action. No exception applies the action to every repeated use.">
|
||||||
|
<select value={reuseAllowance} disabled={locked} onChange={(event) => patchReusePolicy({ allow_within: event.target.value })}>
|
||||||
|
<option value="none">No exception</option>
|
||||||
|
<option value="same_recipient">Within the same recipient</option>
|
||||||
|
<option value="same_message">Within the same built message</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<DismissibleAlert tone={reuseAction === "block" ? "warning" : "info"} dismissible={false} compact>
|
||||||
|
Every repeated-file finding is retained in the build protocol. Review requires a reason bound to the exact build; Block prevents affected messages from being queued.
|
||||||
|
</DismissibleAlert>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Unassigned file disposition" collapsible>
|
||||||
|
<div className="campaign-residual-file-form">
|
||||||
|
<FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected.">
|
||||||
|
<select value={residualMode} disabled={locked} onChange={(event) => {
|
||||||
|
const mode = event.target.value;
|
||||||
|
patchResidualFiles({
|
||||||
|
mode,
|
||||||
|
recipient: mode === "none" ? null : {
|
||||||
|
email: String(residualRecipient.email ?? ""),
|
||||||
|
name: String(residualRecipient.name ?? "") || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}>
|
||||||
|
<option value="none">Apply warning policy only</option>
|
||||||
|
<option value="report">Prepare report message</option>
|
||||||
|
<option value="attach">Prepare report with files attached</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{residualMode !== "none" && <>
|
||||||
|
<FormField label="Recipient email">
|
||||||
|
<input type="email" value={String(residualRecipient.email ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, email: event.target.value } })} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Recipient name">
|
||||||
|
<input value={String(residualRecipient.name ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ recipient: { ...residualRecipient, name: event.target.value || null } })} />
|
||||||
|
</FormField>
|
||||||
|
<div className="campaign-residual-file-wide">
|
||||||
|
<FormField label="Subject">
|
||||||
|
<input value={String(residualFiles.subject ?? "Unassigned files in campaign {{local:campaign_name}}") } disabled={locked} onChange={(event) => patchResidualFiles({ subject: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="campaign-residual-file-wide">
|
||||||
|
<FormField label="Report text" help="Available values: {{local:campaign_name}}, {{local:residual_file_count}}, and {{local:residual_file_list}}.">
|
||||||
|
<textarea rows={5} value={String(residualFiles.text ?? "")} disabled={locked} onChange={(event) => patchResidualFiles({ text: event.target.value })} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
{residualMode !== "none" && <DismissibleAlert tone="info" dismissible={false} compact>
|
||||||
|
A residual-file message becomes a separate campaign row that always needs review. It uses the normal build, approval, queue, delivery, report, and audit lifecycle; saving this setting never sends it.
|
||||||
|
</DismissibleAlert>}
|
||||||
|
</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"
|
||||||
@@ -290,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,
|
||||||
@@ -310,7 +471,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
}
|
}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
<Card id="campaign-global-attachments" tabIndex={-1} title="i18n:govoplan-campaign.global_attachments.492bd841" collapsible>
|
||||||
<AttachmentRulesDataGrid
|
<AttachmentRulesDataGrid
|
||||||
id={`campaign-${campaignId}-global-attachments`}
|
id={`campaign-${campaignId}-global-attachments`}
|
||||||
rules={globalRules}
|
rules={globalRules}
|
||||||
@@ -394,7 +555,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
|
|||||||
onConfirm={confirmIndividualDisable}
|
onConfirm={confirmIndividualDisable}
|
||||||
onCancel={() => setIndividualDisable(null)} />
|
onCancel={() => setIndividualDisable(null)} />
|
||||||
|
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,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;
|
||||||
@@ -411,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",
|
||||||
@@ -444,18 +608,65 @@ 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" })} />,
|
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"
|
||||||
|
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"
|
||||||
|
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"
|
||||||
|
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 }))] },
|
||||||
@@ -572,6 +783,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[];
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
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 { DismissibleAlert } from "@govoplan/core-webui";
|
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { ActionBlockerHint, DocumentationHelpLink, PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { ActionBlockerHint, DocumentationHelpLink } from "@govoplan/core-webui";
|
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
|
|
||||||
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
@@ -13,20 +10,20 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
|
|||||||
const version = data.currentVersion;
|
const version = data.currentVersion;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="detail"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.audit_log.3cfc5f1c</PageTitle>
|
title="i18n:govoplan-campaign.audit_log.3cfc5f1c"
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
|
||||||
</div>
|
loading={loading}
|
||||||
<div className="button-row compact-actions">
|
loadingLabel="i18n:govoplan-campaign.loading_audit_data.af52b968"
|
||||||
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
|
error={error}
|
||||||
</div>
|
actions={<PageActionBar
|
||||||
</div>
|
variant="detail"
|
||||||
|
refreshable
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
|
||||||
|
/>}
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
>
|
||||||
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
||||||
<ActionBlockerHint
|
<ActionBlockerHint
|
||||||
tone="info"
|
tone="info"
|
||||||
@@ -50,7 +47,6 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
|
|||||||
label="Open Campaign assurance documentation"
|
label="Open Campaign assurance documentation"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</PageLayout>);
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +1,7 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||||
@@ -150,22 +150,25 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="editor"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.fields.e8b68527</PageTitle>
|
title="i18n:govoplan-campaign.fields.e8b68527"
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error}
|
||||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
actions={<PageActionBar
|
||||||
<Button variant="primary" onClick={saveFields} disabled={!canSave}>i18n:govoplan-campaign.save.efc007a3</Button>
|
variant="editor"
|
||||||
</div>
|
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
</div>
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: saveFields, disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current field-definition blocker before saving." : undefined }}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
/>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
notices={(localError || fieldNameWarning || locked) ? <>
|
||||||
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
{fieldNameWarning && <DismissibleAlert tone="warning" resetKey={fieldNameWarning} floating>{fieldNameWarning}</DismissibleAlert>}
|
||||||
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
</> : undefined}
|
||||||
|
>
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
@@ -184,7 +187,7 @@ export default function CampaignFieldsPage({ settings, campaignId }: {settings:
|
|||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } 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 { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
import { asRecord, getCampaignJson } from "./utils/campaignView";
|
||||||
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
import { downloadJson, safeFileStem } from "./utils/draftEditor";
|
||||||
@@ -17,28 +15,31 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
|||||||
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
|
const filename = `${safeFileStem(String(campaign.id || data.campaign?.external_id || campaignId))}.json`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="detail"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.json.031a4e76</PageTitle>
|
title="i18n:govoplan-campaign.json.031a4e76"
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
|
||||||
</div>
|
loading={loading}
|
||||||
<div className="button-row compact-actions">
|
loadingLabel="i18n:govoplan-campaign.loading_json.812c7a50"
|
||||||
<Button onClick={() => void reload({ force: true })} disabled={loading}>Discard</Button>
|
error={error}
|
||||||
<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>
|
actions={<PageActionBar
|
||||||
</div>
|
variant="detail"
|
||||||
</div>
|
refreshable
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
|
||||||
<DismissibleAlert tone="warning" dismissible={false}>
|
primaryActions={<Button onClick={() => downloadJson(filename, campaignJson)} disabled={!version}>i18n:govoplan-campaign.download_json.d296a30a</Button>}
|
||||||
This expert view contains the complete authorized campaign configuration,
|
/>}
|
||||||
including recipient and message fields that may contain personal data.
|
notices={
|
||||||
Download and share it only for an authorized purpose.
|
<DismissibleAlert tone="warning" dismissible={false}>
|
||||||
</DismissibleAlert>
|
This expert view contains the complete authorized campaign configuration,
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
|
including recipient and message fields that may contain personal data.
|
||||||
|
Download and share it only for an authorized purpose.
|
||||||
|
</DismissibleAlert>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</PageLayout>);
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import type { ApiSettings } 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 { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert, TableActionGroup, 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, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
||||||
import type { CampaignListItem } from "../../types";
|
import type { CampaignListItem } from "../../types";
|
||||||
@@ -144,22 +142,23 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScrollViewport>
|
<PageLayout
|
||||||
<div className="content-pad workspace-data-page campaigns-page">
|
archetype="collection"
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
mode="workspace"
|
||||||
|
className="campaigns-page"
|
||||||
<div className="page-heading split workspace-heading">
|
title="i18n:govoplan-campaign.all_campaigns.2bd1ee3a"
|
||||||
<div>
|
description={<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>}
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.all_campaigns.2bd1ee3a</PageTitle>
|
headerLoading={loading}
|
||||||
<p className="mono-small">{lastLoadedAt ? i18nMessage("i18n:govoplan-campaign.last_loaded_value.35ef046a", { value0: lastLoadedAt }) : "i18n:govoplan-campaign.not_loaded_yet.9968c191"}</p>
|
error={error}
|
||||||
</div>
|
actions={<PageActionBar
|
||||||
<div className="button-row compact-actions">
|
variant="collection"
|
||||||
<Button onClick={() => void load(null)} disabled={loading}>Discard</Button>
|
refreshable
|
||||||
<Button variant="primary" onClick={create} disabled={creating}>
|
reloadAction={{ onReload: () => void load(null), loading }}
|
||||||
|
createAction={<Button variant="primary" onClick={create} disabled={creating}>
|
||||||
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
/>}
|
||||||
</div>
|
>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
|
<Card title="i18n:govoplan-campaign.campaigns.01a23a28">
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaigns.90a466bb">
|
||||||
@@ -186,8 +185,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
|||||||
}
|
}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PageLayout>);
|
||||||
</PageScrollViewport>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { lazy } from "react";
|
import { lazy } from "react";
|
||||||
import {
|
import {
|
||||||
ModuleSubnav,
|
ModuleSubnav,
|
||||||
|
WorkspaceLayout,
|
||||||
hasAnyScope,
|
hasAnyScope,
|
||||||
useGuardedNavigate,
|
useGuardedNavigate,
|
||||||
type ApiSettings,
|
type ApiSettings,
|
||||||
@@ -64,15 +65,19 @@ export default function CampaignModulePage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace campaign-module-workspace">
|
<WorkspaceLayout
|
||||||
<ModuleSubnav active={active} groups={groups} onSelect={select} />
|
className="campaign-module-workspace"
|
||||||
<section className="workspace-content">
|
primary={<ModuleSubnav active={active} groups={groups} onSelect={select} />}
|
||||||
|
primaryLabel="Campaign module sections"
|
||||||
|
contentLabel="Campaign module content"
|
||||||
|
interfaceId="campaign.module-workspace"
|
||||||
|
helpModuleId="campaign"
|
||||||
|
>
|
||||||
{active === "queue"
|
{active === "queue"
|
||||||
? <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} />}
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
|
import { Archive, CalendarClock, Copy, 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 { Button } from "@govoplan/core-webui";
|
import { FormGrid, Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
@@ -17,13 +19,19 @@ import {
|
|||||||
archiveCampaign,
|
archiveCampaign,
|
||||||
archiveCampaignVersion,
|
archiveCampaignVersion,
|
||||||
copyCampaign,
|
copyCampaign,
|
||||||
|
createCampaignSchedule,
|
||||||
deleteCampaign,
|
deleteCampaign,
|
||||||
getCampaignLifecyclePolicy,
|
getCampaignLifecyclePolicy,
|
||||||
lockCampaignVersionPermanently,
|
lockCampaignVersionPermanently,
|
||||||
lockCampaignVersionTemporarily,
|
lockCampaignVersionTemporarily,
|
||||||
unlockCampaignVersionUserLock,
|
unlockCampaignVersionUserLock,
|
||||||
|
listCampaignSchedules,
|
||||||
|
setCampaignScheduleState,
|
||||||
updateCampaignMetadata,
|
updateCampaignMetadata,
|
||||||
|
type CampaignSchedule,
|
||||||
|
type CampaignScheduleCreate,
|
||||||
type CampaignLifecyclePolicy,
|
type CampaignLifecyclePolicy,
|
||||||
|
type CampaignCopyOptions,
|
||||||
type CampaignVersionDetail,
|
type CampaignVersionDetail,
|
||||||
type CampaignVersionListItem } from
|
type CampaignVersionListItem } from
|
||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
@@ -51,6 +59,35 @@ type PendingLifecycleAction = {
|
|||||||
policy: CampaignLifecyclePolicy;
|
policy: CampaignLifecyclePolicy;
|
||||||
version?: CampaignVersionListItem;
|
version?: CampaignVersionListItem;
|
||||||
} | null;
|
} | null;
|
||||||
|
const defaultCopyOptions: CampaignCopyOptions = {
|
||||||
|
name: "",
|
||||||
|
external_id: "",
|
||||||
|
include_recipients: true,
|
||||||
|
include_files: true,
|
||||||
|
include_shares: false,
|
||||||
|
include_policies: true,
|
||||||
|
include_mail_profile: true
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultScheduleDraft(): CampaignScheduleCreate {
|
||||||
|
const start = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
start.setSeconds(0, 0);
|
||||||
|
return {
|
||||||
|
source_version_id: "",
|
||||||
|
name: "",
|
||||||
|
delivery_mode: "manual",
|
||||||
|
recurrence_kind: "once",
|
||||||
|
interval_count: 1,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||||
|
starts_at: localDateTimeValue(start),
|
||||||
|
max_occurrences: 1,
|
||||||
|
include_recipients: true,
|
||||||
|
include_files: true,
|
||||||
|
include_shares: false,
|
||||||
|
include_policies: true,
|
||||||
|
include_mail_profile: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
@@ -65,12 +102,35 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||||
const [lockBusy, setLockBusy] = useState(false);
|
const [lockBusy, setLockBusy] = useState(false);
|
||||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||||
|
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
|
||||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
|
||||||
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||||
|
const [scheduleDraft, setScheduleDraft] = useState<CampaignScheduleCreate>(defaultScheduleDraft);
|
||||||
|
const [scheduleBusy, setScheduleBusy] = useState(false);
|
||||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||||
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") && hasScope(auth, "campaigns:recipient:read");
|
const canCopy = Boolean(data.currentVersion) && 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");
|
||||||
|
|
||||||
|
function openSection(section: string, fragment = "") {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (data.currentVersion?.id) params.set("version", data.currentVersion.id);
|
||||||
|
const query = params.toString();
|
||||||
|
navigate(`/campaigns/${campaignId}/${section}${query ? `?${query}` : ""}${fragment}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQueue() {
|
||||||
|
const params = new URLSearchParams({ campaign: campaignId });
|
||||||
|
if (data.currentVersion?.id) params.set("version", data.currentVersion.id);
|
||||||
|
navigate(`/campaigns/queue?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
useUnsavedDraftGuard({
|
useUnsavedDraftGuard({
|
||||||
dirty: identityDirty,
|
dirty: identityDirty,
|
||||||
@@ -97,6 +157,20 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
});
|
});
|
||||||
}, [campaign, identityDirty]);
|
}, [campaign, identityDirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!campaign?.id) {
|
||||||
|
setSchedules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let active = true;
|
||||||
|
void listCampaignSchedules(settings, campaign.id).then((items) => {
|
||||||
|
if (active) setSchedules(items);
|
||||||
|
}).catch((err) => {
|
||||||
|
if (active) setError(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
|
return () => { active = false; };
|
||||||
|
}, [campaign?.id, settings, setError]);
|
||||||
|
|
||||||
function patchIdentity(key: keyof typeof identity, value: string) {
|
function patchIdentity(key: keyof typeof identity, value: string) {
|
||||||
setIdentity((current) => ({ ...current, [key]: value }));
|
setIdentity((current) => ({ ...current, [key]: value }));
|
||||||
setIdentityDirty(true);
|
setIdentityDirty(true);
|
||||||
@@ -177,6 +251,13 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
|
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === "copy_campaign") {
|
||||||
|
setCopyOptions({
|
||||||
|
...defaultCopyOptions,
|
||||||
|
name: `${campaign.name} (copy)`,
|
||||||
|
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
||||||
|
});
|
||||||
|
}
|
||||||
setPendingLifecycleAction({ action, policy, version });
|
setPendingLifecycleAction({ action, policy, version });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
@@ -201,7 +282,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
navigate("/campaigns");
|
navigate("/campaigns");
|
||||||
return;
|
return;
|
||||||
} else if (pending.action === "copy_campaign" && pending.version) {
|
} else if (pending.action === "copy_campaign" && pending.version) {
|
||||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token, copyOptions);
|
||||||
setPendingLifecycleAction(null);
|
setPendingLifecycleAction(null);
|
||||||
navigate(`/campaigns/${created.campaign.id}`);
|
navigate(`/campaigns/${created.campaign.id}`);
|
||||||
return;
|
return;
|
||||||
@@ -218,14 +299,72 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openScheduleDialog() {
|
||||||
|
if (!campaign || !data.currentVersion) return;
|
||||||
|
setScheduleDraft({
|
||||||
|
...defaultScheduleDraft(),
|
||||||
|
source_version_id: data.currentVersion.id,
|
||||||
|
name: campaign.name,
|
||||||
|
include_recipients: hasScope(auth, "campaigns:recipient:read")
|
||||||
|
});
|
||||||
|
setScheduleDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSchedule() {
|
||||||
|
if (!campaign || scheduleBusy) return;
|
||||||
|
setScheduleBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const created = await createCampaignSchedule(settings, campaign.id, {
|
||||||
|
...scheduleDraft,
|
||||||
|
starts_at: new Date(scheduleDraft.starts_at).toISOString(),
|
||||||
|
max_occurrences: scheduleDraft.recurrence_kind === "once" ? 1 : scheduleDraft.max_occurrences
|
||||||
|
});
|
||||||
|
setSchedules((current) => [created, ...current]);
|
||||||
|
setScheduleDialogOpen(false);
|
||||||
|
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) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setScheduleBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleSchedule(schedule: CampaignSchedule) {
|
||||||
|
if (!campaign || scheduleBusy) return;
|
||||||
|
setScheduleBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const updated = await setCampaignScheduleState(
|
||||||
|
settings,
|
||||||
|
campaign.id,
|
||||||
|
schedule.id,
|
||||||
|
!schedule.active,
|
||||||
|
schedule.resource_revision
|
||||||
|
);
|
||||||
|
setSchedules((current) => current.map((item) => item.id === updated.id ? updated : item));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setScheduleBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="editor"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>{campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}</PageTitle>
|
title={campaign?.name || "i18n:govoplan-campaign.overview.0efc2e6b"}
|
||||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
description={<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error}
|
||||||
|
success={message}
|
||||||
|
actions={<PageActionBar
|
||||||
|
variant="editor"
|
||||||
|
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
|
||||||
|
primaryActions={<>
|
||||||
{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}
|
||||||
@@ -233,6 +372,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<Copy size={16} aria-hidden="true" />
|
<Copy size={16} aria-hidden="true" />
|
||||||
Copy campaign
|
Copy campaign
|
||||||
</Button>}
|
</Button>}
|
||||||
|
{canSchedule && <Button
|
||||||
|
onClick={openScheduleDialog}
|
||||||
|
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||||
|
disabledReason={identityDirty ? "Save or discard overview changes before scheduling." : undefined}>
|
||||||
|
<CalendarClock size={16} aria-hidden="true" />
|
||||||
|
Schedule
|
||||||
|
</Button>}
|
||||||
|
</>}
|
||||||
|
destructiveActions={<>
|
||||||
{canDelete && <Button
|
{canDelete && <Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
||||||
@@ -249,27 +397,25 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<Archive size={16} aria-hidden="true" />
|
<Archive size={16} aria-hidden="true" />
|
||||||
i18n:govoplan-campaign.archive_campaign.26dcfb8a
|
i18n:govoplan-campaign.archive_campaign.26dcfb8a
|
||||||
</Button>}
|
</Button>}
|
||||||
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
|
</>}
|
||||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardOverview(), disabled: loading || lockBusy }}
|
||||||
</div>
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveIdentity(), disabled: !campaign && identityDirty, disabledReason: !campaign && identityDirty ? "The campaign identity is not available." : undefined }}
|
||||||
</div>
|
/>}
|
||||||
|
>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
||||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_campaign_overview.ffa1adf0">
|
||||||
<div className="metric-grid campaign-overview-metrics">
|
<MetricGrid>
|
||||||
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" />
|
<MetricCard label="i18n:govoplan-campaign.queueable.ea776f8d" value={data.summary?.cards?.queueable ?? "—"} tone="good" detail="i18n:govoplan-campaign.ready_or_warning.4dcce676" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-send") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" />
|
<MetricCard label="i18n:govoplan-campaign.needs_attention.a126722e" value={data.summary?.cards?.needs_attention ?? "—"} tone="warning" detail="i18n:govoplan-campaign.review_first.741ac781" drilldown={{ label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => openSection("review", "#workflow-build-review") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" />
|
<MetricCard label="i18n:govoplan-campaign.sent.35f49dcf" value={data.summary?.cards?.sent ?? "—"} tone="info" detail="i18n:govoplan-campaign.smtp_success.3591a856" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" />
|
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={data.summary?.cards?.failed ?? "—"} tone="danger" detail="i18n:govoplan-campaign.smtp_failures.00b33b85" drilldown={{ label: "i18n:govoplan-campaign.open_report.44f83158", onActivate: () => openSection("report") }} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
|
title="i18n:govoplan-campaign.campaign_identity.a00ca574"
|
||||||
collapsible
|
collapsible
|
||||||
actions={<Link className="btn btn-secondary" to="wizard">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
|
actions={<Link className="btn btn-secondary" to="wizard">i18n:govoplan-campaign.edit_with_wizard.672a7d1a</Link>}>
|
||||||
<div className="form-grid campaign-identity-grid">
|
<FormGrid columns={1} collapseAt="standard" className="campaign-identity-grid">
|
||||||
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
<FormField label="i18n:govoplan-campaign.campaign_id.4c4ed79e">
|
||||||
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
<input value={identity.external_id} onChange={(event) => patchIdentity("external_id", event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -284,9 +430,42 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
||||||
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
<textarea rows={4} value={identity.description} onChange={(event) => patchIdentity("description", event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{(canSchedule || schedules.length > 0) && <Card
|
||||||
|
title="Schedules"
|
||||||
|
collapsible
|
||||||
|
actions={canSchedule ? <Button onClick={openScheduleDialog}>
|
||||||
|
<CalendarClock size={16} aria-hidden="true" />
|
||||||
|
Add schedule
|
||||||
|
</Button> : undefined}>
|
||||||
|
<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.map((schedule) => <div className="campaign-schedule-row" key={schedule.id}>
|
||||||
|
<div className="campaign-schedule-main">
|
||||||
|
<strong>{schedule.name}</strong>
|
||||||
|
<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.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>}
|
||||||
|
</div>
|
||||||
|
<div className="button-row compact-actions">
|
||||||
|
{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
|
||||||
|
iconOnly
|
||||||
|
aria-label={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||||
|
title={schedule.active ? "Pause schedule" : "Resume schedule"}
|
||||||
|
disabled={scheduleBusy || !canSchedule}
|
||||||
|
onClick={() => void toggleSchedule(schedule)}>
|
||||||
|
{schedule.active ? <Pause size={16} aria-hidden="true" /> : <Play size={16} aria-hidden="true" />}
|
||||||
|
</Button>}
|
||||||
|
</div>
|
||||||
|
</div>)}
|
||||||
|
</div>}
|
||||||
|
</Card>}
|
||||||
|
|
||||||
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
|
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
|
||||||
{archivedVersionCount > 0 && <ToggleSwitch
|
{archivedVersionCount > 0 && <ToggleSwitch
|
||||||
label={`Show archived (${archivedVersionCount})`}
|
label={`Show archived (${archivedVersionCount})`}
|
||||||
@@ -300,18 +479,18 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
i18n:govoplan-campaign.open.cf9b7706
|
i18n:govoplan-campaign.open.cf9b7706
|
||||||
</Link>
|
</Link>
|
||||||
</div>}>
|
</div>}>
|
||||||
<div className="metric-grid inside campaign-versions-metrics">
|
<MetricGrid spacing="inset">
|
||||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Fields" }), onActivate: () => openSection("fields") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" />
|
<MetricCard label="i18n:govoplan-campaign.recipients.78cbf8eb" value={versionMetrics.recipientCount} tone="neutral" detail="i18n:govoplan-campaign.active_inline_recipients.8ba58f6e" drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_value.a34416a9", { value0: "Recipients" }), onActivate: () => openSection("recipients") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} />
|
<MetricCard label="i18n:govoplan-campaign.template_health.22e14b59" value={versionMetrics.templateHealthValue} tone={versionMetrics.templateHealthTone} detail={versionMetrics.templateHealthDetail} drilldown={{ label: "i18n:govoplan-campaign.open_template_editor.1739b545", onActivate: () => openSection("template") }} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<div className="metric-grid inside">
|
<MetricGrid spacing="inset">
|
||||||
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} />
|
<MetricCard label="i18n:govoplan-campaign.validation_errors.e54ca4fe" value={summaryValue(data.currentVersion?.validation_summary, ["error_count", "errors", "blocked"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} />
|
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(data.currentVersion?.validation_summary, ["warning_count", "warnings"])} drilldown={{ label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => openSection("review", "#workflow-validate-review") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} />
|
<MetricCard label="i18n:govoplan-campaign.built_messages.1fb804f2" value={summaryValue(data.currentVersion?.build_summary, ["built_count", "built", "messages_built"])} drilldown={{ label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => openSection("review", "#workflow-build-review") }} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} />
|
<MetricCard label="i18n:govoplan-campaign.jobs_total.98da65bc" value={data.summary?.cards?.jobs_total ?? "—"} drilldown={{ label: i18nMessage("i18n:govoplan-campaign.open_queue_for_value.804fbed9", { value0: campaign?.name ?? campaignId }), onActivate: openQueue }} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<div className="admin-table-surface version-history-table-surface">
|
<div className="admin-table-surface version-history-table-surface">
|
||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-versions`}
|
id={`campaign-${campaignId}-versions`}
|
||||||
@@ -335,7 +514,7 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(pendingLifecycleAction)}
|
open={Boolean(pendingLifecycleAction && pendingLifecycleAction.action !== "copy_campaign")}
|
||||||
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
||||||
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
||||||
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
||||||
@@ -344,6 +523,127 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
onCancel={() => setPendingLifecycleAction(null)}
|
onCancel={() => setPendingLifecycleAction(null)}
|
||||||
onConfirm={() => void applyLifecycleAction()} />
|
onConfirm={() => void applyLifecycleAction()} />
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={pendingLifecycleAction?.action === "copy_campaign"}
|
||||||
|
title="Copy campaign"
|
||||||
|
className="campaign-copy-dialog"
|
||||||
|
helpContextId="campaigns.action.copy-campaign"
|
||||||
|
closeDisabled={lifecycleBusy}
|
||||||
|
onClose={() => setPendingLifecycleAction(null)}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => setPendingLifecycleAction(null)} disabled={lifecycleBusy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void applyLifecycleAction()} disabled={lifecycleBusy || !copyOptions.name?.trim()}>
|
||||||
|
{lifecycleBusy ? "Creating copy..." : "Create copy"}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<div className="campaign-copy-form">
|
||||||
|
<p className="muted small-note">
|
||||||
|
Create a new draft from version #{pendingLifecycleAction?.version?.version_number ?? "?"}. Delivery jobs, outcomes, locks, reports, and audit evidence are never copied.
|
||||||
|
</p>
|
||||||
|
<div className="campaign-copy-identity">
|
||||||
|
<FormField label="Campaign name" help="The new campaign receives an independent identity and version history.">
|
||||||
|
<input value={copyOptions.name ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, name: event.target.value }))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Campaign ID" help="Leave blank to generate a unique ID from the source campaign.">
|
||||||
|
<input value={copyOptions.external_id ?? ""} onChange={(event) => setCopyOptions((current) => ({ ...current, external_id: event.target.value }))} placeholder="Generated automatically" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<div className="campaign-copy-options">
|
||||||
|
<CopyOption
|
||||||
|
label="Recipients"
|
||||||
|
detail="Global address headers, recipient rows, imported audience provenance, and per-recipient values."
|
||||||
|
checked={copyOptions.include_recipients}
|
||||||
|
disabled={!hasScope(auth, "campaigns:recipient:read")}
|
||||||
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_recipients: checked }))} />
|
||||||
|
<CopyOption
|
||||||
|
label="Files"
|
||||||
|
detail="Campaign and recipient attachment rules. Managed files remain owned by Files and are referenced, not duplicated."
|
||||||
|
checked={copyOptions.include_files}
|
||||||
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_files: checked }))} />
|
||||||
|
<CopyOption
|
||||||
|
label="Shares"
|
||||||
|
detail="Current active user and group shares. Ownership always starts with the account creating the copy."
|
||||||
|
checked={copyOptions.include_shares}
|
||||||
|
disabled={!hasScope(auth, "campaigns:campaign:share")}
|
||||||
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_shares: checked }))} />
|
||||||
|
<CopyOption
|
||||||
|
label="Policies"
|
||||||
|
detail="Validation behavior and campaign settings."
|
||||||
|
checked={copyOptions.include_policies}
|
||||||
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_policies: checked }))} />
|
||||||
|
<CopyOption
|
||||||
|
label="Mail profile"
|
||||||
|
detail="References to the reusable Mail profile, its campaign policy, server selections, and credential selections. Secrets are never copied into Campaign."
|
||||||
|
checked={copyOptions.include_mail_profile}
|
||||||
|
onChange={(checked) => setCopyOptions((current) => ({ ...current, include_mail_profile: checked }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={scheduleDialogOpen}
|
||||||
|
title="Schedule campaign"
|
||||||
|
className="campaign-schedule-dialog"
|
||||||
|
helpContextId="campaigns.action.schedule-drafts"
|
||||||
|
closeDisabled={scheduleBusy}
|
||||||
|
onClose={() => setScheduleDialogOpen(false)}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => setScheduleDialogOpen(false)} disabled={scheduleBusy}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void submitSchedule()} disabled={scheduleBusy || !scheduleDraft.name.trim() || !scheduleDraft.starts_at}>
|
||||||
|
{scheduleBusy ? "Creating schedule..." : "Create schedule"}
|
||||||
|
</Button>
|
||||||
|
</>}>
|
||||||
|
<DismissibleAlert tone="info" resetKey={`campaign-schedule-safety-${scheduleDraft.delivery_mode}`}>
|
||||||
|
{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>
|
||||||
|
<div className="campaign-schedule-form">
|
||||||
|
<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 }))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="First occurrence">
|
||||||
|
<input type="datetime-local" value={scheduleDraft.starts_at} onChange={(event) => setScheduleDraft((current) => ({ ...current, starts_at: event.target.value }))} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Recurrence">
|
||||||
|
<select value={scheduleDraft.recurrence_kind} onChange={(event) => {
|
||||||
|
const recurrence_kind = event.target.value as CampaignSchedule["recurrence_kind"];
|
||||||
|
setScheduleDraft((current) => ({ ...current, recurrence_kind, max_occurrences: recurrence_kind === "once" ? 1 : Math.max(2, current.max_occurrences) }));
|
||||||
|
}}>
|
||||||
|
<option value="once">Once</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
<option value="monthly">Monthly</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Every">
|
||||||
|
<div className="campaign-schedule-interval">
|
||||||
|
<input type="number" min={1} max={365} value={scheduleDraft.interval_count} onChange={(event) => setScheduleDraft((current) => ({ ...current, interval_count: Math.max(1, Number(event.target.value) || 1) }))} />
|
||||||
|
<span>{scheduleDraft.recurrence_kind.replace("ly", "")} interval(s)</span>
|
||||||
|
</div>
|
||||||
|
</FormField>}
|
||||||
|
{scheduleDraft.recurrence_kind !== "once" && <FormField label="Maximum occurrences" help="Recurring schedules are bounded. Create a new schedule if the approved plan changes.">
|
||||||
|
<input type="number" min={2} max={1000} value={scheduleDraft.max_occurrences} onChange={(event) => setScheduleDraft((current) => ({ ...current, max_occurrences: Math.max(2, Number(event.target.value) || 2) }))} />
|
||||||
|
</FormField>}
|
||||||
|
<FormField label="Timezone">
|
||||||
|
<input value={scheduleDraft.timezone} onChange={(event) => setScheduleDraft((current) => ({ ...current, timezone: event.target.value }))} />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
{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="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="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 }))} />
|
||||||
|
</div>}
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(pendingLockAction)}
|
open={Boolean(pendingLockAction)}
|
||||||
title={lockDialogTitle(pendingLockAction)}
|
title={lockDialogTitle(pendingLockAction)}
|
||||||
@@ -354,10 +654,46 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
onCancel={() => setPendingLockAction(null)}
|
onCancel={() => setPendingLockAction(null)}
|
||||||
onConfirm={() => void applyLockAction()} />
|
onConfirm={() => void applyLockAction()} />
|
||||||
|
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localDateTimeValue(value: Date): string {
|
||||||
|
const local = new Date(value.getTime() - value.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleCadence(schedule: CampaignSchedule): string {
|
||||||
|
if (schedule.recurrence_kind === "once") return "One time";
|
||||||
|
const unit = { daily: "day", weekly: "week", monthly: "month" }[schedule.recurrence_kind];
|
||||||
|
return schedule.interval_count === 1 ? schedule.recurrence_kind : `Every ${schedule.interval_count} ${unit}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyOption({
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
|
checked,
|
||||||
|
disabled = false,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
checked: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return <div className={`campaign-copy-option ${disabled ? "is-disabled" : ""}`}>
|
||||||
|
<div><strong>{label}</strong><small>{detail}</small></div>
|
||||||
|
<ToggleSwitch
|
||||||
|
label={`Copy ${label.toLowerCase()}`}
|
||||||
|
inactiveLabel="Exclude"
|
||||||
|
activeLabel="Include"
|
||||||
|
checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={onChange} />
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
type TemplateHealthTone = "neutral" | "good" | "warning" | "danger" | "info";
|
||||||
|
|
||||||
type CampaignVersionMetrics = {
|
type CampaignVersionMetrics = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Check, RotateCcw, Search, X } from "lucide-react";
|
import { Check, RotateCcw, Search, X } from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
@@ -14,14 +15,14 @@ import {
|
|||||||
type CampaignJobsResponse,
|
type CampaignJobsResponse,
|
||||||
type CampaignRetentionReport } from
|
type CampaignRetentionReport } from
|
||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { ContentGrid, Card } from "@govoplan/core-webui";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||||
import { Dialog } from "@govoplan/core-webui";
|
import { Dialog } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
||||||
@@ -445,24 +446,28 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
[busyAction, retryFailedSynchronously]);
|
[busyAction, retryFailedSynchronously]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="detail"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.report.ee45c303</PageTitle>
|
title="i18n:govoplan-campaign.report.ee45c303"
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />
|
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at ?? data.summary?.generated_at} />}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error || actionError}
|
||||||
|
success={actionMessage}
|
||||||
|
actions={<PageActionBar
|
||||||
|
variant="detail"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reloadAll(), loading: loading || jobsLoading }}
|
||||||
|
primaryActions={<>
|
||||||
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
<Button onClick={() => void exportCsv()} disabled={busyAction === "csv"}>i18n:govoplan-campaign.download_csv.eaa216ad</Button>
|
||||||
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
<Button onClick={() => setEmailOpen(true)}>i18n:govoplan-campaign.email_report.ee3e7091</Button>
|
||||||
<Button onClick={() => void reloadAll()} disabled={loading || jobsLoading}>Discard</Button>
|
</>}
|
||||||
</div>
|
/>}
|
||||||
</div>
|
>
|
||||||
{(error || actionError) && <DismissibleAlert tone="danger" resetKey={`${error}${actionError}`} floating>{error || actionError}</DismissibleAlert>}
|
|
||||||
{actionMessage && <DismissibleAlert tone="success" resetKey={actionMessage} floating>{actionMessage}</DismissibleAlert>}
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_report_data.0908ade5">
|
||||||
<div className="dashboard-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||||
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||||
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||||
const active = activeJobGridShortcut === shortcutId;
|
const active = activeJobGridShortcut === shortcutId;
|
||||||
@@ -477,10 +482,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||||
const active = activeJobGridShortcut === shortcutId;
|
const active = activeJobGridShortcut === shortcutId;
|
||||||
return (
|
return (
|
||||||
@@ -498,10 +503,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.execution_snapshot.5a67f098</dt><dd title={String(delivery.execution_snapshot_hash ?? "")}>{delivery.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: String(delivery.execution_snapshot_hash).slice(0, 12) }) : "i18n:govoplan-campaign.missing.92185dc5"}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Postbox receipt evidence">
|
<Card title="Postbox receipt evidence">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div>
|
<div>
|
||||||
<dt>Evidence state</dt>
|
<dt>Evidence state</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -522,7 +527,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<div><dt>Read</dt><dd>{String(postboxReceipts.read_delivery_count ?? "—")}</dd></div>
|
<div><dt>Read</dt><dd>{String(postboxReceipts.read_delivery_count ?? "—")}</dd></div>
|
||||||
<div><dt>Acknowledged</dt><dd>{String(postboxReceipts.acknowledged_delivery_count ?? "—")}</dd></div>
|
<div><dt>Acknowledged</dt><dd>{String(postboxReceipts.acknowledged_delivery_count ?? "—")}</dd></div>
|
||||||
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
|
<div><dt>Withdrawn / expired copies</dt><dd>{String(Number(postboxReceipts.withdrawn_message_count ?? 0) + Number(postboxReceipts.expired_message_count ?? 0))}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
<RetentionPrivacyCard retention={retention} />
|
<RetentionPrivacyCard retention={retention} />
|
||||||
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
<Card title="i18n:govoplan-campaign.explicit_delivery_actions.b35e72a4">
|
||||||
@@ -535,7 +540,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
<Button onClick={() => void runExplicitAction("unattempted")} disabled={!version || Boolean(busyAction)}>i18n:govoplan-campaign.send_unattempted_jobs.db7acc9f</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</ContentGrid>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
||||||
<p className="muted small-note">
|
<p className="muted small-note">
|
||||||
@@ -606,7 +611,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
|
|
||||||
{detail &&
|
{detail &&
|
||||||
<div className="stacked-sections">
|
<div className="stacked-sections">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(detail.job.recipient_email ?? "—")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(detail.job.subject ?? "—")}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
<div><dt>i18n:govoplan-campaign.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||||
@@ -618,7 +623,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.imap_status ?? "unknown"))} /></dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.attachments.6771ade6</dt><dd>{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}</dd></div>
|
||||||
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
|
<div><dt>Message SHA-256</dt><dd><code>{String(detail.job.eml_sha256 ?? "—")}</code></dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<PostboxTargetEvidenceSection
|
<PostboxTargetEvidenceSection
|
||||||
targets={
|
targets={
|
||||||
Array.isArray(detail.job.resolved_postbox_targets)
|
Array.isArray(detail.job.resolved_postbox_targets)
|
||||||
@@ -647,7 +652,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
|||||||
onConfirm={() => void reconcileOutcome()}
|
onConfirm={() => void reconcileOutcome()}
|
||||||
onCancel={() => setReconcile(null)} />
|
onCancel={() => setReconcile(null)} />
|
||||||
|
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,7 +675,7 @@ function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionRepo
|
|||||||
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
|
<Card title="i18n:govoplan-campaign.retention_and_privacy.24418676">
|
||||||
<p>{retention.privacy_impact.summary}</p>
|
<p>{retention.privacy_impact.summary}</p>
|
||||||
{retention.policy_reason && <p className="muted">{retention.policy_reason}</p>}
|
{retention.policy_reason && <p className="muted">{retention.policy_reason}</p>}
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div>
|
<div>
|
||||||
<dt>i18n:govoplan-campaign.policy_state.7e955a0d</dt>
|
<dt>i18n:govoplan-campaign.policy_state.7e955a0d</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -712,7 +717,7 @@ function RetentionPrivacyCard({ retention }: { retention?: CampaignRetentionRepo
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { lazy, useEffect, useState } from "react";
|
|||||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router";
|
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
ConcurrencyConflictProvider,
|
ConcurrencyConflictProvider,
|
||||||
|
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"));
|
||||||
@@ -22,8 +24,9 @@ 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 sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
const sectionPaths: Record<CampaignWorkspaceNavigationSection, string> = {
|
||||||
overview: "",
|
overview: "",
|
||||||
campaign: "recipients",
|
campaign: "recipients",
|
||||||
"global-settings": "global-settings",
|
"global-settings": "global-settings",
|
||||||
@@ -37,6 +40,7 @@ const sectionPaths: Record<CampaignWorkspaceSection, string> = {
|
|||||||
"mail-policy": "mail-policy",
|
"mail-policy": "mail-policy",
|
||||||
review: "review",
|
review: "review",
|
||||||
report: "report",
|
report: "report",
|
||||||
|
activity: "activity",
|
||||||
audit: "audit",
|
audit: "audit",
|
||||||
json: "json"
|
json: "json"
|
||||||
};
|
};
|
||||||
@@ -72,7 +76,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);
|
||||||
@@ -85,9 +89,13 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="workspace">
|
<WorkspaceLayout
|
||||||
<SectionSidebar active={active} onSelect={select} />
|
primary={<SectionSidebar active={active} onSelect={select} canReadActivity={hasScope(auth, "campaigns:discussion:read")} />}
|
||||||
<section className="workspace-content">
|
primaryLabel="Campaign sections"
|
||||||
|
contentLabel="Campaign workspace"
|
||||||
|
interfaceId="campaign.workspace"
|
||||||
|
helpModuleId="campaign"
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<CampaignOverviewPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
<Route index element={<CampaignOverviewPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||||
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
||||||
@@ -95,7 +103,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" />} />
|
||||||
@@ -107,6 +115,7 @@ 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="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 || ""} />} />
|
||||||
@@ -121,12 +130,11 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
|||||||
<Route path="Review" element={<Navigate to="../" replace />} />
|
<Route path="Review" element={<Navigate to="../" replace />} />
|
||||||
<Route path="*" element={<Navigate to="../" replace />} />
|
<Route path="*" element={<Navigate to="../" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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];
|
||||||
|
|
||||||
@@ -144,6 +152,7 @@ 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 === "audit") return "audit";
|
if (section === "audit") return "audit";
|
||||||
if (section === "json") return "json";
|
if (section === "json") return "json";
|
||||||
return "overview";
|
return "overview";
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
type CampaignCalendarCatalog,
|
type CampaignCalendarCatalog,
|
||||||
type CampaignPostboxCatalog
|
type CampaignPostboxCatalog
|
||||||
} from "../../api/campaigns";
|
} from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { PolicyRow } from "@govoplan/core-webui";
|
import { PolicyRow } from "@govoplan/core-webui";
|
||||||
import { PolicyTable } from "@govoplan/core-webui";
|
import { PolicyTable } from "@govoplan/core-webui";
|
||||||
@@ -178,21 +178,24 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="editor"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>{pageTitle}</PageTitle>
|
title={pageTitle}
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error}
|
||||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
actions={<PageActionBar
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>
|
variant="editor"
|
||||||
</div>
|
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
</div>
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
/>}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
notices={(localError || locked) ? <>
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
</> : undefined}
|
||||||
|
>
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
{isPolicyView ?
|
{isPolicyView ?
|
||||||
@@ -209,7 +212,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="dashboard-grid below-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="below-grid">
|
||||||
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
|
<Card title="i18n:govoplan-campaign.validation_policy.57dcc756" collapsible>
|
||||||
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
|
<PolicyTable className="campaign-policy-table" rowClassName="campaign-policy-row" headerClassName="campaign-policy-row-header" fieldLabel="i18n:govoplan-campaign.policy.bb9cf141" settingLabel="i18n:govoplan-campaign.setting.fb449f71" effectiveLabel="i18n:govoplan-campaign.effective_behavior.3daa11c2" showEffectiveColumn>
|
||||||
<PolicyRow
|
<PolicyRow
|
||||||
@@ -319,20 +322,20 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
|
|
||||||
</PolicyTable>
|
</PolicyTable>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</ContentGrid>
|
||||||
</> :
|
</> :
|
||||||
|
|
||||||
<>
|
<>
|
||||||
{data.campaign && <CampaignAccessCard settings={settings} auth={auth} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
{data.campaign && <CampaignAccessCard settings={settings} auth={auth} campaign={data.campaign} onChanged={reload} onError={setError} />}
|
||||||
|
|
||||||
<div className="dashboard-grid below-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="below-grid">
|
||||||
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
<Card title="i18n:govoplan-campaign.delivery_defaults.33cc3b97" collapsible>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} disabled={locked} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||||
</div>
|
</FormGrid>
|
||||||
{postboxModuleInstalled &&
|
{postboxModuleInstalled &&
|
||||||
<div className="campaign-postbox-delivery-settings">
|
<div className="campaign-postbox-delivery-settings">
|
||||||
<FormField label="Delivery channels">
|
<FormField label="Delivery channels">
|
||||||
@@ -373,7 +376,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Calendar invitations" collapsible>
|
<Card title="Calendar invitations" collapsible>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Send individualized invitations"
|
label="Send individualized invitations"
|
||||||
checked={getBool(calendarInvitation, "enabled")}
|
checked={getBool(calendarInvitation, "enabled")}
|
||||||
@@ -452,7 +455,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
onChange={(event) => patch(["delivery", "calendar_invitation", "description_template"], event.target.value)}
|
onChange={(event) => patch(["delivery", "calendar_invitation", "description_template"], event.target.value)}
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
{!mailModuleInstalled &&
|
{!mailModuleInstalled &&
|
||||||
<DismissibleAlert tone="warning" dismissible={false}>
|
<DismissibleAlert tone="warning" dismissible={false}>
|
||||||
Mail must be active to deliver iCalendar invitations.
|
Mail must be active to deliver iCalendar invitations.
|
||||||
@@ -481,7 +484,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
</div>
|
</div>
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
|
<p className="muted small-note">i18n:govoplan-campaign.these_opt_ins_are_stored_in_the_draft_editor_met.dc6ffbfd</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</ContentGrid>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
@@ -500,7 +503,7 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
|||||||
onClose={() => setPostboxTargetsOpen(false)}
|
onClose={() => setPostboxTargetsOpen(false)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { FormGrid,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -8,7 +9,8 @@ import {
|
|||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MailServerFolderLookupResultView,
|
MailServerFolderLookupResultView,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
PageTitle,
|
PageActionBar,
|
||||||
|
PageLayout,
|
||||||
PasswordField,
|
PasswordField,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
usePlatformModuleInstalled,
|
usePlatformModuleInstalled,
|
||||||
@@ -326,21 +328,32 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype={isPolicyView ? "detail" : "editor"}
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>{isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}</PageTitle>
|
title={isPolicyView ? "i18n:govoplan-campaign.mail_policy.3eb5d32a" : "i18n:govoplan-campaign.mail_settings.19e07f55"}
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error}
|
||||||
{!isPolicyView && <Button variant="primary" onClick={() => void saveDraft("manual")} disabled={!canSave}>{dirty ? "i18n:govoplan-campaign.save_now.3989b7c0" : "i18n:govoplan-campaign.saved.c0ae8f6e"}</Button>}
|
actions={isPolicyView ? (
|
||||||
<Button onClick={() => void discardDraft()} disabled={loading}>i18n:govoplan-campaign.discard.36fff63c</Button>
|
<PageActionBar
|
||||||
</div>
|
variant="detail"
|
||||||
</div>
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload({ force: true }), loading }}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
/>
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
) : (
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
<PageActionBar
|
||||||
|
variant="editor"
|
||||||
|
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => void saveDraft("manual"), disabled: !canSave && dirty, disabledReason: !canSave && dirty ? "Resolve the current mail-settings blocker before saving." : undefined }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
notices={(localError || locked) ? <>
|
||||||
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
</> : undefined}
|
||||||
|
>
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
@@ -373,7 +386,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
|
<Button variant="primary" 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>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||||
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
|
<option value="">i18n:govoplan-campaign.select_a_mail_profile.76480af0</option>
|
||||||
@@ -408,14 +421,14 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||||
{selectedProfile && <div className="metric-grid inside">
|
{selectedProfile && <MetricGrid spacing="inset">
|
||||||
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
|
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
|
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
|
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
|
||||||
</div>}
|
</MetricGrid>}
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedSmtpServer || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedSmtpServer || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
||||||
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
|
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
|
||||||
@@ -425,12 +438,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||||
</Card>}
|
</Card>}
|
||||||
|
|
||||||
<Dialog
|
<Dialog variant="administration" size="wide"
|
||||||
open={credentialDialogOpen}
|
open={credentialDialogOpen}
|
||||||
title="Add campaign credential"
|
title="Add campaign credential"
|
||||||
onClose={() => setCredentialDialogOpen(false)}
|
onClose={() => setCredentialDialogOpen(false)}
|
||||||
closeDisabled={credentialSaving}
|
closeDisabled={credentialSaving}
|
||||||
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
|
|
||||||
footerClassName="button-row compact-actions"
|
footerClassName="button-row compact-actions"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
@@ -457,7 +469,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<h3>Campaign login</h3>
|
<h3>Campaign login</h3>
|
||||||
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
|
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
|
||||||
</header>
|
</header>
|
||||||
<div className="form-grid two">
|
<FormGrid columns={2} collapseAt="standard" className="">
|
||||||
<FormField label="Name">
|
<FormField label="Name">
|
||||||
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
|
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -467,17 +479,17 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
<FormField label="Password">
|
<FormField label="Password">
|
||||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
|
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<div className="form-grid two">
|
<FormGrid columns={2} collapseAt="standard" className="">
|
||||||
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
|
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
|
||||||
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
|
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
|
||||||
</div>
|
</FormGrid>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="mail-server-form-grid">
|
||||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
|
label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8"
|
||||||
@@ -497,11 +509,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
|||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
<MailServerFolderLookupResultView result={folderResult} disabled={locked || !imapAppendEnabled} onUseDetected={useDetectedSentFolder} floatingFailures />
|
<MailServerFolderLookupResultView result={folderResult} disabled={locked || !imapAppendEnabled} onUseDetected={useDetectedSentFolder} floatingFailures />
|
||||||
</div>
|
</FormGrid>
|
||||||
</Card>}
|
</Card>}
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -37,12 +38,12 @@ import {
|
|||||||
type CampaignSummary } from
|
type CampaignSummary } from
|
||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||||
import { Button, Dialog, FormField, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
import { FormGrid, Button, Dialog, FormField, MetricCard, MetricGrid, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridQueryState } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { i18nMessage } from "@govoplan/core-webui";
|
import { i18nMessage } from "@govoplan/core-webui";
|
||||||
@@ -79,7 +80,6 @@ import {
|
|||||||
} from "./review/ReviewWorkflowGuidance";
|
} from "./review/ReviewWorkflowGuidance";
|
||||||
import { calculateBuildReviewProgress } from "./review/reviewProgress";
|
import { calculateBuildReviewProgress } from "./review/reviewProgress";
|
||||||
import {
|
import {
|
||||||
WorkflowFact,
|
|
||||||
WorkflowNavigation,
|
WorkflowNavigation,
|
||||||
WorkflowStage,
|
WorkflowStage,
|
||||||
stageConnectorState,
|
stageConnectorState,
|
||||||
@@ -158,6 +158,11 @@ export default function ReviewSendPage({
|
|||||||
);
|
);
|
||||||
const validation = asRecord(version?.validation_summary);
|
const validation = asRecord(version?.validation_summary);
|
||||||
const build = asRecord(version?.build_summary);
|
const build = asRecord(version?.build_summary);
|
||||||
|
const attachmentReuse = asRecord(build.attachment_reuse);
|
||||||
|
const attachmentReusePolicy = asRecord(attachmentReuse.policy);
|
||||||
|
const attachmentReuseFindings = asArray(attachmentReuse.findings).map(asRecord);
|
||||||
|
const residualFileDisposition = asRecord(build.residual_file_disposition);
|
||||||
|
const residualFileRecipient = asRecord(residualFileDisposition.recipient);
|
||||||
const printOutput = asRecord(build.print_output);
|
const printOutput = asRecord(build.print_output);
|
||||||
const printArtifact = asRecord(printOutput.artifact);
|
const printArtifact = asRecord(printOutput.artifact);
|
||||||
const summary = liveSummary ?? data.summary;
|
const summary = liveSummary ?? data.summary;
|
||||||
@@ -927,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();
|
||||||
@@ -1287,7 +1293,27 @@ export default function ReviewSendPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollToStage(id: string) {
|
function scrollToStage(id: string) {
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
revealReviewElement(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealReviewElement(id: string) {
|
||||||
|
const target = document.getElementById(id);
|
||||||
|
if (!target) return;
|
||||||
|
const stage = target.classList.contains("review-flow-stage")
|
||||||
|
? target
|
||||||
|
: target.closest<HTMLElement>(".review-flow-stage");
|
||||||
|
stage?.querySelector<HTMLButtonElement>('.card-collapse-toggle[aria-expanded="false"]')?.click();
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
target.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBuiltDetails(filters: Record<string, string> = {}, includeAll = false) {
|
||||||
|
setShowAllReviewJobs(includeAll);
|
||||||
|
setReviewQuery({ sort: null, filters });
|
||||||
|
setReviewPage(1);
|
||||||
|
window.requestAnimationFrame(() => revealReviewElement("campaign-built-message-details"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]);
|
const matchedAttachments = numberFrom(attachmentSummary, ["total_matched_files"]);
|
||||||
@@ -1364,46 +1390,46 @@ export default function ReviewSendPage({
|
|||||||
reviewedExplicitCount === explicitReviewCount;
|
reviewedExplicitCount === explicitReviewCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page review-send-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="workspace"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading || Boolean(busy)}>i18n:govoplan-campaign.review_send.1627617d</PageTitle>
|
className="review-send-page"
|
||||||
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
|
title="i18n:govoplan-campaign.review_send.1627617d"
|
||||||
</div>
|
description={<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />}
|
||||||
<div className="button-row compact-actions">
|
headerLoading={loading || Boolean(busy)}
|
||||||
<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
|
error={error}
|
||||||
|
actions={<PageActionBar
|
||||||
|
variant="workspace"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload({ force: true }), loading: loading || Boolean(busy) }}
|
||||||
|
primaryActions={<Button onClick={() => void refreshQueueStatus(false)} disabled={!version || queueStatusLoading || Boolean(busy)}>
|
||||||
{queueStatusLoading ? "i18n:govoplan-campaign.refreshing_status.d8965739" : "i18n:govoplan-campaign.refresh_status.ade15a52"}
|
{queueStatusLoading ? "i18n:govoplan-campaign.refreshing_status.d8965739" : "i18n:govoplan-campaign.refresh_status.ade15a52"}
|
||||||
</Button>
|
</Button>}
|
||||||
<Button onClick={() => void reload({ force: true })} disabled={loading || Boolean(busy)}>Discard</Button>
|
/>}
|
||||||
</div>
|
notices={(message || (version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion))) ? <>
|
||||||
</div>
|
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
|
||||||
|
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) &&
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
<LockedVersionNotice
|
||||||
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
|
settings={settings}
|
||||||
|
campaignId={campaignId}
|
||||||
|
version={version}
|
||||||
{version && (historicalVersion || readyForDelivery || userLockedVersion || finalVersion) &&
|
currentVersionId={data.campaign?.current_version_id}
|
||||||
<LockedVersionNotice
|
reload={reload}
|
||||||
settings={settings}
|
message="i18n:govoplan-campaign.this_workflow_is_read_only_for_the_selected_vers.6626b342" />}
|
||||||
campaignId={campaignId}
|
</> : undefined}
|
||||||
version={version}
|
>
|
||||||
currentVersionId={data.campaign?.current_version_id}
|
|
||||||
reload={reload}
|
|
||||||
message="i18n:govoplan-campaign.this_workflow_is_read_only_for_the_selected_vers.6626b342" />
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_workflow_state.5319aae1">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_workflow_state.5319aae1">
|
||||||
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
|
<WorkflowNavigation stages={stages} onSelect={scrollToStage} />
|
||||||
|
|
||||||
<div className="review-flow-timeline" aria-label="i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b">
|
<div className="review-flow-timeline" aria-label="i18n:govoplan-campaign.campaign_review_and_sending_workflow.e784ea7b">
|
||||||
<WorkflowStage stage={stages[0]} nextState={stages[1].state} nextConnectorState={stageConnectorState(stages[1])}>
|
<WorkflowStage stage={stages[0]} nextState={stages[1].state} nextConnectorState={stageConnectorState(stages[1])}>
|
||||||
<div className="review-flow-fact-grid">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.status.bae7d5be" value={validationPresent ? validationOk ? "i18n:govoplan-campaign.passed.271d60f4" : "i18n:govoplan-campaign.needs_attention.a126722e" : "i18n:govoplan-campaign.not_run.9e019cd5"} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocking.d785c0d4" value={validationPresent ? validationErrors : "—"} drilldown={validationErrors > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} drilldown={validationWarnings > 0 && visibleValidationIssues.length > 0 ? { label: "i18n:govoplan-campaign.validation_details.aa503267", onActivate: () => revealReviewElement("campaign-validation-details") } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} drilldown={hasBuild && Number(cards?.needs_attention ?? 0) > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<ValidationWorkflowGuidance
|
<ValidationWorkflowGuidance
|
||||||
errors={validationErrors}
|
errors={validationErrors}
|
||||||
warnings={validationWarnings}
|
warnings={validationWarnings}
|
||||||
@@ -1412,19 +1438,21 @@ export default function ReviewSendPage({
|
|||||||
{validationPresent && validationErrors === 0 &&
|
{validationPresent && validationErrors === 0 &&
|
||||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
|
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
|
||||||
}
|
}
|
||||||
<AttachmentLinkingPreview
|
<div id="campaign-attachment-preview" tabIndex={-1}>
|
||||||
preview={attachmentPreview}
|
<AttachmentLinkingPreview
|
||||||
loading={attachmentPreviewLoading}
|
preview={attachmentPreview}
|
||||||
error={attachmentPreviewError}
|
loading={attachmentPreviewLoading}
|
||||||
linking={attachmentLinking}
|
error={attachmentPreviewError}
|
||||||
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
|
linking={attachmentLinking}
|
||||||
onRefresh={() => void reloadAttachmentPreview(false)}
|
disabled={!version || readOnlyVersion || readyForDelivery || Boolean(busy)}
|
||||||
onLink={() => void linkMatchedAttachmentFiles()} />
|
onRefresh={() => void reloadAttachmentPreview(false)}
|
||||||
|
onLink={() => void linkMatchedAttachmentFiles()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{visibleValidationIssues.length > 0 &&
|
{visibleValidationIssues.length > 0 &&
|
||||||
<div className="review-flow-data-section">
|
<div id="campaign-validation-details" className="review-flow-data-section" tabIndex={-1}>
|
||||||
<h3>i18n:govoplan-campaign.validation_details.aa503267</h3>
|
<h3>i18n:govoplan-campaign.validation_details.aa503267</h3>
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{visibleValidationIssues.map((issue, index) =>
|
{visibleValidationIssues.map((issue, index) =>
|
||||||
<div key={`${String(issue.code ?? "issue")}:${index}`}>
|
<div key={`${String(issue.code ?? "issue")}:${index}`}>
|
||||||
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
|
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
|
||||||
@@ -1435,7 +1463,7 @@ export default function ReviewSendPage({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">i18n:govoplan-campaign.showing.163d8174 {visibleValidationIssues.length} of {validationIssues.length} i18n:govoplan-campaign.validation_issue_s.c6a8b911</p>}
|
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">i18n:govoplan-campaign.showing.163d8174 {visibleValidationIssues.length} of {validationIssues.length} i18n:govoplan-campaign.validation_issue_s.c6a8b911</p>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -1456,18 +1484,53 @@ export default function ReviewSendPage({
|
|||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[1]} nextState={stages[2].state} nextConnectorState={stageConnectorState(stages[2])}>
|
<WorkflowStage stage={stages[1]} nextState={stages[2].state} nextConnectorState={stageConnectorState(stages[2])}>
|
||||||
<div className="review-flow-fact-grid">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} drilldown={buildBlocked > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ validation: 'list:["blocked"]' }) } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} drilldown={buildNeedsReview > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails({ validation: 'list:["warning","needs_review"]' }) } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} drilldown={buildReviewProgress.reviewed > 0 ? { label: "i18n:govoplan-campaign.reviewed.31ef8593", onActivate: () => showBuiltDetails({ reviewed: 'list:["yes"]' }, true) } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} drilldown={buildReviewProgress.remaining > 0 ? { label: "i18n:govoplan-campaign.review.e29a79fe", onActivate: () => showBuiltDetails({ reviewed: 'list:["no"]' }) } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} drilldown={reviewJobs.total > 0 ? { label: "i18n:govoplan-campaign.review_candidates.438b8b57", onActivate: () => showBuiltDetails() } : undefined} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
|
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
|
||||||
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
|
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
|
||||||
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
|
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
|
||||||
|
{hasBuild && Object.keys(residualFileDisposition).length > 0 && (
|
||||||
|
<div className="review-flow-data-section" data-residual-file-policy>
|
||||||
|
<h3>i18n:govoplan-campaign.unassigned_file_policy</h3>
|
||||||
|
<DescriptionList variant="inline">
|
||||||
|
<div><dt>i18n:govoplan-campaign.action.97c89a4d</dt><dd>{humanize(String(residualFileDisposition.action ?? "review"))}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-campaign.validation_policy.57dcc756</dt><dd>{humanize(String(residualFileDisposition.validation_behavior ?? "warn"))}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-campaign.unassigned_files_detected</dt><dd>{String(residualFileDisposition.residual_file_count ?? 0)}</dd></div>
|
||||||
|
<div><dt>i18n:govoplan-campaign.watched_sources</dt><dd>{String(residualFileDisposition.watched_source_count ?? 0)}</dd></div>
|
||||||
|
{residualFileRecipient.email ? <div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{String(residualFileRecipient.email)}</dd></div> : null}
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasBuild && Object.keys(attachmentReuse).length > 0 && (
|
||||||
|
<div className="review-flow-data-section" data-attachment-reuse-policy>
|
||||||
|
<h3>Attachment reuse policy</h3>
|
||||||
|
<DescriptionList variant="inline">
|
||||||
|
<div><dt>Action</dt><dd>{humanize(String(attachmentReusePolicy.action ?? "allow"))}</dd></div>
|
||||||
|
<div><dt>Allowed boundary</dt><dd>{humanize(String(attachmentReusePolicy.allow_within ?? "none"))}</dd></div>
|
||||||
|
<div><dt>Repeated files</dt><dd>{String(attachmentReuse.duplicate_file_count ?? 0)}</dd></div>
|
||||||
|
<div><dt>Allowed</dt><dd>{String(attachmentReuse.allowed_file_count ?? 0)}</dd></div>
|
||||||
|
<div><dt>Policy findings</dt><dd>{String(attachmentReuse.violation_file_count ?? 0)}</dd></div>
|
||||||
|
<div><dt>Affected messages</dt><dd>{String(attachmentReuse.affected_message_count ?? 0)}</dd></div>
|
||||||
|
</DescriptionList>
|
||||||
|
{attachmentReuseFindings.length > 0 && (
|
||||||
|
<ul className="small-note">
|
||||||
|
{attachmentReuseFindings.slice(0, 10).map((finding) => (
|
||||||
|
<li key={String(finding.file_fingerprint)}>
|
||||||
|
{String(finding.file_name ?? "Attachment")} · {String(finding.use_count ?? 0)} uses · {humanize(String(finding.disposition ?? "allowed"))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{attachmentReuseFindings.length > 10 && <p className="muted small-note">Showing 10 of {attachmentReuseFindings.length} repeated files.</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{getText(printOutput, "render_id") && (
|
{getText(printOutput, "render_id") && (
|
||||||
<div className="review-flow-data-section">
|
<div className="review-flow-data-section">
|
||||||
<div className="page-heading split">
|
<div className="page-heading split">
|
||||||
@@ -1489,11 +1552,11 @@ export default function ReviewSendPage({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>Template hash</dt><dd><code>{getText(printOutput, "template_hash") || "—"}</code></dd></div>
|
<div><dt>Template hash</dt><dd><code>{getText(printOutput, "template_hash") || "—"}</code></dd></div>
|
||||||
<div><dt>Input hash</dt><dd><code>{getText(printOutput, "input_hash") || "—"}</code></dd></div>
|
<div><dt>Input hash</dt><dd><code>{getText(printOutput, "input_hash") || "—"}</code></dd></div>
|
||||||
<div><dt>Output hash</dt><dd><code>{getText(printOutput, "output_sha256") || "—"}</code></dd></div>
|
<div><dt>Output hash</dt><dd><code>{getText(printOutput, "output_sha256") || "—"}</code></dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="button-row compact-actions review-flow-stage-actions">
|
<div className="button-row compact-actions review-flow-stage-actions">
|
||||||
@@ -1518,7 +1581,7 @@ export default function ReviewSendPage({
|
|||||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
|
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
|
||||||
}
|
}
|
||||||
{hasBuild &&
|
{hasBuild &&
|
||||||
<div className="review-flow-data-section">
|
<div id="campaign-built-message-details" className="review-flow-data-section" tabIndex={-1}>
|
||||||
<div className="page-heading split">
|
<div className="page-heading split">
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
<Button onClick={() => {setShowAllReviewJobs((value) => !value);setReviewPage(1);setJobsLoadedKey("");}} disabled={busy === "inspect"}>
|
<Button onClick={() => {setShowAllReviewJobs((value) => !value);setReviewPage(1);setJobsLoadedKey("");}} disabled={busy === "inspect"}>
|
||||||
@@ -1559,12 +1622,12 @@ export default function ReviewSendPage({
|
|||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[2]} nextState={stages[3].state} nextConnectorState={stageConnectorState(stages[3])}>
|
<WorkflowStage stage={stages[2]} nextState={stages[3].state} nextConnectorState={stageConnectorState(stages[3])}>
|
||||||
<div className="review-flow-fact-grid">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_smtp.78f2e2ca" value={mockResult ? mockSent : "—"} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.mock_failures.9475ce2a" value={mockResult ? mockFailed : "—"} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.skipped.5a000ad7" value={mockResult ? mockSkipped : "—"} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.captured_messages.a833d293" value={!mockWorkflowAvailable ? "i18n:govoplan-campaign.unavailable.2c9c1f79" : mockResult ? mockMailboxMessages.length : "—"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<div className="button-row compact-actions review-flow-stage-actions">
|
<div className="button-row compact-actions review-flow-stage-actions">
|
||||||
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
|
||||||
{busy === "mock" ? "i18n:govoplan-campaign.running_mock_delivery.06c6a3bf" : mockResult ? "i18n:govoplan-campaign.run_mock_delivery_again.77fa416e" : "i18n:govoplan-campaign.run_mock_delivery.b74ff33d"}
|
{busy === "mock" ? "i18n:govoplan-campaign.running_mock_delivery.06c6a3bf" : mockResult ? "i18n:govoplan-campaign.run_mock_delivery_again.77fa416e" : "i18n:govoplan-campaign.run_mock_delivery.b74ff33d"}
|
||||||
@@ -1616,19 +1679,19 @@ export default function ReviewSendPage({
|
|||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[3]} nextState={stages[4].state} nextConnectorState={stageConnectorState(stages[4])}>
|
<WorkflowStage stage={stages[3]} nextState={stages[4].state} nextConnectorState={stageConnectorState(stages[4])}>
|
||||||
<div className="review-flow-execution-summary">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<div><span>i18n:govoplan-campaign.recipients.78cbf8eb</span><strong>{jobsTotal || "—"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.recipients.78cbf8eb" value={jobsTotal || "—"} drilldown={hasBuild && jobsTotal > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
|
||||||
<div><span>i18n:govoplan-campaign.messages_to_send.f7763bf9</span><strong>{builtCount || jobsTotal || "—"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.messages_to_send.f7763bf9" value={builtCount || jobsTotal || "—"} drilldown={hasBuild && builtCount > 0 ? { label: "i18n:govoplan-campaign.show_all_messages.1c2107a1", onActivate: () => showBuiltDetails({}, true) } : undefined} />
|
||||||
<div><span>i18n:govoplan-campaign.matched_attachments.ead1eeb1</span><strong>{matchedAttachments || "—"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.matched_attachments.ead1eeb1" value={matchedAttachments || "—"} drilldown={matchedAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
|
||||||
<div><span>i18n:govoplan-campaign.missing_ambiguous.fffdd3f5</span><strong>{missingAttachments} / {ambiguousAttachments}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.missing_ambiguous.fffdd3f5" value={`${missingAttachments} / ${ambiguousAttachments}`} drilldown={missingAttachments + ambiguousAttachments > 0 ? { label: "i18n:govoplan-campaign.review_attachment_rules.552c2116", onActivate: () => revealReviewElement("campaign-attachment-preview") } : undefined} />
|
||||||
<div><span>i18n:govoplan-campaign.rate_limit.d08e55f5</span><strong>{messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.rate_limit.d08e55f5" value={messagesPerMinute > 0 ? i18nMessage("i18n:govoplan-campaign.value_min.c9d89eae", { value0: messagesPerMinute }) : "i18n:govoplan-campaign.not_set.93039e60"} />
|
||||||
<div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.minimum_duration.91a71a6d" value={estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"} />
|
||||||
<div><span>i18n:govoplan-campaign.imap_append.8c0d9e96</span><strong>{Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_append.8c0d9e96" value={Boolean(imapAppend.enabled) ? "i18n:govoplan-campaign.enabled.df174a3f" : "i18n:govoplan-campaign.disabled.f4f4473d"} />
|
||||||
<div><span>i18n:govoplan-campaign.synchronous_limit.f88c0bcd</span><strong>{deliveryOptionsLoading ? "…" : synchronousSendLimit}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.synchronous_limit.f88c0bcd" value={deliveryOptionsLoading ? "…" : synchronousSendLimit} />
|
||||||
<div><span>i18n:govoplan-campaign.limit_source.bd933adb</span><strong>{deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.limit_source.bd933adb" value={deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))} />
|
||||||
<div><span>i18n:govoplan-campaign.worker_queue.c911e32c</span><strong>{workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.worker_queue.c911e32c" value={workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"} />
|
||||||
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.version.2da600bf" value={version ? `v${version.version_number}` : "—"} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
||||||
<div className="review-send-controls">
|
<div className="review-send-controls">
|
||||||
<span>Approval gate</span>
|
<span>Approval gate</span>
|
||||||
@@ -1642,12 +1705,12 @@ export default function ReviewSendPage({
|
|||||||
{approvalGateConfigured &&
|
{approvalGateConfigured &&
|
||||||
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
|
<Button disabled={Boolean(busy)} onClick={() => navigate("/approvals")}>Open approval</Button>}
|
||||||
</div>
|
</div>
|
||||||
<div className="review-flow-fact-grid">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued.6a599877" value={queuedSendCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.claimed_sending.6951622a" value={activeSendCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.not_queued.b7f41e1e" value={notQueuedCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.send_attempts.2cdb19e8" value={sendAttemptCount} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
{queueStatusNote &&
|
{queueStatusNote &&
|
||||||
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
|
<p className={`review-flow-inline-note ${queueStatusTone}`.trim()}>{queueStatusNote}</p>
|
||||||
}
|
}
|
||||||
@@ -1703,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`}
|
||||||
@@ -1718,18 +1788,18 @@ export default function ReviewSendPage({
|
|||||||
</WorkflowStage>
|
</WorkflowStage>
|
||||||
|
|
||||||
<WorkflowStage stage={stages[4]}>
|
<WorkflowStage stage={stages[4]}>
|
||||||
<div className="review-flow-fact-grid">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_failed.50dbca55" value={imapFailedForDisplay} />
|
||||||
<WorkflowFact label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
|
<MetricCard density="compact" surface="subtle" label="i18n:govoplan-campaign.imap_skipped.5a97b542" value={imapSkipped} />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<div className="review-flow-result-line">
|
<div className="review-flow-result-line">
|
||||||
<StatusBadge status={deliveryDisplayStatus} />
|
<StatusBadge status={deliveryDisplayStatus} />
|
||||||
<span>{deliveryStarted ? "i18n:govoplan-campaign.delivery_activity_is_available_in_the_report_and.cb163d1d" : "i18n:govoplan-campaign.no_real_delivery_has_started_for_this_campaign_v.3b9235ff"}</span>
|
<span>{deliveryStarted ? "i18n:govoplan-campaign.delivery_activity_is_available_in_the_report_and.cb163d1d" : "i18n:govoplan-campaign.no_real_delivery_has_started_for_this_campaign_v.3b9235ff"}</span>
|
||||||
@@ -1816,7 +1886,7 @@ export default function ReviewSendPage({
|
|||||||
{recentFailures.length > 0 &&
|
{recentFailures.length > 0 &&
|
||||||
<div className="review-flow-data-section">
|
<div className="review-flow-data-section">
|
||||||
<h3>i18n:govoplan-campaign.recent_delivery_failures.282285fb</h3>
|
<h3>i18n:govoplan-campaign.recent_delivery_failures.282285fb</h3>
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{recentFailures.map((failure, index) =>
|
{recentFailures.map((failure, index) =>
|
||||||
<div key={String(failure.job_id ?? index)}>
|
<div key={String(failure.job_id ?? index)}>
|
||||||
<dt>{String(failure.recipient_email ?? failure.entry_id ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: String(failure.entry_index ?? index + 1) }))}</dt>
|
<dt>{String(failure.recipient_email ?? failure.entry_id ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: String(failure.entry_index ?? index + 1) }))}</dt>
|
||||||
@@ -1827,7 +1897,7 @@ export default function ReviewSendPage({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="button-row compact-actions">
|
<div className="button-row compact-actions">
|
||||||
@@ -1900,7 +1970,7 @@ export default function ReviewSendPage({
|
|||||||
{busy === "approval" ? "Requesting..." : "Request approval"}
|
{busy === "approval" ? "Requesting..." : "Request approval"}
|
||||||
</Button>
|
</Button>
|
||||||
</>}>
|
</>}>
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
|
<p className="muted small-note form-grid-wide">The request covers the exact built message, attachment, recipient, policy, and transport snapshot. Rebuilding invalidates and removes this gate.</p>
|
||||||
<FormField label="Approver type">
|
<FormField label="Approver type">
|
||||||
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
|
<select value={approvalSelectorKind} disabled={busy === "approval"} onChange={(event) => setApprovalSelectorKind(event.target.value as typeof approvalSelectorKind)}>
|
||||||
@@ -1934,7 +2004,7 @@ export default function ReviewSendPage({
|
|||||||
})} />)}
|
})} />)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormGrid>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -2030,7 +2100,7 @@ export default function ReviewSendPage({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{singleSendConfirmRow && (
|
{singleSendConfirmRow && (
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={singleMessageActionKind}
|
value={singleMessageActionKind}
|
||||||
width="fill"
|
width="fill"
|
||||||
@@ -2090,7 +2160,7 @@ export default function ReviewSendPage({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FormGrid>
|
||||||
)}
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -2113,6 +2183,6 @@ export default function ReviewSendPage({
|
|||||||
onClose={() => setSelectedMockMessage(null)} />
|
onClose={() => setSelectedMockMessage(null)} />
|
||||||
|
|
||||||
}
|
}
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
|
listCampaignContentLibrary,
|
||||||
listCampaignPrintTemplates,
|
listCampaignPrintTemplates,
|
||||||
previewCampaignAttachments,
|
previewCampaignAttachments,
|
||||||
|
saveCampaignContentLibraryItem,
|
||||||
type CampaignAttachmentPreviewRule,
|
type CampaignAttachmentPreviewRule,
|
||||||
|
type CampaignContentLibraryItem,
|
||||||
|
type CampaignContentLibraryResponse,
|
||||||
|
type CampaignContentLibraryTarget,
|
||||||
type CampaignPrintTemplate
|
type CampaignPrintTemplate
|
||||||
} from "../../api/campaigns";
|
} from "../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { ConfirmDialog, Dialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { FieldLabel } from "@govoplan/core-webui";
|
import { FieldLabel } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
import { DismissibleAlert, SegmentedControl, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
|
import { WysiwygEditor, type WysiwygEditorHandle } from "@govoplan/core-webui/wysiwyg";
|
||||||
@@ -25,10 +31,19 @@ import { cloneJson, getBool, getText } from "./utils/draftEditor";
|
|||||||
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
import { humanizeFieldName } from "./utils/fieldDefinitions";
|
||||||
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
|
import { campaignJsonForAttachmentPreview } from "./utils/templatePreviewDraft";
|
||||||
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions, removePlaceholderFromText, replacePlaceholderInText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
|
||||||
|
import { campaignContentFieldType, checkContentLibraryCompatibility, type ContentLibraryCompatibility } from "./utils/contentLibraryCompatibility";
|
||||||
|
|
||||||
type TemplateBodyMode = "text" | "html" | "both";
|
type TemplateBodyMode = "text" | "html" | "both";
|
||||||
type BodyEditorMode = "text" | "html";
|
type BodyEditorMode = "text" | "html";
|
||||||
type EditorTarget = "subject" | "text" | "html";
|
type EditorTarget = "subject" | "text" | "html";
|
||||||
|
type ContentLibrarySaveKind = "fragment" | "campaign_part";
|
||||||
|
type PendingContentApply = {
|
||||||
|
item: CampaignContentLibraryItem;
|
||||||
|
target?: CampaignContentLibraryTarget;
|
||||||
|
value?: string;
|
||||||
|
overwrites: boolean;
|
||||||
|
compatibility: ContentLibraryCompatibility;
|
||||||
|
};
|
||||||
|
|
||||||
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
export default function TemplateDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||||
@@ -44,6 +59,20 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
|
const [printTemplates, setPrintTemplates] = useState<CampaignPrintTemplate[]>([]);
|
||||||
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
|
const [printTemplatesLoading, setPrintTemplatesLoading] = useState(true);
|
||||||
const [printTemplatesError, setPrintTemplatesError] = useState("");
|
const [printTemplatesError, setPrintTemplatesError] = useState("");
|
||||||
|
const [contentLibraryOpen, setContentLibraryOpen] = useState(false);
|
||||||
|
const [contentLibraryQuery, setContentLibraryQuery] = useState("");
|
||||||
|
const [contentLibrary, setContentLibrary] = useState<CampaignContentLibraryResponse | null>(null);
|
||||||
|
const [contentLibraryLoading, setContentLibraryLoading] = useState(false);
|
||||||
|
const [contentLibraryError, setContentLibraryError] = useState("");
|
||||||
|
const [contentSaveOpen, setContentSaveOpen] = useState(false);
|
||||||
|
const [contentSaveBusy, setContentSaveBusy] = useState(false);
|
||||||
|
const [contentSaveName, setContentSaveName] = useState("");
|
||||||
|
const [contentSaveDescription, setContentSaveDescription] = useState("");
|
||||||
|
const [contentSaveKind, setContentSaveKind] = useState<ContentLibrarySaveKind>("fragment");
|
||||||
|
const [contentSaveTarget, setContentSaveTarget] = useState<CampaignContentLibraryTarget>("text");
|
||||||
|
const [contentSaveVisibility, setContentSaveVisibility] = useState<"personal" | "tenant">("personal");
|
||||||
|
const [contentLibraryNotice, setContentLibraryNotice] = useState("");
|
||||||
|
const [pendingContentApply, setPendingContentApply] = useState<PendingContentApply | null>(null);
|
||||||
const subjectRef = useRef<HTMLInputElement | null>(null);
|
const subjectRef = useRef<HTMLInputElement | null>(null);
|
||||||
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
const textRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
|
const htmlRef = useRef<WysiwygEditorHandle | null>(null);
|
||||||
@@ -68,6 +97,8 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
const printConfig = asRecord(delivery.print);
|
const printConfig = asRecord(delivery.print);
|
||||||
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
|
const selectedPrintTemplate = printTemplates.find((item) => item.id === getText(printConfig, "template_id")) ?? null;
|
||||||
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
const templateBodyMode = normalizeTemplateBodyMode(getText(template, "body_mode", "both"));
|
||||||
|
const contentSaveSelectedValue = getText(template, contentSaveTarget);
|
||||||
|
const contentSaveHasBody = Boolean(getText(template, "text").trim() || getText(template, "html").trim());
|
||||||
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
|
const visibleBodyEditor: BodyEditorMode = templateBodyMode === "html" ? "html" : templateBodyMode === "text" ? "text" : activeBodyEditor;
|
||||||
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
|
const fields = useMemo(() => asArray(displayDraft.fields).map(asRecord), [displayDraft.fields]);
|
||||||
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
const localFieldNames = useMemo(() => fields.map((field) => String(field.name || field.id || "")).filter(Boolean), [fields]);
|
||||||
@@ -76,6 +107,25 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
|
const localAvailableNames = useMemo(() => new Set([...localFieldNames, ...builtInAddressNames]), [builtInAddressNames, localFieldNames]);
|
||||||
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
|
const globalAvailableNames = useMemo(() => new Set(globalFieldNames), [globalFieldNames]);
|
||||||
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
|
const allAvailableNames = useMemo(() => new Set([...localAvailableNames, ...globalAvailableNames]), [globalAvailableNames, localAvailableNames]);
|
||||||
|
const contentAvailableFields = useMemo(() => {
|
||||||
|
const available = new Map<string, string>();
|
||||||
|
for (const field of fields) {
|
||||||
|
const name = String(field.name || field.id || "").trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const valueType = campaignFieldDefinitionType(String(field.type || "string"));
|
||||||
|
available.set(name, valueType);
|
||||||
|
available.set(`local:${name}`, valueType);
|
||||||
|
available.set(`global:${name}`, valueType);
|
||||||
|
}
|
||||||
|
for (const [name, value] of Object.entries(asRecord(displayDraft.global_values))) {
|
||||||
|
const valueType = available.get(name) ?? campaignContentFieldType(value);
|
||||||
|
available.set(name, valueType);
|
||||||
|
available.set(`local:${name}`, valueType);
|
||||||
|
available.set(`global:${name}`, valueType);
|
||||||
|
}
|
||||||
|
for (const name of builtInAddressNames) available.set(`local:${name}`, "string");
|
||||||
|
return available;
|
||||||
|
}, [builtInAddressNames, displayDraft.global_values, fields]);
|
||||||
const localFieldOptions = useMemo(() => {
|
const localFieldOptions = useMemo(() => {
|
||||||
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
|
const options = [...recipientAddressTemplateFieldOptions(), ...localFieldNames.map((name) => ({ name, label: name }))];
|
||||||
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
|
return options.filter((option, index) => options.findIndex((candidate) => candidate.name === option.name) === index);
|
||||||
@@ -179,6 +229,32 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
}, [campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentLibraryOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setContentLibraryLoading(true);
|
||||||
|
setContentLibraryError("");
|
||||||
|
const handle = window.setTimeout(() => {
|
||||||
|
void listCampaignContentLibrary(settings, campaignId, contentLibraryQuery)
|
||||||
|
.then((response) => {
|
||||||
|
if (!cancelled) setContentLibrary(response);
|
||||||
|
})
|
||||||
|
.catch((reason: unknown) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setContentLibrary(null);
|
||||||
|
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setContentLibraryLoading(false);
|
||||||
|
});
|
||||||
|
}, 180);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(handle);
|
||||||
|
};
|
||||||
|
}, [campaignId, contentLibraryOpen, contentLibraryQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||||
|
|
||||||
|
|
||||||
function patchTemplateText(target: EditorTarget, value: string) {
|
function patchTemplateText(target: EditorTarget, value: string) {
|
||||||
patch(["template", target], value);
|
patch(["template", target], value);
|
||||||
@@ -280,30 +356,149 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
setUndefinedDialog(null);
|
setUndefinedDialog(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openContentSaveDialog() {
|
||||||
|
setContentSaveName("");
|
||||||
|
setContentSaveDescription("");
|
||||||
|
setContentSaveKind("fragment");
|
||||||
|
setContentSaveTarget(activeEditor === "subject" ? "subject" : visibleBodyEditor);
|
||||||
|
setContentSaveVisibility("personal");
|
||||||
|
setContentLibraryError("");
|
||||||
|
setContentSaveOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertContentFragment(target: CampaignContentLibraryTarget, value: string) {
|
||||||
|
if (locked || !value) return;
|
||||||
|
if (target === "html") {
|
||||||
|
const inserted = htmlRef.current?.insertText(value) ?? false;
|
||||||
|
if (!inserted) patchTemplateText("html", `${getText(template, "html")}${value}`);
|
||||||
|
setActiveBodyEditor("html");
|
||||||
|
setActiveEditor("html");
|
||||||
|
setContentLibraryOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const element = target === "subject" ? subjectRef.current : textRef.current;
|
||||||
|
const currentText = getText(template, target);
|
||||||
|
const start = element?.selectionStart ?? currentText.length;
|
||||||
|
const end = element?.selectionEnd ?? currentText.length;
|
||||||
|
patchTemplateText(target, `${currentText.slice(0, start)}${value}${currentText.slice(end)}`);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
element?.focus();
|
||||||
|
const cursor = start + value.length;
|
||||||
|
element?.setSelectionRange(cursor, cursor);
|
||||||
|
});
|
||||||
|
if (target === "text") setActiveBodyEditor("text");
|
||||||
|
setActiveEditor(target);
|
||||||
|
setContentLibraryOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestContentFragment(item: CampaignContentLibraryItem, target: CampaignContentLibraryTarget, value: string) {
|
||||||
|
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
|
||||||
|
if (compatibility.compatible) {
|
||||||
|
insertContentFragment(target, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingContentApply({ item, target, value, overwrites: false, compatibility });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCampaignPart(item: CampaignContentLibraryItem) {
|
||||||
|
if (locked) return;
|
||||||
|
setDraft((current) => {
|
||||||
|
const next = cloneJson(current ?? {});
|
||||||
|
next.template = {
|
||||||
|
...asRecord(next.template),
|
||||||
|
subject: item.subject ?? "",
|
||||||
|
text: item.text ?? "",
|
||||||
|
html: item.html ?? "",
|
||||||
|
body_mode: item.body_mode
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
markDirty();
|
||||||
|
setActiveBodyEditor(item.body_mode === "html" ? "html" : "text");
|
||||||
|
setActiveEditor(item.body_mode === "html" ? "html" : "text");
|
||||||
|
setContentLibraryOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCampaignPart(item: CampaignContentLibraryItem) {
|
||||||
|
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
|
||||||
|
const overwrites = ["subject", "text", "html"].some((field) => {
|
||||||
|
const current = getText(template, field);
|
||||||
|
const replacement = field === "subject" ? item.subject : field === "text" ? item.text : item.html;
|
||||||
|
return Boolean(current.trim()) && current !== (replacement ?? "");
|
||||||
|
});
|
||||||
|
if (!overwrites && compatibility.compatible) {
|
||||||
|
applyCampaignPart(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingContentApply({ item, overwrites, compatibility });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmContentApply() {
|
||||||
|
const pending = pendingContentApply;
|
||||||
|
if (!pending) return;
|
||||||
|
setPendingContentApply(null);
|
||||||
|
if (pending.target && pending.value !== undefined) {
|
||||||
|
insertContentFragment(pending.target, pending.value);
|
||||||
|
} else {
|
||||||
|
applyCampaignPart(pending.item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReusableContent() {
|
||||||
|
if (contentSaveBusy || !contentSaveName.trim()) return;
|
||||||
|
setContentSaveBusy(true);
|
||||||
|
setContentLibraryError("");
|
||||||
|
try {
|
||||||
|
const result = await saveCampaignContentLibraryItem(settings, campaignId, {
|
||||||
|
name: contentSaveName.trim(),
|
||||||
|
description: contentSaveDescription.trim() || null,
|
||||||
|
kind: contentSaveKind,
|
||||||
|
target: contentSaveKind === "fragment" ? contentSaveTarget : null,
|
||||||
|
subject: getText(template, "subject"),
|
||||||
|
text: getText(template, "text"),
|
||||||
|
html: getText(template, "html"),
|
||||||
|
body_mode: templateBodyMode,
|
||||||
|
locale: "de",
|
||||||
|
visibility: contentSaveVisibility
|
||||||
|
});
|
||||||
|
setContentSaveOpen(false);
|
||||||
|
setContentLibraryNotice(`${result.template.name} was saved as an unpublished Templates draft.`);
|
||||||
|
setContentLibrary(null);
|
||||||
|
} catch (reason) {
|
||||||
|
setContentLibraryError(reason instanceof Error ? reason.message : String(reason));
|
||||||
|
} finally {
|
||||||
|
setContentSaveBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="editor"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.template.3ec1ae06</PageTitle>
|
title="i18n:govoplan-campaign.template.3ec1ae06"
|
||||||
<VersionLine version={version} versions={data.versions} status={saveState} />
|
description={<VersionLine version={version} versions={data.versions} status={saveState} />}
|
||||||
</div>
|
headerLoading={loading}
|
||||||
<div className="button-row compact-actions">
|
error={error}
|
||||||
<Button disabled>i18n:govoplan-campaign.manage_templates.23688071</Button>
|
success={contentLibraryNotice}
|
||||||
<Button onClick={() => void discardDraft()} disabled={loading}>Discard</Button>
|
actions={<PageActionBar
|
||||||
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
variant="editor"
|
||||||
</div>
|
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
</div>
|
contextActions={<Button onClick={() => window.location.assign("/templates")}>i18n:govoplan-campaign.manage_templates.23688071</Button>}
|
||||||
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: () => void discardDraft() }}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: () => saveDraft("manual"), disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
/>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
notices={(localError || locked) ? <>
|
||||||
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={data.campaign?.current_version_id} reload={reload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
|
</> : undefined}
|
||||||
|
>
|
||||||
|
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
||||||
<>
|
<>
|
||||||
<div className="dashboard-grid template-editor-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="template-editor-grid">
|
||||||
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
|
<Card title="i18n:govoplan-campaign.editable_template.5e747a1e" actions={<Button onClick={() => setPreviewOpen(true)}>i18n:govoplan-campaign.preview.f1fbb2b4</Button>}>
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
|
<FormField label="i18n:govoplan-campaign.subject.8d183dbd">
|
||||||
<input
|
<input
|
||||||
ref={subjectRef}
|
ref={subjectRef}
|
||||||
@@ -371,16 +566,16 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div className="button-row template-editor-actions">
|
<div className="button-row template-editor-actions">
|
||||||
<Button disabled>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
<Button onClick={() => setContentLibraryOpen(true)} disabled={locked}>i18n:govoplan-campaign.load_from_library.327ada7c</Button>
|
||||||
<Button disabled>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
<Button onClick={openContentSaveDialog} disabled={locked}>i18n:govoplan-campaign.save_to_library.396649bf</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormGrid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="template-side-stack">
|
<div className="template-side-stack">
|
||||||
<Card title="Printable output">
|
<Card title="Printable output">
|
||||||
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
|
<LoadingFrame loading={printTemplatesLoading} label="Loading printable templates">
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
|
{printTemplatesError && <DismissibleAlert tone="danger" compact resetKey={printTemplatesError}>{printTemplatesError}</DismissibleAlert>}
|
||||||
{!printTemplatesError && !printTemplatesAvailable && (
|
{!printTemplatesError && !printTemplatesAvailable && (
|
||||||
<DismissibleAlert tone="info" compact dismissible={false}>
|
<DismissibleAlert tone="info" compact dismissible={false}>
|
||||||
@@ -433,7 +628,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</FormGrid>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-campaign.fields.e8b68527">
|
<Card title="i18n:govoplan-campaign.fields.e8b68527">
|
||||||
@@ -466,7 +661,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
<UndefinedPlaceholderList items={undefinedPlaceholders} onSelect={setUndefinedDialog} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
@@ -494,6 +689,173 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={contentLibraryOpen}
|
||||||
|
title="Reusable content"
|
||||||
|
className="campaign-content-library-dialog"
|
||||||
|
helpContextId="campaign.template.content-library"
|
||||||
|
onClose={() => setContentLibraryOpen(false)}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => window.location.assign("/templates")}>Manage Templates</Button>
|
||||||
|
<Button variant="primary" onClick={() => setContentLibraryOpen(false)}>Close</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<div className="campaign-content-library">
|
||||||
|
<FormField label="Search library" help="Searches content published or visible to your Templates scope.">
|
||||||
|
<input
|
||||||
|
value={contentLibraryQuery}
|
||||||
|
onChange={(event) => setContentLibraryQuery(event.target.value)}
|
||||||
|
placeholder="Name or description"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
|
||||||
|
<LoadingFrame loading={contentLibraryLoading} label="Loading reusable content">
|
||||||
|
<div className="campaign-content-library-list">
|
||||||
|
{!contentLibraryError && contentLibrary && !contentLibrary.available && (
|
||||||
|
<DismissibleAlert tone="info" dismissible={false}>{contentLibrary.reason || "Enable Templates to use reusable content."}</DismissibleAlert>
|
||||||
|
)}
|
||||||
|
{!contentLibraryError && contentLibrary?.available && contentLibrary.items.length === 0 && (
|
||||||
|
<p className="muted">No reusable Campaign content matches this search.</p>
|
||||||
|
)}
|
||||||
|
{contentLibrary?.items.map((item) => {
|
||||||
|
const compatibility = checkContentLibraryCompatibility(item.required_fields, contentAvailableFields);
|
||||||
|
return <div className="campaign-content-library-item" key={`${item.id}:${item.revision_id}`}>
|
||||||
|
<div className="campaign-content-library-item-copy">
|
||||||
|
<div className="campaign-content-library-item-title">
|
||||||
|
<strong>{item.name}</strong>
|
||||||
|
<span>{item.published ? `Published r${item.revision}` : `Draft r${item.revision}`}</span>
|
||||||
|
</div>
|
||||||
|
{item.description && <p>{item.description}</p>}
|
||||||
|
<small>{item.kind === "fragment" ? "Content fragment" : "Complete campaign part"} · {item.scope_type} · {item.locale || "unspecified locale"}</small>
|
||||||
|
{!compatibility.compatible && (
|
||||||
|
<div className="campaign-content-library-compatibility" role="status">
|
||||||
|
<strong>Field compatibility needs attention.</strong>
|
||||||
|
{compatibility.missing.length > 0 && <span>Missing: {compatibility.missing.map((field) => field.label).join(", ")}.</span>}
|
||||||
|
{compatibility.incompatible.length > 0 && <span>Wrong type: {compatibility.incompatible.map((field) => `${field.label} (${field.actual} instead of ${field.expected})`).join(", ")}.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="button-row campaign-content-library-item-actions">
|
||||||
|
{item.kind === "campaign_part" ? (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={locked}
|
||||||
|
onClick={() => requestCampaignPart(item)}
|
||||||
|
title="Replaces the current subject and body fields in this draft"
|
||||||
|
>Apply part</Button>
|
||||||
|
) : item.targets.map((target) => {
|
||||||
|
const value = target === "html" ? item.html : item.text;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={target}
|
||||||
|
disabled={locked || !value}
|
||||||
|
onClick={() => value && requestContentFragment(item, target, value)}
|
||||||
|
>Insert in {target}</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</LoadingFrame>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(pendingContentApply)}
|
||||||
|
title={pendingContentApply?.overwrites ? "Replace current Campaign content?" : "Apply content with field mismatches?"}
|
||||||
|
message={contentApplyConfirmationMessage(pendingContentApply)}
|
||||||
|
confirmLabel={pendingContentApply?.overwrites ? "Replace content" : "Apply content"}
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
helpContextId="campaign.template.content-library"
|
||||||
|
onConfirm={confirmContentApply}
|
||||||
|
onCancel={() => setPendingContentApply(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={contentSaveOpen}
|
||||||
|
title="Save reusable content"
|
||||||
|
size="large"
|
||||||
|
helpContextId="campaign.template.content-library"
|
||||||
|
closeDisabled={contentSaveBusy}
|
||||||
|
onClose={() => setContentSaveOpen(false)}
|
||||||
|
footer={<>
|
||||||
|
<Button onClick={() => setContentSaveOpen(false)} disabled={contentSaveBusy}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => void saveReusableContent()}
|
||||||
|
disabled={
|
||||||
|
contentSaveBusy ||
|
||||||
|
!contentSaveName.trim() ||
|
||||||
|
(contentSaveKind === "fragment" ? !contentSaveSelectedValue.trim() : !contentSaveHasBody)
|
||||||
|
}
|
||||||
|
>{contentSaveBusy ? "Saving..." : "Save draft"}</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<div className="campaign-content-save-form">
|
||||||
|
<DismissibleAlert tone="info" compact dismissible={false}>
|
||||||
|
Saving creates an unpublished Templates draft. Publication and later revisions remain governed in Templates.
|
||||||
|
</DismissibleAlert>
|
||||||
|
{contentLibraryError && <DismissibleAlert tone="danger" compact resetKey={contentLibraryError}>{contentLibraryError}</DismissibleAlert>}
|
||||||
|
<div className="campaign-content-save-identity">
|
||||||
|
<FormField label="Name">
|
||||||
|
<input value={contentSaveName} onChange={(event) => setContentSaveName(event.target.value)} autoFocus />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Visibility" help="Personal drafts are visible to you; tenant drafts are available to authorized template users.">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Template visibility"
|
||||||
|
value={contentSaveVisibility}
|
||||||
|
onChange={setContentSaveVisibility}
|
||||||
|
size="content"
|
||||||
|
width="inline"
|
||||||
|
options={[
|
||||||
|
{ id: "personal", label: "Personal" },
|
||||||
|
{ id: "tenant", label: "Tenant" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<FormField label="Description">
|
||||||
|
<textarea rows={3} value={contentSaveDescription} onChange={(event) => setContentSaveDescription(event.target.value)} />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Content kind">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Reusable content kind"
|
||||||
|
value={contentSaveKind}
|
||||||
|
onChange={setContentSaveKind}
|
||||||
|
size="content"
|
||||||
|
width="inline"
|
||||||
|
options={[
|
||||||
|
{ id: "fragment", label: "Fragment" },
|
||||||
|
{ id: "campaign_part", label: "Complete part" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
{contentSaveKind === "fragment" && (
|
||||||
|
<FormField label="Source field" help="The selected current field becomes the reusable fragment.">
|
||||||
|
<SegmentedControl
|
||||||
|
ariaLabel="Fragment source field"
|
||||||
|
value={contentSaveTarget}
|
||||||
|
onChange={setContentSaveTarget}
|
||||||
|
size="content"
|
||||||
|
width="inline"
|
||||||
|
options={[
|
||||||
|
{ id: "subject", label: "Subject" },
|
||||||
|
{ id: "text", label: "Text" },
|
||||||
|
{ id: "html", label: "HTML" }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
<p className="muted small-note">
|
||||||
|
{contentSaveKind === "fragment"
|
||||||
|
? `${contentSaveSelectedValue.length.toLocaleString()} characters from ${contentSaveTarget}.`
|
||||||
|
: "Subject, text, HTML, and body mode are stored as one reusable Campaign part."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<UndefinedPlaceholderDecisionDialog
|
<UndefinedPlaceholderDecisionDialog
|
||||||
field={undefinedDialog}
|
field={undefinedDialog}
|
||||||
contextLabel="template"
|
contextLabel="template"
|
||||||
@@ -505,7 +867,7 @@ export default function TemplateDataPage({ settings, campaignId }: {settings: Ap
|
|||||||
localFields={localFieldOptions}
|
localFields={localFieldOptions}
|
||||||
globalFields={globalFieldOptions} />
|
globalFields={globalFieldOptions} />
|
||||||
|
|
||||||
</div>);
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,6 +976,27 @@ function normalizeTemplateBodyMode(value: string): TemplateBodyMode {
|
|||||||
return "both";
|
return "both";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function campaignFieldDefinitionType(value: string): string {
|
||||||
|
if (value === "double") return "number";
|
||||||
|
if (value === "integer" || value === "date") return value;
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentApplyConfirmationMessage(pending: PendingContentApply | null): string {
|
||||||
|
if (!pending) return "";
|
||||||
|
const messages = pending.overwrites
|
||||||
|
? [`Applying ${pending.item.name} replaces the current subject and body in this editable Campaign draft.`]
|
||||||
|
: [`Applying ${pending.item.name} inserts content into this editable Campaign draft.`];
|
||||||
|
if (pending.compatibility.missing.length > 0) {
|
||||||
|
messages.push(`Missing required fields: ${pending.compatibility.missing.map((field) => field.label).join(", ")}.`);
|
||||||
|
}
|
||||||
|
if (pending.compatibility.incompatible.length > 0) {
|
||||||
|
messages.push(`Fields with incompatible types: ${pending.compatibility.incompatible.map((field) => `${field.label} (${field.actual} instead of ${field.expected})`).join(", ")}.`);
|
||||||
|
}
|
||||||
|
messages.push("Review the resulting placeholders before saving the Campaign draft.");
|
||||||
|
return messages.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
function uniqueSorted(values: string[]): string[] {
|
function uniqueSorted(values: string[]): string[] {
|
||||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
fetchResourceAccessExplanation,
|
fetchResourceAccessExplanation,
|
||||||
|
fetchResourceAccessExplanationSubjects,
|
||||||
getCampaignShares,
|
getCampaignShares,
|
||||||
campaignShareTargetProvider,
|
campaignShareTargetProvider,
|
||||||
revokeCampaignShare,
|
revokeCampaignShare,
|
||||||
upsertCampaignShare,
|
upsertCampaignShare,
|
||||||
type CampaignShare,
|
type CampaignShare,
|
||||||
type ResourceAccessExplanationResponse } from
|
type ResourceAccessExplanationResponse,
|
||||||
|
type ResourceAccessExplanationSubjectsResponse } from
|
||||||
"../../../api/campaigns";
|
"../../../api/campaigns";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -72,6 +74,8 @@ export default function CampaignAccessCard({
|
|||||||
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
const [sharePermission, setSharePermission] = useState<"read" | "write">("read");
|
||||||
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
|
const [accessExplanationOpen, setAccessExplanationOpen] = useState(false);
|
||||||
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
const [accessExplanation, setAccessExplanation] = useState<ResourceAccessExplanationResponse | null>(null);
|
||||||
|
const [accessExplanationSubjects, setAccessExplanationSubjects] = useState<ResourceAccessExplanationSubjectsResponse | null>(null);
|
||||||
|
const [accessExplanationSubjectId, setAccessExplanationSubjectId] = useState("");
|
||||||
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
const [accessExplanationLoading, setAccessExplanationLoading] = useState(false);
|
||||||
const [ownershipTransfers, setOwnershipTransfers] = useState<OwnershipTransfer[]>([]);
|
const [ownershipTransfers, setOwnershipTransfers] = useState<OwnershipTransfer[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -346,13 +350,24 @@ export default function CampaignAccessCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openAccessExplanation() {
|
async function openAccessExplanation() {
|
||||||
if (!auth.user?.id) return;
|
const currentUserId = auth.principal?.membership_id || auth.user?.id;
|
||||||
|
if (!currentUserId) return;
|
||||||
setAccessExplanationOpen(true);
|
setAccessExplanationOpen(true);
|
||||||
setAccessExplanation(null);
|
setAccessExplanation(null);
|
||||||
|
setAccessExplanationSubjects(null);
|
||||||
setAccessExplanationLoading(true);
|
setAccessExplanationLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const subjects = await fetchResourceAccessExplanationSubjects(settings, {
|
||||||
|
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
||||||
|
});
|
||||||
|
const initialSubjectId = subjects.users.some((user) => user.id === currentUserId)
|
||||||
|
? currentUserId
|
||||||
|
: subjects.users[0]?.id;
|
||||||
|
if (!initialSubjectId) throw new Error("No permitted access-explanation subject is available.");
|
||||||
|
setAccessExplanationSubjects(subjects);
|
||||||
|
setAccessExplanationSubjectId(initialSubjectId);
|
||||||
setAccessExplanation(await fetchResourceAccessExplanation(settings, {
|
setAccessExplanation(await fetchResourceAccessExplanation(settings, {
|
||||||
userId: auth.user.id,
|
userId: initialSubjectId,
|
||||||
resourceType: "campaign",
|
resourceType: "campaign",
|
||||||
resourceId: campaign.id,
|
resourceId: campaign.id,
|
||||||
action: "campaigns:campaign:read",
|
action: "campaigns:campaign:read",
|
||||||
@@ -366,6 +381,26 @@ export default function CampaignAccessCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function selectAccessExplanationSubject(userId: string) {
|
||||||
|
if (userId === accessExplanationSubjectId) return;
|
||||||
|
setAccessExplanationLoading(true);
|
||||||
|
try {
|
||||||
|
const explanation = await fetchResourceAccessExplanation(settings, {
|
||||||
|
userId,
|
||||||
|
resourceType: "campaign",
|
||||||
|
resourceId: campaign.id,
|
||||||
|
action: "campaigns:campaign:read",
|
||||||
|
tenantId: (auth.active_tenant ?? auth.tenant)?.id
|
||||||
|
});
|
||||||
|
setAccessExplanation(explanation);
|
||||||
|
setAccessExplanationSubjectId(userId);
|
||||||
|
} catch (err) {
|
||||||
|
onError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setAccessExplanationLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
const columns = useMemo<DataGridColumn<CampaignShare>[]>(() => [
|
||||||
{
|
{
|
||||||
id: "target",
|
id: "target",
|
||||||
@@ -517,8 +552,15 @@ export default function CampaignAccessCard({
|
|||||||
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
<Dialog open={accessExplanationOpen} className="campaign-access-dialog" title="i18n:govoplan-core.access_explanation.75ee7f62" onClose={() => { if (!accessExplanationLoading) { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); } }} footer={<Button onClick={() => { setAccessExplanationOpen(false); setAccessExplanation(null); setAccessExplanationSubjects(null); setAccessExplanationSubjectId(""); }} disabled={accessExplanationLoading}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||||
<ResourceAccessExplanation loading={accessExplanationLoading} explanation={accessExplanation} fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id} />
|
<ResourceAccessExplanation
|
||||||
|
loading={accessExplanationLoading}
|
||||||
|
explanation={accessExplanation}
|
||||||
|
subjects={accessExplanationSubjects}
|
||||||
|
selectedUserId={accessExplanationSubjectId}
|
||||||
|
onSelectedUserIdChange={(userId) => void selectAccessExplanationSubject(userId)}
|
||||||
|
fallbackResourceLabel={campaign.name || campaign.external_id || campaign.id}
|
||||||
|
/>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
<ConfirmDialog open={Boolean(revokeTarget)} title="i18n:govoplan-campaign.remove_campaign_share.2c2d42eb" message="i18n:govoplan-campaign.remove_this_user_s_or_group_s_explicit_access_to.5ff07672" confirmLabel="i18n:govoplan-campaign.remove_share.69c932e1" tone="danger" busy={busy} onCancel={() => setRevokeTarget(null)} onConfirm={() => void revoke()} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Button, DismissibleAlert, LoadingFrame, PageTitle } from "@govoplan/core-webui";
|
import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
import type { CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||||
import LockedVersionNotice from "./LockedVersionNotice";
|
import LockedVersionNotice from "./LockedVersionNotice";
|
||||||
@@ -24,6 +24,7 @@ type CampaignDraftPageScaffoldProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// semantic-editor-guard: delegated to each caller's useCampaignDraftEditor lifecycle.
|
||||||
export default function CampaignDraftPageScaffold({
|
export default function CampaignDraftPageScaffold({
|
||||||
settings,
|
settings,
|
||||||
campaignId,
|
campaignId,
|
||||||
@@ -43,25 +44,27 @@ export default function CampaignDraftPageScaffold({
|
|||||||
children
|
children
|
||||||
}: CampaignDraftPageScaffoldProps) {
|
}: CampaignDraftPageScaffoldProps) {
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="editor"
|
||||||
<div>
|
mode="workspace"
|
||||||
<PageTitle loading={loading}>{title}</PageTitle>
|
title={title}
|
||||||
<VersionLine version={version} versions={versions} status={saveState} />
|
description={<VersionLine version={version} versions={versions} status={saveState} />}
|
||||||
</div>
|
loading={loading || !draft}
|
||||||
<div className="button-row compact-actions">
|
loadingLabel="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50"
|
||||||
<Button onClick={onReload} disabled={loading}>Discard</Button>
|
error={error || ""}
|
||||||
<Button variant="primary" onClick={onSave} disabled={!dirty || locked || !draft}>i18n:govoplan-campaign.save.efc007a3</Button>
|
actions={<PageActionBar
|
||||||
</div>
|
variant="editor"
|
||||||
</div>
|
state={loading ? "saving" : dirty ? "dirty" : "clean"}
|
||||||
|
discardAction={{ label: "i18n:govoplan-campaign.discard.36fff63c", onClick: onReload }}
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
saveAction={{ label: "i18n:govoplan-campaign.save.efc007a3", onClick: onSave, disabled: (locked || !draft) && dirty, disabledReason: locked && dirty ? "This campaign version is locked." : !draft && dirty ? "The campaign draft is not available." : undefined }}
|
||||||
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
/>}
|
||||||
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
notices={(localError || locked) ?
|
||||||
|
<>
|
||||||
<LoadingFrame loading={loading || !draft} label="i18n:govoplan-campaign.loading_campaign_draft.1cf47e50">
|
{localError && <DismissibleAlert tone="danger" resetKey={localError} floating>{localError}</DismissibleAlert>}
|
||||||
<>{children}</>
|
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} currentVersionId={currentVersionId} reload={onReload} message="i18n:govoplan-campaign.this_page_is_read_only_for_the_selected_version.dacf5743" />}
|
||||||
</LoadingFrame>
|
</> : undefined}
|
||||||
</div>);
|
>
|
||||||
|
{children}
|
||||||
|
</PageLayout>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
|
||||||
import { Button, Dialog, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
|
import { ActionToolbar, Button, Dialog, MessageDisplayPanel, type MessageDisplayAttachment } from "@govoplan/core-webui";
|
||||||
|
|
||||||
// Campaign review/template/mock previews need recipient navigation, review notes,
|
// Campaign review/template/mock previews need recipient navigation, review notes,
|
||||||
// raw MIME inspection and attachment grouping. Generic mailbox reading uses
|
// raw MIME inspection and attachment grouping. Generic mailbox reading uses
|
||||||
@@ -106,7 +106,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
>
|
>
|
||||||
<div ref={contentRef} className="message-preview-content">
|
<div ref={contentRef} className="message-preview-content">
|
||||||
{(recipientLabel || recipientNote || navigation) &&
|
{(recipientLabel || recipientNote || navigation) &&
|
||||||
<div className="template-preview-toolbar">
|
<ActionToolbar className="template-preview-toolbar">
|
||||||
<div>
|
<div>
|
||||||
{recipientLabel && <strong>{recipientLabel}</strong>}
|
{recipientLabel && <strong>{recipientLabel}</strong>}
|
||||||
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
{recipientNote && <p className="muted small-note">{recipientNote}</p>}
|
||||||
@@ -120,7 +120,7 @@ export default function CampaignMessagePreviewOverlay({
|
|||||||
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
<button type="button" className="version-arrow" onClick={navigation.onLast} disabled={navigation.index >= navigation.total - 1} title="i18n:govoplan-campaign.last_message.83741110" aria-label="i18n:govoplan-campaign.last_message.83741110"><ArrowBigRightDash aria-hidden="true" /></button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</ActionToolbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
<MessageDisplayPanel
|
<MessageDisplayPanel
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
|
import { FormGrid, ActionToolbar, Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
CampaignPostboxCatalog,
|
CampaignPostboxCatalog,
|
||||||
@@ -91,7 +91,7 @@ export default function PostboxTargetsDialog({
|
|||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
title={title}
|
title={title}
|
||||||
className="campaign-postbox-target-modal"
|
size="wide"
|
||||||
bodyClassName="campaign-postbox-target-body"
|
bodyClassName="campaign-postbox-target-body"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
footer={
|
footer={
|
||||||
@@ -101,7 +101,7 @@ export default function PostboxTargetsDialog({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="campaign-postbox-target-toolbar">
|
<ActionToolbar className="campaign-postbox-target-toolbar">
|
||||||
{showMerge &&
|
{showMerge &&
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Postbox target defaults"
|
label="Postbox target defaults"
|
||||||
@@ -121,7 +121,7 @@ export default function PostboxTargetsDialog({
|
|||||||
<Plus aria-hidden="true" />
|
<Plus aria-hidden="true" />
|
||||||
Add target
|
Add target
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
||||||
{draftTargets.length === 0 &&
|
{draftTargets.length === 0 &&
|
||||||
@@ -181,7 +181,7 @@ function PostboxTargetRow({
|
|||||||
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
|
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
|
<FormGrid columns={2} collapseAt="wide" className="campaign-postbox-target-grid">
|
||||||
<FormField label="Target type">
|
<FormField label="Target type">
|
||||||
<select
|
<select
|
||||||
value={target.mode}
|
value={target.mode}
|
||||||
@@ -219,7 +219,7 @@ function PostboxTargetRow({
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</FormGrid>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -264,7 +265,7 @@ export default function AddressSourceImportDialog({
|
|||||||
|
|
||||||
{snapshot && (
|
{snapshot && (
|
||||||
<>
|
<>
|
||||||
<dl className="detail-list recipient-import-summary">
|
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
|
||||||
<div>
|
<div>
|
||||||
<dt>Source</dt>
|
<dt>Source</dt>
|
||||||
<dd>{snapshot.source_label}</dd>
|
<dd>{snapshot.source_label}</dd>
|
||||||
@@ -295,7 +296,7 @@ export default function AddressSourceImportDialog({
|
|||||||
<dt>Revision</dt>
|
<dt>Revision</dt>
|
||||||
<dd className="mono-small">{snapshot.source_revision}</dd>
|
<dd className="mono-small">{snapshot.source_revision}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
{snapshot.excluded_count > 0 && (
|
{snapshot.excluded_count > 0 && (
|
||||||
<DismissibleAlert tone="warning" compact dismissible={false}>
|
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||||
{snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts.
|
{snapshot.excluded_count} contact point{snapshot.excluded_count === 1 ? " was" : "s were"} excluded by effective communication-governance facts.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -294,7 +295,7 @@ export default function DistributionListImportDialog({
|
|||||||
)}
|
)}
|
||||||
{preview && (
|
{preview && (
|
||||||
<>
|
<>
|
||||||
<dl className="detail-list recipient-import-summary">
|
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
|
||||||
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
|
<div><dt>List</dt><dd>{preview.source.name}</dd></div>
|
||||||
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
|
<div><dt>Revision</dt><dd>{preview.source.revision}</dd></div>
|
||||||
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
|
<div><dt>Included</dt><dd>{preview.recipients.length}</dd></div>
|
||||||
@@ -302,7 +303,7 @@ export default function DistributionListImportDialog({
|
|||||||
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
|
<div><dt>Providers</dt><dd>{preview.provider_evidence.length}</dd></div>
|
||||||
<div><dt>Route decisions</dt><dd>{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}</dd></div>
|
<div><dt>Route decisions</dt><dd>{unresolvedRoutes.length ? `${unresolvedRoutes.length} required` : "Complete"}</dd></div>
|
||||||
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
|
<div><dt>State</dt><dd>{preview.stale ? "Stale" : preview.truncated ? "Truncated" : "Current"}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
{preview.stale && (
|
{preview.stale && (
|
||||||
<DismissibleAlert tone="warning" compact dismissible={false}>
|
<DismissibleAlert tone="warning" compact dismissible={false}>
|
||||||
At least one provider result is stale. Review its diagnostics before freezing this expansion.
|
At least one provider result is stale. Review its diagnostics before freezing this expansion.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
|
||||||
import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
@@ -309,7 +310,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
|
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
|
||||||
<div className="recipient-address-category-header">
|
<ActionToolbar surface="section-header" className="recipient-address-category-header">
|
||||||
<h3>{column.label}</h3>
|
<h3>{column.label}</h3>
|
||||||
<div className="recipient-address-category-controls">
|
<div className="recipient-address-category-controls">
|
||||||
{onMergeChange ?
|
{onMergeChange ?
|
||||||
@@ -322,7 +323,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
|||||||
onChange={onMergeChange} /> :
|
onChange={onMergeChange} /> :
|
||||||
null}
|
null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
|
|
||||||
<div className="recipient-address-lines">
|
<div className="recipient-address-lines">
|
||||||
{column.allowMultiple && draftAddresses.length === 0 &&
|
{column.allowMultiple && draftAddresses.length === 0 &&
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ApiSettings } from "../../../types";
|
import type { ApiSettings } from "../../../types";
|
||||||
import {
|
import {
|
||||||
@@ -506,12 +507,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
</div>
|
</div>
|
||||||
{table &&
|
{table &&
|
||||||
<>
|
<>
|
||||||
<dl className="detail-list recipient-import-summary">
|
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
|
||||||
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
|
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
|
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -543,12 +544,12 @@ export function RecipientImportDialog({ settings, campaignId, existingEntries, e
|
|||||||
<>
|
<>
|
||||||
{preview &&
|
{preview &&
|
||||||
<>
|
<>
|
||||||
<dl className="detail-list recipient-import-summary">
|
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
|
||||||
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
|
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
|
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
|
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
{preview.fieldNamesToCreate.length > 0 &&
|
{preview.fieldNamesToCreate.length > 0 &&
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
|
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
|
||||||
}
|
}
|
||||||
@@ -676,12 +677,12 @@ function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving,
|
|||||||
|
|
||||||
{resolution &&
|
{resolution &&
|
||||||
<>
|
<>
|
||||||
<dl className="detail-list recipient-import-summary">
|
<DescriptionList columns={5} collapseAt="never" className="recipient-import-summary">
|
||||||
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
|
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
|
|
||||||
{resolution.unmatchedPatterns.length > 0 &&
|
{resolution.unmatchedPatterns.length > 0 &&
|
||||||
<div className="recipient-import-unmatched-patterns">
|
<div className="recipient-import-unmatched-patterns">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link2, X } from "lucide-react";
|
import { Link2, X } from "lucide-react";
|
||||||
import { Button, Dialog, DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
import { Button, Dialog, DismissibleAlert, MetricCard, MetricGrid, i18nMessage } from "@govoplan/core-webui";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CampaignAttachmentPreviewFile,
|
CampaignAttachmentPreviewFile,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
attachmentPreviewLinkableFiles,
|
attachmentPreviewLinkableFiles,
|
||||||
attachmentPreviewMatchedFiles
|
attachmentPreviewMatchedFiles
|
||||||
} from "../utils/attachmentPreview";
|
} from "../utils/attachmentPreview";
|
||||||
import { WorkflowFact } from "./WorkflowNavigation";
|
|
||||||
|
|
||||||
export default function AttachmentLinkingPreview({
|
export default function AttachmentLinkingPreview({
|
||||||
preview,
|
preview,
|
||||||
@@ -70,8 +69,8 @@ export default function AttachmentLinkingPreview({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="review-flow-fact-grid">
|
<MetricGrid columns={4} density="compact" spacing="block" minimum="compact">
|
||||||
<WorkflowFact
|
<MetricCard density="compact" surface="subtle"
|
||||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||||
value={
|
value={
|
||||||
!loading && matchedFiles.length > 0 ? (
|
!loading && matchedFiles.length > 0 ? (
|
||||||
@@ -95,19 +94,19 @@ export default function AttachmentLinkingPreview({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<WorkflowFact
|
<MetricCard density="compact" surface="subtle"
|
||||||
label="i18n:govoplan-campaign.linked.a089f600"
|
label="i18n:govoplan-campaign.linked.a089f600"
|
||||||
value={loading ? "..." : linkedCount || "—"}
|
value={loading ? "..." : linkedCount || "—"}
|
||||||
/>
|
/>
|
||||||
<WorkflowFact
|
<MetricCard density="compact" surface="subtle"
|
||||||
label="i18n:govoplan-campaign.need_link.fa4ab530"
|
label="i18n:govoplan-campaign.need_link.fa4ab530"
|
||||||
value={loading ? "..." : unlinkedCount || "—"}
|
value={loading ? "..." : unlinkedCount || "—"}
|
||||||
/>
|
/>
|
||||||
<WorkflowFact
|
<MetricCard density="compact" surface="subtle"
|
||||||
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
|
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
|
||||||
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</MetricGrid>
|
||||||
{error && (
|
{error && (
|
||||||
<DismissibleAlert tone="danger" compact dismissible={false}>
|
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
||||||
|
|
||||||
import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView";
|
import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView";
|
||||||
@@ -28,7 +29,7 @@ export default function DeliveryJobDetailOverlay({
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div>
|
<div>
|
||||||
<dt>i18n:govoplan-campaign.recipient.90343260</dt>
|
<dt>i18n:govoplan-campaign.recipient.90343260</dt>
|
||||||
<dd>
|
<dd>
|
||||||
@@ -54,7 +55,7 @@ export default function DeliveryJobDetailOverlay({
|
|||||||
<dd>{String(job.last_error)}</dd>
|
<dd>{String(job.last_error)}</dd>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
{issues.length > 0 && (
|
{issues.length > 0 && (
|
||||||
<AttemptList
|
<AttemptList
|
||||||
title="i18n:govoplan-campaign.message_issues.9092a7db"
|
title="i18n:govoplan-campaign.message_issues.9092a7db"
|
||||||
@@ -90,7 +91,7 @@ function AttemptList({
|
|||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<p className="muted small-note">{emptyText}</p>
|
<p className="muted small-note">{emptyText}</p>
|
||||||
) : (
|
) : (
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
||||||
<dt>
|
<dt>
|
||||||
@@ -129,7 +130,7 @@ function AttemptList({
|
|||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</dl>
|
</DescriptionList>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
import { type CSSProperties, type ReactNode } from "react";
|
||||||
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
|
import { LockKeyhole, type LucideIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Card,
|
||||||
InlineHelp,
|
InlineHelp,
|
||||||
StageRail,
|
StageRail,
|
||||||
i18nMessage,
|
i18nMessage,
|
||||||
@@ -107,7 +108,6 @@ export function WorkflowStage({
|
|||||||
}) {
|
}) {
|
||||||
const Icon = stage.icon;
|
const Icon = stage.icon;
|
||||||
const locked = stage.state === "locked";
|
const locked = stage.state === "locked";
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
|
||||||
const title = i18nMessage(stage.title);
|
const title = i18nMessage(stage.title);
|
||||||
const description = i18nMessage(stage.description);
|
const description = i18nMessage(stage.description);
|
||||||
const stateText = i18nMessage(stage.stateLabel);
|
const stateText = i18nMessage(stage.stateLabel);
|
||||||
@@ -127,11 +127,16 @@ export function WorkflowStage({
|
|||||||
</div>
|
</div>
|
||||||
{nextState && <div className="review-flow-stage-line" />}
|
{nextState && <div className="review-flow-stage-line" />}
|
||||||
</div>
|
</div>
|
||||||
<article
|
<Card
|
||||||
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
|
as="article"
|
||||||
|
collapsible
|
||||||
|
persistCollapse={false}
|
||||||
|
className={`review-flow-stage-card${locked ? " is-locked" : ""}`}
|
||||||
|
headerClassName="review-flow-stage-header"
|
||||||
|
bodyClassName="review-flow-stage-content"
|
||||||
|
actionsClassName="review-flow-stage-header-actions"
|
||||||
aria-disabled={locked || undefined}
|
aria-disabled={locked || undefined}
|
||||||
>
|
title={
|
||||||
<header className="card-header review-flow-stage-header">
|
|
||||||
<h2>
|
<h2>
|
||||||
<span>{title}</span>
|
<span>{title}</span>
|
||||||
{locked && (
|
{locked && (
|
||||||
@@ -153,54 +158,22 @@ export function WorkflowStage({
|
|||||||
)}
|
)}
|
||||||
<InlineHelp>{description}</InlineHelp>
|
<InlineHelp>{description}</InlineHelp>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="review-flow-stage-header-actions">
|
}
|
||||||
<button
|
afterBody={locked ? (
|
||||||
type="button"
|
|
||||||
className="card-collapse-toggle"
|
|
||||||
aria-expanded={!collapsed}
|
|
||||||
aria-label={
|
|
||||||
collapsed
|
|
||||||
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
|
|
||||||
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
collapsed
|
|
||||||
? "i18n:govoplan-campaign.show_content.0528d8d2"
|
|
||||||
: "i18n:govoplan-campaign.show_header_only.24afefca"
|
|
||||||
}
|
|
||||||
onClick={() => setCollapsed((value) => !value)}
|
|
||||||
>
|
|
||||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{!collapsed && (
|
|
||||||
<>
|
|
||||||
<div className="card-body review-flow-stage-content">{children}</div>
|
|
||||||
{locked && (
|
|
||||||
<div className="review-flow-lock-message">
|
<div className="review-flow-lock-message">
|
||||||
<span className="review-flow-lock-icon">
|
<span className="review-flow-lock-icon">
|
||||||
<LockKeyhole size={20} aria-hidden="true" />
|
<LockKeyhole size={20} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span>{lockReason}</span>
|
<span>{lockReason}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : undefined}
|
||||||
</>
|
>
|
||||||
)}
|
{children}
|
||||||
</article>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="review-flow-fact">
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stateLabel(state: FlowState): string {
|
export function stateLabel(state: FlowState): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case "complete":
|
case "complete":
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export type ContentFieldRequirement = {
|
||||||
|
path: string;
|
||||||
|
value_type: string;
|
||||||
|
label?: string | null;
|
||||||
|
required: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentFieldMismatch = {
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
expected: string;
|
||||||
|
actual?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentLibraryCompatibility = {
|
||||||
|
compatible: boolean;
|
||||||
|
missing: ContentFieldMismatch[];
|
||||||
|
incompatible: ContentFieldMismatch[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function checkContentLibraryCompatibility(
|
||||||
|
requirements: readonly ContentFieldRequirement[],
|
||||||
|
availableFields: ReadonlyMap<string, string>
|
||||||
|
): ContentLibraryCompatibility {
|
||||||
|
const missing: ContentFieldMismatch[] = [];
|
||||||
|
const incompatible: ContentFieldMismatch[] = [];
|
||||||
|
for (const requirement of requirements) {
|
||||||
|
if (!requirement.required || !requirement.path.trim()) continue;
|
||||||
|
const path = normalizeContentFieldPath(requirement.path);
|
||||||
|
const actual = availableFields.get(path) ?? recipientAddressRequirementType(path, availableFields);
|
||||||
|
const mismatch = {
|
||||||
|
path,
|
||||||
|
label: requirement.label?.trim() || path,
|
||||||
|
expected: requirement.value_type || "string"
|
||||||
|
};
|
||||||
|
if (!actual) {
|
||||||
|
missing.push(mismatch);
|
||||||
|
} else if (!fieldTypesAreCompatible(mismatch.expected, actual)) {
|
||||||
|
incompatible.push({ ...mismatch, actual });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
compatible: missing.length === 0 && incompatible.length === 0,
|
||||||
|
missing,
|
||||||
|
incompatible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeContentFieldPath(path: string): string {
|
||||||
|
return path.trim()
|
||||||
|
.replace(/^fields\./, "")
|
||||||
|
.replace(/^local\./, "local:")
|
||||||
|
.replace(/^global\./, "global:")
|
||||||
|
.replace(/^local::/, "local:")
|
||||||
|
.replace(/^global::/, "global:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function campaignContentFieldType(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) return "array";
|
||||||
|
if (value && typeof value === "object") return "object";
|
||||||
|
if (typeof value === "boolean") return "boolean";
|
||||||
|
if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldTypesAreCompatible(expected: string, actual: string): boolean {
|
||||||
|
if (actual === "unknown" || expected === actual) return true;
|
||||||
|
return expected === "number" && (actual === "integer" || actual === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
function recipientAddressRequirementType(path: string, availableFields: ReadonlyMap<string, string>): string | undefined {
|
||||||
|
const match = /^local:(all_)?(from|to|reply_to|cc|bcc)(?:\.|$)/.exec(path);
|
||||||
|
if (!match) return undefined;
|
||||||
|
return availableFields.get(`local:${match[1] ?? ""}${match[2]}`);
|
||||||
|
}
|
||||||
@@ -28,7 +28,17 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
|
|||||||
allow_individual: false,
|
allow_individual: false,
|
||||||
send_without_attachments: true,
|
send_without_attachments: true,
|
||||||
send_without_attachments_behavior: "continue",
|
send_without_attachments_behavior: "continue",
|
||||||
|
reuse_policy: {
|
||||||
|
action: "allow",
|
||||||
|
allow_within: "none"
|
||||||
|
},
|
||||||
global: [],
|
global: [],
|
||||||
|
residual_files: {
|
||||||
|
mode: "none",
|
||||||
|
recipient: null,
|
||||||
|
subject: "Unassigned files in campaign {{local:campaign_name}}",
|
||||||
|
text: "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||||
|
},
|
||||||
missing_behavior: "warn",
|
missing_behavior: "warn",
|
||||||
ambiguous_behavior: "ask",
|
ambiguous_behavior: "ask",
|
||||||
...sourceAttachments
|
...sourceAttachments
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import {
|
import {
|
||||||
PageTitle,
|
PageActionBar,
|
||||||
|
PageLayout,
|
||||||
WizardDirectory,
|
WizardDirectory,
|
||||||
usePlatformUiCapabilities,
|
usePlatformUiCapabilities,
|
||||||
wizardEntriesForContext,
|
wizardEntriesForContext,
|
||||||
@@ -38,18 +39,19 @@ export default function WizardDirectoryPage({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="content-pad workspace-data-page">
|
<PageLayout
|
||||||
<div className="page-heading split workspace-heading">
|
archetype="collection"
|
||||||
<PageTitle>i18n:govoplan-campaign.guided_workflows</PageTitle>
|
mode="workspace"
|
||||||
<Link className="btn btn-secondary" to="../">
|
title="i18n:govoplan-campaign.guided_workflows"
|
||||||
|
actions={<PageActionBar variant="collection" contextActions={<Link className="btn btn-secondary" to="../">
|
||||||
i18n:govoplan-campaign.back_to_overview.ec986cba
|
i18n:govoplan-campaign.back_to_overview.ec986cba
|
||||||
</Link>
|
</Link>} />}
|
||||||
</div>
|
>
|
||||||
<WizardDirectory
|
<WizardDirectory
|
||||||
entries={entries}
|
entries={entries}
|
||||||
auth={auth}
|
auth={auth}
|
||||||
emptyText="i18n:govoplan-campaign.no_guided_workflows"
|
emptyText="i18n:govoplan-campaign.no_guided_workflows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { FormGrid, Button } from "@govoplan/core-webui";
|
||||||
import { FieldLabel } from "@govoplan/core-webui";
|
import { FieldLabel } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
@@ -22,7 +23,7 @@ export type WizardStepProps = {
|
|||||||
export function BasicsStep({ draft, patch }: WizardStepProps) {
|
export function BasicsStep({ draft, patch }: WizardStepProps) {
|
||||||
const campaign = asRecord(draft.campaign);
|
const campaign = asRecord(draft.campaign);
|
||||||
return (
|
return (
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.campaign_name.aa5d0e72" help="i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e">
|
<FormField label="i18n:govoplan-campaign.campaign_name.aa5d0e72" help="i18n:govoplan-campaign.a_human_readable_name_shown_in_lists_and_reports.afc23e7e">
|
||||||
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
|
<input value={getText(campaign, "name")} onChange={(event) => patch(["campaign", "name"], event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
@@ -39,7 +40,7 @@ export function BasicsStep({ draft, patch }: WizardStepProps) {
|
|||||||
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
<FormField label="i18n:govoplan-campaign.description.55f8ebc8">
|
||||||
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
|
<textarea rows={5} value={getText(campaign, "description")} onChange={(event) => patch(["campaign", "description"], event.target.value)} />
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>);
|
</FormGrid>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
|
|||||||
const delivery = asRecord(draft.delivery);
|
const delivery = asRecord(draft.delivery);
|
||||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||||
return (
|
return (
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
|
||||||
<EmailAddressInput
|
<EmailAddressInput
|
||||||
value={from}
|
value={from}
|
||||||
@@ -108,7 +109,7 @@ export function SenderStep({ draft, patch }: WizardStepProps) {
|
|||||||
</FormField>
|
</FormField>
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f</p>
|
<p className="muted small-note">i18n:govoplan-campaign.select_the_delivery_profile_on_the_mail_settings.a89cbb5f</p>
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.append_successful_messages_to_sent_via_imap.dbd1b1d8" checked={getBool(imapAppend, "enabled")} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
|
||||||
</div>);
|
</FormGrid>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
|
|||||||
<h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
|
<h2>i18n:govoplan-campaign.template.3ec1ae06</h2>
|
||||||
<p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
|
<p>i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid">
|
<FormGrid columns={1} collapseAt="standard" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
|
<FormField label="i18n:govoplan-campaign.subject.8d183dbd"><input value={getText(template, "subject")} onChange={(event) => patch(["template", "subject"], event.target.value)} /></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
|
<FormField label="i18n:govoplan-campaign.plain_text_body.030a0da0"><textarea rows={12} value={getText(template, "text")} onChange={(event) => patch(["template", "text"], event.target.value)} /></FormField>
|
||||||
<div className="form-field">
|
<div className="form-field">
|
||||||
@@ -157,7 +158,7 @@ export function TemplateStep({ draft, patch }: WizardStepProps) {
|
|||||||
onChange={(value) => patch(["template", "html"], value)}
|
onChange={(value) => patch(["template", "html"], value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormGrid>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -170,12 +171,12 @@ export function AttachmentsStep({ draft, patch }: WizardStepProps) {
|
|||||||
<h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
|
<h2>i18n:govoplan-campaign.attachments.6771ade6</h2>
|
||||||
<p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
|
<p>i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.campaign_attachment_base_path.84827619"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
<FormField label="i18n:govoplan-campaign.campaign_attachment_base_path.84827619"><input value={getText(attachments, "base_path", ".")} onChange={(event) => patch(["attachments", "base_path"], event.target.value)} /></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.missing_behavior.de3d1ac7"><select value={getText(attachments, "missing_behavior", "warn")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
<FormField label="i18n:govoplan-campaign.missing_behavior.de3d1ac7"><select value={getText(attachments, "missing_behavior", "warn")} onChange={(event) => patch(["attachments", "missing_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.ambiguous_behavior.e86e724b"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
<FormField label="i18n:govoplan-campaign.ambiguous_behavior.e86e724b"><select value={getText(attachments, "ambiguous_behavior", "ask")} onChange={(event) => patch(["attachments", "ambiguous_behavior"], event.target.value)}>{behaviorOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
|
||||||
<ToggleSwitch label="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
<ToggleSwitch label="i18n:govoplan-campaign.allow_individual_attachments.a6ff0e87" checked={getBool(attachments, "allow_individual")} onChange={(checked) => patch(["attachments", "allow_individual"], checked)} />
|
||||||
</div>
|
</FormGrid>
|
||||||
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
|
<JsonEditor value={attachments.global ?? []} onValid={(value) => patch(["attachments", "global"], value)} />
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
@@ -189,11 +190,11 @@ export function ReviewStep({ version, onValidate }: {version: unknown;onValidate
|
|||||||
<h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
|
<h2>i18n:govoplan-campaign.review_setup.d0059acb</h2>
|
||||||
<p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
|
<p>i18n:govoplan-campaign.validate_the_campaign_definition_before_building.d36ee1dc</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="metric-grid inside">
|
<MetricGrid spacing="inset">
|
||||||
<MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
<MetricCard label="i18n:govoplan-campaign.errors.805e86a8" value={summaryValue(asRecord(record.validation_summary), ["error_count", "errors", "blocked"])} tone="danger" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
<MetricCard label="i18n:govoplan-campaign.warnings.1430f976" value={summaryValue(asRecord(record.validation_summary), ["warning_count", "warnings"])} tone="warning" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
<MetricCard label="i18n:govoplan-campaign.built.a6ad3f82" value={summaryValue(asRecord(record.build_summary), ["built_count", "built", "messages_built"])} tone="info" />
|
||||||
</div>
|
</MetricGrid>
|
||||||
<Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
|
<Button variant="primary" onClick={onValidate}>i18n:govoplan-campaign.validate_campaign.6934c1c2</Button>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
@@ -208,10 +209,10 @@ export function SendStep({ draft, patch }: WizardStepProps) {
|
|||||||
<h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
|
<h2>i18n:govoplan-campaign.send_preparation.6e078f43</h2>
|
||||||
<p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
|
<p>i18n:govoplan-campaign.configure_rate_limits_and_prepare_the_final_send.af3d4e48</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.messages_per_minute.bea49348"><input type="number" min={1} value={getNumber(rateLimit, "messages_per_minute", 5)} onChange={(event) => patch(["delivery", "rate_limit", "messages_per_minute"], Number(event.target.value || 1))} /></FormField>
|
||||||
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
<FormField label="i18n:govoplan-campaign.concurrency.2ec390bf"><input type="number" min={1} value={getNumber(rateLimit, "concurrency", 1)} onChange={(event) => patch(["delivery", "rate_limit", "concurrency"], Number(event.target.value || 1))} /></FormField>
|
||||||
</div>
|
</FormGrid>
|
||||||
<p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
|
<p className="muted">i18n:govoplan-campaign.test_send_and_queue_actions_remain_in_the_send_w.60dee716</p>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { MetricGrid } from "@govoplan/core-webui";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
|
import { Ban, ExternalLink, Eye, Pause, Play, RotateCcw, Send, ShieldCheck } from "lucide-react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
@@ -25,11 +26,9 @@ import {
|
|||||||
type DataGridListOption,
|
type DataGridListOption,
|
||||||
type DataGridQueryState
|
type DataGridQueryState
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
import { DismissibleAlert, PageActionBar, PageLayout } from "@govoplan/core-webui";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
|
||||||
import {
|
import {
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TableActionGroup,
|
TableActionGroup,
|
||||||
@@ -757,26 +756,29 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
|
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScrollViewport>
|
<PageLayout
|
||||||
<div className="content-pad workspace-data-page">
|
archetype="collection"
|
||||||
<div className="page-heading split workspace-heading">
|
mode="workspace"
|
||||||
<div>
|
title="i18n:govoplan-campaign.operator_queue.72492fb5"
|
||||||
<PageTitle loading={loading}>i18n:govoplan-campaign.operator_queue.72492fb5</PageTitle>
|
description={
|
||||||
|
<>
|
||||||
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
|
<p>i18n:govoplan-campaign.queue_state_retry_candidates_and_reconciliation_.1b592bbe</p>
|
||||||
<p className="muted small-note">i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205</p>
|
<p className="muted small-note">i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205</p>
|
||||||
</div>
|
</>
|
||||||
<div className="button-row compact-actions">
|
}
|
||||||
<Button onClick={() => void refreshAll()} disabled={loading || jobsLoading}>i18n:govoplan-campaign.refresh.56e3badc</Button>
|
error={error}
|
||||||
</div>
|
success={message}
|
||||||
</div>
|
actions={<PageActionBar
|
||||||
|
variant="collection"
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
refreshable
|
||||||
{jobsError && <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert>}
|
reloadAction={{ onReload: () => void refreshAll(), loading: loading || jobsLoading }}
|
||||||
{message && <DismissibleAlert tone="success" resetKey={message} floating>{message}</DismissibleAlert>}
|
/>}
|
||||||
|
notices={jobsError ? <DismissibleAlert tone="danger" resetKey={jobsError} floating>{jobsError}</DismissibleAlert> : undefined}
|
||||||
|
>
|
||||||
|
|
||||||
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
|
<section className="queue-pressure-section" aria-labelledby="operator-queue-pressure-title">
|
||||||
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
|
<h2 id="operator-queue-pressure-title" className="queue-pressure-heading">i18n:govoplan-campaign.queue_pressure.28eee15e</h2>
|
||||||
<div className="metric-grid queue-pressure-grid">
|
<MetricGrid columns="auto" spacing="none" className="queue-pressure-metrics">
|
||||||
<MetricCard label="i18n:govoplan-campaign.queued.6a599877" value={totals.queued} tone="neutral" />
|
<MetricCard label="i18n:govoplan-campaign.queued.6a599877" value={totals.queued} tone="neutral" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.claimed.83c87884" value={totals.claimed} tone="info" />
|
<MetricCard label="i18n:govoplan-campaign.claimed.83c87884" value={totals.claimed} tone="info" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.sending.ceafde86" value={totals.sending} tone="info" />
|
<MetricCard label="i18n:govoplan-campaign.sending.ceafde86" value={totals.sending} tone="info" />
|
||||||
@@ -786,7 +788,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
<MetricCard label="i18n:govoplan-campaign.unattempted.e7411dd6" value={totals.notAttempted} tone="neutral" />
|
<MetricCard label="i18n:govoplan-campaign.unattempted.e7411dd6" value={totals.notAttempted} tone="neutral" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.paused.c7dfb6f1" value={totals.paused} tone="neutral" />
|
<MetricCard label="i18n:govoplan-campaign.paused.c7dfb6f1" value={totals.paused} tone="neutral" />
|
||||||
<MetricCard label="i18n:govoplan-campaign.imap_failed.50dbca55" value={totals.imapFailed} tone="warning" />
|
<MetricCard label="i18n:govoplan-campaign.imap_failed.50dbca55" value={totals.imapFailed} tone="warning" />
|
||||||
</div>
|
</MetricGrid>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_operator_queue.ae6576e0">
|
||||||
@@ -847,8 +849,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
|||||||
if (cancelTarget) void runAction(cancelTarget, "cancel");
|
if (cancelTarget) void runAction(cancelTarget, "cancel");
|
||||||
}}
|
}}
|
||||||
onCancel={() => setCancelTarget(null)} />
|
onCancel={() => setCancelTarget(null)} />
|
||||||
</div>
|
</PageLayout>);
|
||||||
</PageScrollViewport>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Eye, RefreshCw } from "lucide-react";
|
import { Eye } from "lucide-react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import {
|
import { ContentGrid,
|
||||||
Button,
|
|
||||||
Card,
|
Card,
|
||||||
DataGrid,
|
DataGrid,
|
||||||
DismissibleAlert,
|
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
PageScrollViewport,
|
PageActionBar,
|
||||||
PageTitle,
|
PageLayout,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TableActionGroup,
|
TableActionGroup,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
@@ -167,24 +166,22 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
|||||||
const outcomes = report?.outcomes;
|
const outcomes = report?.outcomes;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScrollViewport className="aggregate-reports-page">
|
<PageLayout
|
||||||
<div className="content-pad workspace-data-page">
|
archetype="overview"
|
||||||
<div className="page-heading split workspace-heading">
|
mode="workspace"
|
||||||
<div>
|
className="aggregate-reports-page"
|
||||||
<PageTitle loading={listLoading || reportLoading}>i18n:govoplan-campaign.reports.88bc3fe3</PageTitle>
|
title="i18n:govoplan-campaign.reports.88bc3fe3"
|
||||||
<p className="muted">i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d</p>
|
description="i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d"
|
||||||
</div>
|
error={error}
|
||||||
<div className="button-row compact-actions">
|
actions={<PageActionBar
|
||||||
<Button
|
variant="overview"
|
||||||
onClick={() => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()])}
|
refreshable
|
||||||
disabled={listLoading || reportLoading}
|
reloadAction={{
|
||||||
>
|
onReload: () => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()]),
|
||||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-campaign.refresh.56e3badc
|
loading: listLoading || reportLoading
|
||||||
</Button>
|
}}
|
||||||
</div>
|
/>}
|
||||||
</div>
|
>
|
||||||
|
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
|
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
|
||||||
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
|
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
|
||||||
@@ -203,17 +200,17 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
|||||||
{report && outcomes && (
|
{report && outcomes && (
|
||||||
<>
|
<>
|
||||||
<Card title={report.campaign.name}>
|
<Card title={report.campaign.name}>
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={report.campaign.status} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={report.campaign.status} /></dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.completion.2ff25568</dt><dd><StatusBadge status={report.completion_state} label={aggregateCompletionLabel(report.completion_state)} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.completion.2ff25568</dt><dd><StatusBadge status={report.completion_state} label={aggregateCompletionLabel(report.completion_state)} /></dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.version.2da600bf</dt><dd>{report.version_number ?? "—"}</dd></div>
|
<div><dt>i18n:govoplan-campaign.version.2da600bf</dt><dd>{report.version_number ?? "—"}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Aggregate outcome cards are deliberately not filter shortcuts: each subgroup
|
{/* Aggregate outcome cards are deliberately not filter shortcuts: each subgroup
|
||||||
would require a separately suppressed aggregate response from the server. */}
|
would require a separately suppressed aggregate response from the server. */}
|
||||||
<div className="dashboard-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||||
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
|
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
|
||||||
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
|
<MetricCard label="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
|
||||||
<MetricCard label="Print accepted" value={countValue(outcomes.print_accepted)} tone="good" />
|
<MetricCard label="Print accepted" value={countValue(outcomes.print_accepted)} tone="good" />
|
||||||
@@ -225,22 +222,22 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
|||||||
<MetricCard label="i18n:govoplan-campaign.not_attempted.e1be3c69" value={countValue(outcomes.not_attempted)} />
|
<MetricCard label="i18n:govoplan-campaign.not_attempted.e1be3c69" value={countValue(outcomes.not_attempted)} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.cancelled.a1bf92ef" value={countValue(outcomes.cancelled)} />
|
<MetricCard label="i18n:govoplan-campaign.cancelled.a1bf92ef" value={countValue(outcomes.cancelled)} />
|
||||||
<MetricCard label="i18n:govoplan-campaign.excluded.9804952b" value={countValue(outcomes.excluded)} />
|
<MetricCard label="i18n:govoplan-campaign.excluded.9804952b" value={countValue(outcomes.excluded)} />
|
||||||
</div>
|
</ContentGrid>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.population_and_exclusions.cdff62ad">
|
<Card title="i18n:govoplan-campaign.population_and_exclusions.cdff62ad">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>i18n:govoplan-campaign.report_denominator.d089074a</dt><dd>{countValue(report.population.denominator)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.report_denominator.d089074a</dt><dd>{countValue(report.population.denominator)}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.inactive_source_entries.88fa0cf8</dt><dd>{countValue(report.population.inactive_source_entries)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.inactive_source_entries.88fa0cf8</dt><dd>{countValue(report.population.inactive_source_entries)}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
<p className="muted">i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc</p>
|
<p className="muted">i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.delivery_activity_range.0d1b62ec">
|
<Card title="i18n:govoplan-campaign.delivery_activity_range.0d1b62ec">
|
||||||
<dl className="detail-list">
|
<DescriptionList variant="inline">
|
||||||
<div><dt>i18n:govoplan-campaign.first_activity.3fe36ef4</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.first_activity_at ?? undefined)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.first_activity.3fe36ef4</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.first_activity_at ?? undefined)}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.last_activity.1c73b806</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.last_activity_at ?? undefined)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.last_activity.1c73b806</dt><dd>{report.time_range.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : formatDateTime(report.time_range.last_activity_at ?? undefined)}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="i18n:govoplan-campaign.privacy_boundary.d37e12bc">
|
<Card title="i18n:govoplan-campaign.privacy_boundary.d37e12bc">
|
||||||
@@ -252,8 +249,7 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>
|
</PageLayout>
|
||||||
</PageScrollViewport>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { DescriptionList } from "@govoplan/core-webui";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { FormGrid, ContentGrid, Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
import { PageActionBar, PageLayout, WorkspaceLayout } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
|
||||||
import { FieldLabel } from "@govoplan/core-webui";
|
import { FieldLabel } from "@govoplan/core-webui";
|
||||||
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
|
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
|
||||||
import { usePlatformLanguage } from "@govoplan/core-webui";
|
import { usePlatformLanguage } from "@govoplan/core-webui";
|
||||||
@@ -137,20 +137,24 @@ export default function TemplatesPage() {
|
|||||||
|
|
||||||
if (selectedTemplate) {
|
if (selectedTemplate) {
|
||||||
return (
|
return (
|
||||||
<div className="workspace module-workspace">
|
<WorkspaceLayout
|
||||||
<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />
|
className="module-workspace"
|
||||||
<section className="workspace-content">
|
primary={<ModuleSubnav active={active} groups={templateSubnav(() => setSelectedId(null))} onSelect={setActive} />}
|
||||||
<div className="content-pad workspace-data-page">
|
primaryLabel="Template sections"
|
||||||
<div className="page-heading split workspace-heading">
|
contentLabel="Template workspace"
|
||||||
<div>
|
interfaceId="campaign.template-workspace"
|
||||||
<PageTitle>{selectedTemplate.name}</PageTitle>
|
helpModuleId="campaign"
|
||||||
<p>{selectedTemplate.description}</p>
|
>
|
||||||
</div>
|
<PageLayout
|
||||||
<div className="button-row compact-actions">
|
archetype="detail"
|
||||||
|
mode="workspace"
|
||||||
|
title={selectedTemplate.name}
|
||||||
|
description={selectedTemplate.description}
|
||||||
|
actions={<PageActionBar variant="detail" primaryActions={<>
|
||||||
<Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
|
<Button disabled>i18n:govoplan-campaign.duplicate.972d5737</Button>
|
||||||
<Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
|
<Button variant="primary" disabled>i18n:govoplan-campaign.use_in_campaign.779163bc</Button>
|
||||||
</div>
|
</>} />}
|
||||||
</div>
|
>
|
||||||
|
|
||||||
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
|
{active === "overview" && <TemplateOverview template={selectedTemplate} />}
|
||||||
{active === "content" && <TemplateContent template={selectedTemplate} />}
|
{active === "content" && <TemplateContent template={selectedTemplate} />}
|
||||||
@@ -158,25 +162,22 @@ export default function TemplatesPage() {
|
|||||||
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
|
{active === "preview" && <TemplatePreview template={selectedTemplate} />}
|
||||||
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
|
{active === "usage" && <TemplateUsage template={selectedTemplate} />}
|
||||||
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
|
{active === "versions" && <TemplateVersions template={selectedTemplate} />}
|
||||||
</div>
|
</PageLayout>
|
||||||
</section>
|
</WorkspaceLayout>);
|
||||||
</div>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageScrollViewport>
|
<PageLayout
|
||||||
<div className="content-pad workspace-data-page module-entry-page">
|
archetype="collection"
|
||||||
<div className="page-heading split workspace-heading">
|
className="module-entry-page"
|
||||||
<div>
|
title="i18n:govoplan-campaign.templates.f25b700e"
|
||||||
<PageTitle>i18n:govoplan-campaign.templates.f25b700e</PageTitle>
|
description="i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb"
|
||||||
<p>i18n:govoplan-campaign.reusable_message_templates_open_a_template_to_ed.792e2afb</p>
|
actions={<PageActionBar variant="collection" contextActions={<>
|
||||||
</div>
|
|
||||||
<div className="button-row compact-actions">
|
|
||||||
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
<Button disabled>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||||
<Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
|
<Button variant="primary" disabled>i18n:govoplan-campaign.export.f3e4fadb</Button>
|
||||||
</div>
|
</>} />}
|
||||||
</div>
|
>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
@@ -196,21 +197,20 @@ export default function TemplatesPage() {
|
|||||||
className="compact-table-wrap module-table-wrap module-entry-table" />
|
className="compact-table-wrap module-table-wrap module-entry-table" />
|
||||||
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</PageLayout>);
|
||||||
</PageScrollViewport>);
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TemplateOverview({ template }: {template: TemplateRecord;}) {
|
function TemplateOverview({ template }: {template: TemplateRecord;}) {
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-grid settings-dashboard-grid">
|
<ContentGrid columns={2} collapseAt="workspace" className="">
|
||||||
<Card title="i18n:govoplan-campaign.template_status.8bec97cf">
|
<Card title="i18n:govoplan-campaign.template_status.8bec97cf">
|
||||||
<dl className="detail-list compact-detail-list">
|
<DescriptionList variant="inline" density="compact">
|
||||||
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
|
<div><dt>i18n:govoplan-campaign.status.bae7d5be</dt><dd><StatusBadge status={template.status} /></dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
|
<div><dt>i18n:govoplan-campaign.type.3deb7456</dt><dd>{templateTypeLabel(template.type)}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
|
<div><dt>i18n:govoplan-campaign.updated.f2f8570d</dt><dd>{template.updatedAt}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
|
<div><dt>i18n:govoplan-campaign.versions.a239107e</dt><dd>{template.versions}</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
|
<Card title="i18n:govoplan-campaign.compatibility_notes.7d648a6d">
|
||||||
<p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
|
<p className="muted">i18n:govoplan-campaign.campaigns_can_reuse_this_template_but_each_campa.bdb25009</p>
|
||||||
@@ -220,7 +220,7 @@ function TemplateOverview({ template }: {template: TemplateRecord;}) {
|
|||||||
<span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
|
<span>i18n:govoplan-campaign.a_mapping_wizard_can_be_added_later.6b2fe0f3</span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>);
|
</ContentGrid>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,10 +230,10 @@ function TemplateContent({ template }: {template: TemplateRecord;}) {
|
|||||||
const body = translateText(template.body);
|
const body = translateText(template.body);
|
||||||
return (
|
return (
|
||||||
<Card title="i18n:govoplan-campaign.template_content.48630575" actions={<Button disabled>i18n:govoplan-campaign.save_changes.179359b3</Button>}>
|
<Card title="i18n:govoplan-campaign.template_content.48630575" actions={<Button disabled>i18n:govoplan-campaign.save_changes.179359b3</Button>}>
|
||||||
<div className="form-grid compact responsive-form-grid">
|
<FormGrid columns={2} collapseAt="wide" className="">
|
||||||
<label className="form-field"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_subject_stored_in_this_reusable_templa.549ae246">i18n:govoplan-campaign.subject.8d183dbd</FieldLabel><input value={subject} readOnly /></label>
|
<label className="form-field"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_subject_stored_in_this_reusable_templa.549ae246">i18n:govoplan-campaign.subject.8d183dbd</FieldLabel><input value={subject} readOnly /></label>
|
||||||
<label className="form-field full-span"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_body_stored_in_this_reusable_template_.98fbf178">i18n:govoplan-campaign.body.718a7e8a</FieldLabel><textarea rows={10} value={body} readOnly /></label>
|
<label className="form-field full-span"><FieldLabel className="form-label" help="i18n:govoplan-campaign.read_only_body_stored_in_this_reusable_template_.98fbf178">i18n:govoplan-campaign.body.718a7e8a</FieldLabel><textarea rows={10} value={body} readOnly /></label>
|
||||||
</div>
|
</FormGrid>
|
||||||
</Card>);
|
</Card>);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -284,10 +284,10 @@ function TemplateUsage({ template }: {template: TemplateRecord;}) {
|
|||||||
return (
|
return (
|
||||||
<Card title="i18n:govoplan-campaign.usage.0bb18642">
|
<Card title="i18n:govoplan-campaign.usage.0bb18642">
|
||||||
<p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
|
<p className="muted">i18n:govoplan-campaign.this_view_will_list_campaigns_using_the_template.116f7ee0</p>
|
||||||
<dl className="detail-list compact-detail-list">
|
<DescriptionList variant="inline" density="compact">
|
||||||
<div><dt>i18n:govoplan-campaign.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</dd></div>
|
<div><dt>i18n:govoplan-campaign.currently_used_by.a065cfbe</dt><dd>{template.usedBy}</dd></div>
|
||||||
<div><dt>i18n:govoplan-campaign.safe_to_edit.57b64df5</dt><dd>i18n:govoplan-campaign.requires_versioning_once_templates_are_shared.e020b221</dd></div>
|
<div><dt>i18n:govoplan-campaign.safe_to_edit.57b64df5</dt><dd>i18n:govoplan-campaign.requires_versioning_once_templates_are_shared.e020b221</dd></div>
|
||||||
</dl>
|
</DescriptionList>
|
||||||
</Card>);
|
</Card>);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user