Compare commits
25 Commits
03100b77db
...
v0.1.10
| Author | SHA1 | Date | |
|---|---|---|---|
| 735e874bd0 | |||
| 99d44eeb8d | |||
| f095a3e2c7 | |||
| 1225802c5d | |||
| ac3329cafe | |||
| 4eb651c6ac | |||
| 0b4017c240 | |||
| 79b576b4bc | |||
| b0282ebff2 | |||
| aae4ef5952 | |||
| 8ee87b7558 | |||
| 7229fb8e3d | |||
| 3487ec7048 | |||
| 22d72f82f5 | |||
| fc36aee6c0 | |||
| 06125cc0e8 | |||
| 21f3014ac5 | |||
| 62a68792a4 | |||
| aa4ec66b7b | |||
| 60efd1cb5d | |||
| 7e1660344d | |||
| a8c0750dd7 | |||
| bfbb86564c | |||
| c05bb8e474 | |||
| 99ef25b08f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -333,6 +333,7 @@ webui/.policy-test-build/
|
||||
webui/.template-preview-test-build/
|
||||
webui/.import-test-build/
|
||||
webui/.review-preview-test-build/
|
||||
webui/.report-grid-test-build/
|
||||
|
||||
# GovOPlaN shared ignore rules from govoplan-core
|
||||
# Local WebUI test/build scratch directories
|
||||
|
||||
@@ -93,6 +93,7 @@ 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 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.
|
||||
- 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/add-ideas/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.
|
||||
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
||||
|
||||
@@ -5,10 +5,12 @@ been validated, built, reviewed, and locked.
|
||||
|
||||
## Operating Modes
|
||||
|
||||
- Local direct send: `CELERY_ENABLED=false`. Queueing stores jobs in the DB, and
|
||||
small development runs can be processed with "Send queued now".
|
||||
- Local direct send: `CELERY_ENABLED=false`. **Send now** is available only for
|
||||
exact built runs within the effective synchronous limit. It preflights the
|
||||
complete batch before the first SMTP effect.
|
||||
- Worker send: `CELERY_ENABLED=true` with Redis/Celery workers running. Queueing
|
||||
publishes delivery tasks, and the Review & Send page polls summary counters.
|
||||
publishes durable delivery tasks, and Review and send polls their persisted
|
||||
summary counters even after the initiating request has returned.
|
||||
- Mock send: use only for development review. It does not prove real SMTP/IMAP
|
||||
credentials or server policy.
|
||||
|
||||
@@ -26,6 +28,8 @@ been validated, built, reviewed, and locked.
|
||||
ZIP before using production recipients.
|
||||
- Keep the report page open during tests; it is the operational source of truth
|
||||
for attempts, outcomes, and reconciliation.
|
||||
- Confirm the effective Send now recipient-job limit. The safe default is 25;
|
||||
use Queue for workers for ordinary batches or any run above that limit.
|
||||
|
||||
## Deliverability Preflight
|
||||
|
||||
@@ -49,10 +53,15 @@ Before the first live send for a sender domain or mail-server profile:
|
||||
1. Validate the version with file checks enabled.
|
||||
2. Build the version and inspect all blocking review items.
|
||||
3. Queue only after the selected version is the intended immutable execution
|
||||
version.
|
||||
4. In local mode, use "Send queued now" for small test runs.
|
||||
version. Select **Queue for workers**, then verify the committed and
|
||||
published counts.
|
||||
4. Use **Send now** only if the exact eligible count is non-zero and at or below
|
||||
the effective deployment/tenant limit shown on the page.
|
||||
5. In worker mode, verify queue counters move from queued/claimed/sending to a
|
||||
terminal SMTP state.
|
||||
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
|
||||
directs oversized runs to workers.
|
||||
|
||||
## Outcome Handling
|
||||
|
||||
@@ -98,6 +107,8 @@ bed where possible:
|
||||
## Reporting Checks
|
||||
|
||||
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||
- Excluded messages must show SMTP and IMAP as `skipped`, with skipped counts
|
||||
and filters separate from unattempted or failed delivery.
|
||||
- Accepted and unknown jobs must not appear in retry selections.
|
||||
- Reconciled accepted jobs must remain protected from resend.
|
||||
- Reconciled not-sent jobs must appear only as explicit retry candidates.
|
||||
|
||||
@@ -144,6 +144,11 @@ paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
||||
the dedicated diagnostic permission and must not leak through ordinary campaign,
|
||||
version, job, or report responses.
|
||||
|
||||
The current Campaign Report Web UI additionally requires recipient-read access
|
||||
and does not yet hide every action control that the actor lacks. The server
|
||||
still authorizes each action, but an aggregate-only reader UI remains open
|
||||
work; do not promise that experience from `campaigns:report:read` alone.
|
||||
|
||||
### Deliver and resolve outcomes
|
||||
|
||||
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
|
||||
@@ -151,9 +156,13 @@ operator sequence.
|
||||
|
||||
At a minimum:
|
||||
|
||||
1. Queue only a validated, locked, built version.
|
||||
2. Use worker delivery for ordinary batches; synchronous send is intentionally
|
||||
limited to small controlled runs.
|
||||
1. Queue only a validated, locked, built version. Use **Queue for workers** for
|
||||
ordinary batches; the durable progress remains visible after leaving and
|
||||
returning to Review and send.
|
||||
2. Use **Send now** only when the exact persisted eligible build is within the
|
||||
effective synchronous limit shown by the UI. The default deployment limit
|
||||
is 25 recipient jobs. The backend repeats the count and preflights every
|
||||
message and the Mail profile revision before contacting SMTP.
|
||||
3. Treat `smtp_accepted` as protected from ordinary retry.
|
||||
4. Retry `failed_temporary` explicitly after inspecting the cause.
|
||||
5. Include `failed_permanent` only after correcting the cause and making a
|
||||
@@ -172,9 +181,17 @@ Pause stops new eligible work but cannot undo a provider effect already in
|
||||
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
||||
it cannot recall accepted mail.
|
||||
|
||||
The deployment ceiling is configured with
|
||||
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0 disables Send now; the
|
||||
accepted range is 0–500). A tenant may only narrow that ceiling with
|
||||
`tenant.settings.campaign_delivery_policy.synchronous_send_max_recipients`.
|
||||
The effective value and source are returned by the protected delivery-options
|
||||
API, recorded for successful/rejected synchronous commands, and stated in the
|
||||
configured handbook topic.
|
||||
|
||||
### Test and one-message actions
|
||||
|
||||
The current baseline includes mock send, queue dry-run, synchronous small-run
|
||||
The current baseline includes mock send, queue dry-run, synchronous immediate
|
||||
delivery, sending one selected job, retry selection, and unattempted-job
|
||||
selection. Their audit and state behavior is not yet the final vocabulary
|
||||
accepted for issue `govoplan-campaign#69`.
|
||||
@@ -212,10 +229,12 @@ continue to satisfy the snapshot. A host, account identity, protocol, sender,
|
||||
or other revisioned transport change stops delivery until the campaign is
|
||||
revalidated and rebuilt.
|
||||
|
||||
### Legacy campaign records
|
||||
### Existing legacy database records
|
||||
|
||||
Historical versions may contain inline SMTP/IMAP material. They are retained as
|
||||
audit records, but:
|
||||
The current Campaign database may contain versions with inline SMTP/IMAP
|
||||
material. No separate historical Campaign JSON corpus exists. Inline transport
|
||||
material in those rows is treated as inert legacy data and is never interpreted
|
||||
as an executable Mail configuration:
|
||||
|
||||
- public responses remove legacy transport fields and secrets;
|
||||
- validation, build, queue, retry, and delivery fail closed;
|
||||
@@ -223,11 +242,10 @@ audit records, but:
|
||||
Mail-settings save; and
|
||||
- a locked version is preserved and must be forked to an editable successor.
|
||||
|
||||
This is execution containment, not complete historical secret remediation.
|
||||
Operators still need a separately approved migration/retention procedure for
|
||||
old database snapshots and backups that may contain legacy campaign secrets;
|
||||
that decision and its acceptance conditions are tracked in
|
||||
[`govoplan-campaign#77`](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/77).
|
||||
Normal database backup/restore and access controls cover those rows together
|
||||
with the rest of the current database. There is no separate historical-JSON,
|
||||
backup-scanning, or inline-secret migration program. Restoring an existing
|
||||
legacy row preserves it as inert evidence and does not make it deliverable.
|
||||
|
||||
### Recipient and attachment evidence
|
||||
|
||||
@@ -243,6 +261,13 @@ The delivery record should be able to identify:
|
||||
reconciliation note; and
|
||||
- actor/system trigger, timestamps, policy context, and corrections.
|
||||
|
||||
An excluded recipient/message is a completed validation decision, not a
|
||||
pending delivery. Its SMTP and IMAP states are both `skipped`; it is counted and
|
||||
filterable separately from unattempted, failed, accepted, and append outcomes.
|
||||
No SMTP or IMAP attempt exists for such a row. If historical data contains
|
||||
actual transport evidence despite an exclusion marker, that evidence is
|
||||
preserved for audit and reconciliation rather than relabelled.
|
||||
|
||||
## Administration and policy
|
||||
|
||||
### Roles and permissions
|
||||
@@ -444,9 +469,7 @@ the current baseline:
|
||||
- a fully packaged one-command Campaign reference composition with production
|
||||
policy presets and target-provider certification;
|
||||
- function-bound Postbox delivery (stage 2 of the reference program);
|
||||
- generic workflow-driven campaign transitions; and
|
||||
- complete remediation of legacy secrets in historical databases and backups
|
||||
([`govoplan-campaign#77`](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/77)).
|
||||
- generic workflow-driven campaign transitions.
|
||||
|
||||
Each item needs an owning issue, implementation, failure tests, documentation,
|
||||
and release evidence before the wording above can move from planned to current.
|
||||
|
||||
@@ -57,10 +57,12 @@ Campaign authors need `mail:profile:use` in addition to the relevant Campaign
|
||||
permission. Profile visibility remains governed by Mail policy and campaign
|
||||
owner context.
|
||||
|
||||
## Legacy versions and migration
|
||||
## Existing database rows and migration
|
||||
|
||||
Older versions can contain inline SMTP/IMAP settings or credentials. GovOPlaN
|
||||
does not delete or rewrite those stored audit records automatically:
|
||||
The current database can contain campaign versions with inline SMTP/IMAP
|
||||
settings or credentials. There is no separate historical Campaign JSON corpus
|
||||
to import or remediate. GovOPlaN treats those inline fields as inert legacy
|
||||
material and does not delete or rewrite the stored audit rows automatically:
|
||||
|
||||
- API responses omit all legacy transport fields and secrets and expose a
|
||||
`mail_profile_migration_required` marker.
|
||||
@@ -72,11 +74,12 @@ does not delete or rewrite those stored audit records automatically:
|
||||
- a locked version remains unchanged. Creating its editable successor records
|
||||
the migration while retaining the locked source as audit evidence.
|
||||
|
||||
Legacy execution snapshots are likewise retained for audit but cannot be used
|
||||
for delivery. Revalidate and rebuild an editable profile-only version. Removing
|
||||
the remaining live and backup secret copies requires the separately approved
|
||||
remediation in
|
||||
[`govoplan-campaign#77`](https://git.add-ideas.de/add-ideas/govoplan-campaign/issues/77).
|
||||
Legacy execution snapshots are likewise retained but cannot be used for
|
||||
delivery. Revalidate and rebuild an editable profile-only version. Normal
|
||||
database backup, restore, encryption, and access controls apply to the current
|
||||
database as a whole; the product does not define a separate historical-JSON or
|
||||
inline-secret recovery workflow. A restored legacy row remains inert and
|
||||
fail-closed under the same rules.
|
||||
|
||||
## Operator checks
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -22,7 +22,7 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.9"
|
||||
version = "0.1.10"
|
||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -89,6 +89,7 @@ class BuildStatus(StrEnum):
|
||||
class SendStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
QUEUED = "queued"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class CampaignMeta(StrictModel):
|
||||
|
||||
@@ -78,6 +78,7 @@ class JobQueueStatus(StrEnum):
|
||||
|
||||
class JobSendStatus(StrEnum):
|
||||
NOT_QUEUED = "not_queued"
|
||||
SKIPPED = "skipped"
|
||||
QUEUED = "queued"
|
||||
CLAIMED = "claimed"
|
||||
SENDING = "sending"
|
||||
@@ -212,6 +213,8 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
campaign: Mapped[Campaign] = relationship(back_populates="versions")
|
||||
|
||||
|
||||
107
src/govoplan_campaign/backend/delivery_policy.py
Normal file
107
src/govoplan_campaign/backend/delivery_policy.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 25
|
||||
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS = 500
|
||||
SYNCHRONOUS_SEND_MAX_ENV = "GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS"
|
||||
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY = "campaign_delivery_policy"
|
||||
SYNCHRONOUS_SEND_MAX_SETTINGS_KEY = "synchronous_send_max_recipients"
|
||||
|
||||
|
||||
class CampaignDeliveryPolicyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SynchronousSendPolicy:
|
||||
max_recipient_jobs: int
|
||||
source: str
|
||||
deployment_max_recipient_jobs: int
|
||||
tenant_max_recipient_jobs: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"max_recipient_jobs": self.max_recipient_jobs,
|
||||
"source": self.source,
|
||||
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
|
||||
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
|
||||
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
|
||||
"tenant_setting": (
|
||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def effective_synchronous_send_policy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> SynchronousSendPolicy:
|
||||
env = os.environ if environ is None else environ
|
||||
deployment_value = _configured_limit(
|
||||
env.get(SYNCHRONOUS_SEND_MAX_ENV),
|
||||
source=SYNCHRONOUS_SEND_MAX_ENV,
|
||||
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
|
||||
if tenant_raw is None:
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=deployment_value,
|
||||
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
)
|
||||
|
||||
tenant_value = _configured_limit(
|
||||
tenant_raw,
|
||||
source=(
|
||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||
),
|
||||
)
|
||||
effective_value = min(deployment_value, tenant_value)
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=effective_value,
|
||||
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
tenant_max_recipient_jobs=tenant_value,
|
||||
)
|
||||
|
||||
|
||||
def _tenant_limit_value(settings: Mapping[str, Any] | None) -> object | None:
|
||||
if not isinstance(settings, Mapping):
|
||||
return None
|
||||
policy = settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY)
|
||||
if not isinstance(policy, Mapping):
|
||||
return None
|
||||
return policy.get(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY)
|
||||
|
||||
|
||||
def _configured_limit(value: object, *, source: str, default: int | None = None) -> int:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
if default is not None:
|
||||
return default
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be configured as an integer")
|
||||
if isinstance(value, bool):
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be an integer, not a boolean")
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be an integer") from exc
|
||||
if str(parsed) != str(value).strip() and not isinstance(value, int):
|
||||
raise CampaignDeliveryPolicyError(f"{source} must be an integer")
|
||||
if parsed < 0 or parsed > ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS:
|
||||
raise CampaignDeliveryPolicyError(
|
||||
f"{source} must be between 0 and {ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS}"
|
||||
)
|
||||
return parsed
|
||||
755
src/govoplan_campaign/backend/documentation.py
Normal file
755
src/govoplan_campaign/backend/documentation.py
Normal file
@@ -0,0 +1,755 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
|
||||
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
|
||||
|
||||
_CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:archive",
|
||||
"campaigns:campaign:delete",
|
||||
"campaigns:campaign:share",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:campaign:send_test",
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
"campaigns:recipient:export",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:export",
|
||||
"campaigns:report:send",
|
||||
)
|
||||
|
||||
_CAMPAIGN_HELP_CONTEXTS = (
|
||||
"campaigns.list",
|
||||
"campaign.overview",
|
||||
)
|
||||
|
||||
_FILES_INTEGRATION = "files.campaign_attachments"
|
||||
_MAIL_INTEGRATION = "mail.campaign_delivery"
|
||||
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
|
||||
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
|
||||
|
||||
def _workflow_topic(
|
||||
*,
|
||||
topic_id: str,
|
||||
title: str,
|
||||
summary: str,
|
||||
body: str,
|
||||
order: int,
|
||||
audience: tuple[str, ...],
|
||||
required_scopes: tuple[str, ...],
|
||||
route: str,
|
||||
screen: str,
|
||||
help_contexts: tuple[str, ...],
|
||||
prerequisites: tuple[str, ...],
|
||||
steps: tuple[str, ...],
|
||||
outcome: str,
|
||||
verification: str,
|
||||
related_topic_ids: tuple[str, ...] = (),
|
||||
required_modules: tuple[str, ...] = ("campaigns",),
|
||||
required_capabilities: tuple[str, ...] = (),
|
||||
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||
related_modules: tuple[str, ...] = (),
|
||||
limitations: tuple[str, ...] = (),
|
||||
) -> DocumentationTopic:
|
||||
metadata: dict[str, object] = {
|
||||
"kind": "workflow",
|
||||
"route": route,
|
||||
"screen": screen,
|
||||
"help_contexts": list(help_contexts),
|
||||
"prerequisites": list(prerequisites),
|
||||
"steps": list(steps),
|
||||
"outcome": outcome,
|
||||
"verification": verification,
|
||||
"related_topic_ids": list(related_topic_ids),
|
||||
}
|
||||
if limitations:
|
||||
metadata["limitations"] = list(limitations)
|
||||
return DocumentationTopic(
|
||||
id=topic_id,
|
||||
title=title,
|
||||
summary=summary,
|
||||
body=body,
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=audience,
|
||||
order=order,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=required_modules,
|
||||
required_capabilities=required_capabilities,
|
||||
required_scopes=required_scopes,
|
||||
),
|
||||
),
|
||||
links=links,
|
||||
related_modules=related_modules,
|
||||
unlocks=(outcome,),
|
||||
source_module_id="campaigns",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_USER_DOCUMENTATION = (
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.create-campaign",
|
||||
title="Create a campaign",
|
||||
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.",
|
||||
order=30,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||
route="/campaigns",
|
||||
screen="Campaigns",
|
||||
help_contexts=("campaigns.list",),
|
||||
prerequisites=("You may create campaigns in the active tenant.",),
|
||||
steps=(
|
||||
"Open Campaigns and select New campaign.",
|
||||
"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.",
|
||||
"Continue through the preparation sections and save the 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.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.import-recipients"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.create-editable-successor",
|
||||
title="Create an editable successor",
|
||||
summary="Continue work after a permanent or delivery-final lock without rewriting the preserved version.",
|
||||
body="Create editable copy means creating the campaign's next working version. Validation locks and temporary user locks are removed in place instead; they must not create parallel drafts.",
|
||||
order=31,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign workspace",
|
||||
help_contexts=("campaign.overview", "campaign.audit"),
|
||||
prerequisites=(
|
||||
"You have write access to the selected campaign.",
|
||||
"Its current version is permanently user-locked or delivery-final.",
|
||||
),
|
||||
steps=(
|
||||
"Open the locked current version and review the reason it is read-only.",
|
||||
"Select Create editable copy in the locked-version notice.",
|
||||
"If the notice instead offers an unlock action, unlock that validation or temporary user lock rather than creating a successor.",
|
||||
"Review the new working version, reselect any Mail profile when requested, and validate and build again before delivery.",
|
||||
),
|
||||
outcome="A new editable working version while the source version and its evidence remain unchanged.",
|
||||
verification="The Campaign overview identifies a new current version number and the earlier version remains in history.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-recipients",
|
||||
title="Import recipients into a campaign",
|
||||
summary="Turn a text, CSV, or spreadsheet table into reviewed campaign-local recipient rows with source provenance.",
|
||||
body="Import copies valid rows into the editable campaign version. Invalid rows stay visible during preview instead of disappearing, and later changes to the source file do not silently change the saved campaign.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/recipients",
|
||||
screen="Recipient data",
|
||||
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||
prerequisites=(
|
||||
"The current campaign version is editable and you have write access to it.",
|
||||
"The source is tabular and contains only data needed for this campaign.",
|
||||
),
|
||||
steps=(
|
||||
"Open Recipient data and select Import.",
|
||||
"Upload or paste the table, then confirm its encoding, sheet, header rows, and separator where applicable.",
|
||||
"Map address and campaign fields, choose append or replace, and review every invalid or excluded row.",
|
||||
"Select Import valid rows, inspect the resulting recipient table, and save the campaign page.",
|
||||
),
|
||||
outcome="A campaign-local recipient snapshot with mapping and source evidence.",
|
||||
verification="Reopen Recipient data, confirm the expected row count and addressing, then validate before building messages.",
|
||||
related_topic_ids=("campaigns.workflow.import-address-source", "campaigns.workflow.prepare-validate-and-build"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-address-source",
|
||||
title="Import a recipient source from Addresses",
|
||||
summary="Copy a permitted reusable address book or list into the campaign as a traceable versioned snapshot.",
|
||||
body="Campaign does not follow the address source live. It records the selected source revision and warns when a newer source revision is available, so re-import is always an explicit author action.",
|
||||
order=32,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_modules=("campaigns", "addresses"),
|
||||
required_capabilities=(_ADDRESSES_SOURCE_INTEGRATION,),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/recipients",
|
||||
screen="Recipient data",
|
||||
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||
prerequisites=(
|
||||
"Addresses offers at least one source visible to you.",
|
||||
"The current campaign version is editable and you have write access to it.",
|
||||
),
|
||||
steps=(
|
||||
"Open Recipient data and select Import address book/list.",
|
||||
"Choose the permitted source and review the returned snapshot and its revision.",
|
||||
"Choose append or replace, import the snapshot, inspect the recipient rows, and save.",
|
||||
"When a stale-source warning appears later, compare the source and re-import deliberately if the campaign should use the new revision.",
|
||||
),
|
||||
outcome="A campaign-local recipient snapshot whose Addresses source and revision can be traced.",
|
||||
verification="Recipient data records the source provenance, and a later source revision does not alter the saved rows until you re-import it.",
|
||||
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||
related_modules=("addresses",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.use-managed-attachments",
|
||||
title="Use managed files as campaign attachments",
|
||||
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
|
||||
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_modules=("campaigns", "files"),
|
||||
required_capabilities=(_FILES_INTEGRATION,),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:recipient:read",
|
||||
"files:file:read",
|
||||
"files:file:share",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/files",
|
||||
screen="Attachments",
|
||||
help_contexts=("campaign.attachments",),
|
||||
prerequisites=(
|
||||
"The current campaign version is editable and you have write access to it.",
|
||||
"The required file versions exist in Files and may be shared with this campaign.",
|
||||
),
|
||||
steps=(
|
||||
"Open Attachments and choose managed files or patterns from Files.",
|
||||
"Define campaign-wide and recipient-specific rules, including unmatched-file and archive behavior.",
|
||||
"Preview the matches and link the exact managed versions to the campaign.",
|
||||
"Save, validate, and build, then inspect the resolved attachment evidence in Review and send.",
|
||||
),
|
||||
outcome="Governed file versions linked to the campaign and frozen into the exact build evidence.",
|
||||
verification="Confirm the expected filenames, paths, matches, and link state in the Campaign UI. Exact managed versions and checksums remain retained in build evidence for authorized supporting tools.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
|
||||
links=(
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
DocumentationLink(label="Files", href="/files", kind="runtime"),
|
||||
),
|
||||
related_modules=("files",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.queue-delivery",
|
||||
title="Queue a campaign for controlled delivery",
|
||||
summary="Place an exact reviewed build into the worker queue while preserving recipient-level state and retry protection.",
|
||||
body="Queueing is a controlled state change, not proof of delivery. Ordinary batches should use background workers, and accepted or outcome-unknown effects remain protected from blind retry.",
|
||||
order=35,
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
required_modules=("campaigns", "mail"),
|
||||
required_capabilities=(_MAIL_INTEGRATION,),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:recipient:read",
|
||||
"mail:profile:use",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/review",
|
||||
screen="Review and send",
|
||||
help_contexts=(),
|
||||
prerequisites=(
|
||||
"The selected version is validated, locked, built, and reviewed.",
|
||||
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||
"You have write access to the selected campaign.",
|
||||
),
|
||||
steps=(
|
||||
"Open Review and send and confirm the selected version, build, recipients, warnings, and review completion.",
|
||||
"Select Queue for workers and confirm the exact durable execution that will be committed.",
|
||||
"Remain on Review and send or leave and return later; both views read the same durable job states.",
|
||||
"Use the delivery controls to pause, resume, cancel unsent work, or retry eligible failures without requeueing accepted or uncertain outcomes.",
|
||||
),
|
||||
outcome="Eligible jobs queued with an auditable execution snapshot and protected delivery state.",
|
||||
verification="The Report shows the selected version's jobs as queued or progressing, without changing any accepted job back to retryable.",
|
||||
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
||||
links=(
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||
),
|
||||
related_modules=("mail", "notifications"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.send-small-controlled-run",
|
||||
title="Send a campaign immediately",
|
||||
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
||||
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
|
||||
order=36,
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
required_modules=("campaigns", "mail"),
|
||||
required_capabilities=(_MAIL_INTEGRATION,),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:recipient:read",
|
||||
"mail:profile:use",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/review",
|
||||
screen="Review and send",
|
||||
help_contexts=("campaign.review-send",),
|
||||
prerequisites=(
|
||||
"The selected version is validated, locked, built, and reviewed.",
|
||||
"The exact eligible recipient-job count is within the effective limit shown on Review and send.",
|
||||
"Its Mail profile remains available and authorized for the current campaign context.",
|
||||
),
|
||||
steps=(
|
||||
"Open Review and send and inspect the exact build and delivery readiness checks.",
|
||||
"Review the effective synchronous limit and exact eligible count; use Queue for workers when Send now is unavailable or for an ordinary batch.",
|
||||
"Select Send now and confirm the protected delivery action.",
|
||||
"Open Report and inspect each resulting delivery state before attempting any recovery action.",
|
||||
),
|
||||
outcome="A synchronous real delivery run with recipient-level attempt and outcome evidence.",
|
||||
verification="The Report distinguishes accepted, failed, unattempted, cancelled, and outcome-unknown jobs and retains their attempt history.",
|
||||
related_topic_ids=("campaigns.workflow.queue-delivery", "campaigns.workflow.retry-and-reconcile"),
|
||||
related_modules=("mail",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.view-aggregate-delivery-report",
|
||||
title="Review aggregate campaign outcomes",
|
||||
summary="Inspect privacy-protected Campaign totals without receiving recipient rows, message content, delivery diagnostics, or export authority.",
|
||||
body="The aggregate Reports view exposes only approved campaign-level business outcomes. Positive cells below the configured threshold are suppressed together with a complementary value or the denominator when needed, so totals cannot be subtracted to recover a small group.",
|
||||
order=37,
|
||||
audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"),
|
||||
required_scopes=("campaigns:report:read",),
|
||||
route="/reports",
|
||||
screen="Reports",
|
||||
help_contexts=("campaign.report",),
|
||||
prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",),
|
||||
steps=(
|
||||
"Open Reports and select a campaign available to you.",
|
||||
"Read the stated denominator before comparing accepted, failed, unknown, active, excluded, cancelled, and unattempted outcomes.",
|
||||
"Treat Suppressed as an intentional privacy boundary rather than zero or missing data.",
|
||||
"Request separately authorized recipient-level access only when the task genuinely requires individual evidence.",
|
||||
),
|
||||
outcome="A business-level Campaign outcome view with small-group and recipient privacy preserved.",
|
||||
verification="No row, address, message, attachment, diagnostic, filter, drill-down, or export action is available from the aggregate view.",
|
||||
related_topic_ids=("campaigns.workflow.view-delivery-report",),
|
||||
links=(DocumentationLink(label="Aggregate Campaign reports", href="/reports", kind="runtime"),),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.view-delivery-report",
|
||||
title="Review campaign delivery details",
|
||||
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
|
||||
body="The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface.",
|
||||
order=38,
|
||||
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
||||
route="/campaigns/{campaign_id}/report",
|
||||
screen="Campaign Report",
|
||||
help_contexts=("campaign.report",),
|
||||
prerequisites=("You may read the selected campaign and its report.",),
|
||||
steps=(
|
||||
"Open the campaign and select Report.",
|
||||
"Review totals for queued, accepted, failed, cancelled, excluded/skipped, unattempted, and outcome-unknown jobs.",
|
||||
"Inspect the authorized recipient-level rows and attempt history.",
|
||||
"Treat outcome-unknown jobs as unresolved and hand them to an authorized operator for evidence-backed reconciliation.",
|
||||
),
|
||||
outcome="A recipient-aware view of delivery progress and outcomes with diagnostics still separately protected.",
|
||||
verification="The report totals and job rows match the selected campaign version, and infrastructure diagnostics are absent unless separately authorized.",
|
||||
related_topic_ids=("campaigns.workflow.view-aggregate-delivery-report", "campaigns.workflow.retry-and-reconcile", "campaigns.workflow.export-delivery-report"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.export-delivery-report",
|
||||
title="Export recipient delivery results",
|
||||
summary="Download an authorized CSV snapshot of recipient-level campaign delivery results for controlled downstream use.",
|
||||
body="A report export contains personal and delivery evidence. Store, transmit, retain, and delete it according to the campaign's purpose and the applicable export and retention policy.",
|
||||
order=39,
|
||||
audience=("campaign_report_exporter", "campaign_auditor"),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:export",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:export",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/report",
|
||||
screen="Campaign Report",
|
||||
help_contexts=("campaign.report", "campaign.audit"),
|
||||
prerequisites=(
|
||||
"You may export both campaign reports and recipient data.",
|
||||
"The export has an approved purpose and destination.",
|
||||
),
|
||||
steps=(
|
||||
"Open the campaign Report and select the intended version.",
|
||||
"Confirm that recipient-level export is necessary for the task.",
|
||||
"Select Download CSV and store the result only in the approved location.",
|
||||
"Verify the selected version and row counts, then apply the required retention or deletion rule to the downloaded copy.",
|
||||
),
|
||||
outcome="A point-in-time CSV export of authorized campaign job results.",
|
||||
verification="The downloaded file identifies the intended campaign/version and contains business evidence without credentials or ordinary-reader infrastructure details.",
|
||||
related_topic_ids=("campaigns.workflow.view-delivery-report",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.share-campaign",
|
||||
title="Share a campaign",
|
||||
summary="Grant a user or group explicit view or write access to one campaign without widening their platform permissions.",
|
||||
body="A share can only narrow access to the selected campaign within the recipient's existing role. It never grants Mail profile use, Files authority, tenant-wide recipient access, or a missing Campaign action.",
|
||||
order=39,
|
||||
audience=("campaign_owner", "campaign_access_manager"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:share"),
|
||||
route="/campaigns/{campaign_id}/global-settings",
|
||||
screen="Ownership and sharing",
|
||||
help_contexts=("campaign.overview", "campaign.global-settings"),
|
||||
prerequisites=(
|
||||
"You have write access to the selected campaign.",
|
||||
"The target user or group already has an appropriate Campaign role for the intended actions.",
|
||||
),
|
||||
steps=(
|
||||
"Open Global settings and find Ownership and sharing.",
|
||||
"Select Share, choose a user or group, and choose Can view or Can edit and operate.",
|
||||
"Save the share and verify the target appears in the sharing table with the intended level.",
|
||||
"Use the row action to revoke the explicit share when it is no longer needed.",
|
||||
),
|
||||
outcome="Explicit campaign access for the selected subject, bounded by that subject's existing permissions.",
|
||||
verification="The sharing table shows the active target and level; revocation removes its explicit access while preserving the audit record.",
|
||||
related_topic_ids=("campaigns.reference.composition-assurance",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.archive-campaign",
|
||||
title="Archive a campaign",
|
||||
summary="Remove a completed campaign from active work while preserving its versions, outcomes, and audit evidence.",
|
||||
body="Archive only after queued, sending, and outcome-unknown work has been resolved. Archiving preserves evidence and is the correct lifecycle action for any campaign with build, lock, or delivery history.",
|
||||
order=40,
|
||||
audience=("campaign_owner", "campaign_records_manager"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign lifecycle",
|
||||
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
|
||||
prerequisites=(
|
||||
"You have write access to the selected campaign.",
|
||||
"No delivery job is queued, sending, or awaiting uncertain-outcome reconciliation.",
|
||||
),
|
||||
steps=(
|
||||
"Open Report and resolve every active or outcome-unknown delivery state.",
|
||||
"Confirm that the campaign should leave active work while its evidence remains retained.",
|
||||
"Invoke the authorized Archive action from a supporting client.",
|
||||
"Reopen or query the campaign and confirm its state is Archived.",
|
||||
),
|
||||
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
|
||||
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
|
||||
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
|
||||
limitations=(
|
||||
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
|
||||
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||
),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.delete-untouched-draft",
|
||||
title="Delete an untouched campaign draft",
|
||||
summary="Immediately remove a draft that has no protected build, lock, publication, snapshot, or delivery evidence.",
|
||||
body="Deletion is deliberately narrower than archive. It marks an eligible draft deleted and emits an audit record; it cannot erase a campaign that already carries evidence that must be retained.",
|
||||
order=41,
|
||||
audience=("campaign_owner", "campaign_records_manager"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:delete"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign lifecycle",
|
||||
help_contexts=("campaign.overview", "campaign.audit"),
|
||||
prerequisites=(
|
||||
"You have write access to the selected campaign.",
|
||||
"The campaign is still a draft and has no jobs, locks, published version, or execution snapshot.",
|
||||
),
|
||||
steps=(
|
||||
"Confirm that the draft is not needed and contains no evidence that should be retained.",
|
||||
"Invoke the authorized Delete action from a supporting client and confirm the destructive action.",
|
||||
"If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.",
|
||||
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
|
||||
),
|
||||
outcome="An eligible untouched draft removed from active Campaigns with attributable deletion evidence.",
|
||||
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
|
||||
related_topic_ids=("campaigns.workflow.archive-campaign",),
|
||||
limitations=(
|
||||
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
|
||||
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||
"""Describe the current Campaign composition without resolving module data.
|
||||
|
||||
The provider deliberately uses only the actor's permission evaluator and the
|
||||
registry's declared contracts. Resource access, policy decisions, and
|
||||
campaign state remain authoritative at the point where an action is used.
|
||||
"""
|
||||
|
||||
if context.documentation_type != "user":
|
||||
return ()
|
||||
principal = context.principal
|
||||
if principal is None or not _has_any_scope(principal, _CAMPAIGN_USER_SCOPES):
|
||||
return ()
|
||||
|
||||
mail_available = _integration_available(context.registry, _MAIL_INTEGRATION)
|
||||
current_configuration = list(_actor_capabilities(principal, mail_available=mail_available))
|
||||
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
|
||||
current_configuration.extend(integration_configuration)
|
||||
delivery_configuration, delivery_limitations = _delivery_policy_summary(context)
|
||||
current_configuration.extend(delivery_configuration)
|
||||
limitations = [
|
||||
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
|
||||
"Profile, file, and address pickers re-evaluate the actual resources visible in the selected campaign; this overview does not claim that an eligible item currently exists.",
|
||||
*integration_limitations,
|
||||
*delivery_limitations,
|
||||
]
|
||||
|
||||
return (
|
||||
DocumentationTopic(
|
||||
id="campaigns.current-composition",
|
||||
title="Your Campaign role and installed composition",
|
||||
summary="This guide separates actions authorized by your role from optional services connected to Campaign in this installation.",
|
||||
body=_composition_body(current_configuration, limitations),
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_user",),
|
||||
order=29,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=_CAMPAIGN_USER_SCOPES,
|
||||
),
|
||||
),
|
||||
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||
related_modules=("mail", "files", "addresses", "notifications"),
|
||||
source_module_id="campaigns",
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/campaigns",
|
||||
"screen": "Campaigns",
|
||||
"help_contexts": list(_CAMPAIGN_HELP_CONTEXTS),
|
||||
"current_configuration": current_configuration,
|
||||
"limitations": limitations,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str, ...]:
|
||||
capabilities: list[str] = []
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:read",), "Open campaigns shared with you and inspect their business state.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||
if mail_available:
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:control",), "Pause, resume, or cancel eligible delivery jobs.")
|
||||
if mail_available:
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
|
||||
_append_if(capabilities, principal, ("campaigns:report:read",), "View authorized campaign delivery reports.")
|
||||
_append_if(
|
||||
capabilities,
|
||||
principal,
|
||||
("campaigns:report:export", "campaigns:recipient:export"),
|
||||
"Export authorized delivery and recipient report data.",
|
||||
require_all=True,
|
||||
)
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:share",), "Manage explicit access to eligible campaigns.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:archive",), "Archive eligible campaigns while retaining their evidence.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:delete",), "Delete eligible untouched drafts.")
|
||||
if mail_available:
|
||||
_append_if(
|
||||
capabilities,
|
||||
principal,
|
||||
("campaigns:report:send", "campaigns:report:read", "mail:profile:use"),
|
||||
"Send an authorized campaign report through a Mail profile.",
|
||||
require_all=True,
|
||||
)
|
||||
return tuple(capabilities)
|
||||
|
||||
|
||||
def _integration_summary(registry: object, principal: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
configured: list[str] = []
|
||||
limitations: list[str] = []
|
||||
|
||||
mail_available = _integration_available(registry, _MAIL_INTEGRATION)
|
||||
can_select_mail_profile = _has_all_scopes(
|
||||
principal,
|
||||
("campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"),
|
||||
)
|
||||
can_deliver_with_mail = _has_scope(principal, "mail:profile:use") and _has_any_scope(
|
||||
principal,
|
||||
("campaigns:campaign:queue", "campaigns:campaign:send", "campaigns:campaign:retry"),
|
||||
)
|
||||
if mail_available and can_select_mail_profile:
|
||||
configured.append("Installed composition: Campaign can open Mail's actor-filtered profile picker while you edit; eligible profiles are resolved in the selected campaign context.")
|
||||
elif mail_available and can_deliver_with_mail:
|
||||
configured.append("Installed composition: Mail-backed Campaign delivery is connected, and the selected profile is re-authorized for each delivery action.")
|
||||
elif mail_available:
|
||||
limitations.append("Mail delivery is configured, but selecting a Mail profile is not included in your current tasks.")
|
||||
else:
|
||||
limitations.append("Mail-backed Campaign delivery is not available in the current composition.")
|
||||
|
||||
files_available = _integration_available(registry, _FILES_INTEGRATION)
|
||||
can_preview_files = _has_all_scopes(
|
||||
principal,
|
||||
("campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"),
|
||||
)
|
||||
can_link_files = _has_all_scopes(
|
||||
principal,
|
||||
(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:recipient:read",
|
||||
"files:file:read",
|
||||
"files:file:share",
|
||||
),
|
||||
)
|
||||
if files_available and can_link_files:
|
||||
configured.append("Installed composition: Managed file versions can be selected, previewed, and linked as governed campaign attachments when the selected campaign and files pass their resource checks.")
|
||||
elif files_available and can_preview_files:
|
||||
configured.append("Installed composition: Managed campaign attachments can be previewed; linking a version requires campaign-update, validation, and attachment-sharing authority.")
|
||||
elif files_available:
|
||||
limitations.append("Managed attachments are configured, but they are not included in your current Campaign tasks.")
|
||||
else:
|
||||
limitations.append("Managed file attachments are not available in the current composition; campaign-owned uploads remain separate.")
|
||||
|
||||
lookup_available = _integration_available(registry, _ADDRESSES_LOOKUP_INTEGRATION)
|
||||
source_available = _integration_available(registry, _ADDRESSES_SOURCE_INTEGRATION)
|
||||
if lookup_available and _has_scope(principal, "campaigns:recipient:read"):
|
||||
configured.append("Installed composition: Address records can be looked up while preparing recipients.")
|
||||
elif lookup_available:
|
||||
limitations.append("Address lookup is configured, but recipient inspection is not included in your current Campaign tasks.")
|
||||
else:
|
||||
limitations.append("Connected address lookup is not available in the current composition.")
|
||||
can_import_source = _has_all_scopes(
|
||||
principal,
|
||||
(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
),
|
||||
)
|
||||
if source_available and can_import_source:
|
||||
configured.append("Installed composition: The actor-filtered Addresses source picker can copy a selected source into a campaign as a traceable recipient snapshot.")
|
||||
elif source_available:
|
||||
limitations.append("Connected recipient sources are configured, but source import is not included in your current Campaign tasks.")
|
||||
else:
|
||||
limitations.append("Connected recipient-source snapshots are not available; direct campaign imports remain available when authorized.")
|
||||
|
||||
if _integration_available(registry, _NOTIFICATIONS_INTEGRATION):
|
||||
configured.append("Installed composition: In-app notifications can report important Campaign delivery status changes.")
|
||||
else:
|
||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||
|
||||
return tuple(configured), tuple(limitations)
|
||||
|
||||
|
||||
def _delivery_policy_summary(context: DocumentationContext) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
session = context.session
|
||||
tenant_id = str(getattr(context.principal, "tenant_id", "") or "").strip()
|
||||
if session is None or not tenant_id:
|
||||
return (), ("The effective synchronous Campaign limit could not be resolved for this documentation request.",)
|
||||
try:
|
||||
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id) # type: ignore[arg-type]
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
return (), (f"Synchronous Campaign delivery is disabled until its policy configuration is corrected: {exc}",)
|
||||
return (
|
||||
(
|
||||
"Delivery policy: Send now is limited to "
|
||||
f"{policy.max_recipient_jobs} eligible recipient job(s) for this tenant. "
|
||||
"The exact built count and Queue for workers alternative are shown before confirmation."
|
||||
),
|
||||
), ()
|
||||
|
||||
|
||||
def _append_if(
|
||||
target: list[str],
|
||||
principal: object,
|
||||
scopes: tuple[str, ...],
|
||||
text: str,
|
||||
*,
|
||||
require_all: bool = False,
|
||||
) -> None:
|
||||
allowed = _has_all_scopes(principal, scopes) if require_all else _has_any_scope(principal, scopes)
|
||||
if allowed:
|
||||
target.append(f"Role authorization: {text}")
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
checker = getattr(principal, "has", None)
|
||||
if not callable(checker):
|
||||
return False
|
||||
try:
|
||||
return bool(checker(scope))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_scope(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||
return any(_has_scope(principal, scope) for scope in scopes)
|
||||
|
||||
|
||||
def _has_all_scopes(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||
return all(_has_scope(principal, scope) for scope in scopes)
|
||||
|
||||
|
||||
def _integration_available(registry: object, interface_name: str) -> bool:
|
||||
return _registry_has_interface(registry, interface_name) and _registry_has_capability(registry, interface_name)
|
||||
|
||||
|
||||
def _registry_has_interface(registry: object, interface_name: str) -> bool:
|
||||
manifests_provider = getattr(registry, "manifests", None)
|
||||
if not callable(manifests_provider):
|
||||
return False
|
||||
try:
|
||||
manifests = tuple(manifests_provider())
|
||||
except Exception:
|
||||
return False
|
||||
return any(
|
||||
getattr(provider, "name", None) == interface_name
|
||||
for manifest in manifests
|
||||
for provider in (getattr(manifest, "provides_interfaces", ()) or ())
|
||||
)
|
||||
|
||||
|
||||
def _registry_has_capability(registry: object, capability_name: str) -> bool:
|
||||
capability_checker = getattr(registry, "has_capability", None)
|
||||
if not callable(capability_checker):
|
||||
return False
|
||||
try:
|
||||
return bool(capability_checker(capability_name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _composition_body(current_configuration: list[str], limitations: list[str]) -> str:
|
||||
del current_configuration, limitations
|
||||
return "The facts below are calculated for the current actor and installed module contracts. They do not bypass campaign ownership, sharing, state, or operation-time resource and policy checks."
|
||||
@@ -28,6 +28,7 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
@@ -74,6 +75,14 @@ PERMISSIONS = (
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="campaign_aggregate_reader",
|
||||
name="Campaign aggregate reader",
|
||||
description="View privacy-protected outcome totals without recipient or delivery diagnostics.",
|
||||
permissions=(
|
||||
"campaigns:report:read",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="campaign_manager",
|
||||
name="Campaign manager",
|
||||
@@ -147,7 +156,7 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.9",
|
||||
version="0.1.10",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("files", "mail", "notifications", "addresses"),
|
||||
provides_interfaces=(
|
||||
@@ -193,12 +202,12 @@ manifest = ModuleManifest(
|
||||
path="/operator",
|
||||
label="Operator Queue",
|
||||
icon="radio-tower",
|
||||
required_all=("campaigns:campaign:read",),
|
||||
required_any=(
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
),
|
||||
order=30,
|
||||
),
|
||||
@@ -213,12 +222,12 @@ manifest = ModuleManifest(
|
||||
path="/operator",
|
||||
label="Operator Queue",
|
||||
icon="radio-tower",
|
||||
required_all=("campaigns:campaign:read",),
|
||||
required_any=(
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
),
|
||||
order=30,
|
||||
),
|
||||
@@ -262,6 +271,7 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
*CAMPAIGN_USER_DOCUMENTATION,
|
||||
DocumentationTopic(
|
||||
id="campaigns.mail-profile-user-journey",
|
||||
title="Choose a Mail profile for campaign delivery",
|
||||
@@ -288,6 +298,7 @@ manifest = ModuleManifest(
|
||||
"kind": "workflow",
|
||||
"route": "/campaigns/{campaign_id}/mail-settings",
|
||||
"screen": "Campaign Mail settings",
|
||||
"help_contexts": ["campaign.server-settings"],
|
||||
"prerequisites": [
|
||||
"Campaign and Mail are installed.",
|
||||
"You may edit the current campaign version and use at least one authorized Mail profile.",
|
||||
@@ -401,6 +412,19 @@ manifest = ModuleManifest(
|
||||
"kind": "workflow",
|
||||
"route": "/campaigns/{campaign_id}/global-settings",
|
||||
"screen": "Campaign workspace",
|
||||
"help_contexts": [
|
||||
"campaign.overview",
|
||||
"campaign.settings",
|
||||
"campaign.fields",
|
||||
"campaign.template",
|
||||
"campaign.attachments",
|
||||
"campaign.recipients",
|
||||
"campaign.recipient-data",
|
||||
"campaign.server-settings",
|
||||
"campaign.global-settings",
|
||||
"campaign.review-send",
|
||||
"campaign.json",
|
||||
],
|
||||
"prerequisites": [
|
||||
"The campaign has an owner and a clear communication purpose.",
|
||||
"You may edit, validate, and build the selected campaign version.",
|
||||
@@ -447,6 +471,7 @@ manifest = ModuleManifest(
|
||||
"kind": "workflow",
|
||||
"route": "/campaigns/{campaign_id}/review",
|
||||
"screen": "Review and send",
|
||||
"help_contexts": ["campaign.review-send"],
|
||||
"prerequisites": [
|
||||
"The selected campaign version is validated and built.",
|
||||
"You may read the campaign and complete its review.",
|
||||
@@ -490,6 +515,7 @@ manifest = ModuleManifest(
|
||||
"kind": "workflow",
|
||||
"route": "/operator",
|
||||
"screen": "Campaign operator queue",
|
||||
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
|
||||
"prerequisites": [
|
||||
"You may perform the selected retry or reconciliation action.",
|
||||
"Provider, mailbox, worker, and campaign evidence has been preserved.",
|
||||
@@ -544,6 +570,7 @@ manifest = ModuleManifest(
|
||||
},
|
||||
),
|
||||
),
|
||||
documentation_providers=(documentation_topics,),
|
||||
capability_factories={
|
||||
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
|
||||
@@ -513,6 +513,12 @@ def _message_draft(
|
||||
eml_path: str | None = None,
|
||||
eml_size: int | None = None,
|
||||
) -> MessageDraft:
|
||||
if validation_status == MessageValidationStatus.EXCLUDED:
|
||||
# Exclusion is a completed validation decision, not a pending delivery.
|
||||
# Keep both transport projections explicit so reports never imply that
|
||||
# SMTP or IMAP work is still expected for this row.
|
||||
send_status = SendStatus.SKIPPED
|
||||
imap_status = ImapStatus.SKIPPED
|
||||
if imap_status is None:
|
||||
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
|
||||
return MessageDraft(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""persist the selected Campaign delivery mode
|
||||
|
||||
Revision ID: c7a2f91e4b60
|
||||
Revises: 4d5e6f7a9203
|
||||
Create Date: 2026-07-22 09:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7a2f91e4b60"
|
||||
down_revision = "4d5e6f7a9203"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.add_column(sa.Column("delivery_mode", sa.String(length=30), nullable=True))
|
||||
batch.add_column(sa.Column("delivery_mode_selected_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch.create_index("ix_campaign_versions_delivery_mode", ["delivery_mode"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.drop_index("ix_campaign_versions_delivery_mode")
|
||||
batch.drop_column("delivery_mode_selected_at")
|
||||
batch.drop_column("delivery_mode")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""mark untouched excluded jobs as skipped delivery
|
||||
|
||||
Revision ID: d8b3e2c1f4a5
|
||||
Revises: c7a2f91e4b60
|
||||
Create Date: 2026-07-22 11:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8b3e2c1f4a5"
|
||||
down_revision = "c7a2f91e4b60"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Only normalize rows with no recorded transport effect. Unexpected
|
||||
# historical delivery evidence must remain intact for audit/reconciliation.
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"UPDATE campaign_jobs "
|
||||
"SET send_status = 'skipped', imap_status = 'skipped' "
|
||||
"WHERE validation_status = 'excluded' "
|
||||
"AND send_status = 'not_queued' "
|
||||
"AND imap_status IN ('not_requested', 'pending', 'skipped')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"UPDATE campaign_jobs "
|
||||
"SET send_status = 'not_queued', imap_status = 'not_requested' "
|
||||
"WHERE validation_status = 'excluded' "
|
||||
"AND send_status = 'skipped' "
|
||||
"AND imap_status = 'skipped'"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""persist the selected Campaign delivery mode
|
||||
|
||||
Revision ID: c7a2f91e4b60
|
||||
Revises: 4d5e6f7a9203
|
||||
Create Date: 2026-07-22 09:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c7a2f91e4b60"
|
||||
down_revision = "4d5e6f7a9203"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.add_column(sa.Column("delivery_mode", sa.String(length=30), nullable=True))
|
||||
batch.add_column(sa.Column("delivery_mode_selected_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch.create_index("ix_campaign_versions_delivery_mode", ["delivery_mode"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("campaign_versions") as batch:
|
||||
batch.drop_index("ix_campaign_versions_delivery_mode")
|
||||
batch.drop_column("delivery_mode_selected_at")
|
||||
batch.drop_column("delivery_mode")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""mark untouched excluded jobs as skipped delivery
|
||||
|
||||
Revision ID: d8b3e2c1f4a5
|
||||
Revises: c7a2f91e4b60
|
||||
Create Date: 2026-07-22 11:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d8b3e2c1f4a5"
|
||||
down_revision = "c7a2f91e4b60"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Only normalize rows with no recorded transport effect. Unexpected
|
||||
# historical delivery evidence must remain intact for audit/reconciliation.
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"UPDATE campaign_jobs "
|
||||
"SET send_status = 'skipped', imap_status = 'skipped' "
|
||||
"WHERE validation_status = 'excluded' "
|
||||
"AND send_status = 'not_queued' "
|
||||
"AND imap_status IN ('not_requested', 'pending', 'skipped')"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"UPDATE campaign_jobs "
|
||||
"SET send_status = 'not_queued', imap_status = 'not_requested' "
|
||||
"WHERE validation_status = 'excluded' "
|
||||
"AND send_status = 'skipped' "
|
||||
"AND imap_status = 'skipped'"
|
||||
)
|
||||
)
|
||||
@@ -35,7 +35,7 @@ from govoplan_campaign.backend.campaign.validation import validate_campaign_conf
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
|
||||
@@ -406,7 +406,11 @@ def _job_from_message(
|
||||
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
|
||||
validation_status=_job_validation_status(message.validation_status.value),
|
||||
queue_status=JobQueueStatus.DRAFT.value,
|
||||
send_status=JobSendStatus.NOT_QUEUED.value,
|
||||
send_status=(
|
||||
JobSendStatus.SKIPPED.value
|
||||
if message.send_status == SendStatus.SKIPPED
|
||||
else JobSendStatus.NOT_QUEUED.value
|
||||
),
|
||||
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
|
||||
resolved_recipients={
|
||||
"from": message.from_.model_dump(mode="json") if message.from_ else None,
|
||||
|
||||
109
src/govoplan_campaign/backend/report_privacy_policy.py
Normal file
109
src/govoplan_campaign/backend/report_privacy_policy.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
DEFAULT_SMALL_CELL_THRESHOLD = 5
|
||||
MIN_SMALL_CELL_THRESHOLD = 2
|
||||
MAX_SMALL_CELL_THRESHOLD = 100
|
||||
SMALL_CELL_THRESHOLD_ENV = "GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD"
|
||||
CAMPAIGN_REPORT_POLICY_SETTINGS_KEY = "campaign_report_privacy_policy"
|
||||
SMALL_CELL_THRESHOLD_SETTINGS_KEY = "small_cell_threshold"
|
||||
|
||||
|
||||
class CampaignReportPrivacyPolicyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignReportPrivacyPolicy:
|
||||
small_cell_threshold: int
|
||||
source: str
|
||||
deployment_small_cell_threshold: int
|
||||
tenant_small_cell_threshold: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"small_cell_threshold": self.small_cell_threshold,
|
||||
"source": self.source,
|
||||
"deployment_small_cell_threshold": self.deployment_small_cell_threshold,
|
||||
"tenant_small_cell_threshold": self.tenant_small_cell_threshold,
|
||||
"deployment_setting": SMALL_CELL_THRESHOLD_ENV,
|
||||
"tenant_setting": (
|
||||
f"tenant.settings.{CAMPAIGN_REPORT_POLICY_SETTINGS_KEY}."
|
||||
f"{SMALL_CELL_THRESHOLD_SETTINGS_KEY}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def effective_campaign_report_privacy_policy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
) -> CampaignReportPrivacyPolicy:
|
||||
env = os.environ if environ is None else environ
|
||||
deployment_raw = env.get(SMALL_CELL_THRESHOLD_ENV)
|
||||
deployment_value = _configured_threshold(
|
||||
deployment_raw,
|
||||
source=SMALL_CELL_THRESHOLD_ENV,
|
||||
default=DEFAULT_SMALL_CELL_THRESHOLD,
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
tenant_raw = _tenant_threshold_value(tenant.settings if tenant is not None else None)
|
||||
if tenant_raw is None:
|
||||
return CampaignReportPrivacyPolicy(
|
||||
small_cell_threshold=deployment_value,
|
||||
source="deployment" if deployment_raw not in (None, "") else "deployment_default",
|
||||
deployment_small_cell_threshold=deployment_value,
|
||||
)
|
||||
|
||||
tenant_value = _configured_threshold(
|
||||
tenant_raw,
|
||||
source=(
|
||||
f"tenant.settings.{CAMPAIGN_REPORT_POLICY_SETTINGS_KEY}."
|
||||
f"{SMALL_CELL_THRESHOLD_SETTINGS_KEY}"
|
||||
),
|
||||
)
|
||||
effective_value = max(deployment_value, tenant_value)
|
||||
return CampaignReportPrivacyPolicy(
|
||||
small_cell_threshold=effective_value,
|
||||
source="tenant" if tenant_value >= deployment_value else "deployment_floor",
|
||||
deployment_small_cell_threshold=deployment_value,
|
||||
tenant_small_cell_threshold=tenant_value,
|
||||
)
|
||||
|
||||
|
||||
def _tenant_threshold_value(settings: Mapping[str, Any] | None) -> object | None:
|
||||
if not isinstance(settings, Mapping):
|
||||
return None
|
||||
policy = settings.get(CAMPAIGN_REPORT_POLICY_SETTINGS_KEY)
|
||||
if not isinstance(policy, Mapping):
|
||||
return None
|
||||
return policy.get(SMALL_CELL_THRESHOLD_SETTINGS_KEY)
|
||||
|
||||
|
||||
def _configured_threshold(value: object, *, source: str, default: int | None = None) -> int:
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
if default is not None:
|
||||
return default
|
||||
raise CampaignReportPrivacyPolicyError(f"{source} must be configured as an integer")
|
||||
if isinstance(value, bool):
|
||||
raise CampaignReportPrivacyPolicyError(f"{source} must be an integer, not a boolean")
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CampaignReportPrivacyPolicyError(f"{source} must be an integer") from exc
|
||||
if str(parsed) != str(value).strip() and not isinstance(value, int):
|
||||
raise CampaignReportPrivacyPolicyError(f"{source} must be an integer")
|
||||
if parsed < MIN_SMALL_CELL_THRESHOLD or parsed > MAX_SMALL_CELL_THRESHOLD:
|
||||
raise CampaignReportPrivacyPolicyError(
|
||||
f"{source} must be between {MIN_SMALL_CELL_THRESHOLD} and {MAX_SMALL_CELL_THRESHOLD}"
|
||||
)
|
||||
return parsed
|
||||
490
src/govoplan_campaign/backend/reports/aggregate.py
Normal file
490
src/govoplan_campaign/backend/reports/aggregate.py
Normal file
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import case, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.report_privacy_policy import (
|
||||
CampaignReportPrivacyPolicy,
|
||||
effective_campaign_report_privacy_policy,
|
||||
)
|
||||
|
||||
|
||||
class AggregateCampaignReportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class AggregateReportCampaign(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
id: str
|
||||
name: str
|
||||
status: str
|
||||
|
||||
|
||||
class AggregateReportCampaignListItem(AggregateReportCampaign):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class AggregateReportCampaignList(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaigns: list[AggregateReportCampaignListItem]
|
||||
|
||||
|
||||
class AggregateCount(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
value: int | None
|
||||
suppressed: bool = False
|
||||
|
||||
|
||||
class AggregatePopulation(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
denominator: AggregateCount
|
||||
denominator_definition: str
|
||||
inactive_source_entries: AggregateCount
|
||||
excluded_or_blocked_jobs: AggregateCount
|
||||
|
||||
|
||||
class AggregateOutcomeCounts(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
smtp_accepted: AggregateCount
|
||||
failed: AggregateCount
|
||||
outcome_unknown: AggregateCount
|
||||
queued_or_active: AggregateCount
|
||||
cancelled: AggregateCount
|
||||
excluded: AggregateCount
|
||||
not_attempted: AggregateCount
|
||||
|
||||
|
||||
class AggregateTimeRange(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
first_activity_at: datetime | None
|
||||
last_activity_at: datetime | None
|
||||
suppressed: bool
|
||||
|
||||
|
||||
class AggregatePrivacy(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
small_cell_threshold: int
|
||||
suppression_applied: bool
|
||||
rule: str
|
||||
|
||||
|
||||
class AggregateCampaignReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
generated_at: datetime
|
||||
campaign: AggregateReportCampaign
|
||||
version_number: int | None
|
||||
completion_state: Literal[
|
||||
"not_started",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"incomplete",
|
||||
"outcome_unknown",
|
||||
"suppressed",
|
||||
]
|
||||
population: AggregatePopulation
|
||||
outcomes: AggregateOutcomeCounts
|
||||
time_range: AggregateTimeRange
|
||||
privacy: AggregatePrivacy
|
||||
|
||||
|
||||
_OUTCOME_KEYS = (
|
||||
"smtp_accepted",
|
||||
"failed",
|
||||
"outcome_unknown",
|
||||
"queued_or_active",
|
||||
"cancelled",
|
||||
"excluded",
|
||||
"not_attempted",
|
||||
)
|
||||
|
||||
|
||||
def generate_aggregate_campaign_report(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
) -> AggregateCampaignReport:
|
||||
"""Build the deliberately small, recipient-free Campaign report projection."""
|
||||
|
||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
version = _selected_version(session, campaign, version_id)
|
||||
facts = _query_aggregate_facts(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
)
|
||||
policy = effective_campaign_report_privacy_policy(session, tenant_id=tenant_id)
|
||||
return _build_aggregate_campaign_report(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
facts=facts,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign:
|
||||
campaign = (
|
||||
session.query(Campaign)
|
||||
.filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if campaign is None:
|
||||
raise AggregateCampaignReportError("Campaign not found")
|
||||
return campaign
|
||||
|
||||
|
||||
def _selected_version(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
version_id: str | None,
|
||||
) -> CampaignVersion | None:
|
||||
selected_id = version_id or campaign.current_version_id
|
||||
if not selected_id:
|
||||
return None
|
||||
version = session.get(CampaignVersion, selected_id)
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise AggregateCampaignReportError("Campaign version not found")
|
||||
return version
|
||||
|
||||
|
||||
def _query_aggregate_facts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion | None,
|
||||
) -> _AggregateFacts:
|
||||
if version is None:
|
||||
return _AggregateFacts.empty()
|
||||
|
||||
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"})
|
||||
failed = CampaignJob.send_status.in_({"failed_temporary", "failed_permanent"})
|
||||
unknown = CampaignJob.send_status == "outcome_unknown"
|
||||
active = CampaignJob.send_status.in_({"queued", "claimed", "sending"})
|
||||
cancelled = CampaignJob.send_status == "cancelled"
|
||||
excluded = CampaignJob.send_status == "skipped"
|
||||
excluded_or_blocked = or_(
|
||||
CampaignJob.validation_status.in_({"blocked", "excluded", "inactive"}),
|
||||
CampaignJob.build_status != "built",
|
||||
)
|
||||
row = (
|
||||
session.query(
|
||||
func.count(CampaignJob.id).label("denominator"),
|
||||
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"),
|
||||
func.sum(case((failed, 1), else_=0)).label("failed"),
|
||||
func.sum(case((unknown, 1), else_=0)).label("outcome_unknown"),
|
||||
func.sum(case((active, 1), else_=0)).label("queued_or_active"),
|
||||
func.sum(case((cancelled, 1), else_=0)).label("cancelled"),
|
||||
func.sum(case((excluded, 1), else_=0)).label("excluded"),
|
||||
func.sum(
|
||||
case((or_(accepted, failed, unknown, active, cancelled, excluded), 0), else_=1)
|
||||
).label("not_attempted"),
|
||||
func.sum(case((excluded_or_blocked, 1), else_=0)).label("excluded_or_blocked"),
|
||||
func.min(CampaignJob.queued_at).label("queued_min"),
|
||||
func.max(CampaignJob.queued_at).label("queued_max"),
|
||||
func.min(CampaignJob.smtp_started_at).label("smtp_min"),
|
||||
func.max(CampaignJob.smtp_started_at).label("smtp_max"),
|
||||
func.min(CampaignJob.sent_at).label("sent_min"),
|
||||
func.max(CampaignJob.sent_at).label("sent_max"),
|
||||
func.min(CampaignJob.outcome_unknown_at).label("unknown_min"),
|
||||
func.max(CampaignJob.outcome_unknown_at).label("unknown_max"),
|
||||
)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
values = row._mapping
|
||||
activity = [
|
||||
values[key]
|
||||
for key in (
|
||||
"queued_min",
|
||||
"queued_max",
|
||||
"smtp_min",
|
||||
"smtp_max",
|
||||
"sent_min",
|
||||
"sent_max",
|
||||
"unknown_min",
|
||||
"unknown_max",
|
||||
)
|
||||
if values[key] is not None
|
||||
]
|
||||
return _AggregateFacts(
|
||||
outcomes={key: int(values[key] or 0) for key in _OUTCOME_KEYS},
|
||||
denominator=int(values["denominator"] or 0),
|
||||
excluded_or_blocked=int(values["excluded_or_blocked"] or 0),
|
||||
first_activity_at=min(activity) if activity else None,
|
||||
last_activity_at=max(activity) if activity else None,
|
||||
)
|
||||
|
||||
|
||||
def build_aggregate_campaign_report(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion | None,
|
||||
jobs: list[CampaignJob],
|
||||
policy: CampaignReportPrivacyPolicy,
|
||||
generated_at: datetime | None = None,
|
||||
) -> AggregateCampaignReport:
|
||||
return _build_aggregate_campaign_report(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
facts=_facts_from_jobs(jobs),
|
||||
policy=policy,
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
|
||||
def _build_aggregate_campaign_report(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion | None,
|
||||
facts: _AggregateFacts,
|
||||
policy: CampaignReportPrivacyPolicy,
|
||||
generated_at: datetime | None = None,
|
||||
) -> AggregateCampaignReport:
|
||||
outcome_values = facts.outcomes
|
||||
outcomes, denominator = _suppress_partition(
|
||||
outcome_values,
|
||||
threshold=policy.small_cell_threshold,
|
||||
)
|
||||
outcome_suppression_applied = denominator.suppressed or any(
|
||||
item.suppressed for item in outcomes.values()
|
||||
)
|
||||
inactive_entries = _inactive_entry_count(version)
|
||||
standalone_counts = {
|
||||
"inactive_source_entries": _suppress_standalone_count(
|
||||
inactive_entries,
|
||||
threshold=policy.small_cell_threshold,
|
||||
),
|
||||
# This population count overlaps the outcome partition, so exposing it
|
||||
# can make a suppressed outcome recoverable through subtraction.
|
||||
"excluded_or_blocked_jobs": (
|
||||
AggregateCount(value=None, suppressed=True)
|
||||
if outcome_suppression_applied
|
||||
else _suppress_standalone_count(
|
||||
facts.excluded_or_blocked,
|
||||
threshold=policy.small_cell_threshold,
|
||||
)
|
||||
),
|
||||
}
|
||||
suppression_applied = denominator.suppressed or any(
|
||||
item.suppressed for item in (*outcomes.values(), *standalone_counts.values())
|
||||
)
|
||||
first_activity = facts.first_activity_at
|
||||
last_activity = facts.last_activity_at
|
||||
suppress_time_range = suppression_applied or (
|
||||
0 < facts.denominator < policy.small_cell_threshold
|
||||
)
|
||||
|
||||
return AggregateCampaignReport(
|
||||
generated_at=generated_at or datetime.now(timezone.utc),
|
||||
campaign=AggregateReportCampaign(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
status=campaign.status,
|
||||
),
|
||||
version_number=version.version_number if version else None,
|
||||
completion_state=(
|
||||
"suppressed"
|
||||
if denominator.suppressed
|
||||
else _completion_state(outcome_values, facts.denominator)
|
||||
),
|
||||
population=AggregatePopulation(
|
||||
denominator=denominator,
|
||||
denominator_definition=(
|
||||
"All persisted recipient delivery jobs for the selected campaign version, "
|
||||
"including excluded or blocked jobs. Inactive source entries without a job "
|
||||
"record are excluded and reported separately."
|
||||
),
|
||||
inactive_source_entries=standalone_counts["inactive_source_entries"],
|
||||
excluded_or_blocked_jobs=standalone_counts["excluded_or_blocked_jobs"],
|
||||
),
|
||||
outcomes=AggregateOutcomeCounts(**outcomes),
|
||||
time_range=AggregateTimeRange(
|
||||
first_activity_at=None if suppress_time_range else first_activity,
|
||||
last_activity_at=None if suppress_time_range else last_activity,
|
||||
suppressed=suppress_time_range and first_activity is not None,
|
||||
),
|
||||
privacy=AggregatePrivacy(
|
||||
small_cell_threshold=policy.small_cell_threshold,
|
||||
suppression_applied=suppression_applied,
|
||||
rule=(
|
||||
"Positive counts below the threshold are hidden. At least one additional "
|
||||
"count or the denominator is hidden when needed to prevent subtraction. "
|
||||
"Overlapping population counts are hidden whenever outcome suppression applies."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def aggregate_report_campaign_item(campaign: Campaign) -> AggregateReportCampaignListItem:
|
||||
return AggregateReportCampaignListItem(
|
||||
id=campaign.id,
|
||||
name=campaign.name,
|
||||
status=campaign.status,
|
||||
updated_at=campaign.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
|
||||
counts: Counter[str] = Counter()
|
||||
for job in jobs:
|
||||
status = job.send_status
|
||||
if status in {"smtp_accepted", "sent"}:
|
||||
counts["smtp_accepted"] += 1
|
||||
elif status in {"failed_temporary", "failed_permanent"}:
|
||||
counts["failed"] += 1
|
||||
elif status == "outcome_unknown":
|
||||
counts["outcome_unknown"] += 1
|
||||
elif status in {"queued", "claimed", "sending"}:
|
||||
counts["queued_or_active"] += 1
|
||||
elif status == "cancelled":
|
||||
counts["cancelled"] += 1
|
||||
elif status == "skipped":
|
||||
counts["excluded"] += 1
|
||||
else:
|
||||
counts["not_attempted"] += 1
|
||||
return {key: counts[key] for key in _OUTCOME_KEYS}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AggregateFacts:
|
||||
outcomes: dict[str, int]
|
||||
denominator: int
|
||||
excluded_or_blocked: int
|
||||
first_activity_at: datetime | None
|
||||
last_activity_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def empty(cls) -> _AggregateFacts:
|
||||
return cls(
|
||||
outcomes={key: 0 for key in _OUTCOME_KEYS},
|
||||
denominator=0,
|
||||
excluded_or_blocked=0,
|
||||
first_activity_at=None,
|
||||
last_activity_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _facts_from_jobs(jobs: list[CampaignJob]) -> _AggregateFacts:
|
||||
first_activity, last_activity = _activity_range(jobs)
|
||||
return _AggregateFacts(
|
||||
outcomes=_outcome_counts(jobs),
|
||||
denominator=len(jobs),
|
||||
excluded_or_blocked=sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.validation_status in {"blocked", "excluded", "inactive"}
|
||||
or job.build_status != "built"
|
||||
),
|
||||
first_activity_at=first_activity,
|
||||
last_activity_at=last_activity,
|
||||
)
|
||||
|
||||
|
||||
def _suppress_partition(
|
||||
counts: dict[str, int],
|
||||
*,
|
||||
threshold: int,
|
||||
) -> tuple[dict[str, AggregateCount], AggregateCount]:
|
||||
total = sum(counts.values())
|
||||
suppressed = {key for key, value in counts.items() if 0 < value < threshold}
|
||||
suppress_denominator = False
|
||||
if suppressed:
|
||||
companions = [
|
||||
(value, key)
|
||||
for key, value in counts.items()
|
||||
if key not in suppressed and value > 0
|
||||
]
|
||||
if companions:
|
||||
suppressed.add(max(companions)[1])
|
||||
else:
|
||||
suppress_denominator = True
|
||||
denominator = AggregateCount(
|
||||
value=None if suppress_denominator else total,
|
||||
suppressed=suppress_denominator,
|
||||
)
|
||||
return (
|
||||
{
|
||||
key: AggregateCount(
|
||||
value=None if key in suppressed else value,
|
||||
suppressed=key in suppressed,
|
||||
)
|
||||
for key, value in counts.items()
|
||||
},
|
||||
denominator,
|
||||
)
|
||||
|
||||
|
||||
def _suppress_standalone_count(value: int, *, threshold: int) -> AggregateCount:
|
||||
if 0 < value < threshold:
|
||||
return AggregateCount(value=None, suppressed=True)
|
||||
return AggregateCount(value=value, suppressed=False)
|
||||
|
||||
|
||||
def _completion_state(
|
||||
counts: dict[str, int],
|
||||
total: int,
|
||||
) -> Literal[
|
||||
"not_started",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"incomplete",
|
||||
"outcome_unknown",
|
||||
]:
|
||||
if total == 0 or counts["not_attempted"] + counts["excluded"] == total:
|
||||
return "not_started"
|
||||
if counts["outcome_unknown"]:
|
||||
return "outcome_unknown"
|
||||
if counts["queued_or_active"]:
|
||||
return "in_progress"
|
||||
accepted = counts["smtp_accepted"]
|
||||
if accepted == total:
|
||||
return "completed"
|
||||
if accepted:
|
||||
return "partially_completed"
|
||||
return "incomplete"
|
||||
|
||||
|
||||
def _activity_range(jobs: list[CampaignJob]) -> tuple[datetime | None, datetime | None]:
|
||||
activity = [
|
||||
value
|
||||
for job in jobs
|
||||
for value in (
|
||||
job.queued_at,
|
||||
job.smtp_started_at,
|
||||
job.sent_at,
|
||||
job.outcome_unknown_at,
|
||||
)
|
||||
if value is not None
|
||||
]
|
||||
if not activity:
|
||||
return None, None
|
||||
return min(activity), max(activity)
|
||||
|
||||
|
||||
def _inactive_entry_count(version: CampaignVersion | None) -> int:
|
||||
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
|
||||
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
|
||||
@@ -87,6 +87,10 @@ def _version_info(
|
||||
),
|
||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||
"delivery_mode": version.delivery_mode,
|
||||
"delivery_mode_selected_at": (
|
||||
version.delivery_mode_selected_at.isoformat() if version.delivery_mode_selected_at else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +114,12 @@ def _load_delivery_info(
|
||||
"queueable_job_count": 0,
|
||||
"estimated_remaining_send_seconds": None,
|
||||
"estimated_remaining_send_human": None,
|
||||
"delivery_mode": getattr(version, "delivery_mode", None) if version else None,
|
||||
"delivery_mode_selected_at": (
|
||||
getattr(version, "delivery_mode_selected_at", None).isoformat()
|
||||
if version and getattr(version, "delivery_mode_selected_at", None)
|
||||
else None
|
||||
),
|
||||
}
|
||||
if include_diagnostics:
|
||||
default.update(
|
||||
@@ -159,6 +169,10 @@ def _load_delivery_info(
|
||||
"queueable_job_count": snapshot.queueable_job_count,
|
||||
"estimated_remaining_send_seconds": estimated_seconds,
|
||||
"estimated_remaining_send_human": _human_duration(estimated_seconds),
|
||||
"delivery_mode": version.delivery_mode,
|
||||
"delivery_mode_selected_at": (
|
||||
version.delivery_mode_selected_at.isoformat() if version.delivery_mode_selected_at else None
|
||||
),
|
||||
}
|
||||
if include_diagnostics:
|
||||
result.update(
|
||||
@@ -544,6 +558,27 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
|
||||
send_counts = _counter([job.send_status for job in jobs])
|
||||
imap_counts = _counter([job.imap_status for job in jobs])
|
||||
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
|
||||
queueable_unattempted = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.attempt_count == 0
|
||||
and job.send_status in {"not_queued", "cancelled"}
|
||||
and job.validation_status in {"ready", "warning"}
|
||||
and job.build_status == "built"
|
||||
)
|
||||
retry_max_attempts = _retry_max_attempts(version)
|
||||
retryable = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.send_status == "failed_temporary"
|
||||
and (retry_max_attempts is None or job.attempt_count < retry_max_attempts)
|
||||
)
|
||||
cancellable = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.send_status
|
||||
not in {"skipped", "smtp_accepted", "sent", "outcome_unknown", "claimed", "sending", "cancelled"}
|
||||
)
|
||||
needs_attention = sum(
|
||||
1
|
||||
for job in jobs
|
||||
@@ -555,6 +590,7 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
|
||||
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
|
||||
outcome_unknown = send_counts.get("outcome_unknown", 0)
|
||||
not_attempted = send_counts.get("not_queued", 0)
|
||||
skipped = send_counts.get("skipped", 0)
|
||||
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
|
||||
cancelled = send_counts.get("cancelled", 0)
|
||||
inactive_entries = _inactive_entry_count(version)
|
||||
@@ -562,20 +598,34 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
|
||||
"jobs_total": len(jobs),
|
||||
"inactive": inactive_entries,
|
||||
"queueable": queueable,
|
||||
"queueable_unattempted": queueable_unattempted,
|
||||
"retryable": retryable,
|
||||
"cancellable": cancellable,
|
||||
"needs_attention": needs_attention,
|
||||
"sent": sent,
|
||||
"smtp_accepted": sent,
|
||||
"failed": failed,
|
||||
"outcome_unknown": outcome_unknown,
|
||||
"not_attempted": not_attempted,
|
||||
"skipped": skipped,
|
||||
"queued_or_active": queued,
|
||||
"cancelled": cancelled,
|
||||
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
|
||||
"imap_appended": imap_counts.get("appended", 0),
|
||||
"imap_failed": imap_counts.get("failed", 0),
|
||||
"imap_skipped": imap_counts.get("skipped", 0),
|
||||
}
|
||||
|
||||
|
||||
def _retry_max_attempts(version: CampaignVersion | None) -> int | None:
|
||||
if version is None or not isinstance(version.execution_snapshot, dict):
|
||||
return None
|
||||
try:
|
||||
return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _inactive_entry_count(version: CampaignVersion | None) -> int:
|
||||
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
|
||||
return int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
|
||||
|
||||
@@ -88,8 +88,10 @@ def _text_summary(report: dict[str, Any]) -> str:
|
||||
f"- Needs attention: {cards['needs_attention']}",
|
||||
f"- Sent: {cards['sent']}",
|
||||
f"- Failed: {cards['failed']}",
|
||||
f"- SMTP skipped (excluded): {cards.get('skipped', status.get('send', {}).get('skipped', 0))}",
|
||||
f"- IMAP appended: {cards['imap_appended']}",
|
||||
f"- IMAP failed: {cards['imap_failed']}",
|
||||
f"- IMAP skipped: {cards.get('imap_skipped', status.get('imap', {}).get('skipped', 0))}",
|
||||
"",
|
||||
f"Build status: {status.get('build', {})}",
|
||||
f"Validation status: {status.get('validation', {})}",
|
||||
|
||||
@@ -40,6 +40,51 @@ CAMPAIGN_DIAGNOSTIC_RESPONSE_KEYS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
_SEND_NOW_RESULT_KEYS = (
|
||||
"campaign_id",
|
||||
"version_id",
|
||||
"attempted_count",
|
||||
"sent_count",
|
||||
"failed_count",
|
||||
"outcome_unknown_count",
|
||||
"skipped_count",
|
||||
"preflight_count",
|
||||
"delivery_mode",
|
||||
"dry_run",
|
||||
)
|
||||
_SEND_NOW_JOB_RESULT_KEYS = (
|
||||
"campaign_id",
|
||||
"version_id",
|
||||
"job_id",
|
||||
"status",
|
||||
"attempt_number",
|
||||
"dry_run",
|
||||
"queued_count",
|
||||
"skipped_count",
|
||||
"blocked_count",
|
||||
"enqueued_count",
|
||||
"delivery_mode",
|
||||
"worker_queue_available",
|
||||
)
|
||||
_SYNCHRONOUS_POLICY_KEYS = (
|
||||
"max_recipient_jobs",
|
||||
"source",
|
||||
"deployment_max_recipient_jobs",
|
||||
"tenant_max_recipient_jobs",
|
||||
)
|
||||
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
|
||||
_BUILD_SUMMARY_KEYS = (
|
||||
"built_count",
|
||||
"build_failed_count",
|
||||
"ready_count",
|
||||
"warning_count",
|
||||
"needs_review_count",
|
||||
"blocked_count",
|
||||
"excluded_count",
|
||||
"inactive_count",
|
||||
"queueable_count",
|
||||
)
|
||||
|
||||
|
||||
def public_campaign_payload(value: Any, *, include_diagnostics: bool = False) -> Any:
|
||||
"""Return a detached payload without infrastructure-only locators."""
|
||||
@@ -83,6 +128,59 @@ def public_delivery_result_message(
|
||||
return "Delivery recorded a warning; an operator can inspect restricted diagnostics."
|
||||
|
||||
|
||||
def public_send_campaign_now_result(
|
||||
value: dict[str, Any],
|
||||
*,
|
||||
validation_summary: dict[str, Any],
|
||||
build_summary: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Project synchronous delivery into its recipient-authorized public contract.
|
||||
|
||||
Per-job provider messages are deliberately omitted. They can contain SMTP
|
||||
diagnostics or refused envelope addresses and belong only in restricted
|
||||
diagnostics backed by persisted job state.
|
||||
"""
|
||||
|
||||
result = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
|
||||
policy = value.get("synchronous_send_policy")
|
||||
result["synchronous_send_policy"] = _selected_payload(
|
||||
policy if isinstance(policy, dict) else {},
|
||||
_SYNCHRONOUS_POLICY_KEYS,
|
||||
)
|
||||
rows = value.get("results")
|
||||
if isinstance(rows, list):
|
||||
result["results"] = [
|
||||
_selected_payload(row, _SEND_NOW_JOB_RESULT_KEYS)
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
else:
|
||||
result["results"] = []
|
||||
result["validation"] = _selected_payload(validation_summary, _VALIDATION_SUMMARY_KEYS)
|
||||
result["build"] = _selected_payload(build_summary, _BUILD_SUMMARY_KEYS)
|
||||
return result
|
||||
|
||||
|
||||
def send_campaign_now_audit_details(value: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return aggregate-only evidence for a synchronous Campaign send audit."""
|
||||
|
||||
details = _selected_payload(value, _SEND_NOW_RESULT_KEYS)
|
||||
policy = value.get("synchronous_send_policy")
|
||||
details["synchronous_send_policy"] = _selected_payload(
|
||||
policy if isinstance(policy, dict) else {},
|
||||
_SYNCHRONOUS_POLICY_KEYS,
|
||||
)
|
||||
return details
|
||||
|
||||
|
||||
def _selected_payload(value: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
return {
|
||||
key: copy.deepcopy(value[key])
|
||||
for key in keys
|
||||
if key in value
|
||||
}
|
||||
|
||||
|
||||
def public_campaign_configuration(value: Any) -> Any:
|
||||
"""Return campaign JSON without infrastructure locators or mail secrets.
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import and_, exists, func, or_
|
||||
@@ -44,6 +45,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignDeliveryOptionsResponse,
|
||||
CampaignListResponse,
|
||||
CampaignResponse,
|
||||
CampaignVersionDetailResponse,
|
||||
@@ -65,7 +67,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||
from govoplan_core.core.change_sequence import (
|
||||
@@ -98,14 +100,25 @@ from govoplan_campaign.backend.db.models import (
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
RecipientImportMappingProfile,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_campaign.backend.report_privacy_policy import CampaignReportPrivacyPolicyError
|
||||
from govoplan_campaign.backend.reports.aggregate import (
|
||||
AggregateCampaignReport,
|
||||
AggregateCampaignReportError,
|
||||
AggregateReportCampaignList,
|
||||
aggregate_report_campaign_item,
|
||||
generate_aggregate_campaign_report,
|
||||
)
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
public_delivery_result_message,
|
||||
public_send_campaign_now_result,
|
||||
send_campaign_now_audit_details,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
@@ -148,6 +161,7 @@ from govoplan_campaign.backend.dev.mock_campaign import MockCampaignSendError, r
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, clear_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
SynchronousSendRejected,
|
||||
cancel_campaign_jobs,
|
||||
enqueue_pending_imap_appends,
|
||||
pause_campaign_jobs,
|
||||
@@ -158,6 +172,7 @@ from govoplan_campaign.backend.sending.jobs import (
|
||||
resume_campaign_jobs,
|
||||
send_campaign_now,
|
||||
send_single_campaign_job,
|
||||
synchronous_send_options,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
@@ -957,6 +972,52 @@ def delete_recipient_import_mapping_profile(
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.get("/aggregate-reports", response_model=AggregateReportCampaignList)
|
||||
def list_aggregate_campaign_reports(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
"""List only the business metadata needed to select an aggregate report."""
|
||||
|
||||
campaigns = (
|
||||
_campaign_query_for_principal(session, principal)
|
||||
.order_by(Campaign.updated_at.desc())
|
||||
.all()
|
||||
)
|
||||
return AggregateReportCampaignList(
|
||||
campaigns=[aggregate_report_campaign_item(campaign) for campaign in campaigns]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/aggregate-reports/{campaign_id}", response_model=AggregateCampaignReport)
|
||||
def aggregate_campaign_report(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
"""Return the privacy-safe aggregate projection without recipient detail."""
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
try:
|
||||
return generate_aggregate_campaign_report(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
except AggregateCampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign report not found",
|
||||
) from exc
|
||||
except CampaignReportPrivacyPolicyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Campaign report privacy policy is invalid",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/address-lookup", response_model=CampaignAddressLookupResponse)
|
||||
def lookup_campaign_addresses(
|
||||
campaign_id: str,
|
||||
@@ -1572,14 +1633,14 @@ def fork_version_for_edit(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
"""Create the campaign's next and only editable working version.
|
||||
|
||||
A new working copy may be created only after the current version is
|
||||
permanently user-locked or delivery-final. Validation and temporary user
|
||||
locks must be removed in place instead of creating parallel drafts.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or CampaignVersionUpdateRequest()
|
||||
source_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
@@ -1654,12 +1715,12 @@ def unlock_version_validation(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
"""Unlock a successfully validated version before delivery starts.
|
||||
|
||||
Unlocking invalidates validation/build state and removes generated jobs for
|
||||
that version. Sent/final versions cannot be unlocked and must be copied.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
@@ -2249,6 +2310,125 @@ def _status_counts(session: Session, filters: list[object]) -> dict[str, dict[st
|
||||
return result
|
||||
|
||||
|
||||
CAMPAIGN_JOB_GRID_SORT_COLUMNS = {
|
||||
"number": CampaignJob.entry_index,
|
||||
"recipient": func.lower(func.coalesce(CampaignJob.recipient_email, "")),
|
||||
"subject": func.lower(func.coalesce(CampaignJob.subject, "")),
|
||||
"validation": CampaignJob.validation_status,
|
||||
"queue": CampaignJob.queue_status,
|
||||
"send": CampaignJob.send_status,
|
||||
"imap": CampaignJob.imap_status,
|
||||
"attempts": CampaignJob.attempt_count,
|
||||
"updated": CampaignJob.updated_at,
|
||||
}
|
||||
CAMPAIGN_JOB_GRID_LIST_FILTERS = {
|
||||
"validation": (CampaignJob.validation_status, {item.value for item in JobValidationStatus}),
|
||||
"queue": (CampaignJob.queue_status, {item.value for item in JobQueueStatus}),
|
||||
"send": (CampaignJob.send_status, {item.value for item in JobSendStatus}),
|
||||
"imap": (CampaignJob.imap_status, {item.value for item in JobImapStatus}),
|
||||
}
|
||||
|
||||
|
||||
def _campaign_jobs_grid_filter_expressions(grid_filters: dict[str, str] | None) -> list[object]:
|
||||
values = grid_filters or {}
|
||||
expressions: list[object] = []
|
||||
recipient = values.get("recipient", "").strip()
|
||||
if recipient:
|
||||
pattern = _contains_pattern(recipient)
|
||||
expressions.append(or_(
|
||||
CampaignJob.recipient_email.ilike(pattern, escape="\\"),
|
||||
CampaignJob.entry_id.ilike(pattern, escape="\\"),
|
||||
))
|
||||
subject = values.get("subject", "").strip()
|
||||
if subject:
|
||||
expressions.append(CampaignJob.subject.ilike(_contains_pattern(subject), escape="\\"))
|
||||
evidence = values.get("evidence", "").strip()
|
||||
if evidence:
|
||||
pattern = _contains_pattern(evidence)
|
||||
expressions.append(or_(
|
||||
CampaignJob.message_id_header.ilike(pattern, escape="\\"),
|
||||
CampaignJob.eml_sha256.ilike(pattern, escape="\\"),
|
||||
))
|
||||
attempts = values.get("attempts", "").strip()
|
||||
if attempts:
|
||||
expressions.append(_campaign_jobs_integer_filter(CampaignJob.attempt_count, attempts, column_id="attempts"))
|
||||
for column_id, (column, allowed_values) in CAMPAIGN_JOB_GRID_LIST_FILTERS.items():
|
||||
raw_value = values.get(column_id, "").strip()
|
||||
if not raw_value:
|
||||
continue
|
||||
selected = _campaign_jobs_list_filter(raw_value, column_id=column_id, allowed_values=allowed_values)
|
||||
expressions.append(column.in_(selected))
|
||||
return expressions
|
||||
|
||||
|
||||
def _campaign_jobs_list_filter(raw_value: str, *, column_id: str, allowed_values: set[str]) -> list[str]:
|
||||
if raw_value.startswith("list:"):
|
||||
try:
|
||||
parsed = json.loads(raw_value[5:])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid {column_id} list filter",
|
||||
) from exc
|
||||
if not isinstance(parsed, list) or any(not isinstance(value, str) for value in parsed):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid {column_id} list filter",
|
||||
)
|
||||
selected = list(dict.fromkeys(value.strip() for value in parsed if value.strip()))
|
||||
else:
|
||||
selected = list(dict.fromkeys(value.strip() for value in raw_value.split(",") if value.strip()))
|
||||
if len(selected) > 50 or any(value not in allowed_values for value in selected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid {column_id} list filter",
|
||||
)
|
||||
return selected
|
||||
|
||||
|
||||
def _campaign_jobs_integer_filter(column: object, raw_value: str, *, column_id: str):
|
||||
operator, separator, value = raw_value.partition(":")
|
||||
if not separator:
|
||||
operator, value = "eq", operator
|
||||
if operator not in {"eq", "gt", "gte", "lt", "lte"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid {column_id} filter operator",
|
||||
)
|
||||
try:
|
||||
expected = int(value)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid {column_id} filter value",
|
||||
) from exc
|
||||
if operator == "gt":
|
||||
return column > expected
|
||||
if operator == "gte":
|
||||
return column >= expected
|
||||
if operator == "lt":
|
||||
return column < expected
|
||||
if operator == "lte":
|
||||
return column <= expected
|
||||
return column == expected
|
||||
|
||||
|
||||
def _contains_pattern(value: str) -> str:
|
||||
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
return f"%{escaped}%"
|
||||
|
||||
|
||||
def _campaign_jobs_ordering(sort_by: str, sort_direction: str) -> list[object]:
|
||||
column = CAMPAIGN_JOB_GRID_SORT_COLUMNS.get(sort_by)
|
||||
if column is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Unsupported Campaign job sort column",
|
||||
)
|
||||
primary = column.desc() if sort_direction == "desc" else column.asc()
|
||||
return [primary, CampaignJob.id.asc()]
|
||||
|
||||
|
||||
def _campaign_jobs_query_context(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
@@ -2259,6 +2439,7 @@ def _campaign_jobs_query_context(
|
||||
validation_status: list[str] | None,
|
||||
imap_status: list[str] | None,
|
||||
query_text: str | None,
|
||||
grid_filters: dict[str, str] | None = None,
|
||||
) -> tuple[Campaign, list[object], list[object], dict[str, object], set[str]]:
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
@@ -2287,6 +2468,7 @@ def _campaign_jobs_query_context(
|
||||
CampaignJob.subject.ilike(pattern),
|
||||
CampaignJob.entry_id.ilike(pattern),
|
||||
))
|
||||
filtered.extend(_campaign_jobs_grid_filter_expressions(grid_filters))
|
||||
return campaign, base_filters, filtered, review_metadata, reviewed_keys
|
||||
|
||||
|
||||
@@ -2305,6 +2487,9 @@ def _campaign_jobs_page_response(
|
||||
validation_status: list[str] | None = None,
|
||||
imap_status: list[str] | None = None,
|
||||
query_text: str | None = None,
|
||||
grid_filters: dict[str, str] | None = None,
|
||||
sort_by: str = "number",
|
||||
sort_direction: str = "asc",
|
||||
cursor: str | None = None,
|
||||
changed_job_ids: set[str] | None = None,
|
||||
) -> CampaignJobsResponse:
|
||||
@@ -2319,11 +2504,20 @@ def _campaign_jobs_page_response(
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
)
|
||||
ordered_query = session.query(CampaignJob).filter(*filtered).order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
ordering = _campaign_jobs_ordering(sort_by, sort_direction)
|
||||
ordered_query = session.query(CampaignJob).filter(*filtered).order_by(*ordering)
|
||||
start_cursor: str | None = None
|
||||
effective_offset = 0
|
||||
if cursor:
|
||||
if sort_by != "number" or sort_direction != "asc":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Campaign job cursors require number ascending order",
|
||||
)
|
||||
try:
|
||||
cursor_values = decode_keyset_cursor(CAMPAIGN_JOBS_CURSOR_SCOPE, cursor, fingerprint=fingerprint)
|
||||
if cursor_values is None:
|
||||
@@ -2335,14 +2529,14 @@ def _campaign_jobs_page_response(
|
||||
else:
|
||||
effective_offset = (page - 1) * page_size
|
||||
page_query = session.query(CampaignJob).filter(*filtered)
|
||||
if effective_offset > 0:
|
||||
if effective_offset > 0 and sort_by == "number" and sort_direction == "asc":
|
||||
previous_row = ordered_query.offset(effective_offset - 1).limit(1).first()
|
||||
if previous_row is not None:
|
||||
start_cursor = _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint)
|
||||
|
||||
rows_plus_one = (
|
||||
page_query
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.order_by(*ordering)
|
||||
.offset(effective_offset)
|
||||
.limit(page_size + 1)
|
||||
.all()
|
||||
@@ -2350,7 +2544,12 @@ def _campaign_jobs_page_response(
|
||||
jobs = rows_plus_one[:page_size]
|
||||
next_cursor = (
|
||||
_campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint)
|
||||
if changed_job_ids is None and len(rows_plus_one) > page_size and jobs else None
|
||||
if changed_job_ids is None
|
||||
and sort_by == "number"
|
||||
and sort_direction == "asc"
|
||||
and len(rows_plus_one) > page_size
|
||||
and jobs
|
||||
else None
|
||||
)
|
||||
if changed_job_ids is not None:
|
||||
jobs = [job for job in jobs if job.id in changed_job_ids]
|
||||
@@ -2378,6 +2577,9 @@ def _campaign_jobs_cursor_fingerprint(
|
||||
validation_status: list[str] | None,
|
||||
imap_status: list[str] | None,
|
||||
query_text: str | None,
|
||||
grid_filters: dict[str, str] | None,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
) -> str:
|
||||
return keyset_query_fingerprint(
|
||||
CAMPAIGN_JOBS_CURSOR_SCOPE,
|
||||
@@ -2389,7 +2591,8 @@ def _campaign_jobs_cursor_fingerprint(
|
||||
"validation_status": sorted(validation_status or []),
|
||||
"imap_status": sorted(imap_status or []),
|
||||
"query": (query_text or "").strip(),
|
||||
"order": ["entry_index:asc", "id:asc"],
|
||||
"grid_filters": sorted((grid_filters or {}).items()),
|
||||
"order": [f"{sort_by}:{sort_direction}", "id:asc"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2436,6 +2639,16 @@ class CampaignJobsQuery:
|
||||
validation_status: list[str] | None = Query(default=None),
|
||||
imap_status: list[str] | None = Query(default=None),
|
||||
query_text: str | None = Query(default=None, alias="q", max_length=200),
|
||||
sort_by: Literal["number", "recipient", "subject", "validation", "queue", "send", "imap", "attempts", "updated"] = Query(default="number"),
|
||||
sort_direction: Literal["asc", "desc"] = Query(default="asc"),
|
||||
filter_recipient: str | None = Query(default=None, max_length=500),
|
||||
filter_subject: str | None = Query(default=None, max_length=1000),
|
||||
filter_validation: str | None = Query(default=None, max_length=1000),
|
||||
filter_queue: str | None = Query(default=None, max_length=1000),
|
||||
filter_send: str | None = Query(default=None, max_length=1000),
|
||||
filter_imap: str | None = Query(default=None, max_length=1000),
|
||||
filter_attempts: str | None = Query(default=None, max_length=100),
|
||||
filter_evidence: str | None = Query(default=None, max_length=500),
|
||||
) -> None:
|
||||
self.version_id = version_id
|
||||
self.page = page
|
||||
@@ -2445,6 +2658,22 @@ class CampaignJobsQuery:
|
||||
self.validation_status = validation_status
|
||||
self.imap_status = imap_status
|
||||
self.query_text = query_text
|
||||
self.sort_by = sort_by
|
||||
self.sort_direction = sort_direction
|
||||
self.grid_filters = {
|
||||
column_id: value
|
||||
for column_id, value in {
|
||||
"recipient": filter_recipient,
|
||||
"subject": filter_subject,
|
||||
"validation": filter_validation,
|
||||
"queue": filter_queue,
|
||||
"send": filter_send,
|
||||
"imap": filter_imap,
|
||||
"attempts": filter_attempts,
|
||||
"evidence": filter_evidence,
|
||||
}.items()
|
||||
if value is not None and value.strip()
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
||||
@@ -2469,6 +2698,7 @@ def list_jobs(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
)
|
||||
return _campaign_jobs_page_response(
|
||||
session,
|
||||
@@ -2484,6 +2714,9 @@ def list_jobs(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
@@ -2500,6 +2733,9 @@ def _campaign_jobs_full_delta_response(
|
||||
validation_status: list[str] | None,
|
||||
imap_status: list[str] | None,
|
||||
query_text: str | None,
|
||||
grid_filters: dict[str, str] | None,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
cursor: str | None = None,
|
||||
) -> CampaignJobsDeltaResponse:
|
||||
_campaign, base_filters, filtered, review_metadata, reviewed_keys = _campaign_jobs_query_context(
|
||||
@@ -2511,6 +2747,7 @@ def _campaign_jobs_full_delta_response(
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
)
|
||||
payload = _campaign_jobs_page_response(
|
||||
session,
|
||||
@@ -2526,6 +2763,9 @@ def _campaign_jobs_full_delta_response(
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
cursor=cursor,
|
||||
)
|
||||
return CampaignJobsDeltaResponse(
|
||||
@@ -2543,8 +2783,19 @@ def _job_filter_membership_can_shift(
|
||||
validation_status: list[str] | None,
|
||||
imap_status: list[str] | None,
|
||||
query_text: str | None,
|
||||
grid_filters: dict[str, str] | None,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
) -> bool:
|
||||
return bool(send_status or validation_status or imap_status or (query_text and query_text.strip()))
|
||||
return bool(
|
||||
send_status
|
||||
or validation_status
|
||||
or imap_status
|
||||
or (query_text and query_text.strip())
|
||||
or grid_filters
|
||||
or sort_by != "number"
|
||||
or sort_direction != "asc"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse)
|
||||
@@ -2568,6 +2819,9 @@ def list_jobs_delta(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
@@ -2580,6 +2834,7 @@ def list_jobs_delta(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
)
|
||||
try:
|
||||
since_sequence = decode_sequence_watermark(since)
|
||||
@@ -2603,6 +2858,9 @@ def list_jobs_delta(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
@@ -2629,6 +2887,9 @@ def list_jobs_delta(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
)
|
||||
or any(entry.operation in {"created", "deleted"} for entry in relevant_entries)
|
||||
):
|
||||
@@ -2643,6 +2904,9 @@ def list_jobs_delta(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
)
|
||||
|
||||
@@ -2665,6 +2929,9 @@ def list_jobs_delta(
|
||||
validation_status=filters.validation_status,
|
||||
imap_status=filters.imap_status,
|
||||
query_text=filters.query_text,
|
||||
grid_filters=filters.grid_filters,
|
||||
sort_by=filters.sort_by,
|
||||
sort_direction=filters.sort_direction,
|
||||
cursor=filters.cursor,
|
||||
changed_job_ids=changed_job_ids,
|
||||
)
|
||||
@@ -2726,6 +2993,7 @@ def get_job_diagnostics(
|
||||
"""Return infrastructure details only to campaign operators/admins."""
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if not job or job.campaign_id != campaign.id or job.tenant_id != principal.tenant_id:
|
||||
@@ -2753,10 +3021,10 @@ def campaign_summary(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
"""Return dashboard-friendly campaign status counters and summaries."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
if include_jobs:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
"""Return dashboard-friendly campaign status counters and summaries."""
|
||||
|
||||
try:
|
||||
return generate_campaign_report(
|
||||
@@ -2780,10 +3048,9 @@ def campaign_report(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
"""Return the recipient-level JSON report for one campaign."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
if include_jobs:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
"""Return the full JSON report for one campaign."""
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
try:
|
||||
return generate_campaign_report(
|
||||
@@ -2806,9 +3073,9 @@ def campaign_jobs_csv(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:export")),
|
||||
):
|
||||
"""Export per-job campaign status as CSV."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
"""Export per-job campaign status as CSV."""
|
||||
|
||||
try:
|
||||
csv_text = generate_jobs_csv(
|
||||
@@ -2836,7 +3103,9 @@ def email_campaign_report(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:send")),
|
||||
):
|
||||
"""Generate a campaign report and send it to one or more email addresses."""
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
selected_version_id = payload.version_id or campaign.current_version_id
|
||||
selected_version = session.get(CampaignVersion, selected_version_id) if selected_version_id else None
|
||||
if selected_version is not None and selected_version.campaign_id == campaign.id:
|
||||
@@ -2844,10 +3113,6 @@ def email_campaign_report(
|
||||
principal,
|
||||
selected_version.raw_json if isinstance(selected_version.raw_json, dict) else {},
|
||||
)
|
||||
if payload.include_jobs or payload.attach_jobs_csv:
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
"""Generate a campaign report and send it to one or more email addresses."""
|
||||
|
||||
try:
|
||||
result = send_campaign_report_email(
|
||||
session,
|
||||
@@ -3019,6 +3284,30 @@ def revoke_campaign_share(
|
||||
# Queue / delivery control -------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/delivery-options", response_model=CampaignDeliveryOptionsResponse)
|
||||
def campaign_delivery_options(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_any_scope("campaigns:campaign:send", "campaigns:campaign:queue")
|
||||
),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
return CampaignDeliveryOptionsResponse(
|
||||
**synchronous_send_options(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse)
|
||||
def queue_campaign(
|
||||
campaign_id: str,
|
||||
@@ -3210,14 +3499,14 @@ def mock_send_campaign(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
"""Run a fully visible mock delivery flow without mutating campaign state.
|
||||
|
||||
The route validates and builds the selected version, then optionally records
|
||||
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
||||
real SMTP/IMAP servers and it does not mark the version sent/final.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or MockCampaignSendRequest()
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, payload.version_id)
|
||||
@@ -3262,13 +3551,9 @@ def send_campaign_now_endpoint(
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
"""Preflight and synchronously send a policy-bounded built execution."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
"""Validate/build/queue and synchronously send a small campaign version.
|
||||
|
||||
This endpoint is intentionally conservative and suitable for a first small
|
||||
test campaign. Larger campaigns should use the queue/Celery flow.
|
||||
"""
|
||||
|
||||
payload = payload or SendCampaignNowRequest()
|
||||
try:
|
||||
@@ -3297,7 +3582,7 @@ def send_campaign_now_endpoint(
|
||||
detail="Campaign version must be built before dry-run or sending.",
|
||||
)
|
||||
|
||||
result = send_campaign_now(
|
||||
delivery_result = send_campaign_now(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
@@ -3307,18 +3592,35 @@ def send_campaign_now_endpoint(
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
).as_dict()
|
||||
result["validation"] = validation_result
|
||||
result["build"] = build_result
|
||||
response_result = public_send_campaign_now_result(
|
||||
delivery_result,
|
||||
validation_summary=validation_result,
|
||||
build_summary=build_result,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
details=send_campaign_now_audit_details(delivery_result),
|
||||
commit=True,
|
||||
)
|
||||
return SendCampaignNowResponse(result=result)
|
||||
return SendCampaignNowResponse(result=response_result)
|
||||
except SynchronousSendRejected as exc:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.send_now_rejected",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
**exc.audit_details(),
|
||||
"version_id": payload.version_id,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except (CampaignPersistenceError, QueueingError) as exc:
|
||||
|
||||
@@ -112,6 +112,8 @@ class CampaignVersionResponse(BaseModel):
|
||||
build_summary: dict[str, Any] | None = None
|
||||
execution_snapshot_hash: str | None = None
|
||||
execution_snapshot_at: datetime | None = None
|
||||
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None
|
||||
delivery_mode_selected_at: datetime | None = None
|
||||
|
||||
@field_validator("editor_state", mode="before")
|
||||
@classmethod
|
||||
@@ -492,6 +494,8 @@ class QueueCampaignResponse(BaseModel):
|
||||
skipped_count: int
|
||||
blocked_count: int
|
||||
enqueued_count: int
|
||||
delivery_mode: str = "worker_queue"
|
||||
worker_queue_available: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
@@ -512,6 +516,13 @@ class SendCampaignNowResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class CampaignDeliveryOptionsResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
worker_queue_available: bool
|
||||
synchronous_send: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MockCampaignSendRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -28,7 +28,17 @@ from govoplan_campaign.backend.db.models import (
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
SynchronousSendPolicy,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import (
|
||||
ExecutionSnapshot,
|
||||
ExecutionSnapshotError,
|
||||
ensure_execution_snapshot,
|
||||
profile_delivery_summary,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
ImapAppendError,
|
||||
@@ -49,6 +59,29 @@ class SendJobError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SynchronousSendRejected(QueueingError):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
reason: str,
|
||||
eligible_count: int | None = None,
|
||||
policy: SynchronousSendPolicy | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
self.eligible_count = eligible_count
|
||||
self.policy = policy
|
||||
|
||||
def audit_details(self) -> dict[str, Any]:
|
||||
return {
|
||||
"delivery_mode": "synchronous",
|
||||
"rejection_reason": self.reason,
|
||||
"eligible_recipient_job_count": self.eligible_count,
|
||||
"synchronous_send_policy": self.policy.as_dict() if self.policy is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueueCampaignResult:
|
||||
campaign_id: str
|
||||
@@ -57,6 +90,8 @@ class QueueCampaignResult:
|
||||
skipped_count: int
|
||||
blocked_count: int
|
||||
enqueued_count: int
|
||||
delivery_mode: str = "worker_queue"
|
||||
worker_queue_available: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
@@ -67,6 +102,8 @@ class QueueCampaignResult:
|
||||
"skipped_count": self.skipped_count,
|
||||
"blocked_count": self.blocked_count,
|
||||
"enqueued_count": self.enqueued_count,
|
||||
"delivery_mode": self.delivery_mode,
|
||||
"worker_queue_available": self.worker_queue_available,
|
||||
"dry_run": self.dry_run,
|
||||
}
|
||||
|
||||
@@ -80,6 +117,8 @@ class SendCampaignNowResult:
|
||||
failed_count: int
|
||||
outcome_unknown_count: int
|
||||
skipped_count: int
|
||||
preflight_count: int = 0
|
||||
synchronous_send_policy: dict[str, Any] | None = None
|
||||
dry_run: bool = False
|
||||
results: list[dict[str, Any]] | None = None
|
||||
|
||||
@@ -92,6 +131,9 @@ class SendCampaignNowResult:
|
||||
"failed_count": self.failed_count,
|
||||
"outcome_unknown_count": self.outcome_unknown_count,
|
||||
"skipped_count": self.skipped_count,
|
||||
"preflight_count": self.preflight_count,
|
||||
"delivery_mode": "synchronous",
|
||||
"synchronous_send_policy": self.synchronous_send_policy or {},
|
||||
"dry_run": self.dry_run,
|
||||
"results": self.results or [],
|
||||
}
|
||||
@@ -159,9 +201,18 @@ QUEUEABLE_VALIDATION_STATUSES = {
|
||||
JobValidationStatus.WARNING.value,
|
||||
}
|
||||
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
|
||||
DELIVERY_MODE_SYNCHRONOUS = "synchronous"
|
||||
DELIVERY_MODE_WORKER_QUEUE = "worker_queue"
|
||||
DELIVERY_MODE_DATABASE_QUEUE = "database_queue"
|
||||
DELIVERY_MODES = {
|
||||
DELIVERY_MODE_SYNCHRONOUS,
|
||||
DELIVERY_MODE_WORKER_QUEUE,
|
||||
DELIVERY_MODE_DATABASE_QUEUE,
|
||||
}
|
||||
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
|
||||
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
|
||||
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
|
||||
JobSendStatus.SKIPPED.value,
|
||||
JobSendStatus.CLAIMED.value,
|
||||
JobSendStatus.SENDING.value,
|
||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
@@ -231,6 +282,13 @@ def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _set_version_delivery_mode(version: CampaignVersion, mode: str) -> None:
|
||||
if mode not in DELIVERY_MODES:
|
||||
raise QueueingError(f"Unsupported Campaign delivery mode: {mode}")
|
||||
version.delivery_mode = mode
|
||||
version.delivery_mode_selected_at = _utcnow()
|
||||
|
||||
|
||||
def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign:
|
||||
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
|
||||
if not campaign:
|
||||
@@ -269,6 +327,10 @@ def _should_enqueue_celery(enqueue_celery: bool) -> bool:
|
||||
return bool(enqueue_celery and _celery_enabled())
|
||||
|
||||
|
||||
def _asynchronous_delivery_mode(enqueue_celery: bool) -> str:
|
||||
return DELIVERY_MODE_WORKER_QUEUE if _should_enqueue_celery(enqueue_celery) else DELIVERY_MODE_DATABASE_QUEUE
|
||||
|
||||
|
||||
def _campaign_notification_body(campaign: Campaign, status: str) -> str:
|
||||
return {
|
||||
CampaignStatus.QUEUED.value: f"{campaign.name} has been queued for delivery.",
|
||||
@@ -444,17 +506,128 @@ def _select_campaign_jobs_for_queue(
|
||||
return queued, skipped_count, blocked_count
|
||||
|
||||
|
||||
def synchronous_send_candidate_jobs(
|
||||
version: CampaignVersion,
|
||||
jobs: list[CampaignJob],
|
||||
*,
|
||||
include_warnings: bool = True,
|
||||
) -> list[CampaignJob]:
|
||||
"""Return the exact persisted job set an immediate send may attempt.
|
||||
|
||||
This projection is deliberately side-effect free so the delivery-options
|
||||
API and adaptive documentation can explain the current built execution
|
||||
without changing queue state. The final command repeats the projection
|
||||
after queueing and before any SMTP effect.
|
||||
"""
|
||||
|
||||
allowed_validation = _queue_validation_statuses(include_warnings=include_warnings)
|
||||
reviewed_needs_review_keys = _reviewed_needs_review_keys(version)
|
||||
candidates: list[CampaignJob] = []
|
||||
for job in jobs:
|
||||
if (
|
||||
job.queue_status == JobQueueStatus.QUEUED.value
|
||||
and job.send_status == JobSendStatus.QUEUED.value
|
||||
):
|
||||
candidates.append(job)
|
||||
continue
|
||||
if job.send_status in INITIAL_QUEUE_SKIPPED_SEND_STATUSES:
|
||||
continue
|
||||
if job.queue_status in INITIAL_QUEUE_SKIPPED_QUEUE_STATUSES:
|
||||
continue
|
||||
validation_allowed = job.validation_status in allowed_validation or (
|
||||
job.validation_status == JobValidationStatus.NEEDS_REVIEW.value
|
||||
and _job_review_key(job) in reviewed_needs_review_keys
|
||||
)
|
||||
if job.build_status != JobBuildStatus.BUILT.value or not validation_allowed:
|
||||
continue
|
||||
if not job.eml_local_path and not job.eml_storage_key:
|
||||
continue
|
||||
candidates.append(job)
|
||||
return candidates
|
||||
|
||||
|
||||
def synchronous_send_options(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_warnings: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Describe the actor-independent effective delivery-mode constraints."""
|
||||
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||
jobs = _campaign_jobs_for_version(session, tenant_id=tenant_id, version_id=version.id)
|
||||
candidates = synchronous_send_candidate_jobs(version, jobs, include_warnings=include_warnings)
|
||||
try:
|
||||
policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"worker_queue_available": bool(_celery_enabled()),
|
||||
"synchronous_send": {
|
||||
"allowed": False,
|
||||
"reason": "policy_configuration_invalid",
|
||||
"message": str(exc),
|
||||
"eligible_recipient_job_count": len(candidates),
|
||||
"policy": {},
|
||||
},
|
||||
}
|
||||
ready = _version_is_validated_and_locked(version) and bool(version.build_summary)
|
||||
eligible_count = len(candidates)
|
||||
if not ready:
|
||||
reason = "version_not_ready"
|
||||
elif eligible_count == 0:
|
||||
reason = "no_eligible_recipient_jobs"
|
||||
elif eligible_count > policy.max_recipient_jobs:
|
||||
reason = "recipient_limit_exceeded"
|
||||
else:
|
||||
reason = None
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"worker_queue_available": bool(_celery_enabled()),
|
||||
"synchronous_send": {
|
||||
"allowed": reason is None,
|
||||
"reason": reason,
|
||||
"eligible_recipient_job_count": eligible_count,
|
||||
"policy": policy.as_dict(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _campaign_jobs_for_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version_id: str,
|
||||
) -> list[CampaignJob]:
|
||||
return (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _persist_campaign_queue(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
queued: list[CampaignJob],
|
||||
delivery_mode: str,
|
||||
) -> None:
|
||||
if queued:
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(version, delivery_mode)
|
||||
if version.locked_at is None:
|
||||
version.locked_at = _utcnow()
|
||||
session.add(version)
|
||||
@@ -487,9 +660,13 @@ def queue_campaign_jobs(
|
||||
enqueue_celery: bool = True,
|
||||
include_warnings: bool = True,
|
||||
dry_run: bool = False,
|
||||
delivery_mode: str | None = None,
|
||||
) -> QueueCampaignResult:
|
||||
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
|
||||
|
||||
selected_delivery_mode = delivery_mode or _asynchronous_delivery_mode(enqueue_celery)
|
||||
if selected_delivery_mode not in DELIVERY_MODES:
|
||||
raise QueueingError(f"Unsupported Campaign delivery mode: {selected_delivery_mode}")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_current_version(session, campaign, version_id=version_id)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
@@ -516,6 +693,7 @@ def queue_campaign_jobs(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
queued=queued,
|
||||
delivery_mode=selected_delivery_mode,
|
||||
)
|
||||
enqueued_count = _enqueue_campaign_jobs(
|
||||
queued,
|
||||
@@ -529,6 +707,8 @@ def queue_campaign_jobs(
|
||||
skipped_count=skipped_count,
|
||||
blocked_count=blocked_count,
|
||||
enqueued_count=enqueued_count,
|
||||
delivery_mode=selected_delivery_mode,
|
||||
worker_queue_available=_celery_enabled(),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
@@ -544,14 +724,38 @@ def send_campaign_now(
|
||||
use_rate_limit: bool = True,
|
||||
enqueue_imap_task: bool = False,
|
||||
) -> SendCampaignNowResult:
|
||||
"""Queue and send a small campaign synchronously.
|
||||
"""Queue and send all eligible jobs for one campaign synchronously.
|
||||
|
||||
This is intended for WebUI test/small-campaign flows. Large campaigns can
|
||||
still use queue_campaign_jobs with Celery workers.
|
||||
The effective deployment/tenant limit is checked against the immutable,
|
||||
persisted job set before queue state changes. After queueing, every message
|
||||
and transport revision is preflighted before the first SMTP effect starts.
|
||||
"""
|
||||
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_current_version(session, campaign, version_id=version_id)
|
||||
_ensure_version_validated_and_locked(version)
|
||||
_ensure_campaign_execution_snapshot(session, version)
|
||||
try:
|
||||
synchronous_policy = effective_synchronous_send_policy(session, tenant_id=tenant_id)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
raise SynchronousSendRejected(
|
||||
f"Synchronous Campaign delivery is disabled by an invalid policy configuration: {exc}",
|
||||
reason="policy_configuration_invalid",
|
||||
) from exc
|
||||
initial_jobs = _campaign_jobs_for_queue(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
version_id=version.id,
|
||||
)
|
||||
initial_candidates = synchronous_send_candidate_jobs(
|
||||
version,
|
||||
initial_jobs,
|
||||
include_warnings=include_warnings,
|
||||
)
|
||||
_ensure_synchronous_send_count_allowed(
|
||||
len(initial_candidates),
|
||||
policy=synchronous_policy,
|
||||
)
|
||||
|
||||
queue_result = queue_campaign_jobs(
|
||||
session,
|
||||
@@ -561,6 +765,7 @@ def send_campaign_now(
|
||||
include_warnings=include_warnings,
|
||||
enqueue_celery=False,
|
||||
dry_run=dry_run,
|
||||
delivery_mode=DELIVERY_MODE_SYNCHRONOUS,
|
||||
)
|
||||
if dry_run:
|
||||
return SendCampaignNowResult(
|
||||
@@ -571,20 +776,31 @@ def send_campaign_now(
|
||||
failed_count=0,
|
||||
outcome_unknown_count=0,
|
||||
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
|
||||
preflight_count=len(initial_candidates),
|
||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||
dry_run=True,
|
||||
results=[queue_result.as_dict()],
|
||||
)
|
||||
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
|
||||
jobs = [
|
||||
job
|
||||
for job in _campaign_jobs_for_version(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
version_id=version.id,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
if job.queue_status == JobQueueStatus.QUEUED.value
|
||||
and job.send_status == JobSendStatus.QUEUED.value
|
||||
]
|
||||
# Repeat the hard bound against the post-queue set. This closes the window
|
||||
# where a concurrent queue operation could otherwise enlarge an immediate
|
||||
# run between the initial decision and the first provider effect.
|
||||
_ensure_synchronous_send_count_allowed(len(jobs), policy=synchronous_policy)
|
||||
delivery_contexts = _preflight_synchronous_send_batch(
|
||||
session,
|
||||
version=version,
|
||||
jobs=jobs,
|
||||
policy=synchronous_policy,
|
||||
)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
@@ -594,13 +810,19 @@ def send_campaign_now(
|
||||
skipped_after_queue = 0
|
||||
for job in jobs:
|
||||
try:
|
||||
result = send_campaign_job(
|
||||
session,
|
||||
job_id=job.id,
|
||||
dry_run=False,
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
claimed = _claimed_campaign_job_for_delivery(session, job)
|
||||
if isinstance(claimed, SendJobResult):
|
||||
result = claimed
|
||||
else:
|
||||
claimed_job, claim_token = claimed
|
||||
result = _send_claimed_campaign_job(
|
||||
session,
|
||||
job=claimed_job,
|
||||
claim_token=claim_token,
|
||||
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 {JobSendStatus.SMTP_ACCEPTED.value, "already_accepted"}:
|
||||
@@ -621,11 +843,75 @@ def send_campaign_now(
|
||||
failed_count=failed_count,
|
||||
outcome_unknown_count=outcome_unknown_count,
|
||||
skipped_count=queue_result.skipped_count + queue_result.blocked_count + skipped_after_queue,
|
||||
preflight_count=len(delivery_contexts),
|
||||
synchronous_send_policy=synchronous_policy.as_dict(),
|
||||
dry_run=False,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_synchronous_send_count_allowed(
|
||||
eligible_count: int,
|
||||
*,
|
||||
policy: SynchronousSendPolicy,
|
||||
) -> None:
|
||||
if eligible_count <= 0:
|
||||
raise SynchronousSendRejected(
|
||||
"No eligible built recipient messages are available for synchronous delivery.",
|
||||
reason="no_eligible_recipient_jobs",
|
||||
eligible_count=eligible_count,
|
||||
policy=policy,
|
||||
)
|
||||
if eligible_count > policy.max_recipient_jobs:
|
||||
raise SynchronousSendRejected(
|
||||
(
|
||||
f"This built run contains {eligible_count} eligible recipient jobs, above the effective "
|
||||
f"synchronous limit of {policy.max_recipient_jobs}. Queue it for background workers instead."
|
||||
),
|
||||
reason="recipient_limit_exceeded",
|
||||
eligible_count=eligible_count,
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
|
||||
def _preflight_synchronous_send_batch(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
jobs: list[CampaignJob],
|
||||
policy: SynchronousSendPolicy,
|
||||
) -> dict[str, _SendJobDeliveryContext]:
|
||||
"""Freeze and validate every local input before the first SMTP effect."""
|
||||
|
||||
contexts: dict[str, _SendJobDeliveryContext] = {}
|
||||
try:
|
||||
for job in jobs:
|
||||
state_result = _preflight_send_campaign_job(session, job, dry_run=True)
|
||||
if state_result is not None:
|
||||
raise SendJobError(
|
||||
f"Job {job.id} changed to {state_result.status} during synchronous preflight"
|
||||
)
|
||||
contexts[job.id] = _send_job_delivery_context(session, job)
|
||||
profile_summary = profile_delivery_summary(session, version)
|
||||
expected_revision = next(iter(contexts.values())).snapshot.smtp_transport_revision
|
||||
if profile_summary.get("smtp_transport_revision") != expected_revision:
|
||||
raise SendJobError(
|
||||
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
|
||||
)
|
||||
except (ExecutionSnapshotError, MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
|
||||
raise SynchronousSendRejected(
|
||||
(
|
||||
"Synchronous preflight stopped before contacting SMTP because one or more frozen message "
|
||||
"inputs or the selected Mail profile no longer match the reviewed build. Rebuild and review "
|
||||
"the version before trying again."
|
||||
),
|
||||
reason="batch_preflight_failed",
|
||||
eligible_count=len(jobs),
|
||||
policy=policy,
|
||||
) from exc
|
||||
return contexts
|
||||
|
||||
|
||||
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
|
||||
if not _celery_enabled():
|
||||
return 0
|
||||
@@ -681,6 +967,12 @@ def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str,
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
session.add(job)
|
||||
if jobs:
|
||||
delivery_mode = _asynchronous_delivery_mode(enqueue_celery)
|
||||
for version_id in {job.campaign_version_id for job in jobs}:
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if version is not None and version.campaign_id == campaign.id:
|
||||
_set_version_delivery_mode(version, delivery_mode)
|
||||
session.add(version)
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
session.add(campaign)
|
||||
@@ -710,9 +1002,13 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
|
||||
)
|
||||
cancelled_count = 0
|
||||
protected_count = 0
|
||||
skipped_count = 0
|
||||
version_ids: set[str] = set()
|
||||
for job in jobs:
|
||||
version_ids.add(job.campaign_version_id)
|
||||
if job.send_status == JobSendStatus.SKIPPED.value:
|
||||
skipped_count += 1
|
||||
continue
|
||||
if job.send_status in SMTP_ACCEPTED_STATUSES | {
|
||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
JobSendStatus.CLAIMED.value,
|
||||
@@ -733,6 +1029,7 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
|
||||
"campaign_id": campaign.id,
|
||||
"cancelled_count": cancelled_count,
|
||||
"protected_count": protected_count,
|
||||
"skipped_count": skipped_count,
|
||||
"campaign_status": campaign.status,
|
||||
}
|
||||
|
||||
@@ -807,6 +1104,10 @@ def queue_failed_jobs_for_retry(
|
||||
if selected:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(
|
||||
version,
|
||||
_asynchronous_delivery_mode(enqueue_celery),
|
||||
)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
@@ -876,6 +1177,10 @@ def queue_unattempted_jobs(
|
||||
if selected:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(
|
||||
version,
|
||||
_asynchronous_delivery_mode(enqueue_celery),
|
||||
)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
@@ -944,6 +1249,7 @@ def send_single_campaign_job(
|
||||
previous_status = campaign.status
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||
_set_version_delivery_mode(version, DELIVERY_MODE_SYNCHRONOUS)
|
||||
session.add(campaign)
|
||||
session.add(version)
|
||||
session.commit()
|
||||
|
||||
245
tests/test_aggregate_report.py
Normal file
245
tests/test_aggregate_report.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_campaign.backend.report_privacy_policy import (
|
||||
CampaignReportPrivacyPolicy,
|
||||
CampaignReportPrivacyPolicyError,
|
||||
DEFAULT_SMALL_CELL_THRESHOLD,
|
||||
effective_campaign_report_privacy_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.aggregate import build_aggregate_campaign_report
|
||||
|
||||
|
||||
class _PolicySession:
|
||||
def __init__(self, settings: dict[str, object] | None = None) -> None:
|
||||
self.tenant = SimpleNamespace(settings=settings or {})
|
||||
|
||||
def get(self, _model, _id):
|
||||
return self.tenant
|
||||
|
||||
|
||||
def _policy(threshold: int = 5) -> CampaignReportPrivacyPolicy:
|
||||
return CampaignReportPrivacyPolicy(
|
||||
small_cell_threshold=threshold,
|
||||
source="test",
|
||||
deployment_small_cell_threshold=threshold,
|
||||
)
|
||||
|
||||
|
||||
def _campaign() -> SimpleNamespace:
|
||||
now = datetime(2026, 7, 22, 8, 0, tzinfo=UTC)
|
||||
return SimpleNamespace(
|
||||
id="campaign-safe",
|
||||
tenant_id="tenant-safe",
|
||||
external_id="must-not-leak-external-id",
|
||||
name="Semester notification",
|
||||
description="Business-safe description",
|
||||
status="partially_completed",
|
||||
owner_user_id="must-not-leak-owner",
|
||||
current_version_id="must-not-leak-version-id",
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
|
||||
def _version(*, inactive_count: int = 0) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="must-not-leak-version-id",
|
||||
version_number=7,
|
||||
build_summary={"inactive_count": inactive_count, "build_token": "must-not-leak-token"},
|
||||
execution_snapshot={"smtp": {"password": "must-not-leak-secret"}},
|
||||
raw_json={"entries": [{"email": "must-not-leak@example.test"}]},
|
||||
)
|
||||
|
||||
|
||||
def _job(index: int, send_status: str, **overrides: object) -> SimpleNamespace:
|
||||
started = datetime(2026, 7, 22, 8, 0, tzinfo=UTC) + timedelta(minutes=index)
|
||||
values: dict[str, object] = {
|
||||
"id": f"job-{index}",
|
||||
"recipient_email": f"private-{index}@example.test",
|
||||
"subject": f"Personal subject {index}",
|
||||
"last_error": "smtp.internal.example provider-secret",
|
||||
"resolved_attachments": [{"storage_key": f"private/{index}"}],
|
||||
"send_status": send_status,
|
||||
"validation_status": "ready",
|
||||
"build_status": "built",
|
||||
"queued_at": started,
|
||||
"smtp_started_at": started,
|
||||
"sent_at": started if send_status in {"smtp_accepted", "sent"} else None,
|
||||
"outcome_unknown_at": started if send_status == "outcome_unknown" else None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_aggregate_projection_has_a_strict_recipient_free_shape() -> None:
|
||||
jobs = [
|
||||
*[_job(index, "smtp_accepted") for index in range(10)],
|
||||
*[_job(index + 10, "failed_permanent") for index in range(5)],
|
||||
]
|
||||
|
||||
payload = build_aggregate_campaign_report(
|
||||
campaign=_campaign(), # type: ignore[arg-type]
|
||||
version=_version(inactive_count=5), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=_policy(),
|
||||
generated_at=datetime(2026, 7, 22, 12, 0, tzinfo=UTC),
|
||||
).model_dump(mode="json")
|
||||
|
||||
assert set(payload) == {
|
||||
"generated_at",
|
||||
"campaign",
|
||||
"version_number",
|
||||
"completion_state",
|
||||
"population",
|
||||
"outcomes",
|
||||
"time_range",
|
||||
"privacy",
|
||||
}
|
||||
assert set(payload["campaign"]) == {"id", "name", "status"}
|
||||
assert payload["population"]["denominator"] == {"value": 15, "suppressed": False}
|
||||
assert payload["outcomes"]["smtp_accepted"] == {"value": 10, "suppressed": False}
|
||||
assert payload["outcomes"]["failed"] == {"value": 5, "suppressed": False}
|
||||
assert payload["completion_state"] == "partially_completed"
|
||||
serialized = repr(payload)
|
||||
for forbidden in (
|
||||
"private-0@example.test",
|
||||
"Personal subject",
|
||||
"smtp.internal.example",
|
||||
"provider-secret",
|
||||
"storage_key",
|
||||
"must-not-leak",
|
||||
"Business-safe description",
|
||||
"job-0",
|
||||
):
|
||||
assert forbidden not in serialized
|
||||
|
||||
|
||||
def test_small_cells_use_primary_and_complementary_suppression() -> None:
|
||||
jobs = [
|
||||
*[_job(index, "smtp_accepted") for index in range(8)],
|
||||
_job(8, "failed_permanent"),
|
||||
]
|
||||
|
||||
report = build_aggregate_campaign_report(
|
||||
campaign=_campaign(), # type: ignore[arg-type]
|
||||
version=_version(inactive_count=1), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=_policy(5),
|
||||
)
|
||||
|
||||
assert report.population.denominator.value == 9
|
||||
assert report.outcomes.failed.suppressed is True
|
||||
assert report.outcomes.failed.value is None
|
||||
assert report.outcomes.smtp_accepted.suppressed is True
|
||||
assert report.outcomes.smtp_accepted.value is None
|
||||
assert report.population.inactive_source_entries.suppressed is True
|
||||
assert report.time_range.suppressed is True
|
||||
assert report.privacy.suppression_applied is True
|
||||
|
||||
|
||||
def test_outcome_suppression_also_hides_overlapping_population_count() -> None:
|
||||
jobs = [
|
||||
*[
|
||||
_job(
|
||||
index,
|
||||
"smtp_accepted",
|
||||
validation_status="blocked" if index < 4 else "ready",
|
||||
)
|
||||
for index in range(8)
|
||||
],
|
||||
_job(8, "skipped", validation_status="excluded"),
|
||||
]
|
||||
|
||||
report = build_aggregate_campaign_report(
|
||||
campaign=_campaign(), # type: ignore[arg-type]
|
||||
version=_version(inactive_count=5), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=_policy(5),
|
||||
)
|
||||
|
||||
assert report.outcomes.excluded.suppressed is True
|
||||
assert report.population.excluded_or_blocked_jobs.model_dump() == {
|
||||
"value": None,
|
||||
"suppressed": True,
|
||||
}
|
||||
# Source entries without jobs are outside the outcome denominator and do
|
||||
# not overlap the suppressed partition, so their threshold-safe count stays visible.
|
||||
assert report.population.inactive_source_entries.model_dump() == {
|
||||
"value": 5,
|
||||
"suppressed": False,
|
||||
}
|
||||
|
||||
|
||||
def test_all_small_cells_also_suppress_the_denominator_and_state() -> None:
|
||||
jobs = [_job(0, "smtp_accepted"), _job(1, "failed_permanent")]
|
||||
|
||||
report = build_aggregate_campaign_report(
|
||||
campaign=_campaign(), # type: ignore[arg-type]
|
||||
version=_version(), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=_policy(5),
|
||||
)
|
||||
|
||||
assert report.population.denominator.model_dump() == {"value": None, "suppressed": True}
|
||||
assert report.completion_state == "suppressed"
|
||||
assert report.outcomes.smtp_accepted.value is None
|
||||
assert report.outcomes.failed.value is None
|
||||
|
||||
|
||||
def test_explicitly_skipped_jobs_are_exclusions_not_unattempted_outcomes() -> None:
|
||||
jobs = [_job(index, "skipped") for index in range(5)]
|
||||
|
||||
report = build_aggregate_campaign_report(
|
||||
campaign=_campaign(), # type: ignore[arg-type]
|
||||
version=_version(), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=_policy(5),
|
||||
)
|
||||
|
||||
assert report.outcomes.excluded.value == 5
|
||||
assert report.outcomes.not_attempted.value == 0
|
||||
assert report.completion_state == "not_started"
|
||||
|
||||
|
||||
def test_report_privacy_policy_defaults_to_five_and_tenant_can_only_strengthen() -> None:
|
||||
default = effective_campaign_report_privacy_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={},
|
||||
)
|
||||
assert default.small_cell_threshold == DEFAULT_SMALL_CELL_THRESHOLD == 5
|
||||
assert default.source == "deployment_default"
|
||||
|
||||
strengthened = effective_campaign_report_privacy_policy(
|
||||
_PolicySession(
|
||||
{"campaign_report_privacy_policy": {"small_cell_threshold": 10}}
|
||||
), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": "5"},
|
||||
)
|
||||
assert strengthened.small_cell_threshold == 10
|
||||
assert strengthened.source == "tenant"
|
||||
|
||||
floor = effective_campaign_report_privacy_policy(
|
||||
_PolicySession(
|
||||
{"campaign_report_privacy_policy": {"small_cell_threshold": 3}}
|
||||
), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": "7"},
|
||||
)
|
||||
assert floor.small_cell_threshold == 7
|
||||
assert floor.source == "deployment_floor"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, 0, 1, 101, "2.5", "disabled"])
|
||||
def test_invalid_report_privacy_policy_fails_closed(value: object) -> None:
|
||||
with pytest.raises(CampaignReportPrivacyPolicyError):
|
||||
effective_campaign_report_privacy_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_REPORT_SMALL_CELL_THRESHOLD": value}, # type: ignore[dict-item]
|
||||
)
|
||||
199
tests/test_aggregate_report_routes.py
Normal file
199
tests/test_aggregate_report_routes.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.reports.aggregate import (
|
||||
AggregateCampaignReportError,
|
||||
generate_aggregate_campaign_report,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
|
||||
class _Principal:
|
||||
def __init__(self, *scopes: str, tenant_id: str = "tenant-1") -> None:
|
||||
self.scopes = set(scopes)
|
||||
self.tenant_id = tenant_id
|
||||
self.user = SimpleNamespace(id="reader-1")
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes
|
||||
|
||||
|
||||
def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
principal = _Principal("campaigns:report:read")
|
||||
session = Mock()
|
||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as full_report_denied,
|
||||
):
|
||||
router.campaign_report(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
assert full_report_denied.value.status_code == 403
|
||||
assert "campaigns:recipient:read" in full_report_denied.value.detail
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as job_detail_denied,
|
||||
):
|
||||
router.get_job_detail(
|
||||
"campaign-1",
|
||||
"job-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
assert job_detail_denied.value.status_code == 403
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as report_email_denied,
|
||||
):
|
||||
router.email_campaign_report(
|
||||
"campaign-1",
|
||||
ReportEmailRequest(to=["auditor@example.test"]),
|
||||
session=session,
|
||||
principal=_Principal("campaigns:report:send"), # type: ignore[arg-type]
|
||||
)
|
||||
assert report_email_denied.value.status_code == 403
|
||||
assert "campaigns:recipient:export" in report_email_denied.value.detail
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as diagnostics_denied,
|
||||
):
|
||||
router.get_job_diagnostics(
|
||||
"campaign-1",
|
||||
"job-1",
|
||||
session=session,
|
||||
principal=_Principal("campaigns:diagnostic:read"), # type: ignore[arg-type]
|
||||
)
|
||||
assert diagnostics_denied.value.status_code == 403
|
||||
assert "campaigns:recipient:read" in diagnostics_denied.value.detail
|
||||
|
||||
|
||||
def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
||||
principal = _Principal("campaigns:report:read")
|
||||
session = Mock()
|
||||
safe_projection = Mock()
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal") as acl,
|
||||
patch.object(
|
||||
router,
|
||||
"generate_aggregate_campaign_report",
|
||||
return_value=safe_projection,
|
||||
) as generate,
|
||||
):
|
||||
result = router.aggregate_campaign_report(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result is safe_projection
|
||||
acl.assert_called_once_with(session, "campaign-1", principal)
|
||||
generate.assert_called_once_with(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
version_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
||||
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
||||
route = next(item for item in router.router.routes if item.path == path)
|
||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
||||
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
dependency.call(_Principal())
|
||||
assert denied.value.status_code == 403
|
||||
|
||||
principal = _Principal("campaigns:report:read")
|
||||
assert dependency.call(principal) is principal
|
||||
|
||||
|
||||
def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module() -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
create_scope_tables(engine)
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
with Session(engine) as session:
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
external_id="external-1",
|
||||
name="Safe aggregate",
|
||||
description=None,
|
||||
status="sent",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json={"mail": {"profile_id": "optional-module-not-loaded"}},
|
||||
schema_version="5",
|
||||
build_summary={},
|
||||
)
|
||||
campaign.current_version_id = version.id
|
||||
session.add_all([campaign, version])
|
||||
for index in range(5):
|
||||
session.add(CampaignJob(
|
||||
id=f"job-{index}",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=index,
|
||||
recipient_email=f"private-{index}@example.test",
|
||||
subject="Private",
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
queue_status="queued",
|
||||
send_status="smtp_accepted",
|
||||
imap_status="not_requested",
|
||||
))
|
||||
session.commit()
|
||||
|
||||
report = generate_aggregate_campaign_report(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
assert report.population.denominator.value == 5
|
||||
assert report.outcomes.smtp_accepted.value == 5
|
||||
|
||||
with pytest.raises(AggregateCampaignReportError):
|
||||
generate_aggregate_campaign_report(
|
||||
session,
|
||||
tenant_id="tenant-2",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
|
||||
engine.dispose()
|
||||
@@ -123,6 +123,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.build_status.value, build_status)
|
||||
self.assertEqual(message.validation_status.value, validation_status)
|
||||
if validation_status == "excluded":
|
||||
self.assertEqual(message.send_status.value, "skipped")
|
||||
self.assertEqual(message.imap_status.value, "skipped")
|
||||
coverage_issues = [issue for issue in message.issues if issue.code == "missing_attachment_coverage"]
|
||||
if issue_behavior is None:
|
||||
self.assertEqual(coverage_issues, [])
|
||||
|
||||
421
tests/test_documentation.py
Normal file
421
tests/test_documentation.py
Normal file
@@ -0,0 +1,421 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_campaign.backend.documentation import CAMPAIGN_USER_DOCUMENTATION, documentation_topics
|
||||
from govoplan_core.core.modules import DocumentationContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Principal:
|
||||
scopes: frozenset[str]
|
||||
tenant_id: str = "tenant-1"
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
namespace, resource, _action = scope.split(":", 2)
|
||||
return scope in self.scopes or f"{namespace}:{resource}:*" in self.scopes or f"{namespace}:*" in self.scopes or "*:*" in self.scopes
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, integrations: set[str], *, interface_only: set[str] | None = None, capability_only: set[str] | None = None) -> None:
|
||||
interfaces = integrations | (interface_only or set())
|
||||
self._capabilities = integrations | (capability_only or set())
|
||||
self._manifests = (
|
||||
SimpleNamespace(
|
||||
provides_interfaces=tuple(SimpleNamespace(name=name) for name in sorted(interfaces)),
|
||||
),
|
||||
)
|
||||
|
||||
def manifests(self):
|
||||
return self._manifests
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self._capabilities
|
||||
|
||||
|
||||
def _topics(scopes: set[str], integrations: set[str] | None = None, *, documentation_type: str = "user"):
|
||||
return documentation_topics(
|
||||
DocumentationContext(
|
||||
registry=_Registry(integrations or set()),
|
||||
principal=_Principal(frozenset(scopes)),
|
||||
documentation_type=documentation_type, # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_campaign_runtime_documentation_provider_is_registered() -> None:
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
|
||||
assert documentation_topics in get_manifest().documentation_providers
|
||||
|
||||
|
||||
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||
assert _topics({"docs:documentation:read"}) == ()
|
||||
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||
|
||||
|
||||
def test_runtime_documentation_reflects_actor_authority_without_exposing_scopes() -> None:
|
||||
topic = _topics(
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
}
|
||||
)[0]
|
||||
|
||||
configuration = topic.metadata["current_configuration"]
|
||||
assert "Role authorization: Create new campaigns." in configuration
|
||||
assert "Role authorization: Build exact recipient messages for review." in configuration
|
||||
assert not any("queue" in item.lower() for item in configuration)
|
||||
assert not any("campaigns:" in item or "files:" in item or "mail:" in item for item in configuration)
|
||||
|
||||
|
||||
def test_runtime_documentation_requires_both_interface_and_capability() -> None:
|
||||
integration = "mail.campaign_delivery"
|
||||
scopes = {"campaigns:campaign:read", "campaigns:campaign:update", "mail:profile:use"}
|
||||
|
||||
for registry in (
|
||||
_Registry(set(), interface_only={integration}),
|
||||
_Registry(set(), capability_only={integration}),
|
||||
):
|
||||
topic = documentation_topics(
|
||||
DocumentationContext(registry=registry, principal=_Principal(frozenset(scopes)), documentation_type="user")
|
||||
)[0]
|
||||
assert not any("profile picker" in item for item in topic.metadata["current_configuration"])
|
||||
|
||||
topic = _topics(scopes, {integration})[0]
|
||||
assert any("Mail's actor-filtered profile picker" in item for item in topic.metadata["current_configuration"])
|
||||
|
||||
|
||||
def test_mail_delivery_actions_are_not_presented_without_the_mail_contract() -> None:
|
||||
scopes = {
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
}
|
||||
|
||||
without_mail = _topics(scopes)[0]
|
||||
with_mail = _topics(scopes, {"mail.campaign_delivery"})[0]
|
||||
|
||||
assert not any("Queue an eligible" in item for item in without_mail.metadata["current_configuration"])
|
||||
assert any("Queue an eligible" in item for item in with_mail.metadata["current_configuration"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("integrations", "scopes", "expected"),
|
||||
[
|
||||
(
|
||||
{"files.campaign_attachments"},
|
||||
{"campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"},
|
||||
"Managed campaign attachments can be previewed",
|
||||
),
|
||||
(
|
||||
{"addresses.lookup"},
|
||||
{"campaigns:campaign:read", "campaigns:recipient:read"},
|
||||
"Address records can be looked up",
|
||||
),
|
||||
(
|
||||
{"addresses.recipient_source"},
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
},
|
||||
"traceable recipient snapshot",
|
||||
),
|
||||
(
|
||||
{"notifications.dispatch"},
|
||||
{"campaigns:campaign:read"},
|
||||
"status changes",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_documentation_reflects_optional_composition_permutations(
|
||||
integrations: set[str],
|
||||
scopes: set[str],
|
||||
expected: str,
|
||||
) -> None:
|
||||
topic = _topics(scopes, integrations)[0]
|
||||
assert any(expected in item for item in topic.metadata["current_configuration"])
|
||||
|
||||
|
||||
def test_runtime_documentation_full_composition_uses_only_user_facing_names() -> None:
|
||||
integrations = {
|
||||
"mail.campaign_delivery",
|
||||
"files.campaign_attachments",
|
||||
"addresses.lookup",
|
||||
"addresses.recipient_source",
|
||||
"notifications.dispatch",
|
||||
}
|
||||
topic = _topics({"*:*"}, integrations)[0]
|
||||
rendered = "\n".join(
|
||||
(
|
||||
topic.title,
|
||||
topic.summary,
|
||||
topic.body,
|
||||
*topic.metadata["current_configuration"],
|
||||
*topic.metadata["limitations"],
|
||||
)
|
||||
)
|
||||
|
||||
assert "Mail's actor-filtered profile picker" in rendered
|
||||
assert "Managed file versions" in rendered
|
||||
assert "Address records" in rendered
|
||||
assert "In-app notifications" in rendered
|
||||
assert topic.metadata["help_contexts"] == ["campaigns.list", "campaign.overview"]
|
||||
for technical_name in integrations:
|
||||
assert technical_name not in rendered
|
||||
assert "0.1." not in rendered
|
||||
assert "0.2." not in rendered
|
||||
assert "hostname" not in rendered.lower()
|
||||
assert "secret" not in rendered.lower()
|
||||
|
||||
|
||||
def test_runtime_documentation_states_the_effective_synchronous_limit() -> None:
|
||||
session = SimpleNamespace(
|
||||
get=lambda _model, _id: SimpleNamespace(
|
||||
settings={
|
||||
"campaign_delivery_policy": {
|
||||
"synchronous_send_max_recipients": 12,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
with patch.dict("os.environ", {"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "25"}):
|
||||
topic = documentation_topics(
|
||||
DocumentationContext(
|
||||
registry=_Registry({"mail.campaign_delivery"}),
|
||||
principal=_Principal(frozenset({"campaigns:campaign:read", "campaigns:campaign:send"})),
|
||||
session=session,
|
||||
documentation_type="user",
|
||||
)
|
||||
)[0]
|
||||
|
||||
assert any(
|
||||
"Send now is limited to 12 eligible recipient job(s)" in item
|
||||
for item in topic.metadata["current_configuration"]
|
||||
)
|
||||
|
||||
|
||||
def _visible_static_topics(
|
||||
scopes: set[str],
|
||||
*,
|
||||
modules: set[str] | None = None,
|
||||
capabilities: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
installed = modules or {"campaigns"}
|
||||
available_capabilities = capabilities or set()
|
||||
principal = _Principal(frozenset(scopes))
|
||||
visible: set[str] = set()
|
||||
for topic in CAMPAIGN_USER_DOCUMENTATION:
|
||||
condition = topic.conditions[0]
|
||||
if not set(condition.required_modules).issubset(installed):
|
||||
continue
|
||||
if not set(condition.required_capabilities).issubset(available_capabilities):
|
||||
continue
|
||||
if not all(principal.has(scope) for scope in condition.required_scopes):
|
||||
continue
|
||||
visible.add(topic.id)
|
||||
return visible
|
||||
|
||||
|
||||
def test_campaign_manager_sees_only_authoring_tasks_from_the_static_handbook() -> None:
|
||||
visible = _visible_static_topics(
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
"campaigns:report:read",
|
||||
}
|
||||
)
|
||||
|
||||
assert {
|
||||
"campaigns.workflow.create-campaign",
|
||||
"campaigns.workflow.create-editable-successor",
|
||||
"campaigns.workflow.import-recipients",
|
||||
"campaigns.workflow.view-delivery-report",
|
||||
}.issubset(visible)
|
||||
assert "campaigns.workflow.queue-delivery" not in visible
|
||||
assert "campaigns.workflow.send-small-controlled-run" not in visible
|
||||
assert "campaigns.workflow.export-delivery-report" not in visible
|
||||
assert "campaigns.workflow.share-campaign" not in visible
|
||||
assert "campaigns.workflow.archive-campaign" not in visible
|
||||
assert "campaigns.workflow.delete-untouched-draft" not in visible
|
||||
assert "campaigns.workflow.create-campaign" not in _visible_static_topics({"campaigns:campaign:create"})
|
||||
|
||||
|
||||
def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_authority() -> None:
|
||||
scopes = {
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
"mail:profile:use",
|
||||
}
|
||||
|
||||
without_mail = _visible_static_topics(scopes)
|
||||
with_mail = _visible_static_topics(
|
||||
scopes,
|
||||
modules={"campaigns", "mail"},
|
||||
capabilities={"mail.campaign_delivery"},
|
||||
)
|
||||
|
||||
assert "campaigns.workflow.queue-delivery" not in without_mail
|
||||
assert "campaigns.workflow.send-small-controlled-run" not in without_mail
|
||||
assert "campaigns.workflow.queue-delivery" in with_mail
|
||||
assert "campaigns.workflow.send-small-controlled-run" in with_mail
|
||||
|
||||
queue_topic = next(
|
||||
topic for topic in CAMPAIGN_USER_DOCUMENTATION
|
||||
if topic.id == "campaigns.workflow.queue-delivery"
|
||||
)
|
||||
assert any(link.href == "/operator" for link in queue_topic.links)
|
||||
|
||||
|
||||
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
|
||||
attachment_scopes = {
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:recipient:read",
|
||||
"files:file:read",
|
||||
"files:file:share",
|
||||
}
|
||||
source_scopes = {
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
}
|
||||
|
||||
files_visible = _visible_static_topics(
|
||||
attachment_scopes,
|
||||
modules={"campaigns", "files"},
|
||||
capabilities={"files.campaign_attachments"},
|
||||
)
|
||||
addresses_visible = _visible_static_topics(
|
||||
source_scopes,
|
||||
modules={"campaigns", "addresses"},
|
||||
capabilities={"addresses.recipient_source"},
|
||||
)
|
||||
|
||||
assert "campaigns.workflow.use-managed-attachments" in files_visible
|
||||
assert "campaigns.workflow.import-address-source" in addresses_visible
|
||||
assert "campaigns.workflow.use-managed-attachments" not in _visible_static_topics(attachment_scopes)
|
||||
assert "campaigns.workflow.import-address-source" not in _visible_static_topics(source_scopes)
|
||||
|
||||
|
||||
def test_report_export_and_lifecycle_tasks_are_independently_permission_gated() -> None:
|
||||
exporter = _visible_static_topics(
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:export",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:export",
|
||||
}
|
||||
)
|
||||
custodian = _visible_static_topics(
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:share",
|
||||
"campaigns:campaign:archive",
|
||||
"campaigns:campaign:delete",
|
||||
}
|
||||
)
|
||||
|
||||
assert "campaigns.workflow.export-delivery-report" in exporter
|
||||
assert "campaigns.workflow.view-delivery-report" in exporter
|
||||
assert "campaigns.workflow.share-campaign" in custodian
|
||||
assert "campaigns.workflow.archive-campaign" in custodian
|
||||
assert "campaigns.workflow.delete-untouched-draft" in custodian
|
||||
assert "campaigns.workflow.export-delivery-report" not in custodian
|
||||
|
||||
|
||||
def test_aggregate_report_task_never_implies_recipient_detail_or_export_authority() -> None:
|
||||
visible = _visible_static_topics({"campaigns:report:read"})
|
||||
|
||||
assert visible == {"campaigns.workflow.view-aggregate-delivery-report"}
|
||||
topic = next(
|
||||
item for item in CAMPAIGN_USER_DOCUMENTATION
|
||||
if item.id == "campaigns.workflow.view-aggregate-delivery-report"
|
||||
)
|
||||
assert topic.metadata["route"] == "/reports"
|
||||
assert "export" in topic.metadata["verification"].lower()
|
||||
|
||||
|
||||
def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_resend_claim() -> None:
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
|
||||
topics = get_manifest().documentation
|
||||
ids = [topic.id for topic in topics]
|
||||
rendered_static = "\n".join(
|
||||
(topic.title + "\n" + topic.summary + "\n" + topic.body).lower()
|
||||
for topic in CAMPAIGN_USER_DOCUMENTATION
|
||||
)
|
||||
known_help_contexts = {
|
||||
"campaigns.list",
|
||||
"campaign.overview",
|
||||
"campaign.settings",
|
||||
"campaign.fields",
|
||||
"campaign.template",
|
||||
"campaign.attachments",
|
||||
"campaign.recipients",
|
||||
"campaign.recipient-data",
|
||||
"campaign.server-settings",
|
||||
"campaign.global-settings",
|
||||
"campaign.review-send",
|
||||
"campaign.report",
|
||||
"campaign.audit",
|
||||
"campaign.json",
|
||||
}
|
||||
|
||||
assert len(ids) == len(set(ids))
|
||||
assert "single resend" not in rendered_static
|
||||
for topic in CAMPAIGN_USER_DOCUMENTATION:
|
||||
assert topic.metadata["kind"] == "workflow"
|
||||
assert topic.metadata["prerequisites"]
|
||||
assert topic.metadata["steps"]
|
||||
assert topic.metadata["outcome"]
|
||||
assert topic.metadata["verification"]
|
||||
assert set(topic.metadata["help_contexts"]).issubset(known_help_contexts)
|
||||
|
||||
existing_user_workflows = {
|
||||
topic.id: topic
|
||||
for topic in topics
|
||||
if topic.id
|
||||
in {
|
||||
"campaigns.mail-profile-user-journey",
|
||||
"campaigns.workflow.prepare-validate-and-build",
|
||||
"campaigns.workflow.complete-review",
|
||||
"campaigns.workflow.retry-and-reconcile",
|
||||
}
|
||||
}
|
||||
assert set(existing_user_workflows) == {
|
||||
"campaigns.mail-profile-user-journey",
|
||||
"campaigns.workflow.prepare-validate-and-build",
|
||||
"campaigns.workflow.complete-review",
|
||||
"campaigns.workflow.retry-and-reconcile",
|
||||
}
|
||||
for topic in existing_user_workflows.values():
|
||||
assert topic.metadata["help_contexts"]
|
||||
67
tests/test_excluded_delivery_status.py
Normal file
67
tests/test_excluded_delivery_status.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import BuildStatus, SendStatus
|
||||
from govoplan_campaign.backend.messages.models import ImapStatus, MessageDraft, MessageValidationStatus
|
||||
from govoplan_campaign.backend.persistence.campaigns import _job_from_message
|
||||
|
||||
|
||||
def test_excluded_message_persists_explicit_skipped_transport_states() -> None:
|
||||
message = MessageDraft(
|
||||
entry_index=0,
|
||||
entry_id="excluded-entry",
|
||||
active=True,
|
||||
build_status=BuildStatus.BUILT,
|
||||
validation_status=MessageValidationStatus.EXCLUDED,
|
||||
send_status=SendStatus.SKIPPED,
|
||||
imap_status=ImapStatus.SKIPPED,
|
||||
)
|
||||
|
||||
job = _job_from_message(
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
message=message,
|
||||
)
|
||||
|
||||
assert job.send_status == "skipped"
|
||||
assert job.imap_status == "skipped"
|
||||
|
||||
|
||||
def test_status_migration_only_normalizes_excluded_rows_without_transport_evidence() -> None:
|
||||
migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"d8b3e2c1f4a5_v0110_excluded_delivery_skipped"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(
|
||||
"CREATE TABLE campaign_jobs ("
|
||||
"id TEXT PRIMARY KEY, validation_status TEXT NOT NULL, "
|
||||
"send_status TEXT NOT NULL, imap_status TEXT NOT NULL)"
|
||||
))
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO campaign_jobs (id, validation_status, send_status, imap_status) VALUES "
|
||||
"('untouched', 'excluded', 'not_queued', 'pending'), "
|
||||
"('evidence', 'excluded', 'smtp_accepted', 'appended'), "
|
||||
"('queueable', 'ready', 'not_queued', 'pending')"
|
||||
)
|
||||
)
|
||||
with patch.object(migration.op, "get_bind", return_value=connection):
|
||||
migration.upgrade()
|
||||
|
||||
rows = {
|
||||
row.id: (row.send_status, row.imap_status)
|
||||
for row in connection.execute(
|
||||
text("SELECT id, send_status, imap_status FROM campaign_jobs ORDER BY id")
|
||||
)
|
||||
}
|
||||
|
||||
assert rows["untouched"] == ("skipped", "skipped")
|
||||
assert rows["evidence"] == ("smtp_accepted", "appended")
|
||||
assert rows["queueable"] == ("not_queued", "pending")
|
||||
149
tests/test_job_list_query.py
Normal file
149
tests/test_job_list_query.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.router import (
|
||||
_campaign_jobs_grid_filter_expressions,
|
||||
_campaign_jobs_ordering,
|
||||
_campaign_jobs_page_response,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class CampaignJobListQueryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
rows = [
|
||||
(0, "ordinary-0@example.test", "General notice", "ready", "draft", "not_queued", "not_requested", 0),
|
||||
(1, "ordinary-1@example.test", "General notice", "ready", "draft", "not_queued", "not_requested", 1),
|
||||
(2, "target-c@example.test", "Target notice C", "warning", "queued", "queued", "pending", 2),
|
||||
(3, "ordinary-3@example.test", "General notice", "blocked", "draft", "failed_permanent", "failed", 3),
|
||||
(4, "target-b@example.test", "Target notice B", "ready", "draft", "failed_temporary", "not_requested", 4),
|
||||
(5, "target-a@example.test", "Target notice A", "ready", "draft", "outcome_unknown", "outcome_unknown", 5),
|
||||
(6, "excluded@example.test", "Excluded notice", "excluded", "draft", "skipped", "skipped", 0),
|
||||
]
|
||||
for entry_index, recipient, subject, validation, queue, send, imap, attempts in rows:
|
||||
self.session.add(CampaignJob(
|
||||
id=f"job-{entry_index}",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
campaign_version_id="version-1",
|
||||
entry_index=entry_index,
|
||||
entry_id=f"entry-{entry_index}",
|
||||
recipient_email=recipient,
|
||||
subject=subject,
|
||||
message_id_header=f"<message-{entry_index}@example.test>",
|
||||
eml_sha256=f"sha-{entry_index}",
|
||||
build_status="built",
|
||||
validation_status=validation,
|
||||
queue_status=queue,
|
||||
send_status=send,
|
||||
imap_status=imap,
|
||||
attempt_count=attempts,
|
||||
resolved_attachments=[],
|
||||
issues_snapshot=[],
|
||||
))
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_grid_filters_apply_before_pagination_and_report_filtered_totals(self) -> None:
|
||||
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
|
||||
grid_filters = {"recipient": "target"}
|
||||
filtered = [*base_filters, *_campaign_jobs_grid_filter_expressions(grid_filters)]
|
||||
|
||||
page = _campaign_jobs_page_response(
|
||||
self.session,
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=set(),
|
||||
review_metadata={},
|
||||
page=1,
|
||||
page_size=2,
|
||||
grid_filters=grid_filters,
|
||||
sort_by="recipient",
|
||||
sort_direction="asc",
|
||||
)
|
||||
|
||||
self.assertEqual(page.total, 3)
|
||||
self.assertEqual(page.total_unfiltered, 7)
|
||||
self.assertEqual(page.pages, 2)
|
||||
self.assertEqual(
|
||||
[row["recipient_email"] for row in page.jobs],
|
||||
["target-a@example.test", "target-b@example.test"],
|
||||
)
|
||||
self.assertIsNone(page.next_cursor)
|
||||
|
||||
def test_list_and_integer_filters_share_the_full_backend_query(self) -> None:
|
||||
expressions = _campaign_jobs_grid_filter_expressions({
|
||||
"send": 'list:["failed_temporary","outcome_unknown"]',
|
||||
"attempts": "gte:4",
|
||||
})
|
||||
|
||||
rows = (
|
||||
self.session.query(CampaignJob)
|
||||
.filter(*expressions)
|
||||
.order_by(*_campaign_jobs_ordering("attempts", "desc"))
|
||||
.all()
|
||||
)
|
||||
|
||||
self.assertEqual([row.id for row in rows], ["job-5", "job-4"])
|
||||
|
||||
def test_invalid_list_filters_fail_closed(self) -> None:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
_campaign_jobs_grid_filter_expressions({"send": 'list:["not-a-status"]'})
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 422)
|
||||
|
||||
def test_skipped_transport_filters_and_counts_remain_separate(self) -> None:
|
||||
base_filters = [CampaignJob.tenant_id == "tenant-1", CampaignJob.campaign_id == "campaign-1"]
|
||||
grid_filters = {"send": 'list:["skipped"]', "imap": 'list:["skipped"]'}
|
||||
filtered = [*base_filters, *_campaign_jobs_grid_filter_expressions(grid_filters)]
|
||||
|
||||
page = _campaign_jobs_page_response(
|
||||
self.session,
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=set(),
|
||||
review_metadata={},
|
||||
page=1,
|
||||
page_size=20,
|
||||
grid_filters=grid_filters,
|
||||
)
|
||||
|
||||
self.assertEqual([row["id"] for row in page.jobs], ["job-6"])
|
||||
self.assertEqual(page.counts["send"]["skipped"], 1)
|
||||
self.assertEqual(page.counts["send"]["not_queued"], 2)
|
||||
self.assertEqual(page.counts["imap"]["skipped"], 1)
|
||||
self.assertEqual(page.filtered_counts["send"], {"skipped": 1})
|
||||
self.assertEqual(page.filtered_counts["imap"], {"skipped": 1})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
216
tests/test_queue_controls.py
Normal file
216
tests/test_queue_controls.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
JobBuildStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
cancel_campaign_jobs,
|
||||
pause_campaign_jobs,
|
||||
resume_campaign_jobs,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.campaigns import _campaign_report_cards, _version_info
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry, ChangeSequenceRetentionFloor
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class CampaignQueueControlTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
ChangeSequenceRetentionFloor.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignJob.__table__,
|
||||
],
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
self._add_campaign("campaign-1", "tenant-1", "version-1")
|
||||
self._add_campaign("campaign-2", "tenant-2", "version-2")
|
||||
self._add_job("queued", queue="queued", send="queued")
|
||||
self._add_job("paused", queue="paused", send="queued")
|
||||
self._add_job("failed", queue="draft", send="failed_temporary")
|
||||
self._add_job("unknown", queue="draft", send="outcome_unknown")
|
||||
self._add_job("accepted", queue="draft", send="smtp_accepted")
|
||||
self._add_job("claimed", queue="sending", send="claimed")
|
||||
self._add_job(
|
||||
"excluded",
|
||||
queue="draft",
|
||||
send="skipped",
|
||||
validation="excluded",
|
||||
imap="skipped",
|
||||
)
|
||||
self._add_job(
|
||||
"other-tenant",
|
||||
tenant_id="tenant-2",
|
||||
campaign_id="campaign-2",
|
||||
version_id="version-2",
|
||||
queue="queued",
|
||||
send="queued",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_pause_and_resume_change_only_valid_jobs_in_the_selected_tenant(self) -> None:
|
||||
with patch("govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"):
|
||||
paused = pause_campaign_jobs(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
resumed = resume_campaign_jobs(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
enqueue_celery=False,
|
||||
)
|
||||
|
||||
self.assertEqual(paused["paused_count"], 1)
|
||||
self.assertEqual(resumed, {
|
||||
"campaign_id": "campaign-1",
|
||||
"resumed_count": 2,
|
||||
"enqueued_count": 0,
|
||||
})
|
||||
self.session.expire_all()
|
||||
self.assertEqual(self.session.get(Campaign, "campaign-1").status, CampaignStatus.QUEUED.value)
|
||||
version = self.session.get(CampaignVersion, "version-1")
|
||||
self.assertEqual(version.delivery_mode, "database_queue")
|
||||
self.assertIsNotNone(version.delivery_mode_selected_at)
|
||||
self.assertEqual(self.session.get(CampaignJob, "queued").queue_status, JobQueueStatus.QUEUED.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "paused").queue_status, JobQueueStatus.QUEUED.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "other-tenant").queue_status, JobQueueStatus.QUEUED.value)
|
||||
|
||||
def test_cancel_is_auditable_idempotent_state_change_and_protects_started_effects(self) -> None:
|
||||
with patch("govoplan_campaign.backend.sending.jobs._emit_campaign_status_notification"):
|
||||
first = cancel_campaign_jobs(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
second = cancel_campaign_jobs(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
|
||||
self.assertEqual(first["cancelled_count"], 3)
|
||||
self.assertEqual(first["protected_count"], 3)
|
||||
self.assertEqual(first["skipped_count"], 1)
|
||||
self.assertEqual(second["cancelled_count"], 0)
|
||||
self.assertEqual(second["protected_count"], 3)
|
||||
self.assertEqual(second["skipped_count"], 1)
|
||||
self.session.expire_all()
|
||||
for job_id in ("queued", "paused", "failed"):
|
||||
job = self.session.get(CampaignJob, job_id)
|
||||
self.assertEqual(job.queue_status, JobQueueStatus.CANCELLED.value)
|
||||
self.assertEqual(job.send_status, JobSendStatus.CANCELLED.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "unknown").send_status, JobSendStatus.OUTCOME_UNKNOWN.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "accepted").send_status, JobSendStatus.SMTP_ACCEPTED.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "claimed").send_status, JobSendStatus.CLAIMED.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "excluded").send_status, JobSendStatus.SKIPPED.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "excluded").queue_status, JobQueueStatus.DRAFT.value)
|
||||
self.assertEqual(self.session.get(CampaignJob, "other-tenant").send_status, JobSendStatus.QUEUED.value)
|
||||
|
||||
def test_controls_fail_closed_for_a_campaign_owned_by_another_tenant(self) -> None:
|
||||
for control in (pause_campaign_jobs, resume_campaign_jobs, cancel_campaign_jobs):
|
||||
with self.subTest(control=control.__name__), self.assertRaises(QueueingError):
|
||||
control(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-2",
|
||||
)
|
||||
|
||||
def test_queue_projection_exposes_exact_action_counts_and_persisted_mode_only(self) -> None:
|
||||
version = self.session.get(CampaignVersion, "version-1")
|
||||
version.delivery_mode = "worker_queue"
|
||||
version.delivery_mode_selected_at = version.updated_at
|
||||
jobs = (
|
||||
self.session.query(CampaignJob)
|
||||
.filter(CampaignJob.tenant_id == "tenant-1")
|
||||
.all()
|
||||
)
|
||||
|
||||
cards = _campaign_report_cards(version, jobs)
|
||||
projected_version = _version_info(version)
|
||||
|
||||
self.assertEqual(cards["retryable"], 1)
|
||||
self.assertEqual(cards["queueable_unattempted"], 0)
|
||||
self.assertEqual(cards["cancellable"], 3)
|
||||
self.assertEqual(cards["skipped"], 1)
|
||||
self.assertEqual(cards["imap_skipped"], 1)
|
||||
self.assertEqual(projected_version["delivery_mode"], "worker_queue")
|
||||
self.assertIn("delivery_mode_selected_at", projected_version)
|
||||
self.assertNotIn("execution_snapshot", projected_version)
|
||||
|
||||
def _add_campaign(self, campaign_id: str, tenant_id: str, version_id: str) -> None:
|
||||
self.session.add(Campaign(
|
||||
id=campaign_id,
|
||||
tenant_id=tenant_id,
|
||||
external_id=f"external-{campaign_id}",
|
||||
name=campaign_id,
|
||||
status=CampaignStatus.QUEUED.value,
|
||||
current_version_id=version_id,
|
||||
settings={},
|
||||
mail_profile_policy={},
|
||||
))
|
||||
self.session.add(CampaignVersion(
|
||||
id=version_id,
|
||||
campaign_id=campaign_id,
|
||||
version_number=1,
|
||||
raw_json={},
|
||||
))
|
||||
|
||||
def _add_job(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
queue: str,
|
||||
send: str,
|
||||
tenant_id: str = "tenant-1",
|
||||
campaign_id: str = "campaign-1",
|
||||
version_id: str = "version-1",
|
||||
validation: str = JobValidationStatus.READY.value,
|
||||
imap: str = "not_requested",
|
||||
) -> None:
|
||||
self.session.add(CampaignJob(
|
||||
id=job_id,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
campaign_version_id=version_id,
|
||||
entry_index=len(self.session.new),
|
||||
entry_id=f"entry-{job_id}",
|
||||
build_status=JobBuildStatus.BUILT.value,
|
||||
validation_status=validation,
|
||||
queue_status=queue,
|
||||
send_status=send,
|
||||
imap_status=imap,
|
||||
resolved_attachments=[],
|
||||
issues_snapshot=[],
|
||||
))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -74,7 +74,10 @@ def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> No
|
||||
|
||||
def test_report_email_requires_permission_to_use_selected_mail_profile() -> None:
|
||||
payload = ReportEmailRequest(to=["recipient@example.test"])
|
||||
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
has=lambda scope: scope == "campaigns:recipient:export",
|
||||
)
|
||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
@@ -106,7 +109,10 @@ def test_report_email_requires_permission_to_use_selected_mail_profile() -> None
|
||||
|
||||
def test_unexpected_report_failure_does_not_leak_internal_details() -> None:
|
||||
payload = ReportEmailRequest(to=["recipient@example.test"])
|
||||
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
has=lambda scope: scope == "campaigns:recipient:export",
|
||||
)
|
||||
with (
|
||||
patch.object(
|
||||
router,
|
||||
|
||||
133
tests/test_send_now_response_security.py
Normal file
133
tests/test_send_now_response_security.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
|
||||
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
|
||||
|
||||
|
||||
def test_send_now_omits_provider_and_recipient_text_from_response_and_audit() -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
raw_json={},
|
||||
locked_at=datetime(2026, 7, 22, tzinfo=UTC),
|
||||
validation_summary={
|
||||
"ok": True,
|
||||
"error_count": 0,
|
||||
"warning_count": 1,
|
||||
"issues": [{"message": "provider-secret validation detail"}],
|
||||
},
|
||||
build_summary={
|
||||
"built_count": 2,
|
||||
"build_failed_count": 0,
|
||||
"ready_count": 2,
|
||||
"warning_count": 1,
|
||||
"messages": [{"recipient": "refused-recipient@example.test"}],
|
||||
"build_token": "provider-secret-build-token",
|
||||
},
|
||||
)
|
||||
delivery = SendCampaignNowResult(
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
attempted_count=2,
|
||||
sent_count=1,
|
||||
failed_count=1,
|
||||
outcome_unknown_count=0,
|
||||
skipped_count=0,
|
||||
preflight_count=2,
|
||||
synchronous_send_policy={
|
||||
"max_recipient_jobs": 25,
|
||||
"source": "deployment_default",
|
||||
"deployment_max_recipient_jobs": 25,
|
||||
"tenant_max_recipient_jobs": None,
|
||||
"provider_diagnostic": "provider-secret-policy",
|
||||
},
|
||||
results=[
|
||||
{
|
||||
"job_id": "job-failed",
|
||||
"status": "failed",
|
||||
"attempt_number": 1,
|
||||
"message": "provider-secret smtp.internal.example",
|
||||
"recipient_email": "private@example.test",
|
||||
},
|
||||
{
|
||||
"job_id": "job-refused",
|
||||
"status": "smtp_accepted",
|
||||
"attempt_number": 1,
|
||||
"message": (
|
||||
"SMTP accepted 1/2 envelope recipient(s); refused recipients: "
|
||||
'{"refused-recipient@example.test": [550, "not allowed"]}'
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
principal = SimpleNamespace(tenant_id="tenant-1", user=SimpleNamespace(id="user-1"), api_key=None)
|
||||
audit = Mock()
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(router, "_require_permission"),
|
||||
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
|
||||
patch.object(router, "_get_version_for_tenant", return_value=version),
|
||||
patch.object(router, "_require_mail_profile_use_if_needed"),
|
||||
patch.object(router, "is_user_locked_version", return_value=False),
|
||||
patch.object(router, "send_campaign_now", return_value=delivery),
|
||||
patch.object(router, "audit_from_principal", audit),
|
||||
):
|
||||
response = router.send_campaign_now_endpoint(
|
||||
"campaign-1",
|
||||
SendCampaignNowRequest(),
|
||||
session=Mock(),
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
payload = response.model_dump(mode="json")["result"]
|
||||
assert payload["results"] == [
|
||||
{"job_id": "job-failed", "status": "failed", "attempt_number": 1},
|
||||
{"job_id": "job-refused", "status": "smtp_accepted", "attempt_number": 1},
|
||||
]
|
||||
assert payload["validation"] == {"ok": True, "error_count": 0, "warning_count": 1}
|
||||
assert payload["build"] == {
|
||||
"built_count": 2,
|
||||
"build_failed_count": 0,
|
||||
"ready_count": 2,
|
||||
"warning_count": 1,
|
||||
}
|
||||
assert payload["synchronous_send_policy"] == {
|
||||
"max_recipient_jobs": 25,
|
||||
"source": "deployment_default",
|
||||
"deployment_max_recipient_jobs": 25,
|
||||
"tenant_max_recipient_jobs": None,
|
||||
}
|
||||
|
||||
audit_details = audit.call_args.kwargs["details"]
|
||||
assert set(audit_details) == {
|
||||
"campaign_id",
|
||||
"version_id",
|
||||
"attempted_count",
|
||||
"sent_count",
|
||||
"failed_count",
|
||||
"outcome_unknown_count",
|
||||
"skipped_count",
|
||||
"preflight_count",
|
||||
"delivery_mode",
|
||||
"dry_run",
|
||||
"synchronous_send_policy",
|
||||
}
|
||||
assert "results" not in audit_details
|
||||
assert "validation" not in audit_details
|
||||
assert "build" not in audit_details
|
||||
|
||||
serialized = repr({"response": payload, "audit": audit_details})
|
||||
for forbidden in (
|
||||
"provider-secret",
|
||||
"smtp.internal.example",
|
||||
"private@example.test",
|
||||
"refused-recipient@example.test",
|
||||
"not allowed",
|
||||
):
|
||||
assert forbidden not in serialized
|
||||
358
tests/test_synchronous_delivery_policy.py
Normal file
358
tests/test_synchronous_delivery_policy.py
Normal file
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
JobBuildStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueCampaignResult,
|
||||
SynchronousSendRejected,
|
||||
_ensure_synchronous_send_count_allowed,
|
||||
_preflight_synchronous_send_batch,
|
||||
queue_campaign_jobs,
|
||||
send_campaign_now,
|
||||
synchronous_send_candidate_jobs,
|
||||
synchronous_send_options,
|
||||
)
|
||||
|
||||
|
||||
class _PolicySession:
|
||||
def __init__(self, settings: dict[str, object] | None = None) -> None:
|
||||
self.tenant = SimpleNamespace(settings=settings or {})
|
||||
|
||||
def get(self, _model, _id):
|
||||
return self.tenant
|
||||
|
||||
|
||||
def _version() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
id="version-1",
|
||||
locked_at=object(),
|
||||
published_at=None,
|
||||
validation_summary={"ok": True},
|
||||
build_summary={"build_token": "build-1"},
|
||||
editor_state={
|
||||
"review_send": {
|
||||
"build_token": "build-1",
|
||||
"inspection_complete": True,
|
||||
"reviewed_message_keys": ["reviewed"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _Principal:
|
||||
def __init__(self, *scopes: str) -> None:
|
||||
self.scopes = set(scopes)
|
||||
self.tenant_id = "tenant-1"
|
||||
self.user = SimpleNamespace(id="user-1")
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes
|
||||
|
||||
|
||||
def _job(job_id: str, **overrides: object) -> SimpleNamespace:
|
||||
values: dict[str, object] = {
|
||||
"id": job_id,
|
||||
"entry_id": job_id,
|
||||
"entry_index": 1,
|
||||
"build_status": JobBuildStatus.BUILT.value,
|
||||
"validation_status": JobValidationStatus.READY.value,
|
||||
"queue_status": JobQueueStatus.DRAFT.value,
|
||||
"send_status": JobSendStatus.NOT_QUEUED.value,
|
||||
"eml_local_path": f"{job_id}.eml",
|
||||
"eml_storage_key": None,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def test_synchronous_policy_defaults_to_25_and_tenant_can_only_narrow() -> None:
|
||||
default = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={},
|
||||
)
|
||||
assert default.max_recipient_jobs == DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS == 25
|
||||
assert default.source == "deployment_default"
|
||||
|
||||
narrowed = effective_synchronous_send_policy(
|
||||
_PolicySession(
|
||||
{"campaign_delivery_policy": {"synchronous_send_max_recipients": 10}}
|
||||
), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "40"},
|
||||
)
|
||||
assert narrowed.max_recipient_jobs == 10
|
||||
assert narrowed.source == "tenant"
|
||||
|
||||
ceiling = effective_synchronous_send_policy(
|
||||
_PolicySession(
|
||||
{"campaign_delivery_policy": {"synchronous_send_max_recipients": 100}}
|
||||
), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "40"},
|
||||
)
|
||||
assert ceiling.max_recipient_jobs == 40
|
||||
assert ceiling.source == "deployment_ceiling"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, -1, 501, "2.5", "unbounded"])
|
||||
def test_invalid_synchronous_policy_fails_closed(value: object) -> None:
|
||||
with pytest.raises(CampaignDeliveryPolicyError):
|
||||
effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": value}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
|
||||
def test_candidate_count_uses_exact_built_and_reviewed_job_states() -> None:
|
||||
jobs = [
|
||||
_job("ready"),
|
||||
_job("queued", queue_status=JobQueueStatus.QUEUED.value, send_status=JobSendStatus.QUEUED.value),
|
||||
_job("reviewed", validation_status=JobValidationStatus.NEEDS_REVIEW.value),
|
||||
_job("blocked", validation_status=JobValidationStatus.BLOCKED.value),
|
||||
_job("failed", send_status=JobSendStatus.FAILED_TEMPORARY.value),
|
||||
_job("missing-eml", eml_local_path=None),
|
||||
]
|
||||
|
||||
candidates = synchronous_send_candidate_jobs(_version(), jobs) # type: ignore[arg-type]
|
||||
|
||||
assert [job.id for job in candidates] == ["ready", "queued", "reviewed"]
|
||||
|
||||
|
||||
def test_limit_and_zero_count_reject_before_delivery() -> None:
|
||||
policy = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
|
||||
)
|
||||
with pytest.raises(SynchronousSendRejected, match="No eligible") as empty:
|
||||
_ensure_synchronous_send_count_allowed(0, policy=policy)
|
||||
assert empty.value.reason == "no_eligible_recipient_jobs"
|
||||
|
||||
with pytest.raises(SynchronousSendRejected, match="Queue it") as oversized:
|
||||
_ensure_synchronous_send_count_allowed(3, policy=policy)
|
||||
assert oversized.value.audit_details()["eligible_recipient_job_count"] == 3
|
||||
assert oversized.value.audit_details()["synchronous_send_policy"]["max_recipient_jobs"] == 2
|
||||
|
||||
|
||||
def test_exact_synchronous_policy_boundary_is_allowed() -> None:
|
||||
policy = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
|
||||
)
|
||||
|
||||
_ensure_synchronous_send_count_allowed(2, policy=policy)
|
||||
|
||||
|
||||
def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> None:
|
||||
policy = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={"GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS": "2"},
|
||||
)
|
||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||
initial_jobs = [_job("one"), _job("two")]
|
||||
post_queue_jobs = [
|
||||
_job(
|
||||
job_id,
|
||||
queue_status=JobQueueStatus.QUEUED.value,
|
||||
send_status=JobSendStatus.QUEUED.value,
|
||||
)
|
||||
for job_id in ("one", "two", "concurrent")
|
||||
]
|
||||
queued = QueueCampaignResult(
|
||||
campaign_id="campaign-1",
|
||||
version_id="version-1",
|
||||
queued_count=2,
|
||||
skipped_count=0,
|
||||
blocked_count=0,
|
||||
enqueued_count=0,
|
||||
delivery_mode="synchronous",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
|
||||
patch("govoplan_campaign.backend.sending.jobs._get_current_version", return_value=_version()),
|
||||
patch("govoplan_campaign.backend.sending.jobs._ensure_version_validated_and_locked"),
|
||||
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"),
|
||||
patch("govoplan_campaign.backend.sending.jobs.effective_synchronous_send_policy", return_value=policy),
|
||||
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_queue", return_value=initial_jobs),
|
||||
patch("govoplan_campaign.backend.sending.jobs.queue_campaign_jobs", return_value=queued),
|
||||
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_version", return_value=post_queue_jobs),
|
||||
patch("govoplan_campaign.backend.sending.jobs._preflight_synchronous_send_batch") as batch_preflight,
|
||||
):
|
||||
with pytest.raises(SynchronousSendRejected, match="above the effective") as rejected:
|
||||
send_campaign_now(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
|
||||
assert rejected.value.eligible_count == 3
|
||||
batch_preflight.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("workers_available", "expected_mode", "expected_enqueued"),
|
||||
((False, "database_queue", 0), (True, "worker_queue", 1)),
|
||||
)
|
||||
def test_asynchronous_mode_matches_actual_worker_availability(
|
||||
workers_available: bool,
|
||||
expected_mode: str,
|
||||
expected_enqueued: int,
|
||||
) -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1")
|
||||
version = _version()
|
||||
job = _job("one")
|
||||
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs._celery_enabled", return_value=workers_available),
|
||||
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
|
||||
patch("govoplan_campaign.backend.sending.jobs._get_current_version", return_value=version),
|
||||
patch("govoplan_campaign.backend.sending.jobs._ensure_version_validated_and_locked"),
|
||||
patch("govoplan_campaign.backend.sending.jobs._ensure_campaign_execution_snapshot"),
|
||||
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_queue", return_value=[job]),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._select_campaign_jobs_for_queue",
|
||||
return_value=([job], 0, 0),
|
||||
),
|
||||
patch("govoplan_campaign.backend.sending.jobs._persist_campaign_queue") as persist,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._enqueue_campaign_jobs",
|
||||
return_value=expected_enqueued,
|
||||
) as enqueue,
|
||||
):
|
||||
result = queue_campaign_jobs(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
enqueue_celery=True,
|
||||
)
|
||||
|
||||
assert result.delivery_mode == expected_mode
|
||||
assert result.worker_queue_available is workers_available
|
||||
assert result.enqueued_count == expected_enqueued
|
||||
assert persist.call_args.kwargs["delivery_mode"] == expected_mode
|
||||
assert enqueue.call_args.kwargs["enabled"] is workers_available
|
||||
|
||||
|
||||
def test_invalid_policy_disables_synchronous_mode_without_hiding_queue_availability() -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1")
|
||||
version = _version()
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs._get_campaign_for_tenant", return_value=campaign),
|
||||
patch("govoplan_campaign.backend.sending.jobs._get_version_for_campaign", return_value=version),
|
||||
patch("govoplan_campaign.backend.sending.jobs._campaign_jobs_for_version", return_value=[_job("one")]),
|
||||
patch("govoplan_campaign.backend.sending.jobs._celery_enabled", return_value=True),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.effective_synchronous_send_policy",
|
||||
side_effect=CampaignDeliveryPolicyError("invalid deployment value"),
|
||||
),
|
||||
):
|
||||
options = synchronous_send_options(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
)
|
||||
|
||||
assert options["worker_queue_available"] is True
|
||||
assert options["synchronous_send"]["allowed"] is False
|
||||
assert options["synchronous_send"]["reason"] == "policy_configuration_invalid"
|
||||
assert options["synchronous_send"]["policy"] == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "required_scope"),
|
||||
(
|
||||
("/campaigns/{campaign_id}/send-now", "campaigns:campaign:send"),
|
||||
("/campaigns/{campaign_id}/queue", "campaigns:campaign:queue"),
|
||||
),
|
||||
)
|
||||
def test_delivery_endpoints_require_their_mode_permission_and_recipient_authority(
|
||||
path: str,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
route = next(item for item in router.router.routes if item.path == path)
|
||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
||||
|
||||
with pytest.raises(HTTPException) as missing_mode_permission:
|
||||
dependency.call(_Principal("campaigns:recipient:read"))
|
||||
assert missing_mode_permission.value.status_code == 403
|
||||
allowed = _Principal(required_scope, "campaigns:recipient:read")
|
||||
assert dependency.call(allowed) is allowed
|
||||
|
||||
mode_only = _Principal(required_scope)
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal"),
|
||||
pytest.raises(HTTPException) as missing_recipient_authority,
|
||||
):
|
||||
if required_scope == "campaigns:campaign:send":
|
||||
router.send_campaign_now_endpoint(
|
||||
"campaign-1",
|
||||
session=Mock(),
|
||||
principal=mode_only, # type: ignore[arg-type]
|
||||
)
|
||||
else:
|
||||
router.queue_campaign(
|
||||
"campaign-1",
|
||||
session=Mock(),
|
||||
principal=mode_only, # type: ignore[arg-type]
|
||||
)
|
||||
assert missing_recipient_authority.value.status_code == 403
|
||||
assert "campaigns:recipient:read" in missing_recipient_authority.value.detail
|
||||
|
||||
|
||||
def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
|
||||
jobs = [_job("one"), _job("two")]
|
||||
contexts = {
|
||||
"one": SimpleNamespace(snapshot=SimpleNamespace(smtp_transport_revision="revision-1")),
|
||||
"two": SimpleNamespace(snapshot=SimpleNamespace(smtp_transport_revision="revision-1")),
|
||||
}
|
||||
policy = effective_synchronous_send_policy(
|
||||
_PolicySession(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
environ={},
|
||||
)
|
||||
provider = Mock()
|
||||
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs._preflight_send_campaign_job", return_value=None) as state_preflight,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._send_job_delivery_context",
|
||||
side_effect=lambda _session, job: contexts[job.id],
|
||||
) as input_preflight,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.profile_delivery_summary",
|
||||
return_value={"smtp_transport_revision": "revision-1"},
|
||||
),
|
||||
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=provider),
|
||||
):
|
||||
result = _preflight_synchronous_send_batch(
|
||||
object(), # type: ignore[arg-type]
|
||||
version=_version(), # type: ignore[arg-type]
|
||||
jobs=jobs, # type: ignore[arg-type]
|
||||
policy=policy,
|
||||
)
|
||||
|
||||
assert list(result) == ["one", "two"]
|
||||
assert state_preflight.call_count == 2
|
||||
assert input_preflight.call_count == 2
|
||||
provider.send_campaign_email_bytes.assert_not_called()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.9",
|
||||
"version": "0.1.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.9",
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -27,7 +27,10 @@
|
||||
"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: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:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.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",
|
||||
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
|
||||
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
|
||||
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
||||
import { apiDownload, apiFetch } from "./client";
|
||||
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
||||
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export type {
|
||||
AccessDecisionProvenanceItem,
|
||||
@@ -76,6 +77,8 @@ export type CampaignVersionListItem = {
|
||||
build_summary?: Record<string, unknown> | null;
|
||||
execution_snapshot_hash?: string | null;
|
||||
execution_snapshot_at?: string | null;
|
||||
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
|
||||
delivery_mode_selected_at?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||
@@ -239,22 +242,29 @@ export type CampaignSummary = {
|
||||
created_at?: string | null;
|
||||
validation_summary?: Record<string, unknown> | null;
|
||||
build_summary?: Record<string, unknown> | null;
|
||||
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
|
||||
delivery_mode_selected_at?: string | null;
|
||||
} | null;
|
||||
cards?: {
|
||||
jobs_total?: number;
|
||||
inactive?: number;
|
||||
queueable?: number;
|
||||
queueable_unattempted?: number;
|
||||
retryable?: number;
|
||||
cancellable?: number;
|
||||
needs_attention?: number;
|
||||
sent?: number;
|
||||
smtp_accepted?: number;
|
||||
failed?: number;
|
||||
outcome_unknown?: number;
|
||||
not_attempted?: number;
|
||||
skipped?: number;
|
||||
queued_or_active?: number;
|
||||
cancelled?: number;
|
||||
partially_completed?: boolean;
|
||||
imap_appended?: number;
|
||||
imap_failed?: number;
|
||||
imap_skipped?: number;
|
||||
};
|
||||
status_counts?: Record<string, Record<string, number>>;
|
||||
issues?: Record<string, unknown>;
|
||||
@@ -271,6 +281,24 @@ export type CampaignQueuePayload = {
|
||||
dry_run?: boolean;
|
||||
};
|
||||
|
||||
export type CampaignDeliveryOptions = {
|
||||
campaign_id: string;
|
||||
version_id: string;
|
||||
worker_queue_available: boolean;
|
||||
synchronous_send: {
|
||||
allowed?: boolean;
|
||||
reason?: string | null;
|
||||
message?: string | null;
|
||||
eligible_recipient_job_count?: number;
|
||||
policy?: {
|
||||
max_recipient_jobs?: number;
|
||||
source?: string;
|
||||
deployment_max_recipient_jobs?: number;
|
||||
tenant_max_recipient_jobs?: number | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CampaignSendNowPayload = {
|
||||
version_id?: string | null;
|
||||
include_warnings?: boolean;
|
||||
@@ -386,16 +414,7 @@ export type CampaignSendJobPayload = {
|
||||
};
|
||||
|
||||
|
||||
export type CampaignJobsQuery = {
|
||||
versionId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string | null;
|
||||
sendStatus?: string[];
|
||||
validationStatus?: string[];
|
||||
imapStatus?: string[];
|
||||
query?: string;
|
||||
};
|
||||
export type CampaignJobsQuery = Omit<CampaignJobsQueryParameters, "since" | "limit">;
|
||||
|
||||
export type CampaignJobsResponse = {
|
||||
jobs: Record<string, unknown>[];
|
||||
@@ -441,6 +460,53 @@ export type CampaignReportEmailPayload = {
|
||||
dry_run?: boolean;
|
||||
};
|
||||
|
||||
export type AggregateReportCount = {
|
||||
value: number | null;
|
||||
suppressed: boolean;
|
||||
};
|
||||
|
||||
export type AggregateReportCampaign = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type AggregateReportCampaignListItem = AggregateReportCampaign & {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AggregateCampaignReport = {
|
||||
generated_at: string;
|
||||
campaign: AggregateReportCampaign;
|
||||
version_number: number | null;
|
||||
completion_state: "not_started" | "in_progress" | "completed" | "partially_completed" | "incomplete" | "outcome_unknown" | "suppressed";
|
||||
population: {
|
||||
denominator: AggregateReportCount;
|
||||
denominator_definition: string;
|
||||
inactive_source_entries: AggregateReportCount;
|
||||
excluded_or_blocked_jobs: AggregateReportCount;
|
||||
};
|
||||
outcomes: {
|
||||
smtp_accepted: AggregateReportCount;
|
||||
failed: AggregateReportCount;
|
||||
outcome_unknown: AggregateReportCount;
|
||||
queued_or_active: AggregateReportCount;
|
||||
cancelled: AggregateReportCount;
|
||||
excluded: AggregateReportCount;
|
||||
not_attempted: AggregateReportCount;
|
||||
};
|
||||
time_range: {
|
||||
first_activity_at: string | null;
|
||||
last_activity_at: string | null;
|
||||
suppressed: boolean;
|
||||
};
|
||||
privacy: {
|
||||
small_cell_threshold: number;
|
||||
suppression_applied: boolean;
|
||||
rule: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
|
||||
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
|
||||
|
||||
@@ -451,6 +517,13 @@ export async function listCampaigns(settings: ApiSettings): Promise<CampaignList
|
||||
return response.campaigns ?? response.items ?? response.results ?? [];
|
||||
}
|
||||
|
||||
export async function listAggregateReportCampaigns(
|
||||
settings: ApiSettings)
|
||||
: Promise<AggregateReportCampaignListItem[]> {
|
||||
const response = await apiFetch<{campaigns: AggregateReportCampaignListItem[]}>(settings, "/api/v1/campaigns/aggregate-reports");
|
||||
return response.campaigns;
|
||||
}
|
||||
|
||||
export async function listCampaignsDelta(
|
||||
settings: ApiSettings,
|
||||
options: {since?: string | null;limit?: number;} = {})
|
||||
@@ -783,15 +856,7 @@ settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery = {})
|
||||
: Promise<CampaignJobsResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
for (const value of options.sendStatus ?? []) params.append("send_status", value);
|
||||
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||
if (options.query?.trim()) params.set("q", options.query.trim());
|
||||
const params = campaignJobsQueryParams(options);
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignJobsResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs${suffix}`);
|
||||
}
|
||||
@@ -801,17 +866,7 @@ settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: CampaignJobsQuery & {since?: string | null;limit?: number;} = {})
|
||||
: Promise<CampaignJobsDeltaResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
for (const value of options.sendStatus ?? []) params.append("send_status", value);
|
||||
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||
if (options.query?.trim()) params.set("q", options.query.trim());
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const params = campaignJobsQueryParams(options);
|
||||
const suffix = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return apiFetch<CampaignJobsDeltaResponse>(settings, `/api/v1/campaigns/${campaignId}/jobs/delta${suffix}`);
|
||||
}
|
||||
@@ -834,6 +889,16 @@ versionId?: string)
|
||||
return apiFetch<CampaignSummary>(settings, `/api/v1/campaigns/${campaignId}/report?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function getAggregateCampaignReport(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<AggregateCampaignReport> {
|
||||
return apiFetch<AggregateCampaignReport>(
|
||||
settings,
|
||||
`/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}`
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadCampaignJobsCsv(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -928,6 +993,15 @@ payload: CampaignQueuePayload = {})
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaignDeliveryOptions(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string | null)
|
||||
: Promise<CampaignDeliveryOptions> {
|
||||
const query = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
return apiFetch<CampaignDeliveryOptions>(settings, `/api/v1/campaigns/${campaignId}/delivery-options${query}`);
|
||||
}
|
||||
|
||||
export async function sendCampaignNow(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
downloadCampaignJobsCsv,
|
||||
emailCampaignReport,
|
||||
getCampaignJobDetail,
|
||||
getCampaignJobsDelta,
|
||||
getCampaignJobs,
|
||||
resolveCampaignJobOutcome,
|
||||
retryCampaignJobs,
|
||||
sendCampaignJob,
|
||||
@@ -16,19 +16,29 @@ import {
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridListOption } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage, useDeltaWatermarks } from "@govoplan/core-webui";
|
||||
import { LoadingFrame, TableActionGroup, ToggleSwitch, i18nMessage } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
|
||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||
import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
|
||||
import type { CampaignJobSortColumn } from "./utils/jobListQuery";
|
||||
import {
|
||||
DEFAULT_REPORT_GRID_SORT,
|
||||
activeReportGridShortcut,
|
||||
reportGridQueriesEqual,
|
||||
toggleReportGridShortcut,
|
||||
type ReportGridShortcutId
|
||||
} from "./utils/reportGridShortcuts";
|
||||
|
||||
const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_queued",
|
||||
"skipped",
|
||||
"queued",
|
||||
"claimed",
|
||||
"sending",
|
||||
@@ -38,21 +48,41 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: humanize(value) }));
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
"appending",
|
||||
"appended",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"skipped"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const VALIDATION_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"ready",
|
||||
"warning",
|
||||
"needs_review",
|
||||
"blocked",
|
||||
"excluded",
|
||||
"inactive"].
|
||||
map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
const QUEUE_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"draft",
|
||||
"queued",
|
||||
"sending",
|
||||
"paused",
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: humanize(value) }));
|
||||
|
||||
const JOB_GRID_QUERY_DELAY_MS = 300;
|
||||
|
||||
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
|
||||
|
||||
export default function CampaignReportPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { data, loading, error, reload } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
const version = data.currentVersion;
|
||||
const cards = data.summary?.cards;
|
||||
const delivery = asRecord(data.summary?.delivery);
|
||||
@@ -60,14 +90,19 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
const imapPolicy = asRecord(delivery.imap_append_sent);
|
||||
|
||||
const [jobs, setJobs] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||
const jobsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
|
||||
const jobPageCursorsRef = useRef<Record<number, string | null>>({ 1: null });
|
||||
const jobsRequestRef = useRef(0);
|
||||
const [jobsLoading, setJobsLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sendStatus, setSendStatus] = useState("");
|
||||
const [imapStatus, setImapStatus] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [appliedQuery, setAppliedQuery] = useState("");
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [initialGridFilters] = useState<Record<string, string | string[]>>(() => initialReportGridFilters());
|
||||
const initialGridQuery = useMemo<DataGridQueryState>(() => ({
|
||||
sort: DEFAULT_REPORT_GRID_SORT,
|
||||
filters: serializeInitialGridFilters(initialGridFilters)
|
||||
}), [initialGridFilters]);
|
||||
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
|
||||
const [appliedJobGridQuery, setAppliedJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
|
||||
const [query, setQuery] = useState(() => initialReportQuery());
|
||||
const [appliedQuery, setAppliedQuery] = useState(query.trim());
|
||||
const [actionMessage, setActionMessage] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const [busyAction, setBusyAction] = useState("");
|
||||
@@ -81,86 +116,75 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
useEffect(() => {
|
||||
const handle = window.setTimeout(() => {
|
||||
setAppliedQuery(query.trim());
|
||||
setAppliedJobGridQuery((current) => reportGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery);
|
||||
setPage(1);
|
||||
}, 350);
|
||||
}, JOB_GRID_QUERY_DELAY_MS);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [query]);
|
||||
}, [query, jobGridQuery]);
|
||||
|
||||
const jobsQueryKey = useMemo(
|
||||
() => JSON.stringify({
|
||||
campaignId,
|
||||
versionId: version?.id ?? null,
|
||||
page,
|
||||
pageSize: 50,
|
||||
sendStatus,
|
||||
imapStatus,
|
||||
appliedQuery,
|
||||
apiBaseUrl: settings.apiBaseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
accessToken: settings.accessToken
|
||||
}),
|
||||
[campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]
|
||||
const handleJobGridQuery = useCallback((next: DataGridQueryState) => {
|
||||
setJobGridQuery((current) => reportGridQueriesEqual(current, next) ? current : next);
|
||||
}, []);
|
||||
|
||||
const activeJobGridShortcut = useMemo(
|
||||
() => query.trim() ? null : activeReportGridShortcut(jobGridQuery),
|
||||
[jobGridQuery, query]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
jobPageCursorsRef.current = { 1: null };
|
||||
}, [campaignId, version?.id, sendStatus, imapStatus, appliedQuery, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
const applyJobGridShortcut = useCallback((shortcutId: ReportGridShortcutId) => {
|
||||
const next = toggleReportGridShortcut(jobGridQuery, shortcutId);
|
||||
setQuery("");
|
||||
setAppliedQuery("");
|
||||
setJobGridQuery(next);
|
||||
setAppliedJobGridQuery(next);
|
||||
setPage(1);
|
||||
}, [jobGridQuery]);
|
||||
|
||||
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
|
||||
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
|
||||
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
|
||||
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" },
|
||||
{ label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" },
|
||||
{ label: "i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19", value: cards?.skipped ?? jobs.counts.send?.skipped ?? 0, shortcutId: "smtp_skipped" },
|
||||
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
|
||||
];
|
||||
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
|
||||
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
|
||||
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
|
||||
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
|
||||
];
|
||||
|
||||
const loadJobs = useCallback(async () => {
|
||||
if (!campaignId) return;
|
||||
const requestId = ++jobsRequestRef.current;
|
||||
setJobsLoading(true);
|
||||
setActionError("");
|
||||
try {
|
||||
let nextWatermark = getDeltaWatermark(jobsQueryKey);
|
||||
let merged = jobsRef.current;
|
||||
let hasMore = false;
|
||||
const pageCursor = page === 1 ? null : jobPageCursorsRef.current[page];
|
||||
do {
|
||||
const response = await getCampaignJobsDelta(settings, campaignId, {
|
||||
versionId: version?.id,
|
||||
page,
|
||||
pageSize: 50,
|
||||
cursor: pageCursor,
|
||||
sendStatus: sendStatus ? [sendStatus] : undefined,
|
||||
imapStatus: imapStatus ? [imapStatus] : undefined,
|
||||
query: appliedQuery || undefined,
|
||||
since: nextWatermark
|
||||
});
|
||||
merged = mergeCampaignJobsDelta(merged, response);
|
||||
if (response.cursor !== undefined) jobPageCursorsRef.current[page] = response.cursor ?? null;
|
||||
if (response.next_cursor !== undefined) {
|
||||
if (response.next_cursor) jobPageCursorsRef.current[page + 1] = response.next_cursor;
|
||||
else delete jobPageCursorsRef.current[page + 1];
|
||||
}
|
||||
nextWatermark = response.watermark ?? null;
|
||||
hasMore = response.has_more;
|
||||
} while (hasMore);
|
||||
setDeltaWatermark(jobsQueryKey, nextWatermark);
|
||||
jobsRef.current = merged;
|
||||
setJobs(merged);
|
||||
if (merged.pages > 0 && page > merged.pages) setPage(merged.pages);
|
||||
const response = await getCampaignJobs(settings, campaignId, {
|
||||
versionId: version?.id,
|
||||
page,
|
||||
pageSize,
|
||||
query: appliedQuery || undefined,
|
||||
sortBy: campaignJobSortColumn(appliedJobGridQuery.sort?.columnId),
|
||||
sortDirection: appliedJobGridQuery.sort?.direction ?? "asc",
|
||||
filters: appliedJobGridQuery.filters
|
||||
});
|
||||
if (requestId !== jobsRequestRef.current) return;
|
||||
setJobs(response);
|
||||
if (response.pages > 0 && page > response.pages) setPage(response.pages);
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
if (requestId === jobsRequestRef.current) setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setJobsLoading(false);
|
||||
if (requestId === jobsRequestRef.current) setJobsLoading(false);
|
||||
}
|
||||
}, [settings, campaignId, version?.id, page, sendStatus, imapStatus, appliedQuery, jobsQueryKey, getDeltaWatermark, setDeltaWatermark]);
|
||||
|
||||
useEffect(() => {
|
||||
resetDeltaWatermark(jobsQueryKey);
|
||||
jobsRef.current = emptyCampaignJobsResponse();
|
||||
setJobs(emptyCampaignJobsResponse());
|
||||
}, [jobsQueryKey, resetDeltaWatermark]);
|
||||
}, [settings, campaignId, version?.id, page, pageSize, appliedQuery, appliedJobGridQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
async function reloadAll() {
|
||||
resetDeltaWatermark(jobsQueryKey);
|
||||
jobPageCursorsRef.current = { 1: null };
|
||||
jobsRef.current = emptyCampaignJobsResponse();
|
||||
setJobs(emptyCampaignJobsResponse());
|
||||
await Promise.all([reload({ force: true }), loadJobs()]);
|
||||
}
|
||||
|
||||
@@ -327,10 +351,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
value: (row) => String(row.recipient_email ?? "—")
|
||||
},
|
||||
{ id: "subject", header: "i18n:govoplan-campaign.subject.8d183dbd", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
|
||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||
{ id: "validation", header: "i18n:govoplan-campaign.validation.dd74d182", width: 145, sortable: true, filterable: true, columnType: "from-list", list: { options: VALIDATION_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
|
||||
{ id: "queue", header: "i18n:govoplan-campaign.queue.d325fcd9", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: QUEUE_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.queue_status ?? "unknown")} />, value: (row) => String(row.queue_status ?? "unknown") },
|
||||
{ id: "send", header: "i18n:govoplan-campaign.smtp.efff9cca", width: 160, sortable: true, filterable: true, columnType: "from-list", list: { options: SEND_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.send_status ?? "unknown")} label={deliveryStatusLabel(String(row.send_status ?? "unknown"))} />, value: (row) => String(row.send_status ?? "unknown") },
|
||||
{ id: "imap", header: "i18n:govoplan-campaign.imap.271f9ef2", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: IMAP_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.imap_status ?? "unknown")} label={deliveryStatusLabel(String(row.imap_status ?? "unknown"))} />, value: (row) => String(row.imap_status ?? "unknown") },
|
||||
{ id: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0) },
|
||||
{
|
||||
id: "evidence",
|
||||
@@ -351,7 +375,6 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
header: "i18n:govoplan-campaign.last_result.110b888b",
|
||||
width: "minmax(220px, 1fr)",
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (row) => <span className={row.last_error ? "recipient-outcome-error" : "muted"} title={String(row.last_error ?? "")}>{String(row.last_error ?? "—")}</span>,
|
||||
value: (row) => String(row.last_error ?? "—")
|
||||
},
|
||||
@@ -395,18 +418,36 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div>
|
||||
{deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||
const active = activeJobGridShortcut === shortcutId;
|
||||
return (
|
||||
<div key={shortcutId}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
|
||||
{value}
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</Card>
|
||||
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div>
|
||||
{imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
|
||||
const active = activeJobGridShortcut === shortcutId;
|
||||
return (
|
||||
<div key={shortcutId}>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
|
||||
{value}
|
||||
</Button>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</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>
|
||||
@@ -426,34 +467,43 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
</div>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.recipient_delivery_jobs.52492608">
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00
|
||||
</p>
|
||||
<div className="page-heading split">
|
||||
<div className="button-row compact-actions">
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5" />
|
||||
<select value={sendStatus} onChange={(event) => {setSendStatus(event.target.value);setPage(1);}}>
|
||||
<option value="">i18n:govoplan-campaign.all_smtp_states.739597b1</option>
|
||||
{SEND_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<select value={imapStatus} onChange={(event) => {setImapStatus(event.target.value);setPage(1);}}>
|
||||
<option value="">i18n:govoplan-campaign.all_imap_states.8546b84c</option>
|
||||
{IMAP_STATUS_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<FormField label="i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5">
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
<span className="muted">{jobs.total} i18n:govoplan-campaign.matching_of.66a3778e {jobs.total_unfiltered} i18n:govoplan-campaign.total_job_s.c94b7d20</span>
|
||||
</div>
|
||||
<LoadingFrame loading={jobsLoading} label="i18n:govoplan-campaign.loading_delivery_jobs.20ecc37e">
|
||||
<DataGrid<Record<string, unknown>>
|
||||
id={`campaign-report-jobs-${campaignId}`}
|
||||
id={`campaign-report-jobs-v2-${campaignId}`}
|
||||
rows={jobs.jobs}
|
||||
columns={columns}
|
||||
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
|
||||
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" />
|
||||
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
|
||||
initialFilters={initialGridFilters}
|
||||
initialSort={DEFAULT_REPORT_GRID_SORT}
|
||||
query={jobGridQuery}
|
||||
pagination={{
|
||||
mode: "server",
|
||||
page,
|
||||
pageSize,
|
||||
totalRows: jobs.total,
|
||||
pageSizeOptions: [25, 50, 100, 200],
|
||||
disabled: jobsLoading,
|
||||
onPageChange: setPage,
|
||||
onPageSizeChange: (nextPageSize) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(1);
|
||||
}
|
||||
}}
|
||||
onQueryChange={handleJobGridQuery} />
|
||||
|
||||
</LoadingFrame>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page <= 1 || jobsLoading}>i18n:govoplan-campaign.previous.50f94286</Button>
|
||||
<span>i18n:govoplan-campaign.page.fb06270f {jobs.pages === 0 ? 0 : jobs.page} of {jobs.pages}</span>
|
||||
<Button onClick={() => setPage((value) => Math.min(jobs.pages || 1, value + 1))} disabled={page >= jobs.pages || jobsLoading}>i18n:govoplan-campaign.next.bc981983</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
@@ -489,8 +539,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<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.message_id.465056ba</dt><dd>{String(detail.job.message_id_header ?? "—")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_state.03b83be0</dt><dd><StatusBadge status={String(detail.job.imap_status ?? "unknown")} /></dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_state.ff372566</dt><dd><StatusBadge status={String(detail.job.send_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.send_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>
|
||||
</dl>
|
||||
<AttemptHistoryTable kind="smtp" rows={detail.attempts.smtp ?? []} />
|
||||
@@ -545,6 +595,52 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
||||
|
||||
}
|
||||
|
||||
function initialReportGridFilters(): Record<string, string | string[]> {
|
||||
if (typeof window === "undefined") return {};
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const result: Record<string, string | string[]> = {};
|
||||
const send = statusParameters(params, "send_status", SEND_STATUS_OPTIONS);
|
||||
const imap = statusParameters(params, "imap_status", IMAP_STATUS_OPTIONS);
|
||||
const validation = statusParameters(params, "validation_status", VALIDATION_STATUS_OPTIONS);
|
||||
if (send.length > 0) result.send = send;
|
||||
if (imap.length > 0) result.imap = imap;
|
||||
if (validation.length > 0) result.validation = validation;
|
||||
return result;
|
||||
}
|
||||
|
||||
function deliveryStatusLabel(status: string): string | undefined {
|
||||
return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7" : undefined;
|
||||
}
|
||||
|
||||
function initialReportQuery(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return new URLSearchParams(window.location.search).get("q")?.trim() ?? "";
|
||||
}
|
||||
|
||||
function statusParameters(params: URLSearchParams, name: string, options: DataGridListOption[]): string[] {
|
||||
const allowed = new Set(options.map((option) => option.value));
|
||||
return [...new Set(
|
||||
params.getAll(name).
|
||||
flatMap((value) => value.split(",")).
|
||||
map((value) => value.trim()).
|
||||
filter((value) => allowed.has(value))
|
||||
)];
|
||||
}
|
||||
|
||||
function serializeInitialGridFilters(filters: Record<string, string | string[]>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(filters).map(([columnId, value]) => [
|
||||
columnId,
|
||||
Array.isArray(value) ? `list:${JSON.stringify([...new Set(value)])}` : value
|
||||
]));
|
||||
}
|
||||
|
||||
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
|
||||
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
|
||||
return value;
|
||||
}
|
||||
return "number";
|
||||
}
|
||||
|
||||
function retryableFailedStatus(status: string): boolean {
|
||||
return status === "failed_temporary" || status === "failed_permanent";
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<Route path="global-settings" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="settings" />} />
|
||||
<Route path="policies" element={<GlobalSettingsPage settings={settings} auth={auth} campaignId={campaignId || ""} view="policy" />} />
|
||||
<Route path="policy" element={<Navigate to="../policies" replace />} />
|
||||
<Route path="review" element={<ReviewSendPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="review" element={<ReviewSendPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="send" element={<Navigate to="../review" replace />} />
|
||||
<Route path="report" element={<CampaignReportPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="reports" element={<Navigate to="../report" replace />} />
|
||||
|
||||
@@ -17,26 +17,33 @@ import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
appendSent,
|
||||
buildVersion,
|
||||
cancelCampaign,
|
||||
getCampaignDeliveryOptions,
|
||||
getCampaignJobs,
|
||||
getCampaignJobsDelta,
|
||||
getCampaignJobDetail,
|
||||
getCampaignSummary,
|
||||
linkCampaignAttachmentMatches,
|
||||
mockSendCampaign,
|
||||
pauseCampaign,
|
||||
previewCampaignAttachments,
|
||||
queueCampaign,
|
||||
resumeCampaign,
|
||||
retryCampaignJobs,
|
||||
sendCampaignJob,
|
||||
sendCampaignNow,
|
||||
updateCampaignReviewState,
|
||||
validateVersion,
|
||||
type CampaignAttachmentPreviewFile,
|
||||
type CampaignAttachmentPreviewResponse,
|
||||
type CampaignDeliveryOptions,
|
||||
type CampaignJobsQuery,
|
||||
type CampaignJobsResponse,
|
||||
type CampaignSummary,
|
||||
type CampaignVersionDetail } from
|
||||
"../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
import { Button, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
|
||||
import { Dialog } from "@govoplan/core-webui";
|
||||
@@ -63,6 +70,7 @@ import {
|
||||
isVersionReadyForDelivery,
|
||||
stringifyPreview } from
|
||||
"./utils/campaignView";
|
||||
import { deliveryModeLabel } from "./utils/deliveryMode";
|
||||
import { getBool, getText } from "./utils/draftEditor";
|
||||
import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview";
|
||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||
@@ -97,7 +105,7 @@ type DeliverabilityPreflightItem = {
|
||||
state: "ready" | "warning" | "blocked" | "info";
|
||||
};
|
||||
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "imap" | "";
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
@@ -121,7 +129,7 @@ const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
||||
|
||||
const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "excluded"];
|
||||
|
||||
export default function ReviewSendPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
export default function ReviewSendPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
|
||||
const { getDeltaWatermark, setDeltaWatermark, resetDeltaWatermark } = useDeltaWatermarks();
|
||||
@@ -134,6 +142,12 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
|
||||
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
|
||||
const version = data.currentVersion;
|
||||
const canSendSynchronously = hasScope(auth, "campaigns:campaign:send");
|
||||
const canQueueForWorkers = hasScope(auth, "campaigns:campaign:queue");
|
||||
const canControlDelivery = hasScope(auth, "campaigns:campaign:control");
|
||||
const canRetryDelivery = hasScope(auth, "campaigns:campaign:retry");
|
||||
const [deliveryOptions, setDeliveryOptions] = useState<CampaignDeliveryOptions | null>(null);
|
||||
const [deliveryOptionsLoading, setDeliveryOptionsLoading] = useState(false);
|
||||
const campaignJson = useMemo(() => getCampaignJson(version), [version]);
|
||||
const server = asRecord(campaignJson.server);
|
||||
const selectedMailProfileId = getText(server, "mail_profile_id");
|
||||
@@ -177,7 +191,10 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
const [reviewConfirmOpen, setReviewConfirmOpen] = useState(false);
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
|
||||
const [queueConfirmOpen, setQueueConfirmOpen] = useState(false);
|
||||
const [cancelDeliveryConfirmOpen, setCancelDeliveryConfirmOpen] = useState(false);
|
||||
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [queueResult, setQueueResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
|
||||
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
|
||||
const imapDiagnosticsRef = useRef<CampaignJobsResponse>(emptyCampaignJobsResponse());
|
||||
@@ -200,11 +217,15 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
setAttachmentPreviewError("");
|
||||
setAttachmentLockConfirmOpen(false);
|
||||
setSendResult(null);
|
||||
setQueueResult(null);
|
||||
setImapAppendResult(null);
|
||||
setImapDiagnostics(emptyCampaignJobsResponse());
|
||||
imapDiagnosticsRef.current = emptyCampaignJobsResponse();
|
||||
setSelectedDeliveryJobDetail(null);
|
||||
setSendConfirmOpen(false);
|
||||
setQueueConfirmOpen(false);
|
||||
setCancelDeliveryConfirmOpen(false);
|
||||
setDeliveryOptions(null);
|
||||
setLiveSummary(null);
|
||||
resetDeltaWatermark();
|
||||
}, [version?.id, resetDeltaWatermark]);
|
||||
@@ -234,6 +255,28 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
const buildWarnings = numberFrom(build, ["warning_count", "warnings"]);
|
||||
const hasBuild = buildPresent && (builtCount > 0 || version?.workflow_state === "built");
|
||||
|
||||
const refreshDeliveryOptions = useCallback(async (silent = true) => {
|
||||
if (!version?.id || !(canSendSynchronously || canQueueForWorkers)) {
|
||||
setDeliveryOptions(null);
|
||||
return;
|
||||
}
|
||||
setDeliveryOptionsLoading(true);
|
||||
try {
|
||||
const result = await getCampaignDeliveryOptions(settings, campaignId, version.id);
|
||||
setDeliveryOptions(result);
|
||||
} catch (err) {
|
||||
setDeliveryOptions(null);
|
||||
if (!silent) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeliveryOptionsLoading(false);
|
||||
}
|
||||
}, [campaignId, canQueueForWorkers, canSendSynchronously, setError, settings, version?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version?.id || !hasBuild) return;
|
||||
void refreshDeliveryOptions(true);
|
||||
}, [hasBuild, refreshDeliveryOptions, version?.id, version?.updated_at]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!version?.id || !hasBuild) return;
|
||||
const expectedKey = reviewJobsLoadKey(version.id, showAllReviewJobs);
|
||||
@@ -242,11 +285,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
}, [version?.id, hasBuild, showAllReviewJobs, jobsLoadedKey, campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const statusCounts = asRecord(summary?.status_counts);
|
||||
const queueStatusCounts = asRecord(statusCounts.queue);
|
||||
const sendStatusCounts = asRecord(statusCounts.send);
|
||||
const imapStatusCounts = asRecord(statusCounts.imap);
|
||||
const attempts = asRecord(summary?.attempts);
|
||||
const summaryDelivery = asRecord(summary?.delivery);
|
||||
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
|
||||
const pausedQueueCount = numberFrom(queueStatusCounts, ["paused"]);
|
||||
const claimedSendCount = numberFrom(sendStatusCounts, ["claimed"]);
|
||||
const sendingSendCount = numberFrom(sendStatusCounts, ["sending"]);
|
||||
const activeSendCount = claimedSendCount + sendingSendCount;
|
||||
@@ -255,12 +300,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
const cancelledCount = cards?.cancelled ?? numberFrom(sendStatusCounts, ["cancelled"]);
|
||||
const outcomeUnknownCount = cards?.outcome_unknown ?? numberFrom(sendStatusCounts, ["outcome_unknown"]);
|
||||
const sendAttemptCount = numberFrom(attempts, ["send_attempts"]);
|
||||
const backgroundWorkersEnabled = summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true;
|
||||
const backgroundWorkersDisabled = summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false;
|
||||
const backgroundWorkersEnabled = deliveryOptions?.worker_queue_available === true || summaryDelivery.background_workers_enabled === true || summaryDelivery.celery_enabled === true;
|
||||
const backgroundWorkersDisabled = deliveryOptions?.worker_queue_available === false || summaryDelivery.background_workers_enabled === false || summaryDelivery.celery_enabled === false;
|
||||
const recentFailures = asArray(summary?.recent_failures).map(asRecord).slice(0, 5);
|
||||
const jobsTotal = cards?.jobs_total ?? inlineEntries.filter((entry) => entry.active !== false).length;
|
||||
const sentCount = cards?.sent ?? 0;
|
||||
const failedCount = cards?.failed ?? 0;
|
||||
const retryableCount = cards?.retryable ?? numberFrom(sendStatusCounts, ["failed_temporary"]);
|
||||
const imapAppended = cards?.imap_appended ?? 0;
|
||||
const imapFailed = cards?.imap_failed ?? 0;
|
||||
const imapPending = numberFrom(imapStatusCounts, ["pending"]);
|
||||
@@ -272,6 +318,14 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
const deliveryStarted = deliverySending || deliveryHasTerminalOutcome || ["sent", "completed", "partially_completed", "outcome_unknown", "failed"].includes(currentWorkflowState);
|
||||
const queuedWithoutWorker = backgroundWorkersDisabled && queuedSendCount > 0 && activeSendCount === 0;
|
||||
const directQueuedSendAllowed = queuedWithoutWorker && !deliveryStarted;
|
||||
const synchronousSendOption = asRecord(deliveryOptions?.synchronous_send);
|
||||
const synchronousSendPolicy = asRecord(synchronousSendOption.policy);
|
||||
const synchronousSendLimit = numberFrom(synchronousSendPolicy, ["max_recipient_jobs"]);
|
||||
const synchronousEligibleCount = numberFrom(synchronousSendOption, ["eligible_recipient_job_count"]);
|
||||
const synchronousSendAllowed = synchronousSendOption.allowed === true;
|
||||
const workerQueueAvailable = deliveryOptions?.worker_queue_available === true;
|
||||
const persistedDeliveryMode = version?.delivery_mode ?? null;
|
||||
const persistedDeliveryModeSelectedAt = version?.delivery_mode_selected_at ?? null;
|
||||
const selectedDryRun = dryRun && !directQueuedSendAllowed;
|
||||
const deliveryPartial = cards?.partially_completed === true || ["partially_sent", "failed_partial", "partially_completed"].includes(currentWorkflowState);
|
||||
const deliveryComplete = queuedOrActiveCount === 0 &&
|
||||
@@ -325,8 +379,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
}, [campaignId, setError, settings, version?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(deliveryQueued || deliverySending) || loading || queueStatusLoading || busy === "send") return;
|
||||
const handle = window.setTimeout(() => {void refreshQueueStatus(true);}, 3000);
|
||||
const commandInProgress = ["send", "queue", "control", "retry"].includes(busy);
|
||||
if (!(deliveryQueued || deliverySending || commandInProgress) || loading || queueStatusLoading) return;
|
||||
const handle = window.setTimeout(() => {void refreshQueueStatus(true);}, commandInProgress ? 1000 : 3000);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [deliveryQueued, deliverySending, loading, queueStatusLoading, busy, refreshQueueStatus]);
|
||||
|
||||
@@ -457,7 +512,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
"i18n:govoplan-campaign.build_the_exact_queue_first.98d7ce1b";
|
||||
const sendState: FlowState = !mockGateSatisfied ?
|
||||
"locked" :
|
||||
busy === "send" || deliverySending ?
|
||||
["send", "queue", "control", "retry"].includes(busy) || deliverySending ?
|
||||
"running" :
|
||||
queuedWithoutWorker ?
|
||||
"warning" :
|
||||
@@ -764,7 +819,7 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
}
|
||||
|
||||
async function runSendNow() {
|
||||
if (!version || busy || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return;
|
||||
if (!version || busy || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted) return;
|
||||
const effectiveDryRun = dryRun && !directQueuedSendAllowed;
|
||||
setBusy("send");
|
||||
setMessage(effectiveDryRun ?
|
||||
@@ -797,6 +852,96 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
);
|
||||
setSendConfirmOpen(false);
|
||||
await reload();
|
||||
await refreshDeliveryOptions(true);
|
||||
} catch (err) {
|
||||
setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function runQueueForWorkers() {
|
||||
if (!version || busy || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0) return;
|
||||
setBusy("queue");
|
||||
setMessage("i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2");
|
||||
setQueueResult(null);
|
||||
setError("");
|
||||
try {
|
||||
const response = await queueCampaign(settings, campaignId, {
|
||||
version_id: version.id,
|
||||
include_warnings: true,
|
||||
enqueue_celery: true,
|
||||
dry_run: false
|
||||
});
|
||||
const result = asRecord(response);
|
||||
setQueueResult(result);
|
||||
setMessage(i18nMessage("i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8", {
|
||||
value0: String(result.queued_count ?? 0),
|
||||
value1: String(result.enqueued_count ?? 0)
|
||||
}));
|
||||
setQueueConfirmOpen(false);
|
||||
await reload();
|
||||
await refreshDeliveryOptions(true);
|
||||
} catch (err) {
|
||||
setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeliveryControl(action: "pause" | "resume" | "cancel") {
|
||||
if (busy || !canControlDelivery) return;
|
||||
setBusy("control");
|
||||
setMessage(deliveryControlProgressMessage(action));
|
||||
setError("");
|
||||
try {
|
||||
const response = action === "pause" ?
|
||||
await pauseCampaign(settings, campaignId) :
|
||||
action === "resume" ?
|
||||
await resumeCampaign(settings, campaignId) :
|
||||
await cancelCampaign(settings, campaignId);
|
||||
const result = asRecord(response.result ?? response);
|
||||
setMessage(
|
||||
action === "pause" ? i18nMessage("i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2", {
|
||||
value0: String(result.paused_count ?? 0)
|
||||
}) :
|
||||
action === "resume" ? i18nMessage("i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4", {
|
||||
value0: String(result.resumed_count ?? 0),
|
||||
value1: String(result.enqueued_count ?? 0)
|
||||
}) :
|
||||
i18nMessage("i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24", {
|
||||
value0: String(result.cancelled_count ?? 0),
|
||||
value1: String(result.protected_count ?? 0),
|
||||
value2: String(result.skipped_count ?? 0)
|
||||
})
|
||||
);
|
||||
if (action === "cancel") setCancelDeliveryConfirmOpen(false);
|
||||
await reload();
|
||||
await refreshDeliveryOptions(true);
|
||||
} catch (err) {
|
||||
setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function runRetryFailed() {
|
||||
if (busy || !canRetryDelivery || retryableCount <= 0 || !workerQueueAvailable) return;
|
||||
setBusy("retry");
|
||||
setMessage("i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9");
|
||||
setError("");
|
||||
try {
|
||||
const response = await retryCampaignJobs(settings, campaignId, { enqueue_celery: true });
|
||||
const result = asRecord(response.result ?? response);
|
||||
setMessage(i18nMessage("i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb", {
|
||||
value0: String(result.selected_count ?? 0),
|
||||
value1: String(result.enqueued_count ?? 0)
|
||||
}));
|
||||
await reload();
|
||||
await refreshDeliveryOptions(true);
|
||||
} catch (err) {
|
||||
setMessage("");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -1062,6 +1207,27 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
detail: messagesPerMinute > 0 ? `${messagesPerMinute} message(s) per minute; estimated minimum duration ${estimatedMinutes ?? "unknown"} minute(s).` : "No explicit rate limit is configured for this execution.",
|
||||
state: messagesPerMinute > 0 ? "ready" : "warning"
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.delivery_mode.109ed9d1",
|
||||
detail: deliveryOptionsLoading ?
|
||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011" :
|
||||
synchronousSendAllowed ?
|
||||
i18nMessage("i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c", {
|
||||
value0: synchronousEligibleCount,
|
||||
value1: synchronousSendLimit,
|
||||
value2: workerQueueAvailable
|
||||
? "i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e"
|
||||
: "i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396"
|
||||
}) :
|
||||
workerQueueAvailable ?
|
||||
i18nMessage("i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873", {
|
||||
value0: synchronousSendReason(synchronousSendOption)
|
||||
}) :
|
||||
i18nMessage("i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f", {
|
||||
value0: synchronousSendReason(synchronousSendOption)
|
||||
}),
|
||||
state: synchronousSendAllowed || workerQueueAvailable ? "ready" : deliveryOptionsLoading ? "info" : "blocked"
|
||||
},
|
||||
{
|
||||
label: "Sent copy",
|
||||
detail: Boolean(imapAppend.enabled) ? mailProfileSelected ? i18nMessage("i18n:govoplan-campaign.imap_append_requested_for_value0_validation_chec.51527a20", { value0: String(imapAppend.folder ?? "auto") }) : "i18n:govoplan-campaign.imap_append_is_enabled_but_no_mail_profile_is_se.cf50419e" : "i18n:govoplan-campaign.imap_append_is_disabled_for_this_campaign.7757f7f1",
|
||||
@@ -1303,6 +1469,9 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
<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>
|
||||
<div><span>i18n:govoplan-campaign.minimum_duration.91a71a6d</span><strong>{estimatedMinutes ? i18nMessage("i18n:govoplan-campaign.about_value_min.7c2e77fc", { value0: estimatedMinutes }) : "—"}</strong></div>
|
||||
<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>
|
||||
<div><span>i18n:govoplan-campaign.synchronous_limit.f88c0bcd</span><strong>{deliveryOptionsLoading ? "…" : synchronousSendLimit}</strong></div>
|
||||
<div><span>i18n:govoplan-campaign.limit_source.bd933adb</span><strong>{deliveryPolicySourceLabel(String(synchronousSendPolicy.source ?? ""))}</strong></div>
|
||||
<div><span>i18n:govoplan-campaign.worker_queue.c911e32c</span><strong>{workerQueueAvailable ? "i18n:govoplan-campaign.available.7c62a142" : "i18n:govoplan-campaign.not_configured.811931bb"}</strong></div>
|
||||
<div><span>i18n:govoplan-campaign.version.2da600bf</span><strong>{version ? `v${version.version_number}` : "—"}</strong></div>
|
||||
</div>
|
||||
<DeliverabilityPreflight items={deliverabilityPreflightItems} />
|
||||
@@ -1327,8 +1496,13 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setQueueConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || !canQueueForWorkers || !workerQueueAvailable || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued || deliveryStarted || synchronousEligibleCount <= 0}>
|
||||
{busy === "queue" ? "i18n:govoplan-campaign.queueing_for_workers_.d24584fe" : "i18n:govoplan-campaign.queue_for_workers.dae9a3e4"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => selectedDryRun ? void runSendNow() : setSendConfirmOpen(true)}
|
||||
disabled={!version || Boolean(busy) || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
|
||||
disabled={!version || Boolean(busy) || !canSendSynchronously || !synchronousSendAllowed || readOnlyVersion || !readyForDelivery || !hasBuild || !mockGateSatisfied || deliveryQueued && !directQueuedSendAllowed || deliveryStarted}>
|
||||
|
||||
{busy === "send" ?
|
||||
selectedDryRun ? "i18n:govoplan-campaign.running_dry_run.779d1f54" : "i18n:govoplan-campaign.sending.cf765512" :
|
||||
@@ -1339,6 +1513,26 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
"i18n:govoplan-campaign.send_now.dae33010"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="muted small-note">
|
||||
{i18nMessage("i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a", {
|
||||
value0: synchronousSendLimit || "i18n:govoplan-campaign.the_configured_maximum.eb10006b"
|
||||
})}
|
||||
</p>
|
||||
{!synchronousSendAllowed && canSendSynchronously &&
|
||||
<p className="review-flow-inline-note is-warning">
|
||||
{i18nMessage("i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29", {
|
||||
value0: synchronousSendReason(synchronousSendOption)
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
{queueResult &&
|
||||
<p className="review-flow-inline-note is-stale">
|
||||
{i18nMessage("i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c", {
|
||||
value0: String(queueResult.queued_count ?? 0),
|
||||
value1: String(queueResult.enqueued_count ?? 0)
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
{sendResult &&
|
||||
<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>
|
||||
@@ -1361,6 +1555,8 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
<WorkflowFact label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={sentCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.smtp_failed.0ce5516d" value={failedCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.queued_active.a2784a4a" value={queuedOrActiveCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.paused.c7dfb6f1" value={pausedQueueCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.delivery_mode.109ed9d1" value={deliveryModeLabel(persistedDeliveryMode)} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={outcomeUnknownCount} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.imap_appended.56017ea3" value={imapAppended} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.imap_pending.ed50375e" value={imapPendingForDisplay} />
|
||||
@@ -1371,6 +1567,40 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
<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>
|
||||
</div>
|
||||
{persistedDeliveryMode &&
|
||||
<p className="muted small-note">
|
||||
{persistedDeliveryModeSelectedAt
|
||||
? i18nMessage("i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3", {
|
||||
value0: deliveryModeLabel(persistedDeliveryMode),
|
||||
value1: formatDateTime(persistedDeliveryModeSelectedAt)
|
||||
})
|
||||
: i18nMessage("i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f", {
|
||||
value0: deliveryModeLabel(persistedDeliveryMode)
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
<div className="button-row compact-actions review-flow-stage-actions">
|
||||
<Button
|
||||
onClick={() => void runDeliveryControl("pause")}
|
||||
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount <= 0}>
|
||||
i18n:govoplan-campaign.pause_queued_work.35ab4a5b
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void runDeliveryControl("resume")}
|
||||
disabled={Boolean(busy) || !canControlDelivery || pausedQueueCount <= 0 || !workerQueueAvailable}>
|
||||
i18n:govoplan-campaign.resume_with_workers.510a2a9a
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCancelDeliveryConfirmOpen(true)}
|
||||
disabled={Boolean(busy) || !canControlDelivery || queuedSendCount + pausedQueueCount <= 0}>
|
||||
i18n:govoplan-campaign.cancel_unsent_work.66df9f0d
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void runRetryFailed()}
|
||||
disabled={Boolean(busy) || !canRetryDelivery || retryableCount <= 0 || !workerQueueAvailable}>
|
||||
i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5
|
||||
</Button>
|
||||
</div>
|
||||
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 &&
|
||||
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.imap_sent_append_is_still_pending_for.475700e4 {imapPendingForDisplay} i18n:govoplan-campaign.job_s_pending_with_no_imap_attempt_usually_means.0776d29f</p>
|
||||
}
|
||||
@@ -1476,15 +1706,43 @@ export default function ReviewSendPage({ settings, campaignId }: {settings: ApiS
|
||||
onCancel={() => setReviewConfirmOpen(false)} />
|
||||
|
||||
|
||||
<ConfirmDialog
|
||||
open={queueConfirmOpen}
|
||||
title="i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c"
|
||||
message={i18nMessage("i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222", {
|
||||
value0: synchronousEligibleCount,
|
||||
value1: String(version?.version_number ?? "—")
|
||||
})}
|
||||
confirmLabel="i18n:govoplan-campaign.queue_for_workers.dae9a3e4"
|
||||
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
|
||||
busy={busy === "queue"}
|
||||
onCancel={() => setQueueConfirmOpen(false)}
|
||||
onConfirm={() => void runQueueForWorkers()} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={sendConfirmOpen}
|
||||
title="i18n:govoplan-campaign.send_this_version_now.10a0ca56"
|
||||
message={i18nMessage("i18n:govoplan-campaign.this_sends_the_frozen_execution_snapshot_for_ver.1f7c53cb", { value0: String(version?.version_number ?? "—"), value1: jobsTotal, value2: builtCount, value3: buildBlocked, value4: String(attachmentSummary.total_matched_files ?? 0), value5: String(rateLimit.messages_per_minute ?? "i18n:govoplan-campaign.not_set.ef374c57"), value6: imapAppend.enabled === true ? "enabled" : "disabled", value7: version?.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: version.execution_snapshot_hash.slice(0, 12) }) : "missing" })}
|
||||
message={i18nMessage("i18n:govoplan-campaign.value0_this_is_a_synchronous_request_for_value1_.0267bc6b", {
|
||||
value0: i18nMessage("i18n:govoplan-campaign.this_sends_the_frozen_execution_snapshot_for_ver.1f7c53cb", { value0: String(version?.version_number ?? "—"), value1: jobsTotal, value2: builtCount, value3: buildBlocked, value4: String(attachmentSummary.total_matched_files ?? 0), value5: String(rateLimit.messages_per_minute ?? "i18n:govoplan-campaign.not_set.ef374c57"), value6: imapAppend.enabled === true ? "enabled" : "disabled", value7: version?.execution_snapshot_hash ? i18nMessage("i18n:govoplan-campaign.value.382bcd25", { value0: version.execution_snapshot_hash.slice(0, 12) }) : "missing" }),
|
||||
value1: synchronousEligibleCount,
|
||||
value2: synchronousSendLimit
|
||||
})}
|
||||
confirmLabel={directQueuedSendAllowed ? "i18n:govoplan-campaign.send_queued_now.bbace803" : "i18n:govoplan-campaign.send_now.dae33010"}
|
||||
tone="danger"
|
||||
busy={busy === "send"}
|
||||
onCancel={() => setSendConfirmOpen(false)}
|
||||
onConfirm={() => void runSendNow()} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={cancelDeliveryConfirmOpen}
|
||||
title="i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda"
|
||||
message="i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc"
|
||||
confirmLabel="i18n:govoplan-campaign.cancel_unsent_work.66df9f0d"
|
||||
cancelLabel="i18n:govoplan-campaign.keep_delivery_work.10dbcb13"
|
||||
tone="danger"
|
||||
busy={busy === "control"}
|
||||
onCancel={() => setCancelDeliveryConfirmOpen(false)}
|
||||
onConfirm={() => void runDeliveryControl("cancel")} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={singleSendConfirmRow !== null}
|
||||
@@ -2348,6 +2606,32 @@ function formatSingleAddress(value: unknown): string {
|
||||
return email || name;
|
||||
}
|
||||
|
||||
function synchronousSendReason(option: Record<string, unknown>): string {
|
||||
const configuredMessage = String(option.message ?? "").trim();
|
||||
if (configuredMessage) return configuredMessage;
|
||||
switch (String(option.reason ?? "")) {
|
||||
case "recipient_limit_exceeded":return "i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a";
|
||||
case "no_eligible_recipient_jobs":return "i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c";
|
||||
case "version_not_ready":return "i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc";
|
||||
case "policy_configuration_invalid":return "i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8";
|
||||
default:return "i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e";
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryControlProgressMessage(action: "pause" | "resume" | "cancel"): string {
|
||||
if (action === "pause") return "i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58";
|
||||
if (action === "resume") return "i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2";
|
||||
return "i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47";
|
||||
}
|
||||
|
||||
function deliveryPolicySourceLabel(source: string): string {
|
||||
if (source === "tenant") return "i18n:govoplan-campaign.tenant.3ca93c78";
|
||||
if (source === "deployment") return "i18n:govoplan-campaign.deployment.327a55f8";
|
||||
if (source === "deployment_default") return "i18n:govoplan-campaign.deployment_default.aa39f7b4";
|
||||
if (source === "deployment_ceiling") return "i18n:govoplan-campaign.deployment_ceiling.abbec75b";
|
||||
return "i18n:govoplan-campaign.not_available.d1a17af1";
|
||||
}
|
||||
|
||||
function numberFrom(record: Record<string, unknown>, keys: string[]): number {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
|
||||
6
webui/src/features/campaigns/utils/deliveryMode.ts
Normal file
6
webui/src/features/campaigns/utils/deliveryMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function deliveryModeLabel(mode: string | null | undefined): string {
|
||||
if (mode === "synchronous") return "i18n:govoplan-campaign.synchronous.77c61919";
|
||||
if (mode === "worker_queue") return "i18n:govoplan-campaign.worker_queue.c911e32c";
|
||||
if (mode === "database_queue") return "i18n:govoplan-campaign.database_queue.8bf98437";
|
||||
return mode ? "i18n:govoplan-campaign.unknown.bc7819b3" : "—";
|
||||
}
|
||||
58
webui/src/features/campaigns/utils/jobListQuery.ts
Normal file
58
webui/src/features/campaigns/utils/jobListQuery.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
export type CampaignJobSortColumn =
|
||||
| "number"
|
||||
| "recipient"
|
||||
| "subject"
|
||||
| "validation"
|
||||
| "queue"
|
||||
| "send"
|
||||
| "imap"
|
||||
| "attempts"
|
||||
| "updated";
|
||||
|
||||
export type CampaignJobsQueryParameters = {
|
||||
versionId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
cursor?: string | null;
|
||||
sendStatus?: string[];
|
||||
validationStatus?: string[];
|
||||
imapStatus?: string[];
|
||||
query?: string;
|
||||
sortBy?: CampaignJobSortColumn;
|
||||
sortDirection?: "asc" | "desc";
|
||||
filters?: Record<string, string>;
|
||||
since?: string | null;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const FILTER_PARAMETERS: Record<string, string> = {
|
||||
recipient: "filter_recipient",
|
||||
subject: "filter_subject",
|
||||
validation: "filter_validation",
|
||||
queue: "filter_queue",
|
||||
send: "filter_send",
|
||||
imap: "filter_imap",
|
||||
attempts: "filter_attempts",
|
||||
evidence: "filter_evidence"
|
||||
};
|
||||
|
||||
export function campaignJobsQueryParams(options: CampaignJobsQueryParameters = {}): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (options.versionId) params.set("version_id", options.versionId);
|
||||
if (options.page) params.set("page", String(options.page));
|
||||
if (options.pageSize) params.set("page_size", String(options.pageSize));
|
||||
if (options.cursor) params.set("cursor", options.cursor);
|
||||
for (const value of options.sendStatus ?? []) params.append("send_status", value);
|
||||
for (const value of options.validationStatus ?? []) params.append("validation_status", value);
|
||||
for (const value of options.imapStatus ?? []) params.append("imap_status", value);
|
||||
if (options.query?.trim()) params.set("q", options.query.trim());
|
||||
if (options.sortBy) params.set("sort_by", options.sortBy);
|
||||
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
|
||||
for (const [columnId, value] of Object.entries(options.filters ?? {})) {
|
||||
const parameter = FILTER_PARAMETERS[columnId];
|
||||
if (parameter && value.trim()) params.set(parameter, value);
|
||||
}
|
||||
if (options.since) params.set("since", options.since);
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
return params;
|
||||
}
|
||||
85
webui/src/features/campaigns/utils/reportGridShortcuts.ts
Normal file
85
webui/src/features/campaigns/utils/reportGridShortcuts.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type ReportGridQueryState = {
|
||||
sort: { columnId: string; direction: "asc" | "desc" } | null;
|
||||
filters: Record<string, string>;
|
||||
};
|
||||
|
||||
export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" as const };
|
||||
|
||||
export type ReportGridShortcutId =
|
||||
| "all"
|
||||
| "smtp_accepted"
|
||||
| "failed"
|
||||
| "outcome_unknown"
|
||||
| "not_attempted"
|
||||
| "smtp_skipped"
|
||||
| "cancelled"
|
||||
| "imap_appended"
|
||||
| "imap_failed"
|
||||
| "imap_skipped";
|
||||
|
||||
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
|
||||
all: {},
|
||||
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
|
||||
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
|
||||
outcome_unknown: { send: listFilter(["outcome_unknown"]) },
|
||||
not_attempted: { send: listFilter(["not_queued"]) },
|
||||
smtp_skipped: { send: listFilter(["skipped"]) },
|
||||
cancelled: { send: listFilter(["cancelled"]) },
|
||||
imap_appended: { imap: listFilter(["appended"]) },
|
||||
imap_failed: { imap: listFilter(["failed"]) },
|
||||
imap_skipped: { imap: listFilter(["skipped"]) }
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the complete grid query for a count shortcut. Shortcuts are exact
|
||||
* report views, so applying one intentionally clears every unrelated filter
|
||||
* and restores the stable report ordering.
|
||||
*/
|
||||
export function reportGridQueryForShortcut(shortcutId: ReportGridShortcutId): ReportGridQueryState {
|
||||
return {
|
||||
sort: { ...DEFAULT_REPORT_GRID_SORT },
|
||||
filters: { ...REPORT_GRID_SHORTCUT_FILTERS[shortcutId] }
|
||||
};
|
||||
}
|
||||
|
||||
/** Applying an already selected outcome shortcut returns to the unfiltered report. */
|
||||
export function toggleReportGridShortcut(
|
||||
current: ReportGridQueryState,
|
||||
shortcutId: ReportGridShortcutId
|
||||
): ReportGridQueryState {
|
||||
const target = reportGridQueryForShortcut(shortcutId);
|
||||
if (shortcutId !== "all" && reportGridFiltersEqual(current.filters, target.filters)) {
|
||||
return reportGridQueryForShortcut("all");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function activeReportGridShortcut(query: ReportGridQueryState): ReportGridShortcutId | null {
|
||||
const shortcutIds = Object.keys(REPORT_GRID_SHORTCUT_FILTERS) as ReportGridShortcutId[];
|
||||
return shortcutIds.find((shortcutId) => reportGridFiltersEqual(
|
||||
query.filters,
|
||||
REPORT_GRID_SHORTCUT_FILTERS[shortcutId]
|
||||
)) ?? null;
|
||||
}
|
||||
|
||||
export function reportGridQueriesEqual(left: ReportGridQueryState, right: ReportGridQueryState): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
|
||||
for (const key of keys) {
|
||||
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function listFilter(values: string[]): string {
|
||||
return `list:${JSON.stringify(values)}`;
|
||||
}
|
||||
|
||||
function reportGridFiltersEqual(left: Record<string, string>, right: Record<string, string>): boolean {
|
||||
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
|
||||
for (const key of keys) {
|
||||
if ((left[key] ?? "") !== (right[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
143
webui/src/features/operator/operatorQueueModel.ts
Normal file
143
webui/src/features/operator/operatorQueueModel.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
export type OperatorQueueAction =
|
||||
| "details"
|
||||
| "report"
|
||||
| "pause"
|
||||
| "resume"
|
||||
| "retry"
|
||||
| "queue-unsent"
|
||||
| "reconcile"
|
||||
| "cancel";
|
||||
|
||||
export type OperatorQueuePermissions = {
|
||||
canRead: boolean;
|
||||
canOpenReport: boolean;
|
||||
canControl: boolean;
|
||||
canRetry: boolean;
|
||||
canQueue: boolean;
|
||||
canReconcile: boolean;
|
||||
};
|
||||
|
||||
export type OperatorQueueFacts = {
|
||||
retryable: number;
|
||||
outcomeUnknown: number;
|
||||
queueableUnattempted: number;
|
||||
pausable: number;
|
||||
paused: number;
|
||||
cancellable: number;
|
||||
};
|
||||
|
||||
export const OPERATOR_QUEUE_REASON = {
|
||||
permissionDenied: "i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6",
|
||||
noQueued: "i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e",
|
||||
noPaused: "i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275",
|
||||
noFailed: "i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9",
|
||||
noUnsent: "i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87",
|
||||
noUnknown: "i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c",
|
||||
noCancellable: "i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49"
|
||||
} as const;
|
||||
|
||||
export type CampaignVersionWorkItem<T> = {
|
||||
campaignId: string;
|
||||
versionId: string;
|
||||
versionNumber: number;
|
||||
isCurrent: boolean;
|
||||
summary: T | null;
|
||||
};
|
||||
|
||||
/** Preserve every audit version so work does not disappear when a new draft becomes current. */
|
||||
export function campaignVersionWorkItems<T>(
|
||||
campaignId: string,
|
||||
currentVersionId: string | null | undefined,
|
||||
versions: Array<{id: string;version_number: number;}>,
|
||||
summaries: Record<string, T | null>
|
||||
): CampaignVersionWorkItem<T>[] {
|
||||
return versions.map((version) => ({
|
||||
campaignId,
|
||||
versionId: version.id,
|
||||
versionNumber: version.version_number,
|
||||
isCurrent: version.id === currentVersionId,
|
||||
summary: summaries[version.id] ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
export type CampaignLifecycleTotals = {pausable: number;paused: number;cancellable: number;};
|
||||
|
||||
export function campaignLifecycleTotals(items: CampaignLifecycleTotals[]): CampaignLifecycleTotals {
|
||||
return items.reduce((totals, item) => ({
|
||||
pausable: totals.pausable + item.pausable,
|
||||
paused: totals.paused + item.paused,
|
||||
cancellable: totals.cancellable + item.cancellable
|
||||
}), { pausable: 0, paused: 0, cancellable: 0 });
|
||||
}
|
||||
|
||||
export function operatorDeliveryStateCounts(
|
||||
queue: {queued?: number;},
|
||||
// send.queued intentionally does not contribute: paused queue rows retain that send state.
|
||||
send: {queued?: number;claimed?: number;sending?: number;smtpAccepted?: number;sent?: number;}
|
||||
) {
|
||||
const queued = queue.queued ?? 0;
|
||||
const claimed = send.claimed ?? 0;
|
||||
const sending = send.sending ?? 0;
|
||||
const completed = (send.smtpAccepted ?? 0) + (send.sent ?? 0);
|
||||
return { queued, claimed, sending, completed, queuedOrActive: queued + claimed + sending };
|
||||
}
|
||||
|
||||
export function shouldRefreshOperatorVersionSummary(input: {
|
||||
fullDiscovery: boolean;
|
||||
versionChanged: boolean;
|
||||
selected: boolean;
|
||||
cached: boolean;
|
||||
queuedOrActive: number;
|
||||
needsAttention: number;
|
||||
}): boolean {
|
||||
return input.fullDiscovery
|
||||
|| input.versionChanged
|
||||
|| !input.cached
|
||||
|| input.selected
|
||||
|| input.queuedOrActive > 0
|
||||
|| input.needsAttention > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the reason each fixed queue action is unavailable. A null result means
|
||||
* the action is permitted and meaningful for the current queue projection.
|
||||
*/
|
||||
export function operatorQueueActionBlocks(
|
||||
facts: OperatorQueueFacts,
|
||||
permissions: OperatorQueuePermissions
|
||||
): Record<OperatorQueueAction, string | null> {
|
||||
return {
|
||||
details: permissions.canRead ? null : OPERATOR_QUEUE_REASON.permissionDenied,
|
||||
report: permissions.canOpenReport ? null : OPERATOR_QUEUE_REASON.permissionDenied,
|
||||
pause: !permissions.canControl
|
||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||
: facts.pausable > 0
|
||||
? null
|
||||
: OPERATOR_QUEUE_REASON.noQueued,
|
||||
resume: !permissions.canControl
|
||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||
: facts.paused > 0
|
||||
? null
|
||||
: OPERATOR_QUEUE_REASON.noPaused,
|
||||
retry: !permissions.canRetry
|
||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||
: facts.retryable > 0
|
||||
? null
|
||||
: OPERATOR_QUEUE_REASON.noFailed,
|
||||
"queue-unsent": !permissions.canQueue
|
||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||
: facts.queueableUnattempted > 0
|
||||
? null
|
||||
: OPERATOR_QUEUE_REASON.noUnsent,
|
||||
reconcile: !permissions.canReconcile
|
||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||
: facts.outcomeUnknown > 0
|
||||
? null
|
||||
: OPERATOR_QUEUE_REASON.noUnknown,
|
||||
cancel: !permissions.canControl
|
||||
? OPERATOR_QUEUE_REASON.permissionDenied
|
||||
: facts.cancellable > 0
|
||||
? null
|
||||
: OPERATOR_QUEUE_REASON.noCancellable
|
||||
};
|
||||
}
|
||||
267
webui/src/features/reports/AggregateReportsPage.tsx
Normal file
267
webui/src/features/reports/AggregateReportsPage.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, RefreshCw } from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
PageTitle,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
formatDateTime,
|
||||
i18nMessage,
|
||||
type ApiSettings,
|
||||
type DataGridColumn,
|
||||
type DataGridListOption
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
getAggregateCampaignReport,
|
||||
listAggregateReportCampaigns,
|
||||
type AggregateCampaignReport,
|
||||
type AggregateReportCampaignListItem,
|
||||
type AggregateReportCount
|
||||
} from "../../api/campaigns";
|
||||
|
||||
const CAMPAIGN_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
{ value: "draft", label: "i18n:govoplan-campaign.draft.23d33e22" },
|
||||
{ value: "validated", label: "i18n:govoplan-campaign.valid.a4aefa35" },
|
||||
{ value: "needs_review", label: "i18n:govoplan-campaign.needs_review.33a506cf" },
|
||||
{ value: "ready_to_queue", label: "i18n:govoplan-campaign.ready.20c7c552" },
|
||||
{ value: "queued", label: "i18n:govoplan-campaign.queued.6a599877" },
|
||||
{ value: "sending", label: "i18n:govoplan-campaign.sending.cf765512" },
|
||||
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
|
||||
{ value: "partially_completed", label: "i18n:govoplan-campaign.partially_completed.760bc5e6" },
|
||||
{ value: "outcome_unknown", label: "i18n:govoplan-campaign.outcome_unknown.6e929fca" },
|
||||
{ value: "failed", label: "i18n:govoplan-campaign.failed.09fef5d8" },
|
||||
{ value: "cancelled", label: "i18n:govoplan-campaign.cancelled.a1bf92ef" },
|
||||
{ value: "archived", label: "i18n:govoplan-campaign.archived.eddc813f" }
|
||||
];
|
||||
|
||||
export default function AggregateReportsPage({ settings }: {settings: ApiSettings;}) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedFromUrl = searchParams.get("campaign") ?? "";
|
||||
const [campaigns, setCampaigns] = useState<AggregateReportCampaignListItem[]>([]);
|
||||
const [report, setReport] = useState<AggregateCampaignReport | null>(null);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
const [reportLoading, setReportLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const reportRequest = useRef(0);
|
||||
|
||||
const selectCampaign = useCallback((campaignId: string) => {
|
||||
setSearchParams(campaignId ? { campaign: campaignId } : {}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const loadCampaigns = useCallback(async () => {
|
||||
setListLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const rows = await listAggregateReportCampaigns(settings);
|
||||
setCampaigns(rows);
|
||||
const requested = selectedFromUrl && rows.some((row) => row.id === selectedFromUrl)
|
||||
? selectedFromUrl
|
||||
: rows[0]?.id ?? "";
|
||||
if (requested !== selectedFromUrl) selectCampaign(requested);
|
||||
if (!requested) setReport(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setCampaigns([]);
|
||||
setReport(null);
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, [settings, selectedFromUrl, selectCampaign]);
|
||||
|
||||
const loadReport = useCallback(async (campaignId: string) => {
|
||||
if (!campaignId) {
|
||||
setReport(null);
|
||||
return;
|
||||
}
|
||||
const requestId = ++reportRequest.current;
|
||||
setReportLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await getAggregateCampaignReport(settings, campaignId);
|
||||
if (requestId === reportRequest.current) setReport(next);
|
||||
} catch (err) {
|
||||
if (requestId === reportRequest.current) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setReport(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === reportRequest.current) setReportLoading(false);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCampaigns();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listLoading && selectedFromUrl && campaigns.some((row) => row.id === selectedFromUrl)) {
|
||||
void loadReport(selectedFromUrl);
|
||||
}
|
||||
}, [campaigns, listLoading, loadReport, selectedFromUrl]);
|
||||
|
||||
const columns = useMemo<DataGridColumn<AggregateReportCampaignListItem>[]>(() => [
|
||||
{
|
||||
id: "campaign",
|
||||
header: "i18n:govoplan-campaign.campaign.69390e16",
|
||||
width: "minmax(260px, 1fr)",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
sticky: "start",
|
||||
render: (campaign) => (
|
||||
<div>
|
||||
<strong>{campaign.name}</strong>
|
||||
</div>
|
||||
),
|
||||
value: (campaign) => campaign.name
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "i18n:govoplan-campaign.status.bae7d5be",
|
||||
width: 170,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
columnType: "from-list",
|
||||
list: { options: CAMPAIGN_STATUS_OPTIONS, display: "pill" },
|
||||
render: (campaign) => <StatusBadge status={campaign.status} />,
|
||||
value: (campaign) => campaign.status
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
header: "i18n:govoplan-campaign.updated.f2f8570d",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
filterType: "date",
|
||||
render: (campaign) => formatDateTime(campaign.updated_at),
|
||||
value: (campaign) => campaign.updated_at
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "i18n:govoplan-campaign.actions.c3cd636a",
|
||||
width: 72,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (campaign) => (
|
||||
<TableActionGroup actions={[{
|
||||
id: "view-aggregate-report",
|
||||
label: "i18n:govoplan-campaign.view_aggregate_report.94b42124",
|
||||
icon: <Eye aria-hidden="true" />,
|
||||
variant: campaign.id === selectedFromUrl ? "primary" : "secondary",
|
||||
disabled: campaign.id === selectedFromUrl,
|
||||
disabledReason: campaign.id === selectedFromUrl ? "i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4" : undefined,
|
||||
onClick: () => selectCampaign(campaign.id)
|
||||
}]} />
|
||||
)
|
||||
}
|
||||
], [selectCampaign, selectedFromUrl]);
|
||||
|
||||
const threshold = report?.privacy.small_cell_threshold ?? 5;
|
||||
const outcomes = report?.outcomes;
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
<PageTitle loading={listLoading || reportLoading}>i18n:govoplan-campaign.reports.88bc3fe3</PageTitle>
|
||||
<p className="muted">i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button
|
||||
onClick={() => void Promise.all([loadCampaigns(), selectedFromUrl ? loadReport(selectedFromUrl) : Promise.resolve()])}
|
||||
disabled={listLoading || reportLoading}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-campaign.refresh.56e3badc
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
<Card title="i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403">
|
||||
<LoadingFrame loading={listLoading} label="i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8">
|
||||
<DataGrid
|
||||
id="campaign-aggregate-report-list"
|
||||
rows={campaigns}
|
||||
columns={columns}
|
||||
getRowKey={(campaign) => campaign.id}
|
||||
initialSort={{ columnId: "updated", direction: "desc" }}
|
||||
emptyText="i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781"
|
||||
/>
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
|
||||
<LoadingFrame loading={reportLoading} label="i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091">
|
||||
{report && outcomes && (
|
||||
<>
|
||||
<Card title={report.campaign.name}>
|
||||
<dl className="detail-list">
|
||||
<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.version.2da600bf</dt><dd>{report.version_number ?? "—"}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(report.generated_at)}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Aggregate outcome cards are deliberately not filter shortcuts: each subgroup
|
||||
would require a separately suppressed aggregate response from the server. */}
|
||||
<div className="dashboard-grid">
|
||||
<MetricCard label="i18n:govoplan-campaign.smtp_accepted.e3aa7603" value={countValue(outcomes.smtp_accepted)} tone="good" />
|
||||
<MetricCard label="i18n:govoplan-campaign.failed.09fef5d8" value={countValue(outcomes.failed)} tone="danger" />
|
||||
<MetricCard label="i18n:govoplan-campaign.outcome_unknown.6e929fca" value={countValue(outcomes.outcome_unknown)} tone="warning" />
|
||||
<MetricCard label="i18n:govoplan-campaign.queued_or_active.afdcd7da" value={countValue(outcomes.queued_or_active)} tone="info" />
|
||||
<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.excluded.9804952b" value={countValue(outcomes.excluded)} />
|
||||
</div>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.population_and_exclusions.cdff62ad">
|
||||
<dl className="detail-list">
|
||||
<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.excluded_or_blocked_jobs.e2190d47</dt><dd>{countValue(report.population.excluded_or_blocked_jobs)}</dd></div>
|
||||
</dl>
|
||||
<p className="muted">i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc</p>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.delivery_activity_range.0d1b62ec">
|
||||
<dl className="detail-list">
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.privacy_boundary.d37e12bc">
|
||||
<p>i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093</p>
|
||||
<p className="muted">
|
||||
{i18nMessage("i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6", { value0: threshold })}
|
||||
</p>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countValue(count: AggregateReportCount): string | number {
|
||||
return count.suppressed ? "i18n:govoplan-campaign.suppressed.fce88db5" : count.value ?? "—";
|
||||
}
|
||||
|
||||
function aggregateCompletionLabel(state: AggregateCampaignReport["completion_state"]): string {
|
||||
const labels: Record<AggregateCampaignReport["completion_state"], string> = {
|
||||
not_started: "i18n:govoplan-campaign.not_started.db2c45d5",
|
||||
in_progress: "i18n:govoplan-campaign.in_progress.b6bd42e4",
|
||||
completed: "i18n:govoplan-campaign.completed.1798b3ba",
|
||||
partially_completed: "i18n:govoplan-campaign.partially_completed.760bc5e6",
|
||||
incomplete: "i18n:govoplan-campaign.incomplete.387fd1bb",
|
||||
outcome_unknown: "i18n:govoplan-campaign.outcome_unknown.6e929fca",
|
||||
suppressed: "i18n:govoplan-campaign.suppressed.fce88db5"
|
||||
};
|
||||
return labels[state];
|
||||
}
|
||||
@@ -4,6 +4,117 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||
"i18n:govoplan-campaign.appended.979f5a82": "Appended",
|
||||
"i18n:govoplan-campaign.appending.167de6b2": "Appending",
|
||||
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Another queue action is still running.",
|
||||
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Cancel eligible queued work?",
|
||||
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Cancel all eligible queued work for this Campaign",
|
||||
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Cancel queued work",
|
||||
"i18n:govoplan-campaign.claimed.83c87884": "Claimed",
|
||||
"i18n:govoplan-campaign.current.4fc0e2bc": "Current",
|
||||
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Delivery jobs for {value0}",
|
||||
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Eligible jobs that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
|
||||
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Eligible jobs in every version of this Campaign that have not started will be cancelled. Accepted, sending, and outcome-unknown jobs remain protected.",
|
||||
"i18n:govoplan-campaign.historical.bb2fe205": "Historical",
|
||||
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "No cancellable queued work remains.",
|
||||
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "No failed jobs are eligible for retry.",
|
||||
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "No paused jobs can be resumed.",
|
||||
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "No queued jobs can be paused.",
|
||||
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "No uncertain outcomes require reconciliation.",
|
||||
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "No unsent jobs are available to queue.",
|
||||
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Not requested",
|
||||
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Open recipient report",
|
||||
"i18n:govoplan-campaign.pause_queue.b66e7696": "Pause queue",
|
||||
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Pause all queued work for this Campaign",
|
||||
"i18n:govoplan-campaign.paused.c7dfb6f1": "Paused",
|
||||
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Queue action for {value0} completed: {value1} ({value2}).",
|
||||
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Queue refreshes automatically while delivery is active.",
|
||||
"i18n:govoplan-campaign.queued_at.1ac179b3": "Queued at",
|
||||
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Reconcile outcomes",
|
||||
"i18n:govoplan-campaign.resume_queue.1f22f964": "Resume queue",
|
||||
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Resume all paused work for this Campaign",
|
||||
"i18n:govoplan-campaign.sending.ceafde86": "Sending",
|
||||
"i18n:govoplan-campaign.sent_at.a6f45556": "Sent at",
|
||||
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Temporarily failed",
|
||||
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Permanently failed",
|
||||
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "This Campaign queue is already selected.",
|
||||
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "You do not have permission for this action.",
|
||||
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Campaign reports available to you",
|
||||
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "Cancelled {value0} unsent message(s); {value1} protected outcome(s) and {value2} excluded/skipped message(s) were retained.",
|
||||
"i18n:govoplan-campaign.completion.2ff25568": "Completion",
|
||||
"i18n:govoplan-campaign.database_queue.8bf98437": "Database queue",
|
||||
"i18n:govoplan-campaign.delivery_activity_range.0d1b62ec": "Delivery activity range",
|
||||
"i18n:govoplan-campaign.delivery_mode.109ed9d1": "Delivery mode",
|
||||
"i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47": "Excluded or blocked jobs",
|
||||
"i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00": "Excluded rows are intentionally omitted from delivery. Their SMTP and IMAP states are shown as Skipped because no transport effect is attempted; use the separate status filters to isolate them.",
|
||||
"i18n:govoplan-campaign.first_activity.3fe36ef4": "First activity",
|
||||
"i18n:govoplan-campaign.in_progress.b6bd42e4": "In progress",
|
||||
"i18n:govoplan-campaign.inactive_source_entries.88fa0cf8": "Inactive source entries",
|
||||
"i18n:govoplan-campaign.incomplete.387fd1bb": "Incomplete",
|
||||
"i18n:govoplan-campaign.last_activity.1c73b806": "Last activity",
|
||||
"i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091": "Loading aggregate Campaign report…",
|
||||
"i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8": "Loading Campaign reports…",
|
||||
"i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781": "No aggregate Campaign reports are available to you.",
|
||||
"i18n:govoplan-campaign.not_started.db2c45d5": "Not started",
|
||||
"i18n:govoplan-campaign.partially_completed.760bc5e6": "Partially completed",
|
||||
"i18n:govoplan-campaign.population_and_exclusions.cdff62ad": "Population and exclusions",
|
||||
"i18n:govoplan-campaign.privacy_boundary.d37e12bc": "Privacy boundary",
|
||||
"i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d": "Privacy-protected Campaign outcomes without recipient or delivery diagnostics.",
|
||||
"i18n:govoplan-campaign.queued_or_active.afdcd7da": "Queued or active",
|
||||
"i18n:govoplan-campaign.report_denominator.d089074a": "Report denominator",
|
||||
"i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6": "Small-cell threshold: {value0}. Suppressed values cannot be opened, filtered, exported, or inspected from this view.",
|
||||
"i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19": "SMTP skipped (excluded)",
|
||||
"i18n:govoplan-campaign.suppressed.fce88db5": "Suppressed",
|
||||
"i18n:govoplan-campaign.synchronous.77c61919": "Synchronous",
|
||||
"i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4": "This report is already selected.",
|
||||
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3": "This version last entered {value0} mode at {value1}. This record remains available after leaving and returning.",
|
||||
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f": "This version last entered {value0} mode. This record remains available after leaving and returning.",
|
||||
"i18n:govoplan-campaign.view_aggregate_report.94b42124": "View aggregate report",
|
||||
"i18n:govoplan-campaign.worker_queue.c911e32c": "Worker queue",
|
||||
"i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc": "All persisted recipient delivery jobs for the selected campaign version, including excluded or blocked jobs. Inactive source entries without a job record are excluded and reported separately.",
|
||||
"i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396": "Background workers are not configured.",
|
||||
"i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda": "Cancel all unsent delivery work?",
|
||||
"i18n:govoplan-campaign.cancel_unsent_work.66df9f0d": "Cancel unsent work",
|
||||
"i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47": "Cancelling eligible delivery jobs…",
|
||||
"i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2": "Committing the reviewed execution to the background-worker queue…",
|
||||
"i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e": "delivery options are still being evaluated",
|
||||
"i18n:govoplan-campaign.deployment.327a55f8": "Deployment",
|
||||
"i18n:govoplan-campaign.deployment_ceiling.abbec75b": "Deployment ceiling",
|
||||
"i18n:govoplan-campaign.deployment_default.aa39f7b4": "Deployment default",
|
||||
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Keep delivery work",
|
||||
"i18n:govoplan-campaign.limit_source.bd933adb": "Limit source",
|
||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Loading the effective delivery policy…",
|
||||
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "No real delivery mode is currently available ({value0}).",
|
||||
"i18n:govoplan-campaign.not_available.d1a17af1": "Not available",
|
||||
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Pause queued work",
|
||||
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "Paused {value0} queued message(s).",
|
||||
"i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58": "Pausing eligible delivery jobs…",
|
||||
"i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093": "Positive counts below the threshold are hidden. At least one additional count or the denominator is hidden when needed to prevent subtraction.",
|
||||
"i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8": "Queued {value0} message(s); {value1} worker task(s) published.",
|
||||
"i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb": "Queued {value0} retryable message(s); {value1} worker task(s) published.",
|
||||
"i18n:govoplan-campaign.queue_for_workers.dae9a3e4": "Queue for workers",
|
||||
"i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a": "Queue for workers commits durable jobs and returns immediately; progress remains visible here after you leave. Send now keeps this request open and is limited to {value0} eligible recipient jobs.",
|
||||
"i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c": "Queue this version for background workers?",
|
||||
"i18n:govoplan-campaign.queueing_for_workers_.d24584fe": "Queueing for workers…",
|
||||
"i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9": "Queueing retryable failed messages for background workers…",
|
||||
"i18n:govoplan-campaign.resume_with_workers.510a2a9a": "Resume with workers",
|
||||
"i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4": "Resumed {value0} message(s); {value1} worker task(s) published.",
|
||||
"i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2": "Resuming eligible delivery jobs…",
|
||||
"i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5": "Retry failed with workers",
|
||||
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "Send now is available for {value0} eligible message(s), within the effective limit of {value1}. {value2}",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "Send now is unavailable: {value0}.",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "Send now is unavailable ({value0}). Queue for workers remains available.",
|
||||
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Synchronous limit",
|
||||
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "the built run has no eligible message",
|
||||
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "the configured maximum",
|
||||
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "the delivery policy configuration is invalid",
|
||||
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "the exact built run exceeds the effective limit for recipient jobs",
|
||||
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "The worker queue is also available.",
|
||||
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "This cancels queued or paused messages that have not crossed the SMTP boundary. Accepted, active, or outcome-unknown messages remain protected for audit and reconciliation.",
|
||||
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "This commits {value0} eligible message(s) from version {value1} to durable worker jobs. You may leave this page and return to the same progress and recovery state.",
|
||||
"i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc": "validate, lock, build, and review the current version first",
|
||||
"i18n:govoplan-campaign.value0_this_is_a_synchronous_request_for_value1_.0267bc6b": "{value0} This is a synchronous request for {value1} eligible message(s), within the effective limit of {value2}. All messages are preflighted before SMTP is contacted.",
|
||||
"i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c": "Worker queue committed {value0} message(s) and published {value1} task(s). Refreshing progress does not requeue them.",
|
||||
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Campaign-scoped Mail profile",
|
||||
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign stores only this stable profile reference. The Mail module owns, encrypts, authorizes, tests, and resolves all SMTP/IMAP settings and credentials.",
|
||||
"i18n:govoplan-campaign.configured.668c5fff": "Configured",
|
||||
@@ -1176,6 +1287,117 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"de": {
|
||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
|
||||
"i18n:govoplan-campaign.appending.167de6b2": "Wird abgelegt",
|
||||
"i18n:govoplan-campaign.another_queue_action_is_still_running.4985baea": "Eine andere Warteschlangenaktion wird noch ausgeführt.",
|
||||
"i18n:govoplan-campaign.cancel_eligible_queued_work.e280192e": "Geeignete Warteschlangenaufträge abbrechen?",
|
||||
"i18n:govoplan-campaign.cancel_all_eligible_queued_work_for_this_campaign.1455825b": "Alle geeigneten Warteschlangenaufträge dieser Kampagne abbrechen",
|
||||
"i18n:govoplan-campaign.cancel_queued_work.a54fb955": "Warteschlangenaufträge abbrechen",
|
||||
"i18n:govoplan-campaign.claimed.83c87884": "Beansprucht",
|
||||
"i18n:govoplan-campaign.current.4fc0e2bc": "Aktuell",
|
||||
"i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70": "Versandaufträge für {value0}",
|
||||
"i18n:govoplan-campaign.eligible_jobs_that_have_not_started_will_be_cancelled_.00096ddb": "Geeignete Aufträge, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
|
||||
"i18n:govoplan-campaign.eligible_jobs_in_every_version_of_this_campaign_tha.47b67f34": "Geeignete Aufträge in allen Versionen dieser Kampagne, die noch nicht begonnen haben, werden abgebrochen. Angenommene, laufende und ungeklärte Aufträge bleiben geschützt.",
|
||||
"i18n:govoplan-campaign.historical.bb2fe205": "Historisch",
|
||||
"i18n:govoplan-campaign.no_cancellable_queued_work_remains.80e31d49": "Es sind keine abbrechbaren Warteschlangenaufträge mehr vorhanden.",
|
||||
"i18n:govoplan-campaign.no_failed_jobs_are_eligible_for_retry.e65a69f9": "Keine fehlgeschlagenen Aufträge können erneut versucht werden.",
|
||||
"i18n:govoplan-campaign.no_paused_jobs_can_be_resumed.869b6275": "Es sind keine pausierten Aufträge zum Fortsetzen vorhanden.",
|
||||
"i18n:govoplan-campaign.no_queued_jobs_can_be_paused.acaa0b4e": "Es sind keine wartenden Aufträge zum Pausieren vorhanden.",
|
||||
"i18n:govoplan-campaign.no_uncertain_outcomes_require_reconciliation.933bc53c": "Es müssen keine ungeklärten Ergebnisse abgestimmt werden.",
|
||||
"i18n:govoplan-campaign.no_unsent_jobs_are_available_to_queue.585bfe87": "Es sind keine ungesendeten Aufträge für die Warteschlange verfügbar.",
|
||||
"i18n:govoplan-campaign.not_requested.a0f3bce4": "Nicht angefordert",
|
||||
"i18n:govoplan-campaign.open_recipient_report.d6892ee4": "Empfängerbezogenen Bericht öffnen",
|
||||
"i18n:govoplan-campaign.pause_queue.b66e7696": "Warteschlange pausieren",
|
||||
"i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf": "Alle eingereihten Aufträge dieser Kampagne pausieren",
|
||||
"i18n:govoplan-campaign.paused.c7dfb6f1": "Pausiert",
|
||||
"i18n:govoplan-campaign.queue_action_for_value_completed_value_value.d799ce56": "Warteschlangenaktion für {value0} abgeschlossen: {value1} ({value2}).",
|
||||
"i18n:govoplan-campaign.queue_refreshes_automatically_while_delivery_is_active.df8dc205": "Die Warteschlange wird während eines laufenden Versands automatisch aktualisiert.",
|
||||
"i18n:govoplan-campaign.queued_at.1ac179b3": "Eingereiht am",
|
||||
"i18n:govoplan-campaign.reconcile_outcomes.bd81d8a7": "Ergebnisse abstimmen",
|
||||
"i18n:govoplan-campaign.resume_queue.1f22f964": "Warteschlange fortsetzen",
|
||||
"i18n:govoplan-campaign.resume_all_paused_work_for_this_campaign.69b2aa6a": "Alle pausierten Aufträge dieser Kampagne fortsetzen",
|
||||
"i18n:govoplan-campaign.sending.ceafde86": "Im Versand",
|
||||
"i18n:govoplan-campaign.sent_at.a6f45556": "Gesendet am",
|
||||
"i18n:govoplan-campaign.temporarily_failed.50d9b1a8": "Vorübergehend fehlgeschlagen",
|
||||
"i18n:govoplan-campaign.permanently_failed.32eda23a": "Endgültig fehlgeschlagen",
|
||||
"i18n:govoplan-campaign.this_campaign_queue_is_already_selected_.211ab9d8": "Diese Kampagnenwarteschlange ist bereits ausgewählt.",
|
||||
"i18n:govoplan-campaign.you_do_not_have_permission_for_this_action.dfcfbad6": "Sie haben keine Berechtigung für diese Aktion.",
|
||||
"i18n:govoplan-campaign.campaign_reports_available_to_you.f14fa403": "Für Sie verfügbare Kampagnenberichte",
|
||||
"i18n:govoplan-campaign.cancelled_value0_unsent_message_s_value1_protect.d24c5e24": "{value0} ungesendete Nachricht(en) wurden abgebrochen; {value1} geschützte Ergebnisse und {value2} ausgeschlossene/übersprungene Nachricht(en) wurden beibehalten.",
|
||||
"i18n:govoplan-campaign.completion.2ff25568": "Abschluss",
|
||||
"i18n:govoplan-campaign.database_queue.8bf98437": "Datenbank-Warteschlange",
|
||||
"i18n:govoplan-campaign.delivery_activity_range.0d1b62ec": "Zeitraum der Versandaktivität",
|
||||
"i18n:govoplan-campaign.delivery_mode.109ed9d1": "Versandmodus",
|
||||
"i18n:govoplan-campaign.excluded_or_blocked_jobs.e2190d47": "Ausgeschlossene oder blockierte Aufträge",
|
||||
"i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00": "Ausgeschlossene Zeilen werden absichtlich nicht versendet. Ihr SMTP- und IMAP-Status wird als „Übersprungen“ angezeigt, da kein Transport versucht wird; über die separaten Statusfilter können Sie diese Zeilen gezielt anzeigen.",
|
||||
"i18n:govoplan-campaign.first_activity.3fe36ef4": "Erste Aktivität",
|
||||
"i18n:govoplan-campaign.in_progress.b6bd42e4": "In Bearbeitung",
|
||||
"i18n:govoplan-campaign.inactive_source_entries.88fa0cf8": "Inaktive Quelleinträge",
|
||||
"i18n:govoplan-campaign.incomplete.387fd1bb": "Unvollständig",
|
||||
"i18n:govoplan-campaign.last_activity.1c73b806": "Letzte Aktivität",
|
||||
"i18n:govoplan-campaign.loading_aggregate_campaign_report_.72f2f091": "Aggregierter Kampagnenbericht wird geladen…",
|
||||
"i18n:govoplan-campaign.loading_campaign_reports_.61ec1ee8": "Kampagnenberichte werden geladen…",
|
||||
"i18n:govoplan-campaign.no_aggregate_campaign_reports_are_available_to_y.dafb2781": "Für Sie sind keine aggregierten Kampagnenberichte verfügbar.",
|
||||
"i18n:govoplan-campaign.not_started.db2c45d5": "Nicht begonnen",
|
||||
"i18n:govoplan-campaign.partially_completed.760bc5e6": "Teilweise abgeschlossen",
|
||||
"i18n:govoplan-campaign.population_and_exclusions.cdff62ad": "Grundgesamtheit und Ausschlüsse",
|
||||
"i18n:govoplan-campaign.privacy_boundary.d37e12bc": "Datenschutzgrenze",
|
||||
"i18n:govoplan-campaign.privacy_protected_campaign_outcomes_without_reci.5b8a100d": "Datenschutzgeschützte Kampagnenergebnisse ohne Empfänger- oder Versanddiagnosen.",
|
||||
"i18n:govoplan-campaign.queued_or_active.afdcd7da": "In Warteschlange oder aktiv",
|
||||
"i18n:govoplan-campaign.report_denominator.d089074a": "Bezugsgröße des Berichts",
|
||||
"i18n:govoplan-campaign.small_cell_threshold_value0_suppressed_values_ca.d73f87a6": "Schwellenwert für kleine Fallzahlen: {value0}. Unterdrückte Werte können in dieser Ansicht nicht geöffnet, gefiltert, exportiert oder geprüft werden.",
|
||||
"i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19": "SMTP übersprungen (ausgeschlossen)",
|
||||
"i18n:govoplan-campaign.suppressed.fce88db5": "Unterdrückt",
|
||||
"i18n:govoplan-campaign.synchronous.77c61919": "Synchron",
|
||||
"i18n:govoplan-campaign.this_report_is_already_selected_.983c5be4": "Dieser Bericht ist bereits ausgewählt.",
|
||||
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_at_value1_.c087d2f3": "Diese Version wechselte zuletzt am {value1} in den Modus „{value0}“. Der Eintrag bleibt auch nach dem Verlassen und erneuten Öffnen verfügbar.",
|
||||
"i18n:govoplan-campaign.this_version_last_entered_value0_mode_this_recor.ea2f201f": "Diese Version wechselte zuletzt in den Modus „{value0}“. Der Eintrag bleibt auch nach dem Verlassen und erneuten Öffnen verfügbar.",
|
||||
"i18n:govoplan-campaign.view_aggregate_report.94b42124": "Aggregierten Bericht anzeigen",
|
||||
"i18n:govoplan-campaign.worker_queue.c911e32c": "Worker-Warteschlange",
|
||||
"i18n:govoplan-campaign.all_persisted_recipient_delivery_jobs_for_the_se.208e90cc": "Alle dauerhaft gespeicherten Empfänger-Versandaufträge der ausgewählten Kampagnenversion werden berücksichtigt, einschließlich ausgeschlossener oder blockierter Aufträge. Inaktive Quelleinträge ohne Auftragsdatensatz sind ausgeschlossen und werden separat ausgewiesen.",
|
||||
"i18n:govoplan-campaign.background_workers_are_not_configured_.efa72396": "Hintergrund-Worker sind nicht konfiguriert.",
|
||||
"i18n:govoplan-campaign.cancel_all_unsent_delivery_work_.a2f56cda": "Alle ungesendeten Versandaufträge abbrechen?",
|
||||
"i18n:govoplan-campaign.cancel_unsent_work.66df9f0d": "Ungesendete Aufträge abbrechen",
|
||||
"i18n:govoplan-campaign.cancelling_eligible_delivery_jobs_.4090fa47": "Geeignete Versandaufträge werden abgebrochen…",
|
||||
"i18n:govoplan-campaign.committing_the_reviewed_execution_to_the_backgro.6ae349e2": "Die geprüfte Ausführung wird an die Hintergrund-Worker-Warteschlange übergeben…",
|
||||
"i18n:govoplan-campaign.delivery_options_are_still_being_evaluated.8395096e": "die Versandoptionen werden noch ausgewertet",
|
||||
"i18n:govoplan-campaign.deployment.327a55f8": "Bereitstellung",
|
||||
"i18n:govoplan-campaign.deployment_ceiling.abbec75b": "Bereitstellungsobergrenze",
|
||||
"i18n:govoplan-campaign.deployment_default.aa39f7b4": "Bereitstellungsstandard",
|
||||
"i18n:govoplan-campaign.keep_delivery_work.10dbcb13": "Versandaufträge beibehalten",
|
||||
"i18n:govoplan-campaign.limit_source.bd933adb": "Quelle des Grenzwerts",
|
||||
"i18n:govoplan-campaign.loading_the_effective_delivery_policy_.d6893011": "Wirksame Versandrichtlinie wird geladen…",
|
||||
"i18n:govoplan-campaign.no_real_delivery_mode_is_currently_available_val.618cce1f": "Derzeit ist kein echter Versandmodus verfügbar ({value0}).",
|
||||
"i18n:govoplan-campaign.not_available.d1a17af1": "Nicht verfügbar",
|
||||
"i18n:govoplan-campaign.pause_queued_work.35ab4a5b": "Wartende Aufträge pausieren",
|
||||
"i18n:govoplan-campaign.paused_value0_queued_message_s_.c7d568d2": "{value0} wartende Nachricht(en) pausiert.",
|
||||
"i18n:govoplan-campaign.pausing_eligible_delivery_jobs_.eb6b9d58": "Geeignete Versandaufträge werden pausiert…",
|
||||
"i18n:govoplan-campaign.positive_counts_below_the_threshold_are_hidden_a.1dc97093": "Positive Anzahlen unterhalb des Schwellenwerts werden ausgeblendet. Falls erforderlich, wird mindestens eine weitere Anzahl oder die Bezugsgröße ausgeblendet, damit keine Rückrechnung möglich ist.",
|
||||
"i18n:govoplan-campaign.queued_value0_message_s_value1_worker_task_s_pub.18d67ec8": "{value0} Nachricht(en) wurden eingereiht; {value1} Worker-Aufgabe(n) wurden veröffentlicht.",
|
||||
"i18n:govoplan-campaign.queued_value0_retryable_message_s_value1_worker_.f4795bdb": "{value0} wiederholbare Nachricht(en) wurden eingereiht; {value1} Worker-Aufgabe(n) wurden veröffentlicht.",
|
||||
"i18n:govoplan-campaign.queue_for_workers.dae9a3e4": "Für Worker einreihen",
|
||||
"i18n:govoplan-campaign.queue_for_workers_commits_durable_jobs_and_retur.d0dbe81a": "Durch das Einreihen für Worker werden dauerhafte Aufträge erstellt und die Aktion kehrt sofort zurück; der Fortschritt bleibt nach dem Verlassen sichtbar. „Jetzt senden“ hält diese Anfrage offen und ist auf {value0} geeignete Empfängeraufträge begrenzt.",
|
||||
"i18n:govoplan-campaign.queue_this_version_for_background_workers_.943d032c": "Diese Version für Hintergrund-Worker einreihen?",
|
||||
"i18n:govoplan-campaign.queueing_for_workers_.d24584fe": "Wird für Worker eingereiht…",
|
||||
"i18n:govoplan-campaign.queueing_retryable_failed_messages_for_backgroun.4bc80cd9": "Wiederholbare fehlgeschlagene Nachrichten werden für Hintergrund-Worker eingereiht…",
|
||||
"i18n:govoplan-campaign.resume_with_workers.510a2a9a": "Mit Workern fortsetzen",
|
||||
"i18n:govoplan-campaign.resumed_value0_message_s_value1_worker_task_s_pu.0f59afb4": "{value0} Nachricht(en) wurden fortgesetzt; {value1} Worker-Aufgabe(n) wurden veröffentlicht.",
|
||||
"i18n:govoplan-campaign.resuming_eligible_delivery_jobs_.dd12c5a2": "Geeignete Versandaufträge werden fortgesetzt…",
|
||||
"i18n:govoplan-campaign.retry_failed_with_workers.a4b7dcc5": "Fehlgeschlagene Aufträge mit Workern wiederholen",
|
||||
"i18n:govoplan-campaign.send_now_is_available_for_value0_eligible_messag.6ec0ed6c": "„Jetzt senden“ ist für {value0} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value1} verfügbar. {value2}",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_.d93c0b29": "„Jetzt senden“ ist nicht verfügbar: {value0}.",
|
||||
"i18n:govoplan-campaign.send_now_is_unavailable_value0_queue_for_workers.59bfc873": "„Jetzt senden“ ist nicht verfügbar ({value0}). Die Worker-Warteschlange bleibt verfügbar.",
|
||||
"i18n:govoplan-campaign.synchronous_limit.f88c0bcd": "Grenzwert für synchronen Versand",
|
||||
"i18n:govoplan-campaign.the_built_run_has_no_eligible_message.48e5410c": "der erstellte Lauf enthält keine geeignete Nachricht",
|
||||
"i18n:govoplan-campaign.the_configured_maximum.eb10006b": "das konfigurierte Maximum",
|
||||
"i18n:govoplan-campaign.the_delivery_policy_configuration_is_invalid.a804a8f8": "die Konfiguration der Versandrichtlinie ist ungültig",
|
||||
"i18n:govoplan-campaign.the_exact_built_run_exceeds_the_effective_limit_.d7812d6a": "der exakt erstellte Lauf überschreitet den wirksamen Grenzwert für Empfängeraufträge",
|
||||
"i18n:govoplan-campaign.the_worker_queue_is_also_available_.7b77144e": "Die Worker-Warteschlange ist ebenfalls verfügbar.",
|
||||
"i18n:govoplan-campaign.this_cancels_queued_or_paused_messages_that_have.add583bc": "Dadurch werden wartende oder pausierte Nachrichten abgebrochen, die die SMTP-Grenze noch nicht überschritten haben. Angenommene, aktive oder ungeklärte Nachrichten bleiben für Audit und Abstimmung geschützt.",
|
||||
"i18n:govoplan-campaign.this_commits_value0_eligible_message_s_from_vers.f53e7222": "Dadurch werden {value0} geeignete Nachricht(en) aus Version {value1} als dauerhafte Worker-Aufträge eingereiht. Sie können diese Seite verlassen und später zum selben Fortschritts- und Wiederherstellungszustand zurückkehren.",
|
||||
"i18n:govoplan-campaign.validate_lock_build_and_review_the_current_versi.d0567dcc": "validieren, sperren, erstellen und prüfen Sie zuerst die aktuelle Version",
|
||||
"i18n:govoplan-campaign.value0_this_is_a_synchronous_request_for_value1_.0267bc6b": "{value0} Dies ist eine synchrone Anfrage für {value1} geeignete Nachricht(en) innerhalb des wirksamen Grenzwerts von {value2}. Alle Nachrichten werden vor dem SMTP-Kontakt vollständig vorgeprüft.",
|
||||
"i18n:govoplan-campaign.worker_queue_committed_value0_message_s_and_publ.24400d3c": "Die Worker-Warteschlange hat {value0} Nachricht(en) übernommen und {value1} Aufgabe(n) veröffentlicht. Eine Fortschrittsaktualisierung reiht sie nicht erneut ein.",
|
||||
"i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505": "Kampagnenspezifisches Mail-Profil",
|
||||
"i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809": "Campaign speichert nur diese stabile Profilreferenz. Das Mail-Modul verwaltet, verschlüsselt, autorisiert, testet und löst alle SMTP-/IMAP-Einstellungen und Zugangsdaten auf.",
|
||||
"i18n:govoplan-campaign.configured.668c5fff": "Konfiguriert",
|
||||
@@ -1397,7 +1619,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.can_view.1b3e4006": "Can view",
|
||||
"i18n:govoplan-campaign.cancel_filename_changes.b558598e": "Cancel filename changes",
|
||||
"i18n:govoplan-campaign.cancel.77dfd213": "Abbrechen",
|
||||
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Cancelled",
|
||||
"i18n:govoplan-campaign.cancelled.a1bf92ef": "Abgebrochen",
|
||||
"i18n:govoplan-campaign.captured_mailbox_preview_is_disabled_for_this_ru.6559458d": "Captured mailbox preview is disabled for this run. Recipient outcomes remain available.",
|
||||
"i18n:govoplan-campaign.captured_mailbox_preview_requires_the_mail_devel.9b3d9f0d": "Captured mailbox preview requires the Mail development mailbox API.",
|
||||
"i18n:govoplan-campaign.captured_messages.a833d293": "Captured messages",
|
||||
@@ -1429,7 +1651,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.compatibility_notes.7d648a6d": "Compatibility notes",
|
||||
"i18n:govoplan-campaign.complete_a_successful_mock_delivery_first.bc2a587d": "Complete a successful mock delivery first.",
|
||||
"i18n:govoplan-campaign.complete_review.4c2ed8c8": "Complete review",
|
||||
"i18n:govoplan-campaign.completed.1798b3ba": "Completed",
|
||||
"i18n:govoplan-campaign.completed.1798b3ba": "Abgeschlossen",
|
||||
"i18n:govoplan-campaign.compose_the_subject_and_body_merge_fields_can_la.4fb6e97b": "Compose the subject and body. Merge fields can later be inserted from the field picker.",
|
||||
"i18n:govoplan-campaign.concurrency.2ec390bf": "Concurrency",
|
||||
"i18n:govoplan-campaign.configure_campaign_wide_attachment_behavior_and_.441a6cd2": "Configure campaign-wide attachment behavior and global matching rules.",
|
||||
@@ -1832,7 +2054,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.not_claimed.aa712c1b": "Not claimed",
|
||||
"i18n:govoplan-campaign.not_configured.67f2141f": "not configured",
|
||||
"i18n:govoplan-campaign.not_loaded_yet.9968c191": "Not loaded yet",
|
||||
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Not queued",
|
||||
"i18n:govoplan-campaign.not_queued.b7f41e1e": "Nicht eingereiht",
|
||||
"i18n:govoplan-campaign.not_reviewed.0a0e3cff": "Not reviewed",
|
||||
"i18n:govoplan-campaign.not_run.9e019cd5": "Not run",
|
||||
"i18n:govoplan-campaign.not_sent.587c501e": "Not sent",
|
||||
@@ -1864,7 +2086,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.optional.0c6c4102": "Optional",
|
||||
"i18n:govoplan-campaign.or_click_to_select_file.0e72f25d": "or click to select file",
|
||||
"i18n:govoplan-campaign.outcome_unknown.4383023a": ", outcome unknown",
|
||||
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Outcome unknown",
|
||||
"i18n:govoplan-campaign.outcome_unknown.6e929fca": "Ergebnis ungeklärt",
|
||||
"i18n:govoplan-campaign.overview.0efc2e6b": "Übersicht",
|
||||
"i18n:govoplan-campaign.owner_type.6b86eacc": "Owner type",
|
||||
"i18n:govoplan-campaign.owner.719379ae": "Owner:",
|
||||
@@ -1927,7 +2149,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.queued_jobs_are_waiting_for_a_background_deliver.cc703afb": "Queued jobs are waiting for a background delivery worker. If the counts do not change, check the worker process and queue logs.",
|
||||
"i18n:govoplan-campaign.queued_jobs_are_waiting_if_the_counts_do_not_cha.7d2164d6": "Queued jobs are waiting. If the counts do not change, check whether background delivery workers are enabled and running.",
|
||||
"i18n:govoplan-campaign.queued_jobs_are_waiting_in_the_database_but_back.5b1fadfd": "Queued jobs are waiting in the database, but background delivery workers are disabled on this server. Use Send queued now for a small dev run, or start Redis/Celery with CELERY_ENABLED=true.",
|
||||
"i18n:govoplan-campaign.queued.6a599877": "Queued",
|
||||
"i18n:govoplan-campaign.queued.6a599877": "Eingereiht",
|
||||
"i18n:govoplan-campaign.queueing_pending_imap_append_jobs.31dd388e": "Queueing pending IMAP append jobs...",
|
||||
"i18n:govoplan-campaign.quoted_contents.f7fd335a": "Quoted contents",
|
||||
"i18n:govoplan-campaign.rate_limit.d08e55f5": "Rate limit",
|
||||
@@ -2109,10 +2331,10 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.show_review_candidates_only.f49df60b": "Show review candidates only",
|
||||
"i18n:govoplan-campaign.showing.163d8174": "Showing",
|
||||
"i18n:govoplan-campaign.size.b7152342": "Größe",
|
||||
"i18n:govoplan-campaign.skipped.5a000ad7": "Skipped",
|
||||
"i18n:govoplan-campaign.skipped.5a000ad7": "Übersprungen",
|
||||
"i18n:govoplan-campaign.skipped.6b98496c": ", skipped",
|
||||
"i18n:govoplan-campaign.smtp_accepted.a5d0dccc": ", SMTP accepted",
|
||||
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "SMTP accepted",
|
||||
"i18n:govoplan-campaign.smtp_accepted.e3aa7603": "Von SMTP angenommen",
|
||||
"i18n:govoplan-campaign.smtp_attempts.eb0a9ca6": "SMTP attempts",
|
||||
"i18n:govoplan-campaign.smtp_credentials.10f75c8a": "). SMTP credentials:",
|
||||
"i18n:govoplan-campaign.smtp_failed.0ce5516d": "SMTP failed",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement, lazy, useCallback } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { ResourceAccessBoundary, type ApiSettings, type AuthInfo, type PlatformWebModule } from "@govoplan/core-webui";
|
||||
import { getCampaign } from "./api/campaigns";
|
||||
import { generatedTranslations } from "./i18n/generatedTranslations";
|
||||
import "./styles/campaign-workspace.css";
|
||||
@@ -8,10 +8,11 @@ import "./styles/campaign-workspace.css";
|
||||
const CampaignListPage = lazy(() => import("./features/campaigns/CampaignListPage"));
|
||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||
const OperatorQueuePage = lazy(() => import("./features/operator/OperatorQueuePage"));
|
||||
const AggregateReportsPage = lazy(() => import("./features/reports/AggregateReportsPage"));
|
||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
|
||||
const campaignRead = ["campaigns:campaign:read"];
|
||||
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control", "campaigns:campaign:send"];
|
||||
const operatorScopes = ["campaigns:campaign:queue", "campaigns:campaign:retry", "campaigns:campaign:reconcile", "campaigns:campaign:control"];
|
||||
const translations = {
|
||||
en: generatedTranslations.en,
|
||||
de: generatedTranslations.de
|
||||
@@ -31,6 +32,7 @@ export const campaignModule: PlatformWebModule = {
|
||||
label: "i18n:govoplan-campaign.operator_queue.ddf23260",
|
||||
iconName: "radio-tower",
|
||||
anyOf: operatorScopes,
|
||||
allOf: campaignRead,
|
||||
order: 30
|
||||
},
|
||||
{ to: "/reports", label: "i18n:govoplan-campaign.reports.88bc3fe3", iconName: "clipboard-pen-line", anyOf: ["campaigns:report:read"], order: 70 },
|
||||
@@ -39,8 +41,8 @@ export const campaignModule: PlatformWebModule = {
|
||||
routes: [
|
||||
{ path: "/campaigns", anyOf: campaignRead, order: 20, render: ({ settings }) => createElement(CampaignListPage, { settings }) },
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 21, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: operatorScopes, order: 30, render: ({ settings }) => createElement(OperatorQueuePage, { settings }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: () => createElement(ReportsPage) },
|
||||
{ path: "/operator", anyOf: operatorScopes, allOf: campaignRead, order: 30, render: ({ settings, auth }) => createElement(OperatorQueuePage, { settings, auth }) },
|
||||
{ path: "/reports", anyOf: ["campaigns:report:read"], order: 70, render: ({ settings }) => createElement(AggregateReportsPage, { settings }) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }]
|
||||
|
||||
};
|
||||
@@ -58,18 +60,4 @@ function CampaignResourceRoute({ settings, auth }: {settings: ApiSettings;auth:
|
||||
});
|
||||
}
|
||||
|
||||
function ReportsPage() {
|
||||
return createElement(
|
||||
"div",
|
||||
{ className: "content-pad workspace-data-page" },
|
||||
createElement(
|
||||
"div",
|
||||
{ className: "page-heading workspace-heading" },
|
||||
createElement("h1", null, "i18n:govoplan-campaign.reports"),
|
||||
createElement("p", null, "i18n:govoplan-campaign.reports_module_prepared")
|
||||
),
|
||||
createElement(Card, null, createElement("p", { className: "muted" }, "i18n:govoplan-campaign.next_passes_will_add_functionality_here"))
|
||||
);
|
||||
}
|
||||
|
||||
export default campaignModule;
|
||||
|
||||
76
webui/tests/aggregate-report-ui-structure.test.mjs
Normal file
76
webui/tests/aggregate-report-ui-structure.test.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const api = readFileSync("src/api/campaigns.ts", "utf8");
|
||||
const page = readFileSync("src/features/reports/AggregateReportsPage.tsx", "utf8");
|
||||
const reportPage = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
|
||||
const reviewPage = readFileSync("src/features/campaigns/ReviewSendPage.tsx", "utf8");
|
||||
const operatorPage = readFileSync("src/features/operator/OperatorQueuePage.tsx", "utf8");
|
||||
const deliveryMode = readFileSync("src/features/campaigns/utils/deliveryMode.ts", "utf8");
|
||||
const translationCatalog = readFileSync("src/i18n/generatedTranslations.ts", "utf8");
|
||||
|
||||
assert(api.includes('"/api/v1/campaigns/aggregate-reports"'), "the report list uses the safe aggregate collection");
|
||||
assert(api.includes("/api/v1/campaigns/aggregate-reports/${encodeURIComponent(campaignId)}"), "the selected report uses the safe aggregate projection");
|
||||
|
||||
assert(page.includes("listAggregateReportCampaigns"), "the page loads only the safe Campaign selector projection");
|
||||
assert(page.includes("getAggregateCampaignReport"), "the page loads only the safe aggregate report projection");
|
||||
assert(!page.includes("getCampaignReport"), "the aggregate page cannot load the recipient-aware full report");
|
||||
assert(!page.includes("getCampaignJobs"), "the aggregate page cannot cache recipient jobs");
|
||||
assert(!page.includes("getCampaignJobDetail"), "the aggregate page has no detail route");
|
||||
assert(!page.includes("downloadCampaignJobsCsv"), "the aggregate page has no export path");
|
||||
assert(!page.includes("localStorage"), "the aggregate page does not persist report data in local storage");
|
||||
assert(!page.includes("sessionStorage"), "the aggregate page does not persist report data in session storage");
|
||||
assert(page.includes("TableActionGroup"), "campaign selection uses the central icon-only table action group");
|
||||
assert(page.includes("disabled: campaign.id === selectedFromUrl"), "the selected row action stays visible and disabled");
|
||||
assert(page.includes('columnType: "from-list"'), "campaign status uses the stable shared list-filter model");
|
||||
const expectedCampaignStatuses = [
|
||||
"draft",
|
||||
"validated",
|
||||
"needs_review",
|
||||
"ready_to_queue",
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"archived"
|
||||
];
|
||||
for (const status of expectedCampaignStatuses) {
|
||||
assert(page.includes(`{ value: "${status}", label:`), `the aggregate list declares the ${status} status explicitly`);
|
||||
}
|
||||
const outcomeCardsStart = page.indexOf("Aggregate outcome cards are deliberately not filter shortcuts");
|
||||
const outcomeCardsEnd = page.indexOf('<Card title="i18n:govoplan-campaign.population_and_exclusions', outcomeCardsStart);
|
||||
assert(outcomeCardsStart >= 0 && outcomeCardsEnd > outcomeCardsStart, "the aggregate privacy boundary is documented beside the outcome cards");
|
||||
assert(!page.slice(outcomeCardsStart, outcomeCardsEnd).includes("onClick"), "aggregate outcome cards cannot create unsuppressed filtered subgroups");
|
||||
assert(!page.includes("{report.population.denominator_definition}"), "the known denominator contract uses a localized UI explanation");
|
||||
assert(!page.includes("{report.privacy.rule}"), "the known privacy contract uses a localized UI explanation");
|
||||
assert(page.includes("all_persisted_recipient_delivery_jobs_for_the_se.208e90cc"), "the denominator explanation is bilingual");
|
||||
assert(page.includes("positive_counts_below_the_threshold_are_hidden_a.1dc97093"), "the suppression rule is bilingual");
|
||||
|
||||
const touchedSources = [page, reportPage, reviewPage, operatorPage, deliveryMode].join("\n");
|
||||
const touchedKeys = new Set(touchedSources.match(/i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8}/g) ?? []);
|
||||
for (const key of touchedKeys) {
|
||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const catalogEntries = [...translationCatalog.matchAll(new RegExp(`"${escapedKey}":\\s*("(?:[^"\\\\]|\\\\.)*")`, "g"))];
|
||||
assert.equal(catalogEntries.length, 2, `${key} is present exactly once in both language catalogs`);
|
||||
const english = JSON.parse(catalogEntries[0][1]);
|
||||
const german = JSON.parse(catalogEntries[1][1]);
|
||||
assert.notEqual(english.trim(), "", `${key} has a non-empty English translation`);
|
||||
assert.notEqual(german.trim(), "", `${key} has a non-empty German translation`);
|
||||
}
|
||||
|
||||
const sliceSections = [...translationCatalog.matchAll(/ "i18n:govoplan-campaign\.campaign_reports_available_to_you\.f14fa403":[\s\S]*? "i18n:govoplan-campaign\.worker_queue_committed_value0_message_s_and_publ\.24400d3c":.*\n/g)];
|
||||
assert.equal(sliceSections.length, 2, "the current slice has one contiguous English and German catalog section");
|
||||
const newEnglishEntries = [...sliceSections[0][0].matchAll(/"(i18n:govoplan-campaign\.[a-z0-9_]+\.[a-f0-9]{8})":\s*("(?:[^"\\]|\\.)*")/g)];
|
||||
for (const [, key, encodedEnglish] of newEnglishEntries) {
|
||||
const english = JSON.parse(encodedEnglish);
|
||||
const digest = createHash("sha1").update(english).digest("hex").slice(0, 8);
|
||||
const slug = english.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+/, "").slice(0, 48);
|
||||
assert.equal(key, `i18n:govoplan-campaign.${slug}.${digest}`, `${key} uses the normalized source slug and exact SHA-1 suffix`);
|
||||
assert(touchedKeys.has(key), `${key} is referenced by the current Campaign UI slices`);
|
||||
}
|
||||
|
||||
console.log("aggregate report UI structure checks passed");
|
||||
26
webui/tests/delivery-mode-ui-structure.test.mjs
Normal file
26
webui/tests/delivery-mode-ui-structure.test.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
|
||||
const modeSource = readFileSync(resolve(here, "../src/features/campaigns/utils/deliveryMode.ts"), "utf8");
|
||||
|
||||
assert.match(source, /getCampaignDeliveryOptions/);
|
||||
assert.match(source, /queue_for_workers\.dae9a3e4/);
|
||||
assert.match(source, /queue_for_workers_commits_durable_jobs_and_retur\.d0dbe81a/);
|
||||
assert.match(source, /loading_the_effective_delivery_policy_\.d6893011/);
|
||||
assert.match(source, /deliveryControlProgressMessage/);
|
||||
assert.match(source, /synchronousSendAllowed/);
|
||||
assert.match(source, /commandInProgress[\s\S]*refreshQueueStatus/);
|
||||
assert.match(source, /<ConfirmDialog[\s\S]*open=\{queueConfirmOpen\}/);
|
||||
assert.match(source, /<ConfirmDialog[\s\S]*open=\{cancelDeliveryConfirmOpen\}/);
|
||||
assert.match(source, /deliveryModeLabel\(persistedDeliveryMode\)/);
|
||||
assert.match(source, /this_version_last_entered_value0_mode_at_value1_\.c087d2f3/);
|
||||
assert.match(source, /this_version_last_entered_value0_mode_this_recor\.ea2f201f/);
|
||||
assert.doesNotMatch(source, /This version last entered/);
|
||||
assert.match(modeSource, /synchronous\.77c61919/);
|
||||
assert.match(modeSource, /worker_queue\.c911e32c/);
|
||||
assert.match(modeSource, /database_queue\.8bf98437/);
|
||||
assert.doesNotMatch(source, /window\.alert\s*\(/);
|
||||
151
webui/tests/operator-queue-model.test.ts
Normal file
151
webui/tests/operator-queue-model.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
OPERATOR_QUEUE_REASON,
|
||||
campaignLifecycleTotals,
|
||||
campaignVersionWorkItems,
|
||||
operatorDeliveryStateCounts,
|
||||
operatorQueueActionBlocks,
|
||||
shouldRefreshOperatorVersionSummary,
|
||||
type OperatorQueueFacts,
|
||||
type OperatorQueuePermissions
|
||||
} from "../src/features/operator/operatorQueueModel.ts";
|
||||
|
||||
const everything: OperatorQueueFacts = {
|
||||
retryable: 2,
|
||||
outcomeUnknown: 1,
|
||||
queueableUnattempted: 3,
|
||||
pausable: 4,
|
||||
paused: 5,
|
||||
cancellable: 14
|
||||
};
|
||||
|
||||
const allPermissions: OperatorQueuePermissions = {
|
||||
canRead: true,
|
||||
canOpenReport: true,
|
||||
canControl: true,
|
||||
canRetry: true,
|
||||
canQueue: true,
|
||||
canReconcile: true
|
||||
};
|
||||
|
||||
test("enables every fixed queue action when state and permission allow it", () => {
|
||||
assert.deepEqual(operatorQueueActionBlocks(everything, allPermissions), {
|
||||
details: null,
|
||||
report: null,
|
||||
pause: null,
|
||||
resume: null,
|
||||
retry: null,
|
||||
"queue-unsent": null,
|
||||
reconcile: null,
|
||||
cancel: null
|
||||
});
|
||||
});
|
||||
|
||||
test("retains every unavailable action with a state-specific explanation", () => {
|
||||
assert.deepEqual(
|
||||
operatorQueueActionBlocks(
|
||||
{ retryable: 0, outcomeUnknown: 0, queueableUnattempted: 0, pausable: 0, paused: 0, cancellable: 0 },
|
||||
allPermissions
|
||||
),
|
||||
{
|
||||
details: null,
|
||||
report: null,
|
||||
pause: OPERATOR_QUEUE_REASON.noQueued,
|
||||
resume: OPERATOR_QUEUE_REASON.noPaused,
|
||||
retry: OPERATOR_QUEUE_REASON.noFailed,
|
||||
"queue-unsent": OPERATOR_QUEUE_REASON.noUnsent,
|
||||
reconcile: OPERATOR_QUEUE_REASON.noUnknown,
|
||||
cancel: OPERATOR_QUEUE_REASON.noCancellable
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("permission explanations take precedence over queue state", () => {
|
||||
const noPermissions: OperatorQueuePermissions = {
|
||||
canRead: false,
|
||||
canOpenReport: false,
|
||||
canControl: false,
|
||||
canRetry: false,
|
||||
canQueue: false,
|
||||
canReconcile: false
|
||||
};
|
||||
const blocks = operatorQueueActionBlocks(everything, noPermissions);
|
||||
assert.deepEqual(
|
||||
Object.values(blocks),
|
||||
Array.from({ length: 8 }, () => OPERATOR_QUEUE_REASON.permissionDenied)
|
||||
);
|
||||
});
|
||||
|
||||
test("allows in-page job selection without report permission while keeping report access disabled", () => {
|
||||
const blocks = operatorQueueActionBlocks(everything, {
|
||||
...allPermissions,
|
||||
canOpenReport: false
|
||||
});
|
||||
assert.equal(blocks.details, null);
|
||||
assert.equal(blocks.report, OPERATOR_QUEUE_REASON.permissionDenied);
|
||||
});
|
||||
|
||||
test("keeps historical active work visible after a new draft becomes current", () => {
|
||||
const items = campaignVersionWorkItems(
|
||||
"campaign-1",
|
||||
"version-2",
|
||||
[
|
||||
{ id: "version-2", version_number: 2 },
|
||||
{ id: "version-1", version_number: 1 }
|
||||
],
|
||||
{
|
||||
"version-2": { state: "draft", queued: 0, failed: 0 },
|
||||
"version-1": { state: "active", queued: 3, failed: 1 }
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(items.map((item) => [item.versionId, item.isCurrent]), [
|
||||
["version-2", true],
|
||||
["version-1", false]
|
||||
]);
|
||||
assert.deepEqual(items[1].summary, { state: "active", queued: 3, failed: 1 });
|
||||
});
|
||||
|
||||
test("campaign-wide lifecycle totals are not multiplied across version rows", () => {
|
||||
assert.deepEqual(campaignLifecycleTotals([
|
||||
{ pausable: 3, paused: 1, cancellable: 4 },
|
||||
{ pausable: 0, paused: 2, cancellable: 2 }
|
||||
]), { pausable: 3, paused: 3, cancellable: 6 });
|
||||
});
|
||||
|
||||
test("paused jobs do not overlap with queued or active delivery counts", () => {
|
||||
assert.deepEqual(operatorDeliveryStateCounts(
|
||||
{ queued: 0 },
|
||||
{ queued: 4, claimed: 0, sending: 0, smtpAccepted: 2, sent: 1 }
|
||||
), {
|
||||
queued: 0,
|
||||
claimed: 0,
|
||||
sending: 0,
|
||||
completed: 3,
|
||||
queuedOrActive: 0
|
||||
});
|
||||
});
|
||||
|
||||
test("fast polling refreshes only selected, changed, uncached, active, or attention versions", () => {
|
||||
const baseline = {
|
||||
fullDiscovery: false,
|
||||
versionChanged: false,
|
||||
selected: false,
|
||||
cached: true,
|
||||
queuedOrActive: 0,
|
||||
needsAttention: 0
|
||||
};
|
||||
assert.equal(shouldRefreshOperatorVersionSummary(baseline), false);
|
||||
for (const override of [
|
||||
{ fullDiscovery: true },
|
||||
{ versionChanged: true },
|
||||
{ selected: true },
|
||||
{ cached: false },
|
||||
{ queuedOrActive: 1 },
|
||||
{ needsAttention: 1 }
|
||||
]) {
|
||||
assert.equal(shouldRefreshOperatorVersionSummary({ ...baseline, ...override }), true);
|
||||
}
|
||||
});
|
||||
80
webui/tests/operator-queue-ui-structure.test.mjs
Normal file
80
webui/tests/operator-queue-ui-structure.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(resolve(here, "../src/features/operator/OperatorQueuePage.tsx"), "utf8");
|
||||
const translations = readFileSync(resolve(here, "../src/i18n/generatedTranslations.ts"), "utf8");
|
||||
const backendRouter = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/router.py"), "utf8");
|
||||
const responseSecurity = readFileSync(resolve(here, "../../src/govoplan_campaign/backend/response_security.py"), "utf8");
|
||||
|
||||
assert.match(source, /<DataGrid/);
|
||||
assert.match(source, /<TableActionGroup actions=/);
|
||||
for (const action of ["details", "report", "pause", "resume", "retry", "queue-unsent", "reconcile", "cancel", "open-recipient-report"]) {
|
||||
assert.match(source, new RegExp(`id: "${action}"`));
|
||||
}
|
||||
assert.match(source, /useDeltaWatermarks/);
|
||||
assert.match(source, /useSearchParams/);
|
||||
assert.match(source, /searchParams\.get\("campaign"\)/);
|
||||
assert.match(source, /searchParams\.get\("version"\)/);
|
||||
assert.match(source, /next\.set\("campaign", campaignId\)[\s\S]*next\.set\("version", versionId\)/);
|
||||
assert.match(source, /setSearchParams\(next, \{ replace: true \}\)/);
|
||||
assert.match(source, /window\.setInterval[\s\S]*10_000/);
|
||||
assert.match(source, /window\.setInterval[\s\S]*60_000/);
|
||||
assert.match(source, /pendingFullDiscoveryRef\.current = true/);
|
||||
assert.match(source, /if \(pendingFullDiscoveryRef\.current\)[\s\S]*fullDiscoveryLoadRef\.current\(\)/);
|
||||
assert.match(source, /<ConfirmDialog[\s\S]*cancelTarget/);
|
||||
assert.match(source, /includeVersions: true/);
|
||||
assert.match(source, /getCampaignSummary\(settings, campaign\.id, version\.id\)/);
|
||||
assert.match(source, /campaignVersionWorkItems/);
|
||||
assert.match(source, /operatorDeliveryStateCounts/);
|
||||
assert.doesNotMatch(source, /queuedOrActive: numberValue\(cards\.queued_or_active\)/);
|
||||
assert.match(source, /getCampaignJobs\(settings, selectedRow\.campaign\.id,[\s\S]*versionId: selectedVersionId/);
|
||||
assert.doesNotMatch(source, /getCampaignJobDetail|getCampaignJobsDelta|diagnostics/);
|
||||
assert.match(source, /mode: "server"/);
|
||||
assert.match(source, /totalRows: jobs\.total/);
|
||||
assert.match(source, /query=\{jobGridQuery\}/);
|
||||
assert.match(source, /onQueryChange=\{handleJobGridQuery\}/);
|
||||
assert.match(source, /pageSizeOptions: \[25, 50, 100, 200\]/);
|
||||
assert.match(source, /jobsRequestRef\.current/);
|
||||
assert.match(source, /requestId !== jobsRequestRef\.current/);
|
||||
assert.match(source, /selectedQueueActive[\s\S]*backgroundJobsLoadRef/);
|
||||
assert.match(source, /getRowKey=\{\(row\) => `\$\{row\.campaign\.id\}:\$\{rowVersionId\(row\)/);
|
||||
assert.match(source, /row\.campaign\.id === selectedCampaignId && rowVersionId\(row\) === selectedVersionParam/);
|
||||
assert.match(source, /retryCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
|
||||
assert.match(source, /sendUnattemptedCampaignJobs[\s\S]*version_id: rowVersionId\(row\)/);
|
||||
assert.match(source, /report\?version=\$\{encodeURIComponent\(rowVersionId\(row\)\)\}&send_status=outcome_unknown/);
|
||||
for (const count of ["queued", "claimed", "sending", "completed"]) {
|
||||
assert.match(source, new RegExp(`row\\.${count}`));
|
||||
assert.match(source, new RegExp(`totals\\.${count}`));
|
||||
}
|
||||
for (const field of ["campaign_version_id", "entry_id", "entry_index", "queue_status", "send_status", "imap_status", "attempt_count", "queued_at", "sent_at", "updated_at", "last_error"]) {
|
||||
assert.match(source, new RegExp(field));
|
||||
}
|
||||
assert.match(source, /deliveryResultSummary[\s\S]*send_status[\s\S]*imap_status/);
|
||||
assert.match(source, /durableResultText[\s\S]*slice\(0, 239\)/);
|
||||
assert.match(source, /permissions\.canOpenReport[\s\S]*you_do_not_have_permission/);
|
||||
assert.doesNotMatch(source, /claim_token|eml_local_path|storage_key|smtp_transport_revision|eml_sha256|message_id_header|smtp_response|provider_response|resolved_attachments/);
|
||||
assert.match(backendRouter, /"last_error": public_delivery_result_message\([\s\S]*last_error=job\.last_error/);
|
||||
assert.match(responseSecurity, /Map persisted provider text to a stable business-safe explanation/);
|
||||
assert.match(responseSecurity, /SMTP delivery failed; an operator can inspect restricted diagnostics\./);
|
||||
assert.doesNotMatch(source, /window\.alert\s*\(/);
|
||||
assert.doesNotMatch(source, /function QueuePressureGrid|function OperatorJob/);
|
||||
assert.doesNotMatch(source, /humanize\(/);
|
||||
|
||||
const localized = new Map([
|
||||
["i18n:govoplan-campaign.claimed.83c87884", ["Claimed", "Beansprucht"]],
|
||||
["i18n:govoplan-campaign.sending.ceafde86", ["Sending", "Im Versand"]],
|
||||
["i18n:govoplan-campaign.delivery_jobs_for_value0.50a94a70", ["Delivery jobs for {value0}", "Versandaufträge für {value0}"]],
|
||||
["i18n:govoplan-campaign.open_recipient_report.d6892ee4", ["Open recipient report", "Empfängerbezogenen Bericht öffnen"]],
|
||||
["i18n:govoplan-campaign.queued_at.1ac179b3", ["Queued at", "Eingereiht am"]],
|
||||
["i18n:govoplan-campaign.sent_at.a6f45556", ["Sent at", "Gesendet am"]],
|
||||
["i18n:govoplan-campaign.current.4fc0e2bc", ["Current", "Aktuell"]],
|
||||
["i18n:govoplan-campaign.historical.bb2fe205", ["Historical", "Historisch"]],
|
||||
["i18n:govoplan-campaign.pause_all_queued_work_for_this_campaign.64ee23cf", ["Pause all queued work for this Campaign", "Alle eingereihten Aufträge dieser Kampagne pausieren"]]
|
||||
]);
|
||||
for (const [key, values] of localized) {
|
||||
assert.equal(translations.split(`"${key}"`).length - 1, 2, `${key} occurs once per language`);
|
||||
for (const value of values) assert(translations.includes(`"${key}": ${JSON.stringify(value)}`));
|
||||
}
|
||||
88
webui/tests/report-grid-query.test.ts
Normal file
88
webui/tests/report-grid-query.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { campaignJobsQueryParams } from "../src/features/campaigns/utils/jobListQuery";
|
||||
import {
|
||||
activeReportGridShortcut,
|
||||
reportGridQueryForShortcut,
|
||||
toggleReportGridShortcut
|
||||
} from "../src/features/campaigns/utils/reportGridShortcuts";
|
||||
|
||||
declare function require(name: string): {
|
||||
readFileSync(path: string, encoding: string): string;
|
||||
};
|
||||
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
function assert(condition: unknown, message: string): void {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const params = campaignJobsQueryParams({
|
||||
versionId: "version-1",
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
query: " recipient ",
|
||||
sortBy: "recipient",
|
||||
sortDirection: "desc",
|
||||
filters: {
|
||||
recipient: "target",
|
||||
send: 'list:["failed_temporary","outcome_unknown"]',
|
||||
attempts: "gte:2",
|
||||
unsupported: "must-not-leak"
|
||||
}
|
||||
});
|
||||
|
||||
assert(params.get("version_id") === "version-1", "the selected Campaign version is retained");
|
||||
assert(params.get("page") === "2" && params.get("page_size") === "25", "server pagination is serialized");
|
||||
assert(params.get("q") === "recipient", "the deep-link search query is normalized");
|
||||
assert(params.get("sort_by") === "recipient" && params.get("sort_direction") === "desc", "DataGrid sorting is server-owned");
|
||||
assert(params.get("filter_recipient") === "target", "text filters are sent to the backend");
|
||||
assert(params.get("filter_send") === 'list:["failed_temporary","outcome_unknown"]', "stable list selections are sent intact");
|
||||
assert(params.get("filter_attempts") === "gte:2", "typed number filters retain their operator");
|
||||
assert(!params.toString().includes("unsupported") && !params.toString().includes("must-not-leak"), "only the declared backend filter contract is serialized");
|
||||
|
||||
const shortcutFilters = {
|
||||
smtp_accepted: { send: 'list:["smtp_accepted","sent"]' },
|
||||
failed: { send: 'list:["failed_temporary","failed_permanent"]' },
|
||||
outcome_unknown: { send: 'list:["outcome_unknown"]' },
|
||||
not_attempted: { send: 'list:["not_queued"]' },
|
||||
smtp_skipped: { send: 'list:["skipped"]' },
|
||||
cancelled: { send: 'list:["cancelled"]' },
|
||||
imap_appended: { imap: 'list:["appended"]' },
|
||||
imap_failed: { imap: 'list:["failed"]' },
|
||||
imap_skipped: { imap: 'list:["skipped"]' }
|
||||
} as const;
|
||||
|
||||
for (const [shortcutId, filters] of Object.entries(shortcutFilters)) {
|
||||
const shortcut = reportGridQueryForShortcut(shortcutId as keyof typeof shortcutFilters);
|
||||
assert(JSON.stringify(shortcut.filters) === JSON.stringify(filters), `${shortcutId} uses the exact backend list filter`);
|
||||
assert(shortcut.sort?.columnId === "number" && shortcut.sort.direction === "asc", `${shortcutId} restores stable report ordering`);
|
||||
}
|
||||
|
||||
const failedShortcut = toggleReportGridShortcut({
|
||||
sort: { columnId: "updated", direction: "desc" },
|
||||
filters: { recipient: "old search", imap: 'list:["appended"]' }
|
||||
}, "failed");
|
||||
assert(JSON.stringify(failedShortcut.filters) === JSON.stringify(shortcutFilters.failed), "a shortcut clears unrelated filters");
|
||||
assert(activeReportGridShortcut(failedShortcut) === "failed", "the exact shortcut query is recognized as active");
|
||||
const toggledBack = toggleReportGridShortcut(failedShortcut, "failed");
|
||||
assert(Object.keys(toggledBack.filters).length === 0, "selecting an active count returns to all jobs");
|
||||
assert(activeReportGridShortcut(toggledBack) === "all", "the cleared query activates the all-jobs shortcut");
|
||||
const sortedFailedShortcut = { ...failedShortcut, sort: { columnId: "recipient", direction: "desc" as const } };
|
||||
assert(Object.keys(toggleReportGridShortcut(sortedFailedShortcut, "failed").filters).length === 0, "the active shortcut toggles to all even after the user changes sorting");
|
||||
|
||||
const reportSource = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
|
||||
assert(reportSource.includes('mode: "server"'), "the report DataGrid declares server query ownership");
|
||||
assert(reportSource.includes("totalRows: jobs.total"), "the shared pagination count uses the filtered backend total");
|
||||
assert(reportSource.includes("onQueryChange={handleJobGridQuery}"), "header sort and filter changes drive the backend query");
|
||||
assert(reportSource.includes("initialReportGridFilters()"), "status deep links initialize the DataGrid filters");
|
||||
assert(reportSource.includes("initialReportQuery()"), "q deep links initialize the report search");
|
||||
assert(reportSource.includes("query={jobGridQuery}"), "external count shortcuts synchronize the visible DataGrid query");
|
||||
assert(reportSource.includes('setQuery("");') && reportSource.includes('setAppliedQuery("");'), "count shortcuts clear both visible and applied search terms");
|
||||
assert(reportSource.includes("deliveryOutcomeShortcuts.map") && reportSource.includes("imapOutcomeShortcuts.map"), "top-level delivery counts use one coherent shortcut model");
|
||||
assert(reportSource.includes('<Button type="button" variant={active ? "primary" : "ghost"}'), "count shortcuts render the central Button directly");
|
||||
assert(reportSource.includes('aria-pressed={active}'), "count shortcuts expose their selected state accessibly");
|
||||
assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option");
|
||||
assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog");
|
||||
assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label");
|
||||
assert(reportSource.includes("cards?.skipped ?? jobs.counts.send?.skipped"), "SMTP skipped has a separate report count");
|
||||
assert(reportSource.includes("cards?.imap_skipped ?? jobs.counts.imap?.skipped"), "IMAP skipped has a separate report count");
|
||||
assert(!reportSource.includes("setPage((value) => Math.max(1, value - 1))"), "the one-off report pager is removed in favor of the central DataGrid pager");
|
||||
30
webui/tsconfig.aggregate-report-tests.json
Normal file
30
webui/tsconfig.aggregate-report-tests.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../govoplan-core/webui/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@govoplan/core-webui": [
|
||||
"../../govoplan-core/webui/src/index.ts"
|
||||
],
|
||||
"@govoplan/core-webui/app": [
|
||||
"../../govoplan-core/webui/src/app.ts"
|
||||
],
|
||||
"react": [
|
||||
"../../govoplan-core/webui/node_modules/@types/react/index.d.ts"
|
||||
],
|
||||
"react/jsx-runtime": [
|
||||
"../../govoplan-core/webui/node_modules/@types/react/jsx-runtime.d.ts"
|
||||
]
|
||||
},
|
||||
"typeRoots": [
|
||||
"../../govoplan-core/webui/node_modules/@types"
|
||||
]
|
||||
},
|
||||
"include": [],
|
||||
"files": [
|
||||
"../../govoplan-core/webui/src/vite-env.d.ts",
|
||||
"src/features/reports/AggregateReportsPage.tsx"
|
||||
]
|
||||
}
|
||||
21
webui/tsconfig.report-grid-tests.json
Normal file
21
webui/tsconfig.report-grid-tests.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
"noEmit": false,
|
||||
"outDir": ".report-grid-test-build",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"tests/report-grid-query.test.ts",
|
||||
"src/features/campaigns/utils/jobListQuery.ts",
|
||||
"src/features/campaigns/utils/reportGridShortcuts.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user