Compare commits
17 Commits
6fda123fc3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c769be39da | |||
| 46df12c025 | |||
| 961d5d1130 | |||
| 23b9a531d5 | |||
| cc93945f79 | |||
| 101f3ccd7d | |||
| 2199187e8b | |||
| dd9592a192 | |||
| 5240749ae1 | |||
| f11c56e890 | |||
| 8d0a2608ed | |||
| 89ae14c032 | |||
| 38af25ee88 | |||
| 4774a8025c | |||
| 689dc1fd6b | |||
| 90677348ff | |||
| 06786e86ef |
@@ -15,7 +15,8 @@ This repository owns:
|
||||
- campaign/version/job/issue/send-attempt/append-attempt models and migrations
|
||||
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
|
||||
- WebUI package `@govoplan/campaign-webui`
|
||||
- route contributions for `/campaigns`, `/campaigns/:campaignId/*`, `/operator`, `/reports`, and `/templates`
|
||||
- route contributions for `/campaigns`, the integrated `/campaigns/queue` view,
|
||||
`/campaigns/:campaignId/*`, `/reports`, and `/templates`
|
||||
|
||||
Core owns the auth facade, RBAC/capability contracts, database/session
|
||||
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
||||
@@ -94,7 +95,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.
|
||||
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
||||
- [Campaign/Mail profile boundary](docs/MAIL_PROFILE_BOUNDARY.md) defines profile-only delivery, runtime resolution, execution evidence, and the fail-closed legacy migration path.
|
||||
- [Recipient import guide](docs/RECIPIENT_IMPORT_GUIDE.md) covers user/admin workflows, mapping profiles, validation, and import evidence.
|
||||
- [Recipient and address boundary](docs/RECIPIENT_ADDRESS_BOUNDARY.md) defines the split between campaign-local recipients and future reusable address management.
|
||||
|
||||
@@ -9,3 +9,5 @@ GOVOPLAN_MAIL_TEST_IMAP_PORT=3143
|
||||
GOVOPLAN_MAIL_TEST_SENT_FOLDER=Sent
|
||||
GOVOPLAN_MAIL_TEST_ZIP_PASSWORD=zip-test-password
|
||||
GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS=45
|
||||
GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT=36379
|
||||
GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS=3
|
||||
|
||||
@@ -103,6 +103,47 @@ proof, not proof of a target provider's behavior or Redis/Celery broker
|
||||
redelivery. Use `--success-only` only when testing the success journey without
|
||||
the local failure endpoints.
|
||||
|
||||
## Redis/Celery Redelivery Acceptance
|
||||
|
||||
Run the maintained broker/worker-loss acceptance separately from the GreenMail
|
||||
journey:
|
||||
|
||||
```bash
|
||||
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
/mnt/DATA/git/govoplan/.venv/bin/python run_celery_redelivery_acceptance.py \
|
||||
--evidence /tmp/govoplan-campaign-celery-redelivery-evidence.json
|
||||
```
|
||||
|
||||
The runner creates a unique Compose project, starts only its loopback-bound,
|
||||
AOF-enabled Redis service, creates an isolated temporary GovOPlaN database,
|
||||
and starts a real Celery worker subscribed to `send_email`. A controlled SMTP
|
||||
server holds the transaction after complete DATA and before the final response.
|
||||
The runner kills that solo worker with the task still unacknowledged and starts
|
||||
a replacement worker. After the configured Redis visibility timeout, the same Celery task identity must be redelivered.
|
||||
The replacement must turn the durable
|
||||
unfinished attempt into `outcome_unknown`, acknowledge the task, drain the
|
||||
broker queue/unacked records, and leave the SMTP endpoint at exactly one
|
||||
connection and one DATA transaction.
|
||||
|
||||
Only bounded counts, classifications, and booleans are retained. Worker logs,
|
||||
task IDs, database identifiers, endpoints, credentials, and raw diagnostics are
|
||||
kept in the temporary runtime and deleted. The evidence proves the local Redis
|
||||
transport, real Celery process boundary, runner-supervised replacement, and
|
||||
Campaign's duplicate-effect guard. It deliberately keeps production daemon supervision,
|
||||
target-provider behavior, and source-artifact provenance false.
|
||||
It does not claim that systemd, Kubernetes, another container orchestrator, or
|
||||
an institution's Redis/SMTP deployment behaves identically.
|
||||
|
||||
The default run requires Docker CLI/Compose/daemon access and permission to
|
||||
pull `redis:7-alpine`; the Celery workers execute from the current Python
|
||||
environment. The isolated Compose project and volume are removed on exit.
|
||||
`GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS` defaults to three
|
||||
seconds only to make this destructive local drill finish promptly; it is not a
|
||||
production recommendation.
|
||||
|
||||
Starting the maintained test bed requires a working Docker CLI, Compose plugin,
|
||||
daemon/socket access, and permission to pull `greenmail/standalone:2.1.9`. The
|
||||
Campaign runner needs only the already-running loopback endpoints; it neither
|
||||
|
||||
@@ -15,3 +15,19 @@ services:
|
||||
- "${GOVOPLAN_MAIL_TEST_SMTP_PORT:-3025}:3025"
|
||||
- "${GOVOPLAN_MAIL_TEST_IMAP_PORT:-3143}:3143"
|
||||
- "127.0.0.1:38080:8080"
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "127.0.0.1:${GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT:-36379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 30
|
||||
volumes:
|
||||
- campaign-redis-data:/data
|
||||
|
||||
volumes:
|
||||
campaign-redis-data:
|
||||
|
||||
856
dev/mail-testbed/run_celery_redelivery_acceptance.py
Normal file
856
dev/mail-testbed/run_celery_redelivery_acceptance.py
Normal file
@@ -0,0 +1,856 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prove Redis/Celery redelivery does not repeat an ambiguous SMTP effect.
|
||||
|
||||
The default run starts an isolated Redis Compose service, two successive real
|
||||
Celery worker processes, and a controlled loopback SMTP endpoint. It kills the
|
||||
first worker after complete DATA but before a final SMTP response. The same
|
||||
unacknowledged broker task must be delivered to the replacement worker, which
|
||||
must freeze the unfinished durable attempt as ``outcome_unknown`` without a
|
||||
second SMTP connection or DATA transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Callable, Iterator, Mapping
|
||||
from uuid import uuid4
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
|
||||
SCRIPT_ROOT = Path(__file__).resolve().parent
|
||||
REPOSITORY_ROOT = SCRIPT_ROOT.parents[1]
|
||||
if str(SCRIPT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_ROOT))
|
||||
|
||||
from run_campaign_acceptance import ( # noqa: E402
|
||||
AcceptanceError,
|
||||
DEFAULT_FIXTURE,
|
||||
EXPECTED_AUDIT_ACTIONS,
|
||||
TestbedSettings,
|
||||
_assert_evidence_safe,
|
||||
_core_package_version,
|
||||
_durable_state_evidence,
|
||||
_expect,
|
||||
_report_evidence,
|
||||
create_mail_profile,
|
||||
prepare_campaign_scenario,
|
||||
required_composition_versions,
|
||||
smtp_fault_endpoint,
|
||||
)
|
||||
|
||||
|
||||
EVIDENCE_SCHEMA = "govoplan.campaign.celery-redelivery-acceptance.v1"
|
||||
MAX_EVIDENCE_BYTES = 128 * 1024
|
||||
DEFAULT_COMPOSE_FILE = SCRIPT_ROOT / "docker-compose.yml"
|
||||
TASK_RECEIVED_PATTERN = re.compile(
|
||||
r"Task govoplan[.]campaigns[.]send_email\[([0-9a-f-]{36})\] received",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TASK_SUCCEEDED_PATTERN = re.compile(
|
||||
r"Task govoplan[.]campaigns[.]send_email\[([0-9a-f-]{36})\] succeeded",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
WORKER_BOOTSTRAP = r"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from govoplan_core.celery_app import celery
|
||||
|
||||
visibility_timeout = int(os.environ["GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS"])
|
||||
celery.conf.broker_transport_options = {
|
||||
**dict(celery.conf.broker_transport_options or {}),
|
||||
"polling_interval": 0.25,
|
||||
"visibility_timeout": visibility_timeout,
|
||||
}
|
||||
celery.worker_main(
|
||||
[
|
||||
"worker",
|
||||
"--loglevel=INFO",
|
||||
"--pool=solo",
|
||||
"--concurrency=1",
|
||||
"--queues=send_email",
|
||||
f"--hostname={sys.argv[1]}@%h",
|
||||
"--without-gossip",
|
||||
"--without-mingle",
|
||||
"--without-heartbeat",
|
||||
]
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WorkerProcess:
|
||||
process: subprocess.Popen[bytes]
|
||||
log_path: Path
|
||||
log_handle: Any
|
||||
|
||||
def text(self) -> str:
|
||||
self.log_handle.flush()
|
||||
try:
|
||||
return self.log_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError as exc:
|
||||
raise AcceptanceError("Celery worker evidence log could not be read") from exc
|
||||
|
||||
def received_task_ids(self) -> tuple[str, ...]:
|
||||
return tuple(TASK_RECEIVED_PATTERN.findall(self.text()))
|
||||
|
||||
def succeeded_task_ids(self) -> tuple[str, ...]:
|
||||
return tuple(TASK_SUCCEEDED_PATTERN.findall(self.text()))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RedisBrokerState:
|
||||
queue_depth: int
|
||||
unacked_hash_count: int
|
||||
unacked_index_count: int
|
||||
|
||||
def as_dict(self) -> dict[str, int]:
|
||||
return {
|
||||
"queue_depth": self.queue_depth,
|
||||
"unacked_hash_count": self.unacked_hash_count,
|
||||
"unacked_index_count": self.unacked_index_count,
|
||||
}
|
||||
|
||||
|
||||
def _positive_int(value: str, *, label: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as exc:
|
||||
raise argparse.ArgumentTypeError(f"{label} must be a positive integer") from exc
|
||||
if parsed <= 0:
|
||||
raise argparse.ArgumentTypeError(f"{label} must be a positive integer")
|
||||
return parsed
|
||||
|
||||
|
||||
def _unused_loopback_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return int(probe.getsockname()[1])
|
||||
|
||||
|
||||
def _compose_command(
|
||||
*, compose_file: Path, project_name: str, operation: str
|
||||
) -> list[str]:
|
||||
prefix = [
|
||||
"docker",
|
||||
"compose",
|
||||
"--file",
|
||||
str(compose_file),
|
||||
"--project-name",
|
||||
project_name,
|
||||
]
|
||||
if operation == "up":
|
||||
return [*prefix, "up", "--detach", "redis"]
|
||||
if operation == "down":
|
||||
return [*prefix, "down", "--volumes", "--remove-orphans"]
|
||||
raise AcceptanceError("Unsupported Redis Compose operation")
|
||||
|
||||
|
||||
def _run_compose(
|
||||
command: list[str],
|
||||
*,
|
||||
environment: Mapping[str, str],
|
||||
timeout_seconds: int,
|
||||
) -> None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
env=dict(environment),
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise AcceptanceError("Redis Compose lifecycle command failed") from exc
|
||||
if completed.returncode != 0:
|
||||
raise AcceptanceError("Redis Compose lifecycle command failed")
|
||||
|
||||
|
||||
def _wait_for_redis(redis_url: str, *, timeout_seconds: int) -> None:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
client = Redis.from_url(
|
||||
redis_url,
|
||||
socket_connect_timeout=1,
|
||||
socket_timeout=1,
|
||||
decode_responses=False,
|
||||
)
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
if client.ping() is True:
|
||||
return
|
||||
except RedisError:
|
||||
pass
|
||||
time.sleep(0.25)
|
||||
finally:
|
||||
client.close()
|
||||
raise AcceptanceError("Isolated Redis broker did not become ready")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def isolated_redis_broker(
|
||||
*,
|
||||
compose_file: Path,
|
||||
timeout_seconds: int,
|
||||
requested_port: int | None = None,
|
||||
) -> Iterator[str]:
|
||||
if shutil.which("docker") is None:
|
||||
raise AcceptanceError("Docker CLI is required to start the isolated Redis broker")
|
||||
if not compose_file.is_file():
|
||||
raise AcceptanceError("Redis Compose definition is unavailable")
|
||||
port = requested_port or _unused_loopback_port()
|
||||
if port <= 0 or port > 65_535:
|
||||
raise AcceptanceError("Redis test port is invalid")
|
||||
project_name = f"govoplan-campaign-redelivery-{uuid4().hex[:12]}"
|
||||
environment = {
|
||||
**os.environ,
|
||||
"GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT": str(port),
|
||||
}
|
||||
lifecycle_attempted = False
|
||||
try:
|
||||
lifecycle_attempted = True
|
||||
_run_compose(
|
||||
_compose_command(
|
||||
compose_file=compose_file,
|
||||
project_name=project_name,
|
||||
operation="up",
|
||||
),
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
redis_url = f"redis://127.0.0.1:{port}/0"
|
||||
_wait_for_redis(redis_url, timeout_seconds=timeout_seconds)
|
||||
yield redis_url
|
||||
finally:
|
||||
if lifecycle_attempted:
|
||||
_run_compose(
|
||||
_compose_command(
|
||||
compose_file=compose_file,
|
||||
project_name=project_name,
|
||||
operation="down",
|
||||
),
|
||||
environment=environment,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _start_worker(runtime_root: Path, *, label: str) -> WorkerProcess:
|
||||
log_path = runtime_root / f"{label}.log"
|
||||
log_handle = log_path.open("wb")
|
||||
environment = {**os.environ, "PYTHONUNBUFFERED": "1"}
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, "-c", WORKER_BOOTSTRAP, label],
|
||||
env=environment,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
close_fds=True,
|
||||
)
|
||||
except Exception:
|
||||
log_handle.close()
|
||||
raise
|
||||
return WorkerProcess(process=process, log_path=log_path, log_handle=log_handle)
|
||||
|
||||
|
||||
def _wait_for_worker_ready(worker: WorkerProcess, *, timeout_seconds: int) -> None:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
if worker.process.poll() is not None:
|
||||
raise AcceptanceError("Celery worker exited before becoming ready")
|
||||
if " ready." in worker.text():
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise AcceptanceError("Celery worker did not become ready")
|
||||
|
||||
|
||||
def _wait_for_received_task(
|
||||
worker: WorkerProcess,
|
||||
*,
|
||||
timeout_seconds: int,
|
||||
expected_task_id: str | None = None,
|
||||
) -> str:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
received = worker.received_task_ids()
|
||||
if received:
|
||||
if len(set(received)) != 1:
|
||||
raise AcceptanceError("Celery worker received more than one task identity")
|
||||
task_id = received[0]
|
||||
if expected_task_id is not None and task_id != expected_task_id:
|
||||
raise AcceptanceError("Replacement worker received a different broker task")
|
||||
return task_id
|
||||
if worker.process.poll() is not None:
|
||||
raise AcceptanceError("Celery worker exited before receiving the task")
|
||||
time.sleep(0.2)
|
||||
raise AcceptanceError("Celery worker did not receive the broker task")
|
||||
|
||||
|
||||
def _wait_for_task_success(
|
||||
worker: WorkerProcess,
|
||||
*,
|
||||
task_id: str,
|
||||
timeout_seconds: int,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
while time.monotonic() < deadline:
|
||||
succeeded = worker.succeeded_task_ids()
|
||||
if task_id in succeeded:
|
||||
return
|
||||
if worker.process.poll() is not None:
|
||||
raise AcceptanceError("Replacement Celery worker exited before task success")
|
||||
time.sleep(0.2)
|
||||
raise AcceptanceError("Redelivered Celery task did not complete")
|
||||
|
||||
|
||||
def _kill_worker(worker: WorkerProcess, *, timeout_seconds: int) -> int:
|
||||
if worker.process.poll() is not None:
|
||||
raise AcceptanceError("Celery worker exited before controlled termination")
|
||||
worker.process.kill()
|
||||
try:
|
||||
return_code = worker.process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AcceptanceError("Celery worker could not be killed") from exc
|
||||
if return_code == 0:
|
||||
raise AcceptanceError("Celery worker termination was not forced")
|
||||
return return_code
|
||||
|
||||
|
||||
def _stop_worker(worker: WorkerProcess, *, timeout_seconds: int) -> None:
|
||||
if worker.process.poll() is None:
|
||||
worker.process.terminate()
|
||||
try:
|
||||
worker.process.wait(timeout=timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
worker.process.kill()
|
||||
worker.process.wait(timeout=timeout_seconds)
|
||||
worker.log_handle.close()
|
||||
|
||||
|
||||
def _broker_state(redis_url: str) -> RedisBrokerState:
|
||||
client = Redis.from_url(
|
||||
redis_url,
|
||||
socket_connect_timeout=2,
|
||||
socket_timeout=2,
|
||||
decode_responses=False,
|
||||
)
|
||||
try:
|
||||
return RedisBrokerState(
|
||||
queue_depth=int(client.llen("send_email")),
|
||||
unacked_hash_count=int(client.hlen("unacked")),
|
||||
unacked_index_count=int(client.zcard("unacked_index")),
|
||||
)
|
||||
except RedisError as exc:
|
||||
raise AcceptanceError("Redis broker state could not be inspected") from exc
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _wait_for_broker_drained(
|
||||
redis_url: str,
|
||||
*,
|
||||
timeout_seconds: int,
|
||||
) -> RedisBrokerState:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
last = RedisBrokerState(0, 0, 0)
|
||||
while time.monotonic() < deadline:
|
||||
last = _broker_state(redis_url)
|
||||
if last == RedisBrokerState(0, 0, 0):
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AcceptanceError("Redis broker retained delivery state after recovery")
|
||||
|
||||
|
||||
def _queue_evidence(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
expected = {
|
||||
"queued_count": 1,
|
||||
"skipped_count": 0,
|
||||
"blocked_count": 0,
|
||||
"enqueued_count": 1,
|
||||
"delivery_mode": "worker_queue",
|
||||
"worker_queue_available": True,
|
||||
"dry_run": False,
|
||||
}
|
||||
evidence = {key: payload.get(key) for key in expected}
|
||||
if evidence != expected:
|
||||
raise AcceptanceError("Campaign was not durably queued to one Celery task")
|
||||
return evidence
|
||||
|
||||
|
||||
def execute_redelivery_scenario(
|
||||
client: Any,
|
||||
headers: Mapping[str, str],
|
||||
*,
|
||||
fixture_path: Path,
|
||||
settings: TestbedSettings,
|
||||
endpoint: Any,
|
||||
redis_url: str,
|
||||
runtime_root: Path,
|
||||
snapshot_probe: Callable[[str], tuple[Mapping[str, Any], Mapping[str, Any]]],
|
||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
profile_id = create_mail_profile(
|
||||
client,
|
||||
headers,
|
||||
settings,
|
||||
name="Campaign Redis Celery redelivery drill",
|
||||
smtp_host=endpoint.host,
|
||||
smtp_port=endpoint.port,
|
||||
)
|
||||
prepared = prepare_campaign_scenario(
|
||||
client,
|
||||
headers,
|
||||
fixture_path=fixture_path,
|
||||
profile_id=profile_id,
|
||||
settings=settings,
|
||||
scenario="celery_broker_redelivery",
|
||||
snapshot_probe=snapshot_probe,
|
||||
)
|
||||
|
||||
first_worker = _start_worker(runtime_root, label="first-worker")
|
||||
replacement_worker: WorkerProcess | None = None
|
||||
try:
|
||||
_wait_for_worker_ready(
|
||||
first_worker,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
queued = _expect(
|
||||
client.post(
|
||||
f"/api/v1/campaigns/{prepared.campaign_id}/queue",
|
||||
headers=dict(headers),
|
||||
json={
|
||||
"version_id": prepared.version_id,
|
||||
"include_warnings": True,
|
||||
"enqueue_celery": True,
|
||||
"dry_run": False,
|
||||
},
|
||||
),
|
||||
200,
|
||||
"Celery-redelivery Campaign queue",
|
||||
)
|
||||
queue_evidence = _queue_evidence(queued)
|
||||
first_task_id = _wait_for_received_task(
|
||||
first_worker,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
if not endpoint.wait_for_data(settings.provider_timeout_seconds):
|
||||
raise AcceptanceError("Celery worker did not reach complete SMTP DATA")
|
||||
first_exit_code = _kill_worker(
|
||||
first_worker,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
endpoint.release_held_connection()
|
||||
|
||||
interrupted_state = _durable_state_evidence(
|
||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||
)
|
||||
expected_interrupted = {
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"sending": 1},
|
||||
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||
"unfinished_attempt_count": 1,
|
||||
}
|
||||
if interrupted_state != expected_interrupted:
|
||||
raise AcceptanceError("Killed worker state was not durably SMTP-in-progress")
|
||||
|
||||
replacement_worker = _start_worker(runtime_root, label="replacement-worker")
|
||||
_wait_for_worker_ready(
|
||||
replacement_worker,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
redelivered_task_id = _wait_for_received_task(
|
||||
replacement_worker,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
expected_task_id=first_task_id,
|
||||
)
|
||||
_wait_for_task_success(
|
||||
replacement_worker,
|
||||
task_id=redelivered_task_id,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
recovered_state = _durable_state_evidence(
|
||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||
)
|
||||
expected_recovered = {
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
"attempt_status_counts": {"outcome_unknown": 1},
|
||||
"unfinished_attempt_count": 0,
|
||||
}
|
||||
if recovered_state != expected_recovered:
|
||||
raise AcceptanceError("Redelivered task did not freeze the unfinished attempt")
|
||||
|
||||
protocol = endpoint.evidence()
|
||||
expected_protocol = {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
if protocol != expected_protocol:
|
||||
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
|
||||
broker_after = _wait_for_broker_drained(
|
||||
redis_url,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
first_received = first_worker.received_task_ids()
|
||||
replacement_received = replacement_worker.received_task_ids()
|
||||
if first_received != (first_task_id,) or replacement_received != (
|
||||
redelivered_task_id,
|
||||
):
|
||||
raise AcceptanceError(
|
||||
"Celery workers did not each receive the broker task exactly once"
|
||||
)
|
||||
report = _report_evidence(
|
||||
_expect(
|
||||
client.get(
|
||||
f"/api/v1/campaigns/{prepared.campaign_id}/report",
|
||||
headers=dict(headers),
|
||||
params={"version_id": prepared.version_id},
|
||||
),
|
||||
200,
|
||||
"Celery-redelivery Campaign report",
|
||||
)
|
||||
)
|
||||
if report["send_status_counts"] != {"outcome_unknown": 1}:
|
||||
raise AcceptanceError("Campaign report did not retain outcome_unknown")
|
||||
audit_actions = dict(
|
||||
sorted(audit_probe(prepared.campaign_id, prepared.version_id).items())
|
||||
)
|
||||
if not {
|
||||
"campaign.created",
|
||||
"campaign.validated",
|
||||
"campaign.messages_built",
|
||||
"campaign.queued",
|
||||
}.issubset(audit_actions):
|
||||
raise AcceptanceError("Celery-redelivery Campaign audit evidence is incomplete")
|
||||
|
||||
return {
|
||||
**prepared.public_evidence(),
|
||||
"queue": queue_evidence,
|
||||
"interrupted_durable_state": interrupted_state,
|
||||
"recovered_durable_state": recovered_state,
|
||||
"protocol": protocol,
|
||||
"report": report,
|
||||
"audit_actions": audit_actions,
|
||||
"broker": {
|
||||
"transport": "redis",
|
||||
"same_task_identity_redelivered": first_task_id
|
||||
== redelivered_task_id,
|
||||
"first_worker_received_count": len(first_received),
|
||||
"replacement_worker_received_count": len(replacement_received),
|
||||
**broker_after.as_dict(),
|
||||
},
|
||||
"supervision": {
|
||||
"first_worker_killed_after_complete_data": True,
|
||||
"first_worker_forced_exit": first_exit_code != 0,
|
||||
"replacement_worker_started": True,
|
||||
"replacement_worker_completed_redelivery": True,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
endpoint.release_held_connection()
|
||||
_stop_worker(first_worker, timeout_seconds=5)
|
||||
if replacement_worker is not None:
|
||||
_stop_worker(replacement_worker, timeout_seconds=5)
|
||||
|
||||
|
||||
def _runtime_module_versions(registry: Any) -> dict[str, str]:
|
||||
versions = {"core": _core_package_version()}
|
||||
versions.update(
|
||||
{
|
||||
manifest.id: manifest.version
|
||||
for manifest in registry.manifests()
|
||||
if manifest.id in {"access", "audit", "campaigns", "mail"}
|
||||
}
|
||||
)
|
||||
return versions
|
||||
|
||||
|
||||
def _create_runtime_root() -> Path:
|
||||
"""Create the isolated runtime under the platform-selected temp root."""
|
||||
|
||||
return Path(tempfile.mkdtemp(prefix="govoplan-campaign-celery-redelivery-"))
|
||||
|
||||
|
||||
def _bootstrap_and_run(
|
||||
*,
|
||||
settings: TestbedSettings,
|
||||
fixture_path: Path,
|
||||
redis_url: str,
|
||||
visibility_timeout_seconds: int,
|
||||
) -> dict[str, Any]:
|
||||
runtime_root = _create_runtime_root()
|
||||
database = None
|
||||
try:
|
||||
os.environ.update(
|
||||
{
|
||||
"APP_ENV": "test",
|
||||
"DATABASE_URL": f"sqlite:///{runtime_root / 'acceptance.db'}",
|
||||
"FILE_STORAGE_BACKEND": "local",
|
||||
"FILE_STORAGE_LOCAL_ROOT": str(runtime_root / "files"),
|
||||
"MOCK_MAILBOX_DIR": str(runtime_root / "mock-mailbox"),
|
||||
"DEV_BOOTSTRAP_ENABLED": "false",
|
||||
"CELERY_ENABLED": "true",
|
||||
"REDIS_URL": redis_url,
|
||||
"GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS": str(
|
||||
visibility_timeout_seconds
|
||||
),
|
||||
"GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS": "true",
|
||||
}
|
||||
)
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.bootstrap import bootstrap_dev_data
|
||||
from govoplan_core.db.session import configure_database, set_database
|
||||
from govoplan_core.settings import Settings, settings as core_settings
|
||||
from govoplan_core.tenancy.scope import create_scope_tables
|
||||
|
||||
isolated_settings = Settings()
|
||||
for field_name in Settings.model_fields:
|
||||
setattr(core_settings, field_name, getattr(isolated_settings, field_name))
|
||||
database = configure_database(os.environ["DATABASE_URL"])
|
||||
set_database(database)
|
||||
|
||||
from govoplan_core.server.app import app
|
||||
|
||||
create_scope_tables(database.engine)
|
||||
Base.metadata.create_all(bind=database.engine)
|
||||
with database.SessionLocal() as session:
|
||||
bootstrap_dev_data(
|
||||
session,
|
||||
api_key_secret="celery-redelivery-unused-api-key",
|
||||
user_password="celery-redelivery-admin",
|
||||
)
|
||||
|
||||
def snapshot_probe(
|
||||
version_id: str,
|
||||
) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
|
||||
from govoplan_campaign.backend.db.models import CampaignVersion
|
||||
|
||||
with database.SessionLocal() as session:
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if version is None:
|
||||
raise AcceptanceError("Campaign execution snapshot is unavailable")
|
||||
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
snapshot = (
|
||||
version.execution_snapshot
|
||||
if isinstance(version.execution_snapshot, dict)
|
||||
else {}
|
||||
)
|
||||
return raw, snapshot
|
||||
|
||||
def audit_probe(campaign_id: str, version_id: str) -> Mapping[str, int]:
|
||||
from govoplan_audit.backend.db.models import AuditLog
|
||||
|
||||
with database.SessionLocal() as session:
|
||||
actions = [
|
||||
row[0]
|
||||
for row in session.query(AuditLog.action)
|
||||
.filter(AuditLog.object_id.in_([campaign_id, version_id]))
|
||||
.all()
|
||||
if row[0] in EXPECTED_AUDIT_ACTIONS
|
||||
]
|
||||
return dict(Counter(actions))
|
||||
|
||||
def delivery_probe(campaign_id: str, version_id: str) -> Mapping[str, Any]:
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
||||
|
||||
with database.SessionLocal() as session:
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
job_ids = [job.id for job in jobs]
|
||||
attempts = (
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id.in_(job_ids))
|
||||
.all()
|
||||
if job_ids
|
||||
else []
|
||||
)
|
||||
return {
|
||||
"job_count": len(jobs),
|
||||
"send_status_counts": dict(
|
||||
Counter(job.send_status for job in jobs)
|
||||
),
|
||||
"attempt_status_counts": dict(
|
||||
Counter(attempt.status for attempt in attempts)
|
||||
),
|
||||
"unfinished_attempt_count": sum(
|
||||
1 for attempt in attempts if attempt.finished_at is None
|
||||
),
|
||||
}
|
||||
|
||||
with TestClient(app) as client:
|
||||
login = _expect(
|
||||
client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"email": "admin@example.local",
|
||||
"password": "celery-redelivery-admin",
|
||||
},
|
||||
),
|
||||
200,
|
||||
"Acceptance login",
|
||||
)
|
||||
access_token = str(login.get("access_token") or "")
|
||||
if not access_token:
|
||||
raise AcceptanceError("Acceptance login returned no access token")
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
if registry is None:
|
||||
raise AcceptanceError("The GovOPlaN module registry is unavailable")
|
||||
composition_versions = required_composition_versions(
|
||||
fixture_path,
|
||||
_runtime_module_versions(registry),
|
||||
)
|
||||
with smtp_fault_endpoint("post_data_hold") as endpoint:
|
||||
scenario = execute_redelivery_scenario(
|
||||
client,
|
||||
{"Authorization": f"Bearer {access_token}"},
|
||||
fixture_path=fixture_path,
|
||||
settings=settings,
|
||||
endpoint=endpoint,
|
||||
redis_url=redis_url,
|
||||
runtime_root=runtime_root,
|
||||
snapshot_probe=snapshot_probe,
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
)
|
||||
|
||||
evidence = {
|
||||
"schema_version": EVIDENCE_SCHEMA,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"fixture_sha256": hashlib.sha256(fixture_path.read_bytes()).hexdigest(),
|
||||
"declared_module_versions": composition_versions,
|
||||
"target": {
|
||||
"kind": "local_redis_celery_controlled_smtp",
|
||||
"isolated_temporary_database": True,
|
||||
"redis_started_by_runner": True,
|
||||
"worker_pool": "solo",
|
||||
"worker_prefetch_multiplier": 1,
|
||||
"task_acks_late": True,
|
||||
"task_reject_on_worker_lost": True,
|
||||
"visibility_timeout_seconds": visibility_timeout_seconds,
|
||||
},
|
||||
"scenario": scenario,
|
||||
"coverage": {
|
||||
"redis_broker_delivery": True,
|
||||
"celery_worker_processes": True,
|
||||
"forced_worker_loss_after_complete_data": True,
|
||||
"same_task_broker_redelivery": True,
|
||||
"durable_outcome_unknown_recovery": True,
|
||||
"duplicate_smtp_transaction_prevented": True,
|
||||
"production_daemon_supervisor": False,
|
||||
"target_provider": False,
|
||||
"source_artifact_provenance": False,
|
||||
},
|
||||
}
|
||||
_assert_evidence_safe(evidence, settings=settings)
|
||||
rendered = json.dumps(
|
||||
evidence,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
).encode("utf-8") + b"\n"
|
||||
if len(rendered) > MAX_EVIDENCE_BYTES:
|
||||
raise AcceptanceError("Celery-redelivery evidence exceeds its size limit")
|
||||
return evidence
|
||||
finally:
|
||||
if database is not None:
|
||||
database.engine.dispose()
|
||||
shutil.rmtree(runtime_root, ignore_errors=True)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--fixture", type=Path, default=DEFAULT_FIXTURE)
|
||||
parser.add_argument("--compose-file", type=Path, default=DEFAULT_COMPOSE_FILE)
|
||||
parser.add_argument("--redis-port", type=int)
|
||||
parser.add_argument(
|
||||
"--visibility-timeout-seconds",
|
||||
type=lambda value: _positive_int(value, label="visibility timeout"),
|
||||
default=os.environ.get(
|
||||
"GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS",
|
||||
"3",
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout-seconds",
|
||||
type=lambda value: _positive_int(value, label="timeout"),
|
||||
default=60,
|
||||
)
|
||||
parser.add_argument("--evidence", type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
try:
|
||||
settings = TestbedSettings.from_environment()
|
||||
settings.assert_local_testbed()
|
||||
settings = replace(
|
||||
settings,
|
||||
provider_timeout_seconds=args.timeout_seconds,
|
||||
)
|
||||
with isolated_redis_broker(
|
||||
compose_file=args.compose_file.resolve(),
|
||||
timeout_seconds=args.timeout_seconds,
|
||||
requested_port=args.redis_port,
|
||||
) as redis_url:
|
||||
evidence = _bootstrap_and_run(
|
||||
settings=settings,
|
||||
fixture_path=args.fixture.resolve(),
|
||||
redis_url=redis_url,
|
||||
visibility_timeout_seconds=args.visibility_timeout_seconds,
|
||||
)
|
||||
rendered = json.dumps(
|
||||
evidence,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
) + "\n"
|
||||
if args.evidence:
|
||||
args.evidence.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.evidence.write_text(rendered, encoding="utf-8")
|
||||
else:
|
||||
sys.stdout.write(rendered)
|
||||
return 0
|
||||
except AcceptanceError as exc:
|
||||
print(f"Campaign Celery-redelivery acceptance failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as exc:
|
||||
print(
|
||||
"Campaign Celery-redelivery acceptance failed unexpectedly "
|
||||
f"({type(exc).__name__}); inspect local service logs.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -118,9 +118,21 @@ The runner also terminates a dedicated OS process executing the registered
|
||||
Campaign send task after complete DATA, then invokes the task in a fresh
|
||||
process. The unfinished durable attempt must become `outcome_unknown` and the
|
||||
endpoint must observe no second connection or DATA transaction. This covers
|
||||
the worker task/process boundary. It does not cover Redis/Celery delivery,
|
||||
acknowledgement, broker redelivery, or daemon supervision; repeat the drill in
|
||||
that infrastructure before approving a production worker deployment.
|
||||
the worker task/process boundary but not a broker or daemon.
|
||||
|
||||
Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` for the maintained
|
||||
Redis/Celery delivery and broker redelivery boundary. It starts an isolated
|
||||
Redis Compose service and real Celery workers, kills the first solo worker after complete DATA while the
|
||||
late-ack task is unacknowledged, and requires the same task identity to reach a
|
||||
replacement worker after Redis visibility recovery. Passing evidence also
|
||||
requires durable `outcome_unknown`, an empty broker queue/unacked set, and
|
||||
exactly one SMTP connection and DATA transaction. Raw worker logs and task,
|
||||
database, endpoint, and credential identifiers are never retained.
|
||||
|
||||
That second runner proves local runner-supervised process replacement, not the
|
||||
production process manager. Repeat the worker-loss drill under the selected
|
||||
systemd, container, Kubernetes, or other production supervisor and the target
|
||||
Redis/SMTP infrastructure before deployment approval.
|
||||
|
||||
## Reporting Checks
|
||||
|
||||
|
||||
@@ -469,7 +469,7 @@ the current baseline:
|
||||
- the final audited **test / single send / single resend** semantics;
|
||||
- reusable SMTP batch sessions and their measured throughput benefit;
|
||||
- durable, idempotent Campaign report delivery through a Mail-owned outbox
|
||||
([`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17));
|
||||
([`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17));
|
||||
- a fully packaged one-command Campaign reference composition with production
|
||||
policy presets and target-provider certification;
|
||||
- function-bound Postbox delivery (stage 2 of the reference program);
|
||||
|
||||
@@ -65,6 +65,13 @@ Before tagging a campaign release:
|
||||
- Treat the task-process restart proof separately from the still-open
|
||||
Redis/Celery broker redelivery and daemon-supervision check; the coverage
|
||||
projection must keep `celery_broker_redelivery` false.
|
||||
- Run `dev/mail-testbed/run_celery_redelivery_acceptance.py` as a separate
|
||||
destructive worker-loss check. Retain its bounded evidence only when the same
|
||||
broker task is observed at both workers, the durable state is
|
||||
`outcome_unknown`, broker queue/unacked counts are zero, and the controlled
|
||||
SMTP endpoint observed one connection and one DATA transaction. This closes
|
||||
local Redis/Celery redelivery coverage, while production supervisor and
|
||||
target-provider coverage remain false until separately tested.
|
||||
- Confirm reusable mail profile selection is revalidated after campaign owner
|
||||
transfer.
|
||||
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted
|
||||
|
||||
@@ -49,7 +49,7 @@ Non-dry Campaign report email currently fails closed. It must not bypass the
|
||||
durable job/effect model through a direct SMTP call. Re-enabling it requires the
|
||||
Mail-owned idempotent outbox, attempt, unknown-outcome, and reconciliation path
|
||||
tracked in
|
||||
[`govoplan-mail#17`](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17).
|
||||
[`govoplan-mail#17`](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17).
|
||||
Report generation and dry-run validation remain separate from an external
|
||||
effect; recipient-level exports require recipient-export authorization.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -44,6 +44,7 @@ def _parse_scalar_for_target(target: str, value: Any) -> Any:
|
||||
"merge_reply_to",
|
||||
"merge_bounce_to",
|
||||
"merge_disposition_notification_to",
|
||||
"merge_postbox_targets",
|
||||
"combine_to",
|
||||
"combine_cc",
|
||||
"combine_bcc",
|
||||
|
||||
@@ -4,7 +4,15 @@ import copy
|
||||
from typing import Any
|
||||
|
||||
|
||||
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"})
|
||||
CAMPAIGN_MAIL_SERVER_KEYS = frozenset(
|
||||
{
|
||||
"mail_profile_id",
|
||||
"smtp_server_id",
|
||||
"smtp_credential_id",
|
||||
"imap_server_id",
|
||||
"imap_credential_id",
|
||||
}
|
||||
)
|
||||
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
|
||||
CAMPAIGN_OPT_IN_KEYS = frozenset(
|
||||
{"campaign_address_suggestions", "remember_used_addresses", "inline_guidance"}
|
||||
@@ -24,8 +32,8 @@ class CampaignMailProfileBoundaryError(ValueError):
|
||||
"""Raised when campaign JSON owns mail transport configuration.
|
||||
|
||||
SMTP/IMAP endpoints and credentials are Mail-module data. Campaign JSON
|
||||
may select one Mail-owned profile, but it must never copy or override that
|
||||
profile's transport configuration.
|
||||
may select Mail-owned profile, server, and credential identifiers, but it
|
||||
must never copy or override transport configuration.
|
||||
"""
|
||||
|
||||
|
||||
@@ -218,16 +226,53 @@ def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def campaign_mail_resource_ids(
|
||||
raw_json: dict[str, Any] | None,
|
||||
) -> dict[str, str | None]:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
if not isinstance(server, dict):
|
||||
return {
|
||||
"mail_profile_id": None,
|
||||
"smtp_server_id": None,
|
||||
"smtp_credential_id": None,
|
||||
"imap_server_id": None,
|
||||
"imap_credential_id": None,
|
||||
}
|
||||
return {
|
||||
key: (
|
||||
value.strip()
|
||||
if isinstance((value := server.get(key)), str) and value.strip()
|
||||
else None
|
||||
)
|
||||
for key in CAMPAIGN_MAIL_SERVER_KEYS
|
||||
}
|
||||
|
||||
|
||||
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
if not isinstance(server, dict):
|
||||
return ()
|
||||
|
||||
violations = [f"/server/{key}" for key in sorted(server) if key not in CAMPAIGN_MAIL_SERVER_KEYS]
|
||||
if "mail_profile_id" in server:
|
||||
profile_id = server["mail_profile_id"]
|
||||
if not isinstance(profile_id, str) or not profile_id.strip():
|
||||
violations.append("/server/mail_profile_id")
|
||||
for key in CAMPAIGN_MAIL_SERVER_KEYS:
|
||||
if key not in server:
|
||||
continue
|
||||
value = server[key]
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
violations.append(f"/server/{key}")
|
||||
references = campaign_mail_resource_ids(raw_json)
|
||||
if references["mail_profile_id"] is None and any(
|
||||
references[key]
|
||||
for key in references
|
||||
if key != "mail_profile_id"
|
||||
):
|
||||
violations.append("/server/mail_profile_id")
|
||||
for protocol in ("smtp", "imap"):
|
||||
if (
|
||||
references[f"{protocol}_credential_id"]
|
||||
and not references[f"{protocol}_server_id"]
|
||||
):
|
||||
violations.append(f"/server/{protocol}_server_id")
|
||||
return tuple(violations)
|
||||
|
||||
|
||||
@@ -240,9 +285,9 @@ def assert_campaign_uses_mail_profile_reference(
|
||||
if violations:
|
||||
fields = ", ".join(violations)
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign JSON may only reference a Mail-module profile through "
|
||||
f"server.mail_profile_id; remove campaign-local SMTP/IMAP settings ({fields}), "
|
||||
"select an authorized Mail profile, and save a new campaign version."
|
||||
"Campaign JSON may only reference Mail-owned profiles, servers, and credentials; "
|
||||
f"remove campaign-local SMTP/IMAP settings or invalid references ({fields}), "
|
||||
"select authorized Mail resources, and save a new campaign version."
|
||||
)
|
||||
if require_profile and campaign_mail_profile_id(raw_json) is None:
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
@@ -254,5 +299,8 @@ def assert_campaign_uses_mail_profile_reference(
|
||||
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""Return the complete public/persisted Campaign-to-Mail contract."""
|
||||
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
return {"mail_profile_id": profile_id} if profile_id else {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in campaign_mail_resource_ids(raw_json).items()
|
||||
if value
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class FieldType(StrEnum):
|
||||
DOUBLE = "double"
|
||||
DATE = "date"
|
||||
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
|
||||
ORGANIZATION_UNIT = "organization_unit"
|
||||
ORGANIZATION_FUNCTION = "organization_function"
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
@@ -92,6 +94,104 @@ class SendStatus(StrEnum):
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class DeliveryChannelPolicy(StrEnum):
|
||||
MAIL = "mail"
|
||||
POSTBOX = "postbox"
|
||||
MAIL_AND_POSTBOX = "mail_and_postbox"
|
||||
MAIL_THEN_POSTBOX = "mail_then_postbox"
|
||||
POSTBOX_THEN_MAIL = "postbox_then_mail"
|
||||
|
||||
@property
|
||||
def uses_mail(self) -> bool:
|
||||
return self in {
|
||||
DeliveryChannelPolicy.MAIL,
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
}
|
||||
|
||||
@property
|
||||
def uses_postbox(self) -> bool:
|
||||
return self != DeliveryChannelPolicy.MAIL
|
||||
|
||||
|
||||
class PostboxTargetMode(StrEnum):
|
||||
DIRECT = "direct"
|
||||
DERIVED = "derived"
|
||||
|
||||
|
||||
class PostboxTargetMatch(StrEnum):
|
||||
ID = "id"
|
||||
SLUG = "slug"
|
||||
|
||||
|
||||
class PostboxTargetConfig(StrictModel):
|
||||
id: str = Field(min_length=1, max_length=120)
|
||||
mode: PostboxTargetMode = PostboxTargetMode.DIRECT
|
||||
label: str | None = Field(default=None, max_length=500)
|
||||
|
||||
postbox_id: str | None = Field(default=None, max_length=36)
|
||||
address_key: str | None = Field(default=None, max_length=500)
|
||||
|
||||
template_id: str | None = Field(default=None, max_length=36)
|
||||
organization_unit_id: str | None = Field(default=None, max_length=36)
|
||||
organization_unit_field: str | None = Field(default=None, max_length=255)
|
||||
organization_unit_match: PostboxTargetMatch = PostboxTargetMatch.ID
|
||||
function_id: str | None = Field(default=None, max_length=36)
|
||||
function_field: str | None = Field(default=None, max_length=255)
|
||||
function_match: PostboxTargetMatch = PostboxTargetMatch.ID
|
||||
context_key: str | None = Field(default=None, max_length=255)
|
||||
context_field: str | None = Field(default=None, max_length=255)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_target_shape(self) -> "PostboxTargetConfig":
|
||||
direct_values = [self.postbox_id, self.address_key]
|
||||
if self.mode == PostboxTargetMode.DIRECT:
|
||||
if sum(bool(value) for value in direct_values) != 1:
|
||||
raise ValueError(
|
||||
"A direct Postbox target requires exactly one postbox_id "
|
||||
"or address_key."
|
||||
)
|
||||
if any(
|
||||
(
|
||||
self.template_id,
|
||||
self.organization_unit_id,
|
||||
self.organization_unit_field,
|
||||
self.function_id,
|
||||
self.function_field,
|
||||
self.context_key,
|
||||
self.context_field,
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
"A direct Postbox target cannot contain derived target fields."
|
||||
)
|
||||
return self
|
||||
|
||||
if any(direct_values):
|
||||
raise ValueError(
|
||||
"A derived Postbox target cannot contain postbox_id or address_key."
|
||||
)
|
||||
if not self.template_id:
|
||||
raise ValueError("A derived Postbox target requires template_id.")
|
||||
if bool(self.organization_unit_id) == bool(self.organization_unit_field):
|
||||
raise ValueError(
|
||||
"A derived Postbox target requires exactly one fixed or "
|
||||
"field-derived organization unit."
|
||||
)
|
||||
if bool(self.function_id) == bool(self.function_field):
|
||||
raise ValueError(
|
||||
"A derived Postbox target requires exactly one fixed or "
|
||||
"field-derived function."
|
||||
)
|
||||
if self.context_key and self.context_field:
|
||||
raise ValueError(
|
||||
"A derived Postbox target may use a fixed context or a context "
|
||||
"field, not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CampaignMeta(StrictModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -114,6 +214,10 @@ class MailProfileCapabilities(StrictModel):
|
||||
|
||||
class ServerConfig(StrictModel):
|
||||
mail_profile_id: str | None = None
|
||||
smtp_server_id: str | None = None
|
||||
smtp_credential_id: str | None = None
|
||||
imap_server_id: str | None = None
|
||||
imap_credential_id: str | None = None
|
||||
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
|
||||
|
||||
|
||||
@@ -446,6 +550,13 @@ class EntryConfig(StrictModel):
|
||||
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_disposition_notification_to: bool = True
|
||||
|
||||
channel_policy: DeliveryChannelPolicy | None = None
|
||||
postbox_targets: list[PostboxTargetConfig] = Field(
|
||||
default_factory=list,
|
||||
max_length=50,
|
||||
)
|
||||
merge_postbox_targets: bool = True
|
||||
|
||||
attachments: list[AttachmentConfig] = Field(default_factory=list)
|
||||
combine_attachments: bool = True
|
||||
|
||||
@@ -559,7 +670,17 @@ class RetryConfig(StrictModel):
|
||||
return values
|
||||
|
||||
|
||||
class PostboxDeliveryConfig(StrictModel):
|
||||
targets: list[PostboxTargetConfig] = Field(default_factory=list, max_length=50)
|
||||
classification: str = Field(default="internal", min_length=1, max_length=50)
|
||||
unresolved_target: Behavior = Behavior.BLOCK
|
||||
vacant_target: Behavior = Behavior.WARN
|
||||
duplicate_target: Behavior = Behavior.WARN
|
||||
|
||||
|
||||
class DeliveryConfig(StrictModel):
|
||||
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
|
||||
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
|
||||
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
||||
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
|
||||
retry: RetryConfig = Field(default_factory=RetryConfig)
|
||||
@@ -602,3 +723,23 @@ class CampaignConfig(StrictModel):
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_file.parent / path).resolve()
|
||||
|
||||
|
||||
def effective_delivery_channel_policy(
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
) -> DeliveryChannelPolicy:
|
||||
return entry.channel_policy or config.delivery.channel_policy
|
||||
|
||||
|
||||
def effective_postbox_targets(
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
) -> list[PostboxTargetConfig]:
|
||||
global_targets = list(config.delivery.postbox.targets)
|
||||
individual_targets = list(entry.postbox_targets)
|
||||
if not individual_targets:
|
||||
return global_targets
|
||||
if entry.merge_postbox_targets:
|
||||
return [*global_targets, *individual_targets]
|
||||
return individual_targets
|
||||
|
||||
321
src/govoplan_campaign/backend/campaign/postbox_targets.py
Normal file
321
src/govoplan_campaign/backend/campaign/postbox_targets.py
Normal file
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.field_values import (
|
||||
effective_entry_field_values,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
Behavior,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
PostboxTargetConfig,
|
||||
PostboxTargetMatch,
|
||||
PostboxTargetMode,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import postbox_integration
|
||||
from govoplan_campaign.backend.messages.models import (
|
||||
MessageIssue,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
|
||||
def _apply_behavior(
|
||||
current: MessageValidationStatus,
|
||||
behavior: Behavior,
|
||||
) -> MessageValidationStatus:
|
||||
if behavior == Behavior.BLOCK:
|
||||
return MessageValidationStatus.BLOCKED
|
||||
if behavior == Behavior.DROP:
|
||||
return MessageValidationStatus.EXCLUDED
|
||||
if behavior == Behavior.ASK and current not in {
|
||||
MessageValidationStatus.BLOCKED,
|
||||
MessageValidationStatus.EXCLUDED,
|
||||
}:
|
||||
return MessageValidationStatus.NEEDS_REVIEW
|
||||
if behavior == Behavior.WARN and current == MessageValidationStatus.READY:
|
||||
return MessageValidationStatus.WARNING
|
||||
return current
|
||||
|
||||
|
||||
def _issue(
|
||||
*,
|
||||
code: str,
|
||||
message: str,
|
||||
behavior: Behavior,
|
||||
) -> MessageIssue:
|
||||
return MessageIssue(
|
||||
severity="error" if behavior == Behavior.BLOCK else "warning",
|
||||
code=code,
|
||||
message=message,
|
||||
behavior=behavior.value,
|
||||
source="postbox",
|
||||
)
|
||||
|
||||
|
||||
def _field_value(
|
||||
values: dict[str, Any],
|
||||
field_name: str | None,
|
||||
) -> str | None:
|
||||
if not field_name:
|
||||
return None
|
||||
value = values.get(field_name)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _match_unit(
|
||||
catalog: PostboxDeliveryCatalogRef,
|
||||
value: str | None,
|
||||
match: PostboxTargetMatch,
|
||||
):
|
||||
if not value:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
unit
|
||||
for unit in catalog.organization_units
|
||||
if (unit.id if match == PostboxTargetMatch.ID else unit.slug) == value
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _match_function(unit, value: str | None, match: PostboxTargetMatch):
|
||||
if unit is None or not value:
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
function
|
||||
for function in unit.functions
|
||||
if (
|
||||
function.id
|
||||
if match == PostboxTargetMatch.ID
|
||||
else function.slug
|
||||
)
|
||||
== value
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _target_ref(
|
||||
target: PostboxTargetConfig,
|
||||
*,
|
||||
values: dict[str, Any],
|
||||
catalog: PostboxDeliveryCatalogRef,
|
||||
) -> tuple[PostboxTargetRef | None, str | None]:
|
||||
if target.mode == PostboxTargetMode.DIRECT:
|
||||
return (
|
||||
PostboxTargetRef(
|
||||
postbox_id=target.postbox_id,
|
||||
address_key=target.address_key,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
unit_value = target.organization_unit_id or _field_value(
|
||||
values,
|
||||
target.organization_unit_field,
|
||||
)
|
||||
unit_match = (
|
||||
PostboxTargetMatch.ID
|
||||
if target.organization_unit_id
|
||||
else target.organization_unit_match
|
||||
)
|
||||
unit = _match_unit(catalog, unit_value, unit_match)
|
||||
if unit is None:
|
||||
return None, (
|
||||
f"Organization unit {unit_value!r} could not be resolved by "
|
||||
f"{unit_match.value}."
|
||||
)
|
||||
|
||||
function_value = target.function_id or _field_value(
|
||||
values,
|
||||
target.function_field,
|
||||
)
|
||||
function_match = (
|
||||
PostboxTargetMatch.ID
|
||||
if target.function_id
|
||||
else target.function_match
|
||||
)
|
||||
function = _match_function(unit, function_value, function_match)
|
||||
if function is None:
|
||||
return None, (
|
||||
f"Organization function {function_value!r} could not be resolved "
|
||||
f"inside {unit.name!r} by {function_match.value}."
|
||||
)
|
||||
|
||||
context_key = target.context_key or _field_value(
|
||||
values,
|
||||
target.context_field,
|
||||
)
|
||||
return (
|
||||
PostboxTargetRef(
|
||||
template_id=target.template_id,
|
||||
organization_unit_id=unit.id,
|
||||
function_id=function.id,
|
||||
context_key=context_key,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _resolved_target_payload(
|
||||
target: PostboxTargetConfig,
|
||||
entry: PostboxDirectoryEntryRef,
|
||||
*,
|
||||
position: int,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"target_id": target.id,
|
||||
"position": position,
|
||||
"mode": target.mode.value,
|
||||
"requested": target.model_dump(mode="json", exclude_none=True),
|
||||
"postbox_id": entry.id,
|
||||
"address": entry.address,
|
||||
"address_key": entry.address_key,
|
||||
"name": entry.name,
|
||||
"status": entry.status,
|
||||
"classification": entry.classification,
|
||||
"organization_unit_id": entry.organization_unit_id,
|
||||
"organization_unit_name": entry.organization_unit_name,
|
||||
"function_id": entry.function_id,
|
||||
"function_name": entry.function_name,
|
||||
"context_key": entry.context_key,
|
||||
"template_revision_id": entry.template_revision_id,
|
||||
"holder_count": entry.holder_count,
|
||||
"vacant": entry.vacant,
|
||||
}
|
||||
|
||||
|
||||
def resolve_entry_postbox_targets(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
validation_status: MessageValidationStatus,
|
||||
materialize: bool,
|
||||
) -> tuple[
|
||||
list[dict[str, Any]],
|
||||
list[MessageIssue],
|
||||
MessageValidationStatus,
|
||||
]:
|
||||
integration = postbox_integration()
|
||||
policy = config.delivery.postbox
|
||||
targets = effective_postbox_targets(config, entry)
|
||||
if not targets:
|
||||
issue = _issue(
|
||||
code="postbox_target_missing",
|
||||
message="Postbox delivery requires at least one target.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
return (
|
||||
[],
|
||||
[issue],
|
||||
_apply_behavior(validation_status, policy.unresolved_target),
|
||||
)
|
||||
|
||||
try:
|
||||
catalog = integration.delivery_catalog(session, tenant_id=tenant_id)
|
||||
except Exception as exc:
|
||||
issue = _issue(
|
||||
code="postbox_unavailable",
|
||||
message=str(exc),
|
||||
behavior=Behavior.BLOCK,
|
||||
)
|
||||
return [], [issue], MessageValidationStatus.BLOCKED
|
||||
|
||||
values = effective_entry_field_values(config, entry)
|
||||
resolved: list[dict[str, Any]] = []
|
||||
issues: list[MessageIssue] = []
|
||||
status = validation_status
|
||||
seen_postbox_ids: set[str] = set()
|
||||
for position, target in enumerate(targets):
|
||||
target_ref, resolution_error = _target_ref(
|
||||
target,
|
||||
values=values,
|
||||
catalog=catalog,
|
||||
)
|
||||
if target_ref is None:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=resolution_error or "Postbox target could not be resolved.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
try:
|
||||
entry_ref = integration.resolve_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
target=target_ref,
|
||||
materialize=materialize,
|
||||
)
|
||||
except Exception as exc:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=f"Postbox target {target.id!r} could not be resolved: {exc}",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
if entry_ref is None:
|
||||
issue = _issue(
|
||||
code="postbox_target_unresolved",
|
||||
message=f"Postbox target {target.id!r} does not exist.",
|
||||
behavior=policy.unresolved_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.unresolved_target)
|
||||
continue
|
||||
if entry_ref.id in seen_postbox_ids:
|
||||
issue = _issue(
|
||||
code="postbox_target_duplicate",
|
||||
message=(
|
||||
f"Postbox {entry_ref.address!r} is selected more than once; "
|
||||
"it will receive one message."
|
||||
),
|
||||
behavior=policy.duplicate_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.duplicate_target)
|
||||
continue
|
||||
seen_postbox_ids.add(entry_ref.id)
|
||||
resolved.append(
|
||||
_resolved_target_payload(
|
||||
target,
|
||||
entry_ref,
|
||||
position=position,
|
||||
)
|
||||
)
|
||||
if entry_ref.vacant:
|
||||
issue = _issue(
|
||||
code="postbox_target_vacant",
|
||||
message=(
|
||||
f"Postbox {entry_ref.address!r} currently has no function "
|
||||
"holder."
|
||||
),
|
||||
behavior=policy.vacant_target,
|
||||
)
|
||||
issues.append(issue)
|
||||
status = _apply_behavior(status, policy.vacant_target)
|
||||
return resolved, issues, status
|
||||
|
||||
|
||||
def delivery_catalog_payload(catalog: PostboxDeliveryCatalogRef) -> dict[str, Any]:
|
||||
return asdict(catalog)
|
||||
@@ -10,7 +10,21 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .addressing import effective_address_lists
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
||||
from .models import (
|
||||
AttachmentConfig,
|
||||
CampaignConfig,
|
||||
DeliveryChannelPolicy,
|
||||
EntryConfig,
|
||||
FieldType,
|
||||
PostboxTargetConfig,
|
||||
SourceType,
|
||||
ZipArchiveConfig,
|
||||
ZipPasswordMode,
|
||||
ZipPasswordScope,
|
||||
ZipRuleMode,
|
||||
effective_delivery_channel_policy,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
|
||||
@@ -90,6 +104,8 @@ def _mapping_target_known(target: str, field_names: set[str]) -> bool:
|
||||
"merge_reply_to",
|
||||
"merge_bounce_to",
|
||||
"merge_disposition_notification_to",
|
||||
"merge_postbox_targets",
|
||||
"channel_policy",
|
||||
"combine_to",
|
||||
"combine_cc",
|
||||
"combine_bcc",
|
||||
@@ -337,10 +353,148 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
|
||||
]
|
||||
|
||||
|
||||
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
def _active_delivery_entries(config: CampaignConfig) -> list[EntryConfig]:
|
||||
if config.entries.is_inline:
|
||||
return [
|
||||
entry
|
||||
for entry in (config.entries.inline or [])
|
||||
if entry.active
|
||||
]
|
||||
return [config.entries.defaults or EntryConfig()]
|
||||
|
||||
|
||||
def _delivery_policies(config: CampaignConfig) -> set[DeliveryChannelPolicy]:
|
||||
return {
|
||||
effective_delivery_channel_policy(config, entry)
|
||||
for entry in _active_delivery_entries(config)
|
||||
}
|
||||
|
||||
|
||||
def _postbox_target_field_issues(
|
||||
config: CampaignConfig,
|
||||
target: PostboxTargetConfig,
|
||||
path: str,
|
||||
) -> list[SemanticIssue]:
|
||||
definitions = {field.name: field for field in config.fields}
|
||||
checks = (
|
||||
(
|
||||
target.organization_unit_field,
|
||||
FieldType.ORGANIZATION_UNIT,
|
||||
"organization unit",
|
||||
"organization_unit_field",
|
||||
),
|
||||
(
|
||||
target.function_field,
|
||||
FieldType.ORGANIZATION_FUNCTION,
|
||||
"organization function",
|
||||
"function_field",
|
||||
),
|
||||
(target.context_field, None, "context", "context_field"),
|
||||
)
|
||||
issues: list[SemanticIssue] = []
|
||||
for field_name, expected_type, label, key in checks:
|
||||
if not field_name:
|
||||
continue
|
||||
definition = definitions.get(field_name)
|
||||
if definition is None:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"postbox_target_field_missing",
|
||||
f"Postbox {label} field {field_name!r} is not declared.",
|
||||
f"{path}/{key}",
|
||||
)
|
||||
)
|
||||
elif expected_type is not None and definition.type != expected_type:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.WARNING,
|
||||
"postbox_target_field_type",
|
||||
(
|
||||
f"Postbox {label} field {field_name!r} should use "
|
||||
f"field type {expected_type.value!r}."
|
||||
),
|
||||
f"{path}/{key}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _postbox_delivery_issues(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
postbox_available: bool,
|
||||
) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
policies = _delivery_policies(config)
|
||||
if not any(policy.uses_postbox for policy in policies):
|
||||
return issues
|
||||
if not postbox_available:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"postbox_unavailable",
|
||||
(
|
||||
"This campaign uses Postbox delivery, but the Postbox "
|
||||
"module and its delivery directory are not active."
|
||||
),
|
||||
"/delivery/channel_policy",
|
||||
)
|
||||
)
|
||||
for entry_index, entry in enumerate(_active_delivery_entries(config)):
|
||||
policy = effective_delivery_channel_policy(config, entry)
|
||||
if not policy.uses_postbox:
|
||||
continue
|
||||
targets = effective_postbox_targets(config, entry)
|
||||
if not targets:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"postbox_target_missing",
|
||||
"Postbox delivery requires at least one target.",
|
||||
f"/entries/inline/{entry_index}/postbox_targets",
|
||||
)
|
||||
)
|
||||
continue
|
||||
seen_ids: set[str] = set()
|
||||
for target_index, target in enumerate(targets):
|
||||
target_path = (
|
||||
f"/entries/inline/{entry_index}/postbox_targets/"
|
||||
f"{target_index}"
|
||||
)
|
||||
if target.id in seen_ids:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.WARNING,
|
||||
"postbox_target_id_duplicate",
|
||||
f"Postbox target id {target.id!r} is repeated.",
|
||||
f"{target_path}/id",
|
||||
)
|
||||
)
|
||||
seen_ids.add(target.id)
|
||||
issues.extend(
|
||||
_postbox_target_field_issues(config, target, target_path)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _delivery_issues(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
postbox_available: bool,
|
||||
) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
policies = _delivery_policies(config)
|
||||
uses_mail = any(policy.uses_mail for policy in policies)
|
||||
profile_id = (config.server.mail_profile_id or "").strip()
|
||||
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
|
||||
if (
|
||||
(
|
||||
config.campaign.mode == "send"
|
||||
and uses_mail
|
||||
or config.delivery.imap_append_sent.enabled
|
||||
)
|
||||
and not profile_id
|
||||
):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
@@ -350,7 +504,12 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
)
|
||||
)
|
||||
capabilities = config.server.profile_capabilities
|
||||
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
|
||||
if (
|
||||
config.campaign.mode == "send"
|
||||
and uses_mail
|
||||
and profile_id
|
||||
and not capabilities.smtp_available
|
||||
):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
@@ -368,13 +527,22 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
"/server/mail_profile_id",
|
||||
)
|
||||
)
|
||||
issues.extend(
|
||||
_postbox_delivery_issues(
|
||||
config,
|
||||
postbox_available=postbox_available,
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
"""Require Campaign-owned sender data before a send-mode build."""
|
||||
|
||||
if config.campaign.mode != "send":
|
||||
if (
|
||||
config.campaign.mode != "send"
|
||||
or not any(policy.uses_mail for policy in _delivery_policies(config))
|
||||
):
|
||||
return []
|
||||
if config.entries.is_inline:
|
||||
return [
|
||||
@@ -385,7 +553,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
f"/entries/inline/{index}/from",
|
||||
)
|
||||
for index, entry in enumerate(config.entries.inline or [])
|
||||
if entry.active and not effective_address_lists(config, entry)["from"]
|
||||
if (
|
||||
entry.active
|
||||
and effective_delivery_channel_policy(config, entry).uses_mail
|
||||
and not effective_address_lists(config, entry)["from"]
|
||||
)
|
||||
]
|
||||
if config.recipients.from_:
|
||||
return []
|
||||
@@ -611,6 +783,7 @@ def validate_campaign_config(
|
||||
*,
|
||||
campaign_file: str | Path | None = None,
|
||||
check_files: bool = False,
|
||||
postbox_available: bool = False,
|
||||
) -> SemanticReport:
|
||||
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
|
||||
issues: list[SemanticIssue] = []
|
||||
@@ -622,7 +795,12 @@ def validate_campaign_config(
|
||||
issues.extend(_global_value_issues(config, declared_names))
|
||||
issues.extend(_attachment_path_issues(config))
|
||||
issues.extend(_zip_configuration_issues(config))
|
||||
issues.extend(_delivery_issues(config))
|
||||
issues.extend(
|
||||
_delivery_issues(
|
||||
config,
|
||||
postbox_available=postbox_available,
|
||||
)
|
||||
)
|
||||
issues.extend(_sender_issues(config))
|
||||
|
||||
entries = _entries_validation(
|
||||
|
||||
@@ -21,6 +21,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
new_uuid,
|
||||
)
|
||||
@@ -55,7 +56,10 @@ def _record_campaign_changes(session: OrmSession, _flush_context: object, _insta
|
||||
_record_job_change(session, obj)
|
||||
elif isinstance(obj, CampaignIssue):
|
||||
_record_issue_change(session, obj)
|
||||
elif isinstance(obj, (SendAttempt, ImapAppendAttempt)):
|
||||
elif isinstance(
|
||||
obj,
|
||||
(SendAttempt, ImapAppendAttempt, PostboxDeliveryAttempt),
|
||||
):
|
||||
_record_attempt_change(session, obj)
|
||||
|
||||
|
||||
@@ -182,8 +186,11 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"delivery_channel_policy",
|
||||
"postbox_status",
|
||||
"imap_status",
|
||||
"attempt_count",
|
||||
"postbox_attempt_count",
|
||||
"last_error",
|
||||
"queued_at",
|
||||
"claimed_at",
|
||||
@@ -191,6 +198,7 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
|
||||
"outcome_unknown_at",
|
||||
"sent_at",
|
||||
"resolved_recipients",
|
||||
"resolved_postbox_targets",
|
||||
"resolved_attachments",
|
||||
"issues_snapshot",
|
||||
),
|
||||
@@ -217,6 +225,8 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"delivery_channel_policy": job.delivery_channel_policy,
|
||||
"postbox_status": job.postbox_status,
|
||||
"imap_status": job.imap_status,
|
||||
},
|
||||
)
|
||||
@@ -247,10 +257,25 @@ def _record_issue_change(session: OrmSession, issue: CampaignIssue) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppendAttempt) -> None:
|
||||
def _record_attempt_change(
|
||||
session: OrmSession,
|
||||
attempt: SendAttempt | ImapAppendAttempt | PostboxDeliveryAttempt,
|
||||
) -> None:
|
||||
operation = _operation_for_object(
|
||||
attempt,
|
||||
changed_attrs=("status", "claim_token", "smtp_status_code", "smtp_response", "error_type", "error_message", "folder"),
|
||||
changed_attrs=(
|
||||
"status",
|
||||
"claim_token",
|
||||
"smtp_status_code",
|
||||
"smtp_response",
|
||||
"error_type",
|
||||
"error_message",
|
||||
"folder",
|
||||
"provider_delivery_id",
|
||||
"provider_message_id",
|
||||
"postbox_id",
|
||||
"evidence",
|
||||
),
|
||||
)
|
||||
if operation is None:
|
||||
return
|
||||
@@ -270,7 +295,13 @@ def _record_attempt_change(session: OrmSession, attempt: SendAttempt | ImapAppen
|
||||
payload={
|
||||
**(_campaign_payload(campaign) if campaign is not None else {"campaign_id": job.campaign_id}),
|
||||
"attempt_id": attempt_id,
|
||||
"attempt_kind": "imap" if isinstance(attempt, ImapAppendAttempt) else "smtp",
|
||||
"attempt_kind": (
|
||||
"postbox"
|
||||
if isinstance(attempt, PostboxDeliveryAttempt)
|
||||
else "imap"
|
||||
if isinstance(attempt, ImapAppendAttempt)
|
||||
else "smtp"
|
||||
),
|
||||
"job_id": job.id,
|
||||
"version_id": job.campaign_version_id,
|
||||
},
|
||||
|
||||
@@ -83,6 +83,9 @@ class JobSendStatus(StrEnum):
|
||||
CLAIMED = "claimed"
|
||||
SENDING = "sending"
|
||||
SMTP_ACCEPTED = "smtp_accepted"
|
||||
POSTBOX_ACCEPTED = "postbox_accepted"
|
||||
DELIVERED = "delivered"
|
||||
PARTIALLY_ACCEPTED = "partially_accepted"
|
||||
SENT = "sent" # legacy value retained for existing databases/reports
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
FAILED_TEMPORARY = "failed_temporary"
|
||||
@@ -90,6 +93,19 @@ class JobSendStatus(StrEnum):
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobPostboxStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
DELIVERING = "delivering"
|
||||
ACCEPTED = "accepted"
|
||||
ACCEPTED_VACANT = "accepted_vacant"
|
||||
PARTIALLY_ACCEPTED = "partially_accepted"
|
||||
REJECTED_TEMPORARY = "rejected_temporary"
|
||||
REJECTED_PERMANENT = "rejected_permanent"
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class JobImapStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
@@ -249,9 +265,26 @@ class CampaignJob(Base, TimestampMixin):
|
||||
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
|
||||
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
|
||||
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
|
||||
delivery_channel_policy: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="mail",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
postbox_status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=JobPostboxStatus.NOT_REQUESTED.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
|
||||
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
postbox_attempt_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
@@ -263,6 +296,11 @@ class CampaignJob(Base, TimestampMixin):
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||
|
||||
@@ -342,6 +380,79 @@ class ImapAppendAttempt(Base, TimestampMixin):
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class PostboxDeliveryAttempt(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_postbox_delivery_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"job_id",
|
||||
"target_key",
|
||||
"attempt_number",
|
||||
name="uq_campaign_postbox_attempt_target_number",
|
||||
),
|
||||
Index(
|
||||
"ix_campaign_postbox_attempt_idempotency",
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
),
|
||||
Index(
|
||||
"ix_campaign_postbox_attempt_job_status",
|
||||
"job_id",
|
||||
"status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=new_uuid,
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
job_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
target_key: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
target_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
provider_delivery_id: Mapped[str | None] = mapped_column(String(36))
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(36))
|
||||
postbox_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
address: Mapped[str | None] = mapped_column(String(500))
|
||||
holder_count: Mapped[int | None] = mapped_column(Integer)
|
||||
vacant: Mapped[bool | None] = mapped_column(Boolean)
|
||||
duplicate: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
error_type: Mapped[str | None] = mapped_column(String(255))
|
||||
error_code: Mapped[str | None] = mapped_column(String(100))
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -359,8 +470,10 @@ __all__ = [
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
"JobImapStatus",
|
||||
"JobPostboxStatus",
|
||||
"JobQueueStatus",
|
||||
"JobSendStatus",
|
||||
"JobValidationStatus",
|
||||
"SendAttempt",
|
||||
"PostboxDeliveryAttempt",
|
||||
]
|
||||
|
||||
@@ -292,7 +292,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
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"),
|
||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
||||
),
|
||||
related_modules=("mail", "notifications"),
|
||||
),
|
||||
@@ -338,7 +338,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
order=37,
|
||||
audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"),
|
||||
required_scopes=("campaigns:report:read",),
|
||||
route="/reports",
|
||||
route="/campaigns/reports",
|
||||
screen="Reports",
|
||||
help_contexts=("campaign.report",),
|
||||
prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",),
|
||||
@@ -351,7 +351,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
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"),),
|
||||
links=(DocumentationLink(label="Aggregate Campaign reports", href="/campaigns/reports", kind="runtime"),),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.view-delivery-report",
|
||||
|
||||
@@ -7,11 +7,24 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDeliveryProvider,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxDeliveryResult,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxDirectoryProvider,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import capability
|
||||
|
||||
|
||||
FILES_CAPABILITY = "files.campaign_attachments"
|
||||
MAIL_CAPABILITY = "mail.campaign_delivery"
|
||||
POSTBOX_CAPABILITY = CAPABILITY_POSTBOX_DELIVERY
|
||||
POSTBOX_DIRECTORY_CAPABILITY = CAPABILITY_POSTBOX_DIRECTORY
|
||||
|
||||
|
||||
class OptionalModuleUnavailable(RuntimeError):
|
||||
@@ -50,6 +63,10 @@ class MailProfileError(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class _PreparedCampaignSnapshot:
|
||||
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
|
||||
self._directory = directory
|
||||
@@ -209,9 +226,89 @@ class MailCampaignIntegration:
|
||||
return self._delegate.mock_mailbox()
|
||||
|
||||
|
||||
class PostboxCampaignIntegration:
|
||||
def __init__(
|
||||
self,
|
||||
delivery_delegate: object | None = None,
|
||||
directory_delegate: object | None = None,
|
||||
) -> None:
|
||||
self._delivery_delegate = (
|
||||
delivery_delegate
|
||||
if isinstance(delivery_delegate, PostboxDeliveryProvider)
|
||||
else None
|
||||
)
|
||||
self._directory_delegate = (
|
||||
directory_delegate
|
||||
if isinstance(directory_delegate, PostboxDirectoryProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return (
|
||||
self._delivery_delegate is not None
|
||||
and self._directory_delegate is not None
|
||||
)
|
||||
|
||||
def delivery_catalog(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> PostboxDeliveryCatalogRef:
|
||||
if self._directory_delegate is None:
|
||||
raise PostboxDeliveryUnavailable(
|
||||
"Postbox targets are unavailable because the Postbox module "
|
||||
"is not active."
|
||||
)
|
||||
return self._directory_delegate.delivery_catalog(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
|
||||
def resolve_postbox(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
target: PostboxTargetRef,
|
||||
materialize: bool = False,
|
||||
) -> PostboxDirectoryEntryRef | None:
|
||||
if self._directory_delegate is None:
|
||||
raise PostboxDeliveryUnavailable(
|
||||
"Postbox targets are unavailable because the Postbox module "
|
||||
"is not active."
|
||||
)
|
||||
return self._directory_delegate.resolve_postbox(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
target=target,
|
||||
materialize=materialize,
|
||||
)
|
||||
|
||||
def deliver(
|
||||
self,
|
||||
session: object,
|
||||
request: PostboxDeliveryRequest,
|
||||
) -> PostboxDeliveryResult:
|
||||
if self._delivery_delegate is None:
|
||||
raise PostboxDeliveryUnavailable(
|
||||
"Postbox delivery is unavailable because the Postbox module "
|
||||
"is not active."
|
||||
)
|
||||
return self._delivery_delegate.deliver(session, request)
|
||||
|
||||
|
||||
def files_integration() -> FilesCampaignIntegration:
|
||||
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
|
||||
|
||||
|
||||
def mail_integration() -> MailCampaignIntegration:
|
||||
return MailCampaignIntegration(capability(MAIL_CAPABILITY))
|
||||
|
||||
|
||||
def postbox_integration() -> PostboxCampaignIntegration:
|
||||
return PostboxCampaignIntegration(
|
||||
capability(POSTBOX_CAPABILITY),
|
||||
capability(POSTBOX_DIRECTORY_CAPABILITY),
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from govoplan_core.core.modules import (
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleInterfaceProvider,
|
||||
@@ -25,7 +26,13 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.views import ViewSurface
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
)
|
||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
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
|
||||
@@ -61,9 +68,9 @@ PERMISSIONS = (
|
||||
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
|
||||
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
|
||||
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
|
||||
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
|
||||
_permission("campaigns:campaign:send", "Send campaigns", "Start real Mail or Postbox delivery.", "Campaigns"),
|
||||
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
||||
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP or IMAP attempts after inspection.", "Campaigns"),
|
||||
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown Mail, Postbox, or IMAP attempts after inspection.", "Campaigns"),
|
||||
_permission("campaigns:diagnostic:read", "View campaign diagnostics", "Inspect worker claims and internal storage locators for campaign delivery troubleshooting.", "Campaign operations"),
|
||||
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
||||
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
||||
@@ -74,6 +81,22 @@ PERMISSIONS = (
|
||||
_permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"),
|
||||
)
|
||||
|
||||
OPERATOR_QUEUE_REQUIRED_ANY = (
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:campaign:control",
|
||||
)
|
||||
# Preserve the former /operator route identity for saved View projections.
|
||||
OPERATOR_QUEUE_SURFACE_ID = "campaigns.route.operator"
|
||||
# Preserve the former /reports route identity for saved View projections.
|
||||
REPORTS_SURFACE_ID = "campaigns.route.reports"
|
||||
REPORTS_REQUIRED_ANY = ("campaigns:report:read",)
|
||||
CAMPAIGN_MODULE_REQUIRED_ANY = (
|
||||
"campaigns:campaign:read",
|
||||
*REPORTS_REQUIRED_ANY,
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="campaign_aggregate_reader",
|
||||
@@ -139,6 +162,26 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
return {"campaigns": session.query(Campaign).filter(Campaign.tenant_id == tenant_id).count()}
|
||||
|
||||
|
||||
def _tenant_summary_batch(session, tenant_ids) -> dict[str, dict[str, int]]:
|
||||
from sqlalchemy import func
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
|
||||
ids = tuple(dict.fromkeys(str(tenant_id) for tenant_id in tenant_ids if tenant_id))
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
session.query(Campaign.tenant_id, func.count(Campaign.id))
|
||||
.filter(Campaign.tenant_id.in_(ids))
|
||||
.group_by(Campaign.tenant_id)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
tenant_id: {"campaigns": int(count)}
|
||||
for tenant_id, count in rows
|
||||
}
|
||||
|
||||
|
||||
def _campaigns_router(context: ModuleContext):
|
||||
from govoplan_campaign.backend.runtime import configure_runtime
|
||||
|
||||
@@ -156,9 +199,16 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.11",
|
||||
version="0.1.12",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("files", "mail", "notifications", "addresses"),
|
||||
optional_capabilities=(CAPABILITY_ACCESS_REFERENCE_OPTIONS,),
|
||||
optional_dependencies=(
|
||||
"files",
|
||||
"mail",
|
||||
"notifications",
|
||||
"addresses",
|
||||
"postbox",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="campaigns.delivery_tasks", version="0.1.6"),
|
||||
@@ -167,6 +217,12 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="files.campaign_attachments",
|
||||
version_min="0.1.0",
|
||||
@@ -191,49 +247,73 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_POSTBOX_DELIVERY,
|
||||
version_min="0.1.1",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name=CAPABILITY_POSTBOX_DIRECTORY,
|
||||
version_min="0.1.1",
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_campaigns_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
||||
nav_items=(
|
||||
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
|
||||
NavItem(
|
||||
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",
|
||||
),
|
||||
order=30,
|
||||
),
|
||||
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
|
||||
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="campaigns",
|
||||
package_name="@govoplan/campaign-webui",
|
||||
nav_items=(
|
||||
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=("campaigns:campaign:read",), order=20),
|
||||
NavItem(
|
||||
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",
|
||||
),
|
||||
order=30,
|
||||
routes=(
|
||||
FrontendRoute(
|
||||
path="/campaigns",
|
||||
component="CampaignModulePage",
|
||||
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||
order=20,
|
||||
),
|
||||
NavItem(path="/reports", label="Reports", icon="clipboard-pen-line", required_any=("campaigns:report:read",), order=70),
|
||||
FrontendRoute(
|
||||
path="/campaigns/queue",
|
||||
component="OperatorQueuePage",
|
||||
required_all=("campaigns:campaign:read",),
|
||||
required_any=OPERATOR_QUEUE_REQUIRED_ANY,
|
||||
order=21,
|
||||
surface_id=OPERATOR_QUEUE_SURFACE_ID,
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/campaigns/:campaignId/*",
|
||||
component="CampaignWorkspace",
|
||||
required_any=("campaigns:campaign:read",),
|
||||
order=22,
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/campaigns/reports",
|
||||
component="AggregateReportsPage",
|
||||
required_any=REPORTS_REQUIRED_ANY,
|
||||
order=22,
|
||||
surface_id=REPORTS_SURFACE_ID,
|
||||
),
|
||||
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(path="/campaigns", label="Campaigns", icon="campaign", required_any=CAMPAIGN_MODULE_REQUIRED_ANY, order=20),
|
||||
NavItem(path="/templates", label="Templates", icon="layout-template", order=90),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="campaigns.widget.activity",
|
||||
module_id="campaigns",
|
||||
kind="section",
|
||||
label="Campaign activity widget",
|
||||
order=50,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="campaigns",
|
||||
@@ -251,6 +331,7 @@ manifest = ModuleManifest(
|
||||
campaign_models.AttachmentInstance,
|
||||
campaign_models.SendAttempt,
|
||||
campaign_models.ImapAppendAttempt,
|
||||
campaign_models.PostboxDeliveryAttempt,
|
||||
label="Campaigns",
|
||||
),
|
||||
retirement_notes="Destructive retirement drops campaign-owned database tables after the installer captures a database snapshot.",
|
||||
@@ -267,11 +348,57 @@ manifest = ModuleManifest(
|
||||
campaign_models.AttachmentInstance,
|
||||
campaign_models.SendAttempt,
|
||||
campaign_models.ImapAppendAttempt,
|
||||
campaign_models.PostboxDeliveryAttempt,
|
||||
label="Campaigns",
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
*CAMPAIGN_USER_DOCUMENTATION,
|
||||
DocumentationTopic(
|
||||
id="campaigns.postbox-delivery",
|
||||
title="Deliver Campaign messages to Postboxes",
|
||||
summary="Target one or more exact or organization-derived Postboxes per recipient row, independently or alongside Mail.",
|
||||
body="Configure campaign-wide targets and optional per-row additions or replacements. Derived targets resolve a published Postbox template with organization unit, function, and optional context values, including values sourced from Campaign fields. Targets are frozen during build. Fallback crosses to the second channel only after a confirmed pre-acceptance rejection; accepted or outcome-unknown effects never trigger fallback.",
|
||||
layer="available",
|
||||
documentation_types=("user", "admin"),
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender", "campaign_operator"),
|
||||
order=45,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns", "postbox"),
|
||||
any_scopes=("campaigns:recipient:write", "campaigns:campaign:send", "campaigns:campaign:reconcile"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
DocumentationLink(label="Postboxes", href="/postbox", kind="runtime"),
|
||||
DocumentationLink(label="Campaign schema", href="/api/v1/campaigns/schema", kind="api"),
|
||||
),
|
||||
related_modules=("postbox", "organizations", "idm", "mail", "audit"),
|
||||
unlocks=("Audited direct, derived, combined, and pre-acceptance fallback delivery to Postboxes.",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/campaigns/{campaign_id}/global-settings",
|
||||
"screen": "Campaign delivery defaults and recipient data",
|
||||
"prerequisites": [
|
||||
"Campaign and Postbox are installed and active.",
|
||||
"At least one exact Postbox or published Postbox template is available.",
|
||||
],
|
||||
"steps": [
|
||||
"Choose Postbox, Mail and Postbox, or an ordered fallback policy.",
|
||||
"Configure one or more campaign-wide targets.",
|
||||
"Optionally add or replace targets for individual recipient rows.",
|
||||
"Validate and build to freeze the resolved Postbox addresses before review.",
|
||||
"Review, queue, and inspect channel-specific delivery evidence in the report.",
|
||||
],
|
||||
"outcome": "Each active row resolves an auditable set of Postbox targets without introducing a hard Campaign dependency on Postbox.",
|
||||
"verification": "Confirm the frozen target list in the built job and verify accepted, rejected, or outcome-unknown attempts in Campaign reporting.",
|
||||
"related_topic_ids": [
|
||||
"campaigns.workflow.prepare-validate-and-build",
|
||||
"campaigns.workflow.retry-and-reconcile",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.mail-profile-user-journey",
|
||||
title="Choose a Mail profile for campaign delivery",
|
||||
@@ -368,15 +495,15 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||
DocumentationLink(label="Campaign reports", href="/reports", kind="runtime"),
|
||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
||||
DocumentationLink(label="Campaign reports", href="/campaigns/reports", kind="runtime"),
|
||||
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("mail", "audit"),
|
||||
unlocks=("Fail-closed recovery without exposing Mail credentials.",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/operator",
|
||||
"route": "/campaigns/queue",
|
||||
"screen": "Campaign operator queue",
|
||||
"section": "Profile-backed delivery recovery",
|
||||
"related_topic_ids": [
|
||||
@@ -493,8 +620,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="campaigns.workflow.retry-and-reconcile",
|
||||
title="Retry only known failures and reconcile uncertain effects",
|
||||
summary="Keep safe-to-retry failures separate from SMTP or IMAP effects whose outcome is unknown.",
|
||||
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown SMTP or IMAP effect. Inspect external evidence, reconcile SMTP as accepted or not sent, and reconcile IMAP as appended or not appended; repairing Sent never resends accepted SMTP mail.",
|
||||
summary="Keep safe-to-retry failures separate from Mail, Postbox, or IMAP effects whose outcome is unknown.",
|
||||
body="A retry creates new attempt evidence and is valid only for an explicitly eligible state. Never blindly retry an unknown Mail, Postbox, or IMAP effect. Inspect external evidence and reconcile the affected channel before continuing. Accepted Mail attempts and accepted Postbox targets are immutable during partial retries, and repairing Sent never resends accepted Mail.",
|
||||
layer="evidence",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
@@ -506,14 +633,14 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
||||
DocumentationLink(label="Campaign delivery runbook", href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md", kind="repository"),
|
||||
),
|
||||
related_modules=("mail", "audit"),
|
||||
unlocks=("Evidence-backed recovery without accidental duplicate external effects.",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/operator",
|
||||
"route": "/campaigns/queue",
|
||||
"screen": "Campaign operator queue",
|
||||
"help_contexts": ["campaign.review-send", "campaign.report", "campaign.audit"],
|
||||
"prerequisites": [
|
||||
@@ -553,14 +680,14 @@ manifest = ModuleManifest(
|
||||
DocumentationLink(label="Campaign handbook", href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", kind="repository"),
|
||||
DocumentationLink(label="Reference examples and release checklist", href="govoplan-campaign/docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md", kind="repository"),
|
||||
),
|
||||
related_modules=("mail", "files", "addresses", "audit"),
|
||||
related_modules=("mail", "postbox", "files", "addresses", "audit"),
|
||||
unlocks=("A repeatable, supportable Campaign demonstration rather than an unverified module assembly.",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/campaigns",
|
||||
"screen": "Campaign reference composition",
|
||||
"section": "Release, security, integration, and recovery assurance",
|
||||
"verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target SMTP/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
|
||||
"verification": "Run the maintained examples, module-permutation tests, migration and restore drills, target Mail/Postbox/IMAP checks, version-alignment gate, WebUI/i18n checks, and full security audit for the pinned composition.",
|
||||
"related_topic_ids": [
|
||||
"campaigns.workflow.prepare-validate-and-build",
|
||||
"campaigns.workflow.complete-review",
|
||||
|
||||
@@ -33,6 +33,7 @@ from govoplan_campaign.backend.campaign.models import (
|
||||
ZipArchiveConfig,
|
||||
ZipPasswordMode,
|
||||
ZipPasswordScope,
|
||||
effective_delivery_channel_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
@@ -425,8 +426,14 @@ def _attach_files(
|
||||
|
||||
return attached_count
|
||||
|
||||
def _imap_initial_status(config: CampaignConfig) -> ImapStatus:
|
||||
if config.delivery.imap_append_sent.enabled:
|
||||
def _imap_initial_status(
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
) -> ImapStatus:
|
||||
if (
|
||||
effective_delivery_channel_policy(config, entry).uses_mail
|
||||
and config.delivery.imap_append_sent.enabled
|
||||
):
|
||||
return ImapStatus.PENDING
|
||||
return ImapStatus.NOT_REQUESTED
|
||||
|
||||
@@ -520,7 +527,11 @@ def _message_draft(
|
||||
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
|
||||
imap_status = (
|
||||
_imap_initial_status(config, entry)
|
||||
if build_status == BuildStatus.BUILT
|
||||
else ImapStatus.SKIPPED
|
||||
)
|
||||
return MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
@@ -529,6 +540,10 @@ def _message_draft(
|
||||
validation_status=validation_status,
|
||||
send_status=send_status,
|
||||
imap_status=imap_status,
|
||||
delivery_channel_policy=effective_delivery_channel_policy(
|
||||
config,
|
||||
entry,
|
||||
).value,
|
||||
subject=subject,
|
||||
from_=_message_address(context.sender),
|
||||
from_all=_message_addresses(context.senders),
|
||||
@@ -809,17 +824,19 @@ def build_entry_message(
|
||||
if not entry.active:
|
||||
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
|
||||
|
||||
context.validation_status = _validate_required_sender(
|
||||
context.senders,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
context.validation_status = _validate_required_recipients(
|
||||
config,
|
||||
context.recipients,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
channel_policy = effective_delivery_channel_policy(config, entry)
|
||||
if channel_policy.uses_mail:
|
||||
context.validation_status = _validate_required_sender(
|
||||
context.senders,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
context.validation_status = _validate_required_recipients(
|
||||
config,
|
||||
context.recipients,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
rendered = _render_message_template(config, campaign_file, entry)
|
||||
context.validation_status = _validate_rendered_template(
|
||||
config,
|
||||
|
||||
@@ -78,6 +78,7 @@ class MessageDraft(BaseModel):
|
||||
validation_status: MessageValidationStatus
|
||||
send_status: SendStatus
|
||||
imap_status: ImapStatus
|
||||
delivery_channel_policy: str = "mail"
|
||||
|
||||
subject: str | None = None
|
||||
from_: MessageAddress | None = Field(default=None, alias="from")
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""repair a missing IMAP append attempt claim token
|
||||
|
||||
Revision ID: e9f0a1b2c3d4
|
||||
Revises: d8b3e2c1f4a5
|
||||
Create Date: 2026-07-28 23:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e9f0a1b2c3d4"
|
||||
down_revision = "d8b3e2c1f4a5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
|
||||
if "claim_token" not in columns:
|
||||
op.add_column(
|
||||
"imap_append_attempts",
|
||||
sa.Column("claim_token", sa.String(length=36), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The column belongs to revision 3c4d5e6f8192. This repair revision only
|
||||
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
|
||||
pass
|
||||
@@ -0,0 +1,22 @@
|
||||
"""add governed campaign Postbox delivery
|
||||
|
||||
Revision ID: f0a1b2c3d4e5
|
||||
Revises: e9f0a1b2c3d4
|
||||
Create Date: 2026-07-29 02:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"f0a1b2c3d4e5_v0115_postbox_delivery"
|
||||
)
|
||||
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
upgrade = _migration.upgrade
|
||||
downgrade = _migration.downgrade
|
||||
@@ -0,0 +1,32 @@
|
||||
"""repair a missing IMAP append attempt claim token
|
||||
|
||||
Revision ID: e9f0a1b2c3d4
|
||||
Revises: d8b3e2c1f4a5
|
||||
Create Date: 2026-07-28 23:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e9f0a1b2c3d4"
|
||||
down_revision = "d8b3e2c1f4a5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
columns = {column["name"] for column in sa.inspect(bind).get_columns("imap_append_attempts")}
|
||||
if "claim_token" not in columns:
|
||||
op.add_column(
|
||||
"imap_append_attempts",
|
||||
sa.Column("claim_token", sa.String(length=36), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# The column belongs to revision 3c4d5e6f8192. This repair revision only
|
||||
# restores drift, so downgrading to d8b3e2c1f4a5 must retain it.
|
||||
pass
|
||||
@@ -0,0 +1,153 @@
|
||||
"""add governed campaign Postbox delivery
|
||||
|
||||
Revision ID: f0a1b2c3d4e5
|
||||
Revises: e9f0a1b2c3d4
|
||||
Create Date: 2026-07-29 02:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f0a1b2c3d4e5"
|
||||
down_revision = "e9f0a1b2c3d4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"delivery_channel_policy",
|
||||
sa.String(length=30),
|
||||
nullable=False,
|
||||
server_default="mail",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"postbox_status",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
server_default="not_requested",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"postbox_attempt_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"resolved_postbox_targets",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default="[]",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_campaign_jobs_delivery_channel_policy"),
|
||||
"campaign_jobs",
|
||||
["delivery_channel_policy"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_campaign_jobs_postbox_status"),
|
||||
"campaign_jobs",
|
||||
["postbox_status"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"campaign_postbox_delivery_attempts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("job_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("target_key", sa.String(length=64), nullable=False),
|
||||
sa.Column("target_index", sa.Integer(), nullable=False),
|
||||
sa.Column("attempt_number", sa.Integer(), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("target_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("provider_delivery_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("provider_message_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("postbox_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("address", sa.String(length=500), nullable=True),
|
||||
sa.Column("holder_count", sa.Integer(), nullable=True),
|
||||
sa.Column("vacant", sa.Boolean(), nullable=True),
|
||||
sa.Column(
|
||||
"duplicate",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
sa.Column("error_type", sa.String(length=255), nullable=True),
|
||||
sa.Column("error_code", sa.String(length=100), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["job_id"],
|
||||
["campaign_jobs.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_postbox_delivery_attempts_job_id_campaign_jobs"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_campaign_postbox_delivery_attempts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"job_id",
|
||||
"target_key",
|
||||
"attempt_number",
|
||||
name="uq_campaign_postbox_attempt_target_number",
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "job_id", "status", "postbox_id"):
|
||||
op.create_index(
|
||||
op.f(f"ix_campaign_postbox_delivery_attempts_{column}"),
|
||||
"campaign_postbox_delivery_attempts",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_postbox_attempt_job_status",
|
||||
"campaign_postbox_delivery_attempts",
|
||||
["job_id", "status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_postbox_attempt_idempotency",
|
||||
"campaign_postbox_delivery_attempts",
|
||||
["tenant_id", "idempotency_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("campaign_postbox_delivery_attempts")
|
||||
op.drop_index(
|
||||
op.f("ix_campaign_jobs_postbox_status"),
|
||||
table_name="campaign_jobs",
|
||||
)
|
||||
op.drop_index(
|
||||
op.f("ix_campaign_jobs_delivery_channel_policy"),
|
||||
table_name="campaign_jobs",
|
||||
)
|
||||
op.drop_column("campaign_jobs", "resolved_postbox_targets")
|
||||
op.drop_column("campaign_jobs", "postbox_attempt_count")
|
||||
op.drop_column("campaign_jobs", "postbox_status")
|
||||
op.drop_column("campaign_jobs", "delivery_channel_policy")
|
||||
@@ -22,6 +22,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
CampaignVersionWorkflowState,
|
||||
JobImapStatus,
|
||||
JobPostboxStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
@@ -30,13 +31,26 @@ from govoplan_campaign.backend.campaign.loader import load_campaign_json, valida
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
campaign_mail_profile_id,
|
||||
campaign_mail_resource_ids,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||
from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
resolve_entry_postbox_targets,
|
||||
)
|
||||
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, SendStatus
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
CampaignConfig,
|
||||
DeliveryChannelPolicy,
|
||||
SendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
files_integration,
|
||||
mail_integration,
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
|
||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||
@@ -73,6 +87,7 @@ def load_campaign_config_from_json(
|
||||
materialized = copy.deepcopy(raw_json)
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if profile_id:
|
||||
references = campaign_mail_resource_ids(raw_json)
|
||||
summary = mail_integration().campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -80,6 +95,10 @@ def load_campaign_config_from_json(
|
||||
profile_id=profile_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
smtp_server_id=references["smtp_server_id"],
|
||||
smtp_credential_id=references["smtp_credential_id"],
|
||||
imap_server_id=references["imap_server_id"],
|
||||
imap_credential_id=references["imap_credential_id"],
|
||||
)
|
||||
materialized.setdefault("server", {})["profile_capabilities"] = {
|
||||
"smtp_available": bool(summary.get("smtp_available")),
|
||||
@@ -323,9 +342,19 @@ def validate_campaign_version(
|
||||
) as prepared:
|
||||
managed_raw = load_campaign_json(prepared.path)
|
||||
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||
report = validate_campaign_config(managed_config, campaign_file=prepared.path, check_files=True)
|
||||
report = validate_campaign_config(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
check_files=True,
|
||||
postbox_available=postbox_integration().available,
|
||||
)
|
||||
else:
|
||||
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False)
|
||||
report = validate_campaign_config(
|
||||
config,
|
||||
campaign_file=snapshot_path,
|
||||
check_files=False,
|
||||
postbox_available=postbox_integration().available,
|
||||
)
|
||||
report_json = report.model_dump(mode="json")
|
||||
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
|
||||
version.validation_summary = report_json
|
||||
@@ -388,6 +417,7 @@ def _job_from_message(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
message: MessageDraft,
|
||||
resolved_postbox_targets: list[dict[str, Any]] | None = None,
|
||||
) -> CampaignJob:
|
||||
recipient_email = message.to[0].email if message.to else None
|
||||
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
|
||||
@@ -411,6 +441,12 @@ def _job_from_message(
|
||||
if message.send_status == SendStatus.SKIPPED
|
||||
else JobSendStatus.NOT_QUEUED.value
|
||||
),
|
||||
delivery_channel_policy=message.delivery_channel_policy,
|
||||
postbox_status=(
|
||||
JobPostboxStatus.PENDING.value
|
||||
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
|
||||
else JobPostboxStatus.NOT_REQUESTED.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,
|
||||
@@ -422,12 +458,184 @@ def _job_from_message(
|
||||
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
||||
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
||||
},
|
||||
resolved_postbox_targets=resolved_postbox_targets or [],
|
||||
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
|
||||
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
||||
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_built_postbox_targets(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
config: CampaignConfig,
|
||||
built_messages: list[Any],
|
||||
entries_by_index: dict[int, Any],
|
||||
) -> dict[int, list[dict[str, Any]]]:
|
||||
resolved_by_index: dict[int, list[dict[str, Any]]] = {}
|
||||
for built in built_messages:
|
||||
if not DeliveryChannelPolicy(built.draft.delivery_channel_policy).uses_postbox:
|
||||
continue
|
||||
entry = entries_by_index.get(built.draft.entry_index)
|
||||
if entry is None:
|
||||
raise CampaignPersistenceError("Built recipient row is missing from the campaign input.")
|
||||
resolved, issues, validation_status = resolve_entry_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=config,
|
||||
entry=entry,
|
||||
validation_status=built.draft.validation_status,
|
||||
materialize=True,
|
||||
)
|
||||
built.draft.issues.extend(issues)
|
||||
built.draft.validation_status = validation_status
|
||||
resolved_by_index[built.draft.entry_index] = resolved
|
||||
return resolved_by_index
|
||||
|
||||
|
||||
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||
if isinstance(message_payload, dict):
|
||||
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
||||
report_json.update({
|
||||
"built_at": datetime.now(UTC).isoformat(),
|
||||
"build_token": uuid4().hex,
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
return report_json
|
||||
|
||||
|
||||
def _replace_version_jobs(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
built_messages: list[Any],
|
||||
postbox_targets_by_index: dict[int, list[dict[str, Any]]],
|
||||
) -> list[tuple[CampaignJob, MessageDraft]]:
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version_id,
|
||||
CampaignIssue.job_id.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).delete(synchronize_session=False)
|
||||
session.flush()
|
||||
|
||||
pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||
for built in built_messages:
|
||||
job = _job_from_message(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
message=built.draft,
|
||||
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
|
||||
)
|
||||
session.add(job)
|
||||
pairs.append((job, built.draft))
|
||||
session.flush()
|
||||
return pairs
|
||||
|
||||
|
||||
def _mail_execution_profile(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
config: CampaignConfig,
|
||||
jobs: list[CampaignJob],
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
if not any(DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs):
|
||||
return None, {}
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created.")
|
||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if profile_id is None:
|
||||
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages that use Mail.")
|
||||
summary = profile_delivery_summary(session, version)
|
||||
if not summary.get("smtp_transport_revision"):
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision.")
|
||||
return profile_id, summary
|
||||
|
||||
|
||||
def _store_execution_snapshot(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
config: CampaignConfig,
|
||||
jobs: list[CampaignJob],
|
||||
build_summary: dict[str, Any],
|
||||
) -> None:
|
||||
profile_id, profile = _mail_execution_profile(session, version=version, config=config, jobs=jobs)
|
||||
snapshot, snapshot_hash = create_execution_snapshot(
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
smtp_server_id=profile.get("smtp_server_id"),
|
||||
smtp_credential_id=profile.get("smtp_credential_id"),
|
||||
imap_server_id=profile.get("imap_server_id"),
|
||||
imap_credential_id=profile.get("imap_credential_id"),
|
||||
smtp_transport_revision=profile.get("smtp_transport_revision"),
|
||||
imap_transport_revision=profile.get("imap_transport_revision"),
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=build_summary,
|
||||
)
|
||||
version.execution_snapshot = snapshot
|
||||
version.execution_snapshot_hash = snapshot_hash
|
||||
version.execution_snapshot_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _store_job_issues(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
||||
) -> None:
|
||||
for job, message in job_build_pairs:
|
||||
session.add_all([
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
campaign_version_id=version_id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
for issue in message.issues
|
||||
])
|
||||
|
||||
|
||||
def _apply_campaign_build_state(
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
needs_review_count: int,
|
||||
blocked_count: int,
|
||||
queueable_count: int,
|
||||
) -> None:
|
||||
if needs_review_count or blocked_count:
|
||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
elif queueable_count > 0:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
||||
else:
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
|
||||
def build_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -461,97 +669,54 @@ def build_campaign_version(
|
||||
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
|
||||
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
|
||||
files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||
if isinstance(message_payload, dict):
|
||||
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
||||
report_json["built_at"] = datetime.now(UTC).isoformat()
|
||||
report_json["build_token"] = uuid4().hex
|
||||
report_json.update({
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
entries_by_index = {
|
||||
index: entry
|
||||
for index, entry in enumerate(
|
||||
load_campaign_entries(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
report_json = _campaign_build_report(result, files)
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
|
||||
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.flush()
|
||||
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||
for built in result.built_messages:
|
||||
job = _job_from_message(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
message=built.draft,
|
||||
)
|
||||
session.add(job)
|
||||
job_build_pairs.append((job, built.draft))
|
||||
|
||||
# Assign all job IDs in one round-trip, then persist exact attachment use
|
||||
# records in bulk. This avoids one flush plus several metadata queries per
|
||||
# recipient for large campaigns.
|
||||
session.flush()
|
||||
files.record_campaign_attachment_uses_for_jobs(
|
||||
job_build_pairs = _replace_version_jobs(
|
||||
session,
|
||||
[job for job, _message in job_build_pairs],
|
||||
stage="built",
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
built_messages=result.built_messages,
|
||||
postbox_targets_by_index=resolved_postbox_targets_by_index,
|
||||
)
|
||||
if not managed_config.server.profile_capabilities.smtp_available:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created")
|
||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if profile_id is None:
|
||||
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages")
|
||||
revisions = profile_transport_revisions(session, version)
|
||||
if not revisions["smtp"]:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
|
||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||
jobs = [job for job, _message in job_build_pairs]
|
||||
files.record_campaign_attachment_uses_for_jobs(session, jobs, stage="built")
|
||||
_store_execution_snapshot(session, version=version, config=managed_config, jobs=jobs, build_summary=report_json)
|
||||
_store_job_issues(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
job_build_pairs=job_build_pairs,
|
||||
)
|
||||
_apply_campaign_build_state(
|
||||
campaign,
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
smtp_transport_revision=revisions["smtp"],
|
||||
imap_transport_revision=revisions["imap"],
|
||||
delivery=managed_config.delivery,
|
||||
jobs=[job for job, _message in job_build_pairs],
|
||||
build_summary=report_json,
|
||||
needs_review_count=result.report.needs_review_count,
|
||||
blocked_count=result.report.blocked_count,
|
||||
queueable_count=result.report.queueable_count,
|
||||
)
|
||||
version.execution_snapshot = execution_snapshot
|
||||
version.execution_snapshot_hash = execution_snapshot_hash
|
||||
version.execution_snapshot_at = datetime.now(UTC)
|
||||
for job, message in job_build_pairs:
|
||||
for issue in message.issues:
|
||||
session.add(
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
)
|
||||
|
||||
if result.report.needs_review_count or result.report.blocked_count:
|
||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
elif result.report.queueable_count > 0:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
||||
else:
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
|
||||
@@ -328,6 +328,112 @@ def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> No
|
||||
campaign.external_id = campaign_meta.get("id") or campaign.external_id
|
||||
|
||||
|
||||
def _assert_fork_source_allowed(session: Session, *, campaign: Campaign, source: CampaignVersion) -> None:
|
||||
if campaign_has_active_working_version(session, campaign):
|
||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||
current_number = current.version_number if current else "current"
|
||||
raise LockedCampaignVersionError(
|
||||
f"Campaign already has active working version #{current_number}. "
|
||||
"Unlock or continue editing that version instead of creating a parallel draft."
|
||||
)
|
||||
if campaign.current_version_id and source.id != campaign.current_version_id:
|
||||
raise LockedCampaignVersionError(
|
||||
"Historical versions remain review-only and cannot become a new branch. "
|
||||
"Create the next working copy from the campaign's current immutable version."
|
||||
)
|
||||
|
||||
|
||||
def _fork_runtime_json(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign: Campaign,
|
||||
source: CampaignVersion,
|
||||
raw_json: dict[str, Any] | None,
|
||||
source_filename: str | None,
|
||||
source_base_path: str | None,
|
||||
migrate_legacy_mail_settings: bool,
|
||||
) -> dict[str, Any]:
|
||||
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
|
||||
requires_migration = bool(campaign_mail_profile_boundary_violations(source_json))
|
||||
if requires_migration and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
|
||||
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
|
||||
)
|
||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
|
||||
if requires_migration and raw_json is None:
|
||||
base_json["server"] = public_campaign_mail_server(source_json)
|
||||
assert_server_safe_campaign_paths(
|
||||
base_json,
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=runtime_json,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return runtime_json
|
||||
|
||||
|
||||
def _new_forked_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
source: CampaignVersion,
|
||||
runtime_json: dict[str, Any],
|
||||
current_flow: str | None,
|
||||
current_step: str | None,
|
||||
editor_state: dict[str, Any] | None,
|
||||
source_filename: str | None,
|
||||
source_base_path: str | None,
|
||||
autosave: bool,
|
||||
) -> CampaignVersion:
|
||||
return CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=_next_version_number(session, campaign.id),
|
||||
raw_json=runtime_json,
|
||||
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
|
||||
source_filename=source_filename if source_filename is not None else source.source_filename,
|
||||
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
||||
current_step=current_step if current_step is not None else source.current_step,
|
||||
is_complete=False,
|
||||
editor_state=(
|
||||
validate_campaign_editor_state(editor_state)
|
||||
if editor_state is not None
|
||||
else campaign_editor_state_for_edit(source.editor_state)
|
||||
),
|
||||
autosaved_at=datetime.now(UTC) if autosave else None,
|
||||
)
|
||||
|
||||
|
||||
def _persist_forked_version(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
commit: bool,
|
||||
) -> None:
|
||||
session.add(version)
|
||||
session.flush()
|
||||
_apply_campaign_metadata(campaign, version.raw_json)
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.add(campaign)
|
||||
if commit:
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
|
||||
|
||||
def fork_campaign_version_for_edit(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -354,69 +460,30 @@ def fork_campaign_version_for_edit(
|
||||
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
|
||||
if campaign_has_active_working_version(session, campaign):
|
||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||
current_number = current.version_number if current else "current"
|
||||
raise LockedCampaignVersionError(
|
||||
f"Campaign already has active working version #{current_number}. "
|
||||
"Unlock or continue editing that version instead of creating a parallel draft."
|
||||
)
|
||||
if campaign.current_version_id and source.id != campaign.current_version_id:
|
||||
raise LockedCampaignVersionError(
|
||||
"Historical versions remain review-only and cannot become a new branch. "
|
||||
"Create the next working copy from the campaign's current immutable version."
|
||||
)
|
||||
|
||||
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
|
||||
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
|
||||
if source_requires_mail_migration and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
|
||||
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
|
||||
)
|
||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
|
||||
if source_requires_mail_migration and raw_json is None:
|
||||
base_json["server"] = public_campaign_mail_server(source_json)
|
||||
assert_server_safe_campaign_paths(
|
||||
base_json,
|
||||
_assert_fork_source_allowed(session, campaign=campaign, source=source)
|
||||
runtime_json = _fork_runtime_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign=campaign,
|
||||
source=source,
|
||||
raw_json=raw_json,
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
managed_files_available=files_integration().available,
|
||||
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
|
||||
)
|
||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||
|
||||
new_version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=_next_version_number(session, campaign.id),
|
||||
raw_json=runtime_json,
|
||||
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
|
||||
source_filename=source_filename if source_filename is not None else source.source_filename,
|
||||
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
||||
current_step=current_step if current_step is not None else source.current_step,
|
||||
is_complete=False,
|
||||
editor_state=(
|
||||
validate_campaign_editor_state(editor_state)
|
||||
if editor_state is not None
|
||||
else campaign_editor_state_for_edit(source.editor_state)
|
||||
),
|
||||
autosaved_at=datetime.now(UTC) if autosave else None,
|
||||
new_version = _new_forked_campaign_version(
|
||||
session,
|
||||
campaign=campaign,
|
||||
source=source,
|
||||
runtime_json=runtime_json,
|
||||
current_flow=current_flow,
|
||||
current_step=current_step,
|
||||
editor_state=editor_state,
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
autosave=autosave,
|
||||
)
|
||||
session.add(new_version)
|
||||
session.flush()
|
||||
|
||||
_apply_campaign_metadata(campaign, runtime_json)
|
||||
campaign.current_version_id = new_version.id
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.add(campaign)
|
||||
if commit:
|
||||
_write_campaign_snapshot(new_version)
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
_persist_forked_version(session, campaign=campaign, version=new_version, commit=commit)
|
||||
return new_version
|
||||
|
||||
|
||||
@@ -530,6 +597,113 @@ def unlock_validated_campaign_version(
|
||||
session.flush()
|
||||
return version
|
||||
|
||||
def _assert_update_paths_safe(
|
||||
raw_json: dict[str, Any] | None,
|
||||
*,
|
||||
source_filename: str | None,
|
||||
source_base_path: str | None,
|
||||
) -> None:
|
||||
if raw_json is None and source_filename is None and source_base_path is None:
|
||||
return
|
||||
assert_server_safe_campaign_paths(
|
||||
raw_json if raw_json is not None else {},
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
|
||||
|
||||
def _updated_runtime_json(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw_json: dict[str, Any],
|
||||
source_base_path: str | None,
|
||||
migrate_legacy_mail_settings: bool,
|
||||
) -> dict[str, Any]:
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||
requires_migration = bool(campaign_mail_profile_boundary_violations(version.raw_json))
|
||||
if requires_migration and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
||||
"will not be changed automatically."
|
||||
)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
if requires_migration and campaign_mail_profile_id(runtime_json) is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
||||
"Select a Mail profile before saving."
|
||||
)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=runtime_json,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
return runtime_json
|
||||
|
||||
|
||||
def _apply_version_field_updates(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
current_flow: str | None,
|
||||
current_step: str | None,
|
||||
workflow_state: str | None,
|
||||
is_complete: bool | None,
|
||||
editor_state: dict[str, Any] | None,
|
||||
source_filename: str | None,
|
||||
source_base_path: str | None,
|
||||
autosave: bool,
|
||||
) -> None:
|
||||
updates = (
|
||||
("current_flow", current_flow),
|
||||
("current_step", current_step),
|
||||
("workflow_state", workflow_state),
|
||||
("is_complete", is_complete),
|
||||
("source_filename", source_filename),
|
||||
("source_base_path", source_base_path),
|
||||
)
|
||||
for field_name, value in updates:
|
||||
if value is not None:
|
||||
setattr(version, field_name, value)
|
||||
if editor_state is not None:
|
||||
version.editor_state = validate_campaign_editor_state(editor_state)
|
||||
if autosave:
|
||||
version.autosaved_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _invalidate_version_content(session: Session, *, campaign: Campaign, version: CampaignVersion) -> None:
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
clear_execution_snapshot(version)
|
||||
version.locked_at = None
|
||||
version.locked_by_user_id = None
|
||||
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
|
||||
|
||||
def _persist_updated_version(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
commit: bool,
|
||||
) -> None:
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
if commit:
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
|
||||
|
||||
def update_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -548,13 +722,7 @@ def update_campaign_version(
|
||||
migrate_legacy_mail_settings: bool = False,
|
||||
commit: bool = True,
|
||||
) -> CampaignVersion:
|
||||
if raw_json is not None or source_filename is not None or source_base_path is not None:
|
||||
assert_server_safe_campaign_paths(
|
||||
raw_json if raw_json is not None else {},
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
managed_files_available=files_integration().available,
|
||||
)
|
||||
_assert_update_paths_safe(raw_json, source_filename=source_filename, source_base_path=source_base_path)
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = _require_campaign(session, campaign_id)
|
||||
ensure_current_working_version(campaign, version, action="edit")
|
||||
@@ -565,61 +733,35 @@ def update_campaign_version(
|
||||
)
|
||||
|
||||
if raw_json is not None:
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
|
||||
raise CampaignPersistenceError(
|
||||
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
|
||||
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
|
||||
"will not be changed automatically."
|
||||
)
|
||||
assert_campaign_uses_mail_profile_reference(runtime_json)
|
||||
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
|
||||
"Select a Mail profile before saving."
|
||||
)
|
||||
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||
runtime_json = _updated_runtime_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw_json=raw_json,
|
||||
source_base_path=source_base_path,
|
||||
migrate_legacy_mail_settings=migrate_legacy_mail_settings,
|
||||
)
|
||||
version.raw_json = runtime_json
|
||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||
_apply_campaign_metadata(campaign, runtime_json)
|
||||
|
||||
if current_flow is not None:
|
||||
version.current_flow = current_flow
|
||||
if current_step is not None:
|
||||
version.current_step = current_step
|
||||
if workflow_state is not None:
|
||||
version.workflow_state = workflow_state
|
||||
if is_complete is not None:
|
||||
version.is_complete = is_complete
|
||||
if editor_state is not None:
|
||||
version.editor_state = validate_campaign_editor_state(editor_state)
|
||||
if source_filename is not None:
|
||||
version.source_filename = source_filename
|
||||
if source_base_path is not None:
|
||||
version.source_base_path = source_base_path
|
||||
if autosave:
|
||||
version.autosaved_at = datetime.now(UTC)
|
||||
_apply_version_field_updates(
|
||||
version,
|
||||
current_flow=current_flow,
|
||||
current_step=current_step,
|
||||
workflow_state=workflow_state,
|
||||
is_complete=is_complete,
|
||||
editor_state=editor_state,
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
autosave=autosave,
|
||||
)
|
||||
|
||||
# Changes invalidate previous build and validation summaries.
|
||||
if raw_json is not None:
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
clear_execution_snapshot(version)
|
||||
version.locked_at = None
|
||||
version.locked_by_user_id = None
|
||||
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
if commit:
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
else:
|
||||
session.flush()
|
||||
_invalidate_version_content(session, campaign=campaign, version=version)
|
||||
_persist_updated_version(session, campaign=campaign, version=version, commit=commit)
|
||||
return version
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@ class AggregateOutcomeCounts(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
smtp_accepted: AggregateCount
|
||||
postbox_accepted: AggregateCount
|
||||
delivered: AggregateCount
|
||||
partially_accepted: AggregateCount
|
||||
failed: AggregateCount
|
||||
outcome_unknown: AggregateCount
|
||||
queued_or_active: AggregateCount
|
||||
@@ -105,6 +108,9 @@ class AggregateCampaignReport(BaseModel):
|
||||
|
||||
_OUTCOME_KEYS = (
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"delivered",
|
||||
"partially_accepted",
|
||||
"failed",
|
||||
"outcome_unknown",
|
||||
"queued_or_active",
|
||||
@@ -175,7 +181,18 @@ def _query_aggregate_facts(
|
||||
if version is None:
|
||||
return _AggregateFacts.empty()
|
||||
|
||||
accepted = CampaignJob.send_status.in_({"smtp_accepted", "sent"})
|
||||
smtp_accepted = CampaignJob.send_status.in_(
|
||||
{"smtp_accepted", "sent"}
|
||||
)
|
||||
accepted = CampaignJob.send_status.in_(
|
||||
{
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"delivered",
|
||||
"partially_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"})
|
||||
@@ -188,7 +205,33 @@ def _query_aggregate_facts(
|
||||
row = (
|
||||
session.query(
|
||||
func.count(CampaignJob.id).label("denominator"),
|
||||
func.sum(case((accepted, 1), else_=0)).label("smtp_accepted"),
|
||||
func.sum(case((smtp_accepted, 1), else_=0)).label(
|
||||
"smtp_accepted"
|
||||
),
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
CampaignJob.send_status == "postbox_accepted",
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
).label("postbox_accepted"),
|
||||
func.sum(
|
||||
case(
|
||||
(CampaignJob.send_status == "delivered", 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("delivered"),
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
CampaignJob.send_status == "partially_accepted",
|
||||
1,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
).label("partially_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"),
|
||||
@@ -353,6 +396,12 @@ def _outcome_counts(jobs: list[CampaignJob]) -> dict[str, int]:
|
||||
status = job.send_status
|
||||
if status in {"smtp_accepted", "sent"}:
|
||||
counts["smtp_accepted"] += 1
|
||||
elif status == "postbox_accepted":
|
||||
counts["postbox_accepted"] += 1
|
||||
elif status == "delivered":
|
||||
counts["delivered"] += 1
|
||||
elif status == "partially_accepted":
|
||||
counts["partially_accepted"] += 1
|
||||
elif status in {"failed_temporary", "failed_permanent"}:
|
||||
counts["failed"] += 1
|
||||
elif status == "outcome_unknown":
|
||||
@@ -460,10 +509,15 @@ def _completion_state(
|
||||
return "outcome_unknown"
|
||||
if counts["queued_or_active"]:
|
||||
return "in_progress"
|
||||
accepted = counts["smtp_accepted"]
|
||||
if accepted == total:
|
||||
fully_accepted = (
|
||||
counts["smtp_accepted"]
|
||||
+ counts["postbox_accepted"]
|
||||
+ counts["delivered"]
|
||||
)
|
||||
partially_accepted = counts["partially_accepted"]
|
||||
if fully_accepted == total:
|
||||
return "completed"
|
||||
if accepted:
|
||||
if fully_accepted or partially_accepted:
|
||||
return "partially_completed"
|
||||
return "incomplete"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import io
|
||||
import logging
|
||||
import math
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
@@ -18,6 +19,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
||||
@@ -33,6 +35,8 @@ class CampaignReportError(RuntimeError):
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CAMPAIGN_JSON_JOB_LIMIT = 5_000
|
||||
CAMPAIGN_CSV_JOB_LIMIT = 100_000
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
@@ -96,8 +100,9 @@ def _version_info(
|
||||
|
||||
def _load_delivery_info(
|
||||
version: CampaignVersion | None,
|
||||
jobs: list[CampaignJob],
|
||||
jobs: list[CampaignJob] | None = None,
|
||||
*,
|
||||
pending_job_count: int | None = None,
|
||||
include_diagnostics: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Read deterministic delivery settings from the immutable execution snapshot."""
|
||||
@@ -143,10 +148,17 @@ def _load_delivery_info(
|
||||
return default
|
||||
|
||||
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
|
||||
pending = [job for job in jobs if job.send_status in {"queued", "claimed", "sending"}]
|
||||
if pending_job_count is None:
|
||||
pending_job_count = sum(
|
||||
1
|
||||
for job in jobs or []
|
||||
if job.send_status in {"queued", "claimed", "sending"}
|
||||
)
|
||||
estimated_seconds = None
|
||||
if messages_per_minute and pending:
|
||||
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
|
||||
if messages_per_minute and pending_job_count:
|
||||
estimated_seconds = int(
|
||||
math.ceil((pending_job_count / messages_per_minute) * 60)
|
||||
)
|
||||
|
||||
result = {
|
||||
"rate_limit": {
|
||||
@@ -259,8 +271,184 @@ def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _JobReportAggregate:
|
||||
total: int = 0
|
||||
pending: int = 0
|
||||
queueable: int = 0
|
||||
queueable_unattempted: int = 0
|
||||
retryable: int = 0
|
||||
cancellable: int = 0
|
||||
needs_attention: int = 0
|
||||
build: Counter[str] = field(default_factory=Counter)
|
||||
validation: Counter[str] = field(default_factory=Counter)
|
||||
queue: Counter[str] = field(default_factory=Counter)
|
||||
send: Counter[str] = field(default_factory=Counter)
|
||||
postbox: Counter[str] = field(default_factory=Counter)
|
||||
imap: Counter[str] = field(default_factory=Counter)
|
||||
issue_total: int = 0
|
||||
issue_severity: Counter[str] = field(default_factory=Counter)
|
||||
issue_code: Counter[str] = field(default_factory=Counter)
|
||||
issue_behavior: Counter[str] = field(default_factory=Counter)
|
||||
attachment_total: int = 0
|
||||
attachment_matches: int = 0
|
||||
attachment_zip_enabled: int = 0
|
||||
attachment_missing: int = 0
|
||||
attachment_ambiguous: int = 0
|
||||
attachment_status: Counter[str] = field(default_factory=Counter)
|
||||
attachment_behavior: Counter[str] = field(default_factory=Counter)
|
||||
recent_failures: list[CampaignJob] = field(default_factory=list)
|
||||
|
||||
def add(
|
||||
self,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
retry_max_attempts: int | None,
|
||||
include_recent_failures: bool,
|
||||
) -> None:
|
||||
self.total += 1
|
||||
self._add_statuses(job)
|
||||
self._add_delivery_counts(job, retry_max_attempts=retry_max_attempts)
|
||||
self._add_issues(job)
|
||||
self._add_attachments(job)
|
||||
self._add_recent_failure(job, include_recent_failures=include_recent_failures)
|
||||
|
||||
def _add_statuses(self, job: CampaignJob) -> None:
|
||||
self.build[job.build_status or "unknown"] += 1
|
||||
self.validation[job.validation_status or "unknown"] += 1
|
||||
self.queue[job.queue_status or "unknown"] += 1
|
||||
self.send[job.send_status or "unknown"] += 1
|
||||
self.postbox[job.postbox_status or "unknown"] += 1
|
||||
self.imap[job.imap_status or "unknown"] += 1
|
||||
|
||||
def _add_delivery_counts(self, job: CampaignJob, *, retry_max_attempts: int | None) -> None:
|
||||
if job.send_status in {"queued", "claimed", "sending"}:
|
||||
self.pending += 1
|
||||
if _job_is_queueable(job):
|
||||
self.queueable += 1
|
||||
if _job_is_queueable_unattempted(job):
|
||||
self.queueable_unattempted += 1
|
||||
if _job_is_retryable(job, retry_max_attempts=retry_max_attempts):
|
||||
self.retryable += 1
|
||||
if _job_is_cancellable(job):
|
||||
self.cancellable += 1
|
||||
if _job_needs_attention(job):
|
||||
self.needs_attention += 1
|
||||
|
||||
def _add_recent_failure(self, job: CampaignJob, *, include_recent_failures: bool) -> None:
|
||||
if not include_recent_failures or not _job_is_recent_failure(job):
|
||||
return
|
||||
self.recent_failures.append(job)
|
||||
self.recent_failures.sort(key=lambda item: item.updated_at or item.created_at, reverse=True)
|
||||
del self.recent_failures[20:]
|
||||
|
||||
def _add_issues(self, job: CampaignJob) -> None:
|
||||
for issue in job.issues_snapshot or []:
|
||||
if not isinstance(issue, dict):
|
||||
continue
|
||||
self.issue_total += 1
|
||||
self.issue_severity[issue.get("severity") or "unknown"] += 1
|
||||
self.issue_code[issue.get("code") or "unknown"] += 1
|
||||
if issue.get("behavior"):
|
||||
self.issue_behavior[str(issue["behavior"])] += 1
|
||||
|
||||
def _add_attachments(self, job: CampaignJob) -> None:
|
||||
for attachment in job.resolved_attachments or []:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
self.attachment_total += 1
|
||||
status_value = str(attachment.get("status") or "unknown")
|
||||
self.attachment_status[status_value] += 1
|
||||
if attachment.get("behavior"):
|
||||
self.attachment_behavior[str(attachment["behavior"])] += 1
|
||||
matches = attachment.get("matches")
|
||||
if isinstance(matches, list):
|
||||
self.attachment_matches += len(matches)
|
||||
if attachment.get("zip_enabled"):
|
||||
self.attachment_zip_enabled += 1
|
||||
if status_value == "missing":
|
||||
self.attachment_missing += 1
|
||||
if status_value == "ambiguous":
|
||||
self.attachment_ambiguous += 1
|
||||
|
||||
|
||||
NON_CANCELLABLE_SEND_STATUSES = {
|
||||
"skipped",
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"delivered",
|
||||
"partially_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"claimed",
|
||||
"sending",
|
||||
"cancelled",
|
||||
}
|
||||
|
||||
|
||||
def _job_is_queueable(job: CampaignJob) -> bool:
|
||||
return job.validation_status in {"ready", "warning"} and job.build_status == "built"
|
||||
|
||||
|
||||
def _job_is_queueable_unattempted(job: CampaignJob) -> bool:
|
||||
return (
|
||||
job.attempt_count == 0
|
||||
and job.postbox_attempt_count == 0
|
||||
and job.send_status in {"not_queued", "cancelled"}
|
||||
and _job_is_queueable(job)
|
||||
)
|
||||
|
||||
|
||||
def _job_is_retryable(job: CampaignJob, *, retry_max_attempts: int | None) -> bool:
|
||||
attempts_available = retry_max_attempts is None or job.attempt_count < retry_max_attempts
|
||||
return job.send_status in {"failed_temporary", "partially_accepted"} and attempts_available
|
||||
|
||||
|
||||
def _job_is_cancellable(job: CampaignJob) -> bool:
|
||||
return job.send_status not in NON_CANCELLABLE_SEND_STATUSES
|
||||
|
||||
|
||||
def _job_needs_attention(job: CampaignJob) -> bool:
|
||||
return (
|
||||
job.validation_status in {"needs_review", "blocked"}
|
||||
or job.send_status
|
||||
in {
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"partially_accepted",
|
||||
"outcome_unknown",
|
||||
"claimed",
|
||||
"sending",
|
||||
}
|
||||
or job.postbox_status
|
||||
in {
|
||||
"partially_accepted",
|
||||
"rejected_temporary",
|
||||
"rejected_permanent",
|
||||
"outcome_unknown",
|
||||
}
|
||||
or job.imap_status == "failed"
|
||||
)
|
||||
|
||||
|
||||
def _job_is_recent_failure(job: CampaignJob) -> bool:
|
||||
return bool(
|
||||
job.last_error
|
||||
or str(job.send_status).startswith("failed")
|
||||
or job.send_status == "partially_accepted"
|
||||
or job.postbox_status
|
||||
in {
|
||||
"partially_accepted",
|
||||
"rejected_temporary",
|
||||
"rejected_permanent",
|
||||
"outcome_unknown",
|
||||
}
|
||||
or job.imap_status == "failed"
|
||||
)
|
||||
|
||||
|
||||
def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"]
|
||||
failed = [job for job in jobs if _job_is_recent_failure(job)]
|
||||
failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True)
|
||||
return [
|
||||
{
|
||||
@@ -270,12 +458,15 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
|
||||
"recipient_email": job.recipient_email,
|
||||
"validation_status": job.validation_status,
|
||||
"send_status": job.send_status,
|
||||
"postbox_status": job.postbox_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"postbox_attempt_count": job.postbox_attempt_count,
|
||||
"last_error": public_delivery_result_message(
|
||||
last_error=job.last_error,
|
||||
send_status=job.send_status,
|
||||
imap_status=job.imap_status,
|
||||
postbox_status=job.postbox_status,
|
||||
),
|
||||
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
|
||||
}
|
||||
@@ -294,8 +485,22 @@ def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"delivery_channel_policy": getattr(
|
||||
job,
|
||||
"delivery_channel_policy",
|
||||
"mail",
|
||||
),
|
||||
"postbox_status": getattr(
|
||||
job,
|
||||
"postbox_status",
|
||||
"not_requested",
|
||||
),
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
|
||||
"postbox_target_count": len(
|
||||
getattr(job, "resolved_postbox_targets", None) or []
|
||||
),
|
||||
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
|
||||
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
|
||||
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
|
||||
@@ -303,6 +508,7 @@ def _job_row(job: CampaignJob, *, include_diagnostics: bool = False) -> dict[str
|
||||
last_error=job.last_error,
|
||||
send_status=job.send_status,
|
||||
imap_status=job.imap_status,
|
||||
postbox_status=getattr(job, "postbox_status", "not_requested"),
|
||||
),
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
@@ -388,24 +594,48 @@ def _job_evidence_row(
|
||||
"campaign_id": job.campaign_id,
|
||||
"campaign_version_id": job.campaign_version_id,
|
||||
"message_id_header": job.message_id_header,
|
||||
"from": _address_summary(recipients.get("from")),
|
||||
"to": _address_summary(recipients.get("to")),
|
||||
"cc": _address_summary(recipients.get("cc")),
|
||||
"bcc": _address_summary(recipients.get("bcc")),
|
||||
"reply_to": _address_summary(recipients.get("reply_to")),
|
||||
**_job_evidence_addresses(recipients),
|
||||
"postbox_targets": _job_postbox_target_summary(job),
|
||||
"attachment_names": _attachment_names(job.resolved_attachments),
|
||||
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap),
|
||||
})
|
||||
return row
|
||||
|
||||
|
||||
def _job_evidence_addresses(recipients: dict[str, Any]) -> dict[str, str]:
|
||||
return {key: _address_summary(recipients.get(key)) for key in ("from", "to", "cc", "bcc", "reply_to")}
|
||||
|
||||
|
||||
def _job_postbox_target_summary(job: CampaignJob) -> str:
|
||||
targets = getattr(job, "resolved_postbox_targets", None) or []
|
||||
return "; ".join(
|
||||
str(target.get("address") or target.get("name") or target.get("postbox_id") or "")
|
||||
for target in targets
|
||||
if isinstance(target, dict)
|
||||
)
|
||||
|
||||
|
||||
def _iso_timestamp(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def _job_attempt_evidence(
|
||||
*,
|
||||
latest_smtp: SendAttempt | None,
|
||||
latest_imap: ImapAppendAttempt | None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
|
||||
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
|
||||
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
|
||||
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
|
||||
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
|
||||
"latest_smtp_started_at": _iso_timestamp(latest_smtp.started_at) if latest_smtp else None,
|
||||
"latest_smtp_finished_at": _iso_timestamp(latest_smtp.finished_at) if latest_smtp else None,
|
||||
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
|
||||
"latest_imap_status": latest_imap.status if latest_imap else None,
|
||||
"latest_imap_folder": latest_imap.folder if latest_imap else None,
|
||||
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
|
||||
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
|
||||
})
|
||||
return row
|
||||
"latest_imap_created_at": _iso_timestamp(latest_imap.created_at) if latest_imap else None,
|
||||
"latest_imap_updated_at": _iso_timestamp(latest_imap.updated_at) if latest_imap else None,
|
||||
}
|
||||
|
||||
|
||||
def generate_campaign_report(
|
||||
@@ -427,21 +657,43 @@ def generate_campaign_report(
|
||||
|
||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
version = _selected_version(session, campaign, version_id)
|
||||
jobs = _report_jobs(session, tenant_id=tenant_id, campaign_id=campaign.id, version=version)
|
||||
jobs = _report_jobs(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
)
|
||||
aggregate = _JobReportAggregate()
|
||||
job_rows: list[dict[str, Any]] = []
|
||||
retry_max_attempts = _retry_max_attempts(version)
|
||||
iterator = jobs.yield_per(500) if hasattr(jobs, "yield_per") else jobs
|
||||
for job in iterator:
|
||||
aggregate.add(
|
||||
job,
|
||||
retry_max_attempts=retry_max_attempts,
|
||||
include_recent_failures=include_recent_failures,
|
||||
)
|
||||
if include_jobs:
|
||||
if len(job_rows) >= CAMPAIGN_JSON_JOB_LIMIT:
|
||||
raise CampaignReportError(
|
||||
"The recipient-level JSON report exceeds the safe row "
|
||||
f"limit of {CAMPAIGN_JSON_JOB_LIMIT}; use the CSV export "
|
||||
"or the paginated recipient table."
|
||||
)
|
||||
job_rows.append(
|
||||
_job_row(job, include_diagnostics=include_diagnostics)
|
||||
)
|
||||
report = _campaign_report_payload(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
jobs=jobs,
|
||||
aggregate=aggregate,
|
||||
tenant_id=tenant_id,
|
||||
include_recent_failures=include_recent_failures,
|
||||
include_diagnostics=include_diagnostics,
|
||||
)
|
||||
if include_jobs:
|
||||
report["jobs"] = [
|
||||
_job_row(job, include_diagnostics=include_diagnostics)
|
||||
for job in jobs
|
||||
]
|
||||
report["jobs"] = job_rows
|
||||
return report
|
||||
|
||||
|
||||
@@ -451,7 +703,7 @@ def _report_jobs(
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion | None,
|
||||
) -> list[CampaignJob]:
|
||||
) -> Any:
|
||||
jobs_query = session.query(CampaignJob).filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
@@ -460,7 +712,7 @@ def _report_jobs(
|
||||
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
|
||||
else:
|
||||
jobs_query = jobs_query.filter(False)
|
||||
return jobs_query.order_by(CampaignJob.entry_index.asc()).all()
|
||||
return jobs_query.order_by(CampaignJob.entry_index.asc())
|
||||
|
||||
|
||||
def _campaign_report_payload(
|
||||
@@ -468,21 +720,26 @@ def _campaign_report_payload(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion | None,
|
||||
jobs: list[CampaignJob],
|
||||
aggregate: _JobReportAggregate,
|
||||
tenant_id: str,
|
||||
include_recent_failures: bool,
|
||||
include_diagnostics: bool,
|
||||
) -> dict[str, Any]:
|
||||
job_ids = [job.id for job in jobs]
|
||||
report = {
|
||||
"generated_at": _utcnow_iso(),
|
||||
"campaign": _campaign_report_campaign_payload(campaign),
|
||||
"current_version": _version_info(version, include_diagnostics=include_diagnostics),
|
||||
"selected_version_id": version.id if version else None,
|
||||
"cards": _campaign_report_cards(version, jobs),
|
||||
"status_counts": _campaign_report_status_counts(version, jobs),
|
||||
"cards": _campaign_report_cards_from_aggregate(version, aggregate),
|
||||
"status_counts": _campaign_report_status_counts_from_aggregate(
|
||||
version,
|
||||
aggregate,
|
||||
),
|
||||
"issues": {
|
||||
**_issue_summary_from_jobs(jobs),
|
||||
"total": aggregate.issue_total,
|
||||
"by_severity": dict(aggregate.issue_severity),
|
||||
"by_code": dict(aggregate.issue_code),
|
||||
"by_behavior": dict(aggregate.issue_behavior),
|
||||
"persisted_campaign_issue_count": _persisted_campaign_issue_count(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -490,16 +747,31 @@ def _campaign_report_payload(
|
||||
version=version,
|
||||
),
|
||||
},
|
||||
"attachments": _attachment_summary(jobs),
|
||||
"attempts": _campaign_report_attempt_counts(session, job_ids),
|
||||
"attachments": {
|
||||
"total_attachment_configs": aggregate.attachment_total,
|
||||
"total_matched_files": aggregate.attachment_matches,
|
||||
"zip_enabled_configs": aggregate.attachment_zip_enabled,
|
||||
"missing_configs": aggregate.attachment_missing,
|
||||
"ambiguous_configs": aggregate.attachment_ambiguous,
|
||||
"by_status": dict(aggregate.attachment_status),
|
||||
"by_behavior": dict(aggregate.attachment_behavior),
|
||||
},
|
||||
"attempts": _campaign_report_attempt_counts(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
),
|
||||
"delivery": _load_delivery_info(
|
||||
version,
|
||||
jobs,
|
||||
pending_job_count=aggregate.pending,
|
||||
include_diagnostics=include_diagnostics,
|
||||
),
|
||||
}
|
||||
if include_recent_failures:
|
||||
report["recent_failures"] = _recent_failures(jobs)
|
||||
report["recent_failures"] = _recent_failures(
|
||||
aggregate.recent_failures,
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
@@ -515,10 +787,34 @@ def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _campaign_report_attempt_counts(session: Session, job_ids: list[str]) -> dict[str, int]:
|
||||
def _campaign_report_attempt_counts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion | None,
|
||||
) -> dict[str, int]:
|
||||
def count(model: type[Any]) -> int:
|
||||
query = (
|
||||
session.query(model)
|
||||
.join(CampaignJob, model.job_id == CampaignJob.id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
)
|
||||
)
|
||||
if version is not None:
|
||||
query = query.filter(
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
)
|
||||
else:
|
||||
query = query.filter(False)
|
||||
return int(query.count())
|
||||
|
||||
return {
|
||||
"send_attempts": int(session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
|
||||
"imap_append_attempts": int(session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count()) if job_ids else 0,
|
||||
"send_attempts": count(SendAttempt),
|
||||
"imap_append_attempts": count(ImapAppendAttempt),
|
||||
"postbox_delivery_attempts": count(PostboxDeliveryAttempt),
|
||||
}
|
||||
|
||||
|
||||
@@ -541,84 +837,109 @@ def _persisted_campaign_issue_count(
|
||||
|
||||
|
||||
def _campaign_report_status_counts(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, dict[str, int]]:
|
||||
validation_counts = _counter([job.validation_status for job in jobs])
|
||||
aggregate = _aggregate_job_list(version, jobs)
|
||||
return _campaign_report_status_counts_from_aggregate(version, aggregate)
|
||||
|
||||
|
||||
def _campaign_report_status_counts_from_aggregate(
|
||||
version: CampaignVersion | None,
|
||||
aggregate: _JobReportAggregate,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
validation_counts = dict(aggregate.validation)
|
||||
inactive_entries = _inactive_entry_count(version)
|
||||
if inactive_entries:
|
||||
validation_counts["inactive"] = inactive_entries
|
||||
return {
|
||||
"build": _counter([job.build_status for job in jobs]),
|
||||
"build": dict(aggregate.build),
|
||||
"validation": validation_counts,
|
||||
"queue": _counter([job.queue_status for job in jobs]),
|
||||
"send": _counter([job.send_status for job in jobs]),
|
||||
"imap": _counter([job.imap_status for job in jobs]),
|
||||
"queue": dict(aggregate.queue),
|
||||
"send": dict(aggregate.send),
|
||||
"postbox": dict(aggregate.postbox),
|
||||
"imap": dict(aggregate.imap),
|
||||
}
|
||||
|
||||
|
||||
def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
|
||||
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"
|
||||
def _campaign_report_cards_from_aggregate(
|
||||
version: CampaignVersion | None,
|
||||
aggregate: _JobReportAggregate,
|
||||
) -> dict[str, object]:
|
||||
send_counts = aggregate.send
|
||||
imap_counts = aggregate.imap
|
||||
sent = (
|
||||
send_counts.get("sent", 0)
|
||||
+ send_counts.get("smtp_accepted", 0)
|
||||
+ send_counts.get("postbox_accepted", 0)
|
||||
+ send_counts.get("delivered", 0)
|
||||
+ send_counts.get("partially_accepted", 0)
|
||||
)
|
||||
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)
|
||||
failed = (
|
||||
send_counts.get("failed_temporary", 0)
|
||||
+ send_counts.get("failed_permanent", 0)
|
||||
)
|
||||
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
|
||||
if job.validation_status in {"needs_review", "blocked"}
|
||||
or job.send_status in {"failed_temporary", "failed_permanent", "outcome_unknown", "claimed", "sending"}
|
||||
or job.imap_status == "failed"
|
||||
)
|
||||
sent = send_counts.get("sent", 0) + send_counts.get("smtp_accepted", 0)
|
||||
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)
|
||||
return {
|
||||
"jobs_total": len(jobs),
|
||||
"jobs_total": aggregate.total,
|
||||
"inactive": inactive_entries,
|
||||
"queueable": queueable,
|
||||
"queueable_unattempted": queueable_unattempted,
|
||||
"retryable": retryable,
|
||||
"cancellable": cancellable,
|
||||
"needs_attention": needs_attention,
|
||||
"queueable": aggregate.queueable,
|
||||
"queueable_unattempted": aggregate.queueable_unattempted,
|
||||
"retryable": aggregate.retryable,
|
||||
"cancellable": aggregate.cancellable,
|
||||
"needs_attention": aggregate.needs_attention,
|
||||
"sent": sent,
|
||||
"smtp_accepted": sent,
|
||||
"smtp_accepted": (
|
||||
send_counts.get("sent", 0)
|
||||
+ send_counts.get("smtp_accepted", 0)
|
||||
),
|
||||
"postbox_accepted": send_counts.get("postbox_accepted", 0),
|
||||
"delivered": send_counts.get("delivered", 0),
|
||||
"partially_accepted": send_counts.get("partially_accepted", 0),
|
||||
"failed": failed,
|
||||
"outcome_unknown": outcome_unknown,
|
||||
"not_attempted": not_attempted,
|
||||
"skipped": skipped,
|
||||
"queued_or_active": queued,
|
||||
"skipped": send_counts.get("skipped", 0),
|
||||
"queued_or_active": aggregate.pending,
|
||||
"cancelled": cancelled,
|
||||
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
|
||||
"partially_completed": bool(
|
||||
send_counts.get("partially_accepted", 0)
|
||||
or 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 _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, object]:
|
||||
return _campaign_report_cards_from_aggregate(
|
||||
version,
|
||||
_aggregate_job_list(version, jobs),
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_job_list(
|
||||
version: CampaignVersion | None,
|
||||
jobs: list[CampaignJob],
|
||||
) -> _JobReportAggregate:
|
||||
aggregate = _JobReportAggregate()
|
||||
retry_max_attempts = _retry_max_attempts(version)
|
||||
for job in jobs:
|
||||
aggregate.add(
|
||||
job,
|
||||
retry_max_attempts=retry_max_attempts,
|
||||
include_recent_failures=False,
|
||||
)
|
||||
return aggregate
|
||||
|
||||
|
||||
def _retry_max_attempts(version: CampaignVersion | None) -> int | None:
|
||||
if version is None or not isinstance(version.execution_snapshot, dict):
|
||||
if version is None or not isinstance(
|
||||
getattr(version, "execution_snapshot", None),
|
||||
dict,
|
||||
):
|
||||
return None
|
||||
try:
|
||||
return ExecutionSnapshot.model_validate(version.execution_snapshot).delivery.retry.max_attempts
|
||||
@@ -652,27 +973,25 @@ def generate_jobs_csv(
|
||||
jobs = (
|
||||
jobs_query
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.limit(CAMPAIGN_CSV_JOB_LIMIT + 1)
|
||||
.all()
|
||||
)
|
||||
if len(jobs) > CAMPAIGN_CSV_JOB_LIMIT:
|
||||
raise CampaignReportError(
|
||||
"The campaign CSV export exceeds the safe row limit of "
|
||||
f"{CAMPAIGN_CSV_JOB_LIMIT}. Split the export by recipient filter."
|
||||
)
|
||||
job_ids = [job.id for job in jobs]
|
||||
smtp_attempts = (
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id.in_(job_ids))
|
||||
.order_by(SendAttempt.job_id.asc(), SendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
if job_ids
|
||||
else []
|
||||
latest_smtp = _latest_attempts_for_jobs(
|
||||
session,
|
||||
SendAttempt,
|
||||
job_ids,
|
||||
)
|
||||
imap_attempts = (
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id.in_(job_ids))
|
||||
.order_by(ImapAppendAttempt.job_id.asc(), ImapAppendAttempt.attempt_number.asc())
|
||||
.all()
|
||||
if job_ids
|
||||
else []
|
||||
latest_imap = _latest_attempts_for_jobs(
|
||||
session,
|
||||
ImapAppendAttempt,
|
||||
job_ids,
|
||||
)
|
||||
latest_smtp = _latest_by_job_id(smtp_attempts)
|
||||
latest_imap = _latest_by_job_id(imap_attempts)
|
||||
rows = [
|
||||
_job_evidence_row(
|
||||
job,
|
||||
@@ -700,8 +1019,13 @@ def generate_jobs_csv(
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"delivery_channel_policy",
|
||||
"postbox_status",
|
||||
"imap_status",
|
||||
"attempt_count",
|
||||
"postbox_attempt_count",
|
||||
"postbox_target_count",
|
||||
"postbox_targets",
|
||||
"queued_at",
|
||||
"outcome_unknown_at",
|
||||
"sent_at",
|
||||
@@ -731,3 +1055,30 @@ def generate_jobs_csv(
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _latest_attempts_for_jobs(
|
||||
session: Session,
|
||||
model: type[Any],
|
||||
job_ids: list[str],
|
||||
*,
|
||||
batch_size: int = 500,
|
||||
) -> dict[str, Any]:
|
||||
latest: dict[str, Any] = {}
|
||||
for offset in range(0, len(job_ids), batch_size):
|
||||
batch = job_ids[offset : offset + batch_size]
|
||||
attempts = (
|
||||
session.query(model)
|
||||
.filter(model.job_id.in_(batch))
|
||||
.order_by(model.job_id.asc(), model.attempt_number.asc())
|
||||
.yield_per(batch_size)
|
||||
)
|
||||
for attempt in attempts:
|
||||
current = latest.get(attempt.job_id)
|
||||
if (
|
||||
current is None
|
||||
or (attempt.attempt_number or 0)
|
||||
>= (current.attempt_number or 0)
|
||||
):
|
||||
latest[attempt.job_id] = attempt
|
||||
return latest
|
||||
|
||||
@@ -110,6 +110,7 @@ def public_delivery_result_message(
|
||||
last_error: Any,
|
||||
send_status: Any,
|
||||
imap_status: Any,
|
||||
postbox_status: Any = None,
|
||||
) -> str | None:
|
||||
"""Map persisted provider text to a stable business-safe explanation."""
|
||||
|
||||
@@ -117,10 +118,22 @@ def public_delivery_result_message(
|
||||
return None
|
||||
clean_send_status = str(send_status or "")
|
||||
clean_imap_status = str(imap_status or "")
|
||||
clean_postbox_status = str(postbox_status or "")
|
||||
if clean_postbox_status == "outcome_unknown":
|
||||
return "Postbox delivery outcome requires operator reconciliation."
|
||||
if clean_send_status == "outcome_unknown":
|
||||
return "SMTP delivery outcome requires operator reconciliation."
|
||||
return "Delivery outcome requires operator reconciliation."
|
||||
if clean_postbox_status in {
|
||||
"rejected_temporary",
|
||||
"rejected_permanent",
|
||||
}:
|
||||
return "Postbox delivery was rejected; an operator can inspect restricted diagnostics."
|
||||
if clean_postbox_status == "partially_accepted":
|
||||
return "Some Postbox targets accepted the message and others rejected it."
|
||||
if clean_send_status in {"failed_temporary", "failed_permanent"}:
|
||||
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
|
||||
if clean_postbox_status in {"", "not_requested"}:
|
||||
return "SMTP delivery failed; an operator can inspect restricted diagnostics."
|
||||
return "Delivery failed; an operator can inspect restricted diagnostics."
|
||||
if clean_imap_status in {"outcome_unknown", "appending"}:
|
||||
return "Sent-folder append outcome requires operator reconciliation."
|
||||
if clean_imap_status in {"failed", "skipped"}:
|
||||
|
||||
601
src/govoplan_campaign/backend/route_support.py
Normal file
601
src/govoplan_campaign/backend/route_support.py
Normal file
@@ -0,0 +1,601 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, exists, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||
campaign_mail_profile_id,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
CampaignVersionWorkflowState,
|
||||
RecipientImportMappingProfile,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
|
||||
from govoplan_campaign.backend.persistence.campaigns import CampaignPersistenceError
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
LockedCampaignVersionError,
|
||||
is_user_locked_version,
|
||||
is_version_final_locked,
|
||||
is_version_locked,
|
||||
update_campaign_version,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignVersionDetailResponse,
|
||||
CampaignVersionUpdateRequest,
|
||||
RecipientImportMappingProfilePayload,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import (
|
||||
clear_execution_snapshot,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
|
||||
|
||||
def _capability_payload(value: object) -> dict[str, Any]:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return dataclasses.asdict(value)
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
payload: dict[str, Any] = {}
|
||||
for key in (
|
||||
"contact_id",
|
||||
"address_book_id",
|
||||
"display_name",
|
||||
"email",
|
||||
"email_label",
|
||||
"organization",
|
||||
"role_title",
|
||||
"tags",
|
||||
"source_kind",
|
||||
"source_ref",
|
||||
"source_revision",
|
||||
"source_id",
|
||||
"source_label",
|
||||
"recipient_count",
|
||||
"generated_at",
|
||||
"recipients",
|
||||
"fields",
|
||||
"provenance",
|
||||
):
|
||||
if hasattr(value, key):
|
||||
payload[key] = getattr(value, key)
|
||||
return payload
|
||||
|
||||
|
||||
def _registry_capability(name: str) -> object | None:
|
||||
registry = get_registry()
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(name)
|
||||
):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _access_directory() -> AccessDirectory:
|
||||
registry = get_registry()
|
||||
if (
|
||||
registry is None
|
||||
or not hasattr(registry, "has_capability")
|
||||
or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Access directory capability is not configured",
|
||||
)
|
||||
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
|
||||
if not isinstance(capability, AccessDirectory):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Access directory capability is invalid",
|
||||
)
|
||||
return capability
|
||||
|
||||
|
||||
def _get_campaign_for_tenant(
|
||||
session: Session, campaign_id: str, tenant_id: str
|
||||
) -> Campaign:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found"
|
||||
)
|
||||
return campaign
|
||||
|
||||
|
||||
def _get_version_for_tenant(
|
||||
session: Session, version_id: str, tenant_id: str
|
||||
) -> CampaignVersion:
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if not version:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def _principal_group_ids(session: Session, principal: ApiPrincipal) -> set[str]:
|
||||
del session
|
||||
return {
|
||||
group.id
|
||||
for group in _access_directory().groups_for_user(
|
||||
principal.user.id, tenant_id=principal.tenant_id
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _campaign_acl_filter(session: Session, principal: ApiPrincipal):
|
||||
if has_scope(principal, "tenant:*"):
|
||||
return None
|
||||
group_ids = _principal_group_ids(session, principal)
|
||||
clauses = [Campaign.owner_user_id == principal.user.id]
|
||||
if group_ids:
|
||||
clauses.append(Campaign.owner_group_id.in_(group_ids))
|
||||
share_clauses = [
|
||||
and_(
|
||||
CampaignShare.tenant_id == Campaign.tenant_id,
|
||||
CampaignShare.campaign_id == Campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
CampaignShare.target_type == "user",
|
||||
CampaignShare.target_id == principal.user.id,
|
||||
)
|
||||
]
|
||||
if group_ids:
|
||||
share_clauses.append(
|
||||
and_(
|
||||
CampaignShare.tenant_id == Campaign.tenant_id,
|
||||
CampaignShare.campaign_id == Campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
CampaignShare.target_type == "group",
|
||||
CampaignShare.target_id.in_(group_ids),
|
||||
)
|
||||
)
|
||||
clauses.append(exists().where(or_(*share_clauses)))
|
||||
return or_(*clauses)
|
||||
|
||||
|
||||
def _campaign_acl_allows(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> bool:
|
||||
if has_scope(principal, "tenant:*"):
|
||||
return True
|
||||
if campaign.owner_user_id == principal.user.id:
|
||||
return True
|
||||
group_ids = _principal_group_ids(session, principal)
|
||||
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
||||
return True
|
||||
target_ids = [principal.user.id, *group_ids]
|
||||
if not target_ids:
|
||||
return False
|
||||
query = session.query(CampaignShare).filter(
|
||||
CampaignShare.tenant_id == campaign.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
or_(
|
||||
CampaignShare.target_type == "user",
|
||||
CampaignShare.target_type == "group",
|
||||
),
|
||||
CampaignShare.target_id.in_(target_ids),
|
||||
)
|
||||
shares = query.all()
|
||||
if not shares:
|
||||
return False
|
||||
if not write:
|
||||
return True
|
||||
return any(item.permission == "write" for item in shares)
|
||||
|
||||
|
||||
def _require_campaign_acl(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> None:
|
||||
if not _campaign_acl_allows(session, campaign, principal, write=write):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Campaign is not shared with this principal",
|
||||
)
|
||||
|
||||
|
||||
def _get_campaign_for_principal(
|
||||
session: Session, campaign_id: str, principal: ApiPrincipal, *, write: bool = False
|
||||
) -> Campaign:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
_require_campaign_acl(session, campaign, principal, write=write)
|
||||
return campaign
|
||||
|
||||
|
||||
def _require_permission(principal: ApiPrincipal, scope: str) -> None:
|
||||
if not has_scope(principal, scope):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}"
|
||||
)
|
||||
|
||||
|
||||
def _campaign_query_for_principal(session: Session, principal: ApiPrincipal):
|
||||
query = session.query(Campaign).filter(
|
||||
Campaign.tenant_id == principal.tenant_id, Campaign.status != "deleted"
|
||||
)
|
||||
acl_filter = _campaign_acl_filter(session, principal)
|
||||
if acl_filter is not None:
|
||||
query = query.filter(acl_filter)
|
||||
return query
|
||||
|
||||
|
||||
def _get_recipient_import_profile_for_principal(
|
||||
session: Session, profile_id: str, principal: ApiPrincipal
|
||||
) -> RecipientImportMappingProfile:
|
||||
profile = session.get(RecipientImportMappingProfile, profile_id)
|
||||
if (
|
||||
not profile
|
||||
or profile.tenant_id != principal.tenant_id
|
||||
or profile.owner_user_id != principal.user.id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Recipient import mapping profile not found",
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
def _apply_recipient_import_profile_payload(
|
||||
profile: RecipientImportMappingProfile,
|
||||
payload: RecipientImportMappingProfilePayload,
|
||||
) -> None:
|
||||
profile.name = payload.name.strip()
|
||||
profile.column_count = payload.column_count
|
||||
profile.headers = list(payload.headers)
|
||||
profile.normalized_headers = list(payload.normalized_headers)
|
||||
profile.ordered_header_fingerprint = payload.ordered_header_fingerprint
|
||||
profile.unordered_header_fingerprint = payload.unordered_header_fingerprint
|
||||
profile.delimiter = payload.delimiter
|
||||
profile.header_rows = payload.header_rows
|
||||
profile.quoted = payload.quoted
|
||||
profile.value_separators = payload.value_separators
|
||||
profile.mappings = [mapping.model_dump(mode="json") for mapping in payload.mappings]
|
||||
|
||||
|
||||
def _recipient_sections_changed(
|
||||
current: dict[str, object] | None, proposed: dict[str, object] | None
|
||||
) -> bool:
|
||||
if proposed is None:
|
||||
return False
|
||||
current = current or {}
|
||||
return any(
|
||||
current.get(key) != proposed.get(key) for key in ("recipients", "entries")
|
||||
)
|
||||
|
||||
|
||||
def _campaign_mail_profile_id(raw_json: dict[str, object] | None) -> str | None:
|
||||
return campaign_mail_profile_id(raw_json)
|
||||
|
||||
|
||||
def _require_mail_profile_use_if_needed(
|
||||
principal: ApiPrincipal, raw_json: dict[str, object] | None
|
||||
) -> None:
|
||||
if _campaign_mail_profile_id(raw_json) and not has_scope(
|
||||
principal, "mail:profile:use"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Missing scope: mail:profile:use",
|
||||
)
|
||||
|
||||
|
||||
def _campaign_response_context(principal: ApiPrincipal) -> dict[str, bool]:
|
||||
return {"include_diagnostics": has_scope(principal, "campaigns:diagnostic:read")}
|
||||
|
||||
|
||||
def _campaign_version_detail_response(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
campaign_id: str,
|
||||
mutation: Callable[[], CampaignVersion],
|
||||
*,
|
||||
audit_action: str,
|
||||
details: dict[str, Any] | Callable[[CampaignVersion], dict[str, Any]] | None = None,
|
||||
validation_error_status: int | None = None,
|
||||
) -> CampaignVersionDetailResponse:
|
||||
try:
|
||||
version = mutation()
|
||||
audit_details = (
|
||||
details(version)
|
||||
if callable(details)
|
||||
else dict(details or {"campaign_id": campaign_id})
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=audit_action,
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details=audit_details,
|
||||
commit=True,
|
||||
)
|
||||
_write_current_version_snapshot_if_available(version)
|
||||
return CampaignVersionDetailResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
except LockedCampaignVersionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPathSecurityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPersistenceError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
if validation_error_status is None:
|
||||
raise
|
||||
raise HTTPException(
|
||||
status_code=validation_error_status, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
def _update_campaign_version_detail_response(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
*,
|
||||
autosave: bool,
|
||||
audit_action: str,
|
||||
) -> CampaignVersionDetailResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||
_require_permission(principal, "campaigns:recipient:write")
|
||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: update_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
raw_json=payload.campaign_json,
|
||||
current_flow=payload.current_flow,
|
||||
current_step=payload.current_step,
|
||||
workflow_state=payload.workflow_state,
|
||||
is_complete=payload.is_complete,
|
||||
editor_state=payload.editor_state,
|
||||
source_filename=payload.source_filename,
|
||||
source_base_path=payload.source_base_path,
|
||||
autosave=autosave,
|
||||
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||
commit=False,
|
||||
),
|
||||
audit_action=audit_action,
|
||||
details=lambda version: {
|
||||
"campaign_id": campaign_id,
|
||||
"current_flow": version.current_flow,
|
||||
"current_step": version.current_step,
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
},
|
||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
)
|
||||
|
||||
|
||||
def _require_campaign_profile_use_if_needed(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
) -> None:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
target_version_id = version_id or campaign.current_version_id
|
||||
if not target_version_id:
|
||||
return
|
||||
version = _get_version_for_tenant(session, target_version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
|
||||
|
||||
def _require_campaign_versions_profile_use(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
campaign_id: str,
|
||||
version_ids: set[str],
|
||||
) -> None:
|
||||
"""Authorize every historical version affected by a campaign-wide action."""
|
||||
|
||||
for version_id in sorted(version_ids):
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
)
|
||||
|
||||
|
||||
def _get_version_for_principal(
|
||||
session: Session,
|
||||
version_id: str,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> CampaignVersion:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
campaign = _get_campaign_for_tenant(
|
||||
session, version.campaign_id, principal.tenant_id
|
||||
)
|
||||
_require_campaign_acl(session, campaign, principal, write=write)
|
||||
return version
|
||||
|
||||
|
||||
def _sync_campaign_metadata_to_current_version(
|
||||
session: Session, campaign: Campaign
|
||||
) -> None:
|
||||
"""Keep editable version JSON aligned with version-independent campaign metadata.
|
||||
|
||||
Campaign metadata can be edited from the overview while individual campaign
|
||||
sections save the current version JSON later. Without this sync, a later
|
||||
version save can re-apply stale `campaign.name` / `campaign.id` values from
|
||||
raw_json and make the old overview metadata appear to come back. Audit-safe
|
||||
or validation-locked versions are left untouched.
|
||||
"""
|
||||
|
||||
if not campaign.current_version_id:
|
||||
return
|
||||
|
||||
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if not version or version.campaign_id != campaign.id or is_version_locked(version):
|
||||
return
|
||||
|
||||
raw_json = copy.deepcopy(
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
campaign_section = (
|
||||
raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
)
|
||||
raw_json["campaign"] = {
|
||||
**campaign_section,
|
||||
"id": campaign.external_id,
|
||||
"name": campaign.name,
|
||||
"description": campaign.description or "",
|
||||
}
|
||||
version.raw_json = raw_json
|
||||
session.add(version)
|
||||
|
||||
|
||||
def _clear_current_version_mail_profile_for_owner_transfer(
|
||||
session: Session, campaign: Campaign
|
||||
) -> bool:
|
||||
"""Force explicit profile reselection after campaign ownership changes.
|
||||
|
||||
User/group-scoped reusable mail profiles are evaluated against the current
|
||||
owner. Instead of trying to keep a stale selection across an ownership
|
||||
transfer, clear the profile from the editable current version and invalidate
|
||||
validation/build state so the operator has to reselect and revalidate.
|
||||
"""
|
||||
|
||||
if not campaign.current_version_id:
|
||||
return False
|
||||
|
||||
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if not version or version.campaign_id != campaign.id:
|
||||
return False
|
||||
|
||||
raw_json = copy.deepcopy(
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
server = (
|
||||
raw_json.get("server") if isinstance(raw_json.get("server"), dict) else None
|
||||
)
|
||||
if not isinstance(server, dict):
|
||||
return False
|
||||
|
||||
profile_id = _campaign_mail_profile_id(raw_json)
|
||||
if not profile_id:
|
||||
return False
|
||||
|
||||
if is_version_final_locked(version) or is_user_locked_version(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Change owner only after creating an editable campaign version; the current version has a selected mail profile and is locked.",
|
||||
)
|
||||
|
||||
next_server = dict(server)
|
||||
for key in CAMPAIGN_MAIL_SERVER_KEYS:
|
||||
next_server.pop(key, None)
|
||||
next_server.pop("profile_id", None)
|
||||
raw_json["server"] = next_server
|
||||
|
||||
version.raw_json = raw_json
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
clear_execution_snapshot(version)
|
||||
version.locked_at = None
|
||||
version.locked_by_user_id = None
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
version.is_complete = False
|
||||
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version.id
|
||||
).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(
|
||||
CampaignJob.campaign_version_id == version.id
|
||||
).delete(synchronize_session=False)
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.add(version)
|
||||
_write_current_version_snapshot_if_available(version)
|
||||
return True
|
||||
|
||||
|
||||
def _write_current_version_snapshot_if_available(version: CampaignVersion) -> None:
|
||||
try:
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
_write_campaign_snapshot,
|
||||
)
|
||||
|
||||
_write_campaign_snapshot(version)
|
||||
except Exception:
|
||||
# The database state is authoritative for the WebUI. Snapshot writing is
|
||||
# best-effort here because ownership changes should not fail due to an
|
||||
# unavailable local runtime directory.
|
||||
return
|
||||
|
||||
|
||||
def bounded_query_rows(query, *, limit: int, label: str):
|
||||
rows = query.limit(limit + 1).all()
|
||||
if len(rows) > limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=(
|
||||
f"{label} exceeds the maximum response size of {limit} rows. "
|
||||
"Narrow the request or use a paginated/delta endpoint."
|
||||
),
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def job_attempt_rows(query, *, label: str):
|
||||
return bounded_query_rows(query, limit=1000, label=label)
|
||||
File diff suppressed because it is too large
Load Diff
1
src/govoplan_campaign/backend/routes/__init__.py
Normal file
1
src/govoplan_campaign/backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Focused HTTP route modules for the campaign API."""
|
||||
398
src/govoplan_campaign/backend/routes/attachments.py
Normal file
398
src/govoplan_campaign/backend/routes/attachments.py
Normal file
@@ -0,0 +1,398 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
load_campaign_config_from_json,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
files_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import (
|
||||
CampaignPathSecurityError,
|
||||
assert_server_safe_campaign_paths,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
is_version_final_locked,
|
||||
is_user_locked_version,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_tenant,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
class CampaignAttachmentPreviewRequest(BaseModel):
|
||||
include_unmatched: bool = True
|
||||
include_unlinked_candidates: bool = False
|
||||
campaign_json: dict[str, object] | None = None
|
||||
|
||||
|
||||
class CampaignAttachmentPreviewResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
shared_file_count: int
|
||||
candidate_file_count: int = 0
|
||||
matched_file_count: int = 0
|
||||
linked_file_count: int = 0
|
||||
unlinked_file_count: int = 0
|
||||
rules: list[dict[str, object]] = Field(default_factory=list)
|
||||
linkable_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
unused_shared_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignAttachmentLinkMatchesRequest(BaseModel):
|
||||
campaign_json: dict[str, object] | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignAttachmentLinkMatchesResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
matched_file_count: int
|
||||
already_linked_file_count: int
|
||||
linked_file_count: int
|
||||
dry_run: bool = False
|
||||
linked_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
linkable_files: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _file_preview(session: Session, asset) -> dict[str, object]:
|
||||
version, blob = files_integration().current_version_and_blob(session, asset)
|
||||
return {
|
||||
"id": asset.id,
|
||||
"version_id": version.id,
|
||||
"blob_id": blob.id,
|
||||
"display_path": asset.display_path,
|
||||
"filename": asset.filename,
|
||||
"owner_type": asset.owner_type,
|
||||
"owner_id": asset.owner_user_id
|
||||
if asset.owner_type == "user"
|
||||
else asset.owner_group_id,
|
||||
"checksum_sha256": blob.checksum_sha256,
|
||||
"size_bytes": blob.size_bytes,
|
||||
"content_type": blob.content_type,
|
||||
"linked_to_campaign": True,
|
||||
}
|
||||
|
||||
|
||||
def _managed_preview_file(item: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"id": item["asset_id"],
|
||||
"version_id": item["version_id"],
|
||||
"blob_id": item["blob_id"],
|
||||
"display_path": item["display_path"],
|
||||
"filename": item["filename"],
|
||||
"owner_type": item["owner_type"],
|
||||
"owner_id": item["owner_id"],
|
||||
"checksum_sha256": item["checksum_sha256"],
|
||||
"size_bytes": item["size_bytes"],
|
||||
"content_type": item["content_type"],
|
||||
"linked_to_campaign": bool(item.get("linked_to_campaign", True)),
|
||||
}
|
||||
|
||||
|
||||
def _attachment_preview_for_version(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw: dict[str, object],
|
||||
include_unmatched: bool,
|
||||
include_unlinked_candidates: bool,
|
||||
) -> CampaignAttachmentPreviewResponse:
|
||||
files = files_integration()
|
||||
assert_server_safe_campaign_paths(raw, managed_files_available=files.available)
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
raw_json=raw,
|
||||
include_bytes=False,
|
||||
prefix="govoplan-managed-preview-",
|
||||
include_unlinked_candidates=include_unlinked_candidates,
|
||||
user_id=principal.user.id,
|
||||
is_admin=has_scope(principal, "files:file:admin"),
|
||||
) as prepared:
|
||||
prepared_raw = load_campaign_json(prepared.path)
|
||||
config = load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
raw_json=prepared_raw,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
report = resolve_campaign_attachments(config, campaign_file=prepared.path)
|
||||
rules: list[dict[str, object]] = []
|
||||
matched_asset_ids: set[str] = set()
|
||||
linked_asset_ids: set[str] = set()
|
||||
linkable_by_id: dict[str, dict[str, object]] = {}
|
||||
|
||||
for entry in report.entries:
|
||||
for attachment in entry.attachments:
|
||||
managed_matches = files.managed_match_payloads(
|
||||
attachment.matches, prepared.managed_files_by_local_path
|
||||
)
|
||||
matches: list[dict[str, object]] = []
|
||||
for item in managed_matches:
|
||||
asset_id = str(item["asset_id"])
|
||||
matched_asset_ids.add(asset_id)
|
||||
if bool(item.get("linked_to_campaign", True)):
|
||||
linked_asset_ids.add(asset_id)
|
||||
preview = _managed_preview_file(item)
|
||||
matches.append(preview)
|
||||
if not preview["linked_to_campaign"]:
|
||||
linkable_by_id.setdefault(asset_id, preview)
|
||||
if not matches:
|
||||
matches = [
|
||||
{
|
||||
"id": "",
|
||||
"display_path": match,
|
||||
"filename": match.rsplit("/", 1)[-1].rsplit("\\", 1)[-1],
|
||||
"owner_type": "legacy",
|
||||
"owner_id": "",
|
||||
"linked_to_campaign": True,
|
||||
}
|
||||
for match in attachment.matches
|
||||
]
|
||||
rules.append(
|
||||
{
|
||||
"source": attachment.scope.value,
|
||||
"entry_index": entry.entry_index,
|
||||
"entry_id": entry.entry_id,
|
||||
"index": attachment.index,
|
||||
"attachment_id": attachment.attachment_id,
|
||||
"label": attachment.label,
|
||||
"required": attachment.required,
|
||||
"pattern": attachment.file_filter,
|
||||
"base_path_name": attachment.base_path_name,
|
||||
"base_path": attachment.base_path,
|
||||
"status": attachment.status.value,
|
||||
"behavior": attachment.behavior.value
|
||||
if attachment.behavior
|
||||
else None,
|
||||
"zip_included": attachment.zip_enabled,
|
||||
"zip_mode": attachment.zip_mode.value,
|
||||
"zip_archive_id": attachment.zip_archive_id,
|
||||
"zip_filename": attachment.zip_filename,
|
||||
"matches": matches,
|
||||
"match_count": len(matches),
|
||||
"linked_match_count": sum(
|
||||
1
|
||||
for match in matches
|
||||
if bool(match.get("linked_to_campaign", True))
|
||||
),
|
||||
"unlinked_match_count": sum(
|
||||
1
|
||||
for match in matches
|
||||
if not bool(match.get("linked_to_campaign", True))
|
||||
),
|
||||
"issues": [
|
||||
issue.model_dump(mode="json") for issue in attachment.issues
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
unused = [
|
||||
asset
|
||||
for asset in prepared.shared_assets
|
||||
if asset.id not in matched_asset_ids
|
||||
]
|
||||
return CampaignAttachmentPreviewResponse(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
shared_file_count=len(prepared.shared_assets),
|
||||
candidate_file_count=len(
|
||||
getattr(prepared, "candidate_assets", prepared.shared_assets)
|
||||
),
|
||||
matched_file_count=len(matched_asset_ids),
|
||||
linked_file_count=len(linked_asset_ids),
|
||||
unlinked_file_count=len(linkable_by_id),
|
||||
rules=rules,
|
||||
linkable_files=list(linkable_by_id.values()),
|
||||
unused_shared_files=[_file_preview(session, asset) for asset in unused]
|
||||
if include_unmatched
|
||||
else [],
|
||||
)
|
||||
|
||||
|
||||
def _link_campaign_attachment_matches(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
raw: dict[str, object],
|
||||
dry_run: bool = False,
|
||||
) -> CampaignAttachmentLinkMatchesResponse:
|
||||
preview = _attachment_preview_for_version(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
include_unmatched=False,
|
||||
include_unlinked_candidates=True,
|
||||
)
|
||||
file_ids = [
|
||||
str(item.get("id") or "") for item in preview.linkable_files if item.get("id")
|
||||
]
|
||||
linked_files: list[dict[str, object]] = []
|
||||
if file_ids and not dry_run:
|
||||
files = files_integration()
|
||||
shares = files.share_assets_with_campaign(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
file_ids=file_ids,
|
||||
user_id=principal.user.id,
|
||||
is_admin=has_scope(principal, "files:file:admin"),
|
||||
)
|
||||
share_by_asset_id = {
|
||||
str(item.get("file_asset_id") or ""): item for item in shares
|
||||
}
|
||||
linked_files = [
|
||||
{**item, "share": share_by_asset_id.get(str(item.get("id") or ""))}
|
||||
for item in preview.linkable_files
|
||||
]
|
||||
return CampaignAttachmentLinkMatchesResponse(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
matched_file_count=preview.matched_file_count,
|
||||
already_linked_file_count=preview.linked_file_count,
|
||||
linked_file_count=0 if dry_run else len(file_ids),
|
||||
dry_run=dry_run,
|
||||
linked_files=linked_files,
|
||||
linkable_files=preview.linkable_files,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||
response_model=CampaignAttachmentPreviewResponse,
|
||||
)
|
||||
def preview_campaign_attachments(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentPreviewRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("files:file:read")),
|
||||
):
|
||||
_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)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
|
||||
payload = payload or CampaignAttachmentPreviewRequest()
|
||||
raw = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else version.raw_json
|
||||
)
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
try:
|
||||
return _attachment_preview_for_version(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
include_unmatched=payload.include_unmatched,
|
||||
include_unlinked_candidates=payload.include_unlinked_candidates,
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/attachments/link-matches",
|
||||
response_model=CampaignAttachmentLinkMatchesResponse,
|
||||
)
|
||||
def link_campaign_attachment_matches(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignAttachmentLinkMatchesRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_require_permission(principal, "files:file:share")
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Locked campaign versions cannot link new attachment files",
|
||||
)
|
||||
payload = payload or CampaignAttachmentLinkMatchesRequest()
|
||||
raw = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else version.raw_json
|
||||
)
|
||||
raw = raw if isinstance(raw, dict) else {}
|
||||
_require_mail_profile_use_if_needed(principal, raw)
|
||||
try:
|
||||
result = _link_campaign_attachment_matches(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=raw,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.attachment_matches_linked"
|
||||
if not payload.dry_run
|
||||
else "campaign.attachment_matches_link_previewed",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"matched_file_count": result.matched_file_count,
|
||||
"already_linked_file_count": result.already_linked_file_count,
|
||||
"linked_file_count": result.linked_file_count,
|
||||
"dry_run": result.dry_run,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
1362
src/govoplan_campaign/backend/routes/campaigns.py
Normal file
1362
src/govoplan_campaign/backend/routes/campaigns.py
Normal file
File diff suppressed because it is too large
Load Diff
639
src/govoplan_campaign/backend/routes/delivery.py
Normal file
639
src/govoplan_campaign/backend/routes/delivery.py
Normal file
@@ -0,0 +1,639 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
AppendSentRequest,
|
||||
CampaignActionResponse,
|
||||
CampaignRetryJobsRequest,
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignDeliveryOptionsResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
QueueCampaignRequest,
|
||||
QueueCampaignResponse,
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_send_campaign_now_result,
|
||||
send_campaign_now_audit_details,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
is_user_locked_version,
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.dev.mock_campaign import (
|
||||
MockCampaignSendError,
|
||||
run_mock_campaign_send,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
QueueingError,
|
||||
SynchronousSendRejected,
|
||||
cancel_campaign_jobs,
|
||||
enqueue_pending_imap_appends,
|
||||
pause_campaign_jobs,
|
||||
queue_campaign_jobs,
|
||||
queue_failed_jobs_for_retry,
|
||||
queue_unattempted_jobs,
|
||||
reconcile_job_outcome,
|
||||
resume_campaign_jobs,
|
||||
send_campaign_now,
|
||||
send_single_campaign_job,
|
||||
synchronous_send_options,
|
||||
)
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_tenant,
|
||||
_require_campaign_profile_use_if_needed,
|
||||
_require_campaign_versions_profile_use,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@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,
|
||||
),
|
||||
postbox_available=postbox_integration().available,
|
||||
)
|
||||
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,
|
||||
payload: QueueCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or QueueCampaignRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_campaign_jobs(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.queue_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result.as_dict(),
|
||||
commit=True,
|
||||
)
|
||||
return QueueCampaignResponse(**result.as_dict())
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/retry", response_model=CampaignActionResponse)
|
||||
def retry_campaign_jobs(
|
||||
campaign_id: str,
|
||||
payload: CampaignRetryJobsRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:retry")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignRetryJobsRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_failed_jobs_for_retry(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
include_permanent=payload.include_permanent,
|
||||
force_max_attempts=payload.force_max_attempts,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.jobs_retry_queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.jobs_retry_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse
|
||||
)
|
||||
def send_unattempted_campaign_jobs(
|
||||
campaign_id: str,
|
||||
payload: CampaignSendUnattemptedRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendUnattemptedRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_unattempted_jobs(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.unattempted_jobs_queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.unattempted_jobs_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse)
|
||||
def send_single_campaign_job_endpoint(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignSendJobRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendJobRequest()
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, None)
|
||||
try:
|
||||
result = send_single_campaign_job(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
dry_run=payload.dry_run,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.single_message_sent"
|
||||
if not payload.dry_run
|
||||
else "campaign.single_message_send_dry_run",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/jobs/{job_id}/resolve-outcome",
|
||||
response_model=CampaignActionResponse,
|
||||
)
|
||||
def resolve_campaign_job_outcome(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignResolveOutcomeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
result = reconcile_job_outcome(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
decision=payload.decision,
|
||||
note=payload.note,
|
||||
attempt_id=payload.attempt_id,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.job_outcome_reconciled",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
||||
def mock_send_campaign(
|
||||
campaign_id: str,
|
||||
payload: MockCampaignSendRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
|
||||
):
|
||||
"""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
|
||||
)
|
||||
try:
|
||||
result = run_mock_campaign_send(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
send=payload.send,
|
||||
include_warnings=payload.include_warnings,
|
||||
include_needs_review=payload.include_needs_review,
|
||||
append_sent=payload.append_sent,
|
||||
clear_mailbox=payload.clear_mailbox,
|
||||
check_files=payload.check_files,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.mock_send"
|
||||
if payload.send
|
||||
else "campaign.mock_send_review",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
"version_id": result.get("version_id"),
|
||||
"send_requested": payload.send,
|
||||
"sent_count": result.get("send", {}).get("sent_count"),
|
||||
"failed_count": result.get("send", {}).get("failed_count"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return MockCampaignSendResponse(result=result)
|
||||
except MockCampaignSendError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse)
|
||||
def send_campaign_now_endpoint(
|
||||
campaign_id: str,
|
||||
payload: SendCampaignNowRequest | None = None,
|
||||
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")
|
||||
|
||||
payload = payload or SendCampaignNowRequest()
|
||||
try:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version_id = payload.version_id or campaign.current_version_id
|
||||
if not version_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign has no current version",
|
||||
)
|
||||
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
validation_result: dict[str, object] | None = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else None
|
||||
)
|
||||
build_result: dict[str, object] | None = (
|
||||
version.build_summary if isinstance(version.build_summary, dict) else None
|
||||
)
|
||||
if is_user_locked_version(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
|
||||
)
|
||||
if (
|
||||
not version.locked_at
|
||||
or not validation_result
|
||||
or validation_result.get("ok") is not True
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign version must be validated and locked before dry-run or sending.",
|
||||
)
|
||||
if not build_result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign version must be built before dry-run or sending.",
|
||||
)
|
||||
|
||||
delivery_result = send_campaign_now(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
dry_run=payload.dry_run,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
).as_dict()
|
||||
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=send_campaign_now_audit_details(delivery_result),
|
||||
commit=True,
|
||||
)
|
||||
return SendCampaignNowResponse(result=response_result)
|
||||
except SynchronousSendRejected as exc:
|
||||
# A synchronous request stages queue state before the all-message
|
||||
# preflight can run. Rejecting that preflight must not leave work
|
||||
# eligible for a background worker when no provider effect occurred.
|
||||
session.rollback()
|
||||
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:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
|
||||
def pause_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
result = pause_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.paused",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse)
|
||||
def resume_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
try:
|
||||
result = resume_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.resumed",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse)
|
||||
def cancel_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
result = cancel_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.cancelled",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse)
|
||||
def append_sent(
|
||||
campaign_id: str,
|
||||
payload: AppendSentRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
payload = payload or AppendSentRequest()
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.send_status.in_(
|
||||
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
|
||||
),
|
||||
CampaignJob.imap_status.in_(
|
||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
try:
|
||||
result = enqueue_pending_imap_appends(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
run_inline=payload.run_inline,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.append_sent_enqueued"
|
||||
if not payload.dry_run
|
||||
else "campaign.append_sent_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
436
src/govoplan_campaign/backend/routes/jobs.py
Normal file
436
src/govoplan_campaign/backend/routes/jobs.py
Normal file
@@ -0,0 +1,436 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignJobsResponse,
|
||||
CampaignJobsDeltaResponse,
|
||||
CampaignJobDetailResponse,
|
||||
CampaignJobDiagnosticsResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
encode_sequence_watermark,
|
||||
sequence_entries_since,
|
||||
sequence_watermark_is_expired,
|
||||
)
|
||||
from govoplan_campaign.backend.change_tracking import (
|
||||
CAMPAIGNS_MODULE_ID,
|
||||
CAMPAIGN_JOBS_COLLECTION,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_require_permission,
|
||||
job_attempt_rows as _job_attempt_rows,
|
||||
)
|
||||
from govoplan_campaign.backend.services.job_queries import (
|
||||
CampaignJobsQuery,
|
||||
_campaign_jobs_delta_watermark,
|
||||
_campaign_jobs_page_response,
|
||||
_campaign_jobs_query_context,
|
||||
_job_attempts_payload,
|
||||
_job_detail_payload,
|
||||
_job_diagnostics_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse)
|
||||
def list_jobs(
|
||||
campaign_id: str,
|
||||
filters: CampaignJobsQuery = Depends(CampaignJobsQuery),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
"""Return a lightweight, paginated job list with server-side filters.
|
||||
|
||||
Complete recipients, attachment metadata, issues and attempt history are
|
||||
available from the separate job-detail endpoint.
|
||||
"""
|
||||
|
||||
_campaign, base_filters, filtered, review_metadata, reviewed_keys = (
|
||||
_campaign_jobs_query_context(
|
||||
session,
|
||||
principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
send_status=filters.send_status,
|
||||
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,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=reviewed_keys,
|
||||
review_metadata=review_metadata,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_full_delta_response(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
campaign_id: str,
|
||||
version_id: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
send_status: list[str] | None,
|
||||
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(
|
||||
session,
|
||||
principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
send_status=send_status,
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
)
|
||||
)
|
||||
payload = _campaign_jobs_page_response(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=reviewed_keys,
|
||||
review_metadata=review_metadata,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
send_status=send_status,
|
||||
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(
|
||||
**payload.model_dump(),
|
||||
deleted=[],
|
||||
watermark=_campaign_jobs_delta_watermark(session, principal.tenant_id),
|
||||
has_more=False,
|
||||
full=True,
|
||||
)
|
||||
|
||||
|
||||
def _job_filter_membership_can_shift(
|
||||
*,
|
||||
send_status: list[str] | None,
|
||||
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())
|
||||
or grid_filters
|
||||
or sort_by != "number"
|
||||
or sort_direction != "asc"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs/delta", response_model=CampaignJobsDeltaResponse)
|
||||
def list_jobs_delta(
|
||||
campaign_id: str,
|
||||
filters: CampaignJobsQuery = Depends(CampaignJobsQuery),
|
||||
since: str | None = None,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
if since is None:
|
||||
return _campaign_jobs_full_delta_response(
|
||||
session,
|
||||
principal=principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
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,
|
||||
)
|
||||
|
||||
campaign, base_filters, filtered, review_metadata, reviewed_keys = (
|
||||
_campaign_jobs_query_context(
|
||||
session,
|
||||
principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
send_status=filters.send_status,
|
||||
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)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
if sequence_watermark_is_expired(
|
||||
session,
|
||||
since=since_sequence,
|
||||
tenant_id=principal.tenant_id,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collections=(CAMPAIGN_JOBS_COLLECTION,),
|
||||
):
|
||||
return _campaign_jobs_full_delta_response(
|
||||
session,
|
||||
principal=principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
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,
|
||||
)
|
||||
|
||||
entries_plus_one = sequence_entries_since(
|
||||
session,
|
||||
since=since_sequence,
|
||||
tenant_id=principal.tenant_id,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collections=(CAMPAIGN_JOBS_COLLECTION,),
|
||||
limit=limit + 1,
|
||||
)
|
||||
has_more = len(entries_plus_one) > limit
|
||||
entries = entries_plus_one[:limit]
|
||||
relevant_entries = [
|
||||
entry
|
||||
for entry in entries
|
||||
if (entry.payload or {}).get("campaign_id") == campaign.id
|
||||
and (
|
||||
not filters.version_id
|
||||
or (entry.payload or {}).get("version_id") == filters.version_id
|
||||
)
|
||||
]
|
||||
|
||||
if relevant_entries and (
|
||||
_job_filter_membership_can_shift(
|
||||
send_status=filters.send_status,
|
||||
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)
|
||||
):
|
||||
return _campaign_jobs_full_delta_response(
|
||||
session,
|
||||
principal=principal,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
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 = {
|
||||
entry.resource_id
|
||||
for entry in relevant_entries
|
||||
if entry.resource_type == "campaign_job" and entry.operation != "deleted"
|
||||
}
|
||||
payload = _campaign_jobs_page_response(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
version_id=filters.version_id,
|
||||
base_filters=base_filters,
|
||||
filtered=filtered,
|
||||
reviewed_keys=reviewed_keys,
|
||||
review_metadata=review_metadata,
|
||||
page=filters.page,
|
||||
page_size=filters.page_size,
|
||||
send_status=filters.send_status,
|
||||
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,
|
||||
)
|
||||
watermark = (
|
||||
encode_sequence_watermark(entries[-1].id)
|
||||
if has_more and entries
|
||||
else _campaign_jobs_delta_watermark(session, principal.tenant_id)
|
||||
)
|
||||
return CampaignJobsDeltaResponse(
|
||||
**payload.model_dump(),
|
||||
deleted=[],
|
||||
watermark=watermark,
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/jobs/{job_id}", response_model=CampaignJobDetailResponse)
|
||||
def get_job_detail(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_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
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found"
|
||||
)
|
||||
send_attempts = _job_attempt_rows(
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.asc()),
|
||||
label="SMTP attempts for this campaign job",
|
||||
)
|
||||
imap_attempts = _job_attempt_rows(
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.order_by(ImapAppendAttempt.attempt_number.asc()),
|
||||
label="IMAP attempts for this campaign job",
|
||||
)
|
||||
postbox_attempts = _job_attempt_rows(
|
||||
session.query(PostboxDeliveryAttempt)
|
||||
.filter(PostboxDeliveryAttempt.job_id == job.id)
|
||||
.order_by(
|
||||
PostboxDeliveryAttempt.target_index.asc(),
|
||||
PostboxDeliveryAttempt.attempt_number.asc(),
|
||||
),
|
||||
label="Postbox attempts for this campaign job",
|
||||
)
|
||||
return CampaignJobDetailResponse(
|
||||
job=_job_detail_payload(job),
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/jobs/{job_id}/diagnostics",
|
||||
response_model=CampaignJobDiagnosticsResponse,
|
||||
)
|
||||
def get_job_diagnostics(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:diagnostic:read")),
|
||||
):
|
||||
"""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
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign job not found"
|
||||
)
|
||||
send_attempts = _job_attempt_rows(
|
||||
session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.asc()),
|
||||
label="SMTP diagnostics for this campaign job",
|
||||
)
|
||||
imap_attempts = _job_attempt_rows(
|
||||
session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.order_by(ImapAppendAttempt.attempt_number.asc()),
|
||||
label="IMAP diagnostics for this campaign job",
|
||||
)
|
||||
postbox_attempts = _job_attempt_rows(
|
||||
session.query(PostboxDeliveryAttempt)
|
||||
.filter(PostboxDeliveryAttempt.job_id == job.id)
|
||||
.order_by(
|
||||
PostboxDeliveryAttempt.target_index.asc(),
|
||||
PostboxDeliveryAttempt.attempt_number.asc(),
|
||||
),
|
||||
label="Postbox diagnostics for this campaign job",
|
||||
)
|
||||
return _job_diagnostics_payload(
|
||||
job,
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
)
|
||||
198
src/govoplan_campaign/backend/routes/reports.py
Normal file
198
src/govoplan_campaign/backend/routes/reports.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
ReportEmailRequest,
|
||||
ReportEmailResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
)
|
||||
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.reports.emailing import (
|
||||
CampaignReportEmailError,
|
||||
send_campaign_report_email,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/summary")
|
||||
def campaign_summary(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_jobs: bool = False,
|
||||
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")
|
||||
|
||||
try:
|
||||
return generate_campaign_report(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/report")
|
||||
def campaign_report(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_jobs: bool = False,
|
||||
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)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
try:
|
||||
return generate_campaign_report(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/report/jobs.csv")
|
||||
def campaign_jobs_csv(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
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")
|
||||
|
||||
try:
|
||||
csv_text = generate_jobs_csv(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
return Response(
|
||||
content=csv_text,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse)
|
||||
def email_campaign_report(
|
||||
campaign_id: str,
|
||||
payload: ReportEmailRequest,
|
||||
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:
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal,
|
||||
selected_version.raw_json
|
||||
if isinstance(selected_version.raw_json, dict)
|
||||
else {},
|
||||
)
|
||||
try:
|
||||
result = send_campaign_report_email(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
to=payload.to,
|
||||
include_jobs=payload.include_jobs,
|
||||
attach_jobs_csv=payload.attach_jobs_csv,
|
||||
attach_report_json=payload.attach_report_json,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="report.email_sent"
|
||||
if not payload.dry_run
|
||||
else "report.email_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result.as_dict(),
|
||||
commit=True,
|
||||
)
|
||||
return ReportEmailResponse(result=result.as_dict())
|
||||
except CampaignReportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except (
|
||||
CampaignReportEmailError,
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
logger.error("Campaign report email failed with an unexpected internal error")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Campaign report email could not be completed.",
|
||||
) from exc
|
||||
328
src/govoplan_campaign/backend/routes/sharing.py
Normal file
328
src/govoplan_campaign/backend/routes/sharing.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
ReferenceOptionListResponse,
|
||||
ReferenceOptionResponse,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignShareItem,
|
||||
CampaignShareListResponse,
|
||||
CampaignShareTargetItem,
|
||||
CampaignShareTargetsResponse,
|
||||
CampaignShareUpsertRequest,
|
||||
CampaignOwnerUpdateRequest,
|
||||
CampaignResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignShare,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.core.references import (
|
||||
access_scope_reference_page,
|
||||
access_scope_reference_provider_available,
|
||||
)
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_access_directory,
|
||||
_clear_current_version_mail_profile_for_owner_transfer,
|
||||
_get_campaign_for_principal,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/share-target-options",
|
||||
response_model=ReferenceOptionListResponse,
|
||||
)
|
||||
def search_campaign_share_targets(
|
||||
campaign_id: str,
|
||||
target_type: Literal["user", "group"],
|
||||
q: str = "",
|
||||
selected: list[str] = Query(default=[]),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
) -> ReferenceOptionListResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
registry = get_registry()
|
||||
try:
|
||||
page = access_scope_reference_page(
|
||||
registry,
|
||||
principal,
|
||||
scope_type=target_type,
|
||||
reference_kind="membership" if target_type == "user" else "group",
|
||||
query=q,
|
||||
selected_values=selected,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
administrative=True,
|
||||
session=session,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
return ReferenceOptionListResponse(
|
||||
options=[
|
||||
ReferenceOptionResponse(**option.to_dict())
|
||||
for option in page.options
|
||||
],
|
||||
provider_available=access_scope_reference_provider_available(registry),
|
||||
next_cursor=page.next_cursor,
|
||||
has_more=page.has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/share-targets", response_model=CampaignShareTargetsResponse)
|
||||
def list_campaign_share_targets(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
directory = _access_directory()
|
||||
users = [
|
||||
user
|
||||
for user in directory.users_for_tenant(principal.tenant_id)
|
||||
if user.status == "active"
|
||||
]
|
||||
groups = [
|
||||
group
|
||||
for group in directory.groups_for_tenant(principal.tenant_id)
|
||||
if group.status == "active"
|
||||
]
|
||||
if len(users) > limit or len(groups) > limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail=(
|
||||
f"Campaign share targets exceed the maximum response size of {limit} "
|
||||
"users or groups. Use a searchable directory selector."
|
||||
),
|
||||
)
|
||||
return CampaignShareTargetsResponse(
|
||||
users=[
|
||||
CampaignShareTargetItem(
|
||||
id=item.id, name=item.display_name or item.email, secondary=item.email
|
||||
)
|
||||
for item in users
|
||||
],
|
||||
groups=[
|
||||
CampaignShareTargetItem(id=item.id, name=item.name, secondary=None)
|
||||
for item in groups
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/shares", response_model=CampaignShareListResponse)
|
||||
def list_campaign_shares(
|
||||
campaign_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
query = session.query(CampaignShare).filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
total = query.order_by(None).count()
|
||||
pages = max(1, (total + page_size - 1) // page_size)
|
||||
shares = (
|
||||
query.order_by(
|
||||
CampaignShare.target_type.asc(),
|
||||
CampaignShare.target_id.asc(),
|
||||
CampaignShare.id.asc(),
|
||||
)
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return CampaignShareListResponse(
|
||||
shares=[CampaignShareItem.model_validate(item) for item in shares],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
pages=pages,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{campaign_id}/owner", response_model=CampaignResponse)
|
||||
def update_campaign_owner(
|
||||
campaign_id: str,
|
||||
payload: CampaignOwnerUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
if payload.owner_user_id and payload.owner_group_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Choose either a user owner or a group owner, not both",
|
||||
)
|
||||
directory = _access_directory()
|
||||
if payload.owner_user_id:
|
||||
owner = directory.get_user(payload.owner_user_id)
|
||||
if owner is not None and (
|
||||
owner.tenant_id != principal.tenant_id or owner.status != "active"
|
||||
):
|
||||
owner = None
|
||||
if owner is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Owner user not found"
|
||||
)
|
||||
if payload.owner_group_id:
|
||||
group = directory.get_group(payload.owner_group_id)
|
||||
if group is not None and (
|
||||
group.tenant_id != principal.tenant_id or group.status != "active"
|
||||
):
|
||||
group = None
|
||||
if group is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Owner group not found"
|
||||
)
|
||||
owner_changed = (
|
||||
campaign.owner_user_id != payload.owner_user_id
|
||||
or campaign.owner_group_id != payload.owner_group_id
|
||||
)
|
||||
mail_profile_reselection_required = False
|
||||
if owner_changed:
|
||||
mail_profile_reselection_required = (
|
||||
_clear_current_version_mail_profile_for_owner_transfer(session, campaign)
|
||||
)
|
||||
|
||||
campaign.owner_user_id = payload.owner_user_id
|
||||
campaign.owner_group_id = payload.owner_group_id
|
||||
session.add(campaign)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.owner_updated",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
**payload.model_dump(),
|
||||
"mail_profile_reselection_required": mail_profile_reselection_required,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignResponse.model_validate(campaign)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/shares",
|
||||
response_model=CampaignShareItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def upsert_campaign_share(
|
||||
campaign_id: str,
|
||||
payload: CampaignShareUpsertRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
directory = _access_directory()
|
||||
if payload.target_type == "user":
|
||||
target = directory.get_user(payload.target_id)
|
||||
if target is not None and (
|
||||
target.tenant_id != principal.tenant_id or target.status != "active"
|
||||
):
|
||||
target = None
|
||||
else:
|
||||
target = directory.get_group(payload.target_id)
|
||||
if target is not None and (
|
||||
target.tenant_id != principal.tenant_id or target.status != "active"
|
||||
):
|
||||
target = None
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Share target not found"
|
||||
)
|
||||
share = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.target_type == payload.target_type,
|
||||
CampaignShare.target_id == payload.target_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if share is None:
|
||||
share = CampaignShare(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
target_type=payload.target_type,
|
||||
target_id=payload.target_id,
|
||||
permission=payload.permission,
|
||||
created_by_user_id=principal.user.id,
|
||||
)
|
||||
else:
|
||||
share.permission = payload.permission
|
||||
share.revoked_at = None
|
||||
session.add(share)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.share_upserted",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details=payload.model_dump(),
|
||||
commit=True,
|
||||
)
|
||||
return CampaignShareItem.model_validate(share)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{campaign_id}/shares/{share_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
def revoke_campaign_share(
|
||||
campaign_id: str,
|
||||
share_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:share")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
share = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.id == share_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if share is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign share not found"
|
||||
)
|
||||
share.revoked_at = utc_now()
|
||||
session.add(share)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.share_revoked",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={"share_id": share_id},
|
||||
commit=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# Queue / delivery control -------------------------------------------------
|
||||
667
src/govoplan_campaign/backend/routes/versions.py
Normal file
667
src/govoplan_campaign/backend/routes/versions.py
Normal file
@@ -0,0 +1,667 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
BuildCampaignRequest,
|
||||
CampaignCreateResponse,
|
||||
CampaignResponse,
|
||||
CampaignVersionDetailResponse,
|
||||
CampaignVersionResponse,
|
||||
CampaignVersionSetStepRequest,
|
||||
CampaignReviewStateRequest,
|
||||
CampaignVersionUpdateRequest,
|
||||
CampaignPartialValidationRequest,
|
||||
CampaignPartialValidationResponse,
|
||||
ValidateCampaignRequest,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
build_campaign_version,
|
||||
validate_campaign_version,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError
|
||||
from govoplan_campaign.backend.persistence.versions import (
|
||||
LockedCampaignVersionError,
|
||||
fork_campaign_version_for_edit,
|
||||
is_version_final_locked,
|
||||
is_user_locked_version,
|
||||
get_campaign_version_for_tenant,
|
||||
lock_campaign_version_temporarily,
|
||||
permanently_lock_campaign_version,
|
||||
publish_campaign_version,
|
||||
unlock_user_locked_campaign_version,
|
||||
unlock_validated_campaign_version,
|
||||
update_campaign_version,
|
||||
update_campaign_review_state,
|
||||
validate_campaign_partial,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_campaign_response_context,
|
||||
_campaign_version_detail_response,
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_principal,
|
||||
_get_version_for_tenant,
|
||||
_require_mail_profile_use_if_needed,
|
||||
_require_permission,
|
||||
_update_campaign_version_detail_response,
|
||||
_write_current_version_snapshot_if_available,
|
||||
bounded_query_rows as _bounded_query_rows,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.attachments import (
|
||||
CampaignAttachmentLinkMatchesResponse,
|
||||
_link_campaign_attachment_matches,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse])
|
||||
def list_versions(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
versions = _bounded_query_rows(
|
||||
session.query(CampaignVersion)
|
||||
.filter(CampaignVersion.campaign_id == campaign.id)
|
||||
.order_by(CampaignVersion.version_number.desc()),
|
||||
limit=limit,
|
||||
label="Campaign version history",
|
||||
)
|
||||
return [
|
||||
CampaignVersionResponse.model_validate(
|
||||
item,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
for item in versions
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
|
||||
)
|
||||
def get_version_detail(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
return CampaignVersionDetailResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/fork", response_model=CampaignCreateResponse
|
||||
)
|
||||
def fork_version_for_edit(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
|
||||
):
|
||||
"""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)
|
||||
if source_version.campaign_id != campaign_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found"
|
||||
)
|
||||
effective_json = (
|
||||
payload.campaign_json
|
||||
if isinstance(payload.campaign_json, dict)
|
||||
else source_version.raw_json
|
||||
)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal,
|
||||
effective_json if isinstance(effective_json, dict) else {},
|
||||
)
|
||||
try:
|
||||
version = fork_campaign_version_for_edit(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
raw_json=payload.campaign_json,
|
||||
current_flow=payload.current_flow or "manual",
|
||||
current_step=payload.current_step,
|
||||
editor_state=payload.editor_state,
|
||||
source_filename=payload.source_filename,
|
||||
source_base_path=payload.source_base_path,
|
||||
autosave=True,
|
||||
migrate_legacy_mail_settings=payload.migrate_legacy_mail_settings,
|
||||
commit=False,
|
||||
)
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_forked_for_edit",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"source_version_id": version_id,
|
||||
"version_number": version.version_number,
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
_write_current_version_snapshot_if_available(version)
|
||||
return CampaignCreateResponse(
|
||||
campaign=CampaignResponse.model_validate(campaign),
|
||||
version=CampaignVersionResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
),
|
||||
)
|
||||
except LockedCampaignVersionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPathSecurityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPersistenceError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/unlock-validation",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def unlock_version_validation(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
"""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,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: unlock_validated_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_validation_unlocked",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/lock-temporarily",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def lock_version_temporarily(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: lock_campaign_version_temporarily(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_locked_temporarily",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/unlock-user-lock",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def unlock_version_user_lock(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: unlock_user_locked_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_lock_removed",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/lock-permanently",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def lock_version_permanently(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: permanently_lock_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_locked_permanently",
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse
|
||||
)
|
||||
def update_version_detail(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
return _update_campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
payload,
|
||||
autosave=False,
|
||||
audit_action="campaign.version_updated",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/autosave",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def autosave_version(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
return _update_campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
version_id,
|
||||
payload,
|
||||
autosave=True,
|
||||
audit_action="campaign.version_autosaved",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/set-step",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def set_version_step(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignVersionSetStepRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: update_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
current_flow=payload.current_flow,
|
||||
current_step=payload.current_step,
|
||||
autosave=True,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_step_updated",
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"current_flow": payload.current_flow,
|
||||
"current_step": payload.current_step,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/review-state",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def set_version_review_state(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignReviewStateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
version = update_campaign_review_state(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
inspection_complete=payload.inspection_complete,
|
||||
reviewed_message_keys=payload.reviewed_message_keys,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.message_review_updated",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"inspection_complete": payload.inspection_complete,
|
||||
"reviewed_message_count": len(payload.reviewed_message_keys),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignVersionDetailResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
except LockedCampaignVersionError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
) from exc
|
||||
except CampaignPersistenceError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/validate-partial",
|
||||
response_model=CampaignPartialValidationResponse,
|
||||
)
|
||||
def validate_version_partial(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignPartialValidationRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign_json = (
|
||||
payload.campaign_json
|
||||
if payload and payload.campaign_json is not None
|
||||
else version.raw_json
|
||||
)
|
||||
result = validate_campaign_partial(
|
||||
campaign_json, section=payload.section if payload else None
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_partially_validated",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"section": result.get("section"),
|
||||
"ok": result.get("ok"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignPartialValidationResponse(**result)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/publish",
|
||||
response_model=CampaignVersionDetailResponse,
|
||||
)
|
||||
def publish_version(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
return _campaign_version_detail_response(
|
||||
session,
|
||||
principal,
|
||||
campaign_id,
|
||||
lambda: publish_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=principal.user.id,
|
||||
commit=False,
|
||||
),
|
||||
audit_action="campaign.version_user_locked_permanently",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/validate")
|
||||
def validate_version(
|
||||
version_id: str,
|
||||
payload: ValidateCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:validate")),
|
||||
):
|
||||
_get_version_for_principal(session, version_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or ValidateCampaignRequest()
|
||||
try:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
if is_user_locked_version(version) or is_version_final_locked(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="This version has a user lock or final delivery lock and cannot be validated. Remove a temporary lock or create an editable copy.",
|
||||
)
|
||||
link_result: CampaignAttachmentLinkMatchesResponse | None = None
|
||||
if payload.check_files and payload.link_unshared_matches:
|
||||
_require_permission(principal, "files:file:share")
|
||||
campaign = _get_campaign_for_tenant(
|
||||
session, version.campaign_id, principal.tenant_id
|
||||
)
|
||||
link_result = _link_campaign_attachment_matches(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
raw=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
dry_run=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.attachment_matches_linked",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"matched_file_count": link_result.matched_file_count,
|
||||
"already_linked_file_count": link_result.already_linked_file_count,
|
||||
"linked_file_count": link_result.linked_file_count,
|
||||
"during_validation": True,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
result = validate_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
check_files=payload.check_files,
|
||||
user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.validated",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"check_files": payload.check_files,
|
||||
"link_unshared_matches": payload.link_unshared_matches,
|
||||
"linked_file_count": link_result.linked_file_count
|
||||
if link_result
|
||||
else 0,
|
||||
"ok": result.get("ok"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return public_campaign_payload(
|
||||
result,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/build")
|
||||
def build_version(
|
||||
version_id: str,
|
||||
payload: BuildCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
|
||||
):
|
||||
version = _get_version_for_principal(session, version_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
try:
|
||||
result = build_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
write_eml=payload.write_eml if payload else True,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.messages_built",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"write_eml": payload.write_eml if payload else True,
|
||||
"built_count": result.get("built_count"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return public_campaign_payload(
|
||||
result,
|
||||
include_diagnostics=has_scope(principal, "campaigns:diagnostic:read"),
|
||||
)
|
||||
except CampaignPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
@@ -60,7 +60,9 @@
|
||||
"integer",
|
||||
"double",
|
||||
"date",
|
||||
"password"
|
||||
"password",
|
||||
"organization_unit",
|
||||
"organization_function"
|
||||
],
|
||||
"default": "string"
|
||||
},
|
||||
@@ -92,7 +94,27 @@
|
||||
"mail_profile_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Stable reference to an authorized profile owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
|
||||
"description": "Stable reference to an authorized server envelope owned by the Mail module. Campaign JSON never stores SMTP/IMAP settings or credentials."
|
||||
},
|
||||
"smtp_server_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional explicit Mail-owned SMTP server selection."
|
||||
},
|
||||
"smtp_credential_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional explicit core credential envelope bound to the selected SMTP server."
|
||||
},
|
||||
"imap_server_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional explicit Mail-owned IMAP server selection."
|
||||
},
|
||||
"imap_credential_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Optional explicit core credential envelope bound to the selected IMAP server."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -509,6 +531,12 @@
|
||||
"delivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_policy": {
|
||||
"$ref": "#/$defs/delivery_channel_policy"
|
||||
},
|
||||
"postbox": {
|
||||
"$ref": "#/$defs/postbox_delivery"
|
||||
},
|
||||
"rate_limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -626,6 +654,179 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"delivery_channel_policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mail",
|
||||
"postbox",
|
||||
"mail_and_postbox",
|
||||
"mail_then_postbox",
|
||||
"postbox_then_mail"
|
||||
],
|
||||
"default": "mail"
|
||||
},
|
||||
"postbox_target": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 120
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"direct",
|
||||
"derived"
|
||||
]
|
||||
},
|
||||
"label": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 500
|
||||
},
|
||||
"postbox_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 36
|
||||
},
|
||||
"address_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 500
|
||||
},
|
||||
"template_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 36
|
||||
},
|
||||
"organization_unit_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 36
|
||||
},
|
||||
"organization_unit_field": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255
|
||||
},
|
||||
"organization_unit_match": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"id",
|
||||
"slug"
|
||||
],
|
||||
"default": "id"
|
||||
},
|
||||
"function_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 36
|
||||
},
|
||||
"function_field": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255
|
||||
},
|
||||
"function_match": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"id",
|
||||
"slug"
|
||||
],
|
||||
"default": "id"
|
||||
},
|
||||
"context_key": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255
|
||||
},
|
||||
"context_field": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"postbox_delivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/postbox_target"
|
||||
},
|
||||
"maxItems": 50,
|
||||
"default": []
|
||||
},
|
||||
"classification": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 50,
|
||||
"default": "internal"
|
||||
},
|
||||
"unresolved_target": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "block"
|
||||
},
|
||||
"vacant_target": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "warn"
|
||||
},
|
||||
"duplicate_target": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "warn"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {}
|
||||
},
|
||||
"attachment_config": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -869,6 +1070,29 @@
|
||||
"default": true,
|
||||
"description": "Deprecated compatibility alias for merge_disposition_notification_to. New campaign JSON should use merge_*."
|
||||
},
|
||||
"channel_policy": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/delivery_channel_policy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"postbox_targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/postbox_target"
|
||||
},
|
||||
"maxItems": 50,
|
||||
"default": []
|
||||
},
|
||||
"merge_postbox_targets": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
|
||||
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
@@ -194,6 +194,10 @@ class CampaignDeltaResponse(BaseModel):
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse):
|
||||
@@ -216,6 +220,10 @@ class CampaignShareItem(BaseModel):
|
||||
|
||||
class CampaignShareListResponse(BaseModel):
|
||||
shares: list[CampaignShareItem]
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 500
|
||||
pages: int = 1
|
||||
|
||||
|
||||
class CampaignShareTargetItem(BaseModel):
|
||||
@@ -342,6 +350,13 @@ class CampaignRecipientAddressSourcesResponse(BaseModel):
|
||||
sources: list[CampaignRecipientAddressSource] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignPostboxCatalogResponse(BaseModel):
|
||||
available: bool = False
|
||||
postboxes: list[dict[str, Any]] = Field(default_factory=list)
|
||||
templates: list[dict[str, Any]] = Field(default_factory=list)
|
||||
organization_units: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignRecipientAddressSourceSnapshotRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -439,8 +454,11 @@ class CampaignResolveOutcomeRequest(BaseModel):
|
||||
"not_sent",
|
||||
"imap_appended",
|
||||
"imap_not_appended",
|
||||
"postbox_accepted",
|
||||
"postbox_not_accepted",
|
||||
]
|
||||
note: str | None = Field(default=None, max_length=2000)
|
||||
attempt_id: str | None = Field(default=None, max_length=36)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest":
|
||||
@@ -520,6 +538,7 @@ class CampaignDeliveryOptionsResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
worker_queue_available: bool
|
||||
postbox_available: bool = False
|
||||
synchronous_send: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -551,62 +570,67 @@ class CampaignActionResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
def _valid_report_email_domain(domain: str) -> bool:
|
||||
if not domain or domain.startswith(".") or domain.endswith(".") or ".." in domain:
|
||||
return False
|
||||
return all(
|
||||
label
|
||||
and not label.startswith("-")
|
||||
and not label.endswith("-")
|
||||
and all(character.isalnum() or character == "-" for character in label)
|
||||
for label in domain.split(".")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_report_recipient(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("report recipients must be email-address strings")
|
||||
recipient = value.strip()
|
||||
if len(recipient) > 320:
|
||||
raise ValueError("report recipient addresses must be at most 320 characters")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
|
||||
raise ValueError("report recipient addresses must not contain control characters")
|
||||
if recipient.count("@") != 1:
|
||||
raise ValueError("report recipients must be email addresses")
|
||||
local, domain = recipient.split("@", 1)
|
||||
invalid_local = not local or local.startswith(".") or local.endswith(".") or ".." in local
|
||||
invalid_address = (
|
||||
any(character.isspace() for character in recipient)
|
||||
or any(character in ',;:<>[]()\\"' for character in recipient)
|
||||
)
|
||||
if invalid_local or invalid_address or not _valid_report_email_domain(domain):
|
||||
raise ValueError("report recipients must be email addresses")
|
||||
return recipient
|
||||
|
||||
|
||||
ReportEmailAddress = Annotated[str, BeforeValidator(_normalize_report_recipient)]
|
||||
|
||||
|
||||
def _deduplicate_report_recipients(value: list[str]) -> list[str]:
|
||||
recipients: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for recipient in value:
|
||||
key = recipient.casefold()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
recipients.append(recipient)
|
||||
return recipients
|
||||
|
||||
|
||||
class ReportEmailRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
to: list[str] = Field(min_length=1, max_length=50)
|
||||
to: list[ReportEmailAddress] = Field(min_length=1, max_length=50)
|
||||
version_id: str | None = None
|
||||
include_jobs: bool = False
|
||||
attach_jobs_csv: bool = False
|
||||
attach_report_json: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
@field_validator("to", mode="before")
|
||||
@field_validator("to")
|
||||
@classmethod
|
||||
def normalize_and_validate_recipients(cls, value: Any) -> Any:
|
||||
if not isinstance(value, list):
|
||||
return value
|
||||
if not 1 <= len(value) <= 50:
|
||||
raise ValueError("report email requires between 1 and 50 recipients")
|
||||
recipients: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise ValueError("report recipients must be email-address strings")
|
||||
recipient = item.strip()
|
||||
if len(recipient) > 320:
|
||||
raise ValueError("report recipient addresses must be at most 320 characters")
|
||||
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
|
||||
raise ValueError("report recipient addresses must not contain control characters")
|
||||
if recipient.count("@") != 1:
|
||||
raise ValueError("report recipients must be email addresses")
|
||||
local, domain = recipient.split("@", 1)
|
||||
if (
|
||||
not local
|
||||
or not domain
|
||||
or any(character.isspace() for character in recipient)
|
||||
or any(character in ',;:<>[]()\\"' for character in recipient)
|
||||
or local.startswith(".")
|
||||
or local.endswith(".")
|
||||
or ".." in local
|
||||
or domain.startswith(".")
|
||||
or domain.endswith(".")
|
||||
or ".." in domain
|
||||
or any(
|
||||
not label
|
||||
or label.startswith("-")
|
||||
or label.endswith("-")
|
||||
or not all(character.isalnum() or character == "-" for character in label)
|
||||
for label in domain.split(".")
|
||||
)
|
||||
):
|
||||
raise ValueError("report recipients must be email addresses")
|
||||
key = recipient.casefold()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
recipients.append(recipient)
|
||||
return recipients
|
||||
def normalize_and_validate_recipients(cls, value: list[str]) -> list[str]:
|
||||
return _deduplicate_report_recipients(value)
|
||||
|
||||
|
||||
class ReportEmailResponse(BaseModel):
|
||||
|
||||
@@ -9,16 +9,21 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
DeliveryChannelPolicy,
|
||||
DeliveryConfig,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
assert_campaign_uses_mail_profile_reference,
|
||||
campaign_mail_profile_id,
|
||||
campaign_mail_resource_ids,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
|
||||
|
||||
SNAPSHOT_VERSION = "5"
|
||||
SNAPSHOT_VERSION = "7"
|
||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION}
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
@@ -40,7 +45,11 @@ class ExecutionSnapshot(BaseModel):
|
||||
snapshot_version: str = SNAPSHOT_VERSION
|
||||
campaign_version_id: str
|
||||
campaign_json_sha256: str
|
||||
mail_profile_id: str
|
||||
mail_profile_id: str | None = None
|
||||
smtp_server_id: str | None = None
|
||||
smtp_credential_id: str | None = None
|
||||
imap_server_id: str | None = None
|
||||
imap_credential_id: str | None = None
|
||||
created_at: str
|
||||
build_token: str | None = None
|
||||
built_at: str | None = None
|
||||
@@ -50,6 +59,8 @@ class ExecutionSnapshot(BaseModel):
|
||||
effective_policy_sha256: str | None = None
|
||||
smtp_transport_revision: str | None = None
|
||||
imap_transport_revision: str | None = None
|
||||
uses_mail: bool = True
|
||||
uses_postbox: bool = False
|
||||
delivery: DeliveryConfig
|
||||
|
||||
|
||||
@@ -67,7 +78,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
|
||||
|
||||
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
_assert_version_mail_profile_boundary(raw_json, require_profile=True)
|
||||
mail = mail_integration()
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
|
||||
@@ -75,12 +86,17 @@ def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
references = campaign_mail_resource_ids(raw_json)
|
||||
try:
|
||||
return mail.campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
profile_id=profile_id,
|
||||
smtp_server_id=references["smtp_server_id"],
|
||||
smtp_credential_id=references["smtp_credential_id"],
|
||||
imap_server_id=references["imap_server_id"],
|
||||
imap_credential_id=references["imap_credential_id"],
|
||||
)
|
||||
except MailProfileError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
@@ -96,27 +112,61 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
|
||||
|
||||
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
_assert_version_mail_profile_boundary(
|
||||
raw_json,
|
||||
require_profile=snapshot.uses_mail,
|
||||
)
|
||||
if not snapshot.uses_mail:
|
||||
return
|
||||
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
|
||||
raise ExecutionSnapshotError(
|
||||
"The campaign's Mail profile reference differs from the built execution snapshot. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
references = campaign_mail_resource_ids(raw_json)
|
||||
for key in (
|
||||
"smtp_server_id",
|
||||
"smtp_credential_id",
|
||||
"imap_server_id",
|
||||
"imap_credential_id",
|
||||
):
|
||||
configured = references[key]
|
||||
if configured and configured != getattr(snapshot, key):
|
||||
raise ExecutionSnapshotError(
|
||||
"The campaign's Mail server or credential selection differs from the built "
|
||||
"execution snapshot. Revalidate and rebuild before delivery."
|
||||
)
|
||||
|
||||
|
||||
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
|
||||
def _assert_version_mail_profile_boundary(
|
||||
raw_json: dict[str, Any],
|
||||
*,
|
||||
require_profile: bool,
|
||||
) -> None:
|
||||
try:
|
||||
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
|
||||
assert_campaign_uses_mail_profile_reference(
|
||||
raw_json,
|
||||
require_profile=require_profile,
|
||||
)
|
||||
except CampaignMailProfileBoundaryError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
||||
def _policy_fingerprint(
|
||||
raw_json: dict[str, Any],
|
||||
delivery: DeliveryConfig,
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> str:
|
||||
delivery_payload = delivery.model_dump(mode="json")
|
||||
if snapshot_version == "6":
|
||||
delivery_payload.pop("channel_policy", None)
|
||||
delivery_payload.pop("postbox", None)
|
||||
return _sha256(
|
||||
{
|
||||
"validation_policy": raw_json.get("validation_policy"),
|
||||
"policy": raw_json.get("policy"),
|
||||
"delivery": delivery.model_dump(mode="json"),
|
||||
"delivery": delivery_payload,
|
||||
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
|
||||
if isinstance(raw_json.get("attachments"), dict)
|
||||
else None,
|
||||
@@ -124,8 +174,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
|
||||
)
|
||||
|
||||
|
||||
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
|
||||
return {
|
||||
def _job_execution_input_payload(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
@@ -140,17 +194,47 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
|
||||
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
|
||||
"issues_sha256": _sha256(job.issues_snapshot or []),
|
||||
}
|
||||
if snapshot_version != "6":
|
||||
payload.update(
|
||||
{
|
||||
"delivery_channel_policy": getattr(
|
||||
job,
|
||||
"delivery_channel_policy",
|
||||
DeliveryChannelPolicy.MAIL.value,
|
||||
),
|
||||
"resolved_postbox_targets_sha256": _sha256(
|
||||
getattr(job, "resolved_postbox_targets", None) or []
|
||||
),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def job_execution_input_hash(job: CampaignJob) -> str:
|
||||
return _sha256(_job_execution_input_payload(job))
|
||||
def job_execution_input_hash(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> str:
|
||||
return _sha256(
|
||||
_job_execution_input_payload(
|
||||
job,
|
||||
snapshot_version=snapshot_version,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
def job_manifest_hash(
|
||||
jobs: Iterable[CampaignJob],
|
||||
*,
|
||||
snapshot_version: str = SNAPSHOT_VERSION,
|
||||
) -> str:
|
||||
"""Hash the immutable per-message execution records in stable order."""
|
||||
|
||||
payload = [
|
||||
_job_execution_input_payload(job)
|
||||
_job_execution_input_payload(
|
||||
job,
|
||||
snapshot_version=snapshot_version,
|
||||
)
|
||||
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
|
||||
]
|
||||
return _sha256(payload)
|
||||
@@ -159,31 +243,67 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
def create_execution_snapshot(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
mail_profile_id: str,
|
||||
smtp_transport_revision: str,
|
||||
mail_profile_id: str | None,
|
||||
smtp_transport_revision: str | None,
|
||||
imap_transport_revision: str | None,
|
||||
delivery: DeliveryConfig,
|
||||
smtp_server_id: str | None = None,
|
||||
smtp_credential_id: str | None = None,
|
||||
imap_server_id: str | None = None,
|
||||
imap_credential_id: str | None = None,
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
build_summary: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
job_list = list(jobs)
|
||||
channel_policies = {
|
||||
DeliveryChannelPolicy(
|
||||
getattr(
|
||||
job,
|
||||
"delivery_channel_policy",
|
||||
DeliveryChannelPolicy.MAIL.value,
|
||||
)
|
||||
)
|
||||
for job in job_list
|
||||
}
|
||||
uses_mail = any(policy.uses_mail for policy in channel_policies)
|
||||
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
|
||||
for job in job_list:
|
||||
job.execution_input_sha256 = job_execution_input_hash(job)
|
||||
job.execution_input_sha256 = job_execution_input_hash(
|
||||
job,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
)
|
||||
summary = build_summary if isinstance(build_summary, dict) else {}
|
||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||
payload = ExecutionSnapshot(
|
||||
campaign_version_id=version.id,
|
||||
campaign_json_sha256=_sha256(raw_json),
|
||||
mail_profile_id=mail_profile_id,
|
||||
smtp_server_id=smtp_server_id,
|
||||
smtp_credential_id=smtp_credential_id,
|
||||
imap_server_id=imap_server_id,
|
||||
imap_credential_id=imap_credential_id,
|
||||
build_token=str(summary.get("build_token") or "") or None,
|
||||
built_at=str(summary.get("built_at") or "") or None,
|
||||
job_count=len(job_list),
|
||||
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
||||
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
||||
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
||||
job_manifest_sha256=(
|
||||
job_manifest_hash(
|
||||
job_list,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
)
|
||||
if job_list
|
||||
else None
|
||||
),
|
||||
effective_policy_sha256=_policy_fingerprint(
|
||||
raw_json,
|
||||
delivery,
|
||||
snapshot_version=SNAPSHOT_VERSION,
|
||||
),
|
||||
smtp_transport_revision=smtp_transport_revision,
|
||||
imap_transport_revision=imap_transport_revision,
|
||||
uses_mail=uses_mail,
|
||||
uses_postbox=uses_postbox,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
delivery=delivery,
|
||||
).model_dump(mode="json")
|
||||
@@ -207,13 +327,17 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
"Campaign inputs changed after this execution snapshot was built. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
if not snapshot.smtp_transport_revision:
|
||||
if snapshot.uses_mail and not snapshot.smtp_transport_revision:
|
||||
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
|
||||
if not snapshot.job_manifest_sha256:
|
||||
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
|
||||
if not snapshot.effective_policy_sha256:
|
||||
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
|
||||
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery):
|
||||
if snapshot.effective_policy_sha256 != _policy_fingerprint(
|
||||
raw_json,
|
||||
snapshot.delivery,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
):
|
||||
raise ExecutionSnapshotError(
|
||||
"Campaign delivery policy changed after the execution snapshot was created. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
@@ -224,7 +348,10 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
|
||||
if not getattr(effect_job, "execution_input_sha256", None):
|
||||
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
|
||||
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job):
|
||||
if effect_job.execution_input_sha256 != job_execution_input_hash(
|
||||
effect_job,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
):
|
||||
raise ExecutionSnapshotError(
|
||||
"Built campaign job inputs changed after the execution snapshot was created. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
@@ -246,8 +373,19 @@ def _assert_snapshot_matches_persisted_inputs(
|
||||
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
|
||||
if (
|
||||
snapshot.queueable_job_count != queueable_count
|
||||
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs)
|
||||
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs)
|
||||
or snapshot.job_manifest_sha256
|
||||
!= job_manifest_hash(
|
||||
jobs,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
)
|
||||
or any(
|
||||
getattr(job, "execution_input_sha256", None)
|
||||
!= job_execution_input_hash(
|
||||
job,
|
||||
snapshot_version=snapshot.snapshot_version,
|
||||
)
|
||||
for job in jobs
|
||||
)
|
||||
):
|
||||
raise ExecutionSnapshotError(
|
||||
"Built campaign job inputs changed after the execution snapshot was created. "
|
||||
@@ -276,17 +414,20 @@ def ensure_execution_snapshot(
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
_assert_version_mail_profile_boundary(raw_json)
|
||||
_assert_version_mail_profile_boundary(raw_json, require_profile=False)
|
||||
|
||||
if isinstance(version.execution_snapshot, dict):
|
||||
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
|
||||
stored_version = str(
|
||||
version.execution_snapshot.get("snapshot_version") or ""
|
||||
)
|
||||
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
|
||||
raise ExecutionSnapshotError(
|
||||
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
|
||||
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
|
||||
"and rebuild a new campaign version."
|
||||
)
|
||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
||||
expected = snapshot_hash(version.execution_snapshot)
|
||||
if not version.execution_snapshot_hash:
|
||||
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
|
||||
if version.execution_snapshot_hash != expected:
|
||||
@@ -303,11 +444,6 @@ def ensure_execution_snapshot(
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
|
||||
_, _, config = load_version_config(session, version.id)
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
|
||||
if profile_id is None:
|
||||
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
@@ -316,14 +452,33 @@ def ensure_execution_snapshot(
|
||||
)
|
||||
if not jobs:
|
||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||
revisions = profile_transport_revisions(session, version)
|
||||
if not revisions["smtp"]:
|
||||
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
|
||||
uses_mail = any(
|
||||
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
|
||||
for job in jobs
|
||||
)
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
summary: dict[str, Any] = {}
|
||||
if uses_mail:
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise ExecutionSnapshotError(
|
||||
"The selected Mail profile has no SMTP configuration"
|
||||
)
|
||||
if profile_id is None:
|
||||
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
|
||||
summary = profile_delivery_summary(session, version)
|
||||
if not summary.get("smtp_transport_revision"):
|
||||
raise ExecutionSnapshotError(
|
||||
"The selected Mail profile has no SMTP transport revision"
|
||||
)
|
||||
payload, digest = create_execution_snapshot(
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
smtp_transport_revision=revisions["smtp"],
|
||||
imap_transport_revision=revisions["imap"],
|
||||
smtp_server_id=summary.get("smtp_server_id"),
|
||||
smtp_credential_id=summary.get("smtp_credential_id"),
|
||||
imap_server_id=summary.get("imap_server_id"),
|
||||
imap_credential_id=summary.get("imap_credential_id"),
|
||||
smtp_transport_revision=summary.get("smtp_transport_revision"),
|
||||
imap_transport_revision=summary.get("imap_transport_revision"),
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
506
src/govoplan_campaign/backend/sending/postbox_delivery.py
Normal file
506
src/govoplan_campaign/backend/sending/postbox_delivery.py
Normal file
@@ -0,0 +1,506 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxAttachmentRef,
|
||||
PostboxDeliveryOutcomeUnknown,
|
||||
PostboxDeliveryRejected,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxParticipantRef,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
JobPostboxStatus,
|
||||
PostboxDeliveryAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
PostboxDeliveryUnavailable,
|
||||
postbox_integration,
|
||||
)
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
ACCEPTED_POSTBOX_ATTEMPT_STATUSES = {
|
||||
JobPostboxStatus.ACCEPTED.value,
|
||||
JobPostboxStatus.ACCEPTED_VACANT.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PostboxChannelOutcome:
|
||||
accepted: int = 0
|
||||
accepted_vacant: int = 0
|
||||
rejected_temporary: int = 0
|
||||
rejected_permanent: int = 0
|
||||
outcome_unknown: int = 0
|
||||
messages: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
return self.accepted + self.accepted_vacant
|
||||
|
||||
@property
|
||||
def rejected_count(self) -> int:
|
||||
return self.rejected_temporary + self.rejected_permanent
|
||||
|
||||
@property
|
||||
def all_rejected_before_acceptance(self) -> bool:
|
||||
return (
|
||||
self.accepted_count == 0
|
||||
and self.outcome_unknown == 0
|
||||
and self.rejected_count > 0
|
||||
)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if self.outcome_unknown:
|
||||
return JobPostboxStatus.OUTCOME_UNKNOWN.value
|
||||
if self.accepted_count and self.rejected_count:
|
||||
return JobPostboxStatus.PARTIALLY_ACCEPTED.value
|
||||
if self.accepted_vacant and not self.accepted:
|
||||
return JobPostboxStatus.ACCEPTED_VACANT.value
|
||||
if self.accepted_count:
|
||||
return JobPostboxStatus.ACCEPTED.value
|
||||
if self.rejected_temporary:
|
||||
return JobPostboxStatus.REJECTED_TEMPORARY.value
|
||||
return JobPostboxStatus.REJECTED_PERMANENT.value
|
||||
|
||||
|
||||
def _target_key(target: dict[str, Any]) -> str:
|
||||
payload = json.dumps(
|
||||
target,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _attempt_number(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
target_key: str,
|
||||
) -> int:
|
||||
current = (
|
||||
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
|
||||
.filter(
|
||||
PostboxDeliveryAttempt.job_id == job_id,
|
||||
PostboxDeliveryAttempt.target_key == target_key,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return int(current or 0) + 1
|
||||
|
||||
|
||||
def _accepted_attempt(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
target_key: str,
|
||||
) -> PostboxDeliveryAttempt | None:
|
||||
return (
|
||||
session.query(PostboxDeliveryAttempt)
|
||||
.filter(
|
||||
PostboxDeliveryAttempt.job_id == job_id,
|
||||
PostboxDeliveryAttempt.target_key == target_key,
|
||||
PostboxDeliveryAttempt.status.in_(
|
||||
list(ACCEPTED_POSTBOX_ATTEMPT_STATUSES)
|
||||
),
|
||||
)
|
||||
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _message_body(message_bytes: bytes) -> str | None:
|
||||
message = BytesParser(policy=policy.default).parsebytes(message_bytes)
|
||||
body = message.get_body(preferencelist=("plain", "html"))
|
||||
if body is None:
|
||||
payload = message.get_payload(decode=True)
|
||||
if not isinstance(payload, bytes):
|
||||
return None
|
||||
return payload.decode(message.get_content_charset() or "utf-8", "replace")
|
||||
try:
|
||||
content = body.get_content()
|
||||
except (LookupError, UnicodeError):
|
||||
payload = body.get_payload(decode=True)
|
||||
if not isinstance(payload, bytes):
|
||||
return None
|
||||
return payload.decode(body.get_content_charset() or "utf-8", "replace")
|
||||
return str(content)
|
||||
|
||||
|
||||
def _participants(job: CampaignJob) -> tuple[PostboxParticipantRef, ...]:
|
||||
recipients = job.resolved_recipients or {}
|
||||
values: list[PostboxParticipantRef] = []
|
||||
for key in (
|
||||
"from_all",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"reply_to",
|
||||
"bounce_to",
|
||||
"disposition_notification_to",
|
||||
):
|
||||
for item in recipients.get(key) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
address = str(item.get("email") or "").strip() or None
|
||||
label = str(item.get("name") or "").strip() or None
|
||||
if address is None and label is None:
|
||||
continue
|
||||
values.append(
|
||||
PostboxParticipantRef(
|
||||
kind="sender" if key == "from_all" else key,
|
||||
reference_type="mail_address",
|
||||
label=label,
|
||||
address=address,
|
||||
)
|
||||
)
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _attachments(job: CampaignJob) -> tuple[PostboxAttachmentRef, ...]:
|
||||
values = [_eml_attachment(job)]
|
||||
for rule_index, rule in enumerate(job.resolved_attachments or []):
|
||||
if not isinstance(rule, dict):
|
||||
continue
|
||||
managed_matches = rule.get("managed_matches")
|
||||
if isinstance(managed_matches, list) and managed_matches:
|
||||
values.extend(_managed_attachments(managed_matches))
|
||||
continue
|
||||
matches = rule.get("matches")
|
||||
if isinstance(matches, list):
|
||||
values.extend(_campaign_attachments(job, rule_index=rule_index, matches=matches))
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _eml_attachment(job: CampaignJob) -> PostboxAttachmentRef:
|
||||
return PostboxAttachmentRef(
|
||||
reference_type="campaign_eml",
|
||||
reference_id=job.id,
|
||||
name=f"{job.entry_id or job.entry_index}.eml",
|
||||
media_type="message/rfc822",
|
||||
size_bytes=job.eml_size_bytes,
|
||||
digest=job.eml_sha256,
|
||||
metadata={"campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id},
|
||||
)
|
||||
|
||||
|
||||
MANAGED_ATTACHMENT_METADATA_KEYS = {
|
||||
"asset_id",
|
||||
"version_id",
|
||||
"blob_id",
|
||||
"display_path",
|
||||
"relative_path",
|
||||
"owner_type",
|
||||
"owner_id",
|
||||
"source_revision",
|
||||
}
|
||||
|
||||
|
||||
def _managed_attachment(match: dict[str, Any]) -> PostboxAttachmentRef | None:
|
||||
reference_id = str(match.get("version_id") or match.get("asset_id") or match.get("blob_id") or "").strip()
|
||||
if not reference_id:
|
||||
return None
|
||||
return PostboxAttachmentRef(
|
||||
reference_type="file_version" if match.get("version_id") else "file_asset",
|
||||
reference_id=reference_id,
|
||||
name=str(match.get("filename") or "").strip() or None,
|
||||
media_type=str(match.get("content_type")) if match.get("content_type") else None,
|
||||
size_bytes=int(match["size_bytes"]) if match.get("size_bytes") is not None else None,
|
||||
digest=str(match.get("checksum_sha256")) if match.get("checksum_sha256") else None,
|
||||
metadata={key: value for key, value in match.items() if key in MANAGED_ATTACHMENT_METADATA_KEYS},
|
||||
)
|
||||
|
||||
|
||||
def _managed_attachments(matches: list[Any]) -> list[PostboxAttachmentRef]:
|
||||
attachments = (_managed_attachment(match) for match in matches if isinstance(match, dict))
|
||||
return [attachment for attachment in attachments if attachment is not None]
|
||||
|
||||
|
||||
def _campaign_attachments(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
rule_index: int,
|
||||
matches: list[Any],
|
||||
) -> list[PostboxAttachmentRef]:
|
||||
metadata = {"campaign_id": job.campaign_id, "campaign_version_id": job.campaign_version_id, "job_id": job.id}
|
||||
return [
|
||||
PostboxAttachmentRef(
|
||||
reference_type="campaign_attachment",
|
||||
reference_id=f"{job.id}:{rule_index}:{match_index}",
|
||||
name=str(match).rsplit("/", 1)[-1] or None,
|
||||
metadata=metadata,
|
||||
)
|
||||
for match_index, match in enumerate(matches)
|
||||
]
|
||||
|
||||
|
||||
def _sender_label(job: CampaignJob) -> str | None:
|
||||
recipients = job.resolved_recipients or {}
|
||||
sender = recipients.get("from")
|
||||
if not isinstance(sender, dict):
|
||||
return None
|
||||
name = str(sender.get("name") or "").strip()
|
||||
address = str(sender.get("email") or "").strip()
|
||||
if name and address:
|
||||
return f"{name} <{address}>"
|
||||
return address or name or None
|
||||
|
||||
|
||||
def _request(
|
||||
job: CampaignJob,
|
||||
target: dict[str, Any],
|
||||
*,
|
||||
target_key: str,
|
||||
body_text: str | None,
|
||||
classification: str,
|
||||
) -> PostboxDeliveryRequest:
|
||||
return PostboxDeliveryRequest(
|
||||
tenant_id=job.tenant_id,
|
||||
target=PostboxTargetRef(postbox_id=str(target["postbox_id"])),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_job",
|
||||
producer_resource_id=job.id,
|
||||
idempotency_key=(
|
||||
f"campaign:{job.campaign_version_id}:{job.id}:postbox:"
|
||||
f"{target_key}"
|
||||
),
|
||||
subject=(job.subject or "").strip() or "(No subject)",
|
||||
body_text=body_text,
|
||||
sender_label=_sender_label(job),
|
||||
classification=classification,
|
||||
participants=_participants(job),
|
||||
attachments=_attachments(job),
|
||||
metadata={
|
||||
"campaign_id": job.campaign_id,
|
||||
"campaign_version_id": job.campaign_version_id,
|
||||
"campaign_job_id": job.id,
|
||||
"entry_id": job.entry_id,
|
||||
"entry_index": job.entry_index,
|
||||
"delivery_channel_policy": job.delivery_channel_policy,
|
||||
"target_snapshot": target,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _record_rejection(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
attempt_id: str,
|
||||
exc: Exception,
|
||||
temporary: bool,
|
||||
) -> None:
|
||||
session.rollback()
|
||||
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if attempt is None or job is None:
|
||||
raise RuntimeError(
|
||||
"Postbox rejection could not be written to Campaign evidence."
|
||||
) from exc
|
||||
attempt.status = (
|
||||
JobPostboxStatus.REJECTED_TEMPORARY.value
|
||||
if temporary
|
||||
else JobPostboxStatus.REJECTED_PERMANENT.value
|
||||
)
|
||||
attempt.error_type = exc.__class__.__name__
|
||||
attempt.error_code = str(getattr(exc, "code", "") or "") or None
|
||||
attempt.error_message = str(exc)
|
||||
attempt.finished_at = utc_now()
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _record_unknown(
|
||||
session: Session,
|
||||
*,
|
||||
job_id: str,
|
||||
attempt_id: str,
|
||||
exc: Exception,
|
||||
) -> None:
|
||||
session.rollback()
|
||||
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if attempt is None or job is None:
|
||||
raise RuntimeError(
|
||||
"Unknown Postbox outcome could not be written to Campaign evidence."
|
||||
) from exc
|
||||
attempt.status = JobPostboxStatus.OUTCOME_UNKNOWN.value
|
||||
attempt.error_type = exc.__class__.__name__
|
||||
attempt.error_code = str(getattr(exc, "code", "") or "") or None
|
||||
attempt.error_message = str(exc)
|
||||
attempt.finished_at = utc_now()
|
||||
job.postbox_status = JobPostboxStatus.OUTCOME_UNKNOWN.value
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
|
||||
|
||||
def deliver_campaign_job_to_postboxes(
|
||||
session: Session,
|
||||
*,
|
||||
job: CampaignJob,
|
||||
message_bytes: bytes,
|
||||
classification: str,
|
||||
) -> PostboxChannelOutcome:
|
||||
outcome = PostboxChannelOutcome()
|
||||
targets = [
|
||||
target
|
||||
for target in (job.resolved_postbox_targets or [])
|
||||
if isinstance(target, dict) and target.get("postbox_id")
|
||||
]
|
||||
if not targets:
|
||||
outcome.rejected_permanent = 1
|
||||
outcome.messages.append("No frozen Postbox target is available.")
|
||||
job.postbox_status = JobPostboxStatus.REJECTED_PERMANENT.value
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return outcome
|
||||
|
||||
body_text = _message_body(message_bytes)
|
||||
for target_index, target in enumerate(targets):
|
||||
key = _target_key(target)
|
||||
accepted = _accepted_attempt(
|
||||
session,
|
||||
job_id=job.id,
|
||||
target_key=key,
|
||||
)
|
||||
if accepted is not None:
|
||||
if accepted.vacant:
|
||||
outcome.accepted_vacant += 1
|
||||
else:
|
||||
outcome.accepted += 1
|
||||
continue
|
||||
|
||||
attempt_number = _attempt_number(
|
||||
session,
|
||||
job_id=job.id,
|
||||
target_key=key,
|
||||
)
|
||||
request = _request(
|
||||
job,
|
||||
target,
|
||||
target_key=key,
|
||||
body_text=body_text,
|
||||
classification=classification,
|
||||
)
|
||||
attempt = PostboxDeliveryAttempt(
|
||||
tenant_id=job.tenant_id,
|
||||
job_id=job.id,
|
||||
target_key=key,
|
||||
target_index=target_index,
|
||||
attempt_number=attempt_number,
|
||||
idempotency_key=request.idempotency_key,
|
||||
status=JobPostboxStatus.DELIVERING.value,
|
||||
target_snapshot=target,
|
||||
evidence={},
|
||||
started_at=utc_now(),
|
||||
)
|
||||
job.postbox_attempt_count += 1
|
||||
job.postbox_status = JobPostboxStatus.DELIVERING.value
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
attempt_id = attempt.id
|
||||
|
||||
try:
|
||||
result = postbox_integration().deliver(session, request)
|
||||
current_attempt = session.get(PostboxDeliveryAttempt, attempt_id)
|
||||
current_job = session.get(CampaignJob, job.id)
|
||||
if current_attempt is None or current_job is None:
|
||||
raise RuntimeError(
|
||||
"Campaign Postbox attempt disappeared before acceptance."
|
||||
)
|
||||
current_attempt.status = (
|
||||
JobPostboxStatus.ACCEPTED_VACANT.value
|
||||
if result.vacant
|
||||
else JobPostboxStatus.ACCEPTED.value
|
||||
)
|
||||
current_attempt.provider_delivery_id = result.delivery_id
|
||||
current_attempt.provider_message_id = result.message_id
|
||||
current_attempt.postbox_id = result.postbox_id
|
||||
current_attempt.address = result.address
|
||||
current_attempt.holder_count = result.holder_count
|
||||
current_attempt.vacant = result.vacant
|
||||
current_attempt.duplicate = result.duplicate
|
||||
current_attempt.evidence = dict(result.evidence)
|
||||
current_attempt.finished_at = utc_now()
|
||||
session.add(current_attempt)
|
||||
session.add(current_job)
|
||||
session.commit()
|
||||
if result.vacant:
|
||||
outcome.accepted_vacant += 1
|
||||
else:
|
||||
outcome.accepted += 1
|
||||
except PostboxDeliveryOutcomeUnknown as exc:
|
||||
_record_unknown(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt_id=attempt_id,
|
||||
exc=exc,
|
||||
)
|
||||
outcome.outcome_unknown += 1
|
||||
outcome.messages.append(str(exc))
|
||||
except PostboxDeliveryRejected as exc:
|
||||
if exc.code == "idempotency_conflict":
|
||||
_record_unknown(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt_id=attempt_id,
|
||||
exc=exc,
|
||||
)
|
||||
outcome.outcome_unknown += 1
|
||||
else:
|
||||
_record_rejection(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt_id=attempt_id,
|
||||
exc=exc,
|
||||
temporary=exc.temporary,
|
||||
)
|
||||
if exc.temporary:
|
||||
outcome.rejected_temporary += 1
|
||||
else:
|
||||
outcome.rejected_permanent += 1
|
||||
outcome.messages.append(str(exc))
|
||||
except PostboxDeliveryUnavailable as exc:
|
||||
_record_rejection(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt_id=attempt_id,
|
||||
exc=exc,
|
||||
temporary=True,
|
||||
)
|
||||
outcome.rejected_temporary += 1
|
||||
outcome.messages.append(str(exc))
|
||||
except Exception as exc:
|
||||
_record_unknown(
|
||||
session,
|
||||
job_id=job.id,
|
||||
attempt_id=attempt_id,
|
||||
exc=exc,
|
||||
)
|
||||
outcome.outcome_unknown += 1
|
||||
outcome.messages.append(str(exc))
|
||||
|
||||
current_job = session.get(CampaignJob, job.id)
|
||||
if current_job is not None:
|
||||
current_job.postbox_status = outcome.status
|
||||
session.add(current_job)
|
||||
session.commit()
|
||||
return outcome
|
||||
884
src/govoplan_campaign/backend/services/job_queries.py
Normal file
884
src/govoplan_campaign/backend/services/job_queries.py
Normal file
@@ -0,0 +1,884 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import HTTPException, Query, status
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignJobsResponse,
|
||||
CampaignJobDiagnosticsResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.change_sequence import (
|
||||
encode_sequence_watermark,
|
||||
max_sequence_id,
|
||||
)
|
||||
from govoplan_core.core.pagination import (
|
||||
KeysetCursorError,
|
||||
decode_keyset_cursor,
|
||||
encode_keyset_cursor,
|
||||
keyset_query_fingerprint,
|
||||
)
|
||||
from govoplan_campaign.backend.change_tracking import (
|
||||
CAMPAIGNS_MODULE_ID,
|
||||
CAMPAIGN_JOBS_COLLECTION,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
JobImapStatus,
|
||||
JobPostboxStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
public_delivery_result_message,
|
||||
)
|
||||
|
||||
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_get_campaign_for_tenant,
|
||||
_get_version_for_tenant,
|
||||
_require_permission,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_JOBS_CURSOR_SCOPE = "campaign.jobs"
|
||||
|
||||
|
||||
def _job_review_key(job: CampaignJob) -> str:
|
||||
return str(job.entry_id or job.entry_index)
|
||||
|
||||
|
||||
def _job_summary_payload(
|
||||
job: CampaignJob,
|
||||
*,
|
||||
reviewed_keys: set[str] | None = None,
|
||||
) -> dict[str, object]:
|
||||
review_key = _job_review_key(job)
|
||||
return {
|
||||
"id": job.id,
|
||||
"campaign_version_id": job.campaign_version_id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"subject": job.subject,
|
||||
"message_id_header": job.message_id_header,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"delivery_channel_policy": getattr(job, "delivery_channel_policy", "mail"),
|
||||
"postbox_status": getattr(job, "postbox_status", "not_requested"),
|
||||
"imap_status": job.imap_status,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
"attempt_count": job.attempt_count,
|
||||
"postbox_attempt_count": getattr(job, "postbox_attempt_count", 0),
|
||||
"postbox_target_count": len(
|
||||
getattr(job, "resolved_postbox_targets", None) or []
|
||||
),
|
||||
"last_error": public_delivery_result_message(
|
||||
last_error=job.last_error,
|
||||
send_status=job.send_status,
|
||||
imap_status=job.imap_status,
|
||||
postbox_status=getattr(job, "postbox_status", "not_requested"),
|
||||
),
|
||||
"queued_at": job.queued_at,
|
||||
"outcome_unknown_at": job.outcome_unknown_at,
|
||||
"sent_at": job.sent_at,
|
||||
"created_at": job.created_at,
|
||||
"updated_at": job.updated_at,
|
||||
"issues_count": len(job.issues_snapshot or []),
|
||||
"attachment_count": len(job.resolved_attachments or []),
|
||||
"review_key": review_key,
|
||||
"reviewed": review_key in reviewed_keys if reviewed_keys is not None else False,
|
||||
"matched_file_count": sum(
|
||||
len(item.get("matches") or [])
|
||||
for item in (job.resolved_attachments or [])
|
||||
if isinstance(item, dict)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
|
||||
return {
|
||||
**_job_summary_payload(job),
|
||||
"message_id_header": job.message_id_header,
|
||||
"issues": job.issues_snapshot or [],
|
||||
"attachments": public_campaign_payload(job.resolved_attachments or []),
|
||||
"resolved_recipients": job.resolved_recipients or {},
|
||||
"resolved_postbox_targets": getattr(job, "resolved_postbox_targets", None)
|
||||
or [],
|
||||
}
|
||||
|
||||
|
||||
def _job_attempts_payload(
|
||||
send_attempts: list[SendAttempt],
|
||||
imap_attempts: list[ImapAppendAttempt],
|
||||
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
|
||||
*,
|
||||
include_diagnostics: bool = False,
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
smtp_payloads: list[dict[str, object]] = []
|
||||
for attempt in send_attempts:
|
||||
payload: dict[str, object] = {
|
||||
"id": attempt.id,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"smtp_status_code": attempt.smtp_status_code,
|
||||
"started_at": attempt.started_at,
|
||||
"finished_at": attempt.finished_at,
|
||||
}
|
||||
if include_diagnostics:
|
||||
payload["claim_token"] = attempt.claim_token
|
||||
payload["smtp_response"] = attempt.smtp_response
|
||||
payload["error_type"] = attempt.error_type
|
||||
payload["error_message"] = attempt.error_message
|
||||
smtp_payloads.append(payload)
|
||||
|
||||
imap_payloads: list[dict[str, object]] = []
|
||||
for attempt in imap_attempts:
|
||||
payload = {
|
||||
"id": attempt.id,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"folder": attempt.folder,
|
||||
"created_at": attempt.created_at,
|
||||
"updated_at": attempt.updated_at,
|
||||
}
|
||||
if include_diagnostics:
|
||||
payload["claim_token"] = attempt.claim_token
|
||||
payload["error_message"] = attempt.error_message
|
||||
imap_payloads.append(payload)
|
||||
postbox_payloads: list[dict[str, object]] = []
|
||||
for attempt in postbox_attempts:
|
||||
payload = {
|
||||
"id": attempt.id,
|
||||
"target_index": attempt.target_index,
|
||||
"attempt_number": attempt.attempt_number,
|
||||
"status": attempt.status,
|
||||
"postbox_id": attempt.postbox_id,
|
||||
"address": attempt.address,
|
||||
"holder_count": attempt.holder_count,
|
||||
"vacant": attempt.vacant,
|
||||
"duplicate": attempt.duplicate,
|
||||
"target": attempt.target_snapshot or {},
|
||||
"started_at": attempt.started_at,
|
||||
"finished_at": attempt.finished_at,
|
||||
"error_code": attempt.error_code,
|
||||
}
|
||||
if include_diagnostics:
|
||||
payload["idempotency_key"] = attempt.idempotency_key
|
||||
payload["provider_delivery_id"] = attempt.provider_delivery_id
|
||||
payload["provider_message_id"] = attempt.provider_message_id
|
||||
payload["evidence"] = attempt.evidence or {}
|
||||
payload["error_type"] = attempt.error_type
|
||||
payload["error_message"] = attempt.error_message
|
||||
postbox_payloads.append(payload)
|
||||
return {
|
||||
"smtp": smtp_payloads,
|
||||
"imap": imap_payloads,
|
||||
"postbox": postbox_payloads,
|
||||
}
|
||||
|
||||
|
||||
def _job_diagnostics_payload(
|
||||
job: CampaignJob,
|
||||
send_attempts: list[SendAttempt],
|
||||
imap_attempts: list[ImapAppendAttempt],
|
||||
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
|
||||
) -> CampaignJobDiagnosticsResponse:
|
||||
return CampaignJobDiagnosticsResponse(
|
||||
job_id=job.id,
|
||||
campaign_id=job.campaign_id,
|
||||
campaign_version_id=job.campaign_version_id,
|
||||
storage={
|
||||
"eml_local_path": job.eml_local_path,
|
||||
"eml_storage_key": job.eml_storage_key,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
},
|
||||
worker_claim={
|
||||
"claim_token": job.claim_token,
|
||||
"claimed_at": job.claimed_at,
|
||||
"smtp_started_at": job.smtp_started_at,
|
||||
"outcome_unknown_at": job.outcome_unknown_at,
|
||||
"last_error": job.last_error,
|
||||
},
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts,
|
||||
include_diagnostics=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _review_metadata(
|
||||
session: Session,
|
||||
version: CampaignVersion | None,
|
||||
base_filters: list[object],
|
||||
) -> tuple[dict[str, object], set[str]]:
|
||||
if version is None:
|
||||
return _empty_review_metadata(), set()
|
||||
|
||||
review_state, state_is_current = _current_review_state(version)
|
||||
reviewed_keys = _current_reviewed_keys(
|
||||
review_state, state_is_current=state_is_current
|
||||
)
|
||||
counts = _review_metadata_counts(_review_rows(session, base_filters), reviewed_keys)
|
||||
return {
|
||||
"inspection_complete": bool(
|
||||
state_is_current and review_state.get("inspection_complete") is True
|
||||
),
|
||||
**counts,
|
||||
}, reviewed_keys
|
||||
|
||||
|
||||
def _empty_review_metadata() -> dict[str, object]:
|
||||
return {
|
||||
"inspection_complete": False,
|
||||
"blocking_count": 0,
|
||||
"required_count": 0,
|
||||
"reviewed_required_count": 0,
|
||||
"bulk_acceptable_count": 0,
|
||||
}
|
||||
|
||||
|
||||
def _current_review_state(version: CampaignVersion) -> tuple[dict[str, object], bool]:
|
||||
build_summary = (
|
||||
version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
)
|
||||
build_token = str(
|
||||
build_summary.get("build_token") or build_summary.get("built_at") or ""
|
||||
)
|
||||
editor_state = (
|
||||
version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
)
|
||||
review_state = (
|
||||
editor_state.get("review_send")
|
||||
if isinstance(editor_state.get("review_send"), dict)
|
||||
else {}
|
||||
)
|
||||
state_token = str(review_state.get("build_token") or "")
|
||||
return review_state, bool(build_token and state_token == build_token)
|
||||
|
||||
|
||||
def _current_reviewed_keys(
|
||||
review_state: dict[str, object], *, state_is_current: bool
|
||||
) -> set[str]:
|
||||
return {
|
||||
str(value)
|
||||
for value in (review_state.get("reviewed_message_keys") or [])
|
||||
if state_is_current and str(value).strip()
|
||||
}
|
||||
|
||||
|
||||
def _review_rows(
|
||||
session: Session, base_filters: list[object]
|
||||
) -> list[tuple[object, int, str, str]]:
|
||||
return (
|
||||
session.query(
|
||||
CampaignJob.entry_id,
|
||||
CampaignJob.entry_index,
|
||||
CampaignJob.build_status,
|
||||
CampaignJob.validation_status,
|
||||
)
|
||||
.filter(*base_filters)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _review_metadata_counts(
|
||||
review_rows: list[tuple[object, int, str, str]], reviewed_keys: set[str]
|
||||
) -> dict[str, int]:
|
||||
blocking_count = 0
|
||||
required_count = 0
|
||||
reviewed_required_count = 0
|
||||
bulk_acceptable_count = 0
|
||||
for entry_id, entry_index, build_status, validation_status in review_rows:
|
||||
key = str(entry_id or entry_index)
|
||||
if build_status != "built" or validation_status == "blocked":
|
||||
blocking_count += 1
|
||||
if validation_status == "needs_review":
|
||||
required_count += 1
|
||||
if key in reviewed_keys:
|
||||
reviewed_required_count += 1
|
||||
elif validation_status in {"warning", "excluded"}:
|
||||
bulk_acceptable_count += 1
|
||||
|
||||
return {
|
||||
"blocking_count": blocking_count,
|
||||
"required_count": required_count,
|
||||
"reviewed_required_count": reviewed_required_count,
|
||||
"bulk_acceptable_count": bulk_acceptable_count,
|
||||
}
|
||||
|
||||
|
||||
def _status_counts(
|
||||
session: Session, filters: list[object]
|
||||
) -> dict[str, dict[str, int]]:
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
for field_name in (
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"postbox_status",
|
||||
"imap_status",
|
||||
):
|
||||
column = getattr(CampaignJob, field_name)
|
||||
rows = (
|
||||
session.query(column, func.count(CampaignJob.id))
|
||||
.filter(*filters)
|
||||
.group_by(column)
|
||||
.all()
|
||||
)
|
||||
result[field_name.removesuffix("_status")] = {
|
||||
str(value or "unknown"): int(count) for value, count in rows
|
||||
}
|
||||
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,
|
||||
"postbox": CampaignJob.postbox_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}),
|
||||
"postbox": (
|
||||
CampaignJob.postbox_status,
|
||||
{item.value for item in JobPostboxStatus},
|
||||
),
|
||||
"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,
|
||||
*,
|
||||
campaign_id: str,
|
||||
version_id: str | None,
|
||||
send_status: list[str] | None,
|
||||
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")
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
base_filters: list[object] = [
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
]
|
||||
selected_version: CampaignVersion | None = None
|
||||
if version_id:
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if version.campaign_id != campaign.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign version not found",
|
||||
)
|
||||
selected_version = version
|
||||
base_filters.append(CampaignJob.campaign_version_id == version.id)
|
||||
|
||||
review_metadata, reviewed_keys = _review_metadata(
|
||||
session, selected_version, base_filters
|
||||
)
|
||||
filtered = list(base_filters)
|
||||
if send_status:
|
||||
filtered.append(CampaignJob.send_status.in_(send_status))
|
||||
if validation_status:
|
||||
filtered.append(CampaignJob.validation_status.in_(validation_status))
|
||||
if imap_status:
|
||||
filtered.append(CampaignJob.imap_status.in_(imap_status))
|
||||
if query_text and query_text.strip():
|
||||
pattern = f"%{query_text.strip()}%"
|
||||
filtered.append(
|
||||
or_(
|
||||
CampaignJob.recipient_email.ilike(pattern),
|
||||
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
|
||||
|
||||
|
||||
def _campaign_jobs_page_response(
|
||||
session: Session,
|
||||
*,
|
||||
campaign_id: str,
|
||||
version_id: str | None,
|
||||
base_filters: list[object],
|
||||
filtered: list[object],
|
||||
reviewed_keys: set[str],
|
||||
review_metadata: dict[str, object],
|
||||
page: int,
|
||||
page_size: int,
|
||||
send_status: list[str] | None = None,
|
||||
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:
|
||||
total_unfiltered, total = _campaign_jobs_page_counts(
|
||||
session, base_filters=base_filters, filtered=filtered
|
||||
)
|
||||
pages = (total + page_size - 1) // page_size if total else 0
|
||||
fingerprint = _campaign_jobs_cursor_fingerprint(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
page_size=page_size,
|
||||
send_status=send_status,
|
||||
validation_status=validation_status,
|
||||
imap_status=imap_status,
|
||||
query_text=query_text,
|
||||
grid_filters=grid_filters,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
)
|
||||
ordering = _campaign_jobs_ordering(sort_by, sort_direction)
|
||||
rows_plus_one, start_cursor = _campaign_jobs_page_rows(
|
||||
session,
|
||||
filtered=filtered,
|
||||
ordering=ordering,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
cursor=cursor,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
jobs = rows_plus_one[:page_size]
|
||||
next_cursor = _campaign_jobs_next_cursor(
|
||||
jobs,
|
||||
rows_plus_one=rows_plus_one,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
changed_job_ids=changed_job_ids,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
if changed_job_ids is not None:
|
||||
jobs = [job for job in jobs if job.id in changed_job_ids]
|
||||
return CampaignJobsResponse(
|
||||
jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs],
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_unfiltered=total_unfiltered,
|
||||
pages=pages,
|
||||
cursor=start_cursor,
|
||||
next_cursor=next_cursor,
|
||||
counts=_status_counts(session, base_filters),
|
||||
filtered_counts=_status_counts(session, filtered),
|
||||
review=review_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_page_counts(
|
||||
session: Session,
|
||||
*,
|
||||
base_filters: list[object],
|
||||
filtered: list[object],
|
||||
) -> tuple[int, int]:
|
||||
total_unfiltered = int(
|
||||
session.query(func.count(CampaignJob.id)).filter(*base_filters).scalar() or 0
|
||||
)
|
||||
total = int(
|
||||
session.query(func.count(CampaignJob.id)).filter(*filtered).scalar() or 0
|
||||
)
|
||||
return total_unfiltered, total
|
||||
|
||||
|
||||
def _campaign_jobs_page_rows(
|
||||
session: Session,
|
||||
*,
|
||||
filtered: list[object],
|
||||
ordering: list[object],
|
||||
page: int,
|
||||
page_size: int,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
cursor: str | None,
|
||||
fingerprint: str,
|
||||
) -> tuple[list[CampaignJob], str | None]:
|
||||
if cursor:
|
||||
page_query = _campaign_jobs_query_after_cursor(
|
||||
session,
|
||||
filtered=filtered,
|
||||
cursor=cursor,
|
||||
fingerprint=fingerprint,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
)
|
||||
return page_query.order_by(*ordering).limit(page_size + 1).all(), cursor
|
||||
|
||||
effective_offset = (page - 1) * page_size
|
||||
page_query = session.query(CampaignJob).filter(*filtered)
|
||||
start_cursor = _campaign_jobs_offset_cursor(
|
||||
page_query,
|
||||
ordering=ordering,
|
||||
effective_offset=effective_offset,
|
||||
sort_by=sort_by,
|
||||
sort_direction=sort_direction,
|
||||
fingerprint=fingerprint,
|
||||
)
|
||||
rows = (
|
||||
page_query.order_by(*ordering)
|
||||
.offset(effective_offset)
|
||||
.limit(page_size + 1)
|
||||
.all()
|
||||
)
|
||||
return rows, start_cursor
|
||||
|
||||
|
||||
def _campaign_jobs_query_after_cursor(
|
||||
session: Session,
|
||||
*,
|
||||
filtered: list[object],
|
||||
cursor: str,
|
||||
fingerprint: str,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
):
|
||||
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:
|
||||
raise KeysetCursorError("Invalid pagination cursor")
|
||||
except KeysetCursorError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
||||
) from exc
|
||||
return (
|
||||
session.query(CampaignJob)
|
||||
.filter(*filtered)
|
||||
.filter(_campaign_jobs_cursor_condition(cursor_values))
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_offset_cursor(
|
||||
query,
|
||||
*,
|
||||
ordering: list[object],
|
||||
effective_offset: int,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
fingerprint: str,
|
||||
) -> str | None:
|
||||
if effective_offset <= 0 or sort_by != "number" or sort_direction != "asc":
|
||||
return None
|
||||
previous_row = (
|
||||
query.order_by(*ordering).offset(effective_offset - 1).limit(1).first()
|
||||
)
|
||||
if previous_row is None:
|
||||
return None
|
||||
return _campaign_jobs_cursor_for_row(previous_row, fingerprint=fingerprint)
|
||||
|
||||
|
||||
def _campaign_jobs_next_cursor(
|
||||
jobs: list[CampaignJob],
|
||||
*,
|
||||
rows_plus_one: list[CampaignJob],
|
||||
page_size: int,
|
||||
sort_by: str,
|
||||
sort_direction: str,
|
||||
changed_job_ids: set[str] | None,
|
||||
fingerprint: str,
|
||||
) -> str | None:
|
||||
cursor_supported = sort_by == "number" and sort_direction == "asc"
|
||||
if (
|
||||
changed_job_ids is not None
|
||||
or not cursor_supported
|
||||
or len(rows_plus_one) <= page_size
|
||||
or not jobs
|
||||
):
|
||||
return None
|
||||
return _campaign_jobs_cursor_for_row(jobs[-1], fingerprint=fingerprint)
|
||||
|
||||
|
||||
def _campaign_jobs_cursor_fingerprint(
|
||||
*,
|
||||
campaign_id: str,
|
||||
version_id: str | None,
|
||||
page_size: int,
|
||||
send_status: list[str] | None,
|
||||
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,
|
||||
{
|
||||
"campaign_id": campaign_id,
|
||||
"version_id": version_id or "",
|
||||
"page_size": page_size,
|
||||
"send_status": sorted(send_status or []),
|
||||
"validation_status": sorted(validation_status or []),
|
||||
"imap_status": sorted(imap_status or []),
|
||||
"query": (query_text or "").strip(),
|
||||
"grid_filters": sorted((grid_filters or {}).items()),
|
||||
"order": [f"{sort_by}:{sort_direction}", "id:asc"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_cursor_for_row(row: CampaignJob, *, fingerprint: str) -> str:
|
||||
return encode_keyset_cursor(
|
||||
CAMPAIGN_JOBS_CURSOR_SCOPE,
|
||||
fingerprint=fingerprint,
|
||||
values={"id": row.id, "entry_index": row.entry_index},
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_cursor_condition(cursor_values: dict[str, object]):
|
||||
cursor_id = cursor_values.get("id")
|
||||
cursor_index = cursor_values.get("entry_index")
|
||||
if not isinstance(cursor_id, str) or not cursor_id:
|
||||
raise KeysetCursorError("Invalid pagination cursor")
|
||||
try:
|
||||
entry_index = int(cursor_index)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise KeysetCursorError("Invalid pagination cursor") from exc
|
||||
return or_(
|
||||
CampaignJob.entry_index > entry_index,
|
||||
and_(CampaignJob.entry_index == entry_index, CampaignJob.id > cursor_id),
|
||||
)
|
||||
|
||||
|
||||
def _campaign_jobs_delta_watermark(session: Session, tenant_id: str) -> str:
|
||||
return encode_sequence_watermark(
|
||||
max_sequence_id(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
module_id=CAMPAIGNS_MODULE_ID,
|
||||
collections=(CAMPAIGN_JOBS_COLLECTION,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CampaignJobsQuery:
|
||||
def __init__(
|
||||
self,
|
||||
version_id: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
cursor: str | None = Query(default=None),
|
||||
send_status: list[str] | None = Query(default=None),
|
||||
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",
|
||||
"postbox",
|
||||
"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_postbox: 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
|
||||
self.page_size = page_size
|
||||
self.cursor = cursor
|
||||
self.send_status = send_status
|
||||
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,
|
||||
"postbox": filter_postbox,
|
||||
"imap": filter_imap,
|
||||
"attempts": filter_attempts,
|
||||
"evidence": filter_evidence,
|
||||
}.items()
|
||||
if value is not None and value.strip()
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.capabilities import CampaignAccessService
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -72,7 +73,17 @@ class CampaignAccessProviderTests(unittest.TestCase):
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite:///:memory:", future=True)
|
||||
Base.metadata.create_all(bind=engine, tables=[Account.__table__, User.__table__, Group.__table__, Campaign.__table__, CampaignShare.__table__])
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
return sessionmaker(bind=engine, future=True)()
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@ 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 import router as campaign_api
|
||||
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||
from govoplan_campaign.backend.routes import jobs as job_routes
|
||||
from govoplan_campaign.backend.routes import reports as report_routes
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.reports.aggregate import (
|
||||
AggregateCampaignReportError,
|
||||
@@ -37,10 +40,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as full_report_denied,
|
||||
):
|
||||
router.campaign_report(
|
||||
report_routes.campaign_report(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
@@ -49,10 +52,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert "campaigns:recipient:read" in full_report_denied.value.detail
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as job_detail_denied,
|
||||
):
|
||||
router.get_job_detail(
|
||||
job_routes.get_job_detail(
|
||||
"campaign-1",
|
||||
"job-1",
|
||||
session=session,
|
||||
@@ -61,10 +64,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert job_detail_denied.value.status_code == 403
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as report_email_denied,
|
||||
):
|
||||
router.email_campaign_report(
|
||||
report_routes.email_campaign_report(
|
||||
"campaign-1",
|
||||
ReportEmailRequest(to=["auditor@example.test"]),
|
||||
session=session,
|
||||
@@ -74,10 +77,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert "campaigns:recipient:export" in report_email_denied.value.detail
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as diagnostics_denied,
|
||||
):
|
||||
router.get_job_diagnostics(
|
||||
job_routes.get_job_diagnostics(
|
||||
"campaign-1",
|
||||
"job-1",
|
||||
session=session,
|
||||
@@ -93,14 +96,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
||||
safe_projection = Mock()
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal") as acl,
|
||||
patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
|
||||
patch.object(
|
||||
router,
|
||||
campaign_routes,
|
||||
"generate_aggregate_campaign_report",
|
||||
return_value=safe_projection,
|
||||
) as generate,
|
||||
):
|
||||
result = router.aggregate_campaign_report(
|
||||
result = campaign_routes.aggregate_campaign_report(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
@@ -118,7 +121,7 @@ def test_aggregate_route_uses_only_the_safe_projection() -> 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)
|
||||
route = next(item for item in campaign_api.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:
|
||||
|
||||
@@ -6,7 +6,9 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||
from govoplan_campaign.backend.routes import versions as version_routes
|
||||
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
|
||||
|
||||
|
||||
@@ -34,13 +36,13 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
return version
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal"),
|
||||
patch.object(router, "_get_version_for_tenant", return_value=version),
|
||||
patch.object(router, "update_campaign_version", side_effect=mutate),
|
||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
patch.object(route_support, "_get_campaign_for_principal"),
|
||||
patch.object(route_support, "_get_version_for_tenant", return_value=version),
|
||||
patch.object(route_support, "update_campaign_version", side_effect=mutate),
|
||||
patch.object(route_support, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
):
|
||||
with pytest.raises(HTTPException, match="audit unavailable") as captured:
|
||||
router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
||||
route_support._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
||||
session,
|
||||
principal, # type: ignore[arg-type]
|
||||
"campaign-1",
|
||||
@@ -68,15 +70,15 @@ def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> No
|
||||
return forked
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(router, "_require_permission"),
|
||||
patch.object(router, "_get_version_for_tenant", return_value=source),
|
||||
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
|
||||
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate),
|
||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(version_routes, "_require_permission"),
|
||||
patch.object(version_routes, "_get_version_for_tenant", return_value=source),
|
||||
patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign),
|
||||
patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate),
|
||||
patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||
router.fork_version_for_edit(
|
||||
version_routes.fork_version_for_edit(
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
CampaignVersionUpdateRequest(),
|
||||
@@ -102,12 +104,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(router, "_sync_campaign_metadata_to_current_version"),
|
||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"),
|
||||
patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||
router.update_campaign_metadata_endpoint(
|
||||
campaign_routes.update_campaign_metadata_endpoint(
|
||||
"campaign-1",
|
||||
CampaignUpdateRequest(name="New name"),
|
||||
session=session,
|
||||
|
||||
314
tests/test_celery_redelivery_acceptance.py
Normal file
314
tests/test_celery_redelivery_acceptance.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER_PATH = (
|
||||
REPOSITORY_ROOT
|
||||
/ "dev"
|
||||
/ "mail-testbed"
|
||||
/ "run_celery_redelivery_acceptance.py"
|
||||
)
|
||||
COMPOSE_PATH = REPOSITORY_ROOT / "dev" / "mail-testbed" / "docker-compose.yml"
|
||||
FIXTURE_PATH = REPOSITORY_ROOT / "examples" / "greenmail-delivery" / "campaign.json"
|
||||
TASK_ID = "12345678-1234-4234-8234-123456789abc"
|
||||
|
||||
|
||||
def _load_runner():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"govoplan_campaign_celery_redelivery_acceptance",
|
||||
RUNNER_PATH,
|
||||
)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
runner = _load_runner()
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status_code: int, payload: dict) -> None:
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _Client:
|
||||
def post(self, path: str, **_kwargs) -> _Response:
|
||||
assert path.endswith("/queue")
|
||||
return _Response(
|
||||
200,
|
||||
{
|
||||
"queued_count": 1,
|
||||
"skipped_count": 0,
|
||||
"blocked_count": 0,
|
||||
"enqueued_count": 1,
|
||||
"delivery_mode": "worker_queue",
|
||||
"worker_queue_available": True,
|
||||
"dry_run": False,
|
||||
},
|
||||
)
|
||||
|
||||
def get(self, path: str, **_kwargs) -> _Response:
|
||||
assert path.endswith("/report")
|
||||
return _Response(
|
||||
200,
|
||||
{
|
||||
"cards": {
|
||||
"jobs_total": 1,
|
||||
"outcome_unknown": 1,
|
||||
"needs_attention": 1,
|
||||
},
|
||||
"status_counts": {
|
||||
"send": {"outcome_unknown": 1},
|
||||
"imap": {"pending": 1},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _Endpoint:
|
||||
host = "127.0.0.1"
|
||||
port = 4025
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.release_count = 0
|
||||
|
||||
def wait_for_data(self, _timeout_seconds: int) -> bool:
|
||||
return True
|
||||
|
||||
def release_held_connection(self) -> None:
|
||||
self.release_count += 1
|
||||
|
||||
def evidence(self) -> dict[str, int]:
|
||||
return {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
|
||||
|
||||
def _settings():
|
||||
return runner.TestbedSettings(
|
||||
smtp_host="127.0.0.1",
|
||||
smtp_port=3025,
|
||||
imap_host="127.0.0.1",
|
||||
imap_port=3143,
|
||||
username="campaign-test@govoplan.test",
|
||||
password="local-test-password",
|
||||
sender="campaign-test@govoplan.test",
|
||||
recipient="campaign-test@govoplan.test",
|
||||
sent_folder="Sent",
|
||||
provider_timeout_seconds=5,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_redis_is_isolated_durable_and_health_checked() -> None:
|
||||
compose = COMPOSE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
assert "redis:7-alpine" in compose
|
||||
assert '"--appendonly", "yes"' in compose
|
||||
assert "127.0.0.1:${GOVOPLAN_CAMPAIGN_TEST_REDIS_PORT:-36379}:6379" in compose
|
||||
assert 'test: ["CMD", "redis-cli", "ping"]' in compose
|
||||
assert "campaign-redis-data:/data" in compose
|
||||
|
||||
|
||||
def test_runbook_keeps_local_redelivery_distinct_from_production_supervision() -> None:
|
||||
testbed = (REPOSITORY_ROOT / "dev" / "mail-testbed" / "README.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
runbook = (REPOSITORY_ROOT / "docs" / "CAMPAIGN_DELIVERY_RUNBOOK.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "run_celery_redelivery_acceptance.py" in testbed
|
||||
assert "same Celery task identity must be redelivered" in testbed
|
||||
assert "production daemon supervision" in testbed
|
||||
assert "empty broker queue/unacked set" in runbook
|
||||
assert "production process manager" in runbook
|
||||
|
||||
|
||||
def test_compose_lifecycle_targets_only_isolated_redis_service() -> None:
|
||||
up = runner._compose_command(
|
||||
compose_file=COMPOSE_PATH,
|
||||
project_name="govoplan-campaign-redelivery-test",
|
||||
operation="up",
|
||||
)
|
||||
down = runner._compose_command(
|
||||
compose_file=COMPOSE_PATH,
|
||||
project_name="govoplan-campaign-redelivery-test",
|
||||
operation="down",
|
||||
)
|
||||
|
||||
assert up[-3:] == ["up", "--detach", "redis"]
|
||||
assert down[-3:] == ["down", "--volumes", "--remove-orphans"]
|
||||
assert "greenmail" not in up
|
||||
assert "--project-name" in up
|
||||
|
||||
|
||||
def test_worker_bootstrap_uses_real_late_ack_solo_celery_worker() -> None:
|
||||
source = runner.WORKER_BOOTSTRAP
|
||||
|
||||
assert "celery.worker_main" in source
|
||||
assert '"--pool=solo"' in source
|
||||
assert '"--queues=send_email"' in source
|
||||
assert '"visibility_timeout"' in source
|
||||
assert '"polling_interval"' in source
|
||||
assert "send_email.run" not in source
|
||||
|
||||
|
||||
def test_runtime_root_uses_platform_temp_selection() -> None:
|
||||
with mock.patch(
|
||||
"govoplan_campaign_celery_redelivery_acceptance.tempfile.mkdtemp",
|
||||
return_value="/selected-temp/govoplan-campaign-celery-redelivery-test",
|
||||
) as mkdtemp:
|
||||
runtime_root = runner._create_runtime_root()
|
||||
|
||||
assert runtime_root == Path(
|
||||
"/selected-temp/govoplan-campaign-celery-redelivery-test"
|
||||
)
|
||||
mkdtemp.assert_called_once_with(prefix="govoplan-campaign-celery-redelivery-")
|
||||
|
||||
|
||||
def test_worker_log_projection_matches_redelivered_task_without_retaining_id() -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_directory:
|
||||
log_path = Path(temporary_directory) / "worker.log"
|
||||
log_path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"Task govoplan.campaigns.send_email[{TASK_ID}] received",
|
||||
f"Task govoplan.campaigns.send_email[{TASK_ID}] succeeded in 0.1s",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with log_path.open("ab") as handle:
|
||||
worker = runner.WorkerProcess(
|
||||
process=SimpleNamespace(),
|
||||
log_path=log_path,
|
||||
log_handle=handle,
|
||||
)
|
||||
|
||||
assert worker.received_task_ids() == (TASK_ID,)
|
||||
assert worker.succeeded_task_ids() == (TASK_ID,)
|
||||
|
||||
|
||||
def test_queue_projection_fails_closed_if_no_task_was_published() -> None:
|
||||
with pytest.raises(runner.AcceptanceError, match="one Celery task"):
|
||||
runner._queue_evidence(
|
||||
{
|
||||
"queued_count": 1,
|
||||
"skipped_count": 0,
|
||||
"blocked_count": 0,
|
||||
"enqueued_count": 0,
|
||||
"delivery_mode": "database_queue",
|
||||
"worker_queue_available": False,
|
||||
"dry_run": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_redelivery_orchestration_requires_same_task_and_no_second_smtp_effect(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
first_worker = mock.Mock()
|
||||
first_worker.received_task_ids.return_value = (TASK_ID,)
|
||||
replacement_worker = mock.Mock()
|
||||
replacement_worker.received_task_ids.return_value = (TASK_ID,)
|
||||
workers = iter([first_worker, replacement_worker])
|
||||
endpoint = _Endpoint()
|
||||
durable_states = iter(
|
||||
[
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"sending": 1},
|
||||
"attempt_status_counts": {"smtp_in_progress": 1},
|
||||
"unfinished_attempt_count": 1,
|
||||
},
|
||||
{
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
"attempt_status_counts": {"outcome_unknown": 1},
|
||||
"unfinished_attempt_count": 0,
|
||||
},
|
||||
]
|
||||
)
|
||||
prepared = SimpleNamespace(
|
||||
campaign_id="campaign-internal",
|
||||
version_id="version-internal",
|
||||
public_evidence=lambda: {
|
||||
"validation": {"ok": True},
|
||||
"build": {"built_count": 1},
|
||||
"campaign_mail_boundary": {
|
||||
"profile_reference_only": True,
|
||||
"smtp_revision_frozen": True,
|
||||
"imap_revision_frozen": True,
|
||||
"resolved_transport_material_present": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(runner, "create_mail_profile", lambda *args, **kwargs: "profile-internal")
|
||||
monkeypatch.setattr(runner, "prepare_campaign_scenario", lambda *args, **kwargs: prepared)
|
||||
monkeypatch.setattr(runner, "_start_worker", lambda *args, **kwargs: next(workers))
|
||||
monkeypatch.setattr(runner, "_wait_for_worker_ready", lambda *args, **kwargs: None)
|
||||
received = iter([TASK_ID, TASK_ID])
|
||||
monkeypatch.setattr(runner, "_wait_for_received_task", lambda *args, **kwargs: next(received))
|
||||
monkeypatch.setattr(runner, "_wait_for_task_success", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(runner, "_kill_worker", lambda *args, **kwargs: -9)
|
||||
monkeypatch.setattr(runner, "_stop_worker", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
runner,
|
||||
"_wait_for_broker_drained",
|
||||
lambda *args, **kwargs: runner.RedisBrokerState(0, 0, 0),
|
||||
)
|
||||
|
||||
evidence = runner.execute_redelivery_scenario(
|
||||
_Client(),
|
||||
{"Authorization": "not-retained"},
|
||||
fixture_path=FIXTURE_PATH,
|
||||
settings=_settings(),
|
||||
endpoint=endpoint,
|
||||
redis_url="redis://127.0.0.1:36379/0",
|
||||
runtime_root=Path("/not-used"),
|
||||
snapshot_probe=lambda _version_id: ({}, {}),
|
||||
audit_probe=lambda _campaign_id, _version_id: {
|
||||
"campaign.created": 1,
|
||||
"campaign.validated": 1,
|
||||
"campaign.messages_built": 1,
|
||||
"campaign.queued": 1,
|
||||
},
|
||||
delivery_probe=lambda _campaign_id, _version_id: next(durable_states),
|
||||
)
|
||||
|
||||
assert evidence["broker"] == {
|
||||
"transport": "redis",
|
||||
"same_task_identity_redelivered": True,
|
||||
"first_worker_received_count": 1,
|
||||
"replacement_worker_received_count": 1,
|
||||
"queue_depth": 0,
|
||||
"unacked_hash_count": 0,
|
||||
"unacked_index_count": 0,
|
||||
}
|
||||
assert evidence["protocol"]["connection_count"] == 1
|
||||
assert evidence["protocol"]["data_transactions"] == 1
|
||||
assert evidence["recovered_durable_state"]["send_status_counts"] == {
|
||||
"outcome_unknown": 1
|
||||
}
|
||||
assert TASK_ID not in json.dumps(evidence, sort_keys=True)
|
||||
assert endpoint.release_count >= 1
|
||||
@@ -289,7 +289,7 @@ def test_sender_sees_queue_and_send_only_with_the_mail_contract_and_profile_auth
|
||||
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)
|
||||
assert any(link.href == "/campaigns/queue" for link in queue_topic.links)
|
||||
|
||||
|
||||
def test_connected_authoring_tasks_require_their_declared_contracts_and_permissions() -> None:
|
||||
@@ -361,7 +361,7 @@ def test_aggregate_report_task_never_implies_recipient_detail_or_export_authorit
|
||||
item for item in CAMPAIGN_USER_DOCUMENTATION
|
||||
if item.id == "campaigns.workflow.view-aggregate-delivery-report"
|
||||
)
|
||||
assert topic.metadata["route"] == "/reports"
|
||||
assert topic.metadata["route"] == "/campaigns/reports"
|
||||
assert "export" in topic.metadata["verification"].lower()
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ def _ensure(session: _Session, version) -> None:
|
||||
ensure_execution_snapshot(session, version) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_v5_snapshot_requires_its_persisted_checksum() -> None:
|
||||
def test_current_snapshot_requires_its_persisted_checksum() -> None:
|
||||
job = _job()
|
||||
version = _snapshotted_version(job)
|
||||
version.execution_snapshot_hash = None
|
||||
|
||||
@@ -7,9 +7,11 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend.routes import delivery as router
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
ImapAppendAttempt,
|
||||
@@ -128,6 +130,10 @@ def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
|
||||
mail_profile_id="profile-1",
|
||||
smtp_transport_revision="smtp-revision",
|
||||
imap_transport_revision="imap-revision",
|
||||
smtp_server_id=None,
|
||||
smtp_credential_id=None,
|
||||
imap_server_id=None,
|
||||
imap_credential_id=None,
|
||||
delivery=SimpleNamespace(
|
||||
imap_append_sent=SimpleNamespace(enabled=True, folder="Sent"),
|
||||
),
|
||||
@@ -218,6 +224,31 @@ def test_imap_claim_migration_preserves_and_renumbers_duplicate_attempts() -> No
|
||||
]
|
||||
|
||||
|
||||
def test_imap_attempt_claim_repair_adds_only_the_missing_column() -> None:
|
||||
migration = importlib.import_module(
|
||||
"govoplan_campaign.backend.migrations.versions.e9f0a1b2c3d4_v0114_repair_imap_attempt_claim"
|
||||
)
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"CREATE TABLE imap_append_attempts ("
|
||||
"id VARCHAR(36) PRIMARY KEY, job_id VARCHAR(36) NOT NULL)"
|
||||
)
|
||||
)
|
||||
context = MigrationContext.configure(connection)
|
||||
with patch.object(migration, "op", Operations(context)):
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns("imap_append_attempts")
|
||||
}
|
||||
|
||||
assert columns == {"id", "job_id", "claim_token"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decision", ["smtp_accepted", "not_sent", "imap_appended", "imap_not_appended"])
|
||||
def test_reconciliation_requires_an_evidence_note(decision: str) -> None:
|
||||
with pytest.raises(ValueError, match="evidence note"):
|
||||
@@ -379,14 +410,14 @@ def test_reconciliation_rolls_back_state_when_audit_fails(
|
||||
return {"decision": decision, "job_id": "job-1"}
|
||||
|
||||
with (
|
||||
patch("govoplan_campaign.backend.router._get_campaign_for_principal"),
|
||||
patch("govoplan_campaign.backend.router._require_permission"),
|
||||
patch("govoplan_campaign.backend.routes.delivery._get_campaign_for_principal"),
|
||||
patch("govoplan_campaign.backend.routes.delivery._require_permission"),
|
||||
patch(
|
||||
"govoplan_campaign.backend.router.reconcile_job_outcome",
|
||||
"govoplan_campaign.backend.routes.delivery.reconcile_job_outcome",
|
||||
side_effect=mutate_without_commit,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.router.audit_from_principal",
|
||||
"govoplan_campaign.backend.routes.delivery.audit_from_principal",
|
||||
side_effect=RuntimeError("audit unavailable"),
|
||||
),
|
||||
):
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 (
|
||||
from govoplan_campaign.backend.services.job_queries import (
|
||||
_campaign_jobs_grid_filter_expressions,
|
||||
_campaign_jobs_ordering,
|
||||
_campaign_jobs_page_response,
|
||||
|
||||
@@ -6,7 +6,8 @@ from unittest.mock import call, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.routes import versions as router
|
||||
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
@@ -73,7 +74,7 @@ def test_mail_profile_documentation_is_classified_for_adaptive_views() -> None:
|
||||
def test_campaign_mail_contract_rejects_every_legacy_server_field(legacy_key: str) -> None:
|
||||
raw = _campaign_json({"mail_profile_id": "profile-1", legacy_key: {}})
|
||||
|
||||
with pytest.raises(CampaignMailProfileBoundaryError, match="select an authorized Mail profile"):
|
||||
with pytest.raises(CampaignMailProfileBoundaryError, match="select authorized Mail resources"):
|
||||
assert_campaign_uses_mail_profile_reference(raw)
|
||||
|
||||
|
||||
@@ -138,7 +139,7 @@ def test_new_execution_snapshot_stores_reference_and_evidence_not_transport_mate
|
||||
delivery=DeliveryConfig(),
|
||||
)
|
||||
|
||||
assert payload["snapshot_version"] == "5"
|
||||
assert payload["snapshot_version"] == "7"
|
||||
assert payload["mail_profile_id"] == "profile-1"
|
||||
assert "smtp" not in payload
|
||||
assert "imap" not in payload
|
||||
@@ -214,7 +215,7 @@ def test_fork_inherited_profile_requires_mail_profile_use_scope() -> None:
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(router, "_require_permission"),
|
||||
patch.object(router, "_get_version_for_tenant", return_value=source),
|
||||
patch.object(router, "has_scope", return_value=False),
|
||||
patch.object(route_support, "has_scope", return_value=False),
|
||||
patch.object(router, "fork_campaign_version_for_edit") as fork,
|
||||
):
|
||||
with pytest.raises(HTTPException) as captured:
|
||||
@@ -234,8 +235,8 @@ def test_campaign_wide_effect_authorizes_every_affected_version() -> None:
|
||||
session = object()
|
||||
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||
|
||||
with patch.object(router, "_require_campaign_profile_use_if_needed") as require_profile:
|
||||
router._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
|
||||
with patch.object(route_support, "_require_campaign_profile_use_if_needed") as require_profile:
|
||||
route_support._require_campaign_versions_profile_use( # noqa: SLF001 - security boundary regression test
|
||||
session, # type: ignore[arg-type]
|
||||
principal, # type: ignore[arg-type]
|
||||
"campaign-1",
|
||||
|
||||
64
tests/test_manifest_navigation.py
Normal file
64
tests/test_manifest_navigation.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_campaign.backend.manifest import (
|
||||
CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||
OPERATOR_QUEUE_REQUIRED_ANY,
|
||||
OPERATOR_QUEUE_SURFACE_ID,
|
||||
REPORTS_REQUIRED_ANY,
|
||||
REPORTS_SURFACE_ID,
|
||||
get_manifest,
|
||||
)
|
||||
from govoplan_core.core.registry import manifest_view_surfaces
|
||||
|
||||
|
||||
def test_operator_queue_is_an_integrated_campaign_view() -> None:
|
||||
manifest = get_manifest()
|
||||
assert manifest.frontend is not None
|
||||
|
||||
assert "/operator" not in {item.path for item in manifest.nav_items}
|
||||
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
|
||||
|
||||
routes = {route.path: route for route in manifest.frontend.routes}
|
||||
assert "/operator" not in routes
|
||||
queue = routes["/campaigns/queue"]
|
||||
assert queue.component == "OperatorQueuePage"
|
||||
assert queue.required_all == ("campaigns:campaign:read",)
|
||||
assert queue.required_any == OPERATOR_QUEUE_REQUIRED_ANY
|
||||
assert OPERATOR_QUEUE_SURFACE_ID == "campaigns.route.operator"
|
||||
assert queue.surface_id == OPERATOR_QUEUE_SURFACE_ID
|
||||
|
||||
queue_surfaces = [
|
||||
surface
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
if surface.id == OPERATOR_QUEUE_SURFACE_ID
|
||||
]
|
||||
assert len(queue_surfaces) == 1
|
||||
assert queue_surfaces[0].description == "/campaigns/queue"
|
||||
|
||||
|
||||
def test_aggregate_reports_are_an_integrated_campaign_view() -> None:
|
||||
manifest = get_manifest()
|
||||
assert manifest.frontend is not None
|
||||
|
||||
backend_nav = {item.path: item for item in manifest.nav_items}
|
||||
frontend_nav = {item.path: item for item in manifest.frontend.nav_items}
|
||||
assert "/reports" not in backend_nav
|
||||
assert "/reports" not in frontend_nav
|
||||
assert backend_nav["/campaigns"].required_any == CAMPAIGN_MODULE_REQUIRED_ANY
|
||||
assert frontend_nav["/campaigns"].required_any == CAMPAIGN_MODULE_REQUIRED_ANY
|
||||
|
||||
routes = {route.path: route for route in manifest.frontend.routes}
|
||||
assert "/reports" not in routes
|
||||
report = routes["/campaigns/reports"]
|
||||
assert report.component == "AggregateReportsPage"
|
||||
assert report.required_any == REPORTS_REQUIRED_ANY
|
||||
assert REPORTS_SURFACE_ID == "campaigns.route.reports"
|
||||
assert report.surface_id == REPORTS_SURFACE_ID
|
||||
|
||||
report_surfaces = [
|
||||
surface
|
||||
for surface in manifest_view_surfaces(manifest)
|
||||
if surface.id == REPORTS_SURFACE_ID
|
||||
]
|
||||
assert len(report_surfaces) == 1
|
||||
assert report_surfaces[0].description == "/campaigns/reports"
|
||||
@@ -5,7 +5,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend.routes import campaigns as router
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.persistence.versions import validate_campaign_partial
|
||||
|
||||
400
tests/test_postbox_delivery_orchestration.py
Normal file
400
tests/test_postbox_delivery_orchestration.py
Normal file
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||
from govoplan_campaign.backend.db.models import JobSendStatus
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
SendJobResult,
|
||||
_MailChannelOutcome,
|
||||
_final_multichannel_status,
|
||||
_send_claimed_multichannel_job,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.postbox_delivery import (
|
||||
PostboxChannelOutcome,
|
||||
)
|
||||
|
||||
|
||||
def _context():
|
||||
return SimpleNamespace(
|
||||
message_bytes=b"message",
|
||||
snapshot=SimpleNamespace(
|
||||
delivery=SimpleNamespace(
|
||||
postbox=SimpleNamespace(classification="internal")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _job():
|
||||
return SimpleNamespace(id="job-1")
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, job) -> None:
|
||||
self.job = job
|
||||
|
||||
def get(self, _model, _id):
|
||||
return self.job
|
||||
|
||||
def add(self, _value) -> None:
|
||||
return None
|
||||
|
||||
def commit(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class PostboxFallbackOrchestrationTests(unittest.TestCase):
|
||||
def test_mail_unknown_never_starts_postbox_fallback(self) -> None:
|
||||
job = _job()
|
||||
expected = SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
attempt_number=1,
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
|
||||
return_value=_MailChannelOutcome(outcome_unknown=True),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
|
||||
return_value=PostboxChannelOutcome(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
|
||||
) as deliver_postbox,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=expected,
|
||||
),
|
||||
):
|
||||
result = _send_claimed_multichannel_job(
|
||||
_Session(job), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=_context(), # type: ignore[arg-type]
|
||||
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
self.assertIs(result, expected)
|
||||
deliver_postbox.assert_not_called()
|
||||
|
||||
def test_mail_preacceptance_rejection_starts_postbox_fallback(self) -> None:
|
||||
job = _job()
|
||||
postbox_outcome = PostboxChannelOutcome(accepted=1)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
|
||||
return_value=_MailChannelOutcome(rejected_permanent=True),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
|
||||
return_value=PostboxChannelOutcome(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
|
||||
return_value=postbox_outcome,
|
||||
) as deliver_postbox,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.POSTBOX_ACCEPTED.value,
|
||||
attempt_number=1,
|
||||
),
|
||||
),
|
||||
):
|
||||
_send_claimed_multichannel_job(
|
||||
_Session(job), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=_context(), # type: ignore[arg-type]
|
||||
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
deliver_postbox.assert_called_once()
|
||||
|
||||
def test_accepted_postbox_fallback_prevents_later_mail_retry(self) -> None:
|
||||
job = _job()
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
|
||||
return_value=PostboxChannelOutcome(
|
||||
accepted=1,
|
||||
rejected_temporary=1,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
|
||||
) as deliver_mail,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
|
||||
return_value=PostboxChannelOutcome(accepted=2),
|
||||
) as deliver_postbox,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.POSTBOX_ACCEPTED.value,
|
||||
attempt_number=3,
|
||||
),
|
||||
),
|
||||
):
|
||||
_send_claimed_multichannel_job(
|
||||
_Session(job), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=_context(), # type: ignore[arg-type]
|
||||
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
deliver_mail.assert_not_called()
|
||||
deliver_postbox.assert_called_once()
|
||||
|
||||
def test_unknown_postbox_fallback_prevents_every_later_effect(self) -> None:
|
||||
job = _job()
|
||||
prior = PostboxChannelOutcome(outcome_unknown=1)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._postbox_outcome_from_attempts",
|
||||
return_value=prior,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
|
||||
) as deliver_mail,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes"
|
||||
) as deliver_postbox,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
attempt_number=1,
|
||||
),
|
||||
) as finalize,
|
||||
):
|
||||
_send_claimed_multichannel_job(
|
||||
_Session(job), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=_context(), # type: ignore[arg-type]
|
||||
channel_policy=DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
deliver_mail.assert_not_called()
|
||||
deliver_postbox.assert_not_called()
|
||||
self.assertIs(finalize.call_args.kwargs["postbox"], prior)
|
||||
|
||||
def test_postbox_acceptance_stops_mail_fallback(self) -> None:
|
||||
job = _job()
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
|
||||
return_value=PostboxChannelOutcome(
|
||||
accepted=1,
|
||||
rejected_permanent=1,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel"
|
||||
) as deliver_mail,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
attempt_number=2,
|
||||
),
|
||||
),
|
||||
):
|
||||
_send_claimed_multichannel_job(
|
||||
_Session(job), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=_context(), # type: ignore[arg-type]
|
||||
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
deliver_mail.assert_not_called()
|
||||
|
||||
def test_all_postbox_rejections_start_mail_fallback(self) -> None:
|
||||
job = _job()
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.deliver_campaign_job_to_postboxes",
|
||||
return_value=PostboxChannelOutcome(
|
||||
rejected_temporary=1,
|
||||
rejected_permanent=1,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._deliver_mail_channel",
|
||||
return_value=_MailChannelOutcome(accepted=True),
|
||||
) as deliver_mail,
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finalize_multichannel_job",
|
||||
return_value=SendJobResult(
|
||||
job_id=job.id,
|
||||
status=JobSendStatus.SMTP_ACCEPTED.value,
|
||||
attempt_number=1,
|
||||
),
|
||||
),
|
||||
):
|
||||
_send_claimed_multichannel_job(
|
||||
_Session(job), # type: ignore[arg-type]
|
||||
job=job, # type: ignore[arg-type]
|
||||
claim_token="claim-1",
|
||||
context=_context(), # type: ignore[arg-type]
|
||||
channel_policy=DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
use_rate_limit=False,
|
||||
enqueue_imap_task=False,
|
||||
)
|
||||
|
||||
deliver_mail.assert_called_once()
|
||||
|
||||
def test_explicit_dual_delivery_reports_partial_and_unknown(self) -> None:
|
||||
self.assertEqual(
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
_final_multichannel_status(
|
||||
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
mail=_MailChannelOutcome(accepted=True),
|
||||
postbox=PostboxChannelOutcome(rejected_permanent=1),
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
JobSendStatus.OUTCOME_UNKNOWN.value,
|
||||
_final_multichannel_status(
|
||||
channel_policy=DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
mail=_MailChannelOutcome(accepted=True),
|
||||
postbox=PostboxChannelOutcome(outcome_unknown=1),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
|
||||
@pytest.mark.parametrize(
|
||||
("mail", "postbox"),
|
||||
[
|
||||
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome()),
|
||||
(_MailChannelOutcome(accepted=True), PostboxChannelOutcome(outcome_unknown=1)),
|
||||
(_MailChannelOutcome(outcome_unknown=True), PostboxChannelOutcome(accepted=1)),
|
||||
],
|
||||
)
|
||||
def test_outcome_unknown_has_precedence_for_every_delivery_policy(
|
||||
policy: DeliveryChannelPolicy,
|
||||
mail: _MailChannelOutcome,
|
||||
postbox: PostboxChannelOutcome,
|
||||
) -> None:
|
||||
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == JobSendStatus.OUTCOME_UNKNOWN.value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", list(DeliveryChannelPolicy))
|
||||
@pytest.mark.parametrize(
|
||||
("mail", "postbox", "expected"),
|
||||
[
|
||||
(
|
||||
_MailChannelOutcome(rejected_permanent=True),
|
||||
PostboxChannelOutcome(rejected_permanent=1),
|
||||
JobSendStatus.FAILED_PERMANENT.value,
|
||||
),
|
||||
(
|
||||
_MailChannelOutcome(rejected_temporary=True),
|
||||
PostboxChannelOutcome(rejected_permanent=1),
|
||||
JobSendStatus.FAILED_TEMPORARY.value,
|
||||
),
|
||||
(
|
||||
_MailChannelOutcome(rejected_permanent=True),
|
||||
PostboxChannelOutcome(rejected_temporary=1),
|
||||
JobSendStatus.FAILED_TEMPORARY.value,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejection_precedence_is_exhaustive_for_every_delivery_policy(
|
||||
policy: DeliveryChannelPolicy,
|
||||
mail: _MailChannelOutcome,
|
||||
postbox: PostboxChannelOutcome,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy", "mail", "postbox", "expected"),
|
||||
[
|
||||
(
|
||||
DeliveryChannelPolicy.MAIL,
|
||||
_MailChannelOutcome(accepted=True),
|
||||
PostboxChannelOutcome(),
|
||||
JobSendStatus.SMTP_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.POSTBOX,
|
||||
None,
|
||||
PostboxChannelOutcome(accepted=1),
|
||||
JobSendStatus.POSTBOX_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.POSTBOX,
|
||||
None,
|
||||
PostboxChannelOutcome(accepted=1, rejected_permanent=1),
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
_MailChannelOutcome(accepted=True),
|
||||
PostboxChannelOutcome(accepted=1),
|
||||
JobSendStatus.DELIVERED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
_MailChannelOutcome(accepted=True),
|
||||
PostboxChannelOutcome(rejected_permanent=1),
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
_MailChannelOutcome(rejected_permanent=True),
|
||||
PostboxChannelOutcome(accepted=1),
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
_MailChannelOutcome(rejected_permanent=True),
|
||||
PostboxChannelOutcome(accepted=1),
|
||||
JobSendStatus.POSTBOX_ACCEPTED.value,
|
||||
),
|
||||
(
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
_MailChannelOutcome(accepted=True),
|
||||
PostboxChannelOutcome(rejected_permanent=1),
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_accepted_delivery_decision_table(
|
||||
policy: DeliveryChannelPolicy,
|
||||
mail: _MailChannelOutcome | None,
|
||||
postbox: PostboxChannelOutcome,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert _final_multichannel_status(channel_policy=policy, mail=mail, postbox=postbox) == expected
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
99
tests/test_postbox_integration.py
Normal file
99
tests/test_postbox_integration.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxDeliveryResult,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
PostboxCampaignIntegration,
|
||||
PostboxDeliveryUnavailable,
|
||||
)
|
||||
|
||||
|
||||
class _PostboxDelivery:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def deliver(self, session, request):
|
||||
self.requests.append((session, request))
|
||||
return PostboxDeliveryResult(
|
||||
delivery_id="delivery-1",
|
||||
postbox_id="postbox-1",
|
||||
message_id="message-1",
|
||||
address="intake@postbox",
|
||||
status="accepted",
|
||||
vacant=False,
|
||||
holder_count=1,
|
||||
)
|
||||
|
||||
def delivery_catalog(self, session, *, tenant_id):
|
||||
return PostboxDeliveryCatalogRef()
|
||||
|
||||
def list_visible_postboxes(self, session, *, tenant_id, actor):
|
||||
return ()
|
||||
|
||||
def resolve_postbox(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
tenant_id,
|
||||
target,
|
||||
materialize=False,
|
||||
):
|
||||
return PostboxDirectoryEntryRef(
|
||||
id=target.postbox_id or "postbox-1",
|
||||
tenant_id=tenant_id,
|
||||
address="intake@postbox",
|
||||
address_key="intake",
|
||||
name="Intake",
|
||||
status="active",
|
||||
classification="internal",
|
||||
)
|
||||
|
||||
|
||||
class PostboxCampaignIntegrationTests(unittest.TestCase):
|
||||
def test_optional_delivery_boundary_is_typed_and_explicit(self) -> None:
|
||||
delegate = _PostboxDelivery()
|
||||
integration = PostboxCampaignIntegration(delegate, delegate)
|
||||
request = PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_job",
|
||||
producer_resource_id="job-1",
|
||||
idempotency_key="campaign-1:job-1:postbox-1",
|
||||
subject="Decision",
|
||||
)
|
||||
|
||||
result = integration.deliver(object(), request)
|
||||
|
||||
self.assertTrue(integration.available)
|
||||
self.assertEqual("delivery-1", result.delivery_id)
|
||||
self.assertEqual(request, delegate.requests[0][1])
|
||||
|
||||
def test_missing_postbox_is_reported_without_importing_module_code(
|
||||
self,
|
||||
) -> None:
|
||||
integration = PostboxCampaignIntegration()
|
||||
request = PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id="postbox-1"),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_job",
|
||||
producer_resource_id="job-1",
|
||||
idempotency_key="campaign-1:job-1:postbox-1",
|
||||
subject="Decision",
|
||||
)
|
||||
|
||||
self.assertFalse(integration.available)
|
||||
with self.assertRaises(PostboxDeliveryUnavailable):
|
||||
integration.deliver(object(), request)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
208
tests/test_postbox_target_resolution.py
Normal file
208
tests/test_postbox_target_resolution.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDeliveryTemplateRef,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxOrganizationFunctionTargetRef,
|
||||
PostboxOrganizationUnitTargetRef,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
resolve_entry_postbox_targets,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.validation import (
|
||||
validate_campaign_config,
|
||||
)
|
||||
from govoplan_campaign.backend.messages.models import MessageValidationStatus
|
||||
|
||||
|
||||
def _config() -> CampaignConfig:
|
||||
return CampaignConfig.model_validate(
|
||||
{
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": "campaign-1",
|
||||
"name": "Postbox campaign",
|
||||
"mode": "send",
|
||||
},
|
||||
"fields": [
|
||||
{"name": "org_key", "type": "organization_unit"},
|
||||
{"name": "function_key", "type": "organization_function"},
|
||||
{"name": "case_key", "type": "string"},
|
||||
],
|
||||
"template": {
|
||||
"subject": "Decision",
|
||||
"text": "A decision is available.",
|
||||
"body_mode": "text",
|
||||
},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "recipient-1",
|
||||
"fields": {
|
||||
"org_key": "finance",
|
||||
"function_key": "caseworker",
|
||||
"case_key": "case-42",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"channel_policy": "postbox",
|
||||
"postbox": {
|
||||
"targets": [
|
||||
{
|
||||
"id": "direct",
|
||||
"mode": "direct",
|
||||
"address_key": "central-intake",
|
||||
},
|
||||
{
|
||||
"id": "derived",
|
||||
"mode": "derived",
|
||||
"template_id": "template-1",
|
||||
"organization_unit_field": "org_key",
|
||||
"organization_unit_match": "slug",
|
||||
"function_field": "function_key",
|
||||
"function_match": "slug",
|
||||
"context_field": "case_key",
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _catalog() -> PostboxDeliveryCatalogRef:
|
||||
return PostboxDeliveryCatalogRef(
|
||||
templates=(
|
||||
PostboxDeliveryTemplateRef(
|
||||
id="template-1",
|
||||
slug="case-inbox",
|
||||
name="Case inbox",
|
||||
description=None,
|
||||
published_revision_id="revision-1",
|
||||
function_type_id=None,
|
||||
scope_kind="tenant",
|
||||
scope_id=None,
|
||||
classification="internal",
|
||||
allow_vacant_delivery=True,
|
||||
),
|
||||
),
|
||||
organization_units=(
|
||||
PostboxOrganizationUnitTargetRef(
|
||||
id="unit-1",
|
||||
slug="finance",
|
||||
name="Finance",
|
||||
functions=(
|
||||
PostboxOrganizationFunctionTargetRef(
|
||||
id="function-1",
|
||||
slug="caseworker",
|
||||
name="Caseworker",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _PostboxIntegration:
|
||||
def __init__(self) -> None:
|
||||
self.targets: list[tuple[object, bool]] = []
|
||||
|
||||
def delivery_catalog(self, _session, *, tenant_id: str):
|
||||
assert tenant_id == "tenant-1"
|
||||
return _catalog()
|
||||
|
||||
def resolve_postbox(
|
||||
self,
|
||||
_session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
target,
|
||||
materialize: bool,
|
||||
):
|
||||
self.targets.append((target, materialize))
|
||||
if target.address_key == "central-intake":
|
||||
return PostboxDirectoryEntryRef(
|
||||
id="postbox-direct",
|
||||
tenant_id=tenant_id,
|
||||
address="central-intake@postbox",
|
||||
address_key="central-intake",
|
||||
name="Central intake",
|
||||
status="active",
|
||||
classification="internal",
|
||||
holder_count=2,
|
||||
vacant=False,
|
||||
)
|
||||
assert target.template_id == "template-1"
|
||||
assert target.organization_unit_id == "unit-1"
|
||||
assert target.function_id == "function-1"
|
||||
assert target.context_key == "case-42"
|
||||
return PostboxDirectoryEntryRef(
|
||||
id="postbox-derived",
|
||||
tenant_id=tenant_id,
|
||||
address="finance.caseworker.case-42@postbox",
|
||||
address_key="finance.caseworker.case-42",
|
||||
name="Finance caseworker",
|
||||
status="active",
|
||||
classification="internal",
|
||||
organization_unit_id="unit-1",
|
||||
function_id="function-1",
|
||||
context_key="case-42",
|
||||
holder_count=1,
|
||||
vacant=False,
|
||||
)
|
||||
|
||||
|
||||
def test_postbox_only_campaign_does_not_require_mail() -> None:
|
||||
config = _config()
|
||||
|
||||
available = validate_campaign_config(config, postbox_available=True)
|
||||
unavailable = validate_campaign_config(config, postbox_available=False)
|
||||
|
||||
assert {
|
||||
issue.code
|
||||
for issue in available.issues
|
||||
if issue.severity.value == "error"
|
||||
} == set()
|
||||
assert "missing_mail_profile" not in {
|
||||
issue.code for issue in unavailable.issues
|
||||
}
|
||||
assert "missing_sender" not in {
|
||||
issue.code for issue in unavailable.issues
|
||||
}
|
||||
assert "postbox_unavailable" in {
|
||||
issue.code for issue in unavailable.issues
|
||||
}
|
||||
|
||||
|
||||
def test_row_resolves_multiple_direct_and_field_derived_postboxes() -> None:
|
||||
config = _config()
|
||||
integration = _PostboxIntegration()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.campaign.postbox_targets.postbox_integration",
|
||||
return_value=integration,
|
||||
):
|
||||
targets, issues, status = resolve_entry_postbox_targets(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
config=config,
|
||||
entry=config.entries.inline[0], # type: ignore[index]
|
||||
validation_status=MessageValidationStatus.READY,
|
||||
materialize=True,
|
||||
)
|
||||
|
||||
assert issues == []
|
||||
assert status == MessageValidationStatus.READY
|
||||
assert [target["postbox_id"] for target in targets] == [
|
||||
"postbox-direct",
|
||||
"postbox-derived",
|
||||
]
|
||||
assert targets[1]["context_key"] == "case-42"
|
||||
assert len(integration.targets) == 2
|
||||
assert all(materialize for _target, materialize in integration.targets)
|
||||
@@ -7,7 +7,7 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend.routes import reports as router
|
||||
from govoplan_campaign.backend.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||
|
||||
@@ -42,6 +42,16 @@ def test_report_email_recipient_schema_rejects_unsafe_addresses(recipients: list
|
||||
ReportEmailRequest.model_validate({"to": recipients})
|
||||
|
||||
|
||||
def test_report_email_recipient_error_identifies_the_invalid_list_item() -> None:
|
||||
with pytest.raises(ValidationError) as captured:
|
||||
ReportEmailRequest.model_validate({
|
||||
"to": ["first@example.test", "invalid recipient", "third@example.test"],
|
||||
})
|
||||
|
||||
assert captured.value.errors()[0]["loc"] == ("to", 1)
|
||||
assert "email addresses" in captured.value.errors()[0]["msg"]
|
||||
|
||||
|
||||
def test_report_jobs_csv_attachment_requires_recipient_export_permission() -> None:
|
||||
payload = ReportEmailRequest(to=["recipient@example.test"], attach_jobs_csv=True)
|
||||
principal = SimpleNamespace(tenant_id="tenant-1")
|
||||
|
||||
@@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from govoplan_campaign.backend.response_security import public_campaign_payload
|
||||
from govoplan_campaign.backend.router import (
|
||||
from govoplan_campaign.backend.services.job_queries import (
|
||||
_job_attempts_payload,
|
||||
_job_detail_payload,
|
||||
_job_diagnostics_payload,
|
||||
|
||||
63
tests/test_route_registration.py
Normal file
63
tests/test_route_registration.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from govoplan_campaign.backend.router import router
|
||||
from govoplan_campaign.backend.routes.attachments import router as attachments_router
|
||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
|
||||
|
||||
def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
||||
return [
|
||||
(method, route.path)
|
||||
for route in candidate_router.routes
|
||||
for method in sorted(route.methods or ())
|
||||
]
|
||||
|
||||
|
||||
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
workflow_routers = (
|
||||
campaigns_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
sharing_router,
|
||||
delivery_router,
|
||||
attachments_router,
|
||||
)
|
||||
expected = [
|
||||
operation
|
||||
for workflow_router in workflow_routers
|
||||
for operation in _operation_keys(workflow_router)
|
||||
]
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 62
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||
expectations = (
|
||||
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
||||
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
||||
(
|
||||
attachments_router,
|
||||
(
|
||||
"POST",
|
||||
"/campaigns/{campaign_id}/versions/{version_id}/attachments/preview",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
for workflow_router, operation in expectations:
|
||||
assert operation in _operation_keys(workflow_router)
|
||||
@@ -4,7 +4,7 @@ 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.routes import delivery as router
|
||||
from govoplan_campaign.backend.schemas import SendCampaignNowRequest
|
||||
from govoplan_campaign.backend.sending.jobs import SendCampaignNowResult
|
||||
|
||||
|
||||
@@ -141,6 +141,8 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
||||
)
|
||||
snapshot = SimpleNamespace(
|
||||
mail_profile_id="profile-1",
|
||||
smtp_server_id="smtp-server-1",
|
||||
smtp_credential_id="smtp-credential-1",
|
||||
smtp_transport_revision="frozen",
|
||||
delivery=SimpleNamespace(
|
||||
rate_limit=SimpleNamespace(messages_per_minute=60),
|
||||
|
||||
94
tests/test_share_target_search.py
Normal file
94
tests/test_share_target_search.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_campaign.backend.routes.sharing import (
|
||||
search_campaign_share_targets,
|
||||
)
|
||||
from govoplan_core.core.references import (
|
||||
CAPABILITY_ACCESS_REFERENCE_OPTIONS,
|
||||
ReferenceOption,
|
||||
ReferenceSearchPage,
|
||||
)
|
||||
|
||||
|
||||
class _Provider:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def search_reference_options(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.requests.append(request)
|
||||
return ReferenceSearchPage(
|
||||
options=(
|
||||
ReferenceOption(
|
||||
value="membership-1",
|
||||
label="Ada",
|
||||
kind="membership",
|
||||
source_module="access",
|
||||
),
|
||||
),
|
||||
next_cursor="offset:1",
|
||||
has_more=True,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self) -> None:
|
||||
self.provider = _Provider()
|
||||
|
||||
def has_capability(self, name):
|
||||
return name == CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
|
||||
def capability(self, name):
|
||||
return self.provider if self.has_capability(name) else None
|
||||
|
||||
|
||||
class CampaignShareTargetSearchTests(unittest.TestCase):
|
||||
def test_search_uses_membership_references_and_retains_stale_targets(self) -> None:
|
||||
registry = _Registry()
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
group_ids=frozenset(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.sharing._get_campaign_for_principal"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.sharing.get_registry",
|
||||
return_value=registry,
|
||||
),
|
||||
):
|
||||
response = search_campaign_share_targets(
|
||||
"campaign-1",
|
||||
"user",
|
||||
q="Ada",
|
||||
selected=["removed-membership"],
|
||||
limit=25,
|
||||
cursor="offset:0",
|
||||
session=object(), # type: ignore[arg-type]
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
request = registry.provider.requests[0]
|
||||
self.assertEqual("membership", request.kind)
|
||||
self.assertEqual("tenant-1", request.tenant_id)
|
||||
self.assertEqual("ada", request.query)
|
||||
self.assertTrue(request.context["administrative"])
|
||||
self.assertEqual(
|
||||
["membership-1", "removed-membership"],
|
||||
[option.value for option in response.options],
|
||||
)
|
||||
self.assertEqual("unavailable", response.options[1].availability)
|
||||
self.assertTrue(response.has_more)
|
||||
self.assertEqual("offset:1", response.next_cursor)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,7 +6,8 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend import router as campaign_api
|
||||
from govoplan_campaign.backend.routes import delivery as router
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
@@ -195,7 +196,10 @@ def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> N
|
||||
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.queue_campaign_jobs",
|
||||
return_value=queued,
|
||||
) as queue,
|
||||
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,
|
||||
):
|
||||
@@ -207,6 +211,7 @@ def test_post_queue_growth_is_rejected_before_batch_or_provider_preflight() -> N
|
||||
)
|
||||
|
||||
assert rejected.value.eligible_count == 3
|
||||
assert queue.call_args.kwargs["commit_queue"] is False
|
||||
batch_preflight.assert_not_called()
|
||||
|
||||
|
||||
@@ -251,6 +256,7 @@ def test_asynchronous_mode_matches_actual_worker_availability(
|
||||
assert result.worker_queue_available is workers_available
|
||||
assert result.enqueued_count == expected_enqueued
|
||||
assert persist.call_args.kwargs["delivery_mode"] == expected_mode
|
||||
assert persist.call_args.kwargs["commit"] is True
|
||||
assert enqueue.call_args.kwargs["enabled"] is workers_available
|
||||
|
||||
|
||||
@@ -290,7 +296,7 @@ def test_delivery_endpoints_require_their_mode_permission_and_recipient_authorit
|
||||
path: str,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
route = next(item for item in router.router.routes if item.path == path)
|
||||
route = next(item for item in campaign_api.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:
|
||||
@@ -356,3 +362,45 @@ def test_batch_preflight_checks_every_message_before_provider_effects() -> None:
|
||||
assert state_preflight.call_count == 2
|
||||
assert input_preflight.call_count == 2
|
||||
provider.send_campaign_email_bytes.assert_not_called()
|
||||
|
||||
|
||||
def test_rejected_synchronous_preflight_rolls_back_staged_queue_before_audit() -> None:
|
||||
session = Mock()
|
||||
campaign = SimpleNamespace(id="campaign-1", current_version_id="version-1")
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
raw_json={},
|
||||
locked_at=object(),
|
||||
validation_summary={"ok": True},
|
||||
build_summary={"built_count": 1},
|
||||
)
|
||||
rejection = SynchronousSendRejected(
|
||||
"Preflight rejected the staged send.",
|
||||
reason="batch_preflight_failed",
|
||||
eligible_count=1,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal"),
|
||||
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", side_effect=rejection),
|
||||
patch.object(router, "audit_from_principal") as audit,
|
||||
pytest.raises(HTTPException) as rejected,
|
||||
):
|
||||
router.send_campaign_now_endpoint(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=_Principal(
|
||||
"campaigns:campaign:send", "campaigns:recipient:read"
|
||||
), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert rejected.value.status_code == 422
|
||||
session.rollback.assert_called_once_with()
|
||||
audit.assert_called_once()
|
||||
assert audit.call_args.kwargs["action"] == "campaign.send_now_rejected"
|
||||
assert audit.call_args.kwargs["commit"] is True
|
||||
|
||||
77
tests/test_tenant_summary_batch.py
Normal file
77
tests/test_tenant_summary_batch.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.manifest import _tenant_summary_batch
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class CampaignTenantSummaryBatchTests(unittest.TestCase):
|
||||
def test_batch_summary_groups_all_tenants_in_one_query(self) -> None:
|
||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
engine,
|
||||
tables=[
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.add_all(
|
||||
[
|
||||
Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
external_id="one",
|
||||
name="One",
|
||||
),
|
||||
Campaign(
|
||||
id="campaign-2",
|
||||
tenant_id="tenant-1",
|
||||
external_id="two",
|
||||
name="Two",
|
||||
),
|
||||
Campaign(
|
||||
id="campaign-3",
|
||||
tenant_id="tenant-2",
|
||||
external_id="three",
|
||||
name="Three",
|
||||
),
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
query_count = 0
|
||||
|
||||
def count_query(*_args: object) -> None:
|
||||
nonlocal query_count
|
||||
query_count += 1
|
||||
|
||||
event.listen(engine, "before_cursor_execute", count_query)
|
||||
try:
|
||||
counts = _tenant_summary_batch(
|
||||
session,
|
||||
["tenant-1", "tenant-2", "tenant-empty"],
|
||||
)
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", count_query)
|
||||
|
||||
self.assertEqual(1, query_count)
|
||||
self.assertEqual({"campaigns": 2}, counts["tenant-1"])
|
||||
self.assertEqual({"campaigns": 1}, counts["tenant-2"])
|
||||
self.assertNotIn("tenant-empty", counts)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -21,7 +21,7 @@
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react-router-dom": ">=7.18.2 <8"
|
||||
},
|
||||
"scripts": {
|
||||
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ApiSettings, CampaignListItem, DeltaDeletedItem } from "../types";
|
||||
import { apiDownload, apiFetch } from "./client";
|
||||
import {
|
||||
apiReferenceOptionProvider,
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
import { campaignJobsQueryParams, type CampaignJobsQueryParameters } from "../features/campaigns/utils/jobListQuery";
|
||||
export { fetchResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
export type {
|
||||
@@ -101,6 +105,10 @@ export type CampaignDeltaResponse = {
|
||||
watermark?: string | null;
|
||||
has_more: boolean;
|
||||
full: boolean;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export type CampaignWorkspaceDeltaResponse = CampaignWorkspaceResponse & {
|
||||
@@ -185,6 +193,58 @@ export type CampaignRecipientAddressSourcesResponse = {
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
};
|
||||
|
||||
export type CampaignPostboxDirectoryEntry = {
|
||||
id: string;
|
||||
address: string;
|
||||
address_key: string;
|
||||
name: string;
|
||||
status: string;
|
||||
classification: string;
|
||||
organization_unit_id?: string | null;
|
||||
organization_unit_name?: string | null;
|
||||
function_id?: string | null;
|
||||
function_name?: string | null;
|
||||
context_key?: string | null;
|
||||
holder_count: number;
|
||||
vacant: boolean;
|
||||
};
|
||||
|
||||
export type CampaignPostboxTemplate = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
published_revision_id: string;
|
||||
function_type_id?: string | null;
|
||||
scope_kind: string;
|
||||
scope_id?: string | null;
|
||||
classification: string;
|
||||
allow_vacant_delivery: boolean;
|
||||
};
|
||||
|
||||
export type CampaignPostboxFunction = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
function_type_id?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignPostboxOrganizationUnit = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
unit_type_id?: string | null;
|
||||
parent_id?: string | null;
|
||||
functions: CampaignPostboxFunction[];
|
||||
};
|
||||
|
||||
export type CampaignPostboxCatalog = {
|
||||
available: boolean;
|
||||
postboxes: CampaignPostboxDirectoryEntry[];
|
||||
templates: CampaignPostboxTemplate[];
|
||||
organization_units: CampaignPostboxOrganizationUnit[];
|
||||
};
|
||||
|
||||
export type CampaignRecipientSnapshotItem = {
|
||||
contact_id: string;
|
||||
display_name: string;
|
||||
@@ -285,6 +345,7 @@ export type CampaignDeliveryOptions = {
|
||||
campaign_id: string;
|
||||
version_id: string;
|
||||
worker_queue_available: boolean;
|
||||
postbox_available: boolean;
|
||||
synchronous_send: {
|
||||
allowed?: boolean;
|
||||
reason?: string | null;
|
||||
@@ -448,6 +509,7 @@ export type CampaignJobDetailResponse = {
|
||||
attempts: {
|
||||
smtp?: Record<string, unknown>[];
|
||||
imap?: Record<string, unknown>[];
|
||||
postbox?: Record<string, unknown>[];
|
||||
};
|
||||
};
|
||||
|
||||
@@ -488,6 +550,9 @@ export type AggregateCampaignReport = {
|
||||
};
|
||||
outcomes: {
|
||||
smtp_accepted: AggregateReportCount;
|
||||
postbox_accepted: AggregateReportCount;
|
||||
delivered: AggregateReportCount;
|
||||
partially_accepted: AggregateReportCount;
|
||||
failed: AggregateReportCount;
|
||||
outcome_unknown: AggregateReportCount;
|
||||
queued_or_active: AggregateReportCount;
|
||||
@@ -582,6 +647,13 @@ campaignId: string)
|
||||
return apiFetch<CampaignRecipientAddressSourcesResponse>(settings, `/api/v1/campaigns/${campaignId}/recipient-address-sources`);
|
||||
}
|
||||
|
||||
export async function getCampaignPostboxCatalog(
|
||||
settings: ApiSettings,
|
||||
campaignId: string)
|
||||
: Promise<CampaignPostboxCatalog> {
|
||||
return apiFetch<CampaignPostboxCatalog>(settings, `/api/v1/campaigns/${campaignId}/postbox-catalog`);
|
||||
}
|
||||
|
||||
export async function snapshotCampaignRecipientAddressSource(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
@@ -961,12 +1033,13 @@ export async function resolveCampaignJobOutcome(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
jobId: string,
|
||||
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended",
|
||||
note?: string)
|
||||
decision: "smtp_accepted" | "not_sent" | "imap_appended" | "imap_not_appended" | "postbox_accepted" | "postbox_not_accepted",
|
||||
note?: string,
|
||||
attemptId?: string)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/jobs/${jobId}/resolve-outcome`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ decision, note: note || null })
|
||||
body: JSON.stringify({ decision, note: note || null, attempt_id: attemptId || null })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1056,9 +1129,28 @@ export async function getCampaignShareTargets(settings: ApiSettings, campaignId:
|
||||
return apiFetch<CampaignShareTargets>(settings, `/api/v1/campaigns/${campaignId}/share-targets`);
|
||||
}
|
||||
|
||||
export function campaignShareTargetProvider(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
targetType: "user" | "group"
|
||||
): ReferenceOptionProvider {
|
||||
return apiReferenceOptionProvider(
|
||||
settings,
|
||||
`/api/v1/campaigns/${campaignId}/share-target-options`,
|
||||
{ target_type: targetType }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCampaignShares(settings: ApiSettings, campaignId: string): Promise<CampaignShare[]> {
|
||||
const response = await apiFetch<{shares: CampaignShare[];}>(settings, `/api/v1/campaigns/${campaignId}/shares`);
|
||||
return response.shares;
|
||||
const pageSize = 500;
|
||||
const shares: CampaignShare[] = [];
|
||||
for (let page = 1; ; page += 1) {
|
||||
const response = await apiFetch<{shares: CampaignShare[];pages?: number;}>(settings, `/api/v1/campaigns/${campaignId}/shares?page=${page}&page_size=${pageSize}`);
|
||||
shares.push(...response.shares);
|
||||
if (page >= (response.pages ?? 1)) {
|
||||
return shares;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCampaignOwner(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type {
|
||||
ApiSettings,
|
||||
MailConnectionTestResponse,
|
||||
MailCredentialEnvelope,
|
||||
MailImapFolderListResponse,
|
||||
MailServerProfile,
|
||||
MockMailboxMessageResponse
|
||||
} from "@govoplan/core-webui";
|
||||
import { apiFetch, apiGetList, apiPost } from "./client";
|
||||
import { apiFetch, apiGetList, apiPath, apiPost } from "./client";
|
||||
|
||||
const profileActionEndpoints = {
|
||||
smtp: "test-smtp",
|
||||
@@ -16,11 +17,21 @@ const profileActionEndpoints = {
|
||||
function runProfileAction<TResponse>(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
action: keyof typeof profileActionEndpoints
|
||||
action: keyof typeof profileActionEndpoints,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<TResponse> {
|
||||
return apiPost<TResponse>(
|
||||
settings,
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
|
||||
apiPath(
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`,
|
||||
{
|
||||
server_id: serverId,
|
||||
credential_id: credentialId,
|
||||
campaign_id: campaignId
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,16 +42,58 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
|
||||
});
|
||||
}
|
||||
|
||||
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
|
||||
export function createCampaignMailCredential(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
campaignId: string,
|
||||
payload: {
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
server_ids: string[];
|
||||
}
|
||||
): Promise<MailCredentialEnvelope> {
|
||||
return apiFetch<MailCredentialEnvelope>(
|
||||
settings,
|
||||
apiPath(
|
||||
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/campaign-credentials`,
|
||||
{ campaign_id: campaignId }
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
|
||||
export async function testMailProfileSmtp(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
|
||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
|
||||
export async function testMailProfileImap(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailConnectionTestResponse> {
|
||||
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
|
||||
}
|
||||
|
||||
export async function listMailProfileImapFolders(
|
||||
settings: ApiSettings,
|
||||
profileId: string,
|
||||
serverId?: string | null,
|
||||
credentialId?: string | null,
|
||||
campaignId?: string | null
|
||||
): Promise<MailImapFolderListResponse> {
|
||||
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
|
||||
}
|
||||
|
||||
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
|
||||
|
||||
125
webui/src/features/campaigns/CampaignActivityWidget.tsx
Normal file
125
webui/src/features/campaigns/CampaignActivityWidget.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useCallback } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
DashboardWidgetList,
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
StatusBadge,
|
||||
useDashboardWidgetData,
|
||||
type ApiSettings,
|
||||
type CampaignListItem,
|
||||
type DashboardWidgetConfiguration
|
||||
} from "@govoplan/core-webui";
|
||||
import { listCampaigns } from "../../api/campaigns";
|
||||
|
||||
const TERMINAL_STATUSES = new Set([
|
||||
"sent",
|
||||
"cancelled",
|
||||
"archived",
|
||||
"deleted"
|
||||
]);
|
||||
|
||||
export default function CampaignActivityWidget({
|
||||
settings,
|
||||
refreshKey,
|
||||
configuration
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
refreshKey: number;
|
||||
configuration: DashboardWidgetConfiguration;
|
||||
}) {
|
||||
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
|
||||
const showCompleted = configuration.showCompleted === true;
|
||||
const load = useCallback(async () => {
|
||||
const campaigns = await listCampaigns(settings);
|
||||
return campaigns
|
||||
.filter(
|
||||
(campaign) =>
|
||||
showCompleted || !TERMINAL_STATUSES.has(campaign.status)
|
||||
)
|
||||
.sort(compareCampaigns)
|
||||
.slice(0, maxItems);
|
||||
}, [maxItems, settings, showCompleted]);
|
||||
const { data: campaigns, loading, error } = useDashboardWidgetData(
|
||||
load,
|
||||
refreshKey
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingFrame loading={loading} label="Loading campaign activity">
|
||||
{error && (
|
||||
<DismissibleAlert tone="warning" resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
<DashboardWidgetList
|
||||
emptyText="No active campaigns."
|
||||
items={(campaigns ?? []).map((campaign) => ({
|
||||
id: campaign.id,
|
||||
title: campaign.name,
|
||||
detail: campaignProgress(campaign),
|
||||
meta: updatedLabel(campaign),
|
||||
leading: <Send size={17} aria-hidden="true" />,
|
||||
trailing: (
|
||||
<StatusBadge status={campaign.status} label={statusLabel(campaign.status)} />
|
||||
),
|
||||
to: `/campaigns/${encodeURIComponent(campaign.id)}`
|
||||
}))}
|
||||
/>
|
||||
<div className="dashboard-contribution-footer">
|
||||
<Link className="btn btn-secondary" to="/campaigns">
|
||||
Open campaigns
|
||||
</Link>
|
||||
</div>
|
||||
</LoadingFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function compareCampaigns(
|
||||
left: CampaignListItem,
|
||||
right: CampaignListItem
|
||||
): number {
|
||||
return timestamp(right) - timestamp(left);
|
||||
}
|
||||
|
||||
function timestamp(campaign: CampaignListItem): number {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
|
||||
return value ? new Date(value).getTime() : 0;
|
||||
}
|
||||
|
||||
function updatedLabel(campaign: CampaignListItem): string {
|
||||
const value = campaign.updated_at ?? campaign.updatedAt ?? campaign.created_at;
|
||||
return value
|
||||
? new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
month: "short"
|
||||
}).format(new Date(value))
|
||||
: "";
|
||||
}
|
||||
|
||||
function campaignProgress(campaign: CampaignListItem): string {
|
||||
const parts = [
|
||||
campaign.sent ? `${campaign.sent} sent` : "",
|
||||
campaign.failed ? `${campaign.failed} failed` : "",
|
||||
campaign.blocked ? `${campaign.blocked} blocked` : "",
|
||||
campaign.warnings ? `${campaign.warnings} warnings` : ""
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ") || campaign.description || "No delivery totals yet";
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function numberSetting(
|
||||
value: unknown,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number
|
||||
): number {
|
||||
const numeric = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(numeric)
|
||||
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
|
||||
: fallback;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Button } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
|
||||
@@ -31,7 +32,11 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
||||
let response: CampaignDeltaResponse;
|
||||
do {
|
||||
response = await listCampaignsDelta(settings, { since: nextWatermark });
|
||||
nextCampaigns = mergeCampaignDelta(nextCampaigns, response);
|
||||
nextCampaigns = mergeCampaignDelta(
|
||||
nextCampaigns,
|
||||
response,
|
||||
Boolean(response.full && nextWatermark?.startsWith("full:campaigns:"))
|
||||
);
|
||||
nextWatermark = response.watermark ?? null;
|
||||
} while (response.has_more);
|
||||
setCampaigns(nextCampaigns);
|
||||
@@ -138,6 +143,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
||||
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page campaigns-page">
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
|
||||
@@ -179,7 +185,8 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
|
||||
}
|
||||
</LoadingFrame>
|
||||
</Card>
|
||||
</div>);
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
}
|
||||
|
||||
@@ -196,8 +203,12 @@ function formatLoadedAt(value: Date): string {
|
||||
return formatDateTimeFromDate(value, { second: "2-digit" });
|
||||
}
|
||||
|
||||
function mergeCampaignDelta(current: CampaignListItem[], response: CampaignDeltaResponse): CampaignListItem[] {
|
||||
if (response.full) return response.campaigns;
|
||||
function mergeCampaignDelta(
|
||||
current: CampaignListItem[],
|
||||
response: CampaignDeltaResponse,
|
||||
continuingFullSnapshot = false
|
||||
): CampaignListItem[] {
|
||||
if (response.full && !continuingFullSnapshot) return response.campaigns;
|
||||
return mergeDeltaRows(current, response.campaigns, response.deleted, (campaign) => campaign.id, {
|
||||
deletedResourceType: "campaign",
|
||||
sort: sortCampaignsByUpdatedDesc
|
||||
|
||||
78
webui/src/features/campaigns/CampaignModulePage.tsx
Normal file
78
webui/src/features/campaigns/CampaignModulePage.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { lazy } from "react";
|
||||
import {
|
||||
ModuleSubnav,
|
||||
hasAnyScope,
|
||||
useGuardedNavigate,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type ModuleSubnavGroup
|
||||
} from "@govoplan/core-webui";
|
||||
import { OPERATOR_QUEUE_ROUTE_SCOPES } from "../operator/operatorQueueAccess";
|
||||
|
||||
const CampaignListPage = lazy(() => import("./CampaignListPage"));
|
||||
const OperatorQueuePage = lazy(() => import("../operator/OperatorQueuePage"));
|
||||
const AggregateReportsPage = lazy(() => import("../reports/AggregateReportsPage"));
|
||||
|
||||
export type CampaignModuleSection = "campaigns" | "queue" | "reports";
|
||||
|
||||
export default function CampaignModulePage({
|
||||
active,
|
||||
settings,
|
||||
auth
|
||||
}: {
|
||||
active: CampaignModuleSection;
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const canReadCampaigns = hasAnyScope(auth, ["campaigns:campaign:read"]);
|
||||
const canUseQueue =
|
||||
canReadCampaigns && hasAnyScope(auth, OPERATOR_QUEUE_ROUTE_SCOPES);
|
||||
const canReadReports = hasAnyScope(auth, ["campaigns:report:read"]);
|
||||
const groups: ModuleSubnavGroup<CampaignModuleSection>[] = [{
|
||||
items: [
|
||||
...(canReadCampaigns
|
||||
? [{
|
||||
id: "campaigns" as const,
|
||||
label: "i18n:govoplan-campaign.all_campaigns.2bd1ee3a",
|
||||
primary: true
|
||||
}]
|
||||
: []),
|
||||
...(canUseQueue
|
||||
? [{
|
||||
id: "queue" as const,
|
||||
label: "i18n:govoplan-campaign.operator_queue.72492fb5"
|
||||
}]
|
||||
: []),
|
||||
...(canReadReports
|
||||
? [{
|
||||
id: "reports" as const,
|
||||
label: "i18n:govoplan-campaign.reports.88bc3fe3",
|
||||
primary: !canReadCampaigns
|
||||
}]
|
||||
: [])
|
||||
]
|
||||
}];
|
||||
|
||||
function select(section: CampaignModuleSection) {
|
||||
const routes: Record<CampaignModuleSection, string> = {
|
||||
campaigns: "/campaigns",
|
||||
queue: "/campaigns/queue",
|
||||
reports: "/campaigns/reports"
|
||||
};
|
||||
navigate(routes[section]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="workspace campaign-module-workspace">
|
||||
<ModuleSubnav active={active} groups={groups} onSelect={select} />
|
||||
<section className="workspace-content">
|
||||
{active === "queue"
|
||||
? <OperatorQueuePage settings={settings} auth={auth} />
|
||||
: active === "reports"
|
||||
? <AggregateReportsPage settings={settings} />
|
||||
: <CampaignListPage settings={settings} />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,9 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"claimed",
|
||||
"sending",
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"delivered",
|
||||
"partially_accepted",
|
||||
"sent",
|
||||
"outcome_unknown",
|
||||
"failed_temporary",
|
||||
@@ -50,6 +53,19 @@ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"cancelled"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const POSTBOX_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
"delivering",
|
||||
"accepted",
|
||||
"accepted_vacant",
|
||||
"partially_accepted",
|
||||
"rejected_temporary",
|
||||
"rejected_permanent",
|
||||
"outcome_unknown",
|
||||
"skipped"].
|
||||
map((value) => ({ value, label: deliveryStatusLabel(value) ?? humanize(value) }));
|
||||
|
||||
const IMAP_STATUS_OPTIONS: DataGridListOption[] = [
|
||||
"not_requested",
|
||||
"pending",
|
||||
@@ -253,7 +269,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
});
|
||||
const sendResult = asRecord(asRecord(sendResponse.result ?? sendResponse).result);
|
||||
const status = String(sendResult.status ?? "submitted");
|
||||
if (status === "smtp_accepted" || status === "already_accepted") accepted += 1;
|
||||
if (["smtp_accepted", "postbox_accepted", "delivered", "partially_accepted", "already_accepted"].includes(status)) accepted += 1;
|
||||
else failures.push(`${shortJobId(jobId)}: ${humanize(status)}`);
|
||||
} catch (err) {
|
||||
failures.push(`${shortJobId(jobId)}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -353,9 +369,10 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
{ 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, 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: "send", header: "Delivery", 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: "postbox", header: "Postbox", width: 155, sortable: true, filterable: true, columnType: "from-list", list: { options: POSTBOX_STATUS_OPTIONS, display: "pill" }, render: (row) => <StatusBadge status={String(row.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(row.postbox_status ?? "unknown"))} />, value: (row) => String(row.postbox_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: "attempts", header: "i18n:govoplan-campaign.attempts.5a29585e", width: 105, align: "right", sortable: true, filterType: "integer", value: (row) => Number(row.attempt_count ?? 0), render: (row) => String(Number(row.attempt_count ?? 0) + Number(row.postbox_attempt_count ?? 0)) },
|
||||
{
|
||||
id: "evidence",
|
||||
header: "i18n:govoplan-campaign.evidence.7ea014de",
|
||||
@@ -540,10 +557,13 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
<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")} label={deliveryStatusLabel(String(detail.job.send_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox state</dt><dd><StatusBadge status={String(detail.job.postbox_status ?? "unknown")} label={deliveryStatusLabel(String(detail.job.postbox_status ?? "unknown"))} /></dd></div>
|
||||
<div><dt>Postbox targets</dt><dd>{String(detail.job.postbox_target_count ?? 0)}</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 ?? []} />
|
||||
<AttemptHistoryTable kind="postbox" rows={detail.attempts.postbox ?? []} />
|
||||
<AttemptHistoryTable kind="imap" rows={detail.attempts.imap ?? []} />
|
||||
</div>
|
||||
}
|
||||
@@ -565,13 +585,17 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
|
||||
|
||||
}
|
||||
|
||||
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record<string, unknown>[];}) {
|
||||
const title = kind === "smtp" ? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" : "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
|
||||
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record<string, unknown>[];}) {
|
||||
const title = kind === "smtp"
|
||||
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
|
||||
: kind === "postbox"
|
||||
? "Postbox delivery attempts"
|
||||
: "i18n:govoplan-campaign.imap_append_attempts.b30e980a";
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<section className="attempt-history-section">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.no.816c52fd {kind === "smtp" ? "i18n:govoplan-campaign.smtp.efff9cca" : "i18n:govoplan-campaign.imap.271f9ef2"} i18n:govoplan-campaign.attempt_has_been_recorded_for_this_job.e4050f01</p>
|
||||
<p className="muted small-note">No {kind} attempt has been recorded for this job.</p>
|
||||
</section>);
|
||||
|
||||
}
|
||||
@@ -581,10 +605,12 @@ function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap";rows: Record
|
||||
{ id: "status", header: "i18n:govoplan-campaign.status.bae7d5be", width: 150, sortable: true, filterable: true, value: (row) => String(row.status ?? "unknown"), render: (row) => <StatusBadge status={String(row.status ?? "unknown")} /> },
|
||||
kind === "imap" ?
|
||||
{ id: "folder", header: "i18n:govoplan-campaign.folder.30baa249", width: 180, sortable: true, filterable: true, value: (row) => String(row.folder ?? "—"), render: (row) => String(row.folder ?? "—") } :
|
||||
kind === "postbox" ?
|
||||
{ id: "target", header: "Postbox", width: 220, sortable: true, filterable: true, value: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—"), render: (row) => String(row.address ?? asRecord(row.target).address ?? row.postbox_id ?? "—") } :
|
||||
{ id: "code", header: "i18n:govoplan-campaign.code.adac6937", width: 110, sortable: true, value: (row) => String(row.smtp_status_code ?? "—"), render: (row) => String(row.smtp_status_code ?? "—") },
|
||||
{ id: "started", header: "i18n:govoplan-campaign.started.faa9e7e7", width: 180, sortable: true, value: (row) => String(row.started_at ?? row.created_at ?? ""), render: (row) => formatDateTime(String(row.started_at ?? row.created_at ?? "")) },
|
||||
{ id: "finished", header: "i18n:govoplan-campaign.finished.355bcc57", width: 180, sortable: true, value: (row) => String(row.finished_at ?? row.updated_at ?? ""), render: (row) => formatDateTime(String(row.finished_at ?? row.updated_at ?? "")) },
|
||||
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? "")}>{String(row.smtp_response ?? row.error_message ?? "—")}</span> }
|
||||
{ id: "result", header: "i18n:govoplan-campaign.result.5faa59d4", width: "minmax(240px, 1fr)", minWidth: 200, resizable: true, filterable: true, value: (row) => String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—"), render: (row) => <span title={String(row.smtp_response ?? row.error_message ?? row.error_code ?? "")}>{String(row.smtp_response ?? row.error_message ?? row.error_code ?? "—")}</span> }
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -601,9 +627,11 @@ function initialReportGridFilters(): Record<string, string | string[]> {
|
||||
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 postbox = statusParameters(params, "postbox_status", POSTBOX_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 (postbox.length > 0) result.postbox = postbox;
|
||||
if (validation.length > 0) result.validation = validation;
|
||||
return result;
|
||||
}
|
||||
@@ -635,14 +663,14 @@ function serializeInitialGridFilters(filters: Record<string, string | string[]>)
|
||||
}
|
||||
|
||||
function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
|
||||
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "imap" || value === "attempts" || value === "updated") {
|
||||
if (value === "recipient" || value === "subject" || value === "validation" || value === "queue" || value === "send" || value === "postbox" || value === "imap" || value === "attempts" || value === "updated") {
|
||||
return value;
|
||||
}
|
||||
return "number";
|
||||
}
|
||||
|
||||
function retryableFailedStatus(status: string): boolean {
|
||||
return status === "failed_temporary" || status === "failed_permanent";
|
||||
return status === "failed_temporary" || status === "failed_permanent" || status === "partially_accepted";
|
||||
}
|
||||
|
||||
function shortJobId(jobId: string): string {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import {
|
||||
getCampaignPostboxCatalog,
|
||||
type CampaignPostboxCatalog
|
||||
} from "../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
@@ -14,10 +18,15 @@ import VersionLine from "./components/VersionLine";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { hasScope } from "@govoplan/core-webui";
|
||||
import { RetentionPolicyEditor } from "@govoplan/core-webui";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
||||
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
|
||||
import { cloneJson, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import PostboxTargetsDialog, {
|
||||
normalizePostboxTargets
|
||||
} from "./components/PostboxTargetsDialog";
|
||||
|
||||
const behaviorOptions = ["block", "ask", "drop", "continue", "warn"];
|
||||
|
||||
@@ -34,6 +43,14 @@ type GlobalSettingsPageProps = {
|
||||
export default function GlobalSettingsPage({ settings, auth, campaignId, view = "settings" }: GlobalSettingsPageProps) {
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [editorState, setEditorState] = useState<EditorState>({});
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
const [postboxTargetsOpen, setPostboxTargetsOpen] = useState(false);
|
||||
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
|
||||
const isPolicyView = view === "policy";
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -59,12 +76,53 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
const delivery = asRecord(displayDraft.delivery);
|
||||
const rateLimit = asRecord(delivery.rate_limit);
|
||||
const retry = asRecord(delivery.retry);
|
||||
const postboxDelivery = asRecord(delivery.postbox);
|
||||
const fieldDefinitions = useMemo(
|
||||
() => getDraftFields(displayDraft),
|
||||
[displayDraft]
|
||||
);
|
||||
const statusTracking = asRecord(displayDraft.status_tracking);
|
||||
const optIns = asRecord(editorState.opt_ins);
|
||||
const canReadRetentionPolicy = hasScope(auth, "admin:policies:read");
|
||||
const canWriteRetentionPolicy = hasScope(auth, "admin:policies:write");
|
||||
const pageTitle = isPolicyView ? "i18n:govoplan-campaign.campaign_policies.0b5de1f5" : "i18n:govoplan-campaign.campaign_settings.efffec26";
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getCampaignPostboxCatalog(settings, campaignId)
|
||||
.then((catalog) => {
|
||||
if (!cancelled) setPostboxCatalog(catalog);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
postboxModuleInstalled,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
function patchEditor(path: string[], value: unknown) {
|
||||
if (locked) return;
|
||||
setEditorState((current) => updateNested(current, path, value));
|
||||
@@ -232,6 +290,43 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
<FormField label="i18n:govoplan-campaign.max_attempts.f684fef4"><input type="number" min={1} value={getNumber(retry, "max_attempts", 3)} disabled={locked} onChange={(event) => patch(["delivery", "retry", "max_attempts"], Number(event.target.value || 1))} /></FormField>
|
||||
<ToggleSwitch label="i18n:govoplan-campaign.status_tracking.15f61f88" checked={getBool(statusTracking, "enabled", true)} disabled={locked} onChange={(checked) => patch(["status_tracking", "enabled"], checked)} />
|
||||
</div>
|
||||
{postboxModuleInstalled &&
|
||||
<div className="campaign-postbox-delivery-settings">
|
||||
<FormField label="Delivery channels">
|
||||
<select
|
||||
value={getText(delivery, "channel_policy", "mail")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "channel_policy"], event.target.value)}
|
||||
>
|
||||
<option value="mail">Mail</option>
|
||||
<option value="postbox">Postbox</option>
|
||||
<option value="mail_and_postbox">Mail and Postbox</option>
|
||||
<option value="mail_then_postbox">Mail, then Postbox on pre-acceptance rejection</option>
|
||||
<option value="postbox_then_mail">Postbox, then Mail on pre-acceptance rejection</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Postbox classification">
|
||||
<input
|
||||
value={getText(postboxDelivery, "classification", "internal")}
|
||||
disabled={locked}
|
||||
onChange={(event) => patch(["delivery", "postbox", "classification"], event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Default Postbox targets">
|
||||
<Button
|
||||
disabled={locked || !postboxCatalog.available}
|
||||
onClick={() => setPostboxTargetsOpen(true)}
|
||||
>
|
||||
Configure ({normalizePostboxTargets(postboxDelivery.targets).length})
|
||||
</Button>
|
||||
</FormField>
|
||||
{!postboxCatalog.available &&
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
Postbox delivery is installed but its delivery catalog is unavailable.
|
||||
</DismissibleAlert>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</Card>
|
||||
|
||||
<Card title="i18n:govoplan-campaign.opt_ins_and_local_assistance.d0d23635" collapsible>
|
||||
@@ -246,6 +341,21 @@ export default function GlobalSettingsPage({ settings, auth, campaignId, view =
|
||||
</>
|
||||
}
|
||||
</LoadingFrame>
|
||||
{postboxTargetsOpen &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
title="Default Postbox targets"
|
||||
catalog={postboxCatalog}
|
||||
fields={fieldDefinitions}
|
||||
targets={normalizePostboxTargets(postboxDelivery.targets)}
|
||||
locked={locked}
|
||||
onSave={(targets) => {
|
||||
patch(["delivery", "postbox", "targets"], targets);
|
||||
setPostboxTargetsOpen(false);
|
||||
}}
|
||||
onClose={() => setPostboxTargetsOpen(false)}
|
||||
/>
|
||||
}
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
@@ -2,21 +2,26 @@ import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
LoadingFrame,
|
||||
MailServerFolderLookupResultView,
|
||||
MetricCard,
|
||||
PageTitle,
|
||||
PasswordField,
|
||||
ToggleSwitch,
|
||||
usePlatformModuleInstalled,
|
||||
usePlatformUiCapability,
|
||||
type MailCredentialEnvelope,
|
||||
type MailProfilesUiCapability,
|
||||
type MailServerConnectionTestResult,
|
||||
type MailServerEndpoint,
|
||||
type MailServerFolderLookupResult
|
||||
} from "@govoplan/core-webui";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createCampaignMailCredential,
|
||||
listMailProfileImapFolders,
|
||||
listMailServerProfiles,
|
||||
testMailProfileImap,
|
||||
@@ -39,6 +44,14 @@ type MailSettingsPageProps = {
|
||||
view?: MailSettingsView;
|
||||
};
|
||||
|
||||
type CampaignCredentialDraft = {
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
useSmtp: boolean;
|
||||
useImap: boolean;
|
||||
};
|
||||
|
||||
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
|
||||
const mailModuleInstalled = usePlatformModuleInstalled("mail");
|
||||
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
|
||||
@@ -53,6 +66,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
||||
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
||||
const [credentialDialogOpen, setCredentialDialogOpen] = useState(false);
|
||||
const [credentialSaving, setCredentialSaving] = useState(false);
|
||||
const [credentialDraft, setCredentialDraft] = useState<CampaignCredentialDraft>({
|
||||
name: "",
|
||||
username: "",
|
||||
password: "",
|
||||
useSmtp: true,
|
||||
useImap: false
|
||||
});
|
||||
|
||||
const version = data.currentVersion;
|
||||
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
|
||||
@@ -78,10 +100,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
const server = asRecord(displayDraft.server);
|
||||
const selectedProfileId = getText(server, "mail_profile_id");
|
||||
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
|
||||
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
|
||||
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
|
||||
const selectedSmtpServer = smtpServers.find((item) => item.id === getText(server, "smtp_server_id"))
|
||||
?? smtpServers.find((item) => item.is_default)
|
||||
?? smtpServers[0]
|
||||
?? null;
|
||||
const selectedImapServer = imapServers.find((item) => item.id === getText(server, "imap_server_id"))
|
||||
?? imapServers.find((item) => item.is_default)
|
||||
?? imapServers[0]
|
||||
?? null;
|
||||
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
|
||||
?? selectedSmtpServer?.credentials.find((item) => item.is_default)
|
||||
?? selectedSmtpServer?.credentials[0]
|
||||
?? null;
|
||||
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
|
||||
?? selectedImapServer?.credentials.find((item) => item.is_default)
|
||||
?? selectedImapServer?.credentials[0]
|
||||
?? null;
|
||||
const delivery = asRecord(displayDraft.delivery);
|
||||
const imapAppend = asRecord(delivery.imap_append_sent);
|
||||
const imapAppendEnabled = getBool(imapAppend, "enabled");
|
||||
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
|
||||
const selectedProfileHasImap = Boolean(selectedImapServer);
|
||||
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
|
||||
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
|
||||
|
||||
@@ -117,22 +157,140 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
function selectMailProfile(profileId: string) {
|
||||
if (!mailModuleInstalled || locked) return;
|
||||
patch(["server"], profileId ? { mail_profile_id: profileId } : {});
|
||||
const profile = mailProfiles.find((item) => item.id === profileId);
|
||||
const smtpServer = profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active && item.is_default)
|
||||
?? profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active);
|
||||
const imapServer = profile?.servers?.find((item) => item.protocol === "imap" && item.is_active && item.is_default)
|
||||
?? profile?.servers?.find((item) => item.protocol === "imap" && item.is_active);
|
||||
const smtpCredential = smtpServer?.credentials.find((item) => item.is_active && item.is_default)
|
||||
?? smtpServer?.credentials.find((item) => item.is_active);
|
||||
const imapCredential = imapServer?.credentials.find((item) => item.is_active && item.is_default)
|
||||
?? imapServer?.credentials.find((item) => item.is_active);
|
||||
patch(["server"], profile ? mailReference(
|
||||
profile.id,
|
||||
smtpServer?.id,
|
||||
smtpCredential?.id,
|
||||
imapServer?.id,
|
||||
imapCredential?.id
|
||||
) : {});
|
||||
setSmtpTestResult(null);
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
setProfileError("");
|
||||
}
|
||||
|
||||
function selectServer(protocol: "smtp" | "imap", serverId: string) {
|
||||
if (!selectedProfile || locked) return;
|
||||
const availableServers = protocol === "smtp" ? smtpServers : imapServers;
|
||||
const selected = availableServers.find((item) => item.id === serverId) ?? null;
|
||||
const credential = selected?.credentials.find((item) => item.is_active && item.is_default)
|
||||
?? selected?.credentials.find((item) => item.is_active)
|
||||
?? null;
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
protocol === "smtp" ? selected?.id : selectedSmtpServer?.id,
|
||||
protocol === "smtp" ? credential?.id : selectedSmtpCredential?.id,
|
||||
protocol === "imap" ? selected?.id : selectedImapServer?.id,
|
||||
protocol === "imap" ? credential?.id : selectedImapCredential?.id
|
||||
));
|
||||
if (protocol === "smtp") setSmtpTestResult(null);
|
||||
else {
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
function selectCredential(protocol: "smtp" | "imap", credentialId: string) {
|
||||
if (!selectedProfile || locked) return;
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
selectedSmtpServer?.id,
|
||||
protocol === "smtp" ? credentialId : selectedSmtpCredential?.id,
|
||||
selectedImapServer?.id,
|
||||
protocol === "imap" ? credentialId : selectedImapCredential?.id
|
||||
));
|
||||
if (protocol === "smtp") setSmtpTestResult(null);
|
||||
else {
|
||||
setImapTestResult(null);
|
||||
setFolderResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
function openCredentialDialog() {
|
||||
setCredentialDraft({
|
||||
name: `${data.campaign?.name || "Campaign"} credential`,
|
||||
username: "",
|
||||
password: "",
|
||||
useSmtp: Boolean(selectedSmtpServer),
|
||||
useImap: Boolean(selectedImapServer)
|
||||
});
|
||||
setCredentialDialogOpen(true);
|
||||
setLocalError("");
|
||||
}
|
||||
|
||||
async function saveCampaignCredential() {
|
||||
if (!selectedProfile || credentialSaving) return;
|
||||
const serverIds = [
|
||||
credentialDraft.useSmtp ? selectedSmtpServer?.id : null,
|
||||
credentialDraft.useImap ? selectedImapServer?.id : null
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (
|
||||
!credentialDraft.name.trim() ||
|
||||
!credentialDraft.username.trim() ||
|
||||
!credentialDraft.password ||
|
||||
serverIds.length === 0
|
||||
) return;
|
||||
setCredentialSaving(true);
|
||||
setLocalError("");
|
||||
try {
|
||||
const credential = await createCampaignMailCredential(
|
||||
settings,
|
||||
selectedProfile.id,
|
||||
campaignId,
|
||||
{
|
||||
name: credentialDraft.name.trim(),
|
||||
username: credentialDraft.username.trim(),
|
||||
password: credentialDraft.password,
|
||||
server_ids: serverIds
|
||||
}
|
||||
);
|
||||
patch(["server"], mailReference(
|
||||
selectedProfile.id,
|
||||
selectedSmtpServer?.id,
|
||||
credentialDraft.useSmtp ? credential.id : selectedSmtpCredential?.id,
|
||||
selectedImapServer?.id,
|
||||
credentialDraft.useImap ? credential.id : selectedImapCredential?.id
|
||||
));
|
||||
setCredentialDialogOpen(false);
|
||||
await refreshMailProfiles();
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setCredentialSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runProfileTest(protocol: "smtp" | "imap") {
|
||||
if (!selectedProfileId || locked) return;
|
||||
setMailActionState(protocol);
|
||||
setLocalError("");
|
||||
try {
|
||||
if (protocol === "smtp") {
|
||||
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId));
|
||||
setSmtpTestResult(await testMailProfileSmtp(
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedSmtpServer?.id,
|
||||
selectedSmtpCredential?.id,
|
||||
campaignId
|
||||
));
|
||||
} else {
|
||||
setImapTestResult(await testMailProfileImap(settings, selectedProfileId));
|
||||
setImapTestResult(await testMailProfileImap(
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedImapServer?.id,
|
||||
selectedImapCredential?.id,
|
||||
campaignId
|
||||
));
|
||||
}
|
||||
} catch (err) {
|
||||
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
|
||||
@@ -148,7 +306,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
setMailActionState("folders");
|
||||
setLocalError("");
|
||||
try {
|
||||
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId));
|
||||
setFolderResult(await listMailProfileImapFolders(
|
||||
settings,
|
||||
selectedProfileId,
|
||||
selectedImapServer?.id,
|
||||
selectedImapCredential?.id,
|
||||
campaignId
|
||||
));
|
||||
} catch (err) {
|
||||
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
|
||||
} finally {
|
||||
@@ -204,8 +368,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
|
||||
{!isPolicyView && mailModuleInstalled && <Card
|
||||
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
|
||||
actions={<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>}>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p>
|
||||
actions={<div className="button-row compact-actions">
|
||||
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
|
||||
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
|
||||
</div>}>
|
||||
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
|
||||
<div className="form-grid compact responsive-form-grid">
|
||||
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
|
||||
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
|
||||
@@ -213,16 +380,44 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="SMTP server">
|
||||
<select value={selectedSmtpServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
|
||||
<option value="">No SMTP server</option>
|
||||
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="SMTP credential">
|
||||
<select value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
|
||||
<option value="">No credential</option>
|
||||
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="IMAP server">
|
||||
<select value={selectedImapServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
|
||||
<option value="">No IMAP server</option>
|
||||
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="IMAP credential">
|
||||
<select value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
|
||||
<option value="">No credential</option>
|
||||
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
|
||||
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
|
||||
{selectedProfile && <div className="metric-grid inside">
|
||||
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
|
||||
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
|
||||
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value="i18n:govoplan-campaign.configured.668c5fff" tone="good" />
|
||||
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedProfile.imap ? "i18n:govoplan-campaign.configured.668c5fff" : "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedProfile.imap ? "good" : "neutral"} />
|
||||
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
|
||||
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
|
||||
</div>}
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedProfile || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
||||
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedSmtpServer || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
|
||||
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
|
||||
</div>
|
||||
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
|
||||
@@ -230,6 +425,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
|
||||
</Card>}
|
||||
|
||||
<Dialog
|
||||
open={credentialDialogOpen}
|
||||
title="Add campaign credential"
|
||||
onClose={() => setCredentialDialogOpen(false)}
|
||||
closeDisabled={credentialSaving}
|
||||
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
|
||||
footerClassName="button-row compact-actions"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setCredentialDialogOpen(false)} disabled={credentialSaving}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void saveCampaignCredential()}
|
||||
disabled={
|
||||
credentialSaving ||
|
||||
!credentialDraft.name.trim() ||
|
||||
!credentialDraft.username.trim() ||
|
||||
!credentialDraft.password ||
|
||||
!credentialDraft.useSmtp && !credentialDraft.useImap
|
||||
}
|
||||
>
|
||||
{credentialSaving ? "Saving" : "Save credential"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="adaptive-config-form">
|
||||
<section className="adaptive-config-section">
|
||||
<header>
|
||||
<h3>Campaign login</h3>
|
||||
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
|
||||
</header>
|
||||
<div className="form-grid two">
|
||||
<FormField label="Name">
|
||||
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
|
||||
</FormField>
|
||||
<FormField label="Username">
|
||||
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
|
||||
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
|
||||
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
|
||||
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
|
||||
@@ -266,3 +512,28 @@ function profileScopeLabel(profile: MailServerProfile): string {
|
||||
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
|
||||
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
|
||||
}
|
||||
|
||||
function mailReference(
|
||||
profileId: string,
|
||||
smtpServerId?: string | null,
|
||||
smtpCredentialId?: string | null,
|
||||
imapServerId?: string | null,
|
||||
imapCredentialId?: string | null
|
||||
): Record<string, string> {
|
||||
const value: Record<string, string> = { mail_profile_id: profileId };
|
||||
if (smtpServerId) value.smtp_server_id = smtpServerId;
|
||||
if (smtpServerId && smtpCredentialId) value.smtp_credential_id = smtpCredentialId;
|
||||
if (imapServerId) value.imap_server_id = imapServerId;
|
||||
if (imapServerId && imapCredentialId) value.imap_credential_id = imapCredentialId;
|
||||
return value;
|
||||
}
|
||||
|
||||
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
|
||||
const host = typeof config.host === "string" && config.host ? config.host : "No host";
|
||||
return config.port ? `${host}:${config.port}` : host;
|
||||
}
|
||||
|
||||
function credentialLabel(credential: MailCredentialEnvelope): string {
|
||||
const username = credential.public_data?.username;
|
||||
return username ? `${credential.name} (${String(username)})` : credential.name;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@ import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
createRecipientImportMappingProfile,
|
||||
getCampaignPostboxCatalog,
|
||||
listCampaignRecipientAddressSources,
|
||||
listRecipientImportMappingProfiles,
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
updateRecipientImportMappingProfile,
|
||||
type CampaignPostboxCatalog,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot,
|
||||
type RecipientImportMappingProfilePayload } from
|
||||
@@ -30,6 +32,9 @@ import { getBool } from "./utils/draftEditor";
|
||||
import { getDraftFields } from "./utils/fieldDefinitions";
|
||||
import FieldValueInput from "./components/FieldValueInput";
|
||||
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
|
||||
import PostboxTargetsDialog, {
|
||||
normalizePostboxTargets
|
||||
} from "./components/PostboxTargetsDialog";
|
||||
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
|
||||
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
|
||||
import {
|
||||
@@ -58,6 +63,10 @@ import {
|
||||
type RecipientMappingProfile,
|
||||
type RecipientMappingProfileMatch } from
|
||||
"./utils/bulkImport";
|
||||
import {
|
||||
buildAddressSourceImportPreview,
|
||||
createAddressSourceImportProvenance
|
||||
} from "./utils/addressSourceImport";
|
||||
import {
|
||||
bulkLinkFilesToCampaign,
|
||||
type ImportFileLinkCapability,
|
||||
@@ -72,6 +81,7 @@ import {
|
||||
type MailboxAddress } from
|
||||
"@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||
const recipientHeaderRows = [
|
||||
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
|
||||
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
|
||||
@@ -80,7 +90,6 @@ const;
|
||||
|
||||
type RecipientAddressKey = "to" | "cc" | "bcc";
|
||||
type AddressFieldKey = RecipientAddressKey | "from" | "reply_to";
|
||||
type AddressSourceFilter = "all" | "books" | "lists";
|
||||
type HeaderAddressEditorState = {
|
||||
title: string;
|
||||
columns: EntryAddressColumn[];
|
||||
@@ -105,6 +114,7 @@ const recipientAddressOverlayColumns: EntryAddressColumn[] = [
|
||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
const postboxModuleInstalled = usePlatformModuleInstalled("postbox");
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
|
||||
@@ -115,6 +125,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
|
||||
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
|
||||
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
||||
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
|
||||
|
||||
const version = data.currentVersion;
|
||||
@@ -192,12 +209,53 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postboxModuleInstalled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getCampaignPostboxCatalog(settings, campaignId)
|
||||
.then((catalog) => {
|
||||
if (!cancelled) setPostboxCatalog(catalog);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPostboxCatalog({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
templates: [],
|
||||
organization_units: []
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
postboxModuleInstalled,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setRecipientAddressEditorIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (inlineEntries.length === 0) return null;
|
||||
return Math.max(0, Math.min(current, inlineEntries.length - 1));
|
||||
});
|
||||
setPostboxTargetEditorIndex((current) => {
|
||||
if (current === null) return null;
|
||||
if (inlineEntries.length === 0) return null;
|
||||
return Math.max(0, Math.min(current, inlineEntries.length - 1));
|
||||
});
|
||||
}, [inlineEntries.length]);
|
||||
|
||||
|
||||
@@ -232,27 +290,36 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
replaceInlineEntries(nextEntries);
|
||||
}
|
||||
|
||||
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
|
||||
updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses));
|
||||
}
|
||||
|
||||
function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) {
|
||||
function saveEntryAddresses(
|
||||
index: number,
|
||||
values: HeaderAddressValues,
|
||||
merges: EntryAddressMergeValues
|
||||
) {
|
||||
updateEntry(index, (entry) => {
|
||||
let nextEntry = entry;
|
||||
for (const group of groups) {
|
||||
const currentAddresses = getEntryAddresses(nextEntry, group.key);
|
||||
nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses]));
|
||||
for (const column of recipientAddressOverlayColumns) {
|
||||
if (!(column.key in values)) continue;
|
||||
nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
|
||||
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
|
||||
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
|
||||
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
|
||||
}
|
||||
return nextEntry;
|
||||
});
|
||||
setRecipientAddressEditorIndex(null);
|
||||
}
|
||||
|
||||
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
|
||||
updateEntry(index, (entry) => {
|
||||
const next = { ...entry, [mergeKey]: checked };
|
||||
delete next[mergeKey.replace("merge_", "combine_")];
|
||||
return next;
|
||||
});
|
||||
function saveEntryPostboxTargets(
|
||||
index: number,
|
||||
targets: ReturnType<typeof normalizePostboxTargets>,
|
||||
merge: boolean
|
||||
) {
|
||||
updateEntry(index, (entry) => ({
|
||||
...entry,
|
||||
postbox_targets: targets,
|
||||
merge_postbox_targets: merge
|
||||
}));
|
||||
setPostboxTargetEditorIndex(null);
|
||||
}
|
||||
|
||||
function updateEntryField(index: number, field: string, value: unknown) {
|
||||
@@ -305,8 +372,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
setAddressSourceImportOpen(false);
|
||||
}
|
||||
|
||||
function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) {
|
||||
patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses);
|
||||
function saveHeaderAddresses(values: HeaderAddressValues) {
|
||||
const nextRecipients = { ...recipientsSection };
|
||||
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
|
||||
nextRecipients[key] = key === "from" ? addresses.slice(0, 1) : addresses;
|
||||
}
|
||||
patch(["recipients"], nextRecipients);
|
||||
setHeaderAddressEditor(null);
|
||||
}
|
||||
|
||||
async function copyHeaderAddresses(columns: EntryAddressColumn[]) {
|
||||
@@ -448,12 +520,15 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
draft: displayDraft,
|
||||
locked,
|
||||
filesModuleInstalled,
|
||||
postboxModuleInstalled,
|
||||
postboxCatalog,
|
||||
entries: inlineEntries,
|
||||
fieldDefinitions,
|
||||
individualAttachmentBasePaths,
|
||||
zipConfig,
|
||||
translateText,
|
||||
openAddressEditor: setRecipientAddressEditorIndex,
|
||||
openPostboxTargetEditor: setPostboxTargetEditorIndex,
|
||||
updateEntry,
|
||||
updateEntryAttachments,
|
||||
updateEntryField,
|
||||
@@ -508,11 +583,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
locked={locked}
|
||||
recipientsSection={recipientsSection}
|
||||
entryDefaults={entryDefaults}
|
||||
updateEntryAddressList={updateEntryAddressList}
|
||||
updateEntryAddressGroups={updateEntryAddressGroups}
|
||||
updateEntryMerge={updateEntryMerge}
|
||||
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
|
||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||
|
||||
}
|
||||
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
title={`Recipient ${postboxTargetEditorIndex + 1} - Postbox targets`}
|
||||
catalog={postboxCatalog}
|
||||
fields={fieldDefinitions}
|
||||
targets={normalizePostboxTargets(inlineEntries[postboxTargetEditorIndex].postbox_targets)}
|
||||
merge={inlineEntries[postboxTargetEditorIndex].merge_postbox_targets !== false}
|
||||
showMerge
|
||||
locked={locked}
|
||||
onSave={(targets, merge) => saveEntryPostboxTargets(postboxTargetEditorIndex, targets, merge)}
|
||||
onClose={() => setPostboxTargetEditorIndex(null)} />
|
||||
|
||||
}
|
||||
{headerAddressEditor &&
|
||||
<HeaderAddressEditorDialog
|
||||
@@ -520,7 +607,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
columns={headerAddressEditor.columns}
|
||||
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
|
||||
locked={locked}
|
||||
onAddressesChange={updateHeaderAddressList}
|
||||
onSave={saveHeaderAddresses}
|
||||
onClose={() => setHeaderAddressEditor(null)} />
|
||||
|
||||
}
|
||||
@@ -534,13 +621,12 @@ type RecipientAddressEditorDialogProps = {
|
||||
locked: boolean;
|
||||
recipientsSection: Record<string, unknown>;
|
||||
entryDefaults: Record<string, unknown>;
|
||||
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
|
||||
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
|
||||
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
|
||||
onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
|
||||
type EntryAddressMergeValues = Partial<Record<NonNullable<EntryAddressColumn["mergeKey"]>, boolean>>;
|
||||
|
||||
type AddressHeaderControlProps = {
|
||||
columns: EntryAddressColumn[];
|
||||
@@ -596,26 +682,33 @@ type HeaderAddressEditorDialogProps = {
|
||||
columns: EntryAddressColumn[];
|
||||
values: HeaderAddressValues;
|
||||
locked: boolean;
|
||||
onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void;
|
||||
onSave: (values: HeaderAddressValues) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) {
|
||||
function HeaderAddressEditorDialog({ title, columns, values, locked, onSave, onClose }: HeaderAddressEditorDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const columnKeys = new Set(columns.map((column) => column.key));
|
||||
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() => cloneAddressValues(columns, values));
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
|
||||
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
|
||||
if (groups.length === 0) return false;
|
||||
const nextValues: HeaderAddressValues = { ...values };
|
||||
for (const group of groups) {
|
||||
nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]);
|
||||
}
|
||||
for (const group of groups) {
|
||||
onAddressesChange(group.key, nextValues[group.key] ?? []);
|
||||
}
|
||||
setDraftValues((current) => mergeAddressGroups(current, groups));
|
||||
setValidationError("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = prepareAddressValues(columns, draftValues, translateText);
|
||||
if (prepared.error) {
|
||||
setValidationError(prepared.error);
|
||||
return;
|
||||
}
|
||||
onSave(prepared.values);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -623,17 +716,24 @@ function HeaderAddressEditorDialog({ title, columns, values, locked, onAddresses
|
||||
className="recipient-address-editor-modal"
|
||||
bodyClassName="recipient-address-editor-body"
|
||||
onClose={onClose}
|
||||
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
footer={<>
|
||||
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</>}>
|
||||
|
||||
<div className="recipient-address-editor">
|
||||
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
|
||||
{columns.map((column) =>
|
||||
<RecipientAddressCategoryEditor
|
||||
key={column.key}
|
||||
column={column}
|
||||
addresses={values[column.key] ?? []}
|
||||
addresses={draftValues[column.key] ?? []}
|
||||
merge={false}
|
||||
locked={locked}
|
||||
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)}
|
||||
onAddressesChange={(addresses) => {
|
||||
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
|
||||
setValidationError("");
|
||||
}}
|
||||
onPasteAddresses={applyPastedAddresses} />
|
||||
|
||||
)}
|
||||
@@ -647,21 +747,41 @@ function RecipientAddressEditorDialog({
|
||||
locked,
|
||||
recipientsSection,
|
||||
entryDefaults,
|
||||
updateEntryAddressList,
|
||||
updateEntryAddressGroups,
|
||||
updateEntryMerge,
|
||||
onSave,
|
||||
onClose
|
||||
}: RecipientAddressEditorDialogProps) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
|
||||
const availableKeys = new Set(availableColumns.map((column) => column.key));
|
||||
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() =>
|
||||
Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)]))
|
||||
);
|
||||
const [draftMerges, setDraftMerges] = useState<EntryAddressMergeValues>(() =>
|
||||
Object.fromEntries(
|
||||
availableColumns
|
||||
.filter((column) => column.mergeKey)
|
||||
.map((column) => [column.mergeKey!, getEntryMerge(entry, entryDefaults, column.mergeKey!)])
|
||||
)
|
||||
);
|
||||
const [validationError, setValidationError] = useState("");
|
||||
|
||||
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
|
||||
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
|
||||
if (groups.length === 0) return false;
|
||||
updateEntryAddressGroups(index, groups);
|
||||
setDraftValues((current) => mergeAddressGroups(current, groups));
|
||||
setValidationError("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = prepareAddressValues(availableColumns, draftValues, translateText);
|
||||
if (prepared.error) {
|
||||
setValidationError(prepared.error);
|
||||
return;
|
||||
}
|
||||
onSave(prepared.values, draftMerges);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
@@ -669,9 +789,13 @@ function RecipientAddressEditorDialog({
|
||||
className="recipient-address-editor-modal"
|
||||
bodyClassName="recipient-address-editor-body"
|
||||
onClose={onClose}
|
||||
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
footer={<>
|
||||
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
|
||||
</>}>
|
||||
|
||||
<div className="recipient-address-editor">
|
||||
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
|
||||
{availableColumns.length === 0 &&
|
||||
<p className="muted">No individual recipient address fields are enabled.</p>
|
||||
}
|
||||
@@ -679,11 +803,16 @@ function RecipientAddressEditorDialog({
|
||||
<RecipientAddressCategoryEditor
|
||||
key={column.key}
|
||||
column={column}
|
||||
addresses={getEntryAddresses(entry, column.key)}
|
||||
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false}
|
||||
addresses={draftValues[column.key] ?? []}
|
||||
merge={column.mergeKey ? Boolean(draftMerges[column.mergeKey]) : false}
|
||||
locked={locked}
|
||||
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
|
||||
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined}
|
||||
onAddressesChange={(addresses) => {
|
||||
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
|
||||
setValidationError("");
|
||||
}}
|
||||
onMergeChange={column.mergeKey ? (merge) => {
|
||||
setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge }));
|
||||
} : undefined}
|
||||
onPasteAddresses={applyPastedAddresses} />
|
||||
|
||||
)}
|
||||
@@ -716,7 +845,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
|
||||
function commitAddresses(nextAddresses: MailboxAddress[]) {
|
||||
setDraftAddresses(nextAddresses);
|
||||
onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim()));
|
||||
onAddressesChange(nextAddresses);
|
||||
}
|
||||
|
||||
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
|
||||
@@ -731,7 +860,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
{ name: "", email: "" },
|
||||
...draftAddresses.slice(insertIndex)];
|
||||
|
||||
setDraftAddresses(nextAddresses);
|
||||
commitAddresses(nextAddresses);
|
||||
}
|
||||
|
||||
function removeAddress(addressIndex: number) {
|
||||
@@ -816,242 +945,6 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
|
||||
|
||||
}
|
||||
|
||||
type AddressSourceImportDialogProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
|
||||
};
|
||||
|
||||
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
|
||||
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
|
||||
}
|
||||
|
||||
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
|
||||
if (addressSourceType(source) === "list") return "Address list";
|
||||
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
|
||||
return "Address book";
|
||||
}
|
||||
|
||||
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
const scopeType = String(provenance.scope_type ?? "").trim();
|
||||
const scopeId = String(provenance.scope_id ?? "").trim();
|
||||
if (!scopeType) return "Unknown scope";
|
||||
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
|
||||
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
|
||||
}
|
||||
|
||||
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
return [
|
||||
source.source_label,
|
||||
source.source_kind,
|
||||
source.source_id,
|
||||
addressSourceTypeLabel(source),
|
||||
addressSourceScopeLabel(source),
|
||||
provenance.address_book_id,
|
||||
provenance.address_list_id,
|
||||
provenance.scope_type,
|
||||
provenance.scope_id,
|
||||
provenance.tenant_id
|
||||
].map((value) => String(value ?? "").toLowerCase()).join(" ");
|
||||
}
|
||||
|
||||
function shortSourceRevision(value: string): string {
|
||||
if (!value) return "no revision";
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 19)}...`;
|
||||
}
|
||||
|
||||
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
|
||||
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const sourceCounts = useMemo(() => {
|
||||
return sources.reduce(
|
||||
(counts, source) => {
|
||||
counts[addressSourceType(source)] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ book: 0, list: 0 }
|
||||
);
|
||||
}, [sources]);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
return sources.filter((source) => {
|
||||
const type = addressSourceType(source);
|
||||
if (sourceFilter === "books" && type !== "book") return false;
|
||||
if (sourceFilter === "lists" && type !== "list") return false;
|
||||
if (!query) return true;
|
||||
return addressSourceSearchText(source).includes(query);
|
||||
});
|
||||
}, [sourceFilter, sourceQuery, sources]);
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
|
||||
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
|
||||
setSourceFilter("all");
|
||||
setSourceQuery("");
|
||||
setSelectedSourceId(initialSourceId);
|
||||
}
|
||||
}, [initialSourceId, sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
|
||||
then((nextSnapshot) => {
|
||||
if (!cancelled) setSnapshot(nextSnapshot);
|
||||
}).
|
||||
catch((err) => {
|
||||
if (cancelled) return;
|
||||
setSnapshot(null);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}).
|
||||
finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {cancelled = true;};
|
||||
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
function openAddressBookModule() {
|
||||
onCancel();
|
||||
navigate("/address-book");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import recipients from addresses"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
|
||||
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
|
||||
Import recipients
|
||||
</Button>
|
||||
</>
|
||||
}>
|
||||
|
||||
<div className="address-source-import-controls">
|
||||
<div>
|
||||
<SegmentedControl
|
||||
ariaLabel="Address source type"
|
||||
value={sourceFilter}
|
||||
onChange={setSourceFilter}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading || sources.length === 0}
|
||||
options={[
|
||||
{ id: "all", label: `All (${sources.length})` },
|
||||
{ id: "books", label: `Books (${sourceCounts.book})` },
|
||||
{ id: "lists", label: `Lists (${sourceCounts.list})` }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search address sources"
|
||||
aria-label="Search address sources"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={openAddressBookModule}>
|
||||
Manage address books
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
|
||||
{filteredSources.map((source) =>
|
||||
<button
|
||||
type="button"
|
||||
key={source.source_id}
|
||||
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.source_id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.source_id)}>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.source_label}</strong>
|
||||
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.recipient_count} recipients</span>
|
||||
<code>{shortSourceRevision(source.source_revision)}</code>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{sources.length > 0 && filteredSources.length === 0 &&
|
||||
<div className="empty-state compact-empty">No address sources match the current filter.</div>
|
||||
}
|
||||
</div>
|
||||
<FormField label="Import mode">
|
||||
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
|
||||
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
|
||||
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
|
||||
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
|
||||
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
|
||||
|
||||
{snapshot &&
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
|
||||
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
|
||||
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
|
||||
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
|
||||
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
|
||||
</dl>
|
||||
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
|
||||
</>
|
||||
}
|
||||
</Dialog>);
|
||||
}
|
||||
|
||||
type AddressSourceSnapshotRecipient = CampaignRecipientAddressSourceSnapshot["recipients"][number];
|
||||
|
||||
function AddressSourceRecipientPreviewGrid({ recipients }: {recipients: AddressSourceSnapshotRecipient[];}) {
|
||||
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
|
||||
{ id: "row", header: "#", width: 64, value: (_recipient, index) => index + 1, render: (_recipient, index) => index + 1 },
|
||||
{ id: "name", header: "Name", width: "minmax(180px, 1fr)", minWidth: 160, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.display_name },
|
||||
{ id: "email", header: "Email", width: "minmax(220px, 1.2fr)", minWidth: 190, resizable: true, sortable: true, filterable: true, value: (recipient) => recipient.email },
|
||||
{ id: "fields", header: "Fields", width: 100, sortable: true, value: (recipient) => Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length }
|
||||
];
|
||||
return <DataGrid id="campaign-address-source-import-preview" rows={recipients} columns={columns} getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`} />;
|
||||
}
|
||||
|
||||
type RecipientImportDialogProps = {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
@@ -1825,141 +1718,6 @@ function formatImportBytes(value?: number | null): string {
|
||||
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
|
||||
}
|
||||
|
||||
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
|
||||
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
|
||||
const tableRows = [
|
||||
headers,
|
||||
...snapshot.recipients.map((recipient) => headers.map((header) => {
|
||||
if (header === "display_name") return recipient.display_name;
|
||||
if (header === "email") return recipient.email;
|
||||
return fieldValueToString((recipient.fields ?? {})[header]);
|
||||
}))];
|
||||
|
||||
const table: RecipientImportTable = {
|
||||
delimiter: ",",
|
||||
headers,
|
||||
headerRows: [headers],
|
||||
rows: tableRows,
|
||||
dataRows: tableRows.slice(1),
|
||||
firstDataRowNumber: 2
|
||||
};
|
||||
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
const fieldNamesToCreate = new Set<string>();
|
||||
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
|
||||
const email = recipient.email.trim();
|
||||
const name = recipient.display_name.trim();
|
||||
const fields = stringFieldsFromAddressSource(recipient.fields);
|
||||
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
|
||||
const id = uniqueImportId(
|
||||
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
|
||||
usedIds
|
||||
);
|
||||
const issues: string[] = [];
|
||||
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
|
||||
return {
|
||||
rowNumber: table.firstDataRowNumber + index,
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
active: true,
|
||||
addresses: {
|
||||
from: [],
|
||||
to: email ? [{ name, email }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
reply_to: []
|
||||
},
|
||||
fields,
|
||||
patterns: [],
|
||||
issues
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
table,
|
||||
rows,
|
||||
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
|
||||
validCount: rows.filter((row) => row.issues.length === 0).length,
|
||||
invalidCount: rows.filter((row) => row.issues.length > 0).length,
|
||||
patternCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
function createAddressSourceImportProvenance(
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
preview: RecipientImportPreview,
|
||||
mode: RecipientImportMode)
|
||||
: RecipientImportProvenance {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
|
||||
imported_at: now,
|
||||
mode,
|
||||
source_type: "addresses",
|
||||
source_id: snapshot.source_id,
|
||||
source_label: snapshot.source_label,
|
||||
source_revision: snapshot.source_revision,
|
||||
source_provenance: snapshot.provenance,
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
delimiter: null,
|
||||
header_rows: 0,
|
||||
quoted: null,
|
||||
value_separators: null,
|
||||
rows_total: preview.rows.length,
|
||||
valid_rows: preview.validCount,
|
||||
invalid_rows: preview.invalidCount,
|
||||
imported_rows: preview.validCount,
|
||||
field_names_created: preview.fieldNamesToCreate.slice(),
|
||||
attachment_patterns: 0,
|
||||
mapping: []
|
||||
};
|
||||
}
|
||||
|
||||
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value ?? {})) {
|
||||
const fieldValue = fieldValueToString(rawValue);
|
||||
if (fieldValue) fields[key] = fieldValue;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function fieldValueToString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function idFragmentFromEmail(value: string): string {
|
||||
return safeImportIdFragment(value.split("@")[0] ?? "");
|
||||
}
|
||||
|
||||
function safeImportIdFragment(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
|
||||
}
|
||||
|
||||
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
|
||||
const base = safeImportIdFragment(preferred) || "recipient";
|
||||
let candidate = base;
|
||||
let counter = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
||||
function suggestImportFieldName(value: string): string {
|
||||
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return cleaned || "field";
|
||||
@@ -1971,12 +1729,15 @@ type RecipientProfileColumnContext = {
|
||||
draft: Record<string, unknown>;
|
||||
locked: boolean;
|
||||
filesModuleInstalled: boolean;
|
||||
postboxModuleInstalled: boolean;
|
||||
postboxCatalog: CampaignPostboxCatalog;
|
||||
entries: Record<string, unknown>[];
|
||||
fieldDefinitions: ReturnType<typeof getDraftFields>;
|
||||
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
|
||||
zipConfig: AttachmentZipCollection;
|
||||
translateText: (value: string) => string;
|
||||
openAddressEditor: (index: number) => void;
|
||||
openPostboxTargetEditor: (index: number) => void;
|
||||
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
|
||||
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
|
||||
updateEntryField: (index: number, field: string, value: unknown) => void;
|
||||
@@ -1985,7 +1746,7 @@ type RecipientProfileColumnContext = {
|
||||
removeEntry: (index: number) => void;
|
||||
};
|
||||
|
||||
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
|
||||
return [
|
||||
{
|
||||
id: "number",
|
||||
@@ -2034,6 +1795,45 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
|
||||
value: recipientAddressFilterValue
|
||||
},
|
||||
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
|
||||
...(postboxModuleInstalled ? [{
|
||||
id: "delivery",
|
||||
header: "Delivery",
|
||||
width: "minmax(260px, 0.9fr)",
|
||||
resizable: true,
|
||||
filterable: true,
|
||||
render: (entry, index) => {
|
||||
const targets = normalizePostboxTargets(entry.postbox_targets);
|
||||
return (
|
||||
<div className="campaign-recipient-delivery-cell">
|
||||
<select
|
||||
value={typeof entry.channel_policy === "string" ? entry.channel_policy : ""}
|
||||
disabled={locked}
|
||||
aria-label={`Recipient ${index + 1} delivery policy`}
|
||||
onChange={(event) => updateEntry(index, (current) => {
|
||||
const next = { ...current };
|
||||
if (event.target.value) next.channel_policy = event.target.value;
|
||||
else delete next.channel_policy;
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
<option value="">Campaign default</option>
|
||||
<option value="mail">Mail</option>
|
||||
<option value="postbox">Postbox</option>
|
||||
<option value="mail_and_postbox">Mail and Postbox</option>
|
||||
<option value="mail_then_postbox">Mail, then Postbox fallback</option>
|
||||
<option value="postbox_then_mail">Postbox, then Mail fallback</option>
|
||||
</select>
|
||||
<Button
|
||||
disabled={locked || !postboxCatalog.available}
|
||||
onClick={() => openPostboxTargetEditor(index)}
|
||||
>
|
||||
Postboxes ({targets.length})
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
value: (entry) => `${String(entry.channel_policy ?? "default")} ${normalizePostboxTargets(entry.postbox_targets).map((target) => target.label ?? target.postbox_id ?? target.template_id ?? "").join(" ")}`
|
||||
} as DataGridColumn<Record<string, unknown>>] : []),
|
||||
...(individualAttachmentBasePaths.length > 0 ? [{
|
||||
id: "attachments",
|
||||
header: "i18n:govoplan-campaign.attachments.6771ade6",
|
||||
@@ -2147,9 +1947,59 @@ function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
|
||||
return column;
|
||||
}
|
||||
|
||||
function cloneMailboxAddress(address: MailboxAddress): MailboxAddress {
|
||||
return { name: address.name ?? "", email: address.email ?? "" };
|
||||
}
|
||||
|
||||
function cloneAddressValues(columns: EntryAddressColumn[], values: HeaderAddressValues): HeaderAddressValues {
|
||||
return Object.fromEntries(
|
||||
columns.map((column) => [column.key, (values[column.key] ?? []).map(cloneMailboxAddress)])
|
||||
) as HeaderAddressValues;
|
||||
}
|
||||
|
||||
function mergeAddressGroups(
|
||||
values: HeaderAddressValues,
|
||||
groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>
|
||||
): HeaderAddressValues {
|
||||
const nextValues = cloneAddressValues(recipientAddressOverlayColumns, values);
|
||||
for (const group of groups) {
|
||||
nextValues[group.key] = dedupeAddresses([
|
||||
...(nextValues[group.key] ?? []),
|
||||
...group.addresses.map(cloneMailboxAddress)
|
||||
]);
|
||||
}
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function prepareAddressValues(
|
||||
columns: EntryAddressColumn[],
|
||||
values: HeaderAddressValues,
|
||||
translateText: (value: string) => string
|
||||
): {values: HeaderAddressValues;error: string;} {
|
||||
const prepared: HeaderAddressValues = {};
|
||||
for (const column of columns) {
|
||||
const addresses: MailboxAddress[] = [];
|
||||
for (const address of values[column.key] ?? []) {
|
||||
const name = String(address.name ?? "").trim();
|
||||
const email = String(address.email ?? "").trim();
|
||||
if (!name && !email) continue;
|
||||
if (!email) {
|
||||
return {
|
||||
values: prepared,
|
||||
error: `${translateText(column.label)}: enter an email address or remove the incomplete row.`
|
||||
};
|
||||
}
|
||||
addresses.push({ name, email });
|
||||
}
|
||||
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
|
||||
}
|
||||
return { values: prepared, error: "" };
|
||||
}
|
||||
|
||||
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
if (column.allowMultiple) return addresses;
|
||||
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }];
|
||||
const rows = addresses.map(cloneMailboxAddress);
|
||||
if (column.allowMultiple) return rows;
|
||||
return rows.length > 0 ? rows.slice(0, 1) : [{ name: "", email: "" }];
|
||||
}
|
||||
|
||||
function formatMailboxAddress(address: MailboxAddress): string {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FlaskConical,
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
PackageCheck,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
X,
|
||||
type LucideIcon } from
|
||||
ShieldCheck } from
|
||||
"lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
@@ -34,25 +29,22 @@ import {
|
||||
sendCampaignNow,
|
||||
updateCampaignReviewState,
|
||||
validateVersion,
|
||||
type CampaignAttachmentPreviewFile,
|
||||
type CampaignAttachmentPreviewResponse,
|
||||
type CampaignDeliveryOptions,
|
||||
type CampaignJobsQuery,
|
||||
type CampaignJobsResponse,
|
||||
type CampaignSummary,
|
||||
type CampaignVersionDetail } from
|
||||
type CampaignSummary } from
|
||||
"../../api/campaigns";
|
||||
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
||||
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";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
|
||||
import { i18nMessage } from "@govoplan/core-webui";
|
||||
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
||||
import LockedVersionNotice from "./components/LockedVersionNotice";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
@@ -71,53 +63,41 @@ import {
|
||||
stringifyPreview } from
|
||||
"./utils/campaignView";
|
||||
import { deliveryModeLabel } from "./utils/deliveryMode";
|
||||
import { getBool, getText } from "./utils/draftEditor";
|
||||
import { attachmentPreviewLinkableFiles, attachmentPreviewMatchedFiles } from "./utils/attachmentPreview";
|
||||
import { getText } from "./utils/draftEditor";
|
||||
import { attachmentPreviewLinkableFiles } from "./utils/attachmentPreview";
|
||||
import { emptyCampaignJobsResponse, mergeCampaignJobsDelta } from "./utils/jobDeltas";
|
||||
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders";
|
||||
|
||||
type FlowState =
|
||||
"complete" |
|
||||
"warning" |
|
||||
"danger" |
|
||||
"active" |
|
||||
"locked" |
|
||||
"running" |
|
||||
"partial" |
|
||||
"stale" |
|
||||
"pending";
|
||||
|
||||
type FlowStageDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
state: FlowState;
|
||||
connectorState?: FlowState;
|
||||
stateLabel: string;
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
type DeliverabilityPreflightItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
state: "ready" | "warning" | "blocked" | "info";
|
||||
};
|
||||
import AttachmentLinkingPreview from "./review/AttachmentLinkingPreview";
|
||||
import DeliverabilityPreflight, {
|
||||
type DeliverabilityPreflightItem
|
||||
} from "./review/DeliverabilityPreflight";
|
||||
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
|
||||
import BuiltMessagePreview from "./review/BuiltMessagePreview";
|
||||
import {
|
||||
WorkflowFact,
|
||||
WorkflowNavigation,
|
||||
WorkflowStage,
|
||||
stageConnectorState,
|
||||
stateLabel,
|
||||
type FlowStageDefinition,
|
||||
type FlowState
|
||||
} from "./review/WorkflowNavigation";
|
||||
import {
|
||||
builtMessageKey,
|
||||
filterAndSortBuiltMessageRows,
|
||||
findBuiltMessageIndex,
|
||||
messageNeedsExplicitReview,
|
||||
reviewQueryEquals,
|
||||
sameBuiltMessage,
|
||||
storedMessageReviewState
|
||||
} from "./review/builtMessageQuery";
|
||||
import {
|
||||
countResolvedAttachments,
|
||||
formatAddressList,
|
||||
numberFrom
|
||||
} from "./review/reviewFormatters";
|
||||
|
||||
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "queue" | "control" | "retry" | "imap" | "";
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
warning: "var(--amber)",
|
||||
danger: "var(--red)",
|
||||
active: "var(--blue)",
|
||||
locked: "var(--line-dark)",
|
||||
running: "var(--blue)",
|
||||
partial: "var(--review-flow-partial)",
|
||||
stale: "var(--review-flow-partial)",
|
||||
pending: "var(--muted)"
|
||||
};
|
||||
|
||||
const MESSAGE_VALIDATION_OPTIONS: DataGridListOption[] = [
|
||||
{ value: "ready", label: "i18n:govoplan-campaign.ready.20c7c552" },
|
||||
@@ -1778,409 +1758,6 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
|
||||
|
||||
}
|
||||
|
||||
function WorkflowNavigation({ stages, onSelect }: {stages: FlowStageDefinition[];onSelect: (id: string) => void;}) {
|
||||
return (
|
||||
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
|
||||
<div className="review-flow-navigation-track">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stage.icon;
|
||||
const nextStage = stages[index + 1];
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
|
||||
const title = i18nMessage(stage.title);
|
||||
const shortTitle = i18nMessage(stage.shortTitle);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const style = {
|
||||
"--review-nav-color": stateColors[stage.state],
|
||||
"--review-nav-line-color": stateColors[connectorState],
|
||||
"--review-nav-line-next-color": stateColors[nextConnectorState]
|
||||
} as CSSProperties;
|
||||
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
||||
return (
|
||||
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-navigation-item"
|
||||
data-state={stage.state}
|
||||
onClick={() => onSelect(stage.id)}
|
||||
title={`${title}: ${stateText}`}>
|
||||
|
||||
<span className="review-flow-navigation-icon"><Icon size={17} strokeWidth={1.8} aria-hidden="true" /></span>
|
||||
<span className="review-flow-navigation-copy">
|
||||
<strong>
|
||||
<span>{shortTitle}</span>
|
||||
{stage.state === "locked" && <LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />}
|
||||
</strong>
|
||||
{showSecondaryState && <small>{stateText}</small>}
|
||||
</span>
|
||||
</button>
|
||||
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
||||
</div>);
|
||||
|
||||
})}
|
||||
</div>
|
||||
</nav>);
|
||||
|
||||
}
|
||||
|
||||
function stageConnectorState(stage: FlowStageDefinition): FlowState {
|
||||
return stage.connectorState ?? stage.state;
|
||||
}
|
||||
|
||||
function WorkflowStage({
|
||||
stage,
|
||||
nextState,
|
||||
nextConnectorState,
|
||||
children
|
||||
|
||||
|
||||
|
||||
|
||||
}: {stage: FlowStageDefinition;nextState?: FlowState;nextConnectorState?: FlowState;children: ReactNode;}) {
|
||||
const Icon = stage.icon;
|
||||
const locked = stage.state === "locked";
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const title = i18nMessage(stage.title);
|
||||
const description = i18nMessage(stage.description);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const style = {
|
||||
"--review-stage-color": stateColors[stage.state],
|
||||
"--review-stage-line-color": stateColors[connectorState],
|
||||
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
|
||||
<div className="review-flow-stage-marker" aria-hidden="true">
|
||||
<div className="review-flow-stage-node"><Icon size={20} strokeWidth={1.8} /></div>
|
||||
{nextState && <div className="review-flow-stage-line" />}
|
||||
</div>
|
||||
<article className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`} aria-disabled={locked || undefined}>
|
||||
<header className="card-header review-flow-stage-header">
|
||||
<h2>
|
||||
<span>{title}</span>
|
||||
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="i18n:govoplan-campaign.locked.a798882f" />}
|
||||
{!locked &&
|
||||
<span
|
||||
className="review-flow-state-badge"
|
||||
data-state={stage.state}
|
||||
aria-label={stateText}
|
||||
title={stateText}>
|
||||
|
||||
{stateText}
|
||||
</span>
|
||||
}
|
||||
<InlineHelp>{description}</InlineHelp>
|
||||
</h2>
|
||||
<div className="review-flow-stage-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title }) : i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })}
|
||||
title={collapsed ? "i18n:govoplan-campaign.show_content.0528d8d2" : "i18n:govoplan-campaign.show_header_only.24afefca"}
|
||||
onClick={() => setCollapsed((value) => !value)}>
|
||||
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{!collapsed &&
|
||||
<>
|
||||
<div className="card-body review-flow-stage-content">{children}</div>
|
||||
{locked &&
|
||||
<div className="review-flow-lock-message">
|
||||
<span className="review-flow-lock-icon"><LockKeyhole size={20} aria-hidden="true" /></span>
|
||||
<span>{lockReason}</span>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
}
|
||||
</article>
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
function AttachmentLinkingPreview({
|
||||
preview,
|
||||
loading,
|
||||
error,
|
||||
linking,
|
||||
disabled,
|
||||
onRefresh,
|
||||
onLink
|
||||
}: {
|
||||
preview: CampaignAttachmentPreviewResponse | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
linking: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const matchedFiles = attachmentPreviewMatchedFiles(preview);
|
||||
const linkableFiles = attachmentPreviewLinkableFiles(preview);
|
||||
const linkedCount = preview?.linked_file_count ?? matchedFiles.filter((file) => file.linked_to_campaign !== false).length;
|
||||
const matchedCount = preview?.matched_file_count ?? matchedFiles.length;
|
||||
const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length;
|
||||
return (
|
||||
<>
|
||||
<div className="review-flow-data-section attachment-linking-preview">
|
||||
<div className="attachment-linking-header">
|
||||
<div>
|
||||
<h3>i18n:govoplan-campaign.attachment_file_links.0be74fd1</h3>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onRefresh} disabled={loading || linking}>{loading ? "i18n:govoplan-campaign.refreshing.505dddc9" : "i18n:govoplan-campaign.refresh.56e3badc"}</Button>
|
||||
<Button variant="primary" onClick={onLink} disabled={disabled || loading || linking || unlinkedCount === 0}>
|
||||
{linking ?
|
||||
"i18n:govoplan-campaign.linking.a5f54e0f" :
|
||||
i18nMessage(
|
||||
unlinkedCount === 1 ? "i18n:govoplan-campaign.link_value_file.4d4ce740" : "i18n:govoplan-campaign.link_value_files.88b7e6a7",
|
||||
{ value0: unlinkedCount }
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||
value={!loading && matchedFiles.length > 0 ?
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-fact-detail-button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={detailsOpen}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951", { value0: matchedFiles.length })}
|
||||
onClick={() => setDetailsOpen(true)}>
|
||||
{matchedCount}
|
||||
</button> :
|
||||
loading ? "..." : matchedCount || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.linked.a089f600" value={loading ? "..." : linkedCount || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.need_link.fa4ab530" value={loading ? "..." : unlinkedCount || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.shared_source.7d4a1bf2" value={loading ? "..." : preview?.shared_file_count ?? "—"} />
|
||||
</div>
|
||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
||||
{!error && unlinkedCount > 0 &&
|
||||
<p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998</p>
|
||||
}
|
||||
{!error && !loading && matchedFiles.length === 0 &&
|
||||
<p className="muted small-note">i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c</p>
|
||||
}
|
||||
{matchedFiles.length > 0 &&
|
||||
<AttachmentLinkingFileList files={matchedFiles} compact />
|
||||
}
|
||||
</div>
|
||||
<Dialog
|
||||
open={detailsOpen}
|
||||
title={i18nMessage("i18n:govoplan-campaign.attachment_file_links_value.ce230e30", { value0: matchedFiles.length })}
|
||||
className="attachment-linking-detail-modal"
|
||||
bodyClassName="attachment-linking-detail-body"
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
footer={<Button variant="primary" onClick={() => setDetailsOpen(false)}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
<p className="muted small-note">i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5</p>
|
||||
<AttachmentLinkingFileList files={matchedFiles} />
|
||||
</Dialog>
|
||||
</>);
|
||||
}
|
||||
|
||||
function AttachmentLinkingFileList({ files, compact = false }: {files: CampaignAttachmentPreviewFile[];compact?: boolean;}) {
|
||||
return (
|
||||
<ul
|
||||
className={`attachment-linking-file-list${compact ? " is-compact" : " is-detail"}`}
|
||||
tabIndex={0}
|
||||
aria-label={i18nMessage(
|
||||
files.length === 1 ? "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824" : "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a",
|
||||
{ value0: files.length }
|
||||
)}>
|
||||
{files.map((file, index) => {
|
||||
const linked = file.linked_to_campaign !== false;
|
||||
const Icon = linked ? Link2 : X;
|
||||
return (
|
||||
<li key={`${file.id || file.display_path || file.filename}:${index}`} data-linked={linked ? "true" : "false"}>
|
||||
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{file.filename || file.display_path}</strong>
|
||||
<small>{linked ? "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc" : "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433"}</small>
|
||||
{file.display_path && file.display_path !== file.filename && <small className="muted">{file.display_path}</small>}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>);
|
||||
}
|
||||
|
||||
function DeliverabilityPreflight({ items }: {items: DeliverabilityPreflightItem[];}) {
|
||||
return (
|
||||
<section className="deliverability-preflight" aria-label="Deliverability preflight">
|
||||
<div className="deliverability-preflight-header">
|
||||
<h3>Deliverability preflight</h3>
|
||||
<span className="muted small-note">Operator checks before the first live send.</span>
|
||||
</div>
|
||||
<div className="deliverability-preflight-grid">
|
||||
{items.map((item) =>
|
||||
<div key={item.label} className="deliverability-preflight-item" data-state={item.state}>
|
||||
<div>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.detail}</strong>
|
||||
</div>
|
||||
<StatusBadge status={item.state === "blocked" ? "failed" : item.state === "warning" ? "warning" : item.state === "ready" ? "ready" : "info"} label={humanize(item.state)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>);
|
||||
}
|
||||
|
||||
function BuiltMessagePreview({
|
||||
campaignJson,
|
||||
entries,
|
||||
rows,
|
||||
index,
|
||||
canStartSingleMessageSend,
|
||||
singleMessageSendBusy,
|
||||
onSelect,
|
||||
onSendSingle,
|
||||
onClose
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}: {campaignJson: Record<string, unknown>;entries: Record<string, unknown>[];rows: Record<string, unknown>[];index: number;canStartSingleMessageSend: boolean;singleMessageSendBusy: boolean;onSelect: (index: number) => void;onSendSingle: (index: number) => void;onClose: () => void;}) {
|
||||
const row = rows[index] ?? {};
|
||||
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
|
||||
const entry = entries[entryIndex] ?? {};
|
||||
const template = asRecord(campaignJson.template);
|
||||
const context = buildTemplatePreviewContext(campaignJson, entry);
|
||||
const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false);
|
||||
const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields);
|
||||
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
|
||||
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "i18n:govoplan-campaign.no_subject.7b4e8035");
|
||||
const issues = asArray(row.issues).map(asRecord);
|
||||
const resolvedRecipients = asRecord(row.resolved_recipients);
|
||||
const singleSendDisabledReason = singleMessageSendDisabledReason(row, canStartSingleMessageSend);
|
||||
|
||||
return (
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
|
||||
subject={subject}
|
||||
bodyMode={html.trim() ? "html" : "text"}
|
||||
text={text}
|
||||
html={html}
|
||||
recipientLabel={formatAddressList(resolvedRecipients.to) || String(row.recipient_email ?? `Message ${index + 1}`)}
|
||||
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "i18n:govoplan-campaign.issue.73781a12")).join(" · ")}` : "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"}
|
||||
metaItems={builtMessageMetaItems(row)}
|
||||
attachments={builtMessageAttachments(row)}
|
||||
navigation={{
|
||||
index,
|
||||
total: rows.length,
|
||||
onFirst: () => onSelect(0),
|
||||
onPrevious: () => onSelect(Math.max(0, index - 1)),
|
||||
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
|
||||
onLast: () => onSelect(rows.length - 1)
|
||||
}}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||
title={singleSendDisabledReason || "Send only this built message"}
|
||||
onClick={() => onSendSingle(index)}>
|
||||
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose} />);
|
||||
|
||||
|
||||
}
|
||||
|
||||
function singleMessageSendDisabledReason(row: Record<string, unknown>, canStartSingleMessageSend: boolean): string {
|
||||
if (!canStartSingleMessageSend) {
|
||||
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
|
||||
}
|
||||
const jobId = String(row.id ?? "");
|
||||
if (!jobId) return "This preview row has no delivery job id.";
|
||||
const buildStatus = String(row.build_status ?? "");
|
||||
if (buildStatus !== "built") return "This message has not been built.";
|
||||
const validationStatus = String(row.validation_status ?? "");
|
||||
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
|
||||
return `This message is ${humanize(validationStatus)}.`;
|
||||
}
|
||||
const sendStatus = String(row.send_status ?? "not_queued");
|
||||
const queueStatus = String(row.queue_status ?? "draft");
|
||||
if (["smtp_accepted", "sent"].includes(sendStatus)) return "This message was already accepted by SMTP.";
|
||||
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) return `This message is in delivery state ${humanize(sendStatus)}.`;
|
||||
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) return "Use the explicit retry action for failed messages.";
|
||||
if (sendStatus === "queued" && queueStatus === "queued") return "";
|
||||
if (["not_queued", "cancelled"].includes(sendStatus) && ["draft", "cancelled"].includes(queueStatus)) return "";
|
||||
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
|
||||
}
|
||||
|
||||
function DeliveryJobDetailOverlay({ detail, onClose }: {detail: Record<string, unknown>;onClose: () => void;}) {
|
||||
const job = asRecord(detail.job);
|
||||
const attempts = asRecord(detail.attempts);
|
||||
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
|
||||
const imapAttempts = asArray(attempts.imap).map(asRecord);
|
||||
const issues = asArray(job.issues).map(asRecord);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
|
||||
className="template-preview-modal"
|
||||
onClose={onClose}
|
||||
footer={<Button variant="primary" onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
|
||||
|
||||
<dl className="detail-list">
|
||||
<div><dt>i18n:govoplan-campaign.recipient.90343260</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.subject.8d183dbd</dt><dd>{String(job.subject ?? "-")}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
|
||||
<div><dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
|
||||
{job.last_error ? <div><dt>i18n:govoplan-campaign.last_error.5e4df866</dt><dd>{String(job.last_error)}</dd></div> : null}
|
||||
</dl>
|
||||
{issues.length > 0 && <AttemptList title="i18n:govoplan-campaign.message_issues.9092a7db" rows={issues} />}
|
||||
<AttemptList title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6" rows={smtpAttempts} emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d" />
|
||||
<AttemptList title="i18n:govoplan-campaign.imap_attempts.e815f0c2" rows={imapAttempts} emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e" />
|
||||
</Dialog>);
|
||||
|
||||
}
|
||||
|
||||
function AttemptList({ title, rows, emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75" }: {title: string;rows: Record<string, unknown>[];emptyText?: string;}) {
|
||||
return (
|
||||
<section className="review-flow-data-section">
|
||||
<h3>{title}</h3>
|
||||
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> :
|
||||
<dl className="detail-list">
|
||||
{rows.map((row, index) =>
|
||||
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
||||
<dt>{String(row.status ?? row.severity ?? row.code ?? i18nMessage("i18n:govoplan-campaign.value.44b8c76f", { value0: index + 1 }))}</dt>
|
||||
<dd>
|
||||
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
|
||||
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
|
||||
{row.finished_at || row.updated_at ? <span className="muted"> → {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
}
|
||||
</section>);
|
||||
|
||||
}
|
||||
|
||||
function WorkflowFact({ label, value }: {label: string;value: ReactNode;}) {
|
||||
return (
|
||||
<div className="review-flow-fact">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>);
|
||||
|
||||
}
|
||||
|
||||
function builtMessageColumns(
|
||||
openMessage: (row: Record<string, unknown>) => void,
|
||||
reviewedKeys: Set<string>)
|
||||
@@ -2205,144 +1782,6 @@ reviewedKeys: Set<string>)
|
||||
|
||||
}
|
||||
|
||||
type ReviewFilterType = "text" | "integer" | "list";
|
||||
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||
|
||||
function filterAndSortBuiltMessageRows(
|
||||
rows: Record<string, unknown>[],
|
||||
query: DataGridQueryState,
|
||||
reviewedKeys: Set<string>)
|
||||
: Record<string, unknown>[] {
|
||||
const filters = query.filters ?? {};
|
||||
const filtered = rows.filter((row, rowIndex) =>
|
||||
Object.entries(filters).every(([columnId, filterValue]) => {
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
||||
if (!filterValue.trim()) return true;
|
||||
return matchesReviewFilter(
|
||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
|
||||
filterValue,
|
||||
builtMessageFilterType(columnId)
|
||||
);
|
||||
})
|
||||
);
|
||||
if (!query.sort) return filtered;
|
||||
const { columnId, direction } = query.sort;
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return filtered;
|
||||
return [...filtered].sort((left, right) => {
|
||||
const leftIndex = rows.indexOf(left);
|
||||
const rightIndex = rows.indexOf(right);
|
||||
const result = compareReviewValues(
|
||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
|
||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
|
||||
);
|
||||
return direction === "desc" ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
||||
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
|
||||
}
|
||||
|
||||
function builtMessageColumnValue(
|
||||
columnId: string,
|
||||
row: Record<string, unknown>,
|
||||
index: number,
|
||||
reviewedKeys: Set<string>)
|
||||
: unknown {
|
||||
switch (columnId) {
|
||||
case "number":return Number(row.entry_index ?? index + 1);
|
||||
case "recipient":return formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "—");
|
||||
case "subject":return String(row.subject ?? "—");
|
||||
case "validation":return String(row.validation_status ?? "unknown");
|
||||
case "attachments":return Number(row.attachment_count ?? countResolvedAttachments(row.attachments));
|
||||
case "reviewed":return row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no";
|
||||
default:return "";
|
||||
}
|
||||
}
|
||||
|
||||
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
||||
if (columnId === "number" || columnId === "attachments") return "integer";
|
||||
if (columnId === "validation" || columnId === "reviewed") return "list";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function matchesReviewFilter(value: unknown, filterValue: string, filterType: ReviewFilterType): boolean {
|
||||
if (!filterValue.trim()) return true;
|
||||
if (filterType === "list") {
|
||||
const selected = parseReviewListFilter(filterValue);
|
||||
return selected.includes(stringifyReviewCell(value));
|
||||
}
|
||||
if (filterType === "integer") {
|
||||
const parsed = parseReviewTypedFilter(filterValue);
|
||||
if (!parsed.value.trim()) return true;
|
||||
const actual = parseReviewNumber(value);
|
||||
const expected = Number(parsed.value);
|
||||
if (!Number.isFinite(actual) || !Number.isFinite(expected)) return false;
|
||||
return compareReviewByOperator(actual, expected, parsed.operator);
|
||||
}
|
||||
return stringifyReviewCell(value).toLowerCase().includes(filterValue.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseReviewListFilter(value: string): string[] {
|
||||
if (value.startsWith("list:")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value.slice(5));
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseReviewTypedFilter(value: string): {operator: ReviewFilterOperator;value: string;} {
|
||||
if (!value.includes(":")) return { operator: "eq", value };
|
||||
const [operator, ...parts] = value.split(":");
|
||||
if (operator === "contains" || operator === "eq" || operator === "gt" || operator === "gte" || operator === "lt" || operator === "lte") {
|
||||
return { operator, value: parts.join(":") };
|
||||
}
|
||||
return { operator: "eq", value };
|
||||
}
|
||||
|
||||
function parseReviewNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", ".");
|
||||
if (!text.trim()) return NaN;
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
function compareReviewByOperator(actual: number, expected: number, operator: ReviewFilterOperator): boolean {
|
||||
if (operator === "gt") return actual > expected;
|
||||
if (operator === "gte") return actual >= expected;
|
||||
if (operator === "lt") return actual < expected;
|
||||
if (operator === "lte") return actual <= expected;
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function compareReviewValues(left: unknown, right: unknown): number {
|
||||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||
return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, { numeric: true, sensitivity: "base" });
|
||||
}
|
||||
|
||||
function stringifyReviewCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", ");
|
||||
return "";
|
||||
}
|
||||
|
||||
function reviewQueryEquals(left: DataGridQueryState, right: DataGridQueryState): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const leftFilters = left.filters ?? {};
|
||||
const rightFilters = right.filters ?? {};
|
||||
const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]);
|
||||
for (const key of keys) {
|
||||
if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
|
||||
const options: DataGridListOption[] = [
|
||||
{ value: "sent", label: "i18n:govoplan-campaign.sent.35f49dcf" },
|
||||
@@ -2411,84 +1850,6 @@ function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGri
|
||||
|
||||
}
|
||||
|
||||
function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||
const recipients = asRecord(row.resolved_recipients);
|
||||
return [
|
||||
{ label: "i18n:govoplan-campaign.from.3f66052a", value: formatSingleAddress(recipients.from) || "—" },
|
||||
{ label: "i18n:govoplan-campaign.to.ae79ea1e", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
|
||||
{ label: "i18n:govoplan-campaign.cc.c5a976de", value: formatAddressList(recipients.cc) || null },
|
||||
{ label: "i18n:govoplan-campaign.bcc.4c0145a3", value: formatAddressList(recipients.bcc) || null },
|
||||
{ label: "i18n:govoplan-campaign.validation.dd74d182", value: String(row.validation_status ?? "—") },
|
||||
{ label: "i18n:govoplan-campaign.mime_size.c8b9d519", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" }];
|
||||
|
||||
}
|
||||
|
||||
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
|
||||
return asArray(row.attachments).flatMap((value, index) => {
|
||||
const attachment = asRecord(value);
|
||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||
if (managedMatches.length > 0) {
|
||||
return managedMatches.map((match, matchIndex) => ({
|
||||
filename: String(match.filename ?? match.display_path ?? `Attachment ${index + 1}.${matchIndex + 1}`),
|
||||
detail: String(match.display_path ?? match.relative_path ?? attachment.label ?? ""),
|
||||
contentType: stringOrUndefined(match.content_type),
|
||||
sizeBytes: numberOrUndefined(match.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
|
||||
if (matches.length > 0) {
|
||||
return matches.map((match) => ({
|
||||
filename: match.split(/[\\/]/).pop() || String(attachment.label ?? `Attachment ${index + 1}`),
|
||||
detail: match,
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? attachment.label ?? `Attachment ${index + 1}`),
|
||||
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.file_filter ?? ""),
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: null,
|
||||
archiveLabel: null,
|
||||
protected: false,
|
||||
protectionNote: null
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): {protected: boolean;note: string | null;} {
|
||||
const zipFilename = stringOrUndefined(attachment.zip_filename);
|
||||
if (!zipFilename) return { protected: false, note: null };
|
||||
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
|
||||
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
|
||||
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
|
||||
const scope = rawScope === "global" ? "global" : "local";
|
||||
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "i18n:govoplan-campaign.zipcrypto.03bf7fb4" : "i18n:govoplan-campaign.aes.41f215a6";
|
||||
const source = field ? i18nMessage("i18n:govoplan-campaign.scope_field_value", { value0: humanizeScope(scope), value1: field }) : "";
|
||||
return {
|
||||
protected: true,
|
||||
note: source ?
|
||||
i18nMessage("i18n:govoplan-campaign.value_encryption_value", { value0: source, value1: method }) :
|
||||
i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
||||
};
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global" ? "i18n:govoplan-campaign.global.5f1184f7" : "i18n:govoplan-campaign.local.dc99d54d";
|
||||
}
|
||||
|
||||
function mockMessageMetaItems(message: MockMailboxMessage) {
|
||||
return [
|
||||
{ label: "i18n:govoplan-campaign.from.3f66052a", value: message.from_header || message.envelope_from || "—" },
|
||||
@@ -2510,102 +1871,6 @@ function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePre
|
||||
}));
|
||||
}
|
||||
|
||||
function countResolvedAttachments(value: unknown): number {
|
||||
const archives = new Set<string>();
|
||||
let directCount = 0;
|
||||
for (const item of asArray(value)) {
|
||||
const attachment = asRecord(item);
|
||||
const zipFilename = String(attachment.zip_filename ?? "").trim();
|
||||
const managedCount = asArray(attachment.managed_matches).length;
|
||||
const matchCount = asArray(attachment.matches).length;
|
||||
if (zipFilename && (managedCount > 0 || matchCount > 0)) {
|
||||
archives.add(zipFilename);
|
||||
continue;
|
||||
}
|
||||
if (managedCount > 0) {
|
||||
directCount += managedCount;
|
||||
continue;
|
||||
}
|
||||
directCount += matchCount;
|
||||
}
|
||||
return directCount + archives.size;
|
||||
}
|
||||
|
||||
function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
||||
}
|
||||
|
||||
function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||
buildToken: string;
|
||||
inspectionComplete: boolean;
|
||||
reviewedMessageKeys: string[];
|
||||
} {
|
||||
const build = asRecord(version?.build_summary);
|
||||
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
||||
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
||||
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
|
||||
}
|
||||
return {
|
||||
buildToken,
|
||||
inspectionComplete: review.inspection_complete === true,
|
||||
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter((value): value is string => typeof value === "string")
|
||||
};
|
||||
}
|
||||
|
||||
function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||
return String(row.entry_id ?? row.entry_index ?? index);
|
||||
}
|
||||
|
||||
function findBuiltMessageIndex(rows: Record<string, unknown>[], row: Record<string, unknown>): number {
|
||||
const id = String(row.id ?? "").trim();
|
||||
if (id) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const reviewKey = String(row.review_key ?? "").trim();
|
||||
if (reviewKey) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.review_key ?? "").trim() === reviewKey);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim();
|
||||
if (entryKey) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
return rows.indexOf(row);
|
||||
}
|
||||
|
||||
function sameBuiltMessage(
|
||||
left: Record<string, unknown>,
|
||||
leftIndex: number,
|
||||
right: Record<string, unknown>,
|
||||
rightIndex: number)
|
||||
: boolean {
|
||||
const leftId = String(left.id ?? "").trim();
|
||||
const rightId = String(right.id ?? "").trim();
|
||||
if (leftId && rightId) return leftId === rightId;
|
||||
const leftReviewKey = String(left.review_key ?? "").trim();
|
||||
const rightReviewKey = String(right.review_key ?? "").trim();
|
||||
if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey;
|
||||
const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim();
|
||||
const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim();
|
||||
if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey;
|
||||
return left === right || leftIndex === rightIndex;
|
||||
}
|
||||
|
||||
function formatAddressList(value: unknown): string {
|
||||
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
function formatSingleAddress(value: unknown): string {
|
||||
const address = asRecord(value);
|
||||
const email = String(address.email ?? "").trim();
|
||||
const name = String(address.name ?? "").trim();
|
||||
if (name && email) return `${name} <${email}>`;
|
||||
return email || name;
|
||||
}
|
||||
|
||||
function synchronousSendReason(option: Record<string, unknown>): string {
|
||||
const configuredMessage = String(option.message ?? "").trim();
|
||||
if (configuredMessage) return configuredMessage;
|
||||
@@ -2631,37 +1896,3 @@ function deliveryPolicySourceLabel(source: string): string {
|
||||
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];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function numberOrUndefined(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringOrUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
|
||||
function stateLabel(state: FlowState): string {
|
||||
switch (state) {
|
||||
case "complete":return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
case "warning":return "i18n:govoplan-campaign.warnings.1430f976";
|
||||
case "danger":return "i18n:govoplan-campaign.blocked.99613c74";
|
||||
case "active":return "i18n:govoplan-campaign.available.7c62a142";
|
||||
case "locked":return "i18n:govoplan-campaign.locked.a798882f";
|
||||
case "running":return "i18n:govoplan-campaign.running.73989d9c";
|
||||
case "partial":return "i18n:govoplan-campaign.partial.65de2e2a";
|
||||
case "stale":return "i18n:govoplan-campaign.stale.189cc40c";
|
||||
default:return humanize(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,20 @@ import { KeyRound, Trash2 } from "lucide-react";
|
||||
import {
|
||||
fetchResourceAccessExplanation,
|
||||
getCampaignShares,
|
||||
getCampaignShareTargets,
|
||||
campaignShareTargetProvider,
|
||||
revokeCampaignShare,
|
||||
updateCampaignOwner,
|
||||
upsertCampaignShare,
|
||||
type CampaignShare,
|
||||
type CampaignShareTargets,
|
||||
type ResourceAccessExplanationResponse } from
|
||||
"../../../api/campaigns";
|
||||
import { Button, ResourceAccessExplanation } from "@govoplan/core-webui";
|
||||
import {
|
||||
Button,
|
||||
ReferenceSelect,
|
||||
ResourceAccessExplanation,
|
||||
type ReferenceOption,
|
||||
type ReferenceOptionProvider
|
||||
} from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
@@ -34,7 +39,10 @@ export default function CampaignAccessCard({
|
||||
|
||||
}: {settings: ApiSettings;auth: AuthInfo;campaign: CampaignListItem;onChanged: () => Promise<void>;onError: (message: string) => void;}) {
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
const [targets, setTargets] = useState<CampaignShareTargets>({ users: [], groups: [] });
|
||||
const [targets, setTargets] = useState<{
|
||||
users: readonly ReferenceOption[];
|
||||
groups: readonly ReferenceOption[];
|
||||
}>({ users: [], groups: [] });
|
||||
const [shares, setShares] = useState<CampaignShare[]>([]);
|
||||
const [ownerOpen, setOwnerOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
@@ -57,6 +65,14 @@ export default function CampaignAccessCard({
|
||||
hasScope(auth, "admin:roles:read") ||
|
||||
hasScope(auth, "access:membership:read") ||
|
||||
hasScope(auth, "access:role:read");
|
||||
const userTargetProvider = useMemo(
|
||||
() => campaignShareTargetProvider(settings, campaign.id, "user"),
|
||||
[campaign.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]
|
||||
);
|
||||
const groupTargetProvider = useMemo(
|
||||
() => campaignShareTargetProvider(settings, campaign.id, "group"),
|
||||
[campaign.id, settings.accessToken, settings.apiBaseUrl, settings.apiKey]
|
||||
);
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: ownerDirty || shareDirty,
|
||||
@@ -69,12 +85,25 @@ export default function CampaignAccessCard({
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [nextTargets, nextShares] = await Promise.all([
|
||||
getCampaignShareTargets(settings, campaign.id),
|
||||
getCampaignShares(settings, campaign.id)]
|
||||
const nextShares = (await getCampaignShares(settings, campaign.id))
|
||||
.filter((item) => !item.revoked_at);
|
||||
const userIds = uniqueTargetIds(
|
||||
nextShares,
|
||||
"user",
|
||||
campaign.owner_user_id
|
||||
);
|
||||
setTargets(nextTargets);
|
||||
setShares(nextShares.filter((item) => !item.revoked_at));
|
||||
const groupIds = uniqueTargetIds(
|
||||
nextShares,
|
||||
"group",
|
||||
campaign.owner_group_id
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const [users, groups] = await Promise.all([
|
||||
resolveReferenceOptions(userTargetProvider, userIds, controller.signal),
|
||||
resolveReferenceOptions(groupTargetProvider, groupIds, controller.signal)
|
||||
]);
|
||||
setTargets({ users, groups });
|
||||
setShares(nextShares);
|
||||
setAvailable(true);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -87,18 +116,27 @@ export default function CampaignAccessCard({
|
||||
setOwnerType(campaign.owner_group_id ? "group" : "user");
|
||||
setOwnerId(campaign.owner_group_id || campaign.owner_user_id || "");
|
||||
void load();
|
||||
}, [campaign.id, campaign.owner_group_id, campaign.owner_user_id, settings.accessToken, settings.apiBaseUrl]);
|
||||
}, [
|
||||
campaign.id,
|
||||
campaign.owner_group_id,
|
||||
campaign.owner_user_id,
|
||||
groupTargetProvider,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey,
|
||||
userTargetProvider
|
||||
]);
|
||||
|
||||
const targetOptions = ownerType === "user" ? targets.users : targets.groups;
|
||||
const shareTargetOptions = shareType === "user" ? targets.users : targets.groups;
|
||||
const ownerTargetProvider = ownerType === "user" ? userTargetProvider : groupTargetProvider;
|
||||
const shareTargetProvider = shareType === "user" ? userTargetProvider : groupTargetProvider;
|
||||
const targetMap = useMemo(() => new Map([
|
||||
...targets.users.map((item) => [`user:${item.id}`, item] as const),
|
||||
...targets.groups.map((item) => [`group:${item.id}`, item] as const)]
|
||||
...targets.users.map((item) => [`user:${item.value}`, item] as const),
|
||||
...targets.groups.map((item) => [`group:${item.value}`, item] as const)]
|
||||
), [targets]);
|
||||
|
||||
const ownerLabel = campaign.owner_group_id ?
|
||||
targetMap.get(`group:${campaign.owner_group_id}`)?.name || "i18n:govoplan-campaign.group_owner.55630670" :
|
||||
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.name || "i18n:govoplan-campaign.user_owner.86e3faab";
|
||||
targetMap.get(`group:${campaign.owner_group_id}`)?.label || "i18n:govoplan-campaign.group_owner.55630670" :
|
||||
targetMap.get(`user:${campaign.owner_user_id || ""}`)?.label || "i18n:govoplan-campaign.user_owner.86e3faab";
|
||||
|
||||
function closeOwnerDialog() {
|
||||
setOwnerOpen(false);
|
||||
@@ -189,10 +227,10 @@ export default function CampaignAccessCard({
|
||||
sticky: "start",
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.name || row.target_id,
|
||||
value: (row) => targetMap.get(`${row.target_type}:${row.target_id}`)?.label || row.target_id,
|
||||
render: (row) => {
|
||||
const target = targetMap.get(`${row.target_type}:${row.target_id}`);
|
||||
return <div><strong>{target?.name || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.secondary }) : ""}</div></div>;
|
||||
return <div><strong>{target?.label || row.target_id}</strong><div className="muted small-note">{row.target_type}{target?.description ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: target.description }) : ""}</div></div>;
|
||||
}
|
||||
},
|
||||
{ id: "permission", header: "i18n:govoplan-campaign.access.2f81a22d", width: 120, resizable: false, sortable: true, filterable: true, value: (row) => row.permission, render: (row) => <StatusBadge status={row.permission === "write" ? "active" : "built"} label={row.permission === "write" ? "i18n:govoplan-campaign.can_edit.cb0ab3da" : "i18n:govoplan-campaign.can_view.1b3e4006"} /> },
|
||||
@@ -210,14 +248,32 @@ export default function CampaignAccessCard({
|
||||
</Card>
|
||||
|
||||
<Dialog open={ownerOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.change_campaign_owner.63f80aef" onClose={() => !busy && closeOwnerDialog()} footer={<><Button onClick={closeOwnerDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveOwner()} disabled={busy || !ownerId}>i18n:govoplan-campaign.save_owner.b6763847</Button></>}>
|
||||
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.owner.89ff3122"><select value={ownerId} onChange={(event) => setOwnerId(event.target.value)}>{targetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.owner_type.6b86eacc"><select value={ownerType} onChange={(event) => {const next = event.target.value as TargetType;setOwnerType(next);setOwnerId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.owner.89ff3122">
|
||||
<ReferenceSelect
|
||||
value={ownerId}
|
||||
onChange={(value) => setOwnerId(value)}
|
||||
provider={ownerTargetProvider}
|
||||
selectedOption={targetMap.get(`${ownerType}:${ownerId}`) ?? null}
|
||||
disabled={busy}
|
||||
placeholder="i18n:govoplan-campaign.select.349ac8fb"
|
||||
/>
|
||||
</FormField>
|
||||
<p className="muted small-note">Changing the owner clears a selected reusable mail profile from the editable current version and requires profile reselection plus validation before live delivery.</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={shareOpen} className="campaign-access-dialog" title="i18n:govoplan-campaign.share_campaign.b605982b" onClose={() => !busy && closeShareDialog()} footer={<><Button onClick={closeShareDialog} disabled={busy}>i18n:govoplan-campaign.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveShare()} disabled={busy || !shareTargetId}>i18n:govoplan-campaign.save_share.bcf6ed94</Button></>}>
|
||||
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId((next === "user" ? targets.users : targets.groups)[0]?.id || "");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0"><select value={shareTargetId} onChange={(event) => setShareTargetId(event.target.value)}><option value="">i18n:govoplan-campaign.select.349ac8fb</option>{shareTargetOptions.map((item) => <option key={item.id} value={item.id}>{item.name}{item.secondary ? i18nMessage("i18n:govoplan-campaign.value.48afe802", { value0: item.secondary }) : ""}</option>)}</select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.target_type.a45f8055"><select value={shareType} onChange={(event) => {const next = event.target.value as TargetType;setShareType(next);setShareTargetId("");}}><option value="user">i18n:govoplan-campaign.user.9f8a2389</option><option value="group">i18n:govoplan-campaign.group.171a0606</option></select></FormField>
|
||||
<FormField label="i18n:govoplan-campaign.user_or_group.53406ef0">
|
||||
<ReferenceSelect
|
||||
value={shareTargetId}
|
||||
onChange={(value) => setShareTargetId(value)}
|
||||
provider={shareTargetProvider}
|
||||
selectedOption={targetMap.get(`${shareType}:${shareTargetId}`) ?? null}
|
||||
disabled={busy}
|
||||
placeholder="i18n:govoplan-campaign.select.349ac8fb"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="i18n:govoplan-campaign.access.2f81a22d"><select value={sharePermission} onChange={(event) => setSharePermission(event.target.value as "read" | "write")}><option value="read">i18n:govoplan-campaign.can_view.1b3e4006</option><option value="write">i18n:govoplan-campaign.can_edit_and_operate.77acb15e</option></select></FormField>
|
||||
</Dialog>
|
||||
|
||||
@@ -229,3 +285,36 @@ export default function CampaignAccessCard({
|
||||
</>);
|
||||
|
||||
}
|
||||
|
||||
function uniqueTargetIds(
|
||||
shares: readonly CampaignShare[],
|
||||
targetType: TargetType,
|
||||
ownerId?: string | null
|
||||
): string[] {
|
||||
return [...new Set([
|
||||
...(ownerId ? [ownerId] : []),
|
||||
...shares
|
||||
.filter((share) => share.target_type === targetType)
|
||||
.map((share) => share.target_id)
|
||||
])];
|
||||
}
|
||||
|
||||
async function resolveReferenceOptions(
|
||||
provider: ReferenceOptionProvider,
|
||||
values: readonly string[],
|
||||
signal: AbortSignal
|
||||
): Promise<readonly ReferenceOption[]> {
|
||||
if (!values.length) return [];
|
||||
if (!provider.resolve) {
|
||||
return provider.search("", {
|
||||
selectedValues: values,
|
||||
limit: Math.min(200, Math.max(1, values.length)),
|
||||
signal
|
||||
});
|
||||
}
|
||||
return provider.resolve(values, {
|
||||
selectedValues: values,
|
||||
limit: Math.min(200, Math.max(1, values.length)),
|
||||
signal
|
||||
});
|
||||
}
|
||||
|
||||
567
webui/src/features/campaigns/components/PostboxTargetsDialog.tsx
Normal file
567
webui/src/features/campaigns/components/PostboxTargetsDialog.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Dialog, DismissibleAlert, FormField, TableActionGroup, ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { ArrowDown, ArrowUp, Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
CampaignPostboxCatalog,
|
||||
CampaignPostboxOrganizationUnit
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignFieldDefinition } from "../utils/fieldDefinitions";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
|
||||
export type CampaignPostboxTarget = {
|
||||
id: string;
|
||||
mode: "direct" | "derived";
|
||||
label?: string;
|
||||
postbox_id?: string;
|
||||
address_key?: string;
|
||||
template_id?: string;
|
||||
organization_unit_id?: string;
|
||||
organization_unit_field?: string;
|
||||
organization_unit_match?: "id" | "slug";
|
||||
function_id?: string;
|
||||
function_field?: string;
|
||||
function_match?: "id" | "slug";
|
||||
context_key?: string;
|
||||
context_field?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
targets: CampaignPostboxTarget[];
|
||||
locked?: boolean;
|
||||
merge?: boolean;
|
||||
showMerge?: boolean;
|
||||
onSave: (targets: CampaignPostboxTarget[], merge: boolean) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function PostboxTargetsDialog({
|
||||
open,
|
||||
title,
|
||||
catalog,
|
||||
fields,
|
||||
targets,
|
||||
locked = false,
|
||||
merge = true,
|
||||
showMerge = false,
|
||||
onSave,
|
||||
onClose
|
||||
}: Props) {
|
||||
const [draftTargets, setDraftTargets] = useState<CampaignPostboxTarget[]>(
|
||||
() => targets.map(normalizeTarget)
|
||||
);
|
||||
const [draftMerge, setDraftMerge] = useState(merge);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
function updateTarget(index: number, patch: Partial<CampaignPostboxTarget>) {
|
||||
setDraftTargets((current) =>
|
||||
current.map((target, currentIndex) =>
|
||||
currentIndex === index ? normalizeTarget({ ...target, ...patch }) : target
|
||||
)
|
||||
);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function addTarget() {
|
||||
const firstPostbox = catalog.postboxes[0];
|
||||
setDraftTargets((current) => [
|
||||
...current,
|
||||
{
|
||||
id: newTargetId(),
|
||||
mode: "direct",
|
||||
postbox_id: firstPostbox?.id ?? ""
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const prepared = draftTargets.map(normalizeTarget);
|
||||
const invalidIndex = prepared.findIndex((target) => !targetIsComplete(target));
|
||||
if (invalidIndex >= 0) {
|
||||
setError(`Target ${invalidIndex + 1} is incomplete.`);
|
||||
return;
|
||||
}
|
||||
onSave(prepared, draftMerge);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
title={title}
|
||||
className="campaign-postbox-target-modal"
|
||||
bodyClassName="campaign-postbox-target-body"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" disabled={locked} onClick={save}>Save</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="campaign-postbox-target-toolbar">
|
||||
{showMerge &&
|
||||
<ToggleSwitch
|
||||
label="Postbox target defaults"
|
||||
inactiveLabel="Replace defaults"
|
||||
activeLabel="Add to defaults"
|
||||
checked={draftMerge}
|
||||
disabled={locked}
|
||||
onChange={setDraftMerge}
|
||||
/>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={locked || catalog.postboxes.length + catalog.templates.length === 0}
|
||||
onClick={addTarget}
|
||||
>
|
||||
<Plus aria-hidden="true" />
|
||||
Add target
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <DismissibleAlert tone="danger" dismissible={false}>{error}</DismissibleAlert>}
|
||||
{draftTargets.length === 0 &&
|
||||
<div className="data-grid-empty">No Postbox targets configured.</div>
|
||||
}
|
||||
<div className="campaign-postbox-target-list">
|
||||
{draftTargets.map((target, index) =>
|
||||
<PostboxTargetRow
|
||||
key={target.id}
|
||||
target={target}
|
||||
index={index}
|
||||
count={draftTargets.length}
|
||||
catalog={catalog}
|
||||
fields={fields}
|
||||
locked={locked}
|
||||
onChange={(patch) => updateTarget(index, patch)}
|
||||
onMove={(offset) => setDraftTargets((current) => moveTarget(current, index, index + offset))}
|
||||
onRemove={() => setDraftTargets((current) => current.filter((_, currentIndex) => currentIndex !== index))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PostboxTargetRow({
|
||||
target,
|
||||
index,
|
||||
count,
|
||||
catalog,
|
||||
fields,
|
||||
locked,
|
||||
onChange,
|
||||
onMove,
|
||||
onRemove
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
index: number;
|
||||
count: number;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
onMove: (offset: number) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const selectedUnit = catalog.organization_units.find(
|
||||
(unit) => unit.id === target.organization_unit_id
|
||||
);
|
||||
return (
|
||||
<section className="campaign-postbox-target-row">
|
||||
<div className="campaign-postbox-target-row-heading">
|
||||
<strong>Target {index + 1}</strong>
|
||||
<TableActionGroup actions={[
|
||||
{ id: "up", label: "Move target up", icon: <ArrowUp aria-hidden="true" />, disabled: locked || index === 0, onClick: () => onMove(-1) },
|
||||
{ id: "down", label: "Move target down", icon: <ArrowDown aria-hidden="true" />, disabled: locked || index === count - 1, onClick: () => onMove(1) },
|
||||
{ id: "remove", label: "Remove target", icon: <Trash2 aria-hidden="true" />, disabled: locked, variant: "danger", onClick: onRemove }
|
||||
]} />
|
||||
</div>
|
||||
<div className="form-grid compact responsive-form-grid campaign-postbox-target-grid">
|
||||
<FormField label="Target type">
|
||||
<select
|
||||
value={target.mode}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange(
|
||||
event.target.value === "derived"
|
||||
? derivedTargetDefaults(target.id, catalog, fields)
|
||||
: directTargetDefaults(target.id, catalog)
|
||||
)}
|
||||
>
|
||||
<option value="direct">Direct Postbox</option>
|
||||
<option value="derived">Derived from fields</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Label">
|
||||
<input
|
||||
value={target.label ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ label: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
{target.mode === "direct" ?
|
||||
<DirectTargetFields
|
||||
target={target}
|
||||
catalog={catalog}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
/> :
|
||||
<DerivedTargetFields
|
||||
target={target}
|
||||
catalog={catalog}
|
||||
fields={fields}
|
||||
selectedUnit={selectedUnit}
|
||||
locked={locked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectTargetFields({
|
||||
target,
|
||||
catalog,
|
||||
locked,
|
||||
onChange
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
}) {
|
||||
return (
|
||||
<FormField label="Postbox">
|
||||
<select
|
||||
value={target.postbox_id ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ postbox_id: event.target.value })}
|
||||
>
|
||||
<option value="">Select a Postbox</option>
|
||||
{catalog.postboxes.map((postbox) =>
|
||||
<option key={postbox.id} value={postbox.id}>
|
||||
{postbox.name} ({postbox.address}){postbox.vacant ? " - vacant" : ""}
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedTargetFields({
|
||||
target,
|
||||
catalog,
|
||||
fields,
|
||||
selectedUnit,
|
||||
locked,
|
||||
onChange
|
||||
}: {
|
||||
target: CampaignPostboxTarget;
|
||||
catalog: CampaignPostboxCatalog;
|
||||
fields: CampaignFieldDefinition[];
|
||||
selectedUnit?: CampaignPostboxOrganizationUnit;
|
||||
locked: boolean;
|
||||
onChange: (patch: Partial<CampaignPostboxTarget>) => void;
|
||||
}) {
|
||||
const organizationFields = useMemo(
|
||||
() => fields.filter((field) => field.type === "organization_unit" || field.type === "string"),
|
||||
[fields]
|
||||
);
|
||||
const functionFields = useMemo(
|
||||
() => fields.filter((field) => field.type === "organization_function" || field.type === "string"),
|
||||
[fields]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FormField label="Postbox template">
|
||||
<select
|
||||
value={target.template_id ?? ""}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ template_id: event.target.value })}
|
||||
>
|
||||
<option value="">Select a template</option>
|
||||
{catalog.templates.map((template) =>
|
||||
<option key={template.id} value={template.id}>{template.name}</option>
|
||||
)}
|
||||
</select>
|
||||
</FormField>
|
||||
<DerivedReferenceField
|
||||
label="Organization unit"
|
||||
fixedValue={target.organization_unit_id}
|
||||
fieldValue={target.organization_unit_field}
|
||||
match={target.organization_unit_match}
|
||||
fixedOptions={catalog.organization_units}
|
||||
fields={organizationFields}
|
||||
locked={locked}
|
||||
onFixed={(value) => onChange({
|
||||
organization_unit_id: value,
|
||||
organization_unit_field: undefined,
|
||||
function_id: undefined
|
||||
})}
|
||||
onField={(value) => onChange({
|
||||
organization_unit_id: undefined,
|
||||
organization_unit_field: value
|
||||
})}
|
||||
onMatch={(value) => onChange({ organization_unit_match: value })}
|
||||
/>
|
||||
<DerivedReferenceField
|
||||
label="Function"
|
||||
fixedValue={target.function_id}
|
||||
fieldValue={target.function_field}
|
||||
match={target.function_match}
|
||||
fixedOptions={selectedUnit?.functions ?? []}
|
||||
fields={functionFields}
|
||||
locked={locked}
|
||||
fixedDisabled={!target.organization_unit_id}
|
||||
onFixed={(value) => onChange({
|
||||
function_id: value,
|
||||
function_field: undefined
|
||||
})}
|
||||
onField={(value) => onChange({
|
||||
function_id: undefined,
|
||||
function_field: value
|
||||
})}
|
||||
onMatch={(value) => onChange({ function_match: value })}
|
||||
/>
|
||||
<FormField label="Context source">
|
||||
<select
|
||||
value={target.context_field ? "field" : target.context_key ? "fixed" : "none"}
|
||||
disabled={locked}
|
||||
onChange={(event) => {
|
||||
const mode = event.target.value;
|
||||
onChange({
|
||||
context_key: mode === "fixed" ? target.context_key ?? "" : undefined,
|
||||
context_field: mode === "field" ? target.context_field ?? fields[0]?.name ?? "" : undefined
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="none">No context</option>
|
||||
<option value="fixed">Fixed value</option>
|
||||
<option value="field">Campaign field</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{target.context_field !== undefined ?
|
||||
<FormField label="Context field">
|
||||
<select
|
||||
value={target.context_field}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ context_field: event.target.value })}
|
||||
>
|
||||
<option value="">Select a field</option>
|
||||
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
</FormField> :
|
||||
target.context_key !== undefined &&
|
||||
<FormField label="Context value">
|
||||
<input
|
||||
value={target.context_key}
|
||||
disabled={locked}
|
||||
onChange={(event) => onChange({ context_key: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DerivedReferenceField({
|
||||
label,
|
||||
fixedValue,
|
||||
fieldValue,
|
||||
match,
|
||||
fixedOptions,
|
||||
fields,
|
||||
locked,
|
||||
fixedDisabled = false,
|
||||
onFixed,
|
||||
onField,
|
||||
onMatch
|
||||
}: {
|
||||
label: string;
|
||||
fixedValue?: string;
|
||||
fieldValue?: string;
|
||||
match?: "id" | "slug";
|
||||
fixedOptions: Array<{ id: string; name: string; slug: string }>;
|
||||
fields: CampaignFieldDefinition[];
|
||||
locked: boolean;
|
||||
fixedDisabled?: boolean;
|
||||
onFixed: (value: string) => void;
|
||||
onField: (value: string) => void;
|
||||
onMatch: (value: "id" | "slug") => void;
|
||||
}) {
|
||||
const usesField = fieldValue !== undefined;
|
||||
return (
|
||||
<>
|
||||
<FormField label={`${label} source`}>
|
||||
<select
|
||||
value={usesField ? "field" : "fixed"}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
event.target.value === "field"
|
||||
? onField(fieldValue ?? fields[0]?.name ?? "")
|
||||
: onFixed(fixedValue ?? fixedOptions[0]?.id ?? "")
|
||||
}
|
||||
>
|
||||
<option value="fixed">Fixed value</option>
|
||||
<option value="field">Campaign field</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{usesField ?
|
||||
<>
|
||||
<FormField label={`${label} field`}>
|
||||
<select value={fieldValue ?? ""} disabled={locked} onChange={(event) => onField(event.target.value)}>
|
||||
<option value="">Select a field</option>
|
||||
{fields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Field contains">
|
||||
<select value={match ?? "id"} disabled={locked} onChange={(event) => onMatch(event.target.value as "id" | "slug")}>
|
||||
<option value="id">Stable ID</option>
|
||||
<option value="slug">Key / slug</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</> :
|
||||
<FormField label={label}>
|
||||
<select value={fixedValue ?? ""} disabled={locked || fixedDisabled} onChange={(event) => onFixed(event.target.value)}>
|
||||
<option value="">Select {label.toLowerCase()}</option>
|
||||
{fixedOptions.map((option) => <option key={option.id} value={option.id}>{option.name} ({option.slug})</option>)}
|
||||
</select>
|
||||
</FormField>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizePostboxTargets(value: unknown): CampaignPostboxTarget[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map(asRecord)
|
||||
.map((target) => normalizeTarget({
|
||||
id: String(target.id ?? ""),
|
||||
mode: target.mode === "derived" ? "derived" : "direct",
|
||||
label: optionalText(target.label),
|
||||
postbox_id: optionalText(target.postbox_id),
|
||||
address_key: optionalText(target.address_key),
|
||||
template_id: optionalText(target.template_id),
|
||||
organization_unit_id: optionalText(target.organization_unit_id),
|
||||
organization_unit_field: optionalText(target.organization_unit_field),
|
||||
organization_unit_match: target.organization_unit_match === "slug" ? "slug" : "id",
|
||||
function_id: optionalText(target.function_id),
|
||||
function_field: optionalText(target.function_field),
|
||||
function_match: target.function_match === "slug" ? "slug" : "id",
|
||||
context_key: optionalText(target.context_key),
|
||||
context_field: optionalText(target.context_field)
|
||||
}))
|
||||
.filter((target) => Boolean(target.id));
|
||||
}
|
||||
|
||||
function normalizeTarget(target: CampaignPostboxTarget): CampaignPostboxTarget {
|
||||
const common = {
|
||||
id: target.id || newTargetId(),
|
||||
mode: target.mode,
|
||||
...(target.label?.trim() ? { label: target.label.trim() } : {})
|
||||
};
|
||||
if (target.mode === "direct") {
|
||||
return {
|
||||
...common,
|
||||
mode: "direct",
|
||||
...(target.postbox_id ? { postbox_id: target.postbox_id } : {}),
|
||||
...(!target.postbox_id && target.address_key ? { address_key: target.address_key } : {})
|
||||
};
|
||||
}
|
||||
return {
|
||||
...common,
|
||||
mode: "derived",
|
||||
template_id: target.template_id ?? "",
|
||||
...(target.organization_unit_field !== undefined
|
||||
? {
|
||||
organization_unit_field: target.organization_unit_field,
|
||||
organization_unit_match: target.organization_unit_match ?? "id"
|
||||
}
|
||||
: { organization_unit_id: target.organization_unit_id ?? "" }),
|
||||
...(target.function_field !== undefined
|
||||
? {
|
||||
function_field: target.function_field,
|
||||
function_match: target.function_match ?? "id"
|
||||
}
|
||||
: { function_id: target.function_id ?? "" }),
|
||||
...(target.context_field !== undefined
|
||||
? { context_field: target.context_field }
|
||||
: target.context_key !== undefined
|
||||
? { context_key: target.context_key }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function targetIsComplete(target: CampaignPostboxTarget): boolean {
|
||||
if (!target.id) return false;
|
||||
if (target.mode === "direct") {
|
||||
return Boolean(target.postbox_id || target.address_key);
|
||||
}
|
||||
return Boolean(
|
||||
target.template_id
|
||||
&& (target.organization_unit_id || target.organization_unit_field)
|
||||
&& (target.function_id || target.function_field)
|
||||
);
|
||||
}
|
||||
|
||||
function directTargetDefaults(id: string, catalog: CampaignPostboxCatalog): CampaignPostboxTarget {
|
||||
return {
|
||||
id,
|
||||
mode: "direct",
|
||||
postbox_id: catalog.postboxes[0]?.id ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function derivedTargetDefaults(
|
||||
id: string,
|
||||
catalog: CampaignPostboxCatalog,
|
||||
fields: CampaignFieldDefinition[]
|
||||
): CampaignPostboxTarget {
|
||||
const unit = catalog.organization_units[0];
|
||||
return {
|
||||
id,
|
||||
mode: "derived",
|
||||
template_id: catalog.templates[0]?.id ?? "",
|
||||
...(unit
|
||||
? {
|
||||
organization_unit_id: unit.id,
|
||||
function_id: unit.functions[0]?.id ?? ""
|
||||
}
|
||||
: {
|
||||
organization_unit_field: fields[0]?.name ?? "",
|
||||
organization_unit_match: "id",
|
||||
function_field: fields[0]?.name ?? "",
|
||||
function_match: "id"
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function moveTarget(
|
||||
targets: CampaignPostboxTarget[],
|
||||
source: number,
|
||||
destination: number
|
||||
): CampaignPostboxTarget[] {
|
||||
if (destination < 0 || destination >= targets.length) return targets;
|
||||
const next = [...targets];
|
||||
const [target] = next.splice(source, 1);
|
||||
next.splice(destination, 0, target);
|
||||
return next;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown): string | undefined {
|
||||
const text = String(value ?? "").trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function newTargetId(): string {
|
||||
const suffix = typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `postbox-${suffix}`;
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
DismissibleAlert,
|
||||
FormField,
|
||||
SegmentedControl,
|
||||
useGuardedNavigate,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
snapshotCampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSource,
|
||||
type CampaignRecipientAddressSourceSnapshot
|
||||
} from "../../../api/campaigns";
|
||||
import type { RecipientImportMode } from "../utils/bulkImport";
|
||||
import { asRecord } from "../utils/campaignView";
|
||||
import { fieldValueToString } from "../utils/addressSourceImport";
|
||||
|
||||
type AddressSourceFilter = "all" | "books" | "lists";
|
||||
|
||||
export default function AddressSourceImportDialog({
|
||||
settings,
|
||||
campaignId,
|
||||
sources,
|
||||
initialSourceId = "",
|
||||
onCancel,
|
||||
onImport
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
campaignId: string;
|
||||
sources: CampaignRecipientAddressSource[];
|
||||
initialSourceId?: string;
|
||||
onCancel: () => void;
|
||||
onImport: (
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
mode: RecipientImportMode
|
||||
) => void;
|
||||
}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const [selectedSourceId, setSelectedSourceId] = useState(
|
||||
initialSourceId || sources[0]?.source_id || ""
|
||||
);
|
||||
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
|
||||
const [sourceQuery, setSourceQuery] = useState("");
|
||||
const [mode, setMode] = useState<RecipientImportMode>("append");
|
||||
const [snapshot, setSnapshot] =
|
||||
useState<CampaignRecipientAddressSourceSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const sourceCounts = useMemo(() => {
|
||||
return sources.reduce(
|
||||
(counts, source) => {
|
||||
counts[addressSourceType(source)] += 1;
|
||||
return counts;
|
||||
},
|
||||
{ book: 0, list: 0 }
|
||||
);
|
||||
}, [sources]);
|
||||
const filteredSources = useMemo(() => {
|
||||
const query = sourceQuery.trim().toLowerCase();
|
||||
return sources.filter((source) => {
|
||||
const type = addressSourceType(source);
|
||||
if (sourceFilter === "books" && type !== "book") return false;
|
||||
if (sourceFilter === "lists" && type !== "list") return false;
|
||||
if (!query) return true;
|
||||
return addressSourceSearchText(source).includes(query);
|
||||
});
|
||||
}, [sourceFilter, sourceQuery, sources]);
|
||||
const selectedSource = useMemo(
|
||||
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
|
||||
[selectedSourceId, sources]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredSources.some((source) => source.source_id === selectedSourceId)) {
|
||||
return;
|
||||
}
|
||||
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
|
||||
}, [filteredSources, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
initialSourceId &&
|
||||
sources.some((source) => source.source_id === initialSourceId)
|
||||
) {
|
||||
setSourceFilter("all");
|
||||
setSourceQuery("");
|
||||
setSelectedSourceId(initialSourceId);
|
||||
}
|
||||
}, [initialSourceId, sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSourceId) {
|
||||
setSnapshot(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void snapshotCampaignRecipientAddressSource(
|
||||
settings,
|
||||
campaignId,
|
||||
selectedSourceId
|
||||
)
|
||||
.then((nextSnapshot) => {
|
||||
if (!cancelled) setSnapshot(nextSnapshot);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setSnapshot(null);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
campaignId,
|
||||
selectedSourceId,
|
||||
settings.accessToken,
|
||||
settings.apiBaseUrl,
|
||||
settings.apiKey
|
||||
]);
|
||||
|
||||
function openAddressBookModule() {
|
||||
onCancel();
|
||||
navigate("/address-book");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="Import recipients from addresses"
|
||||
className="recipient-import-modal"
|
||||
bodyClassName="recipient-import-body"
|
||||
closeDisabled={loading}
|
||||
closeOnBackdrop={!loading}
|
||||
onClose={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel} disabled={loading}>
|
||||
i18n:govoplan-campaign.cancel.77dfd213
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={loading || !snapshot || snapshot.recipients.length === 0}
|
||||
onClick={() => snapshot && onImport(snapshot, mode)}
|
||||
>
|
||||
Import recipients
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="address-source-import-controls">
|
||||
<div>
|
||||
<SegmentedControl
|
||||
ariaLabel="Address source type"
|
||||
value={sourceFilter}
|
||||
onChange={setSourceFilter}
|
||||
size="content"
|
||||
width="inline"
|
||||
disabled={loading || sources.length === 0}
|
||||
options={[
|
||||
{ id: "all", label: `All (${sources.length})` },
|
||||
{ id: "books", label: `Books (${sourceCounts.book})` },
|
||||
{ id: "lists", label: `Lists (${sourceCounts.list})` }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={sourceQuery}
|
||||
disabled={loading || sources.length === 0}
|
||||
placeholder="Search address sources"
|
||||
aria-label="Search address sources"
|
||||
onChange={(event) => setSourceQuery(event.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={openAddressBookModule}>
|
||||
Manage address books
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="campaign-header-grid recipient-import-upload-grid">
|
||||
<div
|
||||
className="address-source-picker"
|
||||
role="radiogroup"
|
||||
aria-label="Address source"
|
||||
>
|
||||
{filteredSources.map((source) => (
|
||||
<button
|
||||
type="button"
|
||||
key={source.source_id}
|
||||
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
|
||||
disabled={loading}
|
||||
role="radio"
|
||||
aria-checked={source.source_id === selectedSourceId}
|
||||
onClick={() => setSelectedSourceId(source.source_id)}
|
||||
>
|
||||
<span className="address-source-option-main">
|
||||
<strong>{source.source_label}</strong>
|
||||
<span>
|
||||
{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="address-source-option-meta">
|
||||
<span>{source.recipient_count} recipients</span>
|
||||
<code>{shortSourceRevision(source.source_revision)}</code>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{sources.length > 0 && filteredSources.length === 0 && (
|
||||
<div className="empty-state compact-empty">
|
||||
No address sources match the current filter.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormField label="Import mode">
|
||||
<select
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
setMode(event.target.value === "replace" ? "replace" : "append")
|
||||
}
|
||||
>
|
||||
<option value="append">
|
||||
i18n:govoplan-campaign.append_to_current_profiles.0b434d6f
|
||||
</option>
|
||||
<option value="replace">
|
||||
i18n:govoplan-campaign.replace_current_profiles.3b3be5e2
|
||||
</option>
|
||||
</select>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" compact resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{loading && (
|
||||
<DismissibleAlert tone="info" compact dismissible={false}>
|
||||
Loading address recipients...
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{!loading && sources.length === 0 && (
|
||||
<DismissibleAlert tone="info" dismissible={false}>
|
||||
No address sources are available to this campaign. Create an address book or
|
||||
address list in the addresses module first.
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
{snapshot && (
|
||||
<>
|
||||
<dl className="detail-list recipient-import-summary">
|
||||
<div>
|
||||
<dt>Source</dt>
|
||||
<dd>{snapshot.source_label}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Type</dt>
|
||||
<dd>
|
||||
{selectedSource
|
||||
? addressSourceTypeLabel(selectedSource)
|
||||
: "Address source"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Scope</dt>
|
||||
<dd>
|
||||
{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Recipients</dt>
|
||||
<dd>{snapshot.recipients.length}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Revision</dt>
|
||||
<dd className="mono-small">{snapshot.source_revision}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<AddressSourceRecipientPreviewGrid recipients={snapshot.recipients.slice(0, 20)} />
|
||||
{snapshot.recipients.length > 20 && (
|
||||
<p className="muted small-note">
|
||||
{snapshot.recipients.length - 20} more recipients will be imported.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function addressSourceType(
|
||||
source: CampaignRecipientAddressSource
|
||||
): "book" | "list" {
|
||||
return source.source_id.startsWith("addresses:address_list:") ||
|
||||
source.source_kind === "address_list"
|
||||
? "list"
|
||||
: "book";
|
||||
}
|
||||
|
||||
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
|
||||
if (addressSourceType(source) === "list") return "Address list";
|
||||
if (source.source_kind && source.source_kind !== "local") {
|
||||
return `${source.source_kind} address book`;
|
||||
}
|
||||
return "Address book";
|
||||
}
|
||||
|
||||
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
const scopeType = String(provenance.scope_type ?? "").trim();
|
||||
const scopeId = String(provenance.scope_id ?? "").trim();
|
||||
if (!scopeType) return "Unknown scope";
|
||||
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
|
||||
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
|
||||
}
|
||||
|
||||
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
|
||||
const provenance = asRecord(source.provenance);
|
||||
return [
|
||||
source.source_label,
|
||||
source.source_kind,
|
||||
source.source_id,
|
||||
addressSourceTypeLabel(source),
|
||||
addressSourceScopeLabel(source),
|
||||
provenance.address_book_id,
|
||||
provenance.address_list_id,
|
||||
provenance.scope_type,
|
||||
provenance.scope_id,
|
||||
provenance.tenant_id
|
||||
]
|
||||
.map((value) => String(value ?? "").toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function shortSourceRevision(value: string): string {
|
||||
if (!value) return "no revision";
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 19)}...`;
|
||||
}
|
||||
|
||||
type AddressSourceSnapshotRecipient =
|
||||
CampaignRecipientAddressSourceSnapshot["recipients"][number];
|
||||
|
||||
function AddressSourceRecipientPreviewGrid({
|
||||
recipients
|
||||
}: {
|
||||
recipients: AddressSourceSnapshotRecipient[];
|
||||
}) {
|
||||
const columns: DataGridColumn<AddressSourceSnapshotRecipient>[] = [
|
||||
{
|
||||
id: "row",
|
||||
header: "#",
|
||||
width: 64,
|
||||
value: (_recipient, index) => index + 1,
|
||||
render: (_recipient, index) => index + 1
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
header: "Name",
|
||||
width: "minmax(180px, 1fr)",
|
||||
minWidth: 160,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (recipient) => recipient.display_name
|
||||
},
|
||||
{
|
||||
id: "email",
|
||||
header: "Email",
|
||||
width: "minmax(220px, 1.2fr)",
|
||||
minWidth: 190,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (recipient) => recipient.email
|
||||
},
|
||||
{
|
||||
id: "fields",
|
||||
header: "Fields",
|
||||
width: 100,
|
||||
sortable: true,
|
||||
value: (recipient) =>
|
||||
Object.keys(recipient.fields ?? {}).filter((key) =>
|
||||
fieldValueToString((recipient.fields ?? {})[key])
|
||||
).length
|
||||
}
|
||||
];
|
||||
return (
|
||||
<DataGrid
|
||||
id="campaign-address-source-import-preview"
|
||||
rows={recipients}
|
||||
columns={columns}
|
||||
getRowKey={(recipient) => `${recipient.contact_id}-${recipient.email}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
191
webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx
Normal file
191
webui/src/features/campaigns/review/AttachmentLinkingPreview.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState } from "react";
|
||||
import { Link2, X } from "lucide-react";
|
||||
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import type {
|
||||
CampaignAttachmentPreviewFile,
|
||||
CampaignAttachmentPreviewResponse
|
||||
} from "../../../api/campaigns";
|
||||
import {
|
||||
attachmentPreviewLinkableFiles,
|
||||
attachmentPreviewMatchedFiles
|
||||
} from "../utils/attachmentPreview";
|
||||
import { WorkflowFact } from "./WorkflowNavigation";
|
||||
|
||||
export default function AttachmentLinkingPreview({
|
||||
preview,
|
||||
loading,
|
||||
error,
|
||||
linking,
|
||||
disabled,
|
||||
onRefresh,
|
||||
onLink
|
||||
}: {
|
||||
preview: CampaignAttachmentPreviewResponse | null;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
linking: boolean;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onLink: () => void;
|
||||
}) {
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const matchedFiles = attachmentPreviewMatchedFiles(preview);
|
||||
const linkableFiles = attachmentPreviewLinkableFiles(preview);
|
||||
const linkedCount =
|
||||
preview?.linked_file_count ??
|
||||
matchedFiles.filter((file) => file.linked_to_campaign !== false).length;
|
||||
const matchedCount = preview?.matched_file_count ?? matchedFiles.length;
|
||||
const unlinkedCount = preview?.unlinked_file_count ?? linkableFiles.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="review-flow-data-section attachment-linking-preview">
|
||||
<div className="attachment-linking-header">
|
||||
<div>
|
||||
<h3>i18n:govoplan-campaign.attachment_file_links.0be74fd1</h3>
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.preview_includes_already_linked_files_and_access.dfef92af
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
<Button onClick={onRefresh} disabled={loading || linking}>
|
||||
{loading
|
||||
? "i18n:govoplan-campaign.refreshing.505dddc9"
|
||||
: "i18n:govoplan-campaign.refresh.56e3badc"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onLink}
|
||||
disabled={disabled || loading || linking || unlinkedCount === 0}
|
||||
>
|
||||
{linking
|
||||
? "i18n:govoplan-campaign.linking.a5f54e0f"
|
||||
: i18nMessage(
|
||||
unlinkedCount === 1
|
||||
? "i18n:govoplan-campaign.link_value_file.4d4ce740"
|
||||
: "i18n:govoplan-campaign.link_value_files.88b7e6a7",
|
||||
{ value0: unlinkedCount }
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="review-flow-fact-grid">
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.matched.1bf3ec5b"
|
||||
value={
|
||||
!loading && matchedFiles.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-fact-detail-button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={detailsOpen}
|
||||
aria-label={i18nMessage(
|
||||
"i18n:govoplan-campaign.view_all_value_matched_attachment_file_links.81e53951",
|
||||
{ value0: matchedFiles.length }
|
||||
)}
|
||||
onClick={() => setDetailsOpen(true)}
|
||||
>
|
||||
{matchedCount}
|
||||
</button>
|
||||
) : loading ? (
|
||||
"..."
|
||||
) : (
|
||||
matchedCount || "—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.linked.a089f600"
|
||||
value={loading ? "..." : linkedCount || "—"}
|
||||
/>
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.need_link.fa4ab530"
|
||||
value={loading ? "..." : unlinkedCount || "—"}
|
||||
/>
|
||||
<WorkflowFact
|
||||
label="i18n:govoplan-campaign.shared_source.7d4a1bf2"
|
||||
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
||||
{!error && unlinkedCount > 0 && (
|
||||
<p className="review-flow-inline-note is-stale">
|
||||
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
|
||||
</p>
|
||||
)}
|
||||
{!error && !loading && matchedFiles.length === 0 && (
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.no_managed_files_matched_the_current_attachment_.dba99f5c
|
||||
</p>
|
||||
)}
|
||||
{matchedFiles.length > 0 && <AttachmentLinkingFileList files={matchedFiles} compact />}
|
||||
</div>
|
||||
<Dialog
|
||||
open={detailsOpen}
|
||||
title={i18nMessage(
|
||||
"i18n:govoplan-campaign.attachment_file_links_value.ce230e30",
|
||||
{ value0: matchedFiles.length }
|
||||
)}
|
||||
className="attachment-linking-detail-modal"
|
||||
bodyClassName="attachment-linking-detail-body"
|
||||
onClose={() => setDetailsOpen(false)}
|
||||
footer={
|
||||
<Button variant="primary" onClick={() => setDetailsOpen(false)}>
|
||||
i18n:govoplan-campaign.close.bbfa773e
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="muted small-note">
|
||||
i18n:govoplan-campaign.all_unique_managed_files_matched_by_the_current_.0214c3e5
|
||||
</p>
|
||||
<AttachmentLinkingFileList files={matchedFiles} />
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentLinkingFileList({
|
||||
files,
|
||||
compact = false
|
||||
}: {
|
||||
files: CampaignAttachmentPreviewFile[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ul
|
||||
className={`attachment-linking-file-list${compact ? " is-compact" : " is-detail"}`}
|
||||
tabIndex={0}
|
||||
aria-label={i18nMessage(
|
||||
files.length === 1
|
||||
? "i18n:govoplan-campaign.value_matched_attachment_file_link.30a84824"
|
||||
: "i18n:govoplan-campaign.value_matched_attachment_file_links.ce509a3a",
|
||||
{ value0: files.length }
|
||||
)}
|
||||
>
|
||||
{files.map((file, index) => {
|
||||
const linked = file.linked_to_campaign !== false;
|
||||
const Icon = linked ? Link2 : X;
|
||||
return (
|
||||
<li
|
||||
key={`${file.id || file.display_path || file.filename}:${index}`}
|
||||
data-linked={linked ? "true" : "false"}
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{file.filename || file.display_path}</strong>
|
||||
<small>
|
||||
{linked
|
||||
? "i18n:govoplan-campaign.linked_already_part_of_the_campaign_file_snapsho.d037a6bc"
|
||||
: "i18n:govoplan-campaign.unlinked_candidate_match_potentially_missing_unt.2bae9433"}
|
||||
</small>
|
||||
{file.display_path && file.display_path !== file.filename && (
|
||||
<small className="muted">{file.display_path}</small>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
312
webui/src/features/campaigns/review/BuiltMessagePreview.tsx
Normal file
312
webui/src/features/campaigns/review/BuiltMessagePreview.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Button, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import CampaignMessagePreviewOverlay, {
|
||||
type CampaignMessagePreviewAttachment
|
||||
} from "../components/MessagePreviewOverlay";
|
||||
import { asArray, asRecord, humanize } from "../utils/campaignView";
|
||||
import { getBool, getText } from "../utils/draftEditor";
|
||||
import {
|
||||
buildTemplatePreviewContext,
|
||||
renderTemplatePreviewText
|
||||
} from "../utils/templatePlaceholders";
|
||||
import {
|
||||
formatAddressList,
|
||||
formatSingleAddress,
|
||||
numberFrom,
|
||||
numberOrUndefined,
|
||||
stringOrUndefined
|
||||
} from "./reviewFormatters";
|
||||
|
||||
export default function BuiltMessagePreview({
|
||||
campaignJson,
|
||||
entries,
|
||||
rows,
|
||||
index,
|
||||
canStartSingleMessageSend,
|
||||
singleMessageSendBusy,
|
||||
onSelect,
|
||||
onSendSingle,
|
||||
onClose
|
||||
}: {
|
||||
campaignJson: Record<string, unknown>;
|
||||
entries: Record<string, unknown>[];
|
||||
rows: Record<string, unknown>[];
|
||||
index: number;
|
||||
canStartSingleMessageSend: boolean;
|
||||
singleMessageSendBusy: boolean;
|
||||
onSelect: (index: number) => void;
|
||||
onSendSingle: (index: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const row = rows[index] ?? {};
|
||||
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
|
||||
const entry = entries[entryIndex] ?? {};
|
||||
const template = asRecord(campaignJson.template);
|
||||
const context = buildTemplatePreviewContext(campaignJson, entry);
|
||||
const ignoreEmptyFields = getBool(
|
||||
asRecord(campaignJson.validation_policy),
|
||||
"ignore_empty_fields",
|
||||
false
|
||||
);
|
||||
const html = renderTemplatePreviewText(
|
||||
getText(template, "html"),
|
||||
context,
|
||||
ignoreEmptyFields
|
||||
);
|
||||
const text = renderTemplatePreviewText(
|
||||
getText(template, "text"),
|
||||
context,
|
||||
ignoreEmptyFields
|
||||
);
|
||||
const subject = String(
|
||||
row.subject ||
|
||||
renderTemplatePreviewText(
|
||||
getText(template, "subject"),
|
||||
context,
|
||||
ignoreEmptyFields
|
||||
) ||
|
||||
"i18n:govoplan-campaign.no_subject.7b4e8035"
|
||||
);
|
||||
const issues = asArray(row.issues).map(asRecord);
|
||||
const resolvedRecipients = asRecord(row.resolved_recipients);
|
||||
const singleSendDisabledReason = singleMessageSendDisabledReason(
|
||||
row,
|
||||
canStartSingleMessageSend
|
||||
);
|
||||
|
||||
return (
|
||||
<CampaignMessagePreviewOverlay
|
||||
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
|
||||
subject={subject}
|
||||
bodyMode={html.trim() ? "html" : "text"}
|
||||
text={text}
|
||||
html={html}
|
||||
recipientLabel={
|
||||
formatAddressList(resolvedRecipients.to) ||
|
||||
String(row.recipient_email ?? `Message ${index + 1}`)
|
||||
}
|
||||
recipientNote={
|
||||
issues.length > 0
|
||||
? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues
|
||||
.map((issue) =>
|
||||
String(
|
||||
issue.message ??
|
||||
issue.code ??
|
||||
"i18n:govoplan-campaign.issue.73781a12"
|
||||
)
|
||||
)
|
||||
.join(" · ")}`
|
||||
: "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"
|
||||
}
|
||||
metaItems={builtMessageMetaItems(row)}
|
||||
attachments={builtMessageAttachments(row)}
|
||||
navigation={{
|
||||
index,
|
||||
total: rows.length,
|
||||
onFirst: () => onSelect(0),
|
||||
onPrevious: () => onSelect(Math.max(0, index - 1)),
|
||||
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
|
||||
onLast: () => onSelect(rows.length - 1)
|
||||
}}
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
||||
title={singleSendDisabledReason || "Send only this built message"}
|
||||
onClick={() => onSendSingle(index)}
|
||||
>
|
||||
{singleMessageSendBusy ? "Sending..." : "Send this message..."}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function singleMessageSendDisabledReason(
|
||||
row: Record<string, unknown>,
|
||||
canStartSingleMessageSend: boolean
|
||||
): string {
|
||||
if (!canStartSingleMessageSend) {
|
||||
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
|
||||
}
|
||||
const jobId = String(row.id ?? "");
|
||||
if (!jobId) return "This preview row has no delivery job id.";
|
||||
const buildStatus = String(row.build_status ?? "");
|
||||
if (buildStatus !== "built") return "This message has not been built.";
|
||||
const validationStatus = String(row.validation_status ?? "");
|
||||
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
|
||||
return `This message is ${humanize(validationStatus)}.`;
|
||||
}
|
||||
const sendStatus = String(row.send_status ?? "not_queued");
|
||||
const queueStatus = String(row.queue_status ?? "draft");
|
||||
if (["smtp_accepted", "sent"].includes(sendStatus)) {
|
||||
return "This message was already accepted by SMTP.";
|
||||
}
|
||||
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
|
||||
return `This message is in delivery state ${humanize(sendStatus)}.`;
|
||||
}
|
||||
if (["failed_temporary", "failed_permanent"].includes(sendStatus)) {
|
||||
return "Use the explicit retry action for failed messages.";
|
||||
}
|
||||
if (sendStatus === "queued" && queueStatus === "queued") return "";
|
||||
if (
|
||||
["not_queued", "cancelled"].includes(sendStatus) &&
|
||||
["draft", "cancelled"].includes(queueStatus)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return `This message cannot be sent from queue ${humanize(queueStatus)} / send ${humanize(sendStatus)}.`;
|
||||
}
|
||||
|
||||
function builtMessageMetaItems(row: Record<string, unknown>) {
|
||||
const recipients = asRecord(row.resolved_recipients);
|
||||
return [
|
||||
{
|
||||
label: "i18n:govoplan-campaign.from.3f66052a",
|
||||
value: formatSingleAddress(recipients.from) || "—"
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.to.ae79ea1e",
|
||||
value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—")
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.cc.c5a976de",
|
||||
value: formatAddressList(recipients.cc) || null
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.bcc.4c0145a3",
|
||||
value: formatAddressList(recipients.bcc) || null
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.validation.dd74d182",
|
||||
value: String(row.validation_status ?? "—")
|
||||
},
|
||||
{
|
||||
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
|
||||
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function builtMessageAttachments(
|
||||
row: Record<string, unknown>
|
||||
): CampaignMessagePreviewAttachment[] {
|
||||
return asArray(row.attachments).flatMap((value, index) => {
|
||||
const attachment = asRecord(value);
|
||||
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
|
||||
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
|
||||
if (managedMatches.length > 0) {
|
||||
return managedMatches.map((match, matchIndex) => ({
|
||||
filename: String(
|
||||
match.filename ??
|
||||
match.display_path ??
|
||||
`Attachment ${index + 1}.${matchIndex + 1}`
|
||||
),
|
||||
detail: String(
|
||||
match.display_path ?? match.relative_path ?? attachment.label ?? ""
|
||||
),
|
||||
contentType: stringOrUndefined(match.content_type),
|
||||
sizeBytes: numberOrUndefined(match.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
const matches = asArray(attachment.matches).filter(
|
||||
(match): match is string => typeof match === "string" && Boolean(match.trim())
|
||||
);
|
||||
if (matches.length > 0) {
|
||||
return matches.map((match) => ({
|
||||
filename:
|
||||
match.split(/[\\/]/).pop() ||
|
||||
String(attachment.label ?? `Attachment ${index + 1}`),
|
||||
detail: match,
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
||||
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
||||
protected: zipProtection.protected,
|
||||
protectionNote: zipProtection.note
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
filename: String(
|
||||
attachment.filename ??
|
||||
attachment.filename_used ??
|
||||
attachment.display_path ??
|
||||
attachment.label ??
|
||||
`Attachment ${index + 1}`
|
||||
),
|
||||
detail: String(
|
||||
attachment.display_path ??
|
||||
attachment.source_path ??
|
||||
attachment.file_filter ??
|
||||
""
|
||||
),
|
||||
contentType: stringOrUndefined(attachment.content_type),
|
||||
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
||||
archiveGroup: null,
|
||||
archiveLabel: null,
|
||||
protected: false,
|
||||
protectionNote: null
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function zipProtectionFromBuiltAttachment(
|
||||
attachment: Record<string, unknown>
|
||||
): { protected: boolean; note: string | null } {
|
||||
const zipFilename = stringOrUndefined(attachment.zip_filename);
|
||||
if (!zipFilename) return { protected: false, note: null };
|
||||
const legacyMode = String(
|
||||
attachment.password_mode ?? attachment.zip_password_mode ?? ""
|
||||
).trim();
|
||||
const protectedArchive = getBool(
|
||||
attachment,
|
||||
"password_enabled",
|
||||
getBool(
|
||||
attachment,
|
||||
"zip_password_protected",
|
||||
getBool(
|
||||
attachment,
|
||||
"zip_protected",
|
||||
["direct", "field", "template"].includes(legacyMode)
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!protectedArchive) return { protected: false, note: null };
|
||||
const field =
|
||||
stringOrUndefined(attachment.password_field) ??
|
||||
stringOrUndefined(attachment.zip_password_field);
|
||||
const rawScope = String(
|
||||
attachment.password_scope ?? attachment.zip_password_scope ?? "local"
|
||||
);
|
||||
const scope = rawScope === "global" ? "global" : "local";
|
||||
const method =
|
||||
String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard"
|
||||
? "i18n:govoplan-campaign.zipcrypto.03bf7fb4"
|
||||
: "i18n:govoplan-campaign.aes.41f215a6";
|
||||
const source = field
|
||||
? i18nMessage("i18n:govoplan-campaign.scope_field_value", {
|
||||
value0: humanizeScope(scope),
|
||||
value1: field
|
||||
})
|
||||
: "";
|
||||
return {
|
||||
protected: true,
|
||||
note: source
|
||||
? i18nMessage("i18n:govoplan-campaign.value_encryption_value", {
|
||||
value0: source,
|
||||
value1: method
|
||||
})
|
||||
: i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
||||
};
|
||||
}
|
||||
|
||||
function humanizeScope(scope: string): string {
|
||||
return scope === "global"
|
||||
? "i18n:govoplan-campaign.global.5f1184f7"
|
||||
: "i18n:govoplan-campaign.local.dc99d54d";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
|
||||
import { humanize } from "../utils/campaignView";
|
||||
|
||||
export type DeliverabilityPreflightItem = {
|
||||
label: string;
|
||||
detail: string;
|
||||
state: "ready" | "warning" | "blocked" | "info";
|
||||
};
|
||||
|
||||
export default function DeliverabilityPreflight({
|
||||
items
|
||||
}: {
|
||||
items: DeliverabilityPreflightItem[];
|
||||
}) {
|
||||
return (
|
||||
<section className="deliverability-preflight" aria-label="Deliverability preflight">
|
||||
<div className="deliverability-preflight-header">
|
||||
<h3>Deliverability preflight</h3>
|
||||
<span className="muted small-note">Operator checks before the first live send.</span>
|
||||
</div>
|
||||
<div className="deliverability-preflight-grid">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="deliverability-preflight-item"
|
||||
data-state={item.state}
|
||||
>
|
||||
<div>
|
||||
<span>{item.label}</span>
|
||||
<strong>{item.detail}</strong>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={
|
||||
item.state === "blocked"
|
||||
? "failed"
|
||||
: item.state === "warning"
|
||||
? "warning"
|
||||
: item.state === "ready"
|
||||
? "ready"
|
||||
: "info"
|
||||
}
|
||||
label={humanize(item.state)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
136
webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx
Normal file
136
webui/src/features/campaigns/review/DeliveryJobDetailOverlay.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import { asArray, asRecord, formatDateTime, humanize } from "../utils/campaignView";
|
||||
import { formatAddressList } from "./reviewFormatters";
|
||||
|
||||
export default function DeliveryJobDetailOverlay({
|
||||
detail,
|
||||
onClose
|
||||
}: {
|
||||
detail: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const job = asRecord(detail.job);
|
||||
const attempts = asRecord(detail.attempts);
|
||||
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
|
||||
const imapAttempts = asArray(attempts.imap).map(asRecord);
|
||||
const issues = asArray(job.issues).map(asRecord);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="i18n:govoplan-campaign.delivery_job_details.0d9c5b1f"
|
||||
className="template-preview-modal"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
i18n:govoplan-campaign.close.bbfa773e
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.recipient.90343260</dt>
|
||||
<dd>
|
||||
{formatAddressList(asRecord(job.resolved_recipients).to) ||
|
||||
String(job.recipient_email ?? "-")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.subject.8d183dbd</dt>
|
||||
<dd>{String(job.subject ?? "-")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.smtp_status.6211cb2b</dt>
|
||||
<dd>{humanize(String(job.send_status ?? "-"))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.imap_status.dbc2e430</dt>
|
||||
<dd>{humanize(String(job.imap_status ?? "-"))}</dd>
|
||||
</div>
|
||||
{job.last_error ? (
|
||||
<div>
|
||||
<dt>i18n:govoplan-campaign.last_error.5e4df866</dt>
|
||||
<dd>{String(job.last_error)}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
{issues.length > 0 && (
|
||||
<AttemptList
|
||||
title="i18n:govoplan-campaign.message_issues.9092a7db"
|
||||
rows={issues}
|
||||
/>
|
||||
)}
|
||||
<AttemptList
|
||||
title="i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
|
||||
rows={smtpAttempts}
|
||||
emptyText="i18n:govoplan-campaign.no_smtp_attempts_were_recorded.ff5b4c5d"
|
||||
/>
|
||||
<AttemptList
|
||||
title="i18n:govoplan-campaign.imap_attempts.e815f0c2"
|
||||
rows={imapAttempts}
|
||||
emptyText="i18n:govoplan-campaign.no_imap_append_attempts_were_recorded_if_status_.12279f7e"
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function AttemptList({
|
||||
title,
|
||||
rows,
|
||||
emptyText = "i18n:govoplan-campaign.no_rows.fdbeab75"
|
||||
}: {
|
||||
title: string;
|
||||
rows: Record<string, unknown>[];
|
||||
emptyText?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="review-flow-data-section">
|
||||
<h3>{title}</h3>
|
||||
{rows.length === 0 ? (
|
||||
<p className="muted small-note">{emptyText}</p>
|
||||
) : (
|
||||
<dl className="detail-list">
|
||||
{rows.map((row, index) => (
|
||||
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
|
||||
<dt>
|
||||
{String(
|
||||
row.status ??
|
||||
row.severity ??
|
||||
row.code ??
|
||||
i18nMessage("i18n:govoplan-campaign.value.44b8c76f", {
|
||||
value0: index + 1
|
||||
})
|
||||
)}
|
||||
</dt>
|
||||
<dd>
|
||||
<strong>
|
||||
{String(
|
||||
row.message ??
|
||||
row.error_message ??
|
||||
row.smtp_response ??
|
||||
row.folder ??
|
||||
row.path ??
|
||||
"-"
|
||||
)}
|
||||
</strong>
|
||||
{row.started_at || row.created_at ? (
|
||||
<span className="muted">
|
||||
{" · "}
|
||||
{formatDateTime(String(row.started_at ?? row.created_at))}
|
||||
</span>
|
||||
) : null}
|
||||
{row.finished_at || row.updated_at ? (
|
||||
<span className="muted">
|
||||
{" → "}
|
||||
{formatDateTime(String(row.finished_at ?? row.updated_at))}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
229
webui/src/features/campaigns/review/WorkflowNavigation.tsx
Normal file
229
webui/src/features/campaigns/review/WorkflowNavigation.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, LockKeyhole, type LucideIcon } from "lucide-react";
|
||||
import { InlineHelp, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import { humanize } from "../utils/campaignView";
|
||||
|
||||
export type FlowState =
|
||||
| "complete"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "active"
|
||||
| "locked"
|
||||
| "running"
|
||||
| "partial"
|
||||
| "stale"
|
||||
| "pending";
|
||||
|
||||
export type FlowStageDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
state: FlowState;
|
||||
connectorState?: FlowState;
|
||||
stateLabel: string;
|
||||
lockReason?: string;
|
||||
};
|
||||
|
||||
const stateColors: Record<FlowState, string> = {
|
||||
complete: "var(--green)",
|
||||
warning: "var(--amber)",
|
||||
danger: "var(--red)",
|
||||
active: "var(--blue)",
|
||||
locked: "var(--line-dark)",
|
||||
running: "var(--blue)",
|
||||
partial: "var(--review-flow-partial)",
|
||||
stale: "var(--review-flow-partial)",
|
||||
pending: "var(--muted)"
|
||||
};
|
||||
|
||||
export function WorkflowNavigation({
|
||||
stages,
|
||||
onSelect
|
||||
}: {
|
||||
stages: FlowStageDefinition[];
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="review-flow-navigation" aria-label="i18n:govoplan-campaign.review_and_send_workflow_steps.77fc8af2">
|
||||
<div className="review-flow-navigation-track">
|
||||
{stages.map((stage, index) => {
|
||||
const Icon = stage.icon;
|
||||
const nextStage = stages[index + 1];
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const nextConnectorState = nextStage ? stageConnectorState(nextStage) : connectorState;
|
||||
const title = i18nMessage(stage.title);
|
||||
const shortTitle = i18nMessage(stage.shortTitle);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const style = {
|
||||
"--review-nav-color": stateColors[stage.state],
|
||||
"--review-nav-line-color": stateColors[connectorState],
|
||||
"--review-nav-line-next-color": stateColors[nextConnectorState]
|
||||
} as CSSProperties;
|
||||
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
||||
return (
|
||||
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
||||
<button
|
||||
type="button"
|
||||
className="review-flow-navigation-item"
|
||||
data-state={stage.state}
|
||||
onClick={() => onSelect(stage.id)}
|
||||
title={`${title}: ${stateText}`}
|
||||
>
|
||||
<span className="review-flow-navigation-icon">
|
||||
<Icon size={17} strokeWidth={1.8} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="review-flow-navigation-copy">
|
||||
<strong>
|
||||
<span>{shortTitle}</span>
|
||||
{stage.state === "locked" && (
|
||||
<LockKeyhole size={12} aria-label="i18n:govoplan-campaign.locked.a798882f" />
|
||||
)}
|
||||
</strong>
|
||||
{showSecondaryState && <small>{stateText}</small>}
|
||||
</span>
|
||||
</button>
|
||||
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function stageConnectorState(stage: FlowStageDefinition): FlowState {
|
||||
return stage.connectorState ?? stage.state;
|
||||
}
|
||||
|
||||
export function WorkflowStage({
|
||||
stage,
|
||||
nextState,
|
||||
nextConnectorState,
|
||||
children
|
||||
}: {
|
||||
stage: FlowStageDefinition;
|
||||
nextState?: FlowState;
|
||||
nextConnectorState?: FlowState;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const Icon = stage.icon;
|
||||
const locked = stage.state === "locked";
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const title = i18nMessage(stage.title);
|
||||
const description = i18nMessage(stage.description);
|
||||
const stateText = i18nMessage(stage.stateLabel);
|
||||
const lockReason = stage.lockReason ? i18nMessage(stage.lockReason) : "";
|
||||
const connectorState = stageConnectorState(stage);
|
||||
const style = {
|
||||
"--review-stage-color": stateColors[stage.state],
|
||||
"--review-stage-line-color": stateColors[connectorState],
|
||||
"--review-next-stage-line-color": stateColors[nextConnectorState ?? connectorState]
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<section id={stage.id} className="review-flow-stage" data-state={stage.state} style={style}>
|
||||
<div className="review-flow-stage-marker" aria-hidden="true">
|
||||
<div className="review-flow-stage-node">
|
||||
<Icon size={20} strokeWidth={1.8} />
|
||||
</div>
|
||||
{nextState && <div className="review-flow-stage-line" />}
|
||||
</div>
|
||||
<article
|
||||
className={`card card-collapsible review-flow-stage-card${locked ? " is-locked" : ""}${collapsed ? " is-collapsed" : ""}`}
|
||||
aria-disabled={locked || undefined}
|
||||
>
|
||||
<header className="card-header review-flow-stage-header">
|
||||
<h2>
|
||||
<span>{title}</span>
|
||||
{locked && (
|
||||
<LockKeyhole
|
||||
className="review-flow-title-lock"
|
||||
size={15}
|
||||
aria-label="i18n:govoplan-campaign.locked.a798882f"
|
||||
/>
|
||||
)}
|
||||
{!locked && (
|
||||
<span
|
||||
className="review-flow-state-badge"
|
||||
data-state={stage.state}
|
||||
aria-label={stateText}
|
||||
title={stateText}
|
||||
>
|
||||
{stateText}
|
||||
</span>
|
||||
)}
|
||||
<InlineHelp>{description}</InlineHelp>
|
||||
</h2>
|
||||
<div className="review-flow-stage-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="card-collapse-toggle"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={
|
||||
collapsed
|
||||
? i18nMessage("i18n:govoplan-campaign.expand_value.be085ae4", { value0: title })
|
||||
: i18nMessage("i18n:govoplan-campaign.collapse_value.29095640", { value0: title })
|
||||
}
|
||||
title={
|
||||
collapsed
|
||||
? "i18n:govoplan-campaign.show_content.0528d8d2"
|
||||
: "i18n:govoplan-campaign.show_header_only.24afefca"
|
||||
}
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
>
|
||||
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{!collapsed && (
|
||||
<>
|
||||
<div className="card-body review-flow-stage-content">{children}</div>
|
||||
{locked && (
|
||||
<div className="review-flow-lock-message">
|
||||
<span className="review-flow-lock-icon">
|
||||
<LockKeyhole size={20} aria-hidden="true" />
|
||||
</span>
|
||||
<span>{lockReason}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowFact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="review-flow-fact">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function stateLabel(state: FlowState): string {
|
||||
switch (state) {
|
||||
case "complete":
|
||||
return "i18n:govoplan-campaign.passed.271d60f4";
|
||||
case "warning":
|
||||
return "i18n:govoplan-campaign.warnings.1430f976";
|
||||
case "danger":
|
||||
return "i18n:govoplan-campaign.blocked.99613c74";
|
||||
case "active":
|
||||
return "i18n:govoplan-campaign.available.7c62a142";
|
||||
case "locked":
|
||||
return "i18n:govoplan-campaign.locked.a798882f";
|
||||
case "running":
|
||||
return "i18n:govoplan-campaign.running.73989d9c";
|
||||
case "partial":
|
||||
return "i18n:govoplan-campaign.partial.65de2e2a";
|
||||
case "stale":
|
||||
return "i18n:govoplan-campaign.stale.189cc40c";
|
||||
default:
|
||||
return humanize(state);
|
||||
}
|
||||
}
|
||||
258
webui/src/features/campaigns/review/builtMessageQuery.ts
Normal file
258
webui/src/features/campaigns/review/builtMessageQuery.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { DataGridQueryState } from "@govoplan/core-webui";
|
||||
|
||||
import type { CampaignVersionDetail } from "../../../api/campaigns";
|
||||
import { asArray, asRecord } from "../utils/campaignView";
|
||||
import { countResolvedAttachments, formatAddressList } from "./reviewFormatters";
|
||||
|
||||
type ReviewFilterType = "text" | "integer" | "list";
|
||||
type ReviewFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte";
|
||||
|
||||
export function filterAndSortBuiltMessageRows(
|
||||
rows: Record<string, unknown>[],
|
||||
query: DataGridQueryState,
|
||||
reviewedKeys: Set<string>
|
||||
): Record<string, unknown>[] {
|
||||
const filters = query.filters ?? {};
|
||||
const filtered = rows.filter((row, rowIndex) =>
|
||||
Object.entries(filters).every(([columnId, filterValue]) => {
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return true;
|
||||
if (!filterValue.trim()) return true;
|
||||
return matchesReviewFilter(
|
||||
builtMessageColumnValue(columnId, row, rowIndex, reviewedKeys),
|
||||
filterValue,
|
||||
builtMessageFilterType(columnId)
|
||||
);
|
||||
})
|
||||
);
|
||||
if (!query.sort) return filtered;
|
||||
const { columnId, direction } = query.sort;
|
||||
if (!isBuiltMessageQueryColumn(columnId)) return filtered;
|
||||
return [...filtered].sort((left, right) => {
|
||||
const leftIndex = rows.indexOf(left);
|
||||
const rightIndex = rows.indexOf(right);
|
||||
const result = compareReviewValues(
|
||||
builtMessageColumnValue(columnId, left, leftIndex, reviewedKeys),
|
||||
builtMessageColumnValue(columnId, right, rightIndex, reviewedKeys)
|
||||
);
|
||||
return direction === "desc" ? -result : result;
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewQueryEquals(
|
||||
left: DataGridQueryState,
|
||||
right: DataGridQueryState
|
||||
): boolean {
|
||||
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
|
||||
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
|
||||
const leftFilters = left.filters ?? {};
|
||||
const rightFilters = right.filters ?? {};
|
||||
const keys = new Set([...Object.keys(leftFilters), ...Object.keys(rightFilters)]);
|
||||
for (const key of keys) {
|
||||
if ((leftFilters[key] ?? "") !== (rightFilters[key] ?? "")) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function messageNeedsExplicitReview(row: Record<string, unknown>): boolean {
|
||||
return String(row.validation_status ?? "").toLowerCase() === "needs_review";
|
||||
}
|
||||
|
||||
export function storedMessageReviewState(version: CampaignVersionDetail | null): {
|
||||
buildToken: string;
|
||||
inspectionComplete: boolean;
|
||||
reviewedMessageKeys: string[];
|
||||
} {
|
||||
const build = asRecord(version?.build_summary);
|
||||
const buildToken = String(build.build_token ?? build.built_at ?? "");
|
||||
const review = asRecord(asRecord(version?.editor_state).review_send);
|
||||
if (!buildToken || String(review.build_token ?? "") !== buildToken) {
|
||||
return { buildToken, inspectionComplete: false, reviewedMessageKeys: [] };
|
||||
}
|
||||
return {
|
||||
buildToken,
|
||||
inspectionComplete: review.inspection_complete === true,
|
||||
reviewedMessageKeys: asArray(review.reviewed_message_keys).filter(
|
||||
(value): value is string => typeof value === "string"
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
export function builtMessageKey(row: Record<string, unknown>, index: number): string {
|
||||
return String(row.entry_id ?? row.entry_index ?? index);
|
||||
}
|
||||
|
||||
export function findBuiltMessageIndex(
|
||||
rows: Record<string, unknown>[],
|
||||
row: Record<string, unknown>
|
||||
): number {
|
||||
const id = String(row.id ?? "").trim();
|
||||
if (id) {
|
||||
const index = rows.findIndex((candidate) => String(candidate.id ?? "").trim() === id);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const reviewKey = String(row.review_key ?? "").trim();
|
||||
if (reviewKey) {
|
||||
const index = rows.findIndex(
|
||||
(candidate) => String(candidate.review_key ?? "").trim() === reviewKey
|
||||
);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
const entryKey = String(row.entry_id ?? row.entry_index ?? "").trim();
|
||||
if (entryKey) {
|
||||
const index = rows.findIndex(
|
||||
(candidate) =>
|
||||
String(candidate.entry_id ?? candidate.entry_index ?? "").trim() === entryKey
|
||||
);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
return rows.indexOf(row);
|
||||
}
|
||||
|
||||
export function sameBuiltMessage(
|
||||
left: Record<string, unknown>,
|
||||
leftIndex: number,
|
||||
right: Record<string, unknown>,
|
||||
rightIndex: number
|
||||
): boolean {
|
||||
const leftId = String(left.id ?? "").trim();
|
||||
const rightId = String(right.id ?? "").trim();
|
||||
if (leftId && rightId) return leftId === rightId;
|
||||
const leftReviewKey = String(left.review_key ?? "").trim();
|
||||
const rightReviewKey = String(right.review_key ?? "").trim();
|
||||
if (leftReviewKey && rightReviewKey) return leftReviewKey === rightReviewKey;
|
||||
const leftEntryKey = String(left.entry_id ?? left.entry_index ?? "").trim();
|
||||
const rightEntryKey = String(right.entry_id ?? right.entry_index ?? "").trim();
|
||||
if (leftEntryKey && rightEntryKey) return leftEntryKey === rightEntryKey;
|
||||
return left === right || leftIndex === rightIndex;
|
||||
}
|
||||
|
||||
function isBuiltMessageQueryColumn(columnId: string): boolean {
|
||||
return ["number", "recipient", "subject", "validation", "attachments", "reviewed"].includes(columnId);
|
||||
}
|
||||
|
||||
function builtMessageColumnValue(
|
||||
columnId: string,
|
||||
row: Record<string, unknown>,
|
||||
index: number,
|
||||
reviewedKeys: Set<string>
|
||||
): unknown {
|
||||
switch (columnId) {
|
||||
case "number":
|
||||
return Number(row.entry_index ?? index + 1);
|
||||
case "recipient":
|
||||
return (
|
||||
formatAddressList(asRecord(row.resolved_recipients).to) ||
|
||||
String(row.recipient_email ?? "—")
|
||||
);
|
||||
case "subject":
|
||||
return String(row.subject ?? "—");
|
||||
case "validation":
|
||||
return String(row.validation_status ?? "unknown");
|
||||
case "attachments":
|
||||
return Number(row.attachment_count ?? countResolvedAttachments(row.attachments));
|
||||
case "reviewed":
|
||||
return row.reviewed === true ||
|
||||
reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index)))
|
||||
? "yes"
|
||||
: "no";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function builtMessageFilterType(columnId: string): ReviewFilterType {
|
||||
if (["number", "attachments"].includes(columnId)) return "integer";
|
||||
if (["validation", "reviewed"].includes(columnId)) return "list";
|
||||
return "text";
|
||||
}
|
||||
|
||||
function matchesReviewFilter(
|
||||
value: unknown,
|
||||
filterValue: string,
|
||||
filterType: ReviewFilterType
|
||||
): boolean {
|
||||
if (!filterValue.trim()) return true;
|
||||
if (filterType === "list") {
|
||||
const selected = parseReviewListFilter(filterValue);
|
||||
return selected.includes(stringifyReviewCell(value));
|
||||
}
|
||||
if (filterType === "integer") {
|
||||
const parsed = parseReviewTypedFilter(filterValue);
|
||||
if (!parsed.value.trim()) return true;
|
||||
const actual = parseReviewNumber(value);
|
||||
const expected = Number(parsed.value);
|
||||
if (!Number.isFinite(expected) || !Number.isFinite(actual)) return false;
|
||||
return compareReviewByOperator(actual, expected, parsed.operator);
|
||||
}
|
||||
return stringifyReviewCell(value)
|
||||
.toLowerCase()
|
||||
.includes(filterValue.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseReviewListFilter(value: string): string[] {
|
||||
if (value.startsWith("list:")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value.slice(5));
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseReviewTypedFilter(
|
||||
value: string
|
||||
): { operator: ReviewFilterOperator; value: string } {
|
||||
if (!value.includes(":")) return { operator: "eq", value };
|
||||
const [operator, ...parts] = value.split(":");
|
||||
if (
|
||||
operator === "contains" ||
|
||||
operator === "eq" ||
|
||||
operator === "gt" ||
|
||||
operator === "gte" ||
|
||||
operator === "lt" ||
|
||||
operator === "lte"
|
||||
) {
|
||||
return { operator, value: parts.join(":") };
|
||||
}
|
||||
return { operator: "eq", value };
|
||||
}
|
||||
|
||||
function parseReviewNumber(value: unknown): number {
|
||||
if (typeof value === "number") return value;
|
||||
const text = stringifyReviewCell(value).replace(/[^0-9.,+-]/g, "").replace(",", ".");
|
||||
if (!text.trim()) return Number.NaN;
|
||||
return Number(text);
|
||||
}
|
||||
|
||||
function compareReviewByOperator(
|
||||
actual: number,
|
||||
expected: number,
|
||||
operator: ReviewFilterOperator
|
||||
): boolean {
|
||||
if (operator === "gt") return actual > expected;
|
||||
if (operator === "gte") return actual >= expected;
|
||||
if (operator === "lt") return actual < expected;
|
||||
if (operator === "lte") return actual <= expected;
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
function compareReviewValues(left: unknown, right: unknown): number {
|
||||
if (typeof left === "number" && typeof right === "number") return left - right;
|
||||
return stringifyReviewCell(left).localeCompare(stringifyReviewCell(right), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base"
|
||||
});
|
||||
}
|
||||
|
||||
function stringifyReviewCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(stringifyReviewCell).join(", ");
|
||||
return "";
|
||||
}
|
||||
66
webui/src/features/campaigns/review/reviewFormatters.ts
Normal file
66
webui/src/features/campaigns/review/reviewFormatters.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { asArray, asRecord } from "../utils/campaignView";
|
||||
|
||||
export function formatAddressList(value: unknown): string {
|
||||
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
|
||||
}
|
||||
|
||||
export function formatSingleAddress(value: unknown): string {
|
||||
const address = asRecord(value);
|
||||
const email = String(address.email ?? "").trim();
|
||||
const name = String(address.name ?? "").trim();
|
||||
if (name && email) return `${name} <${email}>`;
|
||||
return email || name;
|
||||
}
|
||||
|
||||
export function countResolvedAttachments(value: unknown): number {
|
||||
const archives = new Set<string>();
|
||||
let directCount = 0;
|
||||
for (const item of asArray(value)) {
|
||||
const attachment = asRecord(item);
|
||||
const zipFilename = String(attachment.zip_filename ?? "").trim();
|
||||
const managedCount = asArray(attachment.managed_matches).length;
|
||||
const matchCount = asArray(attachment.matches).length;
|
||||
if (zipFilename && (managedCount > 0 || matchCount > 0)) {
|
||||
archives.add(zipFilename);
|
||||
continue;
|
||||
}
|
||||
if (managedCount > 0) {
|
||||
directCount += managedCount;
|
||||
continue;
|
||||
}
|
||||
directCount += matchCount;
|
||||
}
|
||||
return directCount + archives.size;
|
||||
}
|
||||
|
||||
export function numberFrom(record: Record<string, unknown>, keys: string[]): number {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim() &&
|
||||
Number.isFinite(Number(value))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function numberOrUndefined(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
value.trim() &&
|
||||
Number.isFinite(Number(value))
|
||||
) {
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function stringOrUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
168
webui/src/features/campaigns/utils/addressSourceImport.ts
Normal file
168
webui/src/features/campaigns/utils/addressSourceImport.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { CampaignRecipientAddressSourceSnapshot } from "../../../api/campaigns";
|
||||
import type {
|
||||
ImportedRecipientRow,
|
||||
RecipientImportMode,
|
||||
RecipientImportPreview,
|
||||
RecipientImportProvenance,
|
||||
RecipientImportTable
|
||||
} from "./bulkImport";
|
||||
|
||||
export function buildAddressSourceImportPreview(
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
existingEntries: Record<string, unknown>[]
|
||||
): RecipientImportPreview {
|
||||
const headers = [
|
||||
"display_name",
|
||||
"email",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"organization",
|
||||
"role_title",
|
||||
"phone",
|
||||
"tags"
|
||||
];
|
||||
const tableRows = [
|
||||
headers,
|
||||
...snapshot.recipients.map((recipient) =>
|
||||
headers.map((header) => {
|
||||
if (header === "display_name") return recipient.display_name;
|
||||
if (header === "email") return recipient.email;
|
||||
return fieldValueToString((recipient.fields ?? {})[header]);
|
||||
})
|
||||
)
|
||||
];
|
||||
|
||||
const table: RecipientImportTable = {
|
||||
delimiter: ",",
|
||||
headers,
|
||||
headerRows: [headers],
|
||||
rows: tableRows,
|
||||
dataRows: tableRows.slice(1),
|
||||
firstDataRowNumber: 2
|
||||
};
|
||||
const usedIds = new Set(
|
||||
existingEntries.map((entry) => String(entry.id || "")).filter(Boolean)
|
||||
);
|
||||
const fieldNamesToCreate = new Set<string>();
|
||||
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
|
||||
const email = recipient.email.trim();
|
||||
const name = recipient.display_name.trim();
|
||||
const fields = stringFieldsFromAddressSource(recipient.fields);
|
||||
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
|
||||
const id = uniqueImportId(
|
||||
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
|
||||
usedIds
|
||||
);
|
||||
const issues: string[] = [];
|
||||
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
|
||||
return {
|
||||
rowNumber: table.firstDataRowNumber + index,
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
active: true,
|
||||
addresses: {
|
||||
from: [],
|
||||
to: email ? [{ name, email }] : [],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
reply_to: []
|
||||
},
|
||||
fields,
|
||||
patterns: [],
|
||||
issues
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
table,
|
||||
rows,
|
||||
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
|
||||
validCount: rows.filter((row) => row.issues.length === 0).length,
|
||||
invalidCount: rows.filter((row) => row.issues.length > 0).length,
|
||||
patternCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function createAddressSourceImportProvenance(
|
||||
snapshot: CampaignRecipientAddressSourceSnapshot,
|
||||
preview: RecipientImportPreview,
|
||||
mode: RecipientImportMode
|
||||
): RecipientImportProvenance {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
|
||||
imported_at: now,
|
||||
mode,
|
||||
source_type: "addresses",
|
||||
source_id: snapshot.source_id,
|
||||
source_label: snapshot.source_label,
|
||||
source_revision: snapshot.source_revision,
|
||||
source_provenance: snapshot.provenance,
|
||||
filename: null,
|
||||
sheet_name: null,
|
||||
encoding: null,
|
||||
delimiter: null,
|
||||
header_rows: 0,
|
||||
quoted: null,
|
||||
value_separators: null,
|
||||
rows_total: preview.rows.length,
|
||||
valid_rows: preview.validCount,
|
||||
invalid_rows: preview.invalidCount,
|
||||
imported_rows: preview.validCount,
|
||||
field_names_created: preview.fieldNamesToCreate.slice(),
|
||||
attachment_patterns: 0,
|
||||
mapping: []
|
||||
};
|
||||
}
|
||||
|
||||
export function fieldValueToString(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(fieldValueToString).filter(Boolean).join(", ");
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function stringFieldsFromAddressSource(
|
||||
value: Record<string, unknown> | undefined
|
||||
): Record<string, string> {
|
||||
const fields: Record<string, string> = {};
|
||||
for (const [key, rawValue] of Object.entries(value ?? {})) {
|
||||
const fieldValue = fieldValueToString(rawValue);
|
||||
if (fieldValue) fields[key] = fieldValue;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function idFragmentFromEmail(value: string): string {
|
||||
return safeImportIdFragment(value.split("@")[0] ?? "");
|
||||
}
|
||||
|
||||
function safeImportIdFragment(value: string): string {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_.-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "source"
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
|
||||
const base = safeImportIdFragment(preferred) || "recipient";
|
||||
let candidate = base;
|
||||
let counter = 2;
|
||||
while (usedIds.has(candidate)) {
|
||||
candidate = `${base}-${counter}`;
|
||||
counter += 1;
|
||||
}
|
||||
usedIds.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { asArray, asRecord } from "./campaignView";
|
||||
import { getBool, getText } from "./draftEditor";
|
||||
|
||||
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
|
||||
export const fieldTypeOptions = [
|
||||
"string",
|
||||
"integer",
|
||||
"double",
|
||||
"date",
|
||||
"password",
|
||||
"organization_unit",
|
||||
"organization_function"
|
||||
] as const;
|
||||
export type CampaignFieldType = typeof fieldTypeOptions[number];
|
||||
|
||||
export type CampaignFieldDefinition = {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type CampaignJobSortColumn =
|
||||
| "validation"
|
||||
| "queue"
|
||||
| "send"
|
||||
| "postbox"
|
||||
| "imap"
|
||||
| "attempts"
|
||||
| "updated";
|
||||
@@ -31,6 +32,7 @@ const FILTER_PARAMETERS: Record<string, string> = {
|
||||
validation: "filter_validation",
|
||||
queue: "filter_queue",
|
||||
send: "filter_send",
|
||||
postbox: "filter_postbox",
|
||||
imap: "filter_imap",
|
||||
attempts: "filter_attempts",
|
||||
evidence: "filter_evidence"
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
|
||||
const server = isRecord(value.server) ? value.server : {};
|
||||
const rawProfileId = server.mail_profile_id;
|
||||
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : "";
|
||||
const profileId = textId(server.mail_profile_id);
|
||||
const smtpServerId = textId(server.smtp_server_id);
|
||||
const smtpCredentialId = textId(server.smtp_credential_id);
|
||||
const imapServerId = textId(server.imap_server_id);
|
||||
const imapCredentialId = textId(server.imap_credential_id);
|
||||
const reference: Record<string, string> = {};
|
||||
if (profileId) reference.mail_profile_id = profileId;
|
||||
if (profileId && smtpServerId) reference.smtp_server_id = smtpServerId;
|
||||
if (profileId && smtpServerId && smtpCredentialId) reference.smtp_credential_id = smtpCredentialId;
|
||||
if (profileId && imapServerId) reference.imap_server_id = imapServerId;
|
||||
if (profileId && imapServerId && imapCredentialId) reference.imap_credential_id = imapCredentialId;
|
||||
return {
|
||||
...value,
|
||||
server: profileId ? { mail_profile_id: profileId } : {}
|
||||
server: reference
|
||||
};
|
||||
}
|
||||
|
||||
function textId(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import {
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
@@ -755,6 +756,7 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
||||
], [navigate, permissions.canOpenReport, selectedRow, selectedVersionId]);
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
@@ -844,7 +846,8 @@ export default function OperatorQueuePage({ settings, auth }: {settings: ApiSett
|
||||
if (cancelTarget) void runAction(cancelTarget, "cancel");
|
||||
}}
|
||||
onCancel={() => setCancelTarget(null)} />
|
||||
</div>);
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
6
webui/src/features/operator/operatorQueueAccess.ts
Normal file
6
webui/src/features/operator/operatorQueueAccess.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const OPERATOR_QUEUE_ROUTE_SCOPES = [
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:campaign:control"
|
||||
];
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DismissibleAlert,
|
||||
LoadingFrame,
|
||||
MetricCard,
|
||||
PageScrollViewport,
|
||||
PageTitle,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
@@ -165,7 +166,8 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
||||
const outcomes = report?.outcomes;
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<PageScrollViewport className="aggregate-reports-page">
|
||||
<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>
|
||||
@@ -212,6 +214,9 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
||||
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="Postbox accepted" value={countValue(outcomes.postbox_accepted)} tone="good" />
|
||||
<MetricCard label="Both channels accepted" value={countValue(outcomes.delivered)} tone="good" />
|
||||
<MetricCard label="Partially accepted" value={countValue(outcomes.partially_accepted)} tone="warning" />
|
||||
<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" />
|
||||
@@ -245,7 +250,8 @@ export default function AggregateReportsPage({ settings }: {settings: ApiSetting
|
||||
</>
|
||||
)}
|
||||
</LoadingFrame>
|
||||
</div>
|
||||
</div>
|
||||
</PageScrollViewport>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { PageScrollViewport } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { FieldLabel } from "@govoplan/core-webui";
|
||||
import { StatusBadge, TableActionGroup, i18nMessage } from "@govoplan/core-webui";
|
||||
@@ -164,6 +165,7 @@ export default function TemplatesPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageScrollViewport>
|
||||
<div className="content-pad workspace-data-page module-entry-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
<div>
|
||||
@@ -194,7 +196,8 @@ export default function TemplatesPage() {
|
||||
className="compact-table-wrap module-table-wrap module-entry-table" />
|
||||
|
||||
</Card>
|
||||
</div>);
|
||||
</div>
|
||||
</PageScrollViewport>);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,6 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.policy.0b779a05": "Policy",
|
||||
"i18n:govoplan-campaign.resource.d1c626a9": "Resource",
|
||||
"i18n:govoplan-campaign.role.b5b4a5a2": "Role",
|
||||
"i18n:govoplan-campaign.share.09ca55ca": "Share",
|
||||
"i18n:govoplan-campaign.actions.c3cd636a": "Actions",
|
||||
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
||||
"i18n:govoplan-campaign.active.a733b809": "Active",
|
||||
@@ -1440,7 +1439,6 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.policy.0b779a05": "Richtlinie",
|
||||
"i18n:govoplan-campaign.resource.d1c626a9": "Ressource",
|
||||
"i18n:govoplan-campaign.role.b5b4a5a2": "Rolle",
|
||||
"i18n:govoplan-campaign.share.09ca55ca": "Freigabe",
|
||||
"i18n:govoplan-campaign.actions.c3cd636a": "Aktionen",
|
||||
"i18n:govoplan-campaign.active_inline_recipients.8ba58f6e": "Active inline recipients",
|
||||
"i18n:govoplan-campaign.active.a733b809": "Aktiv",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user