Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19437ce378 | ||
|
|
8bca4fc728 | ||
|
|
c51fc180fb | ||
|
|
1b32427813 | ||
|
|
c21fb4cf7c | ||
|
|
b41f23c901 | ||
|
|
3934e7fedb | ||
|
|
1bd24f9b5b | ||
|
|
4f52f010ee | ||
|
|
2630498026 | ||
|
|
c2f083e5f6 | ||
|
|
5a21067e44 | ||
|
|
73cfad209a | ||
|
|
2a00d910df | ||
|
|
c846c249b8 | ||
|
|
69e6588a89 | ||
|
|
75e8f864a7 | ||
|
|
d5b874c469 | ||
|
|
5c27527725 | ||
|
|
112ef9dc31 | ||
|
|
f4fe534ee1 | ||
|
|
b6af7665f4 | ||
|
|
14e94873a9 | ||
|
|
8e2f9d743d | ||
|
|
039ce35e78 | ||
|
|
7bc5e4a35c | ||
|
|
06f773e4eb | ||
|
|
9a2f13bc9a | ||
|
|
cf8d7fab11 | ||
|
|
68459fde15 | ||
|
|
c2efd6b7bd | ||
|
|
696f8f6385 | ||
|
|
6562484d32 | ||
|
|
2db99eaf6a | ||
|
|
ea0efe661f | ||
|
|
6bde11a286 | ||
|
|
585493fe7a | ||
|
|
4133de86cd | ||
|
|
9137300780 | ||
|
|
f98fe06143 | ||
|
|
dc63e35550 | ||
|
|
2dac5570cd | ||
|
|
91890fdaf5 | ||
|
|
8f5231147d | ||
|
|
df5a93d6a3 | ||
|
|
3f3545f080 | ||
|
|
d9195a2d2b | ||
|
|
d635f3a5fc | ||
|
|
1d6c745991 | ||
|
|
5df26be074 | ||
|
|
50ce8b0acb | ||
|
|
9da03090a7 | ||
|
|
dd09b06c47 | ||
|
|
c6bbdae2e1 | ||
|
|
bf6e07f307 | ||
|
|
7733265cc8 | ||
|
|
b38597f2be | ||
|
|
4eeba62bbc | ||
|
|
2afdd38128 | ||
|
|
cec3d17bff | ||
|
|
82ddc0c34c | ||
|
|
fa4eb39e0b | ||
|
|
e689fdf495 | ||
|
|
5f7503598c | ||
|
|
cd223cbb95 | ||
|
|
4a120e8009 | ||
|
|
c769be39da | ||
|
|
46df12c025 | ||
|
|
961d5d1130 | ||
|
|
23b9a531d5 | ||
|
|
cc93945f79 | ||
|
|
101f3ccd7d | ||
|
|
2199187e8b | ||
|
|
dd9592a192 | ||
|
|
5240749ae1 | ||
|
|
f11c56e890 | ||
|
|
8d0a2608ed | ||
|
|
89ae14c032 | ||
|
|
38af25ee88 |
@@ -0,0 +1,270 @@
|
||||
name: Module Package Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing protected version tag to publish
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
publish-packages:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Select and validate protected release tag
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_TAG: ${{ inputs.release_tag }}
|
||||
TRIGGER_TAG: ${{ gitea.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
|
||||
case "$tag" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
|
||||
esac
|
||||
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
|
||||
tag_commit="$(git rev-list -n 1 "$tag")"
|
||||
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
|
||||
echo "Release tag is not contained in main" >&2
|
||||
exit 1
|
||||
}
|
||||
git checkout --detach "$tag"
|
||||
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
|
||||
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
|
||||
- name: Validate package versions
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
import tomllib
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag.removeprefix("v")
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
if project.get("version") != expected:
|
||||
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
|
||||
raise SystemExit("Python distribution name must use the govoplan-* namespace")
|
||||
webui = Path("webui/package.json")
|
||||
if webui.is_file():
|
||||
package = json.loads(webui.read_text(encoding="utf-8"))
|
||||
if package.get("version") != expected:
|
||||
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
|
||||
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
|
||||
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
|
||||
release = Path("webui/package.release.json")
|
||||
if release.is_file():
|
||||
release_package = json.loads(release.read_text(encoding="utf-8"))
|
||||
if (
|
||||
release_package.get("name") != package.get("name")
|
||||
or release_package.get("version") != expected
|
||||
):
|
||||
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
|
||||
PY
|
||||
- name: Build immutable package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
|
||||
rm -rf dist .package-webui
|
||||
python -m build --wheel --outdir dist
|
||||
python -m twine check dist/*.whl
|
||||
if [[ -f webui/package.json ]]; then
|
||||
mkdir .package-webui
|
||||
cp -a webui/. .package-webui/
|
||||
rm -rf .package-webui/node_modules .package-webui/dist
|
||||
if [[ -f .package-webui/package.release.json ]]; then
|
||||
cp .package-webui/package.release.json .package-webui/package.json
|
||||
fi
|
||||
node <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const path = ".package-webui/package.json";
|
||||
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
|
||||
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
|
||||
for (const group of groups) {
|
||||
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
|
||||
if (!name.startsWith("@govoplan/")) continue;
|
||||
if (typeof specifier !== "string") {
|
||||
throw new Error(`${group}.${name} must use a string version`);
|
||||
}
|
||||
const packageSlug = name.slice("@govoplan/".length);
|
||||
if (!packageSlug.endsWith("-webui")) {
|
||||
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
|
||||
}
|
||||
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
|
||||
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const gitTag = specifier.match(
|
||||
new RegExp(
|
||||
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
|
||||
),
|
||||
);
|
||||
if (gitTag) {
|
||||
packageJson[group][name] = gitTag[1];
|
||||
continue;
|
||||
}
|
||||
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
|
||||
throw new Error(
|
||||
`${group}.${name} must resolve to an exact registry version for publication`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete packageJson.private;
|
||||
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
npm pkg delete private --prefix .package-webui
|
||||
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
|
||||
fi
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
artifacts = []
|
||||
for path in sorted(Path("dist").iterdir()):
|
||||
if path.suffix not in {".whl", ".tgz"}:
|
||||
continue
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
|
||||
payload = {
|
||||
"schema_version": "1",
|
||||
"repository": os.environ["GITEA_REPOSITORY"],
|
||||
"tag": os.environ["RELEASE_TAG"],
|
||||
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
Path("dist/package-artifacts.json").write_text(
|
||||
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
- name: Retain package hash evidence
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
|
||||
with:
|
||||
name: module-packages-${{ gitea.ref_name }}
|
||||
path: dist/package-artifacts.json
|
||||
- name: Check immutable registry state
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tomllib
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
|
||||
token = os.environ["PACKAGE_TOKEN"]
|
||||
|
||||
def should_publish(kind, name, version, path):
|
||||
package_url = "/".join(
|
||||
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
|
||||
)
|
||||
request = Request(
|
||||
package_url,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
files = json.load(response)
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
print(f"{kind} package {name}=={version} is not published yet")
|
||||
return True
|
||||
raise
|
||||
if not isinstance(files, list) or len(files) != 1:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} has an unexpected file set"
|
||||
)
|
||||
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if files[0].get("sha256") != expected_sha256:
|
||||
raise SystemExit(
|
||||
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
|
||||
)
|
||||
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
|
||||
return False
|
||||
|
||||
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
|
||||
wheels = tuple(Path("dist").glob("*.whl"))
|
||||
if len(wheels) != 1:
|
||||
raise SystemExit("release build must contain exactly one wheel")
|
||||
publish_pypi = should_publish(
|
||||
"pypi", str(project["name"]), str(project["version"]), wheels[0]
|
||||
)
|
||||
|
||||
tarballs = tuple(Path("dist").glob("*.tgz"))
|
||||
if len(tarballs) > 1:
|
||||
raise SystemExit("release build must contain at most one npm package")
|
||||
publish_npm = False
|
||||
if tarballs:
|
||||
webui = json.loads(
|
||||
Path(".package-webui/package.json").read_text(encoding="utf-8")
|
||||
)
|
||||
publish_npm = should_publish(
|
||||
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
|
||||
)
|
||||
|
||||
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
|
||||
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
|
||||
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
|
||||
PY
|
||||
- name: Publish wheel and WebUI package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
|
||||
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$PACKAGE_USERNAME"
|
||||
test -n "$PACKAGE_TOKEN"
|
||||
if [[ "$PUBLISH_PYPI" == 1 ]]; then
|
||||
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
|
||||
python -m twine upload --non-interactive \
|
||||
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
|
||||
dist/*.whl
|
||||
else
|
||||
echo "Exact wheel is already present; skipping immutable retry."
|
||||
fi
|
||||
shopt -s nullglob
|
||||
webui_packages=(dist/*.tgz)
|
||||
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
|
||||
npmrc="$(mktemp)"
|
||||
trap 'rm -f "$npmrc"' EXIT
|
||||
chmod 600 "$npmrc"
|
||||
printf '%s\n' \
|
||||
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
|
||||
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
|
||||
> "$npmrc"
|
||||
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
|
||||
--ignore-scripts --access public \
|
||||
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
|
||||
elif (( ${#webui_packages[@]} )); then
|
||||
echo "Exact WebUI package is already present; skipping immutable retry."
|
||||
fi
|
||||
@@ -1,5 +1,11 @@
|
||||
# GovOPlaN Campaign Codex Guide
|
||||
|
||||
## Documentation Contract
|
||||
|
||||
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
|
||||
- Keep feature content here; `govoplan-docs` projects it without importing Campaign internals.
|
||||
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
|
||||
|
||||
## Scope
|
||||
|
||||
This repository owns the `campaigns` module: campaign authoring, validation, message building, attachment resolution, queue/review/send control, reports, campaign module manifest, and `@govoplan/campaign-webui`.
|
||||
|
||||
@@ -15,12 +15,32 @@ 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/reports`, `/campaigns/:campaignId/*`, and `/templates`
|
||||
|
||||
Core owns the auth facade, RBAC/capability contracts, database/session
|
||||
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
||||
optional platform module for tenant administration and tenant resolver behavior.
|
||||
Files and mail own their respective storage and transport capabilities.
|
||||
When the optional Reporting module is enabled, Campaign contributes its
|
||||
recipient-free aggregate delivery report through the versioned Core report
|
||||
provider contract. Reporting owns the global `/reports` route. Campaign keeps
|
||||
its module-local `/campaigns/reports` view and does not claim the global route
|
||||
when Reporting is absent.
|
||||
|
||||
Generated EML and printable artifacts are durable execution material, not a
|
||||
node-local runtime cache. Campaign stores EML through Core's shared
|
||||
object-storage contract under opaque Campaign-owned keys. Templates returns a
|
||||
bounded artifact or a Files-managed artifact for printable output. Database job
|
||||
rows retain the expected hashes and provenance. Workers resolve and verify the
|
||||
frozen evidence before delivery. Build failure compensates objects written
|
||||
before database commit. Retention uses a fenced forward-recovery operation and
|
||||
independently verifies both artifact absence and the committed locator update;
|
||||
partial or unobservable cleanup remains visible in Ops.
|
||||
An operator-only, dry-run-first reconciler inventories bounded tenant-prefix
|
||||
pages and removes only old objects that remain unreferenced after an active
|
||||
build-fence check. Applied runs are idempotent, fenced, audited, and preserve
|
||||
database references on every storage failure.
|
||||
|
||||
## Dependencies
|
||||
|
||||
@@ -28,10 +48,42 @@ The module has one required runtime dependency:
|
||||
|
||||
- `govoplan-core` for platform services, auth, RBAC, DB/session lifecycle, migrations, and WebUI shell integration
|
||||
|
||||
Files and mail are optional module integrations declared in the campaign manifest:
|
||||
Files, Mail, Distribution Lists, Templates, Postbox, and Calendar are optional module integrations declared in the campaign manifest:
|
||||
|
||||
- `govoplan-files` enables managed attachment selection, frozen file-version evidence, and managed-file usage tracking. Server/API campaigns require this integration for attachments and never resolve caller-supplied local filesystem paths. Legacy file-oriented loading remains available only to explicitly trusted operator/library workflows.
|
||||
- `govoplan-mail` owns reusable profiles, encrypted SMTP/IMAP credentials, delivery policy checks, connection tests, and transport execution. Campaign JSON stores only `server.mail_profile_id`; inline transport settings and credentials are rejected. Without Mail, campaigns can still be authored, but profile validation and real delivery are unavailable.
|
||||
- `govoplan-dist-lists` expands reusable governed audiences. Campaign freezes the exact list revision, provider evidence, candidates, and explicit per-recipient primary/fallback route into its own version.
|
||||
- `govoplan-templates` validates and renders published label, envelope, letter, and list-layout templates for postal or internal-mail delivery. Generated output is hash-bound to its template, inputs, actor, route decisions, and Campaign version.
|
||||
- `govoplan-postbox` resolves exact or organization-derived Postbox targets and records provider acceptance and receipt evidence. It remains optional; Mail-only and print-only campaigns do not require it.
|
||||
- `govoplan-calendar` renders and mirrors individualized VEVENT invitations through the versioned `calendar.invitations` capability. Campaign freezes one METHOD:REQUEST attachment per recipient during build, creates the Calendar mirror only after delivery acceptance, and reads live RSVP state in bounded report batches. Mail may forward METHOD:REPLY parts from an authorized IMAP source. Calendar absence leaves ordinary Campaign authoring and delivery usable.
|
||||
|
||||
Hybrid delivery never treats an opt-in as an implicit duplicate-send instruction. The Campaign author selects one primary route per recipient and may select a supported fallback. A fallback runs only after the first channel rejects before acceptance; accepted or outcome-unknown effects stop cross-channel retry. Printable output is generated once during build, optionally persisted through Files, reviewed with the exact Campaign version, and accepted idempotently per recipient job during delivery.
|
||||
|
||||
Recurring schedules have two immutable modes. Manual mode remains the default
|
||||
and prepares independent drafts without Mail. Autonomous mode is explicit and
|
||||
Mail-only: it seals an already built and explicitly approved execution snapshot,
|
||||
rechecks approval, policy, credential/transport revision, live SMTP health,
|
||||
recipient and attachment evidence before each occurrence, and submits one
|
||||
Mail-owned durable command per frozen message. Occurrence-scoped idempotency is
|
||||
allocated before delivery. Accepted and outcome-unknown effects are never
|
||||
retried automatically; uncertain or systemic failures pause the schedule,
|
||||
notify its accountable operator, and retain non-secret recovery evidence.
|
||||
Generated EML retention excludes source versions while an autonomous schedule
|
||||
has a remaining occurrence, including while it is paused; once the schedule
|
||||
finishes, already accepted Mail commands retain their own encrypted payload and
|
||||
evidence under Mail policy.
|
||||
|
||||
Campaign versions can also be exported as versioned portable JSON packages and
|
||||
imported as independently owned drafts. The privacy-safe export default is
|
||||
metadata plus template/configuration. Recipients, attachment rules, aggregate
|
||||
review state, and recipient-level delivery history are separate scopes with
|
||||
their existing fine-grained permissions. Packages include source provenance,
|
||||
scope/item/redaction manifests, and a SHA-256 integrity digest. They never
|
||||
contain attachment bytes, transport secrets, credential references,
|
||||
password-field values, local storage locators, shares, or ownership grants.
|
||||
Import previews schema and checksum compatibility plus every created/skipped
|
||||
domain. It clears deployment-bound Mail references and never replays locks,
|
||||
approvals, review decisions, jobs, attempts, or sent state.
|
||||
|
||||
Public campaign, version, job, and report responses expose business data and
|
||||
delivery evidence, but never process-local paths, storage-backend keys, or
|
||||
@@ -52,6 +104,9 @@ services can cooperate without importing campaign internals:
|
||||
- `campaigns.policyContext` for retention/policy provenance
|
||||
- `campaigns.deliveryTasks` for queued send and append-to-Sent workers
|
||||
- `campaigns.retention` for campaign-owned retention cleanup
|
||||
- `privacy.dsar.campaigns` for tenant-scoped recipient, version, delivery,
|
||||
report-projection, and artifact-metadata discovery plus governed erasure
|
||||
planning
|
||||
|
||||
Keep these capability payloads narrow: stable ids, policy payloads, and task
|
||||
results only.
|
||||
@@ -94,7 +149,12 @@ 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.
|
||||
- Immediate Mail delivery preflights the selected SMTP transport before the
|
||||
first effect and reuses a healthy bounded connection through Mail. Review and
|
||||
send reports the batch state, connection/reconnect counts, and paused count.
|
||||
A systemic authentication, sender, or connectivity failure pauses remaining
|
||||
jobs; correct and test the Mail profile before explicitly resuming them.
|
||||
- Report-email preview uses the selected version's stored v5 Mail-profile evidence. Live report email fails closed until [govoplan-mail#17](https://git.add-ideas.de/GovOPlaN/govoplan-mail/issues/17) provides a durable, idempotent Mail-owned outbox and transport-attempt ledger; per-job CSV is off by default and requires `campaigns:recipient:export` when requested.
|
||||
- [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.
|
||||
|
||||
@@ -29,7 +29,7 @@ import tomllib
|
||||
from collections import Counter
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterator, Mapping, Protocol
|
||||
from uuid import uuid4
|
||||
@@ -66,6 +66,7 @@ SEND_RESULT_STATUSES = frozenset(
|
||||
{
|
||||
"already_accepted",
|
||||
"already_claimed",
|
||||
"already_sending",
|
||||
"cancelled",
|
||||
"dry_run",
|
||||
"failed",
|
||||
@@ -135,11 +136,21 @@ SMTP_FAULT_MODES = frozenset(
|
||||
}
|
||||
)
|
||||
WORKER_TASK_CODE = """
|
||||
import os
|
||||
import sys
|
||||
from govoplan_core.celery_app import send_email
|
||||
from types import SimpleNamespace
|
||||
from govoplan_core.celery_app import send_email, _worker_runtime_identity
|
||||
from govoplan_core.core.runtime_coordination import register_runtime_node
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
identity = _worker_runtime_identity(SimpleNamespace(hostname=f"campaign-acceptance-{os.getpid()}"))
|
||||
with get_database().SessionLocal() as session:
|
||||
register_runtime_node(session, identity, metadata={"acceptance_worker_pid": os.getpid()})
|
||||
session.commit()
|
||||
|
||||
result = send_email.run(sys.argv[1])
|
||||
if not isinstance(result, dict) or result.get("status") not in {
|
||||
"already_sending",
|
||||
"outcome_unknown",
|
||||
"smtp_accepted",
|
||||
}:
|
||||
@@ -1206,6 +1217,92 @@ def _wait_for_worker_process(process: subprocess.Popen[bytes], *, timeout_second
|
||||
raise AcceptanceError("Restarted Campaign worker task failed")
|
||||
|
||||
|
||||
def recover_stopped_fixture_claim(
|
||||
client: ApiClient, headers: Mapping[str, str], *, database: Any,
|
||||
runtime_root: Path, campaign_id: str, version_id: str,
|
||||
stopped_process: subprocess.Popen[bytes],
|
||||
) -> dict[str, bool]:
|
||||
"""Model supervisor proof ONLY in this runner's disposable SQLite fixture.
|
||||
|
||||
No job, attempt or recovery-operation state is edited here. After proving
|
||||
the exact fixture process exited, expire only its lease and mark its own
|
||||
runtime stopped. The real fenced HTTP action performs domain recovery.
|
||||
"""
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.exc import ArgumentError
|
||||
from govoplan_core.core.runtime_coordination import DistributedLease, RuntimeNode, process_runtime_identity
|
||||
from govoplan_campaign.backend.db.models import CampaignJob
|
||||
from govoplan_campaign.backend.services.delivery_recovery import job_recovery_metadata
|
||||
|
||||
root = runtime_root.resolve()
|
||||
expected_database = root / "acceptance.db"
|
||||
allowed_parents = {Path(tempfile.gettempdir()).resolve(), Path("/tmp").resolve()}
|
||||
if (
|
||||
os.environ.get("APP_ENV") != "test"
|
||||
or root.parent not in allowed_parents
|
||||
or not root.name.startswith(("govoplan-campaign-greenmail-", "govoplan-campaign-celery-redelivery-"))
|
||||
or not expected_database.is_file() or expected_database.is_symlink()
|
||||
or database.engine.url.get_backend_name() != "sqlite"
|
||||
or not database.engine.url.database
|
||||
or Path(database.engine.url.database).resolve() != expected_database
|
||||
):
|
||||
raise AcceptanceError("Claim proof is restricted to the runner's isolated temporary SQLite fixture")
|
||||
try:
|
||||
environment_url = make_url(os.environ.get("DATABASE_URL", ""))
|
||||
except ArgumentError:
|
||||
raise AcceptanceError("Claim proof requires the isolated fixture database environment") from None
|
||||
if (
|
||||
environment_url.get_backend_name() != "sqlite" or not environment_url.database
|
||||
or Path(environment_url.database).resolve() != expected_database
|
||||
):
|
||||
raise AcceptanceError("Claim proof database does not match the isolated fixture environment")
|
||||
if stopped_process.poll() is None or stopped_process.returncode is None:
|
||||
raise AcceptanceError("Claim proof requires the exact fixture worker to have exited")
|
||||
identity = process_runtime_identity()
|
||||
with database.SessionLocal() as session:
|
||||
jobs = session.query(CampaignJob).filter(
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
).all()
|
||||
if len(jobs) != 1 or jobs[0].send_status != "sending" or not jobs[0].claim_token:
|
||||
raise AcceptanceError("Claim proof requires exactly one unfinished fixture send")
|
||||
job = jobs[0]
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == identity.installation_id,
|
||||
DistributedLease.resource_key == f"campaign:delivery:{job.tenant_id}:{job.id}",
|
||||
).one_or_none()
|
||||
node = session.query(RuntimeNode).filter(
|
||||
RuntimeNode.installation_id == identity.installation_id,
|
||||
RuntimeNode.node_id == lease.holder_node_id,
|
||||
).one_or_none() if lease else None
|
||||
if (
|
||||
lease is None or node is None or node.incarnation != lease.holder_incarnation
|
||||
or (node.metadata_ or {}).get("acceptance_worker_pid") != stopped_process.pid
|
||||
):
|
||||
raise AcceptanceError("Stopped fixture process does not own this exact runtime claim")
|
||||
node.state = "stopped"
|
||||
node.stopped_at = datetime.now(timezone.utc)
|
||||
lease.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||
session.commit()
|
||||
metadata = job_recovery_metadata(session, [job])[job.id]["smtp"]
|
||||
if not metadata["eligible"]:
|
||||
raise AcceptanceError("Proven stopped fixture claim is not eligible for fenced recovery")
|
||||
job_id = job.id
|
||||
revision = metadata["revision"]
|
||||
payload = _expect(client.post(
|
||||
f"/api/v1/campaigns/{campaign_id}/jobs/{job_id}/recover-claim",
|
||||
headers=dict(headers),
|
||||
json={
|
||||
"channel": "smtp", "expected_revision": revision,
|
||||
"note": "Verified isolated fixture worker exited after DATA; expired its test lease. Mailbox/provider reconciliation is still required.",
|
||||
},
|
||||
), 200, "Explicit fenced fixture claim recovery")
|
||||
result = payload.get("result") if isinstance(payload.get("result"), dict) else {}
|
||||
if result.get("send_status") != "outcome_unknown" or result.get("reconciliation_required") is not True:
|
||||
raise AcceptanceError("Fenced recovery did not preserve the unknown SMTP outcome")
|
||||
return {"stopped_process_verified": True, "fixture_lease_expired": True, "explicit_fenced_recovery": True}
|
||||
|
||||
|
||||
def execute_worker_interruption_scenario(
|
||||
client: ApiClient,
|
||||
headers: Mapping[str, str],
|
||||
@@ -1218,6 +1315,7 @@ def execute_worker_interruption_scenario(
|
||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||
worker_job_probe: Callable[[str], str],
|
||||
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
prepared = prepare_campaign_scenario(
|
||||
client,
|
||||
@@ -1286,23 +1384,30 @@ def execute_worker_interruption_scenario(
|
||||
restarted_state = _durable_state_evidence(
|
||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||
)
|
||||
expected_restarted_state = {
|
||||
if restarted_state != interrupted_state:
|
||||
raise AcceptanceError("Duplicate task changed an unfinished SMTP attempt without stopped-runtime proof")
|
||||
|
||||
protocol_evidence = endpoint.evidence()
|
||||
expected_protocol = {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
if protocol_evidence != expected_protocol:
|
||||
raise AcceptanceError("Restarted worker contacted SMTP or produced an unexpected transaction")
|
||||
recovery_evidence = dict(recover_claim(prepared.campaign_id, prepared.version_id, first_worker))
|
||||
recovered_state = _durable_state_evidence(delivery_probe(prepared.campaign_id, prepared.version_id))
|
||||
expected_recovered_state = {
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
"attempt_status_counts": {"outcome_unknown": 1},
|
||||
"unfinished_attempt_count": 0,
|
||||
}
|
||||
if restarted_state != expected_restarted_state:
|
||||
raise AcceptanceError("Restarted worker did not freeze the unfinished SMTP attempt")
|
||||
|
||||
protocol_evidence = endpoint.evidence()
|
||||
if protocol_evidence != {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}:
|
||||
raise AcceptanceError("Restarted worker contacted SMTP or produced an unexpected transaction")
|
||||
if recovered_state != expected_recovered_state:
|
||||
raise AcceptanceError("Explicit fenced recovery did not freeze the unfinished SMTP attempt")
|
||||
if endpoint.evidence() != expected_protocol:
|
||||
raise AcceptanceError("Explicit claim recovery contacted SMTP")
|
||||
|
||||
report = _expect(
|
||||
client.get(
|
||||
@@ -1333,12 +1438,15 @@ def execute_worker_interruption_scenario(
|
||||
"queue": queue_evidence,
|
||||
"interrupted_durable_state": interrupted_state,
|
||||
"restarted_durable_state": restarted_state,
|
||||
"recovered_durable_state": recovered_state,
|
||||
"claim_recovery": recovery_evidence,
|
||||
"protocol": protocol_evidence,
|
||||
"report": report_evidence,
|
||||
"audit_actions": audit_actions,
|
||||
"process_boundary": {
|
||||
"dedicated_task_process_terminated_after_data": True,
|
||||
"fresh_task_process_completed": True,
|
||||
"duplicate_task_left_sending_unchanged": True,
|
||||
"duplicate_smtp_transaction_prevented": True,
|
||||
"celery_broker_redelivery_exercised": False,
|
||||
},
|
||||
@@ -1355,6 +1463,7 @@ def run_acceptance(
|
||||
audit_probe: Callable[[str, str], Mapping[str, int]],
|
||||
delivery_probe: Callable[[str, str], Mapping[str, Any]],
|
||||
worker_job_probe: Callable[[str], str],
|
||||
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||
include_failure_drills: bool,
|
||||
module_versions: Mapping[str, str],
|
||||
) -> dict[str, Any]:
|
||||
@@ -1619,6 +1728,7 @@ def run_acceptance(
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
worker_job_probe=worker_job_probe,
|
||||
recover_claim=recover_claim,
|
||||
)
|
||||
drills["worker_interruption"] = worker_interruption
|
||||
|
||||
@@ -1647,6 +1757,8 @@ def run_acceptance(
|
||||
"post_data_connection_loss_outcome_unknown": include_failure_drills,
|
||||
"source_artifact_provenance": False,
|
||||
"worker_restart_interruption": include_failure_drills,
|
||||
"duplicate_worker_leaves_active_claim_unchanged": include_failure_drills,
|
||||
"explicit_stopped_runtime_fenced_recovery": include_failure_drills,
|
||||
"celery_broker_redelivery": False,
|
||||
},
|
||||
}
|
||||
@@ -1888,6 +2000,11 @@ def _bootstrap_and_run(
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
worker_job_probe=worker_job_probe,
|
||||
recover_claim=lambda campaign_id, version_id, process: recover_stopped_fixture_claim(
|
||||
client, {"Authorization": f"Bearer {access_token}"}, database=database,
|
||||
runtime_root=runtime_root, campaign_id=campaign_id, version_id=version_id,
|
||||
stopped_process=process,
|
||||
),
|
||||
include_failure_drills=include_failure_drills,
|
||||
module_versions=module_versions,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,9 @@ 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.
|
||||
must leave the unfinished durable attempt unchanged without a second SMTP
|
||||
connection or DATA transaction. Only then does explicit fenced recovery use
|
||||
verified process exit and an expired fixture lease to record outcome-unknown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -52,6 +53,7 @@ from run_campaign_acceptance import ( # noqa: E402
|
||||
create_mail_profile,
|
||||
prepare_campaign_scenario,
|
||||
required_composition_versions,
|
||||
recover_stopped_fixture_claim,
|
||||
smtp_fault_endpoint,
|
||||
)
|
||||
|
||||
@@ -72,6 +74,13 @@ import os
|
||||
import sys
|
||||
|
||||
from govoplan_core.celery_app import celery
|
||||
from govoplan_core import celery_app as worker_runtime
|
||||
|
||||
# Test-only process identity evidence, confined to this disposable child.
|
||||
original_worker_metadata = worker_runtime._worker_metadata
|
||||
worker_runtime._worker_metadata = lambda: {
|
||||
**original_worker_metadata(), "acceptance_worker_pid": os.getpid(),
|
||||
}
|
||||
|
||||
visibility_timeout = int(os.environ["GOVOPLAN_CAMPAIGN_TEST_REDIS_VISIBILITY_TIMEOUT_SECONDS"])
|
||||
celery.conf.broker_transport_options = {
|
||||
@@ -408,6 +417,7 @@ def execute_redelivery_scenario(
|
||||
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]],
|
||||
recover_claim: Callable[[str, str, subprocess.Popen[bytes]], Mapping[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
profile_id = create_mail_profile(
|
||||
client,
|
||||
@@ -488,9 +498,23 @@ def execute_redelivery_scenario(
|
||||
task_id=redelivered_task_id,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
)
|
||||
recovered_state = _durable_state_evidence(
|
||||
redelivered_state = _durable_state_evidence(
|
||||
delivery_probe(prepared.campaign_id, prepared.version_id)
|
||||
)
|
||||
if redelivered_state != interrupted_state:
|
||||
raise AcceptanceError("Redelivered task changed an unfinished attempt without stopped-runtime proof")
|
||||
expected_protocol = {
|
||||
"connection_count": 1,
|
||||
"accepted_rcpt_commands": 1,
|
||||
"refused_rcpt_commands": 0,
|
||||
"data_transactions": 1,
|
||||
}
|
||||
if endpoint.evidence() != expected_protocol:
|
||||
raise AcceptanceError("Broker redelivery caused an unexpected SMTP transaction")
|
||||
recovery_evidence = dict(recover_claim(
|
||||
prepared.campaign_id, prepared.version_id, first_worker.process,
|
||||
))
|
||||
recovered_state = _durable_state_evidence(delivery_probe(prepared.campaign_id, prepared.version_id))
|
||||
expected_recovered = {
|
||||
"job_count": 1,
|
||||
"send_status_counts": {"outcome_unknown": 1},
|
||||
@@ -498,17 +522,11 @@ def execute_redelivery_scenario(
|
||||
"unfinished_attempt_count": 0,
|
||||
}
|
||||
if recovered_state != expected_recovered:
|
||||
raise AcceptanceError("Redelivered task did not freeze the unfinished attempt")
|
||||
raise AcceptanceError("Explicit fenced recovery 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")
|
||||
raise AcceptanceError("Explicit claim recovery caused an unexpected SMTP transaction")
|
||||
broker_after = _wait_for_broker_drained(
|
||||
redis_url,
|
||||
timeout_seconds=settings.provider_timeout_seconds,
|
||||
@@ -549,7 +567,9 @@ def execute_redelivery_scenario(
|
||||
**prepared.public_evidence(),
|
||||
"queue": queue_evidence,
|
||||
"interrupted_durable_state": interrupted_state,
|
||||
"redelivered_durable_state": redelivered_state,
|
||||
"recovered_durable_state": recovered_state,
|
||||
"claim_recovery": recovery_evidence,
|
||||
"protocol": protocol,
|
||||
"report": report,
|
||||
"audit_actions": audit_actions,
|
||||
@@ -566,6 +586,7 @@ def execute_redelivery_scenario(
|
||||
"first_worker_forced_exit": first_exit_code != 0,
|
||||
"replacement_worker_started": True,
|
||||
"replacement_worker_completed_redelivery": True,
|
||||
"duplicate_task_left_sending_unchanged": True,
|
||||
},
|
||||
}
|
||||
finally:
|
||||
@@ -743,6 +764,11 @@ def _bootstrap_and_run(
|
||||
snapshot_probe=snapshot_probe,
|
||||
audit_probe=audit_probe,
|
||||
delivery_probe=delivery_probe,
|
||||
recover_claim=lambda campaign_id, version_id, process: recover_stopped_fixture_claim(
|
||||
client, {"Authorization": f"Bearer {access_token}"}, database=database,
|
||||
runtime_root=runtime_root, campaign_id=campaign_id, version_id=version_id,
|
||||
stopped_process=process,
|
||||
),
|
||||
)
|
||||
|
||||
evidence = {
|
||||
@@ -766,6 +792,8 @@ def _bootstrap_and_run(
|
||||
"celery_worker_processes": True,
|
||||
"forced_worker_loss_after_complete_data": True,
|
||||
"same_task_broker_redelivery": True,
|
||||
"redelivery_leaves_active_claim_unchanged": True,
|
||||
"explicit_stopped_runtime_fenced_recovery": True,
|
||||
"durable_outcome_unknown_recovery": True,
|
||||
"duplicate_smtp_transaction_prevented": True,
|
||||
"production_daemon_supervisor": False,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Campaign Accessibility Review
|
||||
|
||||
The Campaign WebUI uses Core's semantic `Button`, `Dialog`, `DataGrid`, form,
|
||||
alert, and segmented-control components. Its page frames use Core `PageLayout`
|
||||
and its full-canvas navigation/content shells use `WorkspaceLayout`, so pane
|
||||
scrolling, sticky headings, action collapse, narrow-layout behavior, and help
|
||||
scope remain platform-owned. Core also owns focus trapping, focus return,
|
||||
Escape handling, labels, disabled state, and keyboard behavior for the shared
|
||||
primitives.
|
||||
|
||||
## Repeatable review matrix
|
||||
|
||||
Run this matrix for Campaign overview, wizard, sender and recipients,
|
||||
attachments, template editing, review and send, operator queue, and reports:
|
||||
|
||||
1. Navigate all actions with Tab and Shift+Tab; focus must remain visible and
|
||||
follow the visual reading order.
|
||||
2. Activate buttons and links with Enter, and native buttons with Space.
|
||||
3. Open every dialog, verify initial focus remains within it, close with Escape,
|
||||
and verify focus returns to the opener.
|
||||
4. Use DataGrid sorting, filtering, pagination, row selection, and action menus
|
||||
without a pointer.
|
||||
5. At 200 percent browser zoom and a 320 CSS-pixel viewport, verify that content
|
||||
reflows or scrolls without hiding actions.
|
||||
6. With reduced motion enabled, verify that workflow state does not depend on
|
||||
animation.
|
||||
7. With a screen reader, verify page headings, field labels, validation errors,
|
||||
workflow states, message navigation, and attachment evidence.
|
||||
|
||||
## Automated structural guard
|
||||
|
||||
`npm run test:accessibility-contract` rejects non-semantic click handlers,
|
||||
unlabelled icon-only buttons in the message preview, and Campaign-local modal
|
||||
implementations that bypass Core's `Dialog`. It complements rather than replaces
|
||||
browser and assistive-technology testing.
|
||||
|
||||
The guard also verifies that Campaign retains narrow-viewport layouts, visible
|
||||
keyboard focus for domain-specific controls, and an explicit reduced-motion
|
||||
override. Shared dialog focus trapping and restoration are tested in Core;
|
||||
Campaign tests verify that overlays continue to use that shared primitive.
|
||||
|
||||
## Release evidence
|
||||
|
||||
The feature implementation can be closed once the structural contract, shared
|
||||
Core component tests, TypeScript graph, and representative responsive overlay
|
||||
tests pass. The seven-step matrix above remains a release-candidate checklist:
|
||||
it must be repeated for the exact browser, language packages, theme, density,
|
||||
and assistive-technology combination being certified. Closing the implementation
|
||||
ticket does not make a general WCAG-conformance claim for future releases.
|
||||
|
||||
Implementation closure evidence recorded on 2026-08-03 used headless Chromium
|
||||
against the configured development system. It covered all 13 Campaign routes at
|
||||
1440 CSS pixels, the list, overview, recipients, and review routes at 320 CSS
|
||||
pixels, reduced-motion rendering, semantic accessibility-tree snapshots, HTTP
|
||||
500/page-error capture, and 42 sampled Tab stops across desktop and narrow
|
||||
Review & Send. The run found no unnamed interactive controls, hidden focus,
|
||||
page-level horizontal overflow, titlebar overlap, or unhandled runtime errors.
|
||||
Representative list, overview, review, and narrow-review screenshots were also
|
||||
inspected. This is implementation evidence; release certification still uses
|
||||
the complete matrix above with the selected screen reader and browser versions.
|
||||
|
||||
## Known boundary
|
||||
|
||||
Translation keys may be visible in source because Core resolves them at runtime.
|
||||
The review must use a built application with current language packages. WCAG
|
||||
conformance is a release-level claim and still requires a bounded manual audit
|
||||
of the release candidate.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Campaign Access Explanation Coverage
|
||||
|
||||
Campaign access explanations are resource-specific evidence. They inherit the
|
||||
parent Campaign decision only where the child has no independent grant model,
|
||||
and they must identify that inheritance explicitly.
|
||||
|
||||
## Implemented
|
||||
|
||||
- Campaign
|
||||
- Campaign version
|
||||
- Campaign delivery job / built message
|
||||
- Computed Campaign report, identified by Campaign, version, and report kind
|
||||
- Recipient row, identified by `<version UUID>:<job UUID>`
|
||||
- Frozen recipient source snapshot, identified by its Campaign-version UUID
|
||||
- Campaign attachment binding and version-bound frozen attachment resolution
|
||||
- Persisted validation issue, version-bound review decision, and attachment-policy override
|
||||
- SMTP, IMAP append, Postbox, and printable-output attempts
|
||||
- Message action, message-action attempt, and job reconciliation decision
|
||||
- Campaign share and Core-owned Campaign ownership-transfer record
|
||||
- Independently user-owned recipient import mapping profile
|
||||
- Saved recipient import execution, identified by `<version UUID>:<import UUID>`
|
||||
- Persisted validation, build, execution-snapshot, and review evidence, identified
|
||||
by `<version UUID>:<artifact kind>`
|
||||
|
||||
All persisted child IDs are random UUIDs. Embedded build/review children use a
|
||||
version UUID plus a random job UUID, so callers cannot enumerate a recipient
|
||||
index or infer an address. A version mismatch is reported as a stale reference.
|
||||
Missing and cross-tenant children use the same non-disclosing not-found
|
||||
provenance. Explanations never include recipient addresses, source rows,
|
||||
filenames, object locators, transport responses, worker claims, target
|
||||
snapshots, diagnostic text, or reconciliation notes.
|
||||
|
||||
## Permission matrix
|
||||
|
||||
| Evidence | Parent boundary | Further restriction |
|
||||
| --- | --- | --- |
|
||||
| Recipient row or source snapshot | Campaign read/owner/share | `campaigns:recipient:read` |
|
||||
| Attachment binding/resolution, validation, review, override | Campaign read/owner/share | Campaign review and `campaigns:diagnostic:read` |
|
||||
| Delivery status | Campaign read/owner/share | `campaigns:report:read` |
|
||||
| Transport or worker diagnostics | Campaign read/owner/share | `campaigns:diagnostic:read` |
|
||||
| Exported delivery evidence | Campaign read/owner/share | `campaigns:report:export` |
|
||||
| Reconciliation decision | Campaign read/owner/share | Campaign reconcile and diagnostic read |
|
||||
| Share or ownership transfer | Campaign governance | Campaign share, transfer-participant, group-acceptance, or recovery authority; content access remains a separate decision |
|
||||
| Import mapping profile | Independent user owner | `campaigns:recipient:import`; no Campaign share is inherited |
|
||||
| Import execution | Campaign read/owner/share | Recipient read and import authority |
|
||||
| Persisted protocol artifact | Campaign read/owner/share | Recipient, review, report, diagnostic, or export authority appropriate to the artifact |
|
||||
|
||||
Postbox, Mail/IMAP, and printable attempts keep bounded Campaign-owned evidence
|
||||
after provider acceptance. Their explanation therefore remains available when
|
||||
an optional provider module is later disabled. A missing attempt reports only
|
||||
the optional owner and `unavailable_or_hidden`; it does not distinguish absence
|
||||
from hidden data.
|
||||
|
||||
## Optional and unsupported owner boundaries
|
||||
|
||||
Reusable templates and template revisions are independently governed by the
|
||||
optional Templates module; Campaign never treats a Campaign share as a template
|
||||
grant. Durable export packages are independently governed by the optional
|
||||
Reporting module. Asking the Campaign provider to explain either class therefore
|
||||
fails closed with `independently_governed_by_optional_module` and
|
||||
`unavailable_or_hidden`. The response does not reveal whether the optional
|
||||
module is absent, the object does not exist, or the caller cannot see it.
|
||||
|
||||
Campaign reports generated on demand remain non-persisted, version-bound
|
||||
resources. Their explanation names the report kind and its Campaign/version
|
||||
parent, and keeps report read, export, and diagnostic permissions distinct.
|
||||
|
||||
Each child explanation must include:
|
||||
|
||||
- the child resource identity and current state;
|
||||
- the parent Campaign and version where applicable;
|
||||
- whether access is inherited, independently granted, or further restricted;
|
||||
- effective owner/share/policy provenance;
|
||||
- missing-module or unavailable-evidence reasons without leaking the hidden
|
||||
object;
|
||||
- a stable resource identifier suitable for audit and support links.
|
||||
|
||||
Delivery attempts, review decisions, reports, and exports can contain more
|
||||
sensitive evidence than the Campaign summary. Their read and diagnostic/export
|
||||
permissions therefore remain independently enforceable even when the parent
|
||||
Campaign is readable.
|
||||
|
||||
Import explanations include only the stable import identity, source type,
|
||||
opaque source identity, source revision, and whether additional provenance was
|
||||
recorded. They never return imported rows, filenames, column mappings, or source
|
||||
metadata. Mapping-profile explanations expose only non-reversible header
|
||||
fingerprints and shape information; headers and mappings remain hidden.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Campaign Build Recovery
|
||||
|
||||
Campaign build is fenced per tenant and Campaign version. Before rendering or
|
||||
writing generated artifacts, Campaign commits a Core recovery operation with
|
||||
the canonical source and validation hashes, the runtime fence, and a reserved
|
||||
opaque object prefix. A repeated idempotency key can replay only a verified
|
||||
successful build; it cannot start a second active build.
|
||||
|
||||
Generated EML and bounded print output are written to shared storage and checked
|
||||
for exact size and SHA-256 content. Campaign renews the build fence after that
|
||||
check and before changing jobs, then commits its jobs and execution snapshot,
|
||||
compares the stored object and database manifests, and records verified success.
|
||||
A managed Files output makes the operation forward-recoverable because Campaign
|
||||
cannot undo a Files-owned artifact; object-only builds use explicit
|
||||
compensation.
|
||||
|
||||
If the Campaign database transaction fails, Campaign deletes every object it
|
||||
recorded and verifies absence before recording recovered state. Failed deletion,
|
||||
an unavailable storage check, process loss, or superseded-object cleanup failure
|
||||
leaves a recovery-required operation visible in Ops. Do not retry such an
|
||||
operation as a normal build. Verify its checkpoint chain and reserved prefix,
|
||||
then reconcile it through the owning-module procedure.
|
||||
|
||||
## Orphan inventory and cleanup
|
||||
|
||||
The operator-only endpoint
|
||||
`POST /api/v1/campaigns/operations/artifacts/reconcile` requires
|
||||
`system:settings:write`. It never scans outside
|
||||
`campaign-artifacts/{tenant_id}/`, and one request reads at most `page_size`
|
||||
objects. The default request is a dry run:
|
||||
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
The response reports each eligible key, size, modification time, age, reason,
|
||||
page totals, and `next_cursor`. Continue with that cursor to inspect the next
|
||||
bounded page. Objects are not eligible when they are referenced by a committed
|
||||
EML or print-output row, belong to an actively fenced build, have no trustworthy
|
||||
modification time, have an invalid build-key shape, or are younger than the
|
||||
grace period. The minimum and default grace period is 24 hours.
|
||||
|
||||
Apply an inspected page with a new idempotency key:
|
||||
|
||||
```json
|
||||
{
|
||||
"apply": true,
|
||||
"idempotency_key": "incident-2026-08-03-page-1",
|
||||
"grace_period_hours": 24,
|
||||
"page_size": 250
|
||||
}
|
||||
```
|
||||
|
||||
An applied run rechecks committed references and active build leases before
|
||||
each bounded deletion batch. A Core distributed lease prevents two nodes from
|
||||
committing the same tenant cleanup concurrently. Every attempted deletion is
|
||||
probed afterward. `recovery_required` means an object was verified to remain;
|
||||
`outcome_unknown` means storage could not prove whether the delete took effect.
|
||||
Use a new idempotency key to retry after the storage problem is corrected. A
|
||||
successful repeated request with the same key returns `already_completed` and
|
||||
does not delete again.
|
||||
|
||||
Cleanup never clears Campaign database references. Audit and recovery evidence
|
||||
records counts and hashed manifests rather than object keys. Exact keys are
|
||||
returned only by this privileged endpoint and must stay in restricted incident
|
||||
records.
|
||||
|
||||
No recovery checkpoint contains message bodies, recipients, credentials, or
|
||||
resolved provider secrets. Object keys remain restricted diagnostics rather
|
||||
than Campaign business data.
|
||||
|
||||
Database rows, `campaign-artifacts/` objects, and the encryption/key service are
|
||||
one coordinated backup and recovery boundary. After any partial restore, pause
|
||||
delivery, run a dry inventory, reconcile Campaign recovery operations in Ops,
|
||||
and verify referenced object hashes before workers resume.
|
||||
@@ -30,9 +30,24 @@ been validated, built, reviewed, and locked.
|
||||
for attempts, outcomes, and reconciliation.
|
||||
- Confirm the effective Send now recipient-job limit. The safe default is 25;
|
||||
use Queue for workers for ordinary batches or any run above that limit.
|
||||
System administrators can explicitly configure 0–500 under Administration →
|
||||
SYSTEM → Campaign delivery; an explicit deployment ceiling remains binding,
|
||||
and TENANT policy may only narrow the inherited limit. This setting affects
|
||||
one synchronous request, not campaign size. It is audited and never sends
|
||||
messages; large interactive requests may encounter proxy timeouts.
|
||||
|
||||
## Deliverability Preflight
|
||||
|
||||
The Mail server connection test checks that selected server and credential. It
|
||||
does not authorize the campaign's sender, recipients, or resource selection.
|
||||
SMTP runtime checks require the selected SMTP credential when policy forbids
|
||||
inheritance, independently of IMAP. Sent-folder append checks IMAP credentials
|
||||
independently; full campaign validation still checks both required selections.
|
||||
Preflight errors distinguish Mail profile/credential policy, SMTP configuration,
|
||||
authentication, and connectivity. Do not change TLS or credential policies merely
|
||||
because a campaign preflight failed. A preflight rejection leaves staged jobs
|
||||
uncommitted and starts no message delivery.
|
||||
|
||||
Before the first live send for a sender domain or mail-server profile:
|
||||
|
||||
- Confirm the selected SMTP identity matches the visible From/envelope sender
|
||||
@@ -50,31 +65,64 @@ Before the first live send for a sender domain or mail-server profile:
|
||||
|
||||
## Queue And Send
|
||||
|
||||
1. Validate the version with file checks enabled.
|
||||
1. Link all required managed files, then validate the version with file checks
|
||||
enabled. Locking waits for a fresh attachment preview and asks for explicit
|
||||
**Link and lock** confirmation when matches are not linked. A locked version
|
||||
cannot change attachment links: use an editable version and repeat validation,
|
||||
build and review rather than assuming unlinked files were included.
|
||||
2. Build the version and inspect all blocking review items.
|
||||
3. Queue only after the selected version is the intended immutable execution
|
||||
version. Select **Queue for workers**, then verify the committed and
|
||||
published counts.
|
||||
4. Use **Send now** only if the exact eligible count is non-zero and at or below
|
||||
the effective deployment/tenant limit shown on the page.
|
||||
the effective deployment/system/tenant limit shown on the page.
|
||||
5. In worker mode, verify queue counters move from queued/claimed/sending to a
|
||||
terminal SMTP state.
|
||||
6. If a synchronous request is used, keep Review and send open: it polls the
|
||||
durable counters while the request runs. A rejection occurs before SMTP and
|
||||
directs oversized runs to workers.
|
||||
6. If a synchronous request is used, keep its blocking progress dialog open.
|
||||
Only its small version-scoped persisted counters refresh; the workspace,
|
||||
recipient list, attachment preview and full summary stay unchanged. Read-only
|
||||
refresh failure retains the last counters and does not prove delivery failed.
|
||||
A disconnected request may still be executing; never repeat it blindly.
|
||||
Oversized initial runs are rejected before SMTP and directed to workers.
|
||||
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
||||
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
||||
systemic transport failure stopped the remaining jobs before their SMTP
|
||||
effect; test/correct the Mail profile and explicitly resume the queue.
|
||||
|
||||
## Outcome Handling
|
||||
|
||||
Each real Campaign job delivery is represented by a Core recovery-ledger
|
||||
operation before the worker claims the job or invokes Mail, Postbox, or print.
|
||||
Synchronous batches use the same boundary after their batch-wide preflight.
|
||||
Explicit test/resend actions and post-acceptance IMAP appends use separate
|
||||
action-fenced operations. Operations store only opaque IDs, digests, channel
|
||||
policy, and bounded status evidence. A verified acceptance becomes
|
||||
`succeeded`, a definitive pre-effect or provider rejection becomes `rejected`,
|
||||
and uncertain or stranded effects remain `outcome_unknown` or
|
||||
`recovery_required` in Ops. Campaign jobs, message actions, and channel-attempt
|
||||
records remain the business source of truth.
|
||||
|
||||
For SMTP and Sent-folder APPEND, Campaign also passes the stable job/action and
|
||||
attempt identifier into Mail. Mail establishes its own provider-bound recovery
|
||||
fence after profile authorization and policy checks but before network I/O.
|
||||
This nested ownership is intentional: Campaign proves its business transition,
|
||||
while Mail proves the transport effect. Neither layer replays a completed or
|
||||
unknown provider attempt merely to repair the other layer's state.
|
||||
|
||||
- `smtp_accepted`: Do not retry. If IMAP append is enabled and pending, run or
|
||||
enqueue the append action.
|
||||
- `failed_temporary`: Retry explicitly after checking the error and retry count.
|
||||
- `failed_permanent`: Retry only if the operator has corrected the root cause and
|
||||
intentionally includes permanent failures.
|
||||
- `paused` after a systemic SMTP failure: do not resume until the shared Mail
|
||||
profile passes its connection test. Authentication, sender rejection, and
|
||||
unavailable connectivity affect the batch rather than one recipient.
|
||||
- `outcome_unknown`: Do not retry directly. Check SMTP logs, mailbox evidence, or
|
||||
provider control panels, then reconcile as accepted or not sent.
|
||||
- `claimed` or `sending` that does not progress: treat as a worker interruption.
|
||||
Re-run worker handling or reconcile if SMTP may already have accepted the
|
||||
message.
|
||||
- `claimed` or `sending` that does not progress: investigate the owning runtime.
|
||||
Duplicate worker handling leaves active state unchanged. Never infer from
|
||||
elapsed time alone that SMTP did not accept the message. Use the fenced
|
||||
**Recover interrupted claim** action described below when it is available.
|
||||
- IMAP `appending`: A worker owns the durable append claim. Do not start a
|
||||
second append; if the worker cannot finish, reconcile only after checking the
|
||||
mailbox.
|
||||
@@ -92,6 +140,65 @@ Before the first live send for a sender domain or mail-server profile:
|
||||
- Add a note that identifies the evidence used, for example SMTP log line,
|
||||
provider message ID, or operator ticket.
|
||||
|
||||
For pure-Mail SMTP and channel-specific IMAP operations, reconciliation updates
|
||||
the original Campaign attempt, matching Campaign recovery operation and audit
|
||||
record atomically under a fresh lease. A SMTP-only decision cannot resolve an
|
||||
entire compound Mail/Postbox/Print operation; that ledger remains separately
|
||||
unresolved until all of its effects are established. Campaign does
|
||||
not rewrite Mail-owned nested provider-effect operations: those remain Mail's
|
||||
separate evidence and operational responsibility. A failed audit or conflicting
|
||||
claim must leave the prior unknown state intact.
|
||||
|
||||
### Recovery without workers
|
||||
|
||||
The Report offers explicit inline retry and continuation when workers are not
|
||||
configured. Retry uses `campaigns:campaign:retry` plus
|
||||
`campaigns:campaign:send`; continuation uses `campaigns:campaign:queue` plus
|
||||
`campaigns:campaign:send`. Both use the canonical immutable jobs and ordinary
|
||||
attempt ledger, not a separate one-message resend. Current Mail authorization,
|
||||
review/approval, execution integrity, retry limits and rate limits still apply.
|
||||
Each call is bounded by the effective synchronous recipient-job limit and
|
||||
reports remaining eligible work. Continue explicitly until none remains; it
|
||||
never selects accepted, excluded, active, uncertain or known failed jobs.
|
||||
Known failures have their own explicit retry action. These actions do not
|
||||
turn a long HTTP request into a background worker or guarantee exactly-once
|
||||
SMTP when a provider acknowledgement is lost.
|
||||
|
||||
For an abandoned active SMTP or IMAP claim, the report exposes recovery only
|
||||
after the original durable lease expires **and** the runtime registry proves
|
||||
that its owner stopped or was replaced. A stale heartbeat is insufficient.
|
||||
The opaque claim revision and original recovery evidence are rechecked under a
|
||||
fresh lease. Recovery records the effect as **outcome unknown**, never not sent.
|
||||
Then separately inspect external evidence and reconcile with a factual note
|
||||
before any retry. This requires `campaigns:campaign:reconcile`. If the original
|
||||
lease, evidence, or stopped-owner proof is missing, preserve the records and
|
||||
investigate through Ops; do not edit delivery rows or force a lease expiry in
|
||||
a real installation.
|
||||
|
||||
### Progress totals and Sent-folder batching
|
||||
|
||||
For each channel, **processed** includes successful, failed, uncertain and
|
||||
cancelled outcomes. **In progress** is separate from pending, so the currently
|
||||
sending/appending message remains visible. Paused SMTP work is also separate.
|
||||
Excluded/non-requested channel work is outside the denominator. The endpoint
|
||||
requires campaign read and object access and returns no recipient addresses,
|
||||
message bodies, attachments or credentials. It is not the privacy-thresholded
|
||||
aggregate Reports view and does not grant that view recipient access.
|
||||
|
||||
Append-to-Sent targets the selected campaign version, not all historical
|
||||
versions. Each message remains a separately fenced, sequential IMAP APPEND.
|
||||
Mail reuses the authenticated session and resolved folder for at most 100
|
||||
messages or 300 seconds by default, then rotates the connection. Current
|
||||
authorization, frozen transport revisions, credentials and recovery checks
|
||||
still run for every message. A stale idle connection is checked before another
|
||||
APPEND; an uncertain APPEND is never replayed. This removes repeated
|
||||
connect/login/folder-list round trips, not the time needed to upload each EML.
|
||||
It is not an atomic MULTIAPPEND transaction or parallel delivery.
|
||||
|
||||
SMTP and IMAP use the same progress dialog. An acknowledged operation remains
|
||||
successful even if loading its follow-up diagnostics fails: use Reload to
|
||||
refresh display, not to repeat the external effect.
|
||||
|
||||
## Fault Injection Checklist
|
||||
|
||||
Use mock infrastructure first, then repeat against the non-production real test
|
||||
@@ -116,17 +223,21 @@ deliberately excluded.
|
||||
|
||||
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 but not a broker or daemon.
|
||||
process. Redelivery must leave the active claim unchanged and the endpoint
|
||||
must observe no second connection or DATA transaction. Only a subsequent
|
||||
explicit, fenced recovery with test-fixture stopped-owner and expired-lease
|
||||
proof may change the unfinished attempt to `outcome_unknown`. This covers 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,
|
||||
requires unchanged active state on duplicate delivery, followed by explicit
|
||||
fenced recovery to `outcome_unknown`, an empty broker queue/unacked set, and
|
||||
exactly one SMTP connection and DATA transaction. Lease expiration is simulated
|
||||
only in the isolated fixture after its owner was stopped. Raw worker logs and task,
|
||||
database, endpoint, and credential identifiers are never retained.
|
||||
|
||||
That second runner proves local runner-supervised process replacement, not the
|
||||
@@ -134,8 +245,42 @@ 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.
|
||||
|
||||
## Shared Build Artifacts
|
||||
|
||||
Generated EML is stored through Core's configured object-storage backend under
|
||||
opaque Campaign-owned keys. The job records expected byte size, SHA-256 digest,
|
||||
and Message-ID. A worker on another node must retrieve and verify those values
|
||||
before attempting delivery.
|
||||
|
||||
- Do not expose object keys to ordinary campaign users or copy them into
|
||||
business fields.
|
||||
- A build failure deletes objects written before the database transaction can
|
||||
commit.
|
||||
- Retention starts a job-fenced forward-recovery operation before deletion,
|
||||
commits metadata changes in the Campaign-owned boundary, and independently
|
||||
probes the original locator before reporting success. An unavailable backend
|
||||
leaves an outcome-unknown operation; a deletion/metadata mismatch becomes
|
||||
recovery-required in Ops.
|
||||
- A hard process loss between object creation and metadata commit can leave an
|
||||
orphan object. Use the operator-only, dry-run-first Campaign artifact
|
||||
reconciler documented in `CAMPAIGN_BUILD_RECOVERY.md`; it scans one bounded
|
||||
tenant-prefix page, enforces a minimum 24-hour grace period, protects active
|
||||
build fences, and rechecks committed EML and print-output references before
|
||||
deletion.
|
||||
- Restore Campaign rows, object storage, and the encryption key to one
|
||||
coordinated recovery point before resuming workers.
|
||||
|
||||
For a scaled-runtime drill, build on one API replica, consume from another
|
||||
worker, compare the stored evidence, and inject storage failures during build
|
||||
and retention.
|
||||
|
||||
## Reporting Checks
|
||||
|
||||
- The recipient-aware report shows every frozen To/Cc/Bcc address in authored
|
||||
order, with a primary-address fallback only for old rows without that snapshot.
|
||||
SMTP envelope evidence, not the old primary-only UI, establishes how many
|
||||
recipients were actually offered to the provider. SMTP and IMAP diagnostics
|
||||
use list filters and consistent translated labels.
|
||||
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||
- Excluded messages must show SMTP and IMAP as `skipped`, with skipped counts
|
||||
and filters separate from unattempted or failed delivery.
|
||||
|
||||
+486
-19
@@ -30,12 +30,13 @@ The shorter task documents remain useful companions:
|
||||
|
||||
## What Campaign is for
|
||||
|
||||
Campaign turns governed source data into individually built messages and then
|
||||
controls their review, delivery, and evidence. It is intentionally a
|
||||
composition module: it demonstrates how one user journey can use optional Mail,
|
||||
Files, Addresses, and Notifications capabilities alongside Core access/audit
|
||||
infrastructure without copying ownership from those modules. Policy may consume
|
||||
Campaign context through a narrow capability; Campaign does not import Policy.
|
||||
Campaign turns governed source data into individually built messages or
|
||||
printable output and then controls their review, delivery, and evidence. It is
|
||||
intentionally a composition module: it demonstrates how one user journey can
|
||||
use optional Mail, Files, Addresses, Distribution Lists, Templates, Postbox,
|
||||
and Notifications capabilities alongside Core access/audit infrastructure
|
||||
without copying ownership from those modules. Policy may consume Campaign
|
||||
context through a narrow capability; Campaign does not import Policy.
|
||||
|
||||
Campaign owns:
|
||||
|
||||
@@ -44,7 +45,8 @@ Campaign owns:
|
||||
- message and attachment rules for a version;
|
||||
- validation, review, build, queue, and delivery-control state;
|
||||
- the durable jobs and attempts needed to explain delivery outcomes; and
|
||||
- campaign-specific reports, shares, and frozen execution evidence.
|
||||
- campaign-specific reports, shares, frozen execution evidence, and governed
|
||||
human collaboration entries.
|
||||
|
||||
Campaign does not own:
|
||||
|
||||
@@ -66,9 +68,9 @@ The supported process is a controlled progression, not a single "send" call:
|
||||
create/edit
|
||||
-> validate and resolve policy/integrations
|
||||
-> review warnings and blockers
|
||||
-> build exact recipient messages
|
||||
-> build exact recipient messages and/or printable artifacts
|
||||
-> complete review and queue
|
||||
-> SMTP attempt per job
|
||||
-> selected Mail, Postbox, or print effect per job
|
||||
-> optional IMAP append per accepted job
|
||||
-> report, retry, reconcile, or correct
|
||||
-> archive when no active/uncertain delivery remains
|
||||
@@ -95,9 +97,76 @@ Important distinctions:
|
||||
- **Archive** preserves evidence. Draft-only campaigns without built, locked,
|
||||
or delivery evidence may be deleted where policy allows; evidence-bearing
|
||||
campaigns are archived instead.
|
||||
- **Copy campaign** creates a new campaign and one fresh editable version from
|
||||
the selected source version. It copies configuration, but never delivery
|
||||
jobs, outcomes, shares, locks, or audit evidence.
|
||||
- **Archive historical version** hides only a non-current version from the
|
||||
default history. It does not change the version's workflow state or remove
|
||||
configuration, reports, delivery results, or audit evidence.
|
||||
|
||||
Campaign lifecycle confirmations are bound to the state shown in the UI. If a
|
||||
job, version, share, or campaign state changes before confirmation, the server
|
||||
rejects the stale action and requires a reload. The lifecycle-policy response
|
||||
states the applicable built-in rule and the reason for every unavailable
|
||||
action.
|
||||
|
||||
## User tasks
|
||||
|
||||
### Discuss campaign work
|
||||
|
||||
Open **Collaboration** inside a Campaign to keep human coordination beside the
|
||||
work without changing its version history. Discussion access is independent
|
||||
from Campaign editing: parent Campaign read access remains mandatory, while
|
||||
`campaigns:discussion:read`, `campaigns:discussion:post`, and
|
||||
`campaigns:discussion:moderate` separately control reading, posting, and
|
||||
moderation. A read share is sufficient as the parent grant and a comment never
|
||||
turns that share into write access.
|
||||
|
||||
Comments are append-only and bounded to 8,000 characters. They can carry one
|
||||
validated reference to an immutable Campaign version, saved recipient import,
|
||||
attachment rule, delivery job, or report. References to version-bound evidence
|
||||
include the exact version ID and never edit that version. Authors can withdraw
|
||||
their own comments; moderators can redact comments and use moderator-only
|
||||
visibility. Both operations remove displayed text but preserve a tombstone,
|
||||
content hash, actor snapshot, timestamp, reference context, and bounded Audit
|
||||
event. There is deliberately no comment-edit API.
|
||||
|
||||
Mentions are limited to 20 active users who already have Campaign ownership or
|
||||
share access. When Notifications is installed and healthy, Campaign emits a
|
||||
content-free in-app mention notification. Collaboration remains usable without
|
||||
Notifications. The thread displays only human discussion; approvals, workflow
|
||||
state, delivery events, and durable system evidence remain on their owning
|
||||
surfaces and in Tenant audit.
|
||||
|
||||
### Assign accountable campaign work
|
||||
|
||||
Open **Work** to assign one bounded purpose to an account, group, or
|
||||
organization function that already has Campaign access. Assignment records
|
||||
responsibility only: it never creates a share, transfers ownership, or grants a
|
||||
permission. Assignees may accept, complete, or reject their work; rejection is
|
||||
distinct from administrative cancellation. Managers may reassign or cancel
|
||||
open work, and every transition retains the expected revision, actor snapshot,
|
||||
typed target, and append-only event history.
|
||||
|
||||
Workflow may create or reference a Campaign and open the same assignment through
|
||||
the optional `campaigns.workOrchestration` capability. Those assignments pin the
|
||||
Campaign version and store the Workflow instance, step, correlation, and
|
||||
idempotency provenance. Campaign emits `campaign.work.changed` for assignment,
|
||||
acceptance, start, reassignment, completion, rejection, and cancellation.
|
||||
Workflow uses the assignment ID and event revision, rechecks current Campaign
|
||||
access, and then resumes the matching durable external hand-off without browser
|
||||
polling. A missing Tasks or Notifications capability only removes the optional
|
||||
projection or notification. A missing Campaign provider, revoked Campaign
|
||||
access, or stale event revision keeps the Workflow blocked and inspectable.
|
||||
|
||||
Campaign also contributes the opt-in **Accountable Campaign work hand-off**
|
||||
Workflow template. It is deliberately not activated on installation. A
|
||||
configurator must copy or activate it and supply either `campaign_id` or
|
||||
`create_campaign`; unused optional input keys must be present with `null`
|
||||
values. The template prepares the assignment idempotently, opens the exact
|
||||
Campaign work URL, and waits for completion, rejection, cancellation, or the
|
||||
configured timeout. Opening the link never completes the Workflow.
|
||||
|
||||
### Prepare a campaign
|
||||
|
||||
1. Create a campaign and confirm its owner or owning group.
|
||||
@@ -111,6 +180,17 @@ Important distinctions:
|
||||
5. Open **Mail settings** and select an available Mail profile. The campaign
|
||||
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
|
||||
usernames, passwords, or credential references.
|
||||
If the selected profile permits a campaign-scoped Mail credential, that
|
||||
credential remains Mail-owned even though it is created from this surface.
|
||||
For that credential and for password-valued campaign fields, the shared
|
||||
password generator keeps its candidate separate from the form until **Use
|
||||
password** is explicitly confirmed. Copying a candidate does not save or
|
||||
submit it.
|
||||
If legacy transport data is reported, choose **Migrate selected Mail
|
||||
profile**, including when the existing profile selection is unchanged.
|
||||
Follow a locked version's supported unlock or editable-successor action
|
||||
before migration. Migration is explicit and audited, never sends mail, and
|
||||
requires validation, build, and review again.
|
||||
6. Save the editable version, validate the relevant sections, and resolve every
|
||||
blocking issue. Warnings remain explicit review decisions.
|
||||
7. Build the exact messages and inspect recipient, addressing, template,
|
||||
@@ -120,11 +200,77 @@ If a selected optional module is absent, Campaign remains loadable and explains
|
||||
which function is unavailable. It must not fail startup because Mail, Files, or
|
||||
Addresses is not installed.
|
||||
|
||||
Opening or leaving Template without editing does not change the saved HTML or
|
||||
mark the page dirty. Visual/source inspection and read-only changes likewise
|
||||
do not require a save. Actual saves send only client-owned editor metadata. Review
|
||||
and approval evidence remains server-owned and cannot be overwritten by an
|
||||
ordinary editor save. Omitting that readable evidence from a save does not
|
||||
delete it; normal version-lock and invalidation rules remain authoritative.
|
||||
|
||||
### Preserve recipient address order
|
||||
|
||||
In an individual or global address dialog, use the up/down actions to arrange
|
||||
the addresses, then choose **Save** in the dialog. The campaign draft keeps that
|
||||
order; saving no longer alphabetically sorts it. Duplicate email addresses keep
|
||||
their first position, and pasted addresses append in their entered order.
|
||||
The first individual To address is also the primary name/email shown in the
|
||||
recipient row. Use the page's **Save** to persist the campaign draft. A rejected
|
||||
page save keeps the reordered draft for an explicit retry. **Cancel** in the
|
||||
dialog discards only its unconfirmed changes.
|
||||
|
||||
### Permit Legacy ZipCrypto as an explicit compatibility exception
|
||||
|
||||
AES remains the secure default. Campaign **Settings**, **Policies**, and
|
||||
**Attachments** expose the effective archive policy, configuration links for
|
||||
authorized administrators, and **Reload archive policy**.
|
||||
|
||||
1. A policy administrator opens **Administration → SYSTEM → Campaign archive
|
||||
encryption**, enables **Legacy ZipCrypto**, and saves. The controls work
|
||||
before the first system override exists; opening defaults alone does not
|
||||
create an override or unsaved changes. Changing this global ceiling requires
|
||||
both `system:settings:write` and `admin:policies:write`; tenant policy
|
||||
authority alone cannot loosen it.
|
||||
2. Check tenant and owner policy restrictions. Lower scopes may narrow, never
|
||||
loosen, inherited methods and password-delivery channels.
|
||||
3. The Campaign actor also needs `campaigns:archive:use_legacy_zipcrypto` and
|
||||
edit access to the selected version. Policy administration does not replace
|
||||
that dedicated permission.
|
||||
4. Return to Campaign, reload archive policy, and select **Legacy ZipCrypto**
|
||||
under **Attachments → ZIP attachments**. Acknowledge weak encryption, enter
|
||||
an operational reason of at least 10 characters, and select an allowed
|
||||
separate password-delivery channel.
|
||||
5. Save, validate, build, and review. Policy/configuration saves never send
|
||||
mail; delivery remains a separate action.
|
||||
|
||||
Legacy remains blocked while Policy is unavailable. Neither an encryption
|
||||
error nor an incompatible client causes automatic fallback from AES to
|
||||
ZipCrypto. The build retains policy and acknowledgement evidence but never the
|
||||
password; see the manifest topic `campaigns.archive-encryption-governance`.
|
||||
|
||||
Mail migration and ZIP corrections can be saved in either order. A Mail-only
|
||||
migration preserves unchanged ZIP settings without granting permission to use
|
||||
them or adding acknowledgement evidence. An archive correction with unchanged
|
||||
Mail references preserves legacy transport server-side until its separate,
|
||||
explicit migration. Changes to ZIP settings still require the current policy
|
||||
and any dedicated legacy permission; changes to Mail references still require
|
||||
authorized migration. Validate, build and review again after both repairs.
|
||||
|
||||
A save and the following workspace refresh are separate operations. A failed
|
||||
refresh does not undo a committed save or clear the last usable workspace.
|
||||
Keep any newer unsaved edits, inspect the refresh error, and use Reload to fetch
|
||||
the current state. Responses for an earlier campaign, version or signed-in
|
||||
identity cannot overwrite the current workspace.
|
||||
|
||||
### Review and complete review
|
||||
|
||||
The reviewer should verify the immutable candidate that will be delivered, not
|
||||
just the authoring form:
|
||||
|
||||
If a legacy Mail migration notice appears, follow **Open Mail settings** for
|
||||
that exact version, complete migration, and validate and build again. Review
|
||||
stays read-only until migration is resolved and does not repeatedly request
|
||||
an attachment preview that the legacy transport boundary must reject.
|
||||
|
||||
1. Confirm purpose, owner, selected version, and recipient count.
|
||||
2. Inspect blocking errors, warnings, exclusions, and recipients requiring
|
||||
review.
|
||||
@@ -135,9 +281,92 @@ just the authoring form:
|
||||
5. Confirm attachment behavior when a rule matches no files, ZIP/password
|
||||
behavior, and any recipient-specific files.
|
||||
6. Record review completion and the inspected message keys through the review
|
||||
surface. The current baseline does not persist a distinct approve/reject
|
||||
decision or review reason. If content, recipients, attachment inputs, owner
|
||||
context, or non-secret transport identity changes, revalidate and rebuild.
|
||||
surface. Validation, build, review, and exception evidence records the actor,
|
||||
timestamp, and immutable build token/message digest where applicable. If
|
||||
content, recipients, attachment inputs, owner context, or non-secret
|
||||
transport identity changes, revalidate and rebuild.
|
||||
|
||||
The Review & Send surface separates three kinds of attention. Critical
|
||||
blockers must be corrected before delivery, individual review items require a
|
||||
recorded message decision, and non-critical group items may be acknowledged
|
||||
together after individual review is complete. Each warning or blocker names
|
||||
the required action, the responsible role, and the workspace to open. The
|
||||
review summary keeps reviewed and remaining counts visible; a completed review
|
||||
acknowledges the group items and remains bound to the current build token.
|
||||
|
||||
Save each individual acceptance to persist its reason and reviewed state before
|
||||
completing the entire review. Wait for acknowledgement; reloading then resumes
|
||||
that build's saved progress. If saving fails or conflicts, the pending note
|
||||
remains available for an explicit retry rather than becoming a false success.
|
||||
This small save only loads the selected persisted jobs: it does not rebuild
|
||||
messages, materialize attachments, or reload the whole workspace. Another
|
||||
reviewer's existing decisions and attribution remain intact. Partial progress
|
||||
does not enable delivery; final completion still checks the complete build.
|
||||
|
||||
Use **Accept similar review conditions** to record the same decision for a
|
||||
counted selection of currently loaded matching messages. The server defines
|
||||
eligible categories from the complete combination of overridable conditions;
|
||||
the UI does not interpret a warning badge as permission to override. Select
|
||||
one category, inspect the listed recipients, deselect any exceptions and enter
|
||||
a common reason (required for attachment exceptions). A submission contains
|
||||
at most 200 explicit message IDs. When more remain, save this selection and
|
||||
reopen the dialog; the counts never imply acceptance of unloaded messages or
|
||||
other categories. The reason is recorded separately against each selected
|
||||
message's frozen evidence. A failed save retains the selection and reason for
|
||||
an explicit retry, while a changed build prevents stale acceptance. This
|
||||
action neither sends messages nor completes the final review gate. Hard
|
||||
blockers cannot be accepted this way. Deliberate policy exclusions and
|
||||
attachment rules that explicitly permit zero matches remain informational
|
||||
and do not require review decisions.
|
||||
|
||||
An optional rule with explicit `missing_behavior: continue` may yield no files
|
||||
without creating review work; that outcome remains informational evidence.
|
||||
Required attachment and hard-block policies cannot be weakened by this setting.
|
||||
The separate policy for sending a wholly attachment-free message still applies.
|
||||
Rebuild existing messages after changing attachment policy; historical build
|
||||
evidence is not rewritten.
|
||||
|
||||
The incremental review API uses `merge_progress: true`, the acknowledged
|
||||
`base_revision`, and `build_token` set to the public `review_build_token`.
|
||||
It merges exact reviewed keys/decision job IDs for the current build, with an
|
||||
optional `decision_category_key` to bind a grouped acceptance. The safe review
|
||||
reference is available without diagnostic access; raw build tokens remain
|
||||
diagnostic data. Stale build/revision or simultaneous writes return HTTP 409
|
||||
without overwriting progress. Normal review authorization and audit apply.
|
||||
|
||||
Accepted or expected attachment conditions remain satisfied in **Confirm and
|
||||
send** for the same build. Raw missing/ambiguous source counts remain visible
|
||||
for context; they are not a second approval gate. Reviewed-stage mock delivery
|
||||
uses `use_reviewed_build: true`: it verifies the existing execution seal,
|
||||
completed review, frozen job issues and EML integrity, and current Mail transport
|
||||
before capturing anything in the mock mailbox. It uses those stored messages,
|
||||
not freshly rendered replacements, and never mutates Campaign delivery state.
|
||||
Stale review, changed inputs, changed bytes or changed transport stop the test
|
||||
before captures or requested mailbox clearing. The authoring/mock-preview API
|
||||
keeps its existing transient-build default; `include_needs_review` does not
|
||||
bypass frozen review checks.
|
||||
|
||||
Validation details and repeated-file lists use the shared DataGrid pagination
|
||||
controls so every item is reachable. Related missing-rule causes and their
|
||||
attachment-free policy outcomes appear together, with the technical evidence
|
||||
still expandable. Built messages have four operational states: **Ready**,
|
||||
**Needs review**, **Blocked**, and **Excluded**, plus an explanatory column.
|
||||
Accepted explicit decisions are Ready; warnings awaiting acknowledgment remain
|
||||
Needs review. This presentation does not remove or rewrite frozen issues or
|
||||
audit evidence.
|
||||
|
||||
This evidence is the Campaign input to separation-of-duties policy. Generic
|
||||
approve/reject chains, delegation, substitutions, escalation, and signatures
|
||||
belong to the optional Approvals capability. Campaign must not claim an
|
||||
approval merely because validation, building, or message review completed.
|
||||
|
||||
When a campaign has an Approval request reference, mock and real delivery
|
||||
resolve `approvals.requests` and require an approved request for the exact
|
||||
`campaign_version` subject and current version id. A missing Approvals module,
|
||||
unknown request, pending/rejected chain, or approval for an older version blocks
|
||||
delivery with an explicit requirement. Campaign stores the approval reference,
|
||||
not Approval tables; changing the campaign version requires a new exact-subject
|
||||
approval.
|
||||
|
||||
Normal readers and reviewers see business state and safe evidence. Process-local
|
||||
paths, storage keys, worker claim tokens, and raw provider diagnostics require
|
||||
@@ -153,6 +382,24 @@ every action control that the actor lacks. The server authorizes each action,
|
||||
but permission-aware action visibility on that detailed surface remains open
|
||||
work; do not confuse it with the aggregate reader experience.
|
||||
|
||||
The module-local aggregate surface remains at `/campaigns/reports`. When the
|
||||
optional Reporting module is enabled, Campaign also contributes the same
|
||||
recipient-free projection as the `campaigns/delivery-outcomes` report provider.
|
||||
Reporting owns `/reports`, records the run purpose, effective audience, source
|
||||
campaign/version revision, privacy transformations, retention, actor/time,
|
||||
output hash, and export history, and applies Policy before returning the
|
||||
result. Campaign does not register a fallback `/reports` route when Reporting
|
||||
is absent.
|
||||
|
||||
The detailed Campaign Report includes the selected campaign's effective
|
||||
retention policy, its system/tenant/owner/campaign provenance, and the current
|
||||
evidence state. It distinguishes retained, redacted, expired, partially
|
||||
minimized, unavailable, and not-applicable source JSON, stored report detail,
|
||||
generated EML, and Postbox-copy evidence. When Policy is absent, the report
|
||||
shows the platform defaults and explicitly warns that automated retention
|
||||
enforcement is unavailable. Retention removes or minimizes detail; aggregate
|
||||
counters and audit references may remain so outcomes can still be explained.
|
||||
|
||||
### Deliver and resolve outcomes
|
||||
|
||||
Use the [delivery runbook](CAMPAIGN_DELIVERY_RUNBOOK.md) for the detailed
|
||||
@@ -164,7 +411,7 @@ At a minimum:
|
||||
ordinary batches; the durable progress remains visible after leaving and
|
||||
returning to Review and send.
|
||||
2. Use **Send now** only when the exact persisted eligible build is within the
|
||||
effective synchronous limit shown by the UI. The default deployment limit
|
||||
effective synchronous limit shown by the UI. The unchanged default limit
|
||||
is 25 recipient jobs. The backend repeats the count and preflights every
|
||||
message and the Mail profile revision before contacting SMTP.
|
||||
3. Treat `smtp_accepted` as protected from ordinary retry.
|
||||
@@ -180,15 +427,31 @@ At a minimum:
|
||||
Only the latter becomes explicitly retryable, and neither decision resends
|
||||
the already SMTP-accepted message.
|
||||
8. Archive only after active and uncertain effects are resolved.
|
||||
9. Use **Delete draft** only for an untouched draft. If retained evidence or an
|
||||
active share exists, revoke the share where appropriate or archive instead.
|
||||
|
||||
Pause stops new eligible work but cannot undo a provider effect already in
|
||||
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
||||
it cannot recall accepted mail.
|
||||
|
||||
The deployment ceiling is configured with
|
||||
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` (0 disables Send now; the
|
||||
accepted range is 0–500). A tenant may only narrow that ceiling with
|
||||
`tenant.settings.campaign_delivery_policy.synchronous_send_max_recipients`.
|
||||
Configure **Administration → SYSTEM → Campaign delivery** with
|
||||
`system:settings:read/write`. The default stays 25, but an administrator may
|
||||
explicitly choose 0–500, for example 200 for a 183-recipient-job run. Zero disables
|
||||
Send now. **TENANT → Campaign delivery** uses `admin:policies:read/write` and may
|
||||
only narrow the inherited system policy. Clearing an override restores
|
||||
inheritance. An explicitly configured deployment ceiling
|
||||
`GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS` remains authoritative; the
|
||||
implicit default does not prevent a system administrator choosing a larger
|
||||
bounded value. Save changes only this setting, preserves unrelated settings,
|
||||
checks a revision token including inherited policy, and records before/after
|
||||
configuration history and audit. Failed saves retain the draft; conflicting
|
||||
saves require explicit reload/reconciliation. Policy edits never send mail or
|
||||
change existing reviews or approval requirements.
|
||||
|
||||
This limit applies to one interactive Send now request, not campaign size or
|
||||
worker batching. Larger interactive requests run longer and can meet proxy
|
||||
timeouts. Queue for workers is independent and requires enabled, healthy
|
||||
Redis/Celery infrastructure; changing the numeric limit does not start workers.
|
||||
The effective value and source are returned by the protected delivery-options
|
||||
API, recorded for successful/rejected synchronous commands, and stated in the
|
||||
configured handbook topic.
|
||||
@@ -215,6 +478,32 @@ default.
|
||||
|
||||
## Data and evidence model
|
||||
|
||||
### Portable Campaign transfer
|
||||
|
||||
Campaign offers two reuse paths with different boundaries. **Copy campaign**
|
||||
creates another campaign inside the same installation and can reuse selected
|
||||
local shares, policies, and Mail profile references. **Export package** creates
|
||||
a versioned JSON hand-off whose selected scopes can cross an installation
|
||||
boundary; **Import package** always creates a separately owned draft.
|
||||
|
||||
The export dialog starts with only metadata and template/configuration. Add
|
||||
recipients, attachment rules, review state, or delivery history only when the
|
||||
handoff requires them and the destination and retention are approved. Recipient
|
||||
and delivery scopes remain protected by recipient/report export permissions.
|
||||
Transport secrets, credential references, password-field values, local storage
|
||||
locators, and attachment bytes are always removed. The manifest records scope
|
||||
counts and redactions, while the envelope carries source Campaign/version
|
||||
provenance and a SHA-256 digest.
|
||||
|
||||
Import verifies format, scope, checksum, schema, and destination identity before
|
||||
showing the plan. Editing the destination identity or selected scopes makes the
|
||||
preview stale and requires a new check. The apply step clears source Mail
|
||||
references, creates one editable draft, and stores a bounded source/package and
|
||||
created/skipped receipt. Historical validation/build summaries, review state,
|
||||
approvals, delivery jobs, attempts, and sent outcomes are never replayed. File
|
||||
content is never embedded, so reconnect managed files and local Mail profiles,
|
||||
then validate, build, review, and approve normally.
|
||||
|
||||
### Versions and snapshots
|
||||
|
||||
Editable campaign JSON is versioned. Build creates recipient jobs and an
|
||||
@@ -242,6 +531,8 @@ as an executable Mail configuration:
|
||||
|
||||
- public responses remove legacy transport fields and secrets;
|
||||
- validation, build, queue, retry, and delivery fail closed;
|
||||
- computed previews that require a current Campaign configuration return an
|
||||
actionable validation problem instead of a server error;
|
||||
- an editable version changes to profile-only form only through an explicit
|
||||
Mail-settings save; and
|
||||
- a locked version is preserved and must be forked to an editable successor.
|
||||
@@ -321,6 +612,73 @@ create an editable successor.
|
||||
|
||||
## Operations and recovery
|
||||
|
||||
### Interactive delivery and Sent-folder progress
|
||||
|
||||
Send now and workerless Report retry/continue use a compact blocking progress
|
||||
dialog, as does inline append-to-Sent. It refreshes saved counters only, not the
|
||||
whole campaign behind the overlay. Successful, pending, in-progress, failed,
|
||||
uncertain and excluded messages are shown separately; a currently sending or
|
||||
appending message therefore does not disappear between totals. Read errors keep
|
||||
the last known counters. After a connection interruption, processing may still
|
||||
be running; inspect saved evidence before repeating any action. A successful
|
||||
write is not reclassified as failed when its later display refresh fails.
|
||||
|
||||
The recipient-aware Report shows all frozen To, Cc and Bcc addresses, not only
|
||||
the primary row identity. Address order and recipient-read authorization remain
|
||||
unchanged. SMTP/IMAP diagnostics use translated status-list filters.
|
||||
|
||||
Without workers, explicitly retry eligible failures or continue unattempted
|
||||
jobs through Report. Each request uses the canonical job/attempt recovery
|
||||
boundary and is limited by the effective synchronous policy. Accepted and
|
||||
uncertain SMTP outcomes remain protected. Active abandoned claims require an
|
||||
expired durable lease, proven stopped/replaced owner, current revision and
|
||||
valid original evidence before recovery can mark them unknown. A separate
|
||||
evidence-note reconciliation is required before retrying. A timeout alone is
|
||||
never proof of non-delivery. See the delivery runbook for required permissions
|
||||
and operational limitations.
|
||||
|
||||
Append-to-Sent is scoped to the selected version and reuses a bounded Mail-owned
|
||||
connection/folder resolution (default 100 messages or 300 seconds), while
|
||||
performing one sequential APPEND and all current checks per message. Uncertain
|
||||
appends are never automatically repeated, and repairing Sent never resends SMTP.
|
||||
|
||||
Link required files before locking. Lock and validate waits for attachment
|
||||
matches, rechecks them immediately before locking, and asks for Link and lock
|
||||
confirmation if new unlinked files are found. A locked version cannot acquire
|
||||
new attachment links; use an editable version and validate/build/review again.
|
||||
|
||||
### Fortschritt, Wiederherstellung und Dateiverknüpfungen
|
||||
|
||||
Jetzt senden, synchrone Wiederholung/Fortsetzung im Bericht und Kopieren nach
|
||||
Gesendet verwenden einen kompakten sperrenden Fortschrittsdialog. Nur gespeicherte
|
||||
Zähler werden aktualisiert, nicht der Arbeitsbereich im Hintergrund. Erfolgreich,
|
||||
ausstehend, in Bearbeitung, fehlgeschlagen, ungewiss und ausgeschlossen bleiben
|
||||
getrennt sichtbar. Bei Lesefehlern bleiben die letzten Werte erhalten. Nach einer
|
||||
getrennten Verbindung kann die Verarbeitung weiterlaufen; prüfen Sie Nachweise,
|
||||
bevor Sie erneut handeln. Ein bestätigter Versand wird durch einen nachfolgenden
|
||||
Anzeigefehler nicht nachträglich als fehlgeschlagen dargestellt.
|
||||
|
||||
Der empfängerbezogene Bericht zeigt alle eingefrorenen An-, Cc- und Bcc-Adressen
|
||||
in ihrer Reihenfolge. Leseberechtigungen bleiben unverändert. SMTP und IMAP
|
||||
verwenden übersetzte Zustandslisten zum Filtern.
|
||||
|
||||
Ohne Worker können bekannte Fehler ausdrücklich wiederholt und unversuchte
|
||||
Aufträge begrenzt fortgesetzt werden. Die wirksame synchrone Grenze, gespeicherte
|
||||
Aufträge, Prüfungen, Freigaben und Wiederherstellungsnachweise bleiben verbindlich.
|
||||
Angenommene und ungewisse SMTP-Ergebnisse werden nicht blind wiederholt. Die
|
||||
Wiederherstellung aktiver, verlassener Aufträge benötigt eine abgelaufene Sperre,
|
||||
nachweislich gestoppte/ersetzte Laufzeit und gültige ursprüngliche Nachweise. Sie
|
||||
setzt ausschließlich auf ungewiss; vor Wiederholung sind externe Nachweise und
|
||||
ein getrennter Abgleich mit Notiz erforderlich. Zeitablauf allein genügt nicht.
|
||||
|
||||
Kopieren nach Gesendet betrifft nur die ausgewählte Version. Mail verwendet die
|
||||
Verbindung und Ordnerauflösung begrenzt wieder (Standard: 100 Nachrichten oder
|
||||
300 Sekunden), prüft aber jede Nachricht erneut und führt APPEND nacheinander
|
||||
aus. Ungewisse Ergebnisse werden nicht automatisch wiederholt. Verknüpfen Sie
|
||||
benötigte Dateien vor dem Sperren; eine frische Prüfung fragt bei unverknüpften
|
||||
Treffern nach Verknüpfen und sperren. Gesperrte Versionen benötigen zum Ändern
|
||||
eine bearbeitbare Version mit erneuter Validierung, Build und Prüfung.
|
||||
|
||||
### Health to observe
|
||||
|
||||
- database and migration health;
|
||||
@@ -356,6 +714,12 @@ After restore, keep outbound delivery paused until queue/attempt state and
|
||||
provider evidence have been reconciled; never let restored accepted jobs send
|
||||
again merely because a queue message was lost.
|
||||
|
||||
For unreferenced generated objects after process loss, platform operators first
|
||||
run the bounded Campaign artifact inventory in dry-run mode. Apply only an
|
||||
inspected page with a unique incident idempotency key. The cleanup keeps exact
|
||||
keys out of ordinary Campaign responses, does not clear database references,
|
||||
and leaves storage failures in the Core recovery ledger for explicit retry.
|
||||
|
||||
### Incident handling
|
||||
|
||||
1. Pause new delivery when duplicate or unknown effects are possible.
|
||||
@@ -380,6 +744,8 @@ Current principal contracts include:
|
||||
| `files.campaign_attachments` 0.1.x | Files -> Campaign | Select/materialize governed file versions and preserve campaign usage/evidence |
|
||||
| `addresses.lookup` 0.1.x | Addresses -> Campaign | Optional address suggestions |
|
||||
| `addresses.recipient_source` 0.1.x | Addresses -> Campaign | Optional versioned recipient-source snapshots |
|
||||
| `dist_lists.source` / `dist_lists.expand` 0.1.x | Distribution Lists -> Campaign | Discover, preview, and freeze reusable audiences without importing module internals |
|
||||
| `templates.catalog` / `templates.renderer` 0.1.x | Templates -> Campaign | Select compatible published printable templates and produce deterministic, evidence-bearing artifacts |
|
||||
| `campaigns.access` 0.1.x | Campaign -> platform | Explain campaign access/existence without exporting ORM objects |
|
||||
| `campaigns.mail_policy_context` 0.1.x | Campaign -> Mail | Resolve campaign tenant/owner context for Mail policy |
|
||||
| `campaigns.delivery_tasks` 0.1.x | Campaign -> workers | Execute narrow queued send/append tasks |
|
||||
@@ -389,6 +755,67 @@ Breaking payload or ownership changes require an interface-version bump and a
|
||||
release-composition alignment gate. Optional absence must be tested physically,
|
||||
not only hidden in navigation.
|
||||
|
||||
### Bulk recipient activation
|
||||
|
||||
Recipient data can activate all currently inactive rows or deactivate all
|
||||
currently active rows. The action displays the exact affected count and
|
||||
requires confirmation. It changes only the local Campaign draft until the
|
||||
operator saves. Saving follows the ordinary versioned Campaign update path, so
|
||||
the resulting recipient state is retained in version and protocol evidence and
|
||||
any stale validation, build, or review evidence is invalidated.
|
||||
|
||||
### Reusable Distribution Lists
|
||||
|
||||
When Distribution Lists is available, Recipient data offers a separate import
|
||||
dialog. The author selects a visible list revision, supplies declared
|
||||
parameters, requests candidate channels, and previews included, excluded,
|
||||
stale, ambiguous, suppressed, policy-blocked, and provider-unavailable results.
|
||||
The final action freezes an idempotent Distribution Lists snapshot and copies
|
||||
the resulting rows into the editable Campaign version.
|
||||
|
||||
Each copied row retains the list and revision IDs, definition and expansion
|
||||
hashes, snapshot ID, source entry IDs, provider references, channel candidates,
|
||||
the explicitly selected primary route, optional fallback, and the decision
|
||||
explanation. Campaign-only fields, attachment rules, review state, and outcomes
|
||||
remain local to Campaign and never mutate the reusable list.
|
||||
|
||||
A later list revision only raises a drift warning. Refresh is deliberate and
|
||||
uses append or replace; saving that changed Campaign version clears prior
|
||||
validation, build, review, and execution state through the normal content
|
||||
invalidation path. Preferred or single usable candidates are preselected
|
||||
visibly; ambiguous rows must be decided before freezing. Postal and
|
||||
internal-mail routes remain active when a compatible published Templates output
|
||||
is selected.
|
||||
|
||||
### Governed hybrid and printable delivery
|
||||
|
||||
Campaign supports Mail, Postbox, printable output, and bounded ordered
|
||||
fallbacks without making any of those provider modules mandatory. Opt-in and
|
||||
channel-preference data are inputs to the visible routing decision; they never
|
||||
silently cause duplicate delivery.
|
||||
|
||||
For a printable route, select a published label, envelope, serial-letter,
|
||||
form-letter, list-layout, or generic template on the Template page. Validation
|
||||
checks the selected revision, output format, and required fields. Build sends
|
||||
one deterministic item collection to `templates.renderer`, records template,
|
||||
input, output, actor, route, and artifact hashes, and stores the resulting
|
||||
artifact through Files when configured. The review stage exposes that exact
|
||||
artifact and its hashes before execution.
|
||||
|
||||
Each recipient job records an idempotent print acceptance attempt for its item
|
||||
in the frozen artifact. `mail_then_print` and `postbox_then_print` invoke print
|
||||
only after a confirmed rejection before acceptance. An accepted or
|
||||
outcome-unknown digital effect never falls through to print because that could
|
||||
produce duplicate delivery. Reports and CSV exports include route provenance,
|
||||
print state, attempts, artifact reference, and hashes.
|
||||
|
||||
Without Templates, Campaign still loads and Mail/Postbox authoring remains
|
||||
available; validation explains why a configured print route cannot proceed.
|
||||
Without Files, Templates may return a bounded artifact instead of a managed
|
||||
file. Campaign copies that payload into shared object storage and exposes it
|
||||
through the Campaign ACL plus `campaigns:recipient:read`; it never redistributes
|
||||
the broader Templates URL. A print-only Campaign does not require Mail or Postbox.
|
||||
|
||||
### External API expectations
|
||||
|
||||
- Tenant and campaign access are evaluated for every operation.
|
||||
@@ -425,6 +852,35 @@ purpose, lawful basis, minimization, export control, and retention before the
|
||||
campaign starts; do not use Campaign as a substitute consent or address-master
|
||||
system.
|
||||
|
||||
The Core data-subject-request workflow discovers Campaign through the optional
|
||||
`privacy.dsar.campaigns` capability. After the request's email, membership, and
|
||||
namespaced Campaign references have been independently authorized and
|
||||
corroborated, the provider searches only the effective tenant and isolates the
|
||||
matching recipient entries and jobs. Its JSON result includes safe Campaign,
|
||||
version, delivery-attempt, schedule, report-projection, share, import-mapping,
|
||||
attachment, generated-artifact, and relevant collaboration metadata. It also
|
||||
finds collaboration entries authored, mentioned, or moderated by the subject.
|
||||
Text authored by the subject is included; somebody else's text is not copied
|
||||
merely because the subject was mentioned. Generated EML bytes and paths,
|
||||
storage locators, delivery target snapshots, worker claims, idempotency
|
||||
material, credentials, secret-like values, and unrelated recipients are never
|
||||
embedded in that result. Authorized Campaign and Files review surfaces remain
|
||||
the source for content that cannot safely be copied into the DSAR case.
|
||||
|
||||
Built, locked, published, terminal, delivered, or corrected records are
|
||||
retained with an explicit reason and continue through Campaign's configured
|
||||
retention and redaction process. Draft recipient content and user-owned
|
||||
attachment content require coordinated manual review because copies may span
|
||||
version JSON, jobs, generated messages, and managed files. The provider can
|
||||
idempotently revoke an active share aimed at the subject and delete the
|
||||
subject's personal recipient-import mapping profile. It does not rewrite
|
||||
delivery evidence, delete generated artifacts, or report derived Campaign
|
||||
counts as a separate store. Collaboration withdrawal and redaction retain the
|
||||
tombstone, content hash, context, and Audit evidence; the DSAR workflow does
|
||||
not rewrite these append-only records. Re-running an approved action is safe: already
|
||||
revoked or absent data is reported as unchanged, and tenant, subject, and row
|
||||
ownership are revalidated immediately before mutation.
|
||||
|
||||
### Audit and destructive actions
|
||||
|
||||
Material authoring, validation, locking, review, queueing, send, retry,
|
||||
@@ -437,6 +893,18 @@ lock exists. Evidence-bearing campaigns are archived. Destructive module
|
||||
retirement remains a separately confirmed installer operation with backup and
|
||||
retirement evidence.
|
||||
|
||||
The Campaign **Audit** page is currently an explained handoff, not a second
|
||||
audit store: Campaign emits bounded platform audit records and authorized
|
||||
readers inspect them in Administration > Tenant audit. A future object-scoped
|
||||
projection may improve that navigation without duplicating Audit ownership.
|
||||
Evidence-bundle export and offline verification remain owned by Audit #3.
|
||||
|
||||
The advanced **JSON** page displays and downloads the complete campaign
|
||||
configuration available to the current campaign reader. It contains no inline
|
||||
transport secrets, but recipient, message, and attachment fields may contain
|
||||
personal data. The UI therefore identifies it as sensitive expert output;
|
||||
campaign access and export purpose remain the governing controls.
|
||||
|
||||
## Reference-composition acceptance
|
||||
|
||||
Campaign is ready to serve as the demonstration module only when all of the
|
||||
@@ -467,9 +935,8 @@ The following are part of the selected reference journey but are not implied by
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -68,7 +68,9 @@ material and does not delete or rewrite the stored audit rows automatically:
|
||||
`mail_profile_migration_required` marker.
|
||||
- validation, build, queue, retry, and delivery fail closed with an actionable
|
||||
profile-migration error;
|
||||
- unrelated edits cannot silently scrub the legacy fields;
|
||||
- unrelated edits preserve the exact stored legacy server object when the
|
||||
submitted public Mail references are unchanged; they cannot silently scrub,
|
||||
edit, or re-submit legacy fields or credentials;
|
||||
- an editable version is migrated only through an explicit Mail-settings save
|
||||
with an authorized profile; and
|
||||
- a locked version remains unchanged. Creating its editable successor records
|
||||
@@ -81,6 +83,63 @@ database as a whole; the product does not define a separate historical-JSON or
|
||||
inline-secret recovery workflow. A restored legacy row remains inert and
|
||||
fail-closed under the same rules.
|
||||
|
||||
### Migrate from the Campaign UI
|
||||
|
||||
Open **Mail settings** from the migration notice for the selected version.
|
||||
Select an authorized Mail profile and choose **Migrate selected Mail profile**.
|
||||
The migration action is available even when that profile was already selected
|
||||
and the draft has no other unsaved changes. If the version is locked, first use
|
||||
its supported unlock or editable-successor action; protected source evidence is
|
||||
not rewritten. Reopen the settings and confirm that the migration notice has
|
||||
gone, then validate, build, and review before separately authorizing delivery.
|
||||
Migration itself never sends mail.
|
||||
|
||||
Mail migration and ZIP policy repairs can be saved in either order. An exact,
|
||||
unchanged ZIP configuration does not require a new acknowledgement merely to
|
||||
save a Mail migration, even if the existing ZIP policy or actor's permission is
|
||||
no longer valid. No actor, timestamp, or consent is invented. Any ZIP change
|
||||
still requires the complete current archive policy and, for ZipCrypto, the
|
||||
dedicated permission and reasoned acknowledgement. Conversely, saving an
|
||||
archive correction with unchanged public Mail references retains the exact
|
||||
legacy transport server-side, without using or reauthorizing the old profile.
|
||||
The migration notice remains until the explicit authorized migration succeeds.
|
||||
Changing any Mail profile, server, or credential reference is not an unrelated
|
||||
repair. Inline transport is rejected even if a caller echoes stored values.
|
||||
The same edit-time separation applies after migration: retaining an unchanged
|
||||
Mail selection does not invoke use-policy while saving an unrelated correction,
|
||||
even if a later policy requires an explicit credential. Selecting a new resource
|
||||
or explicitly migrating still checks Mail permission and current policy; actual
|
||||
validation and delivery always recheck them, regardless of save history.
|
||||
Both repair orders invalidate build/execution evidence; validation, build,
|
||||
review and delivery remain blocked until all outstanding conditions are valid.
|
||||
|
||||
Successful saves and subsequent refreshes are separate outcomes. A committed
|
||||
save is not undone by a failed workspace refresh. The workspace retains its
|
||||
last usable same-campaign/version data and displays the refresh error; retry
|
||||
Reload to fetch the current server state. Obsolete responses from an earlier
|
||||
campaign, version, signed-in identity, or reload cannot replace newer data.
|
||||
|
||||
The normal profile selector requests only campaign-authorized profiles. The
|
||||
administrative profile catalogue is requested separately on **Mail policy**;
|
||||
failure or lack of authority there does not empty the usable profile selector.
|
||||
Profile-list errors remain visible next to the affected settings.
|
||||
|
||||
While migration is required, **Review and send** remains read-only and links to
|
||||
the exact version's Mail settings. It does not repeatedly attempt incompatible
|
||||
attachment-preview requests. These UI affordances do not relax backend
|
||||
validation, build, queue, retry, or delivery enforcement.
|
||||
|
||||
### Editor metadata and trusted evidence
|
||||
|
||||
Read responses may contain server-owned `review_send` and `approval_gate`
|
||||
evidence. Ordinary version mutations send only client-owned `created_from`,
|
||||
`field_overrides`, and `opt_ins` editor metadata. The client omits review and
|
||||
approval evidence; the server rejects attempts to write it through an editor
|
||||
mutation and preserves its existing trusted value during metadata updates.
|
||||
Supported unlock, successor-version, and build invalidation rules still remove
|
||||
stale evidence when required. Opening or leaving the Template editor must not
|
||||
require manually deleting server review metadata.
|
||||
|
||||
## Operator checks
|
||||
|
||||
Before live delivery, confirm that:
|
||||
|
||||
@@ -64,6 +64,14 @@ warning and lets the user reopen the import dialog with that source preselected.
|
||||
The user still chooses append or replace; campaign should not silently rewrite
|
||||
recipient rows.
|
||||
|
||||
Distribution Lists is a separate, provider-neutral audience boundary. Campaign
|
||||
uses `dist_lists.source` and `dist_lists.expand` to preview and freeze mixed
|
||||
email, postal, internal-mail, and portal candidates. It stores exact list,
|
||||
revision, source-entry, provider, policy, route, exclusion, and expansion-hash
|
||||
evidence with the Campaign version. Addresses remains the contact/contact-point
|
||||
owner; Distribution Lists remains the reusable audience owner; Campaign owns
|
||||
only its copied recipients, enrichment, route choices, review, and outcomes.
|
||||
|
||||
## Non-Goals For Campaign
|
||||
|
||||
Campaign should not become the global address book. It should not own:
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -22,11 +22,11 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"@govoplan/core-webui": "^0.1.45",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.11"
|
||||
version = "0.1.29"
|
||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.8",
|
||||
"govoplan-core>=0.1.46",
|
||||
"jsonschema>=4,<5",
|
||||
"pydantic>=2,<3",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.approvals import (
|
||||
ApprovalRequestCreateCommand,
|
||||
ApprovalRequestRef,
|
||||
ApprovalStepDefinition,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
ApprovalGateUnavailable,
|
||||
approvals_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
|
||||
|
||||
|
||||
APPROVAL_GATE_KEY = "approval_gate"
|
||||
SUBJECT_MODULE = "campaigns"
|
||||
SUBJECT_TYPE = "campaign_execution"
|
||||
|
||||
|
||||
class CampaignApprovalGateError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _TenantPrincipal:
|
||||
tenant_id: str
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
def campaign_approval_gate(version: CampaignVersion) -> dict[str, object] | None:
|
||||
state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
gate = state.get(APPROVAL_GATE_KEY)
|
||||
return dict(gate) if isinstance(gate, dict) else None
|
||||
|
||||
|
||||
def request_campaign_approval(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
title: str,
|
||||
description: str | None,
|
||||
steps: tuple[ApprovalStepDefinition, ...],
|
||||
idempotency_key: str,
|
||||
template_id: str | None = None,
|
||||
template_revision: int | None = None,
|
||||
unique_actors_across_steps: bool = True,
|
||||
expires_at: datetime | None = None,
|
||||
policy_refs: tuple[str, ...] = (),
|
||||
) -> ApprovalRequestRef:
|
||||
if campaign.tenant_id != str(getattr(principal, "tenant_id", "") or ""):
|
||||
raise CampaignApprovalGateError("Campaign approval tenant mismatch.")
|
||||
if version.campaign_id != campaign.id:
|
||||
raise CampaignApprovalGateError("Campaign approval version mismatch.")
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
digest = str(version.execution_snapshot_hash or "")
|
||||
if len(digest) != 64:
|
||||
raise CampaignApprovalGateError(
|
||||
"Build a valid Campaign execution snapshot before requesting approval."
|
||||
)
|
||||
subject_version = _subject_version(version, snapshot.build_token)
|
||||
evidence_actors = _campaign_evidence_actors(campaign, version)
|
||||
try:
|
||||
request = approvals_integration().create_request(
|
||||
session,
|
||||
principal,
|
||||
command=ApprovalRequestCreateCommand(
|
||||
title=title,
|
||||
description=description,
|
||||
subject_module=SUBJECT_MODULE,
|
||||
subject_type=SUBJECT_TYPE,
|
||||
subject_id=version.id,
|
||||
subject_version=subject_version,
|
||||
subject_digest=digest,
|
||||
steps=steps,
|
||||
separation_of_duties=True,
|
||||
unique_actors_across_steps=unique_actors_across_steps,
|
||||
expires_at=expires_at,
|
||||
policy_refs=policy_refs,
|
||||
evidence_actors=evidence_actors,
|
||||
template_id=template_id,
|
||||
template_revision=template_revision,
|
||||
metadata={
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_number": version.version_number,
|
||||
"execution_snapshot_version": snapshot.snapshot_version,
|
||||
},
|
||||
),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
except ApprovalGateUnavailable as exc:
|
||||
raise CampaignApprovalGateError(str(exc)) from exc
|
||||
state = copy.deepcopy(version.editor_state or {})
|
||||
state[APPROVAL_GATE_KEY] = {
|
||||
"request_id": request.id,
|
||||
"request_revision": request.revision,
|
||||
"subject_version": subject_version,
|
||||
"subject_digest": digest,
|
||||
"requested_at": datetime.now(UTC).isoformat(),
|
||||
"requested_by_user_id": _actor_id(principal),
|
||||
}
|
||||
version.editor_state = state
|
||||
session.add(version)
|
||||
session.flush()
|
||||
return request
|
||||
|
||||
|
||||
def assert_campaign_approval(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version: CampaignVersion,
|
||||
) -> None:
|
||||
gate = campaign_approval_gate(version)
|
||||
if gate is None:
|
||||
return
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
digest = str(version.execution_snapshot_hash or "")
|
||||
subject_version = _subject_version(version, snapshot.build_token)
|
||||
if (
|
||||
gate.get("subject_digest") != digest
|
||||
or gate.get("subject_version") != subject_version
|
||||
):
|
||||
raise CampaignApprovalGateError(
|
||||
"Campaign execution changed after approval was requested. Request approval for the current build."
|
||||
)
|
||||
request_id = str(gate.get("request_id") or "").strip()
|
||||
if not request_id:
|
||||
raise CampaignApprovalGateError(
|
||||
"Campaign approval gate has no request reference."
|
||||
)
|
||||
try:
|
||||
check = approvals_integration().check_approved(
|
||||
session,
|
||||
_TenantPrincipal(tenant_id=tenant_id),
|
||||
request_id=request_id,
|
||||
subject_module=SUBJECT_MODULE,
|
||||
subject_type=SUBJECT_TYPE,
|
||||
subject_id=version.id,
|
||||
subject_version=subject_version,
|
||||
subject_digest=digest,
|
||||
)
|
||||
except (ApprovalGateUnavailable, LookupError, ValueError) as exc:
|
||||
raise CampaignApprovalGateError(str(exc)) from exc
|
||||
if not check.approved:
|
||||
raise CampaignApprovalGateError(
|
||||
f"Campaign delivery requires Approval request {request_id}, which is {check.state}."
|
||||
)
|
||||
|
||||
|
||||
def campaign_approval_status(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version: CampaignVersion,
|
||||
) -> dict[str, object]:
|
||||
gate = campaign_approval_gate(version)
|
||||
integration = approvals_integration()
|
||||
if gate is None:
|
||||
return {
|
||||
"configured": False,
|
||||
"available": integration.available,
|
||||
"approved": False,
|
||||
"state": "not_required",
|
||||
"explanation": None,
|
||||
}
|
||||
try:
|
||||
assert_campaign_approval(session, tenant_id=tenant_id, version=version)
|
||||
except CampaignApprovalGateError as exc:
|
||||
return {
|
||||
**gate,
|
||||
"configured": True,
|
||||
"available": integration.available,
|
||||
"approved": False,
|
||||
"state": "unavailable" if not integration.available else "pending",
|
||||
"explanation": str(exc),
|
||||
}
|
||||
return {
|
||||
**gate,
|
||||
"configured": True,
|
||||
"available": True,
|
||||
"approved": True,
|
||||
"state": "approved",
|
||||
"explanation": None,
|
||||
}
|
||||
|
||||
|
||||
def clear_campaign_approval_gate(version: CampaignVersion) -> None:
|
||||
state = copy.deepcopy(version.editor_state or {})
|
||||
state.pop(APPROVAL_GATE_KEY, None)
|
||||
version.editor_state = state
|
||||
|
||||
|
||||
def _campaign_evidence_actors(
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
) -> Mapping[str, tuple[str, ...]]:
|
||||
validation = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else {}
|
||||
)
|
||||
build = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
editor = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
review = (
|
||||
editor.get("review_send") if isinstance(editor.get("review_send"), dict) else {}
|
||||
)
|
||||
review_decisions = (
|
||||
review.get("issue_decisions")
|
||||
if isinstance(review.get("issue_decisions"), list)
|
||||
else []
|
||||
)
|
||||
values = {
|
||||
"author": (campaign.created_by_user_id,),
|
||||
"owner": (campaign.owner_user_id,),
|
||||
"validator": (
|
||||
validation.get("validated_by_user_id"),
|
||||
version.locked_by_user_id,
|
||||
),
|
||||
"builder": (build.get("built_by_user_id"),),
|
||||
"reviewer": (
|
||||
review.get("updated_by_user_id"),
|
||||
*(
|
||||
item.get("actor_user_id")
|
||||
for item in review_decisions
|
||||
if isinstance(item, dict)
|
||||
),
|
||||
),
|
||||
}
|
||||
return {
|
||||
role: tuple(dict.fromkeys(str(item) for item in actors if item))
|
||||
for role, actors in values.items()
|
||||
if any(actors)
|
||||
}
|
||||
|
||||
|
||||
def _subject_version(version: CampaignVersion, build_token: str | None) -> str:
|
||||
return str(build_token or f"campaign-version-{version.version_number}")[:120]
|
||||
|
||||
|
||||
def _actor_id(principal: object) -> str | None:
|
||||
for name in ("account_id", "user_id", "identity_id", "membership_id"):
|
||||
value = str(getattr(principal, name, "") or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignApprovalGateError",
|
||||
"assert_campaign_approval",
|
||||
"campaign_approval_gate",
|
||||
"campaign_approval_status",
|
||||
"clear_campaign_approval_gate",
|
||||
"request_campaign_approval",
|
||||
]
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_core.core.approvals import ApprovalActorSelector, ApprovalStepDefinition
|
||||
|
||||
|
||||
class CampaignApprovalSelectorInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: Literal["account", "group", "role", "function_assignment", "any_account"]
|
||||
value: str = Field(min_length=1, max_length=255)
|
||||
label: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class CampaignApprovalStepInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str = Field(min_length=1, max_length=120)
|
||||
label: str = Field(min_length=1, max_length=255)
|
||||
selectors: list[CampaignApprovalSelectorInput] = Field(min_length=1, max_length=500)
|
||||
required_approvals: int = Field(default=1, ge=1, le=500)
|
||||
rejection_policy: Literal["fail_fast", "collect"] = "fail_fast"
|
||||
due_at: datetime | None = None
|
||||
signature_required: bool = False
|
||||
forbidden_evidence_roles: list[
|
||||
Literal["author", "owner", "validator", "builder", "reviewer"]
|
||||
] = Field(default_factory=list, max_length=5)
|
||||
|
||||
def to_definition(self) -> ApprovalStepDefinition:
|
||||
return ApprovalStepDefinition(
|
||||
key=self.key,
|
||||
label=self.label,
|
||||
selectors=tuple(
|
||||
ApprovalActorSelector(item.kind, item.value, item.label)
|
||||
for item in self.selectors
|
||||
),
|
||||
required_approvals=self.required_approvals,
|
||||
rejection_policy=self.rejection_policy,
|
||||
due_at=self.due_at,
|
||||
signature_required=self.signature_required,
|
||||
forbidden_evidence_roles=tuple(self.forbidden_evidence_roles),
|
||||
)
|
||||
|
||||
|
||||
class CampaignApprovalRequestInput(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str = Field(
|
||||
default="Approve Campaign delivery", min_length=1, max_length=255
|
||||
)
|
||||
description: str | None = Field(default=None, max_length=10_000)
|
||||
steps: list[CampaignApprovalStepInput] = Field(default_factory=list, max_length=100)
|
||||
template_id: str | None = Field(default=None, max_length=36)
|
||||
template_revision: int | None = Field(default=None, ge=1)
|
||||
unique_actors_across_steps: bool = True
|
||||
expires_at: datetime | None = None
|
||||
policy_refs: list[str] = Field(default_factory=list, max_length=500)
|
||||
idempotency_key: str = Field(min_length=1, max_length=160)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_source(self) -> "CampaignApprovalRequestInput":
|
||||
template = self.template_id is not None or self.template_revision is not None
|
||||
if template and (self.template_id is None or self.template_revision is None):
|
||||
raise ValueError("Template id and revision must be supplied together.")
|
||||
if template and self.steps:
|
||||
raise ValueError("Use either an Approval template or inline steps.")
|
||||
if not template and not self.steps:
|
||||
raise ValueError(
|
||||
"At least one Approval step is required without a template."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
__all__ = ["CampaignApprovalRequestInput"]
|
||||
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.policy import (
|
||||
CampaignArchiveEncryptionDecision,
|
||||
CampaignArchiveEncryptionRequest,
|
||||
campaign_archive_encryption_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
|
||||
|
||||
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
|
||||
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveArchiveEncryptionPolicy:
|
||||
available: bool
|
||||
allowed_password_encryption_methods: frozenset[str]
|
||||
allowed_password_delivery_channels: frozenset[str]
|
||||
policy_hash: str
|
||||
source_path: tuple[Mapping[str, Any], ...]
|
||||
reason: str
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"available": self.available,
|
||||
"allowed_password_encryption_methods": sorted(
|
||||
self.allowed_password_encryption_methods
|
||||
),
|
||||
"allowed_password_delivery_channels": sorted(
|
||||
self.allowed_password_delivery_channels
|
||||
),
|
||||
"policy_hash": self.policy_hash,
|
||||
"source_path": [dict(item) for item in self.source_path],
|
||||
"reason": self.reason,
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
|
||||
}
|
||||
|
||||
|
||||
def effective_archive_encryption_policy(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
provider = campaign_archive_encryption_policy(get_registry())
|
||||
if provider is None:
|
||||
payload = {
|
||||
"available": False,
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"allowed_password_delivery_channels": [
|
||||
"in_person",
|
||||
"letter",
|
||||
"phone",
|
||||
"separate_mail",
|
||||
"sms",
|
||||
],
|
||||
"source_path": [
|
||||
{
|
||||
"scope_type": "system",
|
||||
"scope_id": None,
|
||||
"path": "system",
|
||||
"label": "Secure local fallback",
|
||||
"applied_fields": ["allowed_password_encryption_methods"],
|
||||
"policy": {
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"policy_provider": "unavailable",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=False,
|
||||
allowed_password_encryption_methods=frozenset({"aes"}),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
),
|
||||
policy_hash=_hash(payload),
|
||||
source_path=tuple(payload["source_path"]),
|
||||
reason=(
|
||||
"Policy is unavailable. AES remains available through the secure "
|
||||
"local baseline; legacy ZipCrypto fails closed."
|
||||
),
|
||||
)
|
||||
owner_type: str | None = None
|
||||
owner_id: str | None = None
|
||||
if campaign.owner_group_id:
|
||||
owner_type, owner_id = "group", campaign.owner_group_id
|
||||
elif campaign.owner_user_id:
|
||||
owner_type, owner_id = "user", campaign.owner_user_id
|
||||
decision: CampaignArchiveEncryptionDecision = (
|
||||
provider.resolve_campaign_archive_encryption(
|
||||
session,
|
||||
request=CampaignArchiveEncryptionRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
owner_type=owner_type, # type: ignore[arg-type]
|
||||
owner_id=owner_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=True,
|
||||
allowed_password_encryption_methods=frozenset(
|
||||
decision.allowed_password_encryption_methods
|
||||
),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
decision.allowed_password_delivery_channels
|
||||
),
|
||||
policy_hash=decision.policy_hash,
|
||||
source_path=tuple(step.to_dict() for step in decision.source_path),
|
||||
reason=decision.reason or "Effective archive-encryption policy resolved.",
|
||||
diagnostics=decision.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def assert_archive_encryption_allowed(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
raw_json: Mapping[str, Any],
|
||||
*,
|
||||
principal: ApiPrincipal | None = None,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
for archive in _archive_configs(raw_json):
|
||||
method = str(archive.get("method") or "aes")
|
||||
if method not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{_method_label(method)} is blocked. {policy.reason}"
|
||||
)
|
||||
if method == "zip_standard":
|
||||
if not policy.available:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto cannot be used while Policy is unavailable."
|
||||
)
|
||||
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged"):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
|
||||
)
|
||||
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
|
||||
)
|
||||
if archive.get("password_enabled"):
|
||||
# Existing campaign revisions predate the explicit field. Their
|
||||
# model default is the separate-mail channel; apply the same
|
||||
# normalization before policy enforcement so saved revisions do
|
||||
# not become unusable merely because the field was omitted.
|
||||
channel = str(
|
||||
archive.get("password_delivery_channel") or "separate_mail"
|
||||
)
|
||||
if channel not in policy.allowed_password_delivery_channels:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Password-delivery channel {channel!r} is blocked by the effective policy."
|
||||
)
|
||||
return policy
|
||||
|
||||
|
||||
def stamp_legacy_zipcrypto_acknowledgements(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
current_raw_json: Mapping[str, Any],
|
||||
candidate_raw_json: dict[str, Any] | None,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
if candidate_raw_json is None:
|
||||
return None, []
|
||||
candidate = copy.deepcopy(candidate_raw_json)
|
||||
current_attachments = current_raw_json.get("attachments")
|
||||
candidate_attachments = candidate.get("attachments")
|
||||
current_zip = current_attachments.get("zip") if isinstance(current_attachments, Mapping) else None
|
||||
candidate_zip = candidate_attachments.get("zip") if isinstance(candidate_attachments, Mapping) else None
|
||||
if json.dumps(current_zip, sort_keys=True) == json.dumps(candidate_zip, sort_keys=True):
|
||||
# Saving an unrelated repair does not authorize use of an existing
|
||||
# archive or invent an acknowledgement. Preserve the exact stored ZIP
|
||||
# configuration, including missing evidence or now-revoked policy.
|
||||
# Build/review/delivery still validate the complete configuration.
|
||||
return candidate, []
|
||||
current_by_id = {
|
||||
str(item.get("id") or index): item
|
||||
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||
}
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
archives = _archive_configs(candidate)
|
||||
for index, archive in enumerate(archives):
|
||||
if str(archive.get("method") or "aes") != "zip_standard":
|
||||
archive.pop("legacy_zipcrypto_acknowledged_by", None)
|
||||
archive.pop("legacy_zipcrypto_acknowledged_at", None)
|
||||
continue
|
||||
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
|
||||
)
|
||||
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
|
||||
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
|
||||
)
|
||||
key = str(archive.get("id") or index)
|
||||
previous = current_by_id.get(key, {})
|
||||
unchanged = (
|
||||
previous.get("method") == "zip_standard"
|
||||
and previous.get("legacy_zipcrypto_acknowledged") is True
|
||||
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
|
||||
and previous.get("legacy_zipcrypto_acknowledged_by")
|
||||
and previous.get("legacy_zipcrypto_acknowledged_at")
|
||||
)
|
||||
if unchanged:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_by"
|
||||
]
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
]
|
||||
else:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
|
||||
acknowledgements.append(
|
||||
{
|
||||
"archive_id": key,
|
||||
"reason": reason,
|
||||
"policy_hash": policy.policy_hash,
|
||||
}
|
||||
)
|
||||
# Modified archive settings must satisfy the complete current policy, not
|
||||
# only the special ZipCrypto acknowledgement checks above.
|
||||
assert_archive_encryption_allowed(session, campaign, candidate, principal=principal)
|
||||
return candidate, acknowledgements
|
||||
|
||||
|
||||
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
|
||||
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
|
||||
|
||||
|
||||
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
attachments = raw_json.get("attachments")
|
||||
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
|
||||
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
|
||||
if isinstance(archives, list):
|
||||
return [item for item in archives if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _method_label(method: str) -> str:
|
||||
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
|
||||
|
||||
|
||||
def _hash(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignArchiveEncryptionError",
|
||||
"EffectiveArchiveEncryptionPolicy",
|
||||
"LEGACY_ZIPCRYPTO_LABEL",
|
||||
"LEGACY_ZIPCRYPTO_SCOPE",
|
||||
"assert_archive_encryption_allowed",
|
||||
"effective_archive_encryption_policy",
|
||||
"has_password_archives",
|
||||
"stamp_legacy_zipcrypto_acknowledgements",
|
||||
]
|
||||
@@ -0,0 +1,734 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.core.object_storage import (
|
||||
StorageBackend,
|
||||
StorageBackendError,
|
||||
StorageObjectInfo,
|
||||
)
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryMode,
|
||||
RecoveryOperation,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
TERMINAL_RECOVERY_STATUSES,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import begin_durable_recovery_operation
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_ARTIFACT_NAMESPACE = "campaign-artifacts"
|
||||
MINIMUM_GRACE_HOURS = 24
|
||||
MAXIMUM_PAGE_SIZE = 1000
|
||||
_CHECKPOINT_BATCH_SIZE = 25
|
||||
|
||||
SessionFactory = Callable[[], Session]
|
||||
|
||||
|
||||
class CampaignArtifactReconciliationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ArtifactCandidate:
|
||||
key: str
|
||||
size_bytes: int
|
||||
modified_at: datetime
|
||||
age_seconds: int
|
||||
reason: str = "unreferenced_after_grace_period"
|
||||
disposition: str = "candidate"
|
||||
failure_type: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"key": self.key,
|
||||
"size_bytes": self.size_bytes,
|
||||
"modified_at": self.modified_at.isoformat(),
|
||||
"age_seconds": self.age_seconds,
|
||||
"reason": self.reason,
|
||||
"disposition": self.disposition,
|
||||
"failure_type": self.failure_type,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ArtifactInventory:
|
||||
tenant_prefix: str
|
||||
cursor: str | None
|
||||
next_cursor: str | None
|
||||
scanned_count: int
|
||||
scanned_bytes: int
|
||||
referenced_count: int
|
||||
active_build_count: int
|
||||
young_count: int
|
||||
unknown_age_count: int
|
||||
invalid_shape_count: int
|
||||
candidates: list[ArtifactCandidate]
|
||||
manifest_sha256: str
|
||||
|
||||
@property
|
||||
def candidate_bytes(self) -> int:
|
||||
return sum(candidate.size_bytes for candidate in self.candidates)
|
||||
|
||||
def response(
|
||||
self,
|
||||
*,
|
||||
apply: bool,
|
||||
status: str,
|
||||
recovery_operation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
deleted = [
|
||||
candidate
|
||||
for candidate in self.candidates
|
||||
if candidate.disposition == "deleted"
|
||||
]
|
||||
failures = [
|
||||
candidate
|
||||
for candidate in self.candidates
|
||||
if candidate.disposition
|
||||
in {"delete_failed", "delete_outcome_unknown"}
|
||||
]
|
||||
return {
|
||||
"apply": apply,
|
||||
"status": status,
|
||||
"recovery_operation_id": recovery_operation_id,
|
||||
"tenant_prefix": self.tenant_prefix,
|
||||
"cursor": self.cursor,
|
||||
"next_cursor": self.next_cursor,
|
||||
"scanned_count": self.scanned_count,
|
||||
"scanned_bytes": self.scanned_bytes,
|
||||
"referenced_count": self.referenced_count,
|
||||
"active_build_count": self.active_build_count,
|
||||
"young_count": self.young_count,
|
||||
"unknown_age_count": self.unknown_age_count,
|
||||
"invalid_shape_count": self.invalid_shape_count,
|
||||
"candidate_count": len(self.candidates),
|
||||
"candidate_bytes": self.candidate_bytes,
|
||||
"deleted_count": len(deleted),
|
||||
"deleted_bytes": sum(candidate.size_bytes for candidate in deleted),
|
||||
"failure_count": len(failures),
|
||||
"manifest_sha256": self.manifest_sha256,
|
||||
"candidates": [candidate.as_dict() for candidate in self.candidates],
|
||||
}
|
||||
|
||||
|
||||
def campaign_artifact_inventory(
|
||||
session: Session,
|
||||
*,
|
||||
storage: StorageBackend,
|
||||
tenant_id: str,
|
||||
grace_period: timedelta,
|
||||
cursor: str | None = None,
|
||||
page_size: int = 250,
|
||||
now: datetime | None = None,
|
||||
) -> ArtifactInventory:
|
||||
observed_at = _as_utc(now or datetime.now(timezone.utc))
|
||||
if grace_period < timedelta(hours=MINIMUM_GRACE_HOURS):
|
||||
raise ValueError(
|
||||
f"Campaign artifact grace period must be at least {MINIMUM_GRACE_HOURS} hours"
|
||||
)
|
||||
bounded_page_size = max(1, min(int(page_size), MAXIMUM_PAGE_SIZE))
|
||||
prefix = _tenant_artifact_prefix(tenant_id)
|
||||
if cursor is not None and not cursor.startswith(prefix):
|
||||
raise ValueError("Campaign artifact cursor is outside the tenant namespace")
|
||||
|
||||
page = storage.list_objects(
|
||||
prefix=prefix,
|
||||
after=cursor,
|
||||
limit=bounded_page_size,
|
||||
)
|
||||
page_keys = {info.key for info in page.objects}
|
||||
referenced_keys = _referenced_artifact_keys(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
prefix=prefix,
|
||||
artifact_keys=page_keys,
|
||||
)
|
||||
active_build_ids = _active_build_ids(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
now=observed_at,
|
||||
)
|
||||
|
||||
candidates: list[ArtifactCandidate] = []
|
||||
referenced_count = 0
|
||||
active_build_count = 0
|
||||
young_count = 0
|
||||
unknown_age_count = 0
|
||||
invalid_shape_count = 0
|
||||
scanned_bytes = 0
|
||||
manifest_rows: list[dict[str, Any]] = []
|
||||
for info in page.objects:
|
||||
scanned_bytes += info.size_bytes
|
||||
build_id = _build_id_for_key(info.key, prefix=prefix)
|
||||
modified_at = _object_modified_at(info)
|
||||
manifest_rows.append(
|
||||
{
|
||||
"key_sha256": _sha256(info.key),
|
||||
"size_bytes": info.size_bytes,
|
||||
"modified_at": modified_at.isoformat() if modified_at else None,
|
||||
}
|
||||
)
|
||||
if build_id is None:
|
||||
invalid_shape_count += 1
|
||||
continue
|
||||
if info.key in referenced_keys:
|
||||
referenced_count += 1
|
||||
continue
|
||||
if build_id in active_build_ids:
|
||||
active_build_count += 1
|
||||
continue
|
||||
if modified_at is None:
|
||||
unknown_age_count += 1
|
||||
continue
|
||||
age = observed_at - modified_at
|
||||
if age < grace_period:
|
||||
young_count += 1
|
||||
continue
|
||||
candidates.append(
|
||||
ArtifactCandidate(
|
||||
key=info.key,
|
||||
size_bytes=info.size_bytes,
|
||||
modified_at=modified_at,
|
||||
age_seconds=max(0, int(age.total_seconds())),
|
||||
)
|
||||
)
|
||||
|
||||
return ArtifactInventory(
|
||||
tenant_prefix=prefix,
|
||||
cursor=cursor,
|
||||
next_cursor=page.next_cursor,
|
||||
scanned_count=len(page.objects),
|
||||
scanned_bytes=scanned_bytes,
|
||||
referenced_count=referenced_count,
|
||||
active_build_count=active_build_count,
|
||||
young_count=young_count,
|
||||
unknown_age_count=unknown_age_count,
|
||||
invalid_shape_count=invalid_shape_count,
|
||||
candidates=candidates,
|
||||
manifest_sha256=_canonical_sha256(manifest_rows),
|
||||
)
|
||||
|
||||
|
||||
def reconcile_campaign_artifacts(
|
||||
session_factory: SessionFactory,
|
||||
*,
|
||||
storage: StorageBackend,
|
||||
identity: RuntimeIdentity,
|
||||
tenant_id: str,
|
||||
apply: bool = False,
|
||||
idempotency_key: str | None = None,
|
||||
grace_period_hours: int = MINIMUM_GRACE_HOURS,
|
||||
cursor: str | None = None,
|
||||
page_size: int = 250,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if apply and not (idempotency_key or "").strip():
|
||||
raise ValueError("Applied Campaign artifact cleanup requires an idempotency key")
|
||||
if grace_period_hours < MINIMUM_GRACE_HOURS:
|
||||
raise ValueError(
|
||||
f"Campaign artifact grace period must be at least {MINIMUM_GRACE_HOURS} hours"
|
||||
)
|
||||
observed_at = _as_utc(now or datetime.now(timezone.utc))
|
||||
grace_period = timedelta(hours=grace_period_hours)
|
||||
if not apply:
|
||||
with session_factory() as session:
|
||||
inventory = campaign_artifact_inventory(
|
||||
session,
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
grace_period=grace_period,
|
||||
cursor=cursor,
|
||||
page_size=page_size,
|
||||
now=observed_at,
|
||||
)
|
||||
return inventory.response(apply=False, status="dry_run")
|
||||
|
||||
prefix = _tenant_artifact_prefix(tenant_id)
|
||||
request = {
|
||||
"tenant_id": tenant_id,
|
||||
"prefix": prefix,
|
||||
"cursor_sha256": _sha256(cursor) if cursor else None,
|
||||
"page_size": max(1, min(int(page_size), MAXIMUM_PAGE_SIZE)),
|
||||
"grace_period_hours": grace_period_hours,
|
||||
}
|
||||
recovery_start = begin_durable_recovery_operation(
|
||||
session_factory,
|
||||
identity=identity,
|
||||
module_id="campaigns",
|
||||
operation_type="artifact-orphan-reconciliation",
|
||||
idempotency_key=str(idempotency_key).strip(),
|
||||
request=request,
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=(
|
||||
"inventory is bounded to one tenant Campaign artifact prefix",
|
||||
"objects younger than the conservative grace period are excluded",
|
||||
),
|
||||
forward_recovery_steps=(
|
||||
"retry only objects still unreferenced by committed Campaign state",
|
||||
),
|
||||
verification_steps=(
|
||||
"probe every attempted object after deletion",
|
||||
"preserve database references without mutation",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"tenant_prefix_sha256": _sha256(prefix),
|
||||
"grace_period_hours": grace_period_hours,
|
||||
"page_size": request["page_size"],
|
||||
},
|
||||
lease_resource_key=f"campaign:artifact-reconcile:{tenant_id}",
|
||||
lease_ttl_seconds=15 * 60,
|
||||
resource_type="campaign_artifact_namespace",
|
||||
resource_id=tenant_id,
|
||||
metadata={"tenant_id": tenant_id},
|
||||
)
|
||||
if recovery_start.replayed:
|
||||
return _replayed_response(
|
||||
prefix=prefix,
|
||||
cursor=cursor,
|
||||
operation_id=recovery_start.operation_id,
|
||||
)
|
||||
operation = recovery_start.operation
|
||||
if operation is None: # pragma: no cover - guarded by replay branch
|
||||
raise CampaignArtifactReconciliationError(
|
||||
"Campaign artifact cleanup authority was not created"
|
||||
)
|
||||
|
||||
try:
|
||||
try:
|
||||
with session_factory() as session:
|
||||
inventory = campaign_artifact_inventory(
|
||||
session,
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
grace_period=grace_period,
|
||||
cursor=cursor,
|
||||
page_size=page_size,
|
||||
now=observed_at,
|
||||
)
|
||||
except Exception as exc:
|
||||
operation.fail(
|
||||
summary="Campaign artifact inventory failed before deletion",
|
||||
evidence={
|
||||
"effect_started": False,
|
||||
"failure_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
operation.checkpoint(
|
||||
kind="artifact-inventory",
|
||||
summary="The bounded Campaign artifact inventory was classified",
|
||||
evidence={
|
||||
"manifest_sha256": inventory.manifest_sha256,
|
||||
"scanned_count": inventory.scanned_count,
|
||||
"candidate_count": len(inventory.candidates),
|
||||
"candidate_bytes": inventory.candidate_bytes,
|
||||
"next_page": inventory.next_cursor is not None,
|
||||
},
|
||||
)
|
||||
|
||||
if inventory.candidates:
|
||||
_apply_inventory(
|
||||
session_factory,
|
||||
storage=storage,
|
||||
operation=operation,
|
||||
inventory=inventory,
|
||||
tenant_id=tenant_id,
|
||||
now=observed_at,
|
||||
)
|
||||
|
||||
failed = [
|
||||
item
|
||||
for item in inventory.candidates
|
||||
if item.disposition == "delete_failed"
|
||||
]
|
||||
unknown = [
|
||||
item
|
||||
for item in inventory.candidates
|
||||
if item.disposition == "delete_outcome_unknown"
|
||||
]
|
||||
evidence = _cleanup_evidence(inventory)
|
||||
if unknown:
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Campaign artifact deletion could not be verified",
|
||||
evidence=evidence,
|
||||
failure_summary=(
|
||||
"One or more Campaign artifact deletion outcomes are unknown"
|
||||
),
|
||||
)
|
||||
status = "outcome_unknown"
|
||||
elif failed:
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="Campaign artifact deletion requires a retry",
|
||||
evidence=evidence,
|
||||
failure_summary=(
|
||||
"One or more unreferenced Campaign artifacts remain"
|
||||
),
|
||||
)
|
||||
status = "recovery_required"
|
||||
else:
|
||||
operation.succeed(evidence=evidence)
|
||||
status = "applied"
|
||||
return inventory.response(
|
||||
apply=True,
|
||||
status=status,
|
||||
recovery_operation_id=recovery_start.operation_id,
|
||||
)
|
||||
except Exception:
|
||||
if not operation.closed:
|
||||
try:
|
||||
operation.release_unresolved()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _apply_inventory(
|
||||
session_factory: SessionFactory,
|
||||
*,
|
||||
storage: StorageBackend,
|
||||
operation: Any,
|
||||
inventory: ArtifactInventory,
|
||||
tenant_id: str,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
for index in range(0, len(inventory.candidates), _CHECKPOINT_BATCH_SIZE):
|
||||
batch = inventory.candidates[index : index + _CHECKPOINT_BATCH_SIZE]
|
||||
with session_factory() as session:
|
||||
referenced = _referenced_artifact_keys(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
prefix=inventory.tenant_prefix,
|
||||
artifact_keys={candidate.key for candidate in batch},
|
||||
)
|
||||
active_build_ids = _active_build_ids(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
now=now,
|
||||
)
|
||||
operation.checkpoint(
|
||||
kind="artifact-delete-batch-authorized",
|
||||
summary="Deletion authority was renewed for a bounded object batch",
|
||||
evidence={
|
||||
"batch_index": index // _CHECKPOINT_BATCH_SIZE,
|
||||
"batch_count": len(batch),
|
||||
"batch_manifest_sha256": _canonical_sha256(
|
||||
[_sha256(candidate.key) for candidate in batch]
|
||||
),
|
||||
},
|
||||
)
|
||||
for candidate in batch:
|
||||
build_id = _build_id_for_key(
|
||||
candidate.key,
|
||||
prefix=inventory.tenant_prefix,
|
||||
)
|
||||
if candidate.key in referenced or build_id in active_build_ids:
|
||||
candidate.disposition = "protected_before_delete"
|
||||
candidate.reason = "reference_or_active_build_appeared"
|
||||
continue
|
||||
_delete_and_verify(storage, candidate)
|
||||
|
||||
|
||||
def _delete_and_verify(
|
||||
storage: StorageBackend,
|
||||
candidate: ArtifactCandidate,
|
||||
) -> None:
|
||||
delete_failure: Exception | None = None
|
||||
try:
|
||||
storage.delete(candidate.key)
|
||||
except (OSError, StorageBackendError) as exc:
|
||||
delete_failure = exc
|
||||
try:
|
||||
remains = storage.exists(candidate.key)
|
||||
except (OSError, StorageBackendError) as exc:
|
||||
candidate.disposition = "delete_outcome_unknown"
|
||||
candidate.failure_type = type(exc).__name__
|
||||
return
|
||||
if not remains:
|
||||
candidate.disposition = "deleted"
|
||||
return
|
||||
candidate.disposition = "delete_failed"
|
||||
candidate.failure_type = (
|
||||
type(delete_failure).__name__ if delete_failure is not None else None
|
||||
)
|
||||
|
||||
|
||||
def _referenced_artifact_keys(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
prefix: str,
|
||||
artifact_keys: set[str] | None = None,
|
||||
) -> set[str]:
|
||||
if artifact_keys == set():
|
||||
return set()
|
||||
keys: set[str] = set()
|
||||
version_ids = {
|
||||
identity[1]
|
||||
for key in artifact_keys or ()
|
||||
if (identity := _artifact_identity(key, prefix=prefix)) is not None
|
||||
}
|
||||
job_query = session.query(
|
||||
CampaignJob.eml_storage_key,
|
||||
CampaignJob.resolved_print_output,
|
||||
).filter(CampaignJob.tenant_id == tenant_id)
|
||||
if artifact_keys is not None:
|
||||
filters = [CampaignJob.eml_storage_key.in_(artifact_keys)]
|
||||
if version_ids:
|
||||
filters.append(CampaignJob.campaign_version_id.in_(version_ids))
|
||||
job_query = job_query.filter(or_(*filters))
|
||||
job_rows = job_query.yield_per(1000)
|
||||
for eml_storage_key, print_output in job_rows:
|
||||
_add_key(
|
||||
keys,
|
||||
eml_storage_key,
|
||||
prefix=prefix,
|
||||
allowed_keys=artifact_keys,
|
||||
)
|
||||
_add_key(
|
||||
keys,
|
||||
_print_output_storage_key(print_output),
|
||||
prefix=prefix,
|
||||
allowed_keys=artifact_keys,
|
||||
)
|
||||
|
||||
version_query = session.query(CampaignVersion.build_summary).join(
|
||||
Campaign,
|
||||
Campaign.id == CampaignVersion.campaign_id,
|
||||
).filter(Campaign.tenant_id == tenant_id)
|
||||
if artifact_keys is not None:
|
||||
if not version_ids:
|
||||
return keys
|
||||
version_query = version_query.filter(CampaignVersion.id.in_(version_ids))
|
||||
version_rows = version_query.yield_per(500)
|
||||
for (build_summary,) in version_rows:
|
||||
print_output = (
|
||||
build_summary.get("print_output")
|
||||
if isinstance(build_summary, dict)
|
||||
else None
|
||||
)
|
||||
_add_key(
|
||||
keys,
|
||||
_print_output_storage_key(print_output),
|
||||
prefix=prefix,
|
||||
allowed_keys=artifact_keys,
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def _artifact_identity(
|
||||
key: str,
|
||||
*,
|
||||
prefix: str,
|
||||
) -> tuple[str, str, str] | None:
|
||||
if not key.startswith(prefix):
|
||||
return None
|
||||
parts = key[len(prefix) :].split("/")
|
||||
if len(parts) < 4 or any(not part for part in parts[:4]):
|
||||
return None
|
||||
return parts[0], parts[1], parts[2]
|
||||
|
||||
|
||||
def _active_build_ids(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
now: datetime,
|
||||
) -> set[str]:
|
||||
operations = (
|
||||
session.query(RecoveryOperation)
|
||||
.join(
|
||||
CampaignVersion,
|
||||
CampaignVersion.id == RecoveryOperation.resource_id,
|
||||
)
|
||||
.join(Campaign, Campaign.id == CampaignVersion.campaign_id)
|
||||
.filter(
|
||||
Campaign.tenant_id == tenant_id,
|
||||
RecoveryOperation.module_id == "campaigns",
|
||||
RecoveryOperation.operation_type == "build-artifacts",
|
||||
RecoveryOperation.status.not_in(TERMINAL_RECOVERY_STATUSES),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
resource_keys = {
|
||||
operation.lease_resource_key
|
||||
for operation in operations
|
||||
if operation.lease_resource_key
|
||||
}
|
||||
if not resource_keys:
|
||||
return set()
|
||||
installation_ids = {operation.installation_id for operation in operations}
|
||||
leases = (
|
||||
session.query(DistributedLease)
|
||||
.filter(
|
||||
DistributedLease.installation_id.in_(installation_ids),
|
||||
DistributedLease.resource_key.in_(resource_keys),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
leases_by_key = {
|
||||
(lease.installation_id, lease.resource_key): lease for lease in leases
|
||||
}
|
||||
active: set[str] = set()
|
||||
for operation in operations:
|
||||
lease = leases_by_key.get(
|
||||
(operation.installation_id, operation.lease_resource_key or "")
|
||||
)
|
||||
if (
|
||||
lease is not None
|
||||
and lease.holder_node_id == operation.holder_node_id
|
||||
and lease.holder_incarnation == operation.holder_incarnation
|
||||
and lease.fencing_token == operation.fencing_token
|
||||
and _as_utc(lease.expires_at) > now
|
||||
):
|
||||
active.add(operation.id)
|
||||
return active
|
||||
|
||||
|
||||
def _cleanup_evidence(inventory: ArtifactInventory) -> dict[str, Any]:
|
||||
dispositions: dict[str, int] = {}
|
||||
for candidate in inventory.candidates:
|
||||
dispositions[candidate.disposition] = (
|
||||
dispositions.get(candidate.disposition, 0) + 1
|
||||
)
|
||||
verified = not any(
|
||||
key in dispositions for key in ("delete_failed", "delete_outcome_unknown")
|
||||
)
|
||||
return {
|
||||
"verified": verified,
|
||||
"checks": {
|
||||
"candidate_objects": (
|
||||
"absent-or-newly-protected" if verified else "incomplete"
|
||||
),
|
||||
"database_references": "unchanged",
|
||||
"lease_fence": "renewed-before-each-batch",
|
||||
},
|
||||
"inventory_manifest_sha256": inventory.manifest_sha256,
|
||||
"candidate_manifest_sha256": _canonical_sha256(
|
||||
[_sha256(candidate.key) for candidate in inventory.candidates]
|
||||
),
|
||||
"candidate_count": len(inventory.candidates),
|
||||
"candidate_bytes": inventory.candidate_bytes,
|
||||
"dispositions": dispositions,
|
||||
"database_references_mutated": False,
|
||||
}
|
||||
|
||||
|
||||
def _replayed_response(
|
||||
*,
|
||||
prefix: str,
|
||||
cursor: str | None,
|
||||
operation_id: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"apply": True,
|
||||
"status": "already_completed",
|
||||
"recovery_operation_id": operation_id,
|
||||
"tenant_prefix": prefix,
|
||||
"cursor": cursor,
|
||||
"next_cursor": None,
|
||||
"scanned_count": 0,
|
||||
"scanned_bytes": 0,
|
||||
"referenced_count": 0,
|
||||
"active_build_count": 0,
|
||||
"young_count": 0,
|
||||
"unknown_age_count": 0,
|
||||
"invalid_shape_count": 0,
|
||||
"candidate_count": 0,
|
||||
"candidate_bytes": 0,
|
||||
"deleted_count": 0,
|
||||
"deleted_bytes": 0,
|
||||
"failure_count": 0,
|
||||
"manifest_sha256": None,
|
||||
"candidates": [],
|
||||
}
|
||||
|
||||
|
||||
def _tenant_artifact_prefix(tenant_id: str) -> str:
|
||||
normalized = str(tenant_id or "").strip()
|
||||
if not normalized or "/" in normalized or normalized in {".", ".."}:
|
||||
raise ValueError("Campaign artifact inventory requires a valid tenant id")
|
||||
return f"{CAMPAIGN_ARTIFACT_NAMESPACE}/{normalized}/"
|
||||
|
||||
|
||||
def _build_id_for_key(key: str, *, prefix: str) -> str | None:
|
||||
identity = _artifact_identity(key, prefix=prefix)
|
||||
return identity[2] if identity is not None else None
|
||||
|
||||
|
||||
def _print_output_storage_key(value: object) -> str | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
artifact = value.get("artifact")
|
||||
if not isinstance(artifact, dict):
|
||||
return None
|
||||
key = artifact.get("storage_key")
|
||||
return str(key) if key else None
|
||||
|
||||
|
||||
def _add_key(
|
||||
keys: set[str],
|
||||
value: object,
|
||||
*,
|
||||
prefix: str,
|
||||
allowed_keys: set[str] | None = None,
|
||||
) -> None:
|
||||
if value is None:
|
||||
return
|
||||
key = str(value)
|
||||
if key.startswith(prefix) and (allowed_keys is None or key in allowed_keys):
|
||||
keys.add(key)
|
||||
|
||||
|
||||
def _object_modified_at(info: StorageObjectInfo) -> datetime | None:
|
||||
return _as_utc(info.modified_at) if info.modified_at is not None else None
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _sha256(value: object) -> str:
|
||||
return hashlib.sha256(str(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAMPAIGN_ARTIFACT_NAMESPACE",
|
||||
"CampaignArtifactReconciliationError",
|
||||
"campaign_artifact_inventory",
|
||||
"reconcile_campaign_artifacts",
|
||||
]
|
||||
@@ -53,6 +53,17 @@ class AttachmentIssue(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
behavior: Behavior | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AttachmentPolicyDecision(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
requirement_policy: Behavior
|
||||
campaign_policy: Behavior
|
||||
rule_policy: Behavior | None = None
|
||||
effective_behavior: Behavior
|
||||
legacy_drop_normalized: bool = False
|
||||
|
||||
|
||||
class ResolvedAttachment(BaseModel):
|
||||
@@ -82,6 +93,7 @@ class ResolvedAttachment(BaseModel):
|
||||
zip_entry_names: list[str] = Field(default_factory=list)
|
||||
status: AttachmentMatchStatus
|
||||
behavior: Behavior | None = None
|
||||
missing_policy: AttachmentPolicyDecision | None = None
|
||||
matches: list[str] = Field(default_factory=list)
|
||||
issues: list[AttachmentIssue] = Field(default_factory=list)
|
||||
|
||||
@@ -193,12 +205,53 @@ def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -
|
||||
return config.allow_multiple or any(char in rendered_file_filter for char in "*?[")
|
||||
|
||||
|
||||
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||
_MISSING_BEHAVIOR_STRENGTH = {
|
||||
Behavior.CONTINUE: 0,
|
||||
Behavior.WARN: 1,
|
||||
Behavior.ASK: 2,
|
||||
Behavior.DROP: 2,
|
||||
Behavior.BLOCK: 3,
|
||||
}
|
||||
|
||||
|
||||
def _missing_policy_decision(
|
||||
campaign_config: CampaignConfig,
|
||||
config: AttachmentConfig,
|
||||
) -> AttachmentPolicyDecision:
|
||||
requirement_policy = (
|
||||
campaign_config.validation_policy.missing_required_attachment
|
||||
if config.required
|
||||
else campaign_config.validation_policy.missing_optional_attachment
|
||||
)
|
||||
candidates = [
|
||||
requirement_policy,
|
||||
campaign_config.attachments.missing_behavior,
|
||||
]
|
||||
if config.missing_behavior is not None:
|
||||
return config.missing_behavior
|
||||
if config.required:
|
||||
return campaign_config.validation_policy.missing_required_attachment
|
||||
return campaign_config.validation_policy.missing_optional_attachment
|
||||
candidates.append(config.missing_behavior)
|
||||
configured = max(
|
||||
candidates,
|
||||
key=lambda behavior: _MISSING_BEHAVIOR_STRENGTH[behavior],
|
||||
)
|
||||
if Behavior.BLOCK in candidates:
|
||||
configured = Behavior.BLOCK
|
||||
elif not config.required and config.missing_behavior == Behavior.CONTINUE:
|
||||
# An explicitly optional, allowed-empty rule is an expected outcome,
|
||||
# not an exception to accept. Hard blocking policy still wins above.
|
||||
configured = Behavior.CONTINUE
|
||||
elif Behavior.DROP in candidates:
|
||||
configured = Behavior.DROP
|
||||
return AttachmentPolicyDecision(
|
||||
requirement_policy=requirement_policy,
|
||||
campaign_policy=campaign_config.attachments.missing_behavior,
|
||||
rule_policy=config.missing_behavior,
|
||||
effective_behavior=configured,
|
||||
legacy_drop_normalized=False,
|
||||
)
|
||||
|
||||
|
||||
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||
return _missing_policy_decision(campaign_config, config).effective_behavior
|
||||
|
||||
|
||||
def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||
@@ -356,14 +409,22 @@ def _confine_managed_matches(directory: Path, matches: list[Path]) -> tuple[list
|
||||
return confined, rejected
|
||||
|
||||
|
||||
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
|
||||
def _issue_for_missing(
|
||||
config: AttachmentConfig,
|
||||
policy: AttachmentPolicyDecision,
|
||||
) -> AttachmentIssue:
|
||||
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
||||
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
behavior = policy.effective_behavior
|
||||
severity = (
|
||||
ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else
|
||||
ResolutionSeverity.INFO if behavior in {Behavior.CONTINUE, Behavior.DROP} else ResolutionSeverity.WARNING
|
||||
)
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code=code,
|
||||
message=f"No file matched attachment filter {config.file_filter!r}",
|
||||
behavior=behavior,
|
||||
details={"effective_policy": policy.model_dump(mode="json")},
|
||||
)
|
||||
|
||||
|
||||
@@ -377,10 +438,13 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
||||
)
|
||||
|
||||
|
||||
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||
return config.attachments.send_without_attachments_behavior or (
|
||||
def effective_send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||
configured = config.attachments.send_without_attachments_behavior or (
|
||||
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||
)
|
||||
# Configured exclusion is already an explicit policy decision. It must not
|
||||
# be converted into an acceptance prompt that would send the excluded mail.
|
||||
return configured
|
||||
|
||||
|
||||
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
||||
@@ -391,10 +455,11 @@ def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssu
|
||||
Behavior.WARN: "No attachment file was resolved for this message. Campaign policy allows sending with a warning.",
|
||||
}
|
||||
return AttachmentIssue(
|
||||
severity=ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING,
|
||||
severity=(ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.INFO if behavior == Behavior.DROP else ResolutionSeverity.WARNING),
|
||||
code="missing_attachment_coverage",
|
||||
message=messages.get(behavior, "No attachment file was resolved for this message."),
|
||||
behavior=behavior,
|
||||
details={"effective_behavior": behavior.value},
|
||||
)
|
||||
|
||||
|
||||
@@ -422,24 +487,13 @@ def _resolve_one_config(
|
||||
behavior: Behavior | None = None
|
||||
managed_source = selected_base_path is not None and is_managed_source(selected_base_path.source)
|
||||
unsafe_managed_path = False
|
||||
if managed_source:
|
||||
try:
|
||||
resolution_failed = False
|
||||
try:
|
||||
if managed_source:
|
||||
assert_logical_relative_path(
|
||||
rendered_file_filter,
|
||||
field="rendered managed attachment file_filter",
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
matches = []
|
||||
unsafe_managed_path = True
|
||||
issues.append(
|
||||
AttachmentIssue(
|
||||
severity=ResolutionSeverity.ERROR,
|
||||
code="unsafe_managed_attachment_path",
|
||||
message=str(exc),
|
||||
behavior=Behavior.BLOCK,
|
||||
)
|
||||
)
|
||||
else:
|
||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
|
||||
matches, rejected = _confine_managed_matches(directory, matches)
|
||||
if rejected:
|
||||
@@ -453,16 +507,44 @@ def _resolve_one_config(
|
||||
behavior=Behavior.BLOCK,
|
||||
)
|
||||
)
|
||||
else:
|
||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
|
||||
else:
|
||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
|
||||
except CampaignPathSecurityError as exc:
|
||||
matches = []
|
||||
unsafe_managed_path = True
|
||||
issues.append(
|
||||
AttachmentIssue(
|
||||
severity=ResolutionSeverity.ERROR,
|
||||
code="unsafe_managed_attachment_path",
|
||||
message=str(exc),
|
||||
behavior=Behavior.BLOCK,
|
||||
)
|
||||
)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
matches = []
|
||||
resolution_failed = True
|
||||
issues.append(
|
||||
AttachmentIssue(
|
||||
severity=ResolutionSeverity.ERROR,
|
||||
code="attachment_resolution_failed",
|
||||
message=f"Attachment source could not be read while resolving filter {config.file_filter!r}.",
|
||||
behavior=Behavior.BLOCK,
|
||||
details={"error_type": type(exc).__name__},
|
||||
)
|
||||
)
|
||||
|
||||
missing_policy: AttachmentPolicyDecision | None = None
|
||||
if unsafe_managed_path:
|
||||
status = AttachmentMatchStatus.MISSING
|
||||
behavior = Behavior.BLOCK
|
||||
elif resolution_failed:
|
||||
status = AttachmentMatchStatus.MISSING
|
||||
behavior = Behavior.BLOCK
|
||||
elif not matches:
|
||||
status = AttachmentMatchStatus.MISSING
|
||||
behavior = _missing_behavior(campaign_config, config)
|
||||
issues.append(_issue_for_missing(config, behavior))
|
||||
missing_policy = _missing_policy_decision(campaign_config, config)
|
||||
behavior = missing_policy.effective_behavior
|
||||
issues.append(_issue_for_missing(config, missing_policy))
|
||||
elif len(matches) > 1 and not allow_multiple:
|
||||
status = AttachmentMatchStatus.AMBIGUOUS
|
||||
behavior = _ambiguous_behavior(campaign_config, config)
|
||||
@@ -494,6 +576,7 @@ def _resolve_one_config(
|
||||
zip_entry_name_template=config.zip_entry_name_template,
|
||||
status=status,
|
||||
behavior=behavior,
|
||||
missing_policy=missing_policy,
|
||||
matches=[str(path) for path in matches],
|
||||
issues=issues,
|
||||
)
|
||||
@@ -540,7 +623,7 @@ def resolve_entry_attachments(
|
||||
)
|
||||
|
||||
issues = [issue for item in resolved for issue in item.issues]
|
||||
missing_coverage_behavior = _send_without_attachments_behavior(config)
|
||||
missing_coverage_behavior = effective_send_without_attachments_behavior(config)
|
||||
if (
|
||||
entry.active
|
||||
and resolved
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
AttachmentReuseAction,
|
||||
AttachmentReuseAllowance,
|
||||
AttachmentReusePolicy,
|
||||
)
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft, MessageIssue
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _AttachmentUse:
|
||||
message: MessageDraft
|
||||
message_key: str
|
||||
recipient_key: tuple[str, ...]
|
||||
source_identity: str
|
||||
file_name: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AttachmentReuseEvaluation:
|
||||
report: dict[str, object]
|
||||
issues_by_entry_index: dict[int, list[MessageIssue]]
|
||||
|
||||
|
||||
def evaluate_attachment_reuse(
|
||||
messages: list[MessageDraft],
|
||||
*,
|
||||
policy: AttachmentReusePolicy,
|
||||
) -> AttachmentReuseEvaluation:
|
||||
"""Evaluate repeated resolved-file use without exposing source paths.
|
||||
|
||||
A use is one resolved file occurrence in one attachment rule. The same
|
||||
source file can therefore be detected both across built messages and when
|
||||
two rules add it to one message. Allowed findings remain in the build
|
||||
protocol; policy violations additionally become recipient-level issues.
|
||||
"""
|
||||
|
||||
uses_by_source: dict[str, list[_AttachmentUse]] = defaultdict(list)
|
||||
for message in messages:
|
||||
if not message.active:
|
||||
continue
|
||||
message_key = str(message.entry_id or message.entry_index)
|
||||
recipient_key = _recipient_key(message, fallback=message_key)
|
||||
for attachment in message.attachments:
|
||||
for match in attachment.matches:
|
||||
source_identity = _source_identity(match)
|
||||
uses_by_source[source_identity].append(
|
||||
_AttachmentUse(
|
||||
message=message,
|
||||
message_key=message_key,
|
||||
recipient_key=recipient_key,
|
||||
source_identity=source_identity,
|
||||
file_name=Path(match).name,
|
||||
)
|
||||
)
|
||||
|
||||
findings: list[dict[str, object]] = []
|
||||
issues_by_entry_index: dict[int, list[MessageIssue]] = defaultdict(list)
|
||||
affected_entry_indexes: set[int] = set()
|
||||
allowed_count = 0
|
||||
violation_count = 0
|
||||
|
||||
for source_identity, uses in sorted(uses_by_source.items()):
|
||||
if len(uses) < 2:
|
||||
continue
|
||||
fingerprint = hashlib.sha256(source_identity.encode("utf-8")).hexdigest()
|
||||
message_keys = {item.message_key for item in uses}
|
||||
recipient_keys = {item.recipient_key for item in uses}
|
||||
allowed, explanation = _is_allowed(
|
||||
policy,
|
||||
message_count=len(message_keys),
|
||||
recipient_count=len(recipient_keys),
|
||||
)
|
||||
disposition = "allowed" if allowed else policy.action.value
|
||||
finding = {
|
||||
"file_fingerprint": fingerprint,
|
||||
"file_name": uses[0].file_name,
|
||||
"use_count": len(uses),
|
||||
"message_count": len(message_keys),
|
||||
"recipient_count": len(recipient_keys),
|
||||
"disposition": disposition,
|
||||
"explanation": explanation,
|
||||
}
|
||||
findings.append(finding)
|
||||
if allowed:
|
||||
allowed_count += 1
|
||||
continue
|
||||
|
||||
violation_count += 1
|
||||
behavior = _issue_behavior(policy.action)
|
||||
severity = (
|
||||
"error" if policy.action == AttachmentReuseAction.BLOCK else "warning"
|
||||
)
|
||||
for use in _unique_message_uses(uses):
|
||||
affected_entry_indexes.add(use.message.entry_index)
|
||||
issues_by_entry_index[use.message.entry_index].append(
|
||||
MessageIssue(
|
||||
severity=severity,
|
||||
code="duplicate_attachment_reuse",
|
||||
message=(
|
||||
f"Attachment {use.file_name!r} is reused {len(uses)} times "
|
||||
f"across {len(message_keys)} built message(s); the configured "
|
||||
f"policy requires {disposition}."
|
||||
),
|
||||
behavior=behavior,
|
||||
source="attachments:reuse_policy",
|
||||
details={
|
||||
**finding,
|
||||
"policy": policy.model_dump(mode="json"),
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return AttachmentReuseEvaluation(
|
||||
report={
|
||||
"contract_version": "1",
|
||||
"policy": policy.model_dump(mode="json"),
|
||||
"duplicate_file_count": len(findings),
|
||||
"allowed_file_count": allowed_count,
|
||||
"violation_file_count": violation_count,
|
||||
"affected_message_count": len(affected_entry_indexes),
|
||||
"findings": findings,
|
||||
},
|
||||
issues_by_entry_index=dict(issues_by_entry_index),
|
||||
)
|
||||
|
||||
|
||||
def _source_identity(value: str) -> str:
|
||||
return str(Path(value).resolve(strict=False))
|
||||
|
||||
|
||||
def _recipient_key(message: MessageDraft, *, fallback: str) -> tuple[str, ...]:
|
||||
addresses = message.to or message.bcc or message.cc
|
||||
normalized = sorted(
|
||||
{
|
||||
item.email.strip().casefold()
|
||||
for item in addresses
|
||||
if item.email and item.email.strip()
|
||||
}
|
||||
)
|
||||
return tuple(normalized) if normalized else (f"entry:{fallback}",)
|
||||
|
||||
|
||||
def _is_allowed(
|
||||
policy: AttachmentReusePolicy,
|
||||
*,
|
||||
message_count: int,
|
||||
recipient_count: int,
|
||||
) -> tuple[bool, str]:
|
||||
if policy.action == AttachmentReuseAction.ALLOW:
|
||||
return True, "The campaign policy explicitly allows attachment reuse."
|
||||
if (
|
||||
policy.allow_within == AttachmentReuseAllowance.SAME_MESSAGE
|
||||
and message_count == 1
|
||||
):
|
||||
return True, "Reuse is confined to one built message as allowed by policy."
|
||||
if (
|
||||
policy.allow_within == AttachmentReuseAllowance.SAME_RECIPIENT
|
||||
and recipient_count == 1
|
||||
):
|
||||
return True, "Reuse is confined to one recipient as allowed by policy."
|
||||
return False, (
|
||||
"Reuse crosses the configured allowance and is handled by the "
|
||||
f"{policy.action.value} policy."
|
||||
)
|
||||
|
||||
|
||||
def _issue_behavior(action: AttachmentReuseAction) -> str:
|
||||
if action == AttachmentReuseAction.REVIEW:
|
||||
return "ask"
|
||||
return action.value
|
||||
|
||||
|
||||
def _unique_message_uses(uses: list[_AttachmentUse]) -> list[_AttachmentUse]:
|
||||
unique: dict[int, _AttachmentUse] = {}
|
||||
for use in uses:
|
||||
unique.setdefault(use.message.entry_index, use)
|
||||
return list(unique.values())
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from collections.abc import Mapping
|
||||
|
||||
|
||||
DEFAULT_COPY_OPTIONS: dict[str, bool] = {
|
||||
"include_recipients": True,
|
||||
"include_files": True,
|
||||
"include_shares": False,
|
||||
"include_policies": True,
|
||||
"include_mail_profile": True,
|
||||
}
|
||||
|
||||
|
||||
def campaign_copy_configuration(
|
||||
source: Mapping[str, object],
|
||||
options: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Return an editable configuration copy without operational evidence."""
|
||||
|
||||
selected = {**DEFAULT_COPY_OPTIONS, **dict(options)}
|
||||
raw_json = copy.deepcopy(dict(source))
|
||||
if not selected["include_recipients"]:
|
||||
raw_json["recipients"] = {}
|
||||
raw_json["entries"] = {"inline": [], "imports": []}
|
||||
if not selected["include_files"]:
|
||||
raw_json["attachments"] = {}
|
||||
entries = raw_json.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
inline = entries.get("inline")
|
||||
if isinstance(inline, list):
|
||||
for entry in inline:
|
||||
if isinstance(entry, dict):
|
||||
entry["attachments"] = []
|
||||
entry["combine_attachments"] = True
|
||||
if not selected["include_policies"]:
|
||||
raw_json["validation_policy"] = {}
|
||||
if not selected["include_mail_profile"]:
|
||||
raw_json["server"] = {}
|
||||
return raw_json
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_COPY_OPTIONS", "campaign_copy_configuration"]
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
|
||||
|
||||
POLICY_ID = "campaign.lifecycle"
|
||||
POLICY_VERSION = "2"
|
||||
|
||||
_ACTIVE_QUEUE_STATES = {"queued", "sending"}
|
||||
_ACTIVE_SEND_STATES = {"queued", "claimed", "sending", "outcome_unknown"}
|
||||
_ACTIVE_POSTBOX_STATES = {"pending", "delivering", "outcome_unknown"}
|
||||
_ACTIVE_PRINT_STATES = {"ready", "accepting"}
|
||||
_ACTIVE_IMAP_STATES = {"pending", "appending", "outcome_unknown"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleDecision:
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"allowed": self.allowed, "reason": self.reason}
|
||||
|
||||
|
||||
def _timestamp(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
def _canonical_hash(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _protected_version(version: CampaignVersion) -> bool:
|
||||
return any(
|
||||
value is not None
|
||||
for value in (
|
||||
version.locked_at,
|
||||
version.user_lock_state,
|
||||
version.published_at,
|
||||
version.execution_snapshot_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _active_delivery(job: CampaignJob) -> bool:
|
||||
return any(
|
||||
(
|
||||
job.queue_status in _ACTIVE_QUEUE_STATES,
|
||||
job.send_status in _ACTIVE_SEND_STATES,
|
||||
job.postbox_status in _ACTIVE_POSTBOX_STATES,
|
||||
job.print_status in _ACTIVE_PRINT_STATES,
|
||||
job.imap_status in _ACTIVE_IMAP_STATES,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def campaign_lifecycle_policy(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
principal: ApiPrincipal,
|
||||
version_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
versions = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(CampaignVersion.campaign_id == campaign.id)
|
||||
.order_by(CampaignVersion.version_number.asc())
|
||||
.all()
|
||||
)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_id == campaign.id)
|
||||
.order_by(CampaignJob.id.asc())
|
||||
.all()
|
||||
)
|
||||
shares = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
)
|
||||
schedules = (
|
||||
session.query(CampaignSchedule)
|
||||
.filter(CampaignSchedule.campaign_id == campaign.id)
|
||||
.order_by(CampaignSchedule.id.asc())
|
||||
.all()
|
||||
)
|
||||
selected_version = next(
|
||||
(version for version in versions if version.id == version_id),
|
||||
None,
|
||||
)
|
||||
|
||||
snapshot = {
|
||||
"policy_id": POLICY_ID,
|
||||
"policy_version": POLICY_VERSION,
|
||||
"campaign": {
|
||||
"id": campaign.id,
|
||||
"status": campaign.status,
|
||||
"current_version_id": campaign.current_version_id,
|
||||
"settings_sha256": _canonical_hash(campaign.settings or {}),
|
||||
"mail_profile_policy_sha256": _canonical_hash(
|
||||
campaign.mail_profile_policy or {}
|
||||
),
|
||||
"updated_at": _timestamp(campaign.updated_at),
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"id": version.id,
|
||||
"version_number": version.version_number,
|
||||
"edit_revision": version.edit_revision,
|
||||
"workflow_state": version.workflow_state,
|
||||
"locked_at": _timestamp(version.locked_at),
|
||||
"user_lock_state": version.user_lock_state,
|
||||
"published_at": _timestamp(version.published_at),
|
||||
"execution_snapshot_at": _timestamp(version.execution_snapshot_at),
|
||||
"archived_at": _timestamp(version.archived_at),
|
||||
"configuration_sha256": _canonical_hash(version.raw_json or {}),
|
||||
"updated_at": _timestamp(version.updated_at),
|
||||
}
|
||||
for version in versions
|
||||
],
|
||||
"jobs": [
|
||||
{
|
||||
"id": job.id,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"postbox_status": job.postbox_status,
|
||||
"print_status": job.print_status,
|
||||
"imap_status": job.imap_status,
|
||||
"updated_at": _timestamp(job.updated_at),
|
||||
}
|
||||
for job in jobs
|
||||
],
|
||||
"active_shares": [
|
||||
{
|
||||
"id": share.id,
|
||||
"target_type": share.target_type,
|
||||
"target_id": share.target_id,
|
||||
"permission": share.permission,
|
||||
"updated_at": _timestamp(share.updated_at),
|
||||
}
|
||||
for share in shares
|
||||
],
|
||||
"schedules": [
|
||||
{
|
||||
"id": schedule.id,
|
||||
"active": schedule.active,
|
||||
"resource_revision": schedule.resource_revision,
|
||||
"next_fire_at": _timestamp(schedule.next_fire_at),
|
||||
"occurrence_count": schedule.occurrence_count,
|
||||
"updated_at": _timestamp(schedule.updated_at),
|
||||
}
|
||||
for schedule in schedules
|
||||
],
|
||||
"selected_version_id": version_id,
|
||||
}
|
||||
token = _canonical_hash(snapshot)
|
||||
|
||||
active_delivery = any(_active_delivery(job) for job in jobs)
|
||||
protected_versions = any(_protected_version(version) for version in versions)
|
||||
active_schedules = any(schedule.active for schedule in schedules)
|
||||
|
||||
archive = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:archive"):
|
||||
archive = LifecycleDecision(False, "Missing campaign archive permission.")
|
||||
elif campaign.status in {"archived", "deleted"}:
|
||||
archive = LifecycleDecision(False, "The campaign is already archived or deleted.")
|
||||
elif active_delivery:
|
||||
archive = LifecycleDecision(
|
||||
False,
|
||||
"Active or uncertain delivery must be resolved before archiving.",
|
||||
)
|
||||
elif active_schedules:
|
||||
archive = LifecycleDecision(
|
||||
False,
|
||||
"Pause active Campaign schedules before archiving.",
|
||||
)
|
||||
|
||||
delete = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:delete"):
|
||||
delete = LifecycleDecision(False, "Missing campaign delete permission.")
|
||||
elif campaign.status != "draft":
|
||||
delete = LifecycleDecision(False, "Only untouched draft campaigns can be deleted.")
|
||||
elif jobs:
|
||||
delete = LifecycleDecision(
|
||||
False,
|
||||
"Campaigns with built or delivery jobs must be archived instead of deleted.",
|
||||
)
|
||||
elif protected_versions:
|
||||
delete = LifecycleDecision(
|
||||
False,
|
||||
"Audit-relevant campaign versions must be archived instead of deleted.",
|
||||
)
|
||||
elif shares:
|
||||
delete = LifecycleDecision(
|
||||
False,
|
||||
"Revoke active campaign shares before deleting the untouched draft.",
|
||||
)
|
||||
elif schedules:
|
||||
delete = LifecycleDecision(
|
||||
False,
|
||||
"Campaigns with schedule evidence must be archived instead of deleted.",
|
||||
)
|
||||
|
||||
copy = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:copy"):
|
||||
copy = LifecycleDecision(False, "Missing campaign copy permission.")
|
||||
elif version_id is not None and selected_version is None:
|
||||
copy = LifecycleDecision(False, "The selected source version does not exist.")
|
||||
|
||||
archive_version = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:archive"):
|
||||
archive_version = LifecycleDecision(False, "Missing campaign archive permission.")
|
||||
elif version_id is None or selected_version is None:
|
||||
archive_version = LifecycleDecision(False, "Select a historical campaign version.")
|
||||
elif selected_version.id == campaign.current_version_id:
|
||||
archive_version = LifecycleDecision(False, "The current campaign version cannot be archived.")
|
||||
elif selected_version.archived_at is not None:
|
||||
archive_version = LifecycleDecision(False, "The historical version is already archived.")
|
||||
|
||||
return {
|
||||
"policy_id": POLICY_ID,
|
||||
"policy_version": POLICY_VERSION,
|
||||
"state_token": token,
|
||||
"actions": {
|
||||
"archive_campaign": archive.as_dict(),
|
||||
"delete_campaign": delete.as_dict(),
|
||||
"copy_campaign": copy.as_dict(),
|
||||
"archive_version": archive_version.as_dict(),
|
||||
},
|
||||
"provenance": {
|
||||
"source": "built_in",
|
||||
"rules": (
|
||||
"permission",
|
||||
"campaign_state",
|
||||
"retained_evidence",
|
||||
"active_delivery",
|
||||
"scheduled_automation",
|
||||
"optimistic_concurrency",
|
||||
),
|
||||
"evidence_retention": "Versions, schedule occurrences, delivery outcomes, reports, and audit records are never deleted by archival.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def assert_lifecycle_state_token(actual: str, expected: str) -> None:
|
||||
if not hmac.compare_digest(actual, expected):
|
||||
raise ValueError(
|
||||
"Campaign state changed after this action was prepared. Reload and review the lifecycle decision again."
|
||||
)
|
||||
@@ -1,11 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
CAMPAIGN_MAIL_SERVER_KEYS = frozenset({"mail_profile_id"})
|
||||
CAMPAIGN_CLIENT_EDITOR_STATE_KEYS = frozenset({"created_from", "field_overrides", "opt_ins"})
|
||||
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"}
|
||||
)
|
||||
@@ -14,21 +25,38 @@ CAMPAIGN_REVIEW_STATE_KEYS = frozenset(
|
||||
"build_token",
|
||||
"inspection_complete",
|
||||
"reviewed_message_keys",
|
||||
"issue_decisions",
|
||||
"updated_at",
|
||||
"updated_by_user_id",
|
||||
}
|
||||
)
|
||||
CAMPAIGN_APPROVAL_GATE_KEYS = frozenset(
|
||||
{
|
||||
"request_id",
|
||||
"request_revision",
|
||||
"subject_version",
|
||||
"subject_digest",
|
||||
"requested_at",
|
||||
"requested_by_user_id",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
def campaign_review_reference(version_id: str, build_token: Any) -> str | None:
|
||||
"""A public concurrency reference, not a raw diagnostic/build claim token."""
|
||||
token = str(build_token or "").strip()
|
||||
return hashlib.sha256(f"campaign-review:{version_id}:{token}".encode()).hexdigest() if token else None
|
||||
|
||||
|
||||
def _validated_opt_ins(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict) or any(
|
||||
key not in CAMPAIGN_OPT_IN_KEYS for key in value
|
||||
@@ -85,10 +113,13 @@ def validate_campaign_editor_state(
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise CampaignMailProfileBoundaryError("Campaign editor state must be an object")
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign editor state must be an object"
|
||||
)
|
||||
allowed = set(CAMPAIGN_CLIENT_EDITOR_STATE_KEYS)
|
||||
if allow_server_review_state:
|
||||
allowed.add("review_send")
|
||||
allowed.add("approval_gate")
|
||||
if any(key not in allowed for key in value):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign editor state contains unsupported or transport-owned fields"
|
||||
@@ -104,11 +135,13 @@ def validate_campaign_editor_state(
|
||||
if "opt_ins" in value:
|
||||
result["opt_ins"] = _validated_opt_ins(value["opt_ins"])
|
||||
if "field_overrides" in value:
|
||||
result["field_overrides"] = _validated_field_overrides(
|
||||
value["field_overrides"]
|
||||
)
|
||||
result["field_overrides"] = _validated_field_overrides(value["field_overrides"])
|
||||
if "review_send" in value:
|
||||
result["review_send"] = _validated_server_review_state(value["review_send"])
|
||||
if "approval_gate" in value:
|
||||
result["approval_gate"] = _validated_server_approval_gate(
|
||||
value["approval_gate"]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@@ -134,9 +167,32 @@ def public_campaign_editor_state(
|
||||
review_state = _validated_server_review_state(value["review_send"])
|
||||
if not include_diagnostics:
|
||||
review_state.pop("build_token", None)
|
||||
review_state["issue_decisions"] = [
|
||||
{
|
||||
key: item[key]
|
||||
for key in (
|
||||
"job_id",
|
||||
"review_key",
|
||||
"decision",
|
||||
"reason",
|
||||
"actor_user_id",
|
||||
"decided_at",
|
||||
"issue_codes",
|
||||
)
|
||||
if key in item
|
||||
}
|
||||
for item in review_state.get("issue_decisions", [])
|
||||
]
|
||||
result["review_send"] = review_state
|
||||
except CampaignMailProfileBoundaryError:
|
||||
pass
|
||||
if "approval_gate" in value:
|
||||
try:
|
||||
result["approval_gate"] = _validated_server_approval_gate(
|
||||
value["approval_gate"]
|
||||
)
|
||||
except CampaignMailProfileBoundaryError:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@@ -145,9 +201,66 @@ def campaign_editor_state_for_edit(value: Any) -> dict[str, Any]:
|
||||
|
||||
state = public_campaign_editor_state(value)
|
||||
state.pop("review_send", None)
|
||||
state.pop("approval_gate", None)
|
||||
return state
|
||||
|
||||
|
||||
def campaign_editor_state_with_client_update(
|
||||
stored: Any, client_state: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Replace client metadata without accepting or erasing server evidence.
|
||||
|
||||
Read responses contain review and approval state, but ordinary editor saves
|
||||
may only supply client-owned fields. Their omission must not delete trusted
|
||||
server evidence; content/build invalidation remains owned by its lifecycle.
|
||||
"""
|
||||
|
||||
result = validate_campaign_editor_state(client_state)
|
||||
server_state = public_campaign_editor_state(stored, include_diagnostics=True)
|
||||
for key in ("review_send", "approval_gate"):
|
||||
if key in server_state:
|
||||
result[key] = server_state[key]
|
||||
return result
|
||||
|
||||
|
||||
def _validated_server_approval_gate(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(
|
||||
key not in CAMPAIGN_APPROVAL_GATE_KEYS for key in value
|
||||
):
|
||||
raise CampaignMailProfileBoundaryError("Campaign approval gate is invalid")
|
||||
required_strings = (
|
||||
"request_id",
|
||||
"subject_version",
|
||||
"subject_digest",
|
||||
"requested_at",
|
||||
)
|
||||
if any(
|
||||
not isinstance(value.get(key), str) or not str(value.get(key)).strip()
|
||||
for key in required_strings
|
||||
):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign approval gate references are invalid"
|
||||
)
|
||||
digest = str(value["subject_digest"])
|
||||
if len(digest) != 64 or any(
|
||||
character not in "0123456789abcdef" for character in digest
|
||||
):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign approval gate digest is invalid"
|
||||
)
|
||||
revision = value.get("request_revision")
|
||||
if not isinstance(revision, int) or revision < 1:
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign approval gate revision is invalid"
|
||||
)
|
||||
requested_by = value.get("requested_by_user_id")
|
||||
if requested_by is not None and not isinstance(requested_by, str):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign approval gate actor is invalid"
|
||||
)
|
||||
return copy.deepcopy(value)
|
||||
|
||||
|
||||
def _is_valid_reviewed_message_key(value: Any) -> bool:
|
||||
return isinstance(value, str) and bool(value.strip()) and len(value) <= 512
|
||||
|
||||
@@ -170,6 +283,98 @@ def _validated_review_actor(value: Any) -> str | None:
|
||||
return value
|
||||
|
||||
|
||||
def _validated_issue_decisions(value: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(value, list) or len(value) > 100_000:
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign review issue decisions are invalid"
|
||||
)
|
||||
result: list[dict[str, Any]] = []
|
||||
allowed_keys = {
|
||||
"job_id",
|
||||
"review_key",
|
||||
"decision",
|
||||
"reason",
|
||||
"actor_user_id",
|
||||
"decided_at",
|
||||
"build_token",
|
||||
"message_sha256",
|
||||
"issue_fingerprint",
|
||||
"issue_codes",
|
||||
}
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or any(key not in allowed_keys for key in item):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign review issue decision is invalid"
|
||||
)
|
||||
normalized = {
|
||||
"job_id": _validated_required_string(
|
||||
item.get("job_id"),
|
||||
max_length=36,
|
||||
error="Campaign review decision job is invalid",
|
||||
),
|
||||
"review_key": _validated_required_string(
|
||||
item.get("review_key"),
|
||||
max_length=512,
|
||||
error="Campaign review decision key is invalid",
|
||||
),
|
||||
"decision": _validated_required_string(
|
||||
item.get("decision"),
|
||||
max_length=30,
|
||||
error="Campaign review decision is invalid",
|
||||
),
|
||||
"reason": item.get("reason"),
|
||||
"actor_user_id": _validated_review_actor(item.get("actor_user_id")),
|
||||
"decided_at": _validated_required_string(
|
||||
item.get("decided_at"),
|
||||
max_length=128,
|
||||
error="Campaign review decision timestamp is invalid",
|
||||
),
|
||||
"build_token": _validated_required_string(
|
||||
item.get("build_token"),
|
||||
max_length=256,
|
||||
error="Campaign review decision build token is invalid",
|
||||
),
|
||||
"message_sha256": item.get("message_sha256"),
|
||||
"issue_fingerprint": _validated_required_string(
|
||||
item.get("issue_fingerprint"),
|
||||
max_length=64,
|
||||
error="Campaign review issue fingerprint is invalid",
|
||||
),
|
||||
"issue_codes": item.get("issue_codes"),
|
||||
}
|
||||
if normalized["decision"] != "accept":
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign review decision outcome is invalid"
|
||||
)
|
||||
if normalized["reason"] is not None and (
|
||||
not isinstance(normalized["reason"], str)
|
||||
or len(normalized["reason"]) > 4_000
|
||||
):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign review decision reason is invalid"
|
||||
)
|
||||
if normalized["message_sha256"] is not None and (
|
||||
not isinstance(normalized["message_sha256"], str)
|
||||
or len(normalized["message_sha256"]) > 64
|
||||
):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign review decision message hash is invalid"
|
||||
)
|
||||
if (
|
||||
not isinstance(normalized["issue_codes"], list)
|
||||
or len(normalized["issue_codes"]) > 100
|
||||
or any(
|
||||
not isinstance(code, str) or not code or len(code) > 100
|
||||
for code in normalized["issue_codes"]
|
||||
)
|
||||
):
|
||||
raise CampaignMailProfileBoundaryError(
|
||||
"Campaign review decision issue codes are invalid"
|
||||
)
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
|
||||
def _validated_server_review_state(value: Any) -> dict[str, Any]:
|
||||
if not isinstance(value, dict) or any(
|
||||
key not in CAMPAIGN_REVIEW_STATE_KEYS for key in value
|
||||
@@ -180,6 +385,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
|
||||
build_token = value.get("build_token")
|
||||
inspected = value.get("inspection_complete")
|
||||
keys = value.get("reviewed_message_keys", [])
|
||||
issue_decisions = value.get("issue_decisions", [])
|
||||
updated_at = value.get("updated_at")
|
||||
updated_by = value.get("updated_by_user_id")
|
||||
validated_build_token = _validated_required_string(
|
||||
@@ -192,6 +398,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
|
||||
"Campaign review completion state is invalid"
|
||||
)
|
||||
validated_keys = _validated_reviewed_message_keys(keys)
|
||||
validated_decisions = _validated_issue_decisions(issue_decisions)
|
||||
validated_updated_at = _validated_required_string(
|
||||
updated_at,
|
||||
max_length=128,
|
||||
@@ -202,6 +409,7 @@ def _validated_server_review_state(value: Any) -> dict[str, Any]:
|
||||
"build_token": validated_build_token,
|
||||
"inspection_complete": inspected,
|
||||
"reviewed_message_keys": validated_keys,
|
||||
"issue_decisions": validated_decisions,
|
||||
"updated_at": validated_updated_at,
|
||||
"updated_by_user_id": validated_updated_by,
|
||||
}
|
||||
@@ -218,16 +426,57 @@ def campaign_mail_profile_id(raw_json: dict[str, Any] | None) -> str | None:
|
||||
return normalized or None
|
||||
|
||||
|
||||
def campaign_mail_profile_boundary_violations(raw_json: dict[str, Any] | None) -> tuple[str, ...]:
|
||||
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")
|
||||
violations = [
|
||||
f"/server/{key}"
|
||||
for key in sorted(server)
|
||||
if key not in CAMPAIGN_MAIL_SERVER_KEYS
|
||||
]
|
||||
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 +489,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 +503,28 @@ 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
|
||||
}
|
||||
|
||||
|
||||
def campaign_mail_references_unchanged(
|
||||
current: dict[str, Any] | None, candidate: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""Recognize an unchanged public selection, never client-owned transport."""
|
||||
|
||||
return (
|
||||
isinstance(candidate, dict)
|
||||
and not campaign_mail_profile_boundary_violations(candidate)
|
||||
and candidate.get("server", {}) == public_campaign_mail_server(current)
|
||||
)
|
||||
|
||||
|
||||
def campaign_preserves_legacy_mail_settings(
|
||||
current: dict[str, Any] | None, candidate: dict[str, Any] | None
|
||||
) -> bool:
|
||||
"""Keep stored legacy transport inert without accepting it from a client."""
|
||||
|
||||
return bool(campaign_mail_profile_boundary_violations(current)) and campaign_mail_references_unchanged(current, candidate)
|
||||
|
||||
@@ -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):
|
||||
@@ -74,6 +76,14 @@ class ZipPasswordScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
class ZipPasswordDeliveryChannel(StrEnum):
|
||||
SEPARATE_MAIL = "separate_mail"
|
||||
SMS = "sms"
|
||||
LETTER = "letter"
|
||||
PHONE = "phone"
|
||||
IN_PERSON = "in_person"
|
||||
|
||||
|
||||
class ZipPasswordMode(StrEnum):
|
||||
NONE = "none"
|
||||
DIRECT = "direct"
|
||||
@@ -92,6 +102,131 @@ class SendStatus(StrEnum):
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class DeliveryChannelPolicy(StrEnum):
|
||||
MAIL = "mail"
|
||||
POSTBOX = "postbox"
|
||||
PRINT = "print"
|
||||
MAIL_AND_POSTBOX = "mail_and_postbox"
|
||||
MAIL_THEN_POSTBOX = "mail_then_postbox"
|
||||
POSTBOX_THEN_MAIL = "postbox_then_mail"
|
||||
MAIL_THEN_PRINT = "mail_then_print"
|
||||
POSTBOX_THEN_PRINT = "postbox_then_print"
|
||||
|
||||
@property
|
||||
def uses_mail(self) -> bool:
|
||||
return self in {
|
||||
DeliveryChannelPolicy.MAIL,
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
DeliveryChannelPolicy.MAIL_THEN_PRINT,
|
||||
}
|
||||
|
||||
@property
|
||||
def uses_postbox(self) -> bool:
|
||||
return self in {
|
||||
DeliveryChannelPolicy.POSTBOX,
|
||||
DeliveryChannelPolicy.MAIL_AND_POSTBOX,
|
||||
DeliveryChannelPolicy.MAIL_THEN_POSTBOX,
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_MAIL,
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_PRINT,
|
||||
}
|
||||
|
||||
@property
|
||||
def uses_print(self) -> bool:
|
||||
return self in {
|
||||
DeliveryChannelPolicy.PRINT,
|
||||
DeliveryChannelPolicy.MAIL_THEN_PRINT,
|
||||
DeliveryChannelPolicy.POSTBOX_THEN_PRINT,
|
||||
}
|
||||
|
||||
|
||||
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 PrintTargetConfig(StrictModel):
|
||||
channel: Literal["postal", "internal_mail"]
|
||||
target: str = Field(min_length=1, max_length=4000)
|
||||
target_key: str = Field(min_length=1, max_length=500)
|
||||
contact_point_id: str | None = Field(default=None, max_length=36)
|
||||
locale: str | None = Field(default=None, max_length=35)
|
||||
decision_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignMeta(StrictModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -114,6 +249,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)
|
||||
|
||||
|
||||
@@ -218,6 +357,13 @@ class ZipArchiveConfig(StrictModel):
|
||||
password_field: str | None = None
|
||||
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
password_delivery_channel: ZipPasswordDeliveryChannel = (
|
||||
ZipPasswordDeliveryChannel.SEPARATE_MAIL
|
||||
)
|
||||
legacy_zipcrypto_acknowledged: bool = False
|
||||
legacy_zipcrypto_reason: str | None = Field(default=None, max_length=1000)
|
||||
legacy_zipcrypto_acknowledged_by: str | None = Field(default=None, max_length=255)
|
||||
legacy_zipcrypto_acknowledged_at: str | None = Field(default=None, max_length=80)
|
||||
|
||||
# Compatibility fields for campaigns created by the first single-archive
|
||||
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
||||
@@ -245,6 +391,20 @@ class ZipArchiveConfig(StrictModel):
|
||||
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_legacy_zipcrypto_acknowledgement(self) -> "ZipArchiveConfig":
|
||||
if self.method != ZipMethod.ZIP_STANDARD:
|
||||
return self
|
||||
if not self.legacy_zipcrypto_acknowledged:
|
||||
raise ValueError(
|
||||
"Legacy ZipCrypto requires explicit acknowledgement of its weak encryption"
|
||||
)
|
||||
if len((self.legacy_zipcrypto_reason or "").strip()) < 10:
|
||||
raise ValueError(
|
||||
"Legacy ZipCrypto requires an acknowledgement reason of at least 10 characters"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ZipCollectionConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
@@ -337,6 +497,48 @@ class AttachmentBasePathConfig(StrictModel):
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class ResidualFileMode(StrEnum):
|
||||
NONE = "none"
|
||||
REPORT = "report"
|
||||
ATTACH = "attach"
|
||||
|
||||
|
||||
class AttachmentReuseAction(StrEnum):
|
||||
ALLOW = "allow"
|
||||
WARN = "warn"
|
||||
REVIEW = "review"
|
||||
BLOCK = "block"
|
||||
|
||||
|
||||
class AttachmentReuseAllowance(StrEnum):
|
||||
NONE = "none"
|
||||
SAME_RECIPIENT = "same_recipient"
|
||||
SAME_MESSAGE = "same_message"
|
||||
|
||||
|
||||
class AttachmentReusePolicy(StrictModel):
|
||||
action: AttachmentReuseAction = AttachmentReuseAction.ALLOW
|
||||
allow_within: AttachmentReuseAllowance = AttachmentReuseAllowance.NONE
|
||||
|
||||
|
||||
class ResidualFileDispositionConfig(StrictModel):
|
||||
mode: ResidualFileMode = ResidualFileMode.NONE
|
||||
recipient: RecipientConfig | None = None
|
||||
subject: str = "Unassigned files in campaign {{local:campaign_name}}"
|
||||
text: str = (
|
||||
"The campaign build found {{local:residual_file_count}} file(s) that "
|
||||
"were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_recipient_for_routing(self) -> "ResidualFileDispositionConfig":
|
||||
if self.mode != ResidualFileMode.NONE and self.recipient is None:
|
||||
raise ValueError(
|
||||
"Residual-file report or attachment routing requires a recipient."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AttachmentConfig(StrictModel):
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
@@ -376,8 +578,14 @@ class AttachmentsConfig(StrictModel):
|
||||
send_without_attachments_behavior: Behavior | None = None
|
||||
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
missing_behavior: Behavior = Behavior.WARN
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
reuse_policy: AttachmentReusePolicy = Field(
|
||||
default_factory=AttachmentReusePolicy
|
||||
)
|
||||
residual_files: ResidualFileDispositionConfig = Field(
|
||||
default_factory=ResidualFileDispositionConfig
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def normalize_send_without_attachments_behavior(self) -> "AttachmentsConfig":
|
||||
@@ -446,10 +654,22 @@ 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
|
||||
print_target: PrintTargetConfig | None = None
|
||||
|
||||
attachments: list[AttachmentConfig] = Field(default_factory=list)
|
||||
combine_attachments: bool = True
|
||||
|
||||
fields: dict[str, Any] = Field(default_factory=dict)
|
||||
# Frozen channel candidates, source revisions and the explicit route
|
||||
# decision imported from Distribution Lists. Campaign owns this snapshot;
|
||||
# it never re-resolves the audience during build or delivery.
|
||||
distribution_source: dict[str, Any] = Field(default_factory=dict)
|
||||
last_sent: str | None = None
|
||||
|
||||
|
||||
@@ -465,7 +685,7 @@ class ImportProvenance(StrictModel):
|
||||
id: str
|
||||
imported_at: str
|
||||
mode: Literal["append", "replace"]
|
||||
source_type: Literal["csv", "xlsx", "text", "addresses"]
|
||||
source_type: Literal["csv", "xlsx", "text", "addresses", "distribution_list"]
|
||||
source_id: str | None = None
|
||||
source_label: str | None = None
|
||||
source_revision: str | None = None
|
||||
@@ -527,7 +747,7 @@ class EntriesConfig(StrictModel):
|
||||
|
||||
|
||||
class ValidationPolicy(StrictModel):
|
||||
missing_required_attachment: Behavior = Behavior.ASK
|
||||
missing_required_attachment: Behavior = Behavior.BLOCK
|
||||
missing_optional_attachment: Behavior = Behavior.WARN
|
||||
ambiguous_attachment_match: Behavior = Behavior.ASK
|
||||
ignore_empty_fields: bool = False
|
||||
@@ -559,7 +779,43 @@ 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 PrintDeliveryConfig(StrictModel):
|
||||
template_id: str | None = Field(default=None, max_length=36)
|
||||
template_revision: int | None = Field(default=None, ge=1)
|
||||
usage: str = Field(default="campaign_print", min_length=1, max_length=100)
|
||||
output_format: Literal["html", "text"] = "html"
|
||||
profile_id: str | None = Field(default=None, max_length=120)
|
||||
persist_to_files: bool = True
|
||||
|
||||
|
||||
class CalendarInvitationDeliveryConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
calendar_id: str | None = Field(default=None, max_length=36)
|
||||
summary_template: str | None = Field(default=None, max_length=2_000)
|
||||
description_template: str | None = Field(default=None, max_length=20_000)
|
||||
location_template: str | None = Field(default=None, max_length=2_000)
|
||||
start_at_template: str | None = Field(default=None, max_length=1_000)
|
||||
end_at_template: str | None = Field(default=None, max_length=1_000)
|
||||
timezone: str | None = Field(default=None, max_length=100)
|
||||
classification: Literal["PUBLIC", "PRIVATE", "CONFIDENTIAL"] = "PUBLIC"
|
||||
categories: list[str] = Field(default_factory=list, max_length=50)
|
||||
|
||||
|
||||
class DeliveryConfig(StrictModel):
|
||||
channel_policy: DeliveryChannelPolicy = DeliveryChannelPolicy.MAIL
|
||||
postbox: PostboxDeliveryConfig = Field(default_factory=PostboxDeliveryConfig)
|
||||
print: PrintDeliveryConfig = Field(default_factory=PrintDeliveryConfig)
|
||||
calendar_invitation: CalendarInvitationDeliveryConfig = Field(
|
||||
default_factory=CalendarInvitationDeliveryConfig
|
||||
)
|
||||
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
||||
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
|
||||
retry: RetryConfig = Field(default_factory=RetryConfig)
|
||||
@@ -602,3 +858,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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,964 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
JobBuildStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.approval_gate import (
|
||||
assert_campaign_approval,
|
||||
campaign_approval_gate,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||
from govoplan_campaign.backend.integrations import mail_integration
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
create_campaign_version_from_json,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot
|
||||
from govoplan_campaign.backend.sending.jobs import (
|
||||
_from_header_from_job,
|
||||
_send_job_delivery_context,
|
||||
_single_job_validation_allowed,
|
||||
_synchronous_smtp_batch_manager,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
|
||||
|
||||
RECURRENCE_KINDS = frozenset({"once", "daily", "weekly", "monthly"})
|
||||
SCHEDULE_SOURCE_SCHEMA = "govoplan.campaign.schedule-source.v1"
|
||||
SCHEDULE_DELIVERY_MODES = frozenset({"manual", "autonomous"})
|
||||
|
||||
|
||||
def canonical_configuration_hash(value: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=True,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def campaign_schedule_source_snapshot(
|
||||
*,
|
||||
configuration: Mapping[str, object],
|
||||
campaign_settings: Mapping[str, object],
|
||||
mail_profile_policy: Mapping[str, object],
|
||||
shares: list[Mapping[str, object]],
|
||||
) -> dict[str, object]:
|
||||
"""Seal every selected source domain so worker execution cannot drift."""
|
||||
|
||||
return {
|
||||
"schema": SCHEDULE_SOURCE_SCHEMA,
|
||||
"configuration": copy.deepcopy(dict(configuration)),
|
||||
"campaign_settings": copy.deepcopy(dict(campaign_settings)),
|
||||
"mail_profile_policy": copy.deepcopy(dict(mail_profile_policy)),
|
||||
"shares": [copy.deepcopy(dict(item)) for item in shares],
|
||||
}
|
||||
|
||||
|
||||
def next_schedule_fire(
|
||||
scheduled_for: datetime,
|
||||
*,
|
||||
recurrence_kind: str,
|
||||
interval_count: int,
|
||||
timezone_name: str,
|
||||
) -> datetime | None:
|
||||
if recurrence_kind == "once":
|
||||
return None
|
||||
if recurrence_kind not in RECURRENCE_KINDS:
|
||||
raise ValueError(f"Unsupported campaign recurrence: {recurrence_kind}")
|
||||
if interval_count < 1:
|
||||
raise ValueError("Campaign recurrence interval must be positive")
|
||||
try:
|
||||
zone = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError(f"Unknown campaign schedule timezone: {timezone_name}") from exc
|
||||
local = _as_utc(scheduled_for).astimezone(zone)
|
||||
if recurrence_kind == "daily":
|
||||
upcoming = local + timedelta(days=interval_count)
|
||||
elif recurrence_kind == "weekly":
|
||||
upcoming = local + timedelta(weeks=interval_count)
|
||||
else:
|
||||
month_index = local.year * 12 + local.month - 1 + interval_count
|
||||
year, month_offset = divmod(month_index, 12)
|
||||
month = month_offset + 1
|
||||
day = min(local.day, calendar.monthrange(year, month)[1])
|
||||
upcoming = local.replace(year=year, month=month, day=day)
|
||||
return upcoming.astimezone(UTC)
|
||||
|
||||
|
||||
def dispatch_due_campaign_schedules(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, object]:
|
||||
observed_at = _as_utc(now or datetime.now(UTC))
|
||||
refreshed = refresh_autonomous_schedule_outcomes(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
now=observed_at,
|
||||
)
|
||||
query = session.query(CampaignSchedule).filter(
|
||||
CampaignSchedule.active.is_(True),
|
||||
CampaignSchedule.next_fire_at.is_not(None),
|
||||
CampaignSchedule.next_fire_at <= observed_at,
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(CampaignSchedule.tenant_id == tenant_id)
|
||||
schedules = (
|
||||
query.order_by(CampaignSchedule.next_fire_at.asc(), CampaignSchedule.id.asc())
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(max(1, min(limit, 250)))
|
||||
.all()
|
||||
)
|
||||
result: dict[str, object] = {
|
||||
"selected": len(schedules),
|
||||
"prepared": 0,
|
||||
"autonomous_prepared": 0,
|
||||
"failed": 0,
|
||||
"completed": 0,
|
||||
"coalesced": 0,
|
||||
"duplicates": 0,
|
||||
"deferred": 0,
|
||||
"campaign_ids": [],
|
||||
"operator_actions": [],
|
||||
"refreshed": refreshed,
|
||||
}
|
||||
for schedule in schedules:
|
||||
scheduled_for = _as_utc(schedule.next_fire_at or observed_at)
|
||||
if schedule.delivery_mode == "autonomous" and _has_open_occurrence(
|
||||
session, schedule_id=schedule.id
|
||||
):
|
||||
result["deferred"] = int(result["deferred"]) + 1
|
||||
continue
|
||||
try:
|
||||
with session.begin_nested():
|
||||
if schedule.delivery_mode == "autonomous":
|
||||
_occurrence, skipped = _prepare_autonomous_occurrence(
|
||||
session,
|
||||
schedule=schedule,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
campaign_id = schedule.campaign_id
|
||||
result["autonomous_prepared"] = (
|
||||
int(result["autonomous_prepared"]) + 1
|
||||
)
|
||||
else:
|
||||
campaign, _version, skipped = _prepare_occurrence(
|
||||
session,
|
||||
schedule=schedule,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
)
|
||||
campaign_id = campaign.id
|
||||
result["prepared"] = int(result["prepared"]) + 1
|
||||
result["coalesced"] = int(result["coalesced"]) + skipped
|
||||
result["campaign_ids"].append(campaign_id) # type: ignore[union-attr]
|
||||
if not schedule.active:
|
||||
result["completed"] = int(result["completed"]) + 1
|
||||
except Exception as exc: # noqa: BLE001 - persist bounded operator evidence
|
||||
session.expire_all()
|
||||
recorded = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if recorded is not None:
|
||||
result["duplicates"] = int(result["duplicates"]) + 1
|
||||
if (
|
||||
schedule.active
|
||||
and schedule.next_fire_at is not None
|
||||
and _as_utc(schedule.next_fire_at) == scheduled_for
|
||||
and recorded.status not in {"failed", "uncertain"}
|
||||
):
|
||||
_advance_schedule(
|
||||
session,
|
||||
schedule=schedule,
|
||||
occurrence=recorded,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
sequence=schedule.occurrence_count + 1,
|
||||
)
|
||||
continue
|
||||
session.add(
|
||||
CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="failed",
|
||||
idempotency_key=_occurrence_idempotency_key(
|
||||
schedule.id, scheduled_for
|
||||
),
|
||||
error=str(exc)[:4000],
|
||||
recovery_state="failed",
|
||||
evidence={"delivery_mode": schedule.delivery_mode},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
)
|
||||
schedule.active = False
|
||||
schedule.last_error = str(exc)[:4000]
|
||||
schedule.last_outcome = "failed"
|
||||
schedule.last_recovery_state = "operator_required"
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
result["failed"] = int(result["failed"]) + 1
|
||||
result["operator_actions"].append( # type: ignore[union-attr]
|
||||
{
|
||||
"schedule_id": schedule.id,
|
||||
"campaign_id": schedule.campaign_id,
|
||||
"reason": "draft_preparation_failed",
|
||||
"delivery_mode": schedule.delivery_mode,
|
||||
}
|
||||
)
|
||||
_notify_schedule_operator(
|
||||
session,
|
||||
schedule=schedule,
|
||||
reason="policy_or_systemic_preflight_failed",
|
||||
)
|
||||
session.flush()
|
||||
return result
|
||||
|
||||
|
||||
def _prepare_occurrence(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
scheduled_for: datetime,
|
||||
observed_at: datetime,
|
||||
) -> tuple[Campaign, CampaignVersion, int]:
|
||||
existing = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise RuntimeError("Campaign schedule occurrence was already recorded")
|
||||
|
||||
source_campaign = session.get(Campaign, schedule.campaign_id)
|
||||
if source_campaign is None or source_campaign.tenant_id != schedule.tenant_id:
|
||||
raise RuntimeError("Campaign schedule source is no longer available")
|
||||
source_version = session.get(CampaignVersion, schedule.source_version_id)
|
||||
if source_version is None or source_version.campaign_id != source_campaign.id:
|
||||
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||
snapshot = _schedule_snapshot(schedule.source_snapshot)
|
||||
|
||||
sequence = schedule.occurrence_count + 1
|
||||
external_id = _scheduled_external_id(
|
||||
source_campaign.external_id,
|
||||
schedule.id,
|
||||
sequence,
|
||||
)
|
||||
local_date = scheduled_for.astimezone(ZoneInfo(schedule.timezone)).date().isoformat()
|
||||
generated_name = f"{schedule.name} - {local_date}"
|
||||
raw_json = campaign_copy_configuration(
|
||||
snapshot["configuration"],
|
||||
schedule.copy_options,
|
||||
)
|
||||
metadata = raw_json.get("campaign")
|
||||
if not isinstance(metadata, dict):
|
||||
raise RuntimeError("Campaign schedule snapshot has no campaign metadata")
|
||||
metadata["id"] = external_id
|
||||
metadata["name"] = generated_name
|
||||
metadata["mode"] = "draft"
|
||||
|
||||
generated_campaign, generated_version = create_campaign_version_from_json(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
user_id=schedule.created_by_user_id,
|
||||
raw_json=raw_json,
|
||||
source_filename=None,
|
||||
source_base_path=schedule.source_base_path,
|
||||
commit=False,
|
||||
)
|
||||
if bool(schedule.copy_options.get("include_policies", True)):
|
||||
generated_campaign.settings = copy.deepcopy(snapshot["campaign_settings"])
|
||||
if bool(schedule.copy_options.get("include_mail_profile", True)):
|
||||
generated_campaign.mail_profile_policy = copy.deepcopy(
|
||||
snapshot["mail_profile_policy"]
|
||||
)
|
||||
if bool(schedule.copy_options.get("include_shares", False)):
|
||||
_copy_snapshot_shares(
|
||||
session,
|
||||
schedule=schedule,
|
||||
generated_campaign=generated_campaign,
|
||||
shares=snapshot["shares"],
|
||||
)
|
||||
|
||||
occurrence = CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="prepared",
|
||||
idempotency_key=_occurrence_idempotency_key(schedule.id, scheduled_for),
|
||||
generated_campaign_id=generated_campaign.id,
|
||||
generated_version_id=generated_version.id,
|
||||
recovery_state="none",
|
||||
evidence={"delivery_mode": "manual"},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
session.add(occurrence)
|
||||
session.flush()
|
||||
schedule.last_campaign_id = generated_campaign.id
|
||||
schedule.last_outcome = "prepared"
|
||||
schedule.last_recovery_state = "none"
|
||||
coalesced = _advance_schedule(
|
||||
session,
|
||||
schedule=schedule,
|
||||
occurrence=occurrence,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
sequence=sequence,
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
user_id=schedule.created_by_user_id,
|
||||
action="campaign.schedule.draft_prepared",
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={
|
||||
"source_campaign_id": source_campaign.id,
|
||||
"source_version_id": source_version.id,
|
||||
"scheduled_for": scheduled_for.isoformat(),
|
||||
"generated_campaign_id": generated_campaign.id,
|
||||
"generated_version_id": generated_version.id,
|
||||
"occurrence": sequence,
|
||||
"coalesced_missed_intervals": coalesced,
|
||||
"delivery_started": False,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
return generated_campaign, generated_version, coalesced
|
||||
|
||||
|
||||
def validate_autonomous_schedule_source(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
) -> dict[str, object]:
|
||||
"""Validate the exact immutable execution that an autonomous schedule reuses."""
|
||||
|
||||
gate = campaign_approval_gate(version)
|
||||
if gate is None:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires an explicit Approval request for the built source version."
|
||||
)
|
||||
assert_campaign_approval(session, tenant_id=campaign.tenant_id, version=version)
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
snapshot_hash = str(version.execution_snapshot_hash or "")
|
||||
if len(snapshot_hash) != 64:
|
||||
raise RuntimeError("The approved Campaign execution snapshot is incomplete.")
|
||||
jobs = _autonomous_source_jobs(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
)
|
||||
mail = mail_integration()
|
||||
if not mail.durable_delivery_available:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires Mail's durable delivery-command outbox."
|
||||
)
|
||||
if not snapshot.mail_profile_id or not snapshot.smtp_transport_revision:
|
||||
raise RuntimeError(
|
||||
"The approved Campaign execution has no immutable Mail transport evidence."
|
||||
)
|
||||
summary = mail.campaign_profile_delivery_summary(
|
||||
session,
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
profile_id=snapshot.mail_profile_id,
|
||||
smtp_server_id=snapshot.smtp_server_id,
|
||||
smtp_credential_id=snapshot.smtp_credential_id,
|
||||
)
|
||||
if not summary.get("smtp_available"):
|
||||
raise RuntimeError("The approved Campaign Mail transport is unavailable.")
|
||||
if summary.get("smtp_transport_revision") != snapshot.smtp_transport_revision:
|
||||
raise RuntimeError(
|
||||
"The Campaign Mail transport changed after approval; rebuild and approve a new source version."
|
||||
)
|
||||
return {
|
||||
"execution_snapshot_hash": snapshot_hash,
|
||||
"approval_request_id": str(gate.get("request_id") or ""),
|
||||
"approval_subject_digest": str(gate.get("subject_digest") or ""),
|
||||
"job_count": len(jobs),
|
||||
"job_manifest_sha256": canonical_configuration_hash(
|
||||
{"jobs": [{"id": job.id, "eml_sha256": job.eml_sha256} for job in jobs]}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _autonomous_source_jobs(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion,
|
||||
) -> list[CampaignJob]:
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.all()
|
||||
)
|
||||
if not jobs:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires a built source version with recipient jobs."
|
||||
)
|
||||
for job in jobs:
|
||||
if job.build_status != JobBuildStatus.BUILT.value:
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires every source message to be built."
|
||||
)
|
||||
if not _single_job_validation_allowed(version, job, include_warnings=True):
|
||||
raise RuntimeError(
|
||||
"Autonomous delivery requires every source message to pass its reviewed recipient and attachment gates."
|
||||
)
|
||||
if DeliveryChannelPolicy(job.delivery_channel_policy) != DeliveryChannelPolicy.MAIL:
|
||||
raise RuntimeError(
|
||||
"Autonomous schedules currently support Mail-only delivery; use manual mode for hybrid, Postbox, or print delivery."
|
||||
)
|
||||
return jobs
|
||||
|
||||
|
||||
def _prepare_autonomous_occurrence(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
scheduled_for: datetime,
|
||||
observed_at: datetime,
|
||||
) -> tuple[CampaignScheduleOccurrence, int]:
|
||||
existing = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.scheduled_for == scheduled_for,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise RuntimeError("Campaign schedule occurrence was already recorded")
|
||||
if canonical_configuration_hash(schedule.source_snapshot) != schedule.source_snapshot_hash:
|
||||
raise RuntimeError("Campaign schedule source snapshot integrity check failed")
|
||||
campaign = session.get(Campaign, schedule.campaign_id)
|
||||
version = session.get(CampaignVersion, schedule.source_version_id)
|
||||
if campaign is None or campaign.tenant_id != schedule.tenant_id:
|
||||
raise RuntimeError("Campaign schedule source is no longer available")
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise RuntimeError("Campaign schedule source version is no longer available")
|
||||
validation = validate_autonomous_schedule_source(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
)
|
||||
if (
|
||||
not schedule.approved_execution_snapshot_hash
|
||||
or validation["execution_snapshot_hash"]
|
||||
!= schedule.approved_execution_snapshot_hash
|
||||
):
|
||||
raise RuntimeError(
|
||||
"The approved Campaign execution changed after the autonomous schedule was created."
|
||||
)
|
||||
jobs = _autonomous_source_jobs(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
)
|
||||
occurrence_key = _occurrence_idempotency_key(schedule.id, scheduled_for)
|
||||
occurrence = CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=scheduled_for,
|
||||
status="preparing",
|
||||
idempotency_key=occurrence_key,
|
||||
recovery_state="prepared",
|
||||
evidence={
|
||||
"delivery_mode": "autonomous",
|
||||
"source_campaign_id": campaign.id,
|
||||
"source_version_id": version.id,
|
||||
"source_snapshot_hash": schedule.source_snapshot_hash,
|
||||
**validation,
|
||||
},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
session.add(occurrence)
|
||||
session.flush()
|
||||
|
||||
contexts = {job.id: _send_job_delivery_context(session, job) for job in jobs}
|
||||
with _synchronous_smtp_batch_manager(session, jobs=jobs, contexts=contexts):
|
||||
pass
|
||||
|
||||
mail = mail_integration()
|
||||
commands: list[dict[str, object]] = []
|
||||
for job in jobs:
|
||||
context = contexts[job.id]
|
||||
if context.envelope_from is None or not context.envelope_recipients:
|
||||
raise RuntimeError("A frozen Campaign message has no delivery envelope.")
|
||||
message = BytesParser(policy=policy.default).parsebytes(context.message_bytes)
|
||||
commands.append(
|
||||
mail.submit_delivery_command(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
command_type="campaign_schedule_occurrence",
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign",
|
||||
source_resource_id=campaign.id,
|
||||
source_version_id=version.id,
|
||||
idempotency_key=f"{occurrence_key}:{job.id}",
|
||||
profile_id=context.snapshot.mail_profile_id,
|
||||
message_bytes=context.message_bytes,
|
||||
envelope_from=context.envelope_from,
|
||||
envelope_recipients=context.envelope_recipients,
|
||||
from_header=_from_header_from_job(job) or str(message.get("From") or ""),
|
||||
expected_smtp_transport_revision=(
|
||||
context.snapshot.smtp_transport_revision or ""
|
||||
),
|
||||
smtp_server_id=context.snapshot.smtp_server_id,
|
||||
smtp_credential_id=context.snapshot.smtp_credential_id,
|
||||
created_by_user_id=schedule.created_by_user_id,
|
||||
)
|
||||
)
|
||||
occurrence.delivery_command_ids = [str(item["id"]) for item in commands]
|
||||
occurrence.status = "prepared"
|
||||
occurrence.recovery_state = "pending"
|
||||
occurrence.evidence = {
|
||||
**occurrence.evidence,
|
||||
"command_count": len(commands),
|
||||
"duplicate_command_count": sum(bool(item.get("duplicate")) for item in commands),
|
||||
"command_status_counts": _status_counts(commands),
|
||||
}
|
||||
occurrence.last_checked_at = observed_at
|
||||
sequence = schedule.occurrence_count + 1
|
||||
schedule.last_campaign_id = campaign.id
|
||||
schedule.last_outcome = "prepared"
|
||||
schedule.last_recovery_state = "pending"
|
||||
coalesced = _advance_schedule(
|
||||
session,
|
||||
schedule=schedule,
|
||||
occurrence=occurrence,
|
||||
scheduled_for=scheduled_for,
|
||||
observed_at=observed_at,
|
||||
sequence=sequence,
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=schedule.tenant_id,
|
||||
user_id=schedule.created_by_user_id,
|
||||
action="campaign.schedule.delivery_prepared",
|
||||
object_type="campaign_schedule_occurrence",
|
||||
object_id=occurrence.id,
|
||||
details={
|
||||
"schedule_id": schedule.id,
|
||||
"campaign_id": campaign.id,
|
||||
"source_version_id": version.id,
|
||||
"scheduled_for": scheduled_for.isoformat(),
|
||||
"occurrence_idempotency_key": occurrence_key,
|
||||
"delivery_command_count": len(commands),
|
||||
"execution_snapshot_hash": validation["execution_snapshot_hash"],
|
||||
"approval_request_id": validation["approval_request_id"],
|
||||
"coalesced_missed_intervals": coalesced,
|
||||
},
|
||||
commit=False,
|
||||
)
|
||||
return occurrence, coalesced
|
||||
|
||||
|
||||
def _advance_schedule(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
occurrence: CampaignScheduleOccurrence,
|
||||
scheduled_for: datetime,
|
||||
observed_at: datetime,
|
||||
sequence: int,
|
||||
) -> int:
|
||||
schedule.occurrence_count = sequence
|
||||
schedule.last_fired_at = scheduled_for
|
||||
schedule.last_error = None
|
||||
next_fire = next_schedule_fire(
|
||||
scheduled_for,
|
||||
recurrence_kind=schedule.recurrence_kind,
|
||||
interval_count=schedule.interval_count,
|
||||
timezone_name=schedule.timezone,
|
||||
)
|
||||
coalesced = 0
|
||||
while next_fire is not None and next_fire <= observed_at:
|
||||
session.add(
|
||||
CampaignScheduleOccurrence(
|
||||
tenant_id=schedule.tenant_id,
|
||||
schedule_id=schedule.id,
|
||||
scheduled_for=next_fire,
|
||||
status="superseded",
|
||||
idempotency_key=_occurrence_idempotency_key(schedule.id, next_fire),
|
||||
recovery_state="superseded",
|
||||
evidence={
|
||||
"delivery_mode": schedule.delivery_mode,
|
||||
"reason": "coalesced_missed_interval",
|
||||
"superseded_by_occurrence_id": occurrence.id,
|
||||
},
|
||||
last_checked_at=observed_at,
|
||||
)
|
||||
)
|
||||
next_fire = next_schedule_fire(
|
||||
next_fire,
|
||||
recurrence_kind=schedule.recurrence_kind,
|
||||
interval_count=schedule.interval_count,
|
||||
timezone_name=schedule.timezone,
|
||||
)
|
||||
coalesced += 1
|
||||
if (
|
||||
next_fire is None
|
||||
or sequence >= schedule.max_occurrences
|
||||
or (schedule.ends_at is not None and next_fire > _as_utc(schedule.ends_at))
|
||||
):
|
||||
schedule.active = False
|
||||
schedule.next_fire_at = None
|
||||
else:
|
||||
schedule.next_fire_at = next_fire
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
return coalesced
|
||||
|
||||
|
||||
def refresh_autonomous_schedule_outcomes(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, int]:
|
||||
observed_at = _as_utc(now or datetime.now(UTC))
|
||||
query = session.query(CampaignScheduleOccurrence).filter(
|
||||
CampaignScheduleOccurrence.status.in_(("prepared", "uncertain")),
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(CampaignScheduleOccurrence.tenant_id == tenant_id)
|
||||
counts = {
|
||||
"checked": 0,
|
||||
"accepted": 0,
|
||||
"uncertain": 0,
|
||||
"failed": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
mail = mail_integration()
|
||||
if not mail.durable_delivery_available:
|
||||
for occurrence in query.order_by(
|
||||
CampaignScheduleOccurrence.created_at
|
||||
).limit(250):
|
||||
if not occurrence.delivery_command_ids:
|
||||
continue
|
||||
counts["checked"] += 1
|
||||
counts["uncertain"] += 1
|
||||
_mark_occurrence_uncertain(
|
||||
session,
|
||||
occurrence=occurrence,
|
||||
observed_at=observed_at,
|
||||
reason="mail_delivery_outbox_unavailable",
|
||||
)
|
||||
return counts
|
||||
for occurrence in query.order_by(CampaignScheduleOccurrence.created_at).limit(250):
|
||||
if not occurrence.delivery_command_ids:
|
||||
continue
|
||||
summaries: list[dict[str, object]] = []
|
||||
try:
|
||||
summaries = [
|
||||
mail.delivery_command_summary(
|
||||
session,
|
||||
tenant_id=occurrence.tenant_id,
|
||||
command_id=command_id,
|
||||
)
|
||||
for command_id in occurrence.delivery_command_ids
|
||||
]
|
||||
except Exception:
|
||||
counts["checked"] += 1
|
||||
counts["uncertain"] += 1
|
||||
_mark_occurrence_uncertain(
|
||||
session,
|
||||
occurrence=occurrence,
|
||||
observed_at=observed_at,
|
||||
reason="mail_delivery_status_unavailable",
|
||||
)
|
||||
continue
|
||||
counts["checked"] += 1
|
||||
outcome, recovery_state = _aggregate_command_outcome(summaries)
|
||||
previous_outcome = occurrence.status
|
||||
previous_recovery_state = occurrence.recovery_state
|
||||
occurrence.status = outcome
|
||||
occurrence.recovery_state = recovery_state
|
||||
occurrence.last_checked_at = observed_at
|
||||
occurrence.evidence = {
|
||||
**(occurrence.evidence or {}),
|
||||
"command_status_counts": _status_counts(summaries),
|
||||
"accepted_recipient_count": sum(
|
||||
int(item.get("accepted_count") or 0) for item in summaries
|
||||
),
|
||||
"refused_recipient_count": sum(
|
||||
int(item.get("refused_count") or 0) for item in summaries
|
||||
),
|
||||
"failure_codes": sorted(
|
||||
{
|
||||
str(item["failure_code"])
|
||||
for item in summaries
|
||||
if item.get("failure_code")
|
||||
}
|
||||
),
|
||||
}
|
||||
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||
if schedule is not None:
|
||||
schedule.last_outcome = outcome
|
||||
schedule.last_recovery_state = recovery_state
|
||||
transitioned_to_operator_required = (
|
||||
outcome in {"uncertain", "failed"}
|
||||
and (
|
||||
previous_outcome != outcome
|
||||
or previous_recovery_state != recovery_state
|
||||
or schedule.active
|
||||
)
|
||||
)
|
||||
if transitioned_to_operator_required:
|
||||
schedule.active = False
|
||||
schedule.last_error = (
|
||||
"Autonomous delivery needs operator review; automatic recurrence is paused."
|
||||
)
|
||||
schedule.resource_revision += 1
|
||||
_notify_schedule_operator(
|
||||
session,
|
||||
schedule=schedule,
|
||||
reason=f"delivery_{outcome}",
|
||||
)
|
||||
session.add(schedule)
|
||||
session.add(occurrence)
|
||||
if outcome in counts:
|
||||
counts[outcome] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _mark_occurrence_uncertain(
|
||||
session: Session,
|
||||
*,
|
||||
occurrence: CampaignScheduleOccurrence,
|
||||
observed_at: datetime,
|
||||
reason: str,
|
||||
) -> None:
|
||||
previous_outcome = occurrence.status
|
||||
previous_recovery_state = occurrence.recovery_state
|
||||
occurrence.status = "uncertain"
|
||||
occurrence.recovery_state = "operator_required"
|
||||
occurrence.last_checked_at = observed_at
|
||||
occurrence.evidence = {
|
||||
**(occurrence.evidence or {}),
|
||||
"recovery_reason": reason,
|
||||
}
|
||||
schedule = session.get(CampaignSchedule, occurrence.schedule_id)
|
||||
if schedule is not None:
|
||||
transitioned = (
|
||||
previous_outcome != "uncertain"
|
||||
or previous_recovery_state != "operator_required"
|
||||
or schedule.active
|
||||
)
|
||||
schedule.active = False
|
||||
schedule.last_outcome = "uncertain"
|
||||
schedule.last_recovery_state = "operator_required"
|
||||
schedule.last_error = (
|
||||
"Autonomous delivery status is unavailable; automatic recurrence is paused."
|
||||
)
|
||||
if transitioned:
|
||||
schedule.resource_revision += 1
|
||||
_notify_schedule_operator(
|
||||
session,
|
||||
schedule=schedule,
|
||||
reason=reason,
|
||||
)
|
||||
session.add(schedule)
|
||||
session.add(occurrence)
|
||||
|
||||
|
||||
def _has_open_occurrence(session: Session, *, schedule_id: str) -> bool:
|
||||
rows = (
|
||||
session.query(CampaignScheduleOccurrence.delivery_command_ids)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule_id,
|
||||
CampaignScheduleOccurrence.status == "prepared",
|
||||
)
|
||||
.limit(1000)
|
||||
.all()
|
||||
)
|
||||
return any(bool(command_ids) for (command_ids,) in rows)
|
||||
|
||||
|
||||
def _aggregate_command_outcome(
|
||||
summaries: list[dict[str, object]],
|
||||
) -> tuple[str, str]:
|
||||
statuses = {str(item.get("status") or "") for item in summaries}
|
||||
if statuses and statuses <= {"accepted", "reconciled_accepted"}:
|
||||
return "accepted", "complete"
|
||||
if statuses and statuses <= {"reconciled_not_accepted"}:
|
||||
return "skipped", "reconciled"
|
||||
if statuses & {"outcome_unknown", "in_progress"}:
|
||||
return "uncertain", "operator_required"
|
||||
if statuses & {"permanent_failure", "partially_refused", "reconciled_not_accepted"}:
|
||||
return "failed", "operator_required"
|
||||
return "prepared", "pending"
|
||||
|
||||
|
||||
def _status_counts(items: list[dict[str, object]]) -> dict[str, int]:
|
||||
result: dict[str, int] = {}
|
||||
for item in items:
|
||||
status = str(item.get("status") or "unknown")
|
||||
result[status] = result.get(status, 0) + 1
|
||||
return result
|
||||
|
||||
|
||||
def _occurrence_idempotency_key(schedule_id: str, scheduled_for: datetime) -> str:
|
||||
return f"campaign-schedule:{schedule_id}:{_as_utc(scheduled_for).isoformat()}"
|
||||
|
||||
|
||||
def _notify_schedule_operator(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
reason: str,
|
||||
) -> None:
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None:
|
||||
return
|
||||
try:
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=schedule.tenant_id,
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_schedule",
|
||||
source_resource_id=schedule.id,
|
||||
event_kind="campaign.schedule.operator_required",
|
||||
channel="inbox",
|
||||
recipient_type="user" if schedule.created_by_user_id else None,
|
||||
recipient_id=schedule.created_by_user_id,
|
||||
subject=f"Campaign schedule paused: {schedule.name}",
|
||||
body_text=(
|
||||
"Autonomous Campaign delivery was paused before another occurrence. "
|
||||
"Review its recovery evidence before resuming."
|
||||
),
|
||||
action_url=f"/campaigns/{schedule.campaign_id}",
|
||||
priority=2,
|
||||
payload={"schedule_id": schedule.id, "reason": reason},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _copy_snapshot_shares(
|
||||
session: Session,
|
||||
*,
|
||||
schedule: CampaignSchedule,
|
||||
generated_campaign: Campaign,
|
||||
shares: object,
|
||||
) -> None:
|
||||
if not isinstance(shares, list):
|
||||
raise RuntimeError("Campaign schedule share snapshot is invalid")
|
||||
for source in shares:
|
||||
if not isinstance(source, Mapping):
|
||||
raise RuntimeError("Campaign schedule share snapshot is invalid")
|
||||
target_type = str(source.get("target_type") or "")
|
||||
target_id = str(source.get("target_id") or "")
|
||||
permission = str(source.get("permission") or "read")
|
||||
if not target_type or not target_id:
|
||||
raise RuntimeError("Campaign schedule share snapshot is incomplete")
|
||||
session.add(
|
||||
CampaignShare(
|
||||
tenant_id=schedule.tenant_id,
|
||||
campaign_id=generated_campaign.id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
permission=permission,
|
||||
created_by_user_id=schedule.created_by_user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _schedule_snapshot(value: object) -> dict[str, object]:
|
||||
if not isinstance(value, Mapping) or value.get("schema") != SCHEDULE_SOURCE_SCHEMA:
|
||||
raise RuntimeError("Campaign schedule source snapshot schema is invalid")
|
||||
configuration = value.get("configuration")
|
||||
settings = value.get("campaign_settings")
|
||||
mail_policy = value.get("mail_profile_policy")
|
||||
shares = value.get("shares")
|
||||
if (
|
||||
not isinstance(configuration, Mapping)
|
||||
or not isinstance(settings, Mapping)
|
||||
or not isinstance(mail_policy, Mapping)
|
||||
or not isinstance(shares, list)
|
||||
):
|
||||
raise RuntimeError("Campaign schedule source snapshot is incomplete")
|
||||
return {
|
||||
"configuration": dict(configuration),
|
||||
"campaign_settings": dict(settings),
|
||||
"mail_profile_policy": dict(mail_policy),
|
||||
"shares": shares,
|
||||
}
|
||||
|
||||
|
||||
def _scheduled_external_id(source: str, schedule_id: str, sequence: int) -> str:
|
||||
suffix = f"-scheduled-{schedule_id[:8]}-{sequence}"
|
||||
return f"{source[:255 - len(suffix)]}{suffix}"
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value.astimezone(UTC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"RECURRENCE_KINDS",
|
||||
"SCHEDULE_SOURCE_SCHEMA",
|
||||
"campaign_schedule_source_snapshot",
|
||||
"canonical_configuration_hash",
|
||||
"dispatch_due_campaign_schedules",
|
||||
"next_schedule_fire",
|
||||
"refresh_autonomous_schedule_outcomes",
|
||||
"validate_autonomous_schedule_source",
|
||||
]
|
||||
@@ -0,0 +1,730 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from govoplan_campaign.backend.campaign.loader import validate_against_schema
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_configuration,
|
||||
public_campaign_payload,
|
||||
)
|
||||
|
||||
|
||||
PORTABLE_CAMPAIGN_FORMAT = "govoplan.campaign-portable"
|
||||
PORTABLE_CAMPAIGN_FORMAT_VERSION = "1.0"
|
||||
PORTABLE_CAMPAIGN_SCOPE_ORDER = (
|
||||
"metadata",
|
||||
"template_config",
|
||||
"recipients",
|
||||
"attachments",
|
||||
"review_state",
|
||||
"delivery_history",
|
||||
)
|
||||
DEFAULT_PORTABLE_CAMPAIGN_SCOPES = ("metadata", "template_config")
|
||||
OPERATIONAL_EVIDENCE_SCOPES = frozenset(("review_state", "delivery_history"))
|
||||
_CONFIG_STRUCTURAL_KEYS = frozenset(
|
||||
("version", "campaign", "recipients", "entries", "attachments")
|
||||
)
|
||||
_SENSITIVE_SETTING_FRAGMENTS = (
|
||||
"api_key",
|
||||
"credential",
|
||||
"password",
|
||||
"private_key",
|
||||
"secret",
|
||||
"token",
|
||||
)
|
||||
|
||||
|
||||
class CampaignTransferError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignImportInspection:
|
||||
preview: dict[str, Any]
|
||||
configuration: dict[str, Any] | None
|
||||
portable_settings: dict[str, Any]
|
||||
|
||||
|
||||
def canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def normalize_transfer_scopes(scopes: Iterable[str]) -> tuple[str, ...]:
|
||||
selected = set(scopes)
|
||||
invalid = sorted(selected.difference(PORTABLE_CAMPAIGN_SCOPE_ORDER))
|
||||
if invalid:
|
||||
raise CampaignTransferError(
|
||||
f"Unsupported campaign transfer scope(s): {', '.join(invalid)}"
|
||||
)
|
||||
if not selected:
|
||||
raise CampaignTransferError("Select at least one campaign transfer scope.")
|
||||
return tuple(scope for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER if scope in selected)
|
||||
|
||||
|
||||
def build_campaign_portable_package(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
scopes: Iterable[str],
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
issues: Iterable[CampaignIssue] = (),
|
||||
module_version: str,
|
||||
) -> dict[str, Any]:
|
||||
selected = normalize_transfer_scopes(scopes)
|
||||
configuration = public_campaign_configuration(version.raw_json)
|
||||
if not isinstance(configuration, dict):
|
||||
raise CampaignTransferError("The campaign configuration is not portable JSON.")
|
||||
configuration, password_redactions = _redact_password_field_values(configuration)
|
||||
payload: dict[str, Any] = {}
|
||||
item_counts: dict[str, int] = {}
|
||||
redactions: Counter[str] = Counter(password_redactions)
|
||||
|
||||
if "metadata" in selected:
|
||||
payload["metadata"] = {
|
||||
"external_id": campaign.external_id,
|
||||
"name": campaign.name,
|
||||
"description": campaign.description,
|
||||
"source_status": campaign.status,
|
||||
}
|
||||
item_counts["metadata"] = 1
|
||||
|
||||
if "template_config" in selected:
|
||||
settings, setting_redactions = _redact_sensitive_settings(
|
||||
campaign.settings or {}
|
||||
)
|
||||
mail_policy, mail_policy_redactions = _redact_sensitive_settings(
|
||||
campaign.mail_profile_policy or {}
|
||||
)
|
||||
template_configuration = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in configuration.items()
|
||||
if key not in _CONFIG_STRUCTURAL_KEYS
|
||||
}
|
||||
server = template_configuration.get("server")
|
||||
if isinstance(server, dict):
|
||||
for key in ("smtp_credential_id", "imap_credential_id"):
|
||||
if server.pop(key, None) is not None:
|
||||
redactions["deployment_credential_reference"] += 1
|
||||
payload["template_config"] = {
|
||||
"schema_version": version.schema_version,
|
||||
"configuration": template_configuration,
|
||||
"campaign_settings": settings,
|
||||
"mail_profile_policy": mail_policy,
|
||||
}
|
||||
redactions.update(setting_redactions)
|
||||
redactions.update(mail_policy_redactions)
|
||||
item_counts["template_config"] = len(template_configuration)
|
||||
|
||||
if "recipients" in selected:
|
||||
entries = copy.deepcopy(configuration.get("entries") or {})
|
||||
_remove_entry_attachments(entries)
|
||||
payload["recipients"] = {
|
||||
"recipients": copy.deepcopy(configuration.get("recipients") or {}),
|
||||
"entries": entries,
|
||||
}
|
||||
item_counts["recipients"] = _recipient_entry_count(entries)
|
||||
|
||||
if "attachments" in selected:
|
||||
entry_attachments = _entry_attachment_projection(
|
||||
configuration.get("entries")
|
||||
)
|
||||
payload["attachments"] = {
|
||||
"configuration": copy.deepcopy(configuration.get("attachments") or {}),
|
||||
"entry_attachments": entry_attachments,
|
||||
"content_included": False,
|
||||
}
|
||||
item_counts["attachments"] = _attachment_rule_count(
|
||||
payload["attachments"]
|
||||
)
|
||||
|
||||
issue_rows = tuple(issues)
|
||||
if "review_state" in selected:
|
||||
review_state = _review_state_projection(version, issue_rows)
|
||||
payload["review_state"] = review_state
|
||||
item_counts["review_state"] = int(review_state["decision_count"])
|
||||
|
||||
job_rows = tuple(jobs)
|
||||
if "delivery_history" in selected:
|
||||
payload["delivery_history"] = {
|
||||
"jobs": [_delivery_job_projection(job) for job in job_rows],
|
||||
"counts": _delivery_counts(job_rows),
|
||||
}
|
||||
item_counts["delivery_history"] = len(job_rows)
|
||||
|
||||
exported_at = datetime.now(UTC)
|
||||
package: dict[str, Any] = {
|
||||
"format": PORTABLE_CAMPAIGN_FORMAT,
|
||||
"format_version": PORTABLE_CAMPAIGN_FORMAT_VERSION,
|
||||
"package_id": str(uuid4()),
|
||||
"exported_at": exported_at.isoformat(),
|
||||
"source": {
|
||||
"module": "campaigns",
|
||||
"module_version": module_version,
|
||||
"tenant_ref_sha256": hashlib.sha256(
|
||||
campaign.tenant_id.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_external_id": campaign.external_id,
|
||||
"campaign_name": campaign.name,
|
||||
"version_id": version.id,
|
||||
"version_number": version.version_number,
|
||||
"campaign_schema_version": version.schema_version,
|
||||
},
|
||||
"scopes": list(selected),
|
||||
"manifest": {
|
||||
"item_counts": item_counts,
|
||||
"redactions": dict(sorted(redactions.items())),
|
||||
"privacy_default_scopes": list(DEFAULT_PORTABLE_CAMPAIGN_SCOPES),
|
||||
"attachments_are_references_only": True,
|
||||
"operational_evidence_is_not_replayed": True,
|
||||
"secrets_included": False,
|
||||
},
|
||||
"payload": payload,
|
||||
}
|
||||
package["integrity"] = {
|
||||
"algorithm": "sha256",
|
||||
"package_sha256": canonical_sha256(package),
|
||||
}
|
||||
return package
|
||||
|
||||
|
||||
def inspect_campaign_portable_package(
|
||||
package: Mapping[str, Any],
|
||||
*,
|
||||
selected_scopes: Iterable[str] | None,
|
||||
external_id: str,
|
||||
name: str,
|
||||
) -> CampaignImportInspection:
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
package_dict = copy.deepcopy(dict(package))
|
||||
package_id = _optional_text(package_dict.get("package_id"))
|
||||
format_version = _optional_text(package_dict.get("format_version"))
|
||||
source = package_dict.get("source")
|
||||
source_dict = copy.deepcopy(source) if isinstance(source, dict) else {}
|
||||
integrity = package_dict.get("integrity")
|
||||
expected_hash = (
|
||||
_optional_text(integrity.get("package_sha256"))
|
||||
if isinstance(integrity, dict)
|
||||
else None
|
||||
)
|
||||
hash_input = copy.deepcopy(package_dict)
|
||||
hash_input.pop("integrity", None)
|
||||
actual_hash = canonical_sha256(hash_input)
|
||||
|
||||
if package_dict.get("format") != PORTABLE_CAMPAIGN_FORMAT:
|
||||
errors.append("The file is not a GovOPlaN portable Campaign package.")
|
||||
if format_version != PORTABLE_CAMPAIGN_FORMAT_VERSION:
|
||||
errors.append(
|
||||
"The Campaign package format version is not supported by this installation."
|
||||
)
|
||||
if not package_id:
|
||||
errors.append("The Campaign package has no package identifier.")
|
||||
if not expected_hash or expected_hash != actual_hash:
|
||||
errors.append("The Campaign package integrity checksum does not match its content.")
|
||||
if not isinstance(integrity, dict) or integrity.get("algorithm") != "sha256":
|
||||
errors.append("The Campaign package does not use the supported SHA-256 integrity algorithm.")
|
||||
if not source_dict:
|
||||
errors.append("The Campaign package has no source provenance.")
|
||||
elif source_dict.get("campaign_schema_version") != "1.0":
|
||||
errors.append("The Campaign configuration schema version is not supported by this installation.")
|
||||
|
||||
available: tuple[str, ...] = ()
|
||||
try:
|
||||
raw_scopes = package_dict.get("scopes")
|
||||
if not isinstance(raw_scopes, list):
|
||||
raise CampaignTransferError("The Campaign package has no valid scope list.")
|
||||
available = normalize_transfer_scopes(str(item) for item in raw_scopes)
|
||||
except CampaignTransferError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
try:
|
||||
selected = normalize_transfer_scopes(
|
||||
available if selected_scopes is None else selected_scopes
|
||||
)
|
||||
except CampaignTransferError as exc:
|
||||
errors.append(str(exc))
|
||||
selected = ()
|
||||
unavailable = sorted(set(selected).difference(available))
|
||||
if unavailable:
|
||||
errors.append(
|
||||
f"Selected scope(s) are absent from the package: {', '.join(unavailable)}"
|
||||
)
|
||||
|
||||
payload = package_dict.get("payload")
|
||||
payload_dict = payload if isinstance(payload, dict) else {}
|
||||
if not isinstance(payload, dict):
|
||||
errors.append("The Campaign package has no valid payload object.")
|
||||
if not isinstance(package_dict.get("manifest"), dict):
|
||||
errors.append("The Campaign package has no valid manifest.")
|
||||
for scope in available:
|
||||
if scope not in payload_dict:
|
||||
errors.append(f"The Campaign package payload is missing scope '{scope}'.")
|
||||
elif not isinstance(payload_dict[scope], dict):
|
||||
errors.append(f"The Campaign package scope '{scope}' is not a valid object.")
|
||||
|
||||
template_scope = payload_dict.get("template_config")
|
||||
if (
|
||||
"template_config" in available
|
||||
and isinstance(template_scope, dict)
|
||||
and template_scope.get("schema_version") != "1.0"
|
||||
):
|
||||
errors.append("The portable template/configuration schema version is not supported.")
|
||||
|
||||
configuration: dict[str, Any] | None = None
|
||||
portable_settings: dict[str, Any] = {}
|
||||
will_create: list[dict[str, Any]] = []
|
||||
will_skip: list[dict[str, Any]] = []
|
||||
if not errors:
|
||||
configuration, portable_settings, created, skipped, materialize_warnings = (
|
||||
_materialize_import(
|
||||
payload_dict,
|
||||
available=available,
|
||||
selected=selected,
|
||||
external_id=external_id,
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
will_create.extend(created)
|
||||
will_skip.extend(skipped)
|
||||
warnings.extend(materialize_warnings)
|
||||
try:
|
||||
validate_against_schema(configuration)
|
||||
except Exception as exc:
|
||||
errors.append(f"The imported Campaign configuration is incompatible: {exc}")
|
||||
configuration = None
|
||||
|
||||
manifest = package_dict.get("manifest")
|
||||
if isinstance(manifest, dict) and manifest.get("redactions"):
|
||||
warnings.append(
|
||||
"The source export redacted sensitive or deployment-bound values; review the package manifest and reconfigure them locally."
|
||||
)
|
||||
|
||||
preview = {
|
||||
"compatible": not errors,
|
||||
"package_id": package_id,
|
||||
"package_sha256": actual_hash,
|
||||
"format_version": format_version,
|
||||
"source": source_dict,
|
||||
"available_scopes": list(available),
|
||||
"selected_scopes": list(selected),
|
||||
"destination": {
|
||||
"external_id": external_id,
|
||||
"name": name,
|
||||
"status": "draft",
|
||||
},
|
||||
"will_create": will_create,
|
||||
"will_skip": will_skip,
|
||||
"warnings": list(dict.fromkeys(warnings)),
|
||||
"errors": list(dict.fromkeys(errors)),
|
||||
}
|
||||
return CampaignImportInspection(
|
||||
preview=preview,
|
||||
configuration=configuration,
|
||||
portable_settings=portable_settings,
|
||||
)
|
||||
|
||||
|
||||
def _materialize_import(
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
available: tuple[str, ...],
|
||||
selected: tuple[str, ...],
|
||||
external_id: str,
|
||||
name: str,
|
||||
) -> tuple[
|
||||
dict[str, Any],
|
||||
dict[str, Any],
|
||||
list[dict[str, Any]],
|
||||
list[dict[str, Any]],
|
||||
list[str],
|
||||
]:
|
||||
selected_set = set(selected)
|
||||
configuration = minimal_campaign_json(external_id=external_id, name=name)
|
||||
portable_settings: dict[str, Any] = {}
|
||||
created: list[dict[str, Any]] = [
|
||||
_plan_item("metadata", "campaign_draft", "A new Campaign draft and editable version will be created.", 1)
|
||||
]
|
||||
skipped: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
metadata = payload.get("metadata")
|
||||
if "metadata" in selected_set and isinstance(metadata, dict):
|
||||
description = metadata.get("description")
|
||||
if isinstance(description, str):
|
||||
configuration["campaign"]["description"] = description
|
||||
|
||||
template_payload = payload.get("template_config")
|
||||
if "template_config" in selected_set and isinstance(template_payload, dict):
|
||||
source_configuration = template_payload.get("configuration")
|
||||
if isinstance(source_configuration, dict):
|
||||
for key, value in source_configuration.items():
|
||||
if key in _CONFIG_STRUCTURAL_KEYS:
|
||||
continue
|
||||
configuration[key] = copy.deepcopy(value)
|
||||
source_server = configuration.get("server")
|
||||
if isinstance(source_server, dict) and source_server:
|
||||
configuration["server"] = {}
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
"template_config",
|
||||
"deployment_bound_mail_profile",
|
||||
"Mail profile and server references are not applied across installations; select local Mail resources after import.",
|
||||
len(source_server),
|
||||
)
|
||||
)
|
||||
settings = template_payload.get("campaign_settings")
|
||||
if isinstance(settings, dict):
|
||||
portable_settings = copy.deepcopy(settings)
|
||||
created.append(
|
||||
_plan_item(
|
||||
"template_config",
|
||||
"editable_configuration",
|
||||
"Portable fields, template, delivery settings, and validation policy will be applied to the draft.",
|
||||
len(source_configuration),
|
||||
)
|
||||
)
|
||||
|
||||
recipients_payload = payload.get("recipients")
|
||||
if "recipients" in selected_set and isinstance(recipients_payload, dict):
|
||||
recipients = recipients_payload.get("recipients")
|
||||
entries = recipients_payload.get("entries")
|
||||
if isinstance(recipients, dict):
|
||||
configuration["recipients"] = copy.deepcopy(recipients)
|
||||
if isinstance(entries, dict):
|
||||
configuration["entries"] = copy.deepcopy(entries)
|
||||
created.append(
|
||||
_plan_item(
|
||||
"recipients",
|
||||
"recipient_rows",
|
||||
"Campaign-local recipient rows and source provenance will be copied into the draft.",
|
||||
_recipient_entry_count(entries),
|
||||
)
|
||||
)
|
||||
|
||||
attachments_payload = payload.get("attachments")
|
||||
if "attachments" in selected_set and isinstance(attachments_payload, dict):
|
||||
attachment_configuration = attachments_payload.get("configuration")
|
||||
if isinstance(attachment_configuration, dict):
|
||||
configuration["attachments"] = copy.deepcopy(attachment_configuration)
|
||||
per_entry = attachments_payload.get("entry_attachments")
|
||||
applied_entry_rules = 0
|
||||
if "recipients" in selected_set and isinstance(per_entry, list):
|
||||
inline = configuration.get("entries", {}).get("inline", [])
|
||||
if isinstance(inline, list):
|
||||
for item in per_entry:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
index = item.get("entry_index")
|
||||
rules = item.get("attachments")
|
||||
if (
|
||||
isinstance(index, int)
|
||||
and 0 <= index < len(inline)
|
||||
and isinstance(inline[index], dict)
|
||||
and isinstance(rules, list)
|
||||
):
|
||||
inline[index]["attachments"] = copy.deepcopy(rules)
|
||||
applied_entry_rules += len(rules)
|
||||
elif isinstance(per_entry, list) and per_entry:
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
"attachments",
|
||||
"recipient_scope_required",
|
||||
"Per-recipient attachment rules are skipped unless recipient rows are also imported.",
|
||||
sum(
|
||||
len(item.get("attachments") or [])
|
||||
for item in per_entry
|
||||
if isinstance(item, dict)
|
||||
),
|
||||
)
|
||||
)
|
||||
created.append(
|
||||
_plan_item(
|
||||
"attachments",
|
||||
"attachment_references",
|
||||
"Portable attachment rules will be applied; file content is never embedded in the package.",
|
||||
_attachment_rule_count(attachments_payload) - max(0, _entry_rule_count(per_entry) - applied_entry_rules),
|
||||
)
|
||||
)
|
||||
warnings.append(
|
||||
"Attachment rules contain references only. Reconnect or upload the required files and validate the draft before use."
|
||||
)
|
||||
|
||||
for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER:
|
||||
if scope not in OPERATIONAL_EVIDENCE_SCOPES:
|
||||
continue
|
||||
if scope in selected_set:
|
||||
item_count = _manifest_scope_count(payload.get(scope))
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
scope,
|
||||
"operational_evidence_not_replayed",
|
||||
"Historical review or delivery evidence remains in the source package and import receipt but is never replayed as live Campaign state.",
|
||||
item_count,
|
||||
)
|
||||
)
|
||||
|
||||
for scope in available:
|
||||
if scope not in selected_set:
|
||||
skipped.append(
|
||||
_plan_item(
|
||||
scope,
|
||||
"scope_not_selected",
|
||||
"This available package scope was not selected for import.",
|
||||
_manifest_scope_count(payload.get(scope)),
|
||||
)
|
||||
)
|
||||
|
||||
campaign_metadata = configuration.get("campaign")
|
||||
if not isinstance(campaign_metadata, dict):
|
||||
raise CampaignTransferError("The imported Campaign metadata is invalid.")
|
||||
campaign_metadata.update({"id": external_id, "name": name, "mode": "draft"})
|
||||
return configuration, portable_settings, created, skipped, warnings
|
||||
|
||||
|
||||
def _review_state_projection(
|
||||
version: CampaignVersion, issues: tuple[CampaignIssue, ...]
|
||||
) -> dict[str, Any]:
|
||||
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
|
||||
review = editor_state.get("review_send")
|
||||
review = review if isinstance(review, dict) else {}
|
||||
decisions = [
|
||||
item
|
||||
for item in (review.get("issue_decisions") or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
decision_evidence = [
|
||||
{
|
||||
"decision": item.get("decision"),
|
||||
"issue_codes": sorted(str(code) for code in item.get("issue_codes") or []),
|
||||
"issue_fingerprint": item.get("issue_fingerprint"),
|
||||
"message_sha256": item.get("message_sha256"),
|
||||
"reason_recorded": bool(str(item.get("reason") or "").strip()),
|
||||
}
|
||||
for item in decisions
|
||||
]
|
||||
issue_counts = Counter(str(issue.severity) for issue in issues)
|
||||
return {
|
||||
"workflow_state": version.workflow_state,
|
||||
"inspection_complete": bool(review.get("inspection_complete")),
|
||||
"reviewed_message_count": len(review.get("reviewed_message_keys") or []),
|
||||
"decision_count": len(decisions),
|
||||
"decision_evidence_sha256": canonical_sha256(decision_evidence),
|
||||
"issue_counts": dict(sorted(issue_counts.items())),
|
||||
"validation_summary": public_campaign_payload(version.validation_summary or {}),
|
||||
"build_summary": public_campaign_payload(version.build_summary or {}),
|
||||
}
|
||||
|
||||
|
||||
def _delivery_job_projection(job: CampaignJob) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"message_id_header": job.message_id_header,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"postbox_status": job.postbox_status,
|
||||
"print_status": job.print_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"sent_at": _isoformat(job.sent_at),
|
||||
"outcome_unknown_at": _isoformat(job.outcome_unknown_at),
|
||||
"delivery_provenance": public_campaign_payload(job.delivery_provenance or {}),
|
||||
}
|
||||
|
||||
|
||||
def _delivery_counts(jobs: tuple[CampaignJob, ...]) -> dict[str, dict[str, int]]:
|
||||
return {
|
||||
field: dict(
|
||||
sorted(Counter(str(getattr(job, field) or "unknown") for job in jobs).items())
|
||||
)
|
||||
for field in ("validation_status", "queue_status", "send_status")
|
||||
}
|
||||
|
||||
|
||||
def _redact_sensitive_settings(
|
||||
value: Mapping[str, Any],
|
||||
) -> tuple[dict[str, Any], Counter[str]]:
|
||||
redactions: Counter[str] = Counter()
|
||||
|
||||
def visit(item: Any) -> Any:
|
||||
if isinstance(item, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for raw_key, child in item.items():
|
||||
key = str(raw_key)
|
||||
normalized = key.lower().replace("-", "_")
|
||||
if any(fragment in normalized for fragment in _SENSITIVE_SETTING_FRAGMENTS):
|
||||
redactions["sensitive_setting"] += 1
|
||||
continue
|
||||
result[key] = visit(child)
|
||||
return result
|
||||
if isinstance(item, list):
|
||||
return [visit(child) for child in item]
|
||||
return copy.deepcopy(item)
|
||||
|
||||
return visit(dict(value)), redactions
|
||||
|
||||
|
||||
def _redact_password_field_values(
|
||||
configuration: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], Counter[str]]:
|
||||
result = copy.deepcopy(configuration)
|
||||
password_fields = {
|
||||
str(field.get("name"))
|
||||
for field in result.get("fields") or []
|
||||
if isinstance(field, dict)
|
||||
and field.get("type") == "password"
|
||||
and field.get("name")
|
||||
}
|
||||
redactions: Counter[str] = Counter()
|
||||
if not password_fields:
|
||||
return result, redactions
|
||||
global_values = result.get("global_values")
|
||||
if isinstance(global_values, dict):
|
||||
for key in password_fields:
|
||||
if global_values.pop(key, None) is not None:
|
||||
redactions["password_field_value"] += 1
|
||||
entries = result.get("entries")
|
||||
if isinstance(entries, dict):
|
||||
for entry in entries.get("inline") or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fields = entry.get("fields")
|
||||
if not isinstance(fields, dict):
|
||||
continue
|
||||
for key in password_fields:
|
||||
if fields.pop(key, None) is not None:
|
||||
redactions["password_field_value"] += 1
|
||||
return result, redactions
|
||||
|
||||
|
||||
def _remove_entry_attachments(entries: Any) -> None:
|
||||
if not isinstance(entries, dict):
|
||||
return
|
||||
for entry in entries.get("inline") or []:
|
||||
if isinstance(entry, dict):
|
||||
entry["attachments"] = []
|
||||
defaults = entries.get("defaults")
|
||||
if isinstance(defaults, dict):
|
||||
defaults["attachments"] = []
|
||||
|
||||
|
||||
def _entry_attachment_projection(entries: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(entries, dict):
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for index, entry in enumerate(entries.get("inline") or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
rules = entry.get("attachments")
|
||||
if isinstance(rules, list) and rules:
|
||||
result.append(
|
||||
{
|
||||
"entry_index": index,
|
||||
"attachments": copy.deepcopy(rules),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _recipient_entry_count(entries: Any) -> int:
|
||||
if not isinstance(entries, dict):
|
||||
return 0
|
||||
inline = entries.get("inline")
|
||||
return len(inline) if isinstance(inline, list) else 0
|
||||
|
||||
|
||||
def _attachment_rule_count(value: Any) -> int:
|
||||
if not isinstance(value, dict):
|
||||
return 0
|
||||
configuration = value.get("configuration")
|
||||
global_rules = (
|
||||
configuration.get("global") if isinstance(configuration, dict) else []
|
||||
)
|
||||
return (len(global_rules) if isinstance(global_rules, list) else 0) + _entry_rule_count(
|
||||
value.get("entry_attachments")
|
||||
)
|
||||
|
||||
|
||||
def _entry_rule_count(value: Any) -> int:
|
||||
if not isinstance(value, list):
|
||||
return 0
|
||||
return sum(
|
||||
len(item.get("attachments") or [])
|
||||
for item in value
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
|
||||
|
||||
def _manifest_scope_count(value: Any) -> int:
|
||||
if not isinstance(value, dict):
|
||||
return 0
|
||||
if isinstance(value.get("jobs"), list):
|
||||
return len(value["jobs"])
|
||||
if "decision_count" in value:
|
||||
return int(value.get("decision_count") or 0)
|
||||
if "entries" in value:
|
||||
return _recipient_entry_count(value.get("entries"))
|
||||
return 1
|
||||
|
||||
|
||||
def _plan_item(
|
||||
scope: str, code: str, summary: str, item_count: int | None
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"scope": scope,
|
||||
"code": code,
|
||||
"summary": summary,
|
||||
"item_count": item_count,
|
||||
}
|
||||
|
||||
|
||||
def _optional_text(value: object) -> str | None:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _isoformat(value: datetime | None) -> str | None:
|
||||
return value.isoformat() if value is not None else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignImportInspection",
|
||||
"CampaignTransferError",
|
||||
"DEFAULT_PORTABLE_CAMPAIGN_SCOPES",
|
||||
"OPERATIONAL_EVIDENCE_SCOPES",
|
||||
"PORTABLE_CAMPAIGN_FORMAT",
|
||||
"PORTABLE_CAMPAIGN_FORMAT_VERSION",
|
||||
"PORTABLE_CAMPAIGN_SCOPE_ORDER",
|
||||
"build_campaign_portable_package",
|
||||
"canonical_sha256",
|
||||
"inspect_campaign_portable_package",
|
||||
"normalize_transfer_scopes",
|
||||
]
|
||||
@@ -2,16 +2,31 @@ from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
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 ..attachments.resolver import resolve_campaign_attachments
|
||||
from .models import (
|
||||
AttachmentConfig,
|
||||
CampaignConfig,
|
||||
DeliveryChannelPolicy,
|
||||
EntryConfig,
|
||||
FieldType,
|
||||
PostboxTargetConfig,
|
||||
SourceType,
|
||||
ZipArchiveConfig,
|
||||
ZipPasswordMode,
|
||||
ZipPasswordScope,
|
||||
ZipRuleMode,
|
||||
effective_delivery_channel_policy,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
@@ -90,6 +105,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 +354,150 @@ 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,
|
||||
templates_available: bool,
|
||||
calendar_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 +507,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 +530,163 @@ def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
"/server/mail_profile_id",
|
||||
)
|
||||
)
|
||||
issues.extend(
|
||||
_postbox_delivery_issues(
|
||||
config,
|
||||
postbox_available=postbox_available,
|
||||
)
|
||||
)
|
||||
if any(policy.uses_print for policy in policies):
|
||||
if not templates_available:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"templates_unavailable",
|
||||
"Printable Campaign delivery requires the optional Templates renderer.",
|
||||
"/delivery/print/template_id",
|
||||
)
|
||||
)
|
||||
if not config.delivery.print.template_id:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"print_template_missing",
|
||||
"Select a published compatible template for printable Campaign output.",
|
||||
"/delivery/print/template_id",
|
||||
)
|
||||
)
|
||||
elif config.delivery.print.template_revision is None:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"print_template_revision_missing",
|
||||
"Printable delivery must pin one published template revision.",
|
||||
"/delivery/print/template_revision",
|
||||
)
|
||||
)
|
||||
for entry_index, entry in enumerate(_active_delivery_entries(config)):
|
||||
if not effective_delivery_channel_policy(config, entry).uses_print:
|
||||
continue
|
||||
if entry.print_target is None:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"print_target_missing",
|
||||
"Printable delivery requires an explicit postal or internal-mail target.",
|
||||
f"/entries/inline/{entry_index}/print_target",
|
||||
)
|
||||
)
|
||||
invitation = config.delivery.calendar_invitation
|
||||
if invitation.enabled:
|
||||
if not calendar_available:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_unavailable",
|
||||
"Calendar invitations require the optional Calendar module.",
|
||||
"/delivery/calendar_invitation/enabled",
|
||||
)
|
||||
)
|
||||
if not policies or any(not policy.uses_mail for policy in policies):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_requires_mail",
|
||||
"Calendar invitations require Mail delivery for every active recipient.",
|
||||
"/delivery/channel_policy",
|
||||
)
|
||||
)
|
||||
if not invitation.calendar_id:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_calendar_missing",
|
||||
"Select a writable calendar for campaign invitation tracking.",
|
||||
"/delivery/calendar_invitation/calendar_id",
|
||||
)
|
||||
)
|
||||
if not invitation.start_at_template:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_start_missing",
|
||||
"Calendar invitations require a start date and time or a field template.",
|
||||
"/delivery/calendar_invitation/start_at_template",
|
||||
)
|
||||
)
|
||||
if invitation.timezone:
|
||||
try:
|
||||
ZoneInfo(invitation.timezone)
|
||||
except ZoneInfoNotFoundError:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_timezone_invalid",
|
||||
f"Unknown calendar invitation timezone: {invitation.timezone}",
|
||||
"/delivery/calendar_invitation/timezone",
|
||||
)
|
||||
)
|
||||
valid_start, fixed_start = _fixed_invitation_datetime(
|
||||
invitation.start_at_template
|
||||
)
|
||||
valid_end, fixed_end = _fixed_invitation_datetime(invitation.end_at_template)
|
||||
if invitation.start_at_template and not valid_start:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_start_invalid",
|
||||
"The fixed invitation start must be ISO 8601; recipient field templates are also supported.",
|
||||
"/delivery/calendar_invitation/start_at_template",
|
||||
)
|
||||
)
|
||||
if invitation.end_at_template and not valid_end:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_end_invalid",
|
||||
"The fixed invitation end must be ISO 8601; recipient field templates are also supported.",
|
||||
"/delivery/calendar_invitation/end_at_template",
|
||||
)
|
||||
)
|
||||
if fixed_start and fixed_end and _invitation_range_invalid(
|
||||
fixed_start,
|
||||
fixed_end,
|
||||
):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_range_invalid",
|
||||
"Calendar invitation end must be after its start.",
|
||||
"/delivery/calendar_invitation/end_at_template",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _fixed_invitation_datetime(value: str | None) -> tuple[bool, datetime | None]:
|
||||
if not value:
|
||||
return True, None
|
||||
if "${" in value or "{{" in value:
|
||||
return True, None
|
||||
try:
|
||||
return True, datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False, None
|
||||
|
||||
|
||||
def _invitation_range_invalid(start_at: datetime, end_at: datetime) -> bool:
|
||||
if (start_at.tzinfo is None) != (end_at.tzinfo is None):
|
||||
return True
|
||||
return end_at <= start_at
|
||||
|
||||
|
||||
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 +697,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 []
|
||||
@@ -560,6 +876,10 @@ def _attachment_file_check_issues(config: CampaignConfig, campaign_path: Path) -
|
||||
|
||||
|
||||
def _attachment_resolution_check_issues(config: CampaignConfig, campaign_path: Path) -> list[SemanticIssue]:
|
||||
# The resolver consumes campaign models/entries. Import it only when file
|
||||
# validation is requested so either public entry point can initialize first.
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
try:
|
||||
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
||||
except Exception as exc:
|
||||
@@ -611,6 +931,9 @@ def validate_campaign_config(
|
||||
*,
|
||||
campaign_file: str | Path | None = None,
|
||||
check_files: bool = False,
|
||||
postbox_available: bool = False,
|
||||
templates_available: bool = False,
|
||||
calendar_available: bool = False,
|
||||
) -> SemanticReport:
|
||||
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
|
||||
issues: list[SemanticIssue] = []
|
||||
@@ -622,7 +945,14 @@ 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,
|
||||
templates_available=templates_available,
|
||||
calendar_available=calendar_available,
|
||||
)
|
||||
)
|
||||
issues.extend(_sender_issues(config))
|
||||
|
||||
entries = _entries_validation(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,8 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
SendAttempt,
|
||||
new_uuid,
|
||||
)
|
||||
@@ -55,7 +57,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, PrintOutputAttempt),
|
||||
):
|
||||
_record_attempt_change(session, obj)
|
||||
|
||||
|
||||
@@ -182,8 +187,13 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"delivery_channel_policy",
|
||||
"postbox_status",
|
||||
"print_status",
|
||||
"imap_status",
|
||||
"attempt_count",
|
||||
"postbox_attempt_count",
|
||||
"print_attempt_count",
|
||||
"last_error",
|
||||
"queued_at",
|
||||
"claimed_at",
|
||||
@@ -191,6 +201,9 @@ def _record_job_change(session: OrmSession, job: CampaignJob) -> None:
|
||||
"outcome_unknown_at",
|
||||
"sent_at",
|
||||
"resolved_recipients",
|
||||
"delivery_provenance",
|
||||
"resolved_postbox_targets",
|
||||
"resolved_print_output",
|
||||
"resolved_attachments",
|
||||
"issues_snapshot",
|
||||
),
|
||||
@@ -217,6 +230,9 @@ 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,
|
||||
"print_status": job.print_status,
|
||||
"imap_status": job.imap_status,
|
||||
},
|
||||
)
|
||||
@@ -247,10 +263,27 @@ 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 | PrintOutputAttempt,
|
||||
) -> 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",
|
||||
"render_id",
|
||||
"artifact_sha256",
|
||||
"evidence",
|
||||
),
|
||||
)
|
||||
if operation is None:
|
||||
return
|
||||
@@ -270,7 +303,15 @@ 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 "print"
|
||||
if isinstance(attempt, PrintOutputAttempt)
|
||||
else "imap"
|
||||
if isinstance(attempt, ImapAppendAttempt)
|
||||
else "smtp"
|
||||
),
|
||||
"job_id": job.id,
|
||||
"version_id": job.campaign_version_id,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
@@ -83,6 +84,10 @@ class JobSendStatus(StrEnum):
|
||||
CLAIMED = "claimed"
|
||||
SENDING = "sending"
|
||||
SMTP_ACCEPTED = "smtp_accepted"
|
||||
POSTBOX_ACCEPTED = "postbox_accepted"
|
||||
PRINT_ACCEPTED = "print_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 +95,28 @@ 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 JobPrintStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
READY = "ready"
|
||||
ACCEPTING = "accepting"
|
||||
ACCEPTED = "accepted"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class JobImapStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
@@ -141,6 +168,282 @@ class CampaignShare(Base, TimestampMixin):
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
class CampaignCollaborationEntry(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_collaboration_entries"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_collaboration_entries_thread",
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
visibility: Mapped[str] = mapped_column(
|
||||
String(30),
|
||||
default="collaborators",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
mention_user_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
|
||||
withdrawn_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
withdrawn_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
redacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
redacted_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
tombstone_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class CampaignWorkAssignment(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_work_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"orchestration_idempotency_key",
|
||||
name="uq_campaign_work_assignment_orchestration_key",
|
||||
),
|
||||
Index(
|
||||
"ix_campaign_work_assignments_campaign_status",
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"status",
|
||||
"due_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
reference_kind: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
||||
reference_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
reference_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
purpose: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="open", nullable=False, index=True)
|
||||
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
assignee_type: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
assignee_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
assignee_current_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
assignee_resolution_state: Mapped[str] = mapped_column(
|
||||
String(30), default="resolved", nullable=False, index=True
|
||||
)
|
||||
resolution_provenance: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
resolution_checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
assigned_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
assigned_by_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
task_mirror_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
task_mirror_status: Mapped[str] = mapped_column(
|
||||
String(30), default="not_configured", nullable=False
|
||||
)
|
||||
task_mirror_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
task_mirrored_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
orchestration_idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
orchestration_request_sha256: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True
|
||||
)
|
||||
orchestration_correlation_id: Mapped[str | None] = mapped_column(
|
||||
String(128), nullable=True, index=True
|
||||
)
|
||||
workflow_instance_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
workflow_step_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
|
||||
|
||||
class CampaignWorkAssignmentEvent(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_work_assignment_events"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"ix_campaign_work_assignment_events_history",
|
||||
"tenant_id",
|
||||
"assignment_id",
|
||||
"created_at",
|
||||
"id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
assignment_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_work_assignments.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
event_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
actor_label_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
assignee_type_snapshot: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
assignee_id_snapshot: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
assignee_label_snapshot: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
resolution_state_snapshot: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
details: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class CampaignSchedule(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_schedules"
|
||||
__table_args__ = (
|
||||
Index("ix_campaign_schedules_due", "tenant_id", "active", "next_fire_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
source_version_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
delivery_mode: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="manual",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
recurrence_kind: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="once",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
interval_count: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
timezone: Mapped[str] = mapped_column(String(100), default="UTC", nullable=False)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
next_fire_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
max_occurrences: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
occurrence_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
copy_options: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
source_snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
source_snapshot_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
approved_execution_snapshot_hash: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True
|
||||
)
|
||||
source_base_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||||
last_fired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_campaign_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_outcome: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
last_recovery_state: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class CampaignScheduleOccurrence(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_schedule_occurrences"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"schedule_id",
|
||||
"scheduled_for",
|
||||
name="uq_campaign_schedule_occurrence",
|
||||
),
|
||||
Index("ix_campaign_schedule_occurrences_schedule", "schedule_id", "scheduled_for"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
schedule_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_schedules.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(30), default="preparing", nullable=False, index=True)
|
||||
idempotency_key: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, index=True
|
||||
)
|
||||
generated_campaign_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
generated_version_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
delivery_command_ids: Mapped[list[str]] = mapped_column(
|
||||
JSON, default=list, nullable=False
|
||||
)
|
||||
recovery_state: Mapped[str] = mapped_column(
|
||||
String(30), default="none", nullable=False, index=True
|
||||
)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
last_checked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class RecipientImportMappingProfile(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_recipient_import_mapping_profiles"
|
||||
__table_args__ = (
|
||||
@@ -172,6 +475,11 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
edit_revision: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=1,
|
||||
nullable=False,
|
||||
)
|
||||
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
|
||||
source_filename: Mapped[str | None] = mapped_column(String(500))
|
||||
@@ -215,9 +523,37 @@ class CampaignVersion(Base, TimestampMixin):
|
||||
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
delivery_mode: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
archived_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
archived_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
campaign: Mapped[Campaign] = relationship(back_populates="versions")
|
||||
|
||||
__mapper_args__ = {
|
||||
"version_id_col": edit_revision,
|
||||
}
|
||||
|
||||
@property
|
||||
def review_build_token(self) -> str | None:
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_review_reference
|
||||
summary = self.build_summary if isinstance(self.build_summary, dict) else {}
|
||||
return campaign_review_reference(self.id, summary.get("build_token") or summary.get("built_at"))
|
||||
|
||||
@property
|
||||
def strong_etag(self) -> str:
|
||||
return strong_resource_etag(
|
||||
"campaign_version",
|
||||
self.id,
|
||||
self.edit_revision,
|
||||
)
|
||||
|
||||
@property
|
||||
def mail_profile_migration_required(self) -> bool:
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_boundary_violations
|
||||
@@ -249,9 +585,37 @@ 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,
|
||||
)
|
||||
print_status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=JobPrintStatus.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,
|
||||
)
|
||||
print_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 +627,20 @@ 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)
|
||||
delivery_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
resolved_postbox_targets: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
default=list,
|
||||
nullable=False,
|
||||
)
|
||||
resolved_print_output: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
)
|
||||
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||
|
||||
@@ -327,6 +705,139 @@ class SendAttempt(Base, TimestampMixin):
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class CampaignMessageAction(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_message_actions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_campaign_message_actions_idempotency",
|
||||
),
|
||||
Index(
|
||||
"ix_campaign_message_actions_job_created",
|
||||
"job_id",
|
||||
"created_at",
|
||||
),
|
||||
Index(
|
||||
"ix_campaign_message_actions_campaign_kind",
|
||||
"campaign_id",
|
||||
"kind",
|
||||
"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)
|
||||
campaign_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaigns.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
campaign_version_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_versions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
job_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_jobs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
|
||||
idempotency_key: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
canonical_request_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
context: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("access_users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
actor_api_key_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
message_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
message_size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
recipient_manifest_sha256: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
nullable=False,
|
||||
)
|
||||
recipient_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
prior_send_status: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
prior_attempt_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
final_send_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="initiated",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
accepted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
refused_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
refusal_summary: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
error_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
linked_send_attempt_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("send_attempts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
effect_started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
class CampaignMessageActionAttempt(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_message_action_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"action_id",
|
||||
"attempt_number",
|
||||
name="uq_campaign_message_action_attempt_number",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
action_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("campaign_message_actions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default="initiated",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
effect_started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
accepted_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
refused_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
outcome_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
diagnostic_summary: Mapped[str | None] = mapped_column(
|
||||
String(500),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
class ImapAppendAttempt(Base, TimestampMixin):
|
||||
__tablename__ = "imap_append_attempts"
|
||||
__table_args__ = (
|
||||
@@ -342,6 +853,112 @@ 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),
|
||||
)
|
||||
|
||||
|
||||
class PrintOutputAttempt(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_print_output_attempts"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"job_id",
|
||||
"attempt_number",
|
||||
name="uq_campaign_print_attempt_job_number",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_campaign_print_attempt_idempotency",
|
||||
),
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
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)
|
||||
render_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
||||
artifact_sha256: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
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__ = [
|
||||
@@ -355,12 +972,18 @@ __all__ = [
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"CampaignWorkAssignment",
|
||||
"CampaignWorkAssignmentEvent",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
"JobImapStatus",
|
||||
"JobPostboxStatus",
|
||||
"JobPrintStatus",
|
||||
"JobQueueStatus",
|
||||
"JobSendStatus",
|
||||
"JobValidationStatus",
|
||||
"SendAttempt",
|
||||
"PostboxDeliveryAttempt",
|
||||
"PrintOutputAttempt",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,8 @@ from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
|
||||
@@ -26,6 +28,8 @@ class SynchronousSendPolicy:
|
||||
source: str
|
||||
deployment_max_recipient_jobs: int
|
||||
tenant_max_recipient_jobs: int | None = None
|
||||
system_max_recipient_jobs: int | None = None
|
||||
deployment_ceiling_explicit: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -33,6 +37,9 @@ class SynchronousSendPolicy:
|
||||
"source": self.source,
|
||||
"deployment_max_recipient_jobs": self.deployment_max_recipient_jobs,
|
||||
"tenant_max_recipient_jobs": self.tenant_max_recipient_jobs,
|
||||
"system_max_recipient_jobs": self.system_max_recipient_jobs,
|
||||
"deployment_ceiling_explicit": self.deployment_ceiling_explicit,
|
||||
"system_setting": f"system.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}.{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}",
|
||||
"deployment_setting": SYNCHRONOUS_SEND_MAX_ENV,
|
||||
"tenant_setting": (
|
||||
f"tenant.settings.{CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY}."
|
||||
@@ -46,20 +53,36 @@ def effective_synchronous_send_policy(
|
||||
*,
|
||||
tenant_id: str,
|
||||
environ: Mapping[str, str] | None = None,
|
||||
apply_tenant_override: bool = True,
|
||||
) -> SynchronousSendPolicy:
|
||||
env = os.environ if environ is None else environ
|
||||
deployment_raw = env.get(SYNCHRONOUS_SEND_MAX_ENV)
|
||||
deployment_explicit = deployment_raw is not None and (not isinstance(deployment_raw, str) or bool(deployment_raw.strip()))
|
||||
deployment_value = _configured_limit(
|
||||
env.get(SYNCHRONOUS_SEND_MAX_ENV),
|
||||
source=SYNCHRONOUS_SEND_MAX_ENV,
|
||||
default=DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
default=ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
system = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||
system_raw = _tenant_limit_value(system.settings if system is not None else None)
|
||||
system_value = _configured_limit(system_raw, source="system campaign delivery policy") if system_raw is not None else None
|
||||
# Preserve explicit deployment configuration, but an implicit default is not
|
||||
# an administrator ceiling. No override still retains the conservative 25.
|
||||
inherited = min(deployment_value, system_value) if system_value is not None else (
|
||||
deployment_value if deployment_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS
|
||||
)
|
||||
inherited_source = ("system" if system_value <= deployment_value else "deployment_ceiling") if system_value is not None else (
|
||||
"deployment" if deployment_explicit else "deployment_default"
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id) if apply_tenant_override else None
|
||||
tenant_raw = _tenant_limit_value(tenant.settings if tenant is not None else None)
|
||||
if tenant_raw is None:
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=deployment_value,
|
||||
source=("deployment" if env.get(SYNCHRONOUS_SEND_MAX_ENV) not in (None, "") else "deployment_default"),
|
||||
max_recipient_jobs=inherited,
|
||||
source=inherited_source,
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
system_max_recipient_jobs=system_value,
|
||||
deployment_ceiling_explicit=deployment_explicit,
|
||||
)
|
||||
|
||||
tenant_value = _configured_limit(
|
||||
@@ -69,12 +92,14 @@ def effective_synchronous_send_policy(
|
||||
f"{SYNCHRONOUS_SEND_MAX_SETTINGS_KEY}"
|
||||
),
|
||||
)
|
||||
effective_value = min(deployment_value, tenant_value)
|
||||
effective_value = min(inherited, tenant_value)
|
||||
return SynchronousSendPolicy(
|
||||
max_recipient_jobs=effective_value,
|
||||
source="tenant" if tenant_value <= deployment_value else "deployment_ceiling",
|
||||
source="tenant" if tenant_value <= inherited else ("system_ceiling" if inherited_source == "system" else "deployment_ceiling"),
|
||||
deployment_max_recipient_jobs=deployment_value,
|
||||
tenant_max_recipient_jobs=tenant_value,
|
||||
system_max_recipient_jobs=system_value,
|
||||
deployment_ceiling_explicit=deployment_explicit,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,16 +3,19 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesParser
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
|
||||
from govoplan_campaign.backend.campaign.validation import SemanticReport, validate_campaign_config
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
||||
from govoplan_campaign.backend.messages.builder import BuiltMessage, CampaignBuildResult, build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import CampaignBuildReport, MessageAddress, MessageAttachmentSummary, MessageDraft, MessageValidationStatus
|
||||
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
|
||||
@@ -151,6 +154,7 @@ def _mock_send_batch(
|
||||
include_warnings: bool,
|
||||
include_needs_review: bool,
|
||||
append_sent: bool,
|
||||
reviewed_keys: set[str] | None = None,
|
||||
) -> _MockSendBatch:
|
||||
batch = _MockSendBatch(results=[])
|
||||
for built in built_messages:
|
||||
@@ -160,7 +164,7 @@ def _mock_send_batch(
|
||||
mailbox=mailbox,
|
||||
send=send,
|
||||
include_warnings=include_warnings,
|
||||
include_needs_review=include_needs_review,
|
||||
include_needs_review=include_needs_review or str(built.draft.entry_id or built.draft.entry_index) in (reviewed_keys or set()),
|
||||
append_sent=append_sent,
|
||||
)
|
||||
batch.results.append(outcome.row)
|
||||
@@ -391,6 +395,96 @@ def _build_mock_campaign_run(
|
||||
return validation_report, build_result, send_batch
|
||||
|
||||
|
||||
def _build_reviewed_mock_run(
|
||||
session: Session, *, tenant_id: str, campaign: Campaign, version: CampaignVersion,
|
||||
mailbox: Any | None, send: bool, include_warnings: bool, append_sent: bool,
|
||||
clear_mailbox: bool = False,
|
||||
) -> tuple[Any, Any, _MockSendBatch]:
|
||||
"""Mock the sealed EML, never approve a new transient rendering by entry ID."""
|
||||
from govoplan_campaign.backend.persistence.versions import _complete_campaign_review
|
||||
from govoplan_campaign.backend.sending.execution import ensure_execution_snapshot, profile_delivery_summary
|
||||
from govoplan_campaign.backend.sending.jobs import _load_eml_bytes_for_job
|
||||
|
||||
if not isinstance(version.execution_snapshot, dict) or not version.execution_snapshot_hash:
|
||||
raise MockCampaignSendError("Build the campaign with frozen execution evidence before testing reviewed messages.")
|
||||
# Do not invoke the legacy snapshot-creation fallback: this test must not
|
||||
# alter Campaign state or create approval evidence as a side effect.
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
build = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
token = str(build.get("build_token") or build.get("built_at") or "")
|
||||
if not token or token != str(snapshot.build_token or snapshot.built_at or ""):
|
||||
raise MockCampaignSendError("The frozen execution no longer matches the current message build. Rebuild and review again.")
|
||||
jobs = session.query(CampaignJob).filter(
|
||||
CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id,
|
||||
).order_by(CampaignJob.entry_index.asc()).all()
|
||||
if not jobs:
|
||||
raise MockCampaignSendError("The reviewed build contains no messages.")
|
||||
state = (version.editor_state or {}).get("review_send", {})
|
||||
complete = isinstance(state, dict) and state.get("inspection_complete") is True and state.get("build_token") == token
|
||||
if any(job.validation_status in {"needs_review", "warning"} for job in jobs) and not complete:
|
||||
raise MockCampaignSendError("Complete review for the exact current build before testing accepted exceptions.")
|
||||
reviewed_keys = list(state.get("reviewed_message_keys", [])) if complete else []
|
||||
decisions = list(state.get("issue_decisions", [])) if complete else []
|
||||
_, normalized = _complete_campaign_review(session, version, reviewed_keys, decisions, user_id=None, build_token=token)
|
||||
by_id = {item.get("job_id"): item for item in decisions}
|
||||
for item in normalized:
|
||||
prior = by_id.get(item["job_id"], {})
|
||||
if any(prior.get(key) != item.get(key) for key in ("build_token", "message_sha256", "issue_fingerprint")):
|
||||
raise MockCampaignSendError("Review evidence no longer matches the frozen message issues. Rebuild and review again.")
|
||||
if snapshot.uses_mail:
|
||||
current = profile_delivery_summary(session, version)
|
||||
if current.get("smtp_transport_revision") != snapshot.smtp_transport_revision or (
|
||||
append_sent and current.get("imap_transport_revision") != snapshot.imap_transport_revision
|
||||
):
|
||||
raise MockCampaignSendError("The selected Mail transport changed after build. Rebuild and review before testing these messages.")
|
||||
|
||||
built_messages = []
|
||||
for job in jobs:
|
||||
recipients = job.resolved_recipients or {}
|
||||
attachments = [MessageAttachmentSummary.model_validate({
|
||||
"status": "missing", "required": False, "allow_multiple": False, "zip_enabled": False,
|
||||
"file_filter": "", "directory": "",
|
||||
**{key: value for key, value in item.items() if key in MessageAttachmentSummary.model_fields},
|
||||
}) for item in (job.resolved_attachments or []) if isinstance(item, dict)]
|
||||
inactive = job.validation_status == "inactive"
|
||||
excluded = job.validation_status == "excluded" or inactive
|
||||
draft = MessageDraft(
|
||||
entry_index=job.entry_index, entry_id=job.entry_id, active=not inactive,
|
||||
build_status="built" if job.build_status == "built" else "build_failed",
|
||||
validation_status=job.validation_status, send_status="skipped" if excluded else "draft",
|
||||
imap_status="skipped" if excluded else "not_requested", subject=job.subject,
|
||||
delivery_channel_policy=job.delivery_channel_policy,
|
||||
**{key: recipients.get(key) for key in ("from",) if recipients.get(key)},
|
||||
**{key: recipients.get(key) or [] for key in ("from_all", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to")},
|
||||
issues=job.issues_snapshot or [], attachments=attachments,
|
||||
attachment_count=sum(len(item.managed_matches or item.matches) for item in attachments),
|
||||
eml_size_bytes=job.eml_size_bytes,
|
||||
)
|
||||
mime = None
|
||||
if not excluded and DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail:
|
||||
if not job.eml_sha256:
|
||||
raise MockCampaignSendError("Frozen EML checksum is missing; rebuild before testing reviewed messages.")
|
||||
# The shared reader checks byte length, digest and Message-ID before
|
||||
# any mock capture occurs. All messages preflight before the batch.
|
||||
try:
|
||||
mime = BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job))
|
||||
except Exception as exc:
|
||||
raise MockCampaignSendError("Frozen message bytes are unavailable or no longer match their integrity evidence. Rebuild and review before testing.") from exc
|
||||
built_messages.append(BuiltMessage(draft=draft, mime=mime))
|
||||
report = CampaignBuildReport(campaign_id=campaign.external_id, campaign_name=campaign.name,
|
||||
campaign_file="", entries_count=len(jobs), messages=[item.draft for item in built_messages])
|
||||
validation = SemanticReport(campaign_id=campaign.external_id, campaign_name=campaign.name,
|
||||
entries_mode="frozen_build", entries_count=len(jobs), attachments_base_path="", rate_limit="frozen",
|
||||
imap_append_enabled=snapshot.delivery.imap_append_sent.enabled)
|
||||
config = SimpleNamespace(delivery=snapshot.delivery, server=SimpleNamespace(imap=None))
|
||||
if clear_mailbox and mailbox is not None:
|
||||
mailbox.clear_records()
|
||||
batch = _mock_send_batch(config=config, built_messages=built_messages, mailbox=mailbox, send=send,
|
||||
include_warnings=include_warnings, include_needs_review=False, append_sent=append_sent,
|
||||
reviewed_keys=set(reviewed_keys))
|
||||
return validation, CampaignBuildResult(report=report, built_messages=built_messages), batch
|
||||
|
||||
|
||||
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
|
||||
payload = validation_report.model_dump(mode="json")
|
||||
payload.update(
|
||||
@@ -542,6 +636,7 @@ def run_mock_campaign_send(
|
||||
append_sent: bool = True,
|
||||
clear_mailbox: bool = False,
|
||||
check_files: bool = False,
|
||||
use_reviewed_build: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate, build and optionally mock-send a version without mutating it.
|
||||
|
||||
@@ -557,22 +652,29 @@ def run_mock_campaign_send(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
|
||||
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
mailbox=mailbox,
|
||||
send=send,
|
||||
include_warnings=include_warnings,
|
||||
include_needs_review=include_needs_review,
|
||||
append_sent=append_sent,
|
||||
check_files=check_files,
|
||||
)
|
||||
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox and not use_reviewed_build)
|
||||
if use_reviewed_build:
|
||||
validation_report, build_result, send_batch = _build_reviewed_mock_run(
|
||||
session, tenant_id=tenant_id, campaign=campaign, version=version,
|
||||
mailbox=mailbox, send=send, include_warnings=include_warnings, append_sent=append_sent,
|
||||
clear_mailbox=clear_mailbox,
|
||||
)
|
||||
else:
|
||||
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
mailbox=mailbox,
|
||||
send=send,
|
||||
include_warnings=include_warnings,
|
||||
include_needs_review=include_needs_review,
|
||||
append_sent=append_sent,
|
||||
check_files=check_files,
|
||||
)
|
||||
validation_payload = _mock_validation_payload(validation_report)
|
||||
build_payload = _mock_build_payload(build_result)
|
||||
return _mock_campaign_send_response(
|
||||
result = _mock_campaign_send_response(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
mailbox=mailbox,
|
||||
@@ -586,3 +688,9 @@ def run_mock_campaign_send(
|
||||
include_needs_review=include_needs_review,
|
||||
append_sent=append_sent,
|
||||
)
|
||||
result["use_reviewed_build"] = use_reviewed_build
|
||||
if use_reviewed_build:
|
||||
result["steps"][0].update(label="Verify frozen execution inputs", status="ok")
|
||||
result["steps"][1].update(label="Use reviewed frozen messages", status="ok")
|
||||
result["build"]["review_satisfied"] = True
|
||||
return result
|
||||
|
||||
@@ -6,6 +6,9 @@ from govoplan_campaign.backend.delivery_policy import (
|
||||
CampaignDeliveryPolicyError,
|
||||
effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.german_documentation import (
|
||||
localize_documentation_topics,
|
||||
)
|
||||
|
||||
|
||||
_CAMPAIGN_USER_SCOPES = (
|
||||
@@ -13,6 +16,8 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:export",
|
||||
"campaigns:campaign:import",
|
||||
"campaigns:campaign:archive",
|
||||
"campaigns:campaign:delete",
|
||||
"campaigns:campaign:share",
|
||||
@@ -25,6 +30,12 @@ _CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:discussion:read",
|
||||
"campaigns:discussion:post",
|
||||
"campaigns:discussion:moderate",
|
||||
"campaigns:assignment:read",
|
||||
"campaigns:assignment:manage",
|
||||
"campaigns:assignment:complete",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
@@ -43,7 +54,16 @@ _FILES_INTEGRATION = "files.campaign_attachments"
|
||||
_MAIL_INTEGRATION = "mail.campaign_delivery"
|
||||
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
|
||||
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||
_DISTRIBUTION_LIST_SOURCE_INTEGRATION = "dist_lists.source"
|
||||
_DISTRIBUTION_LIST_EXPAND_INTEGRATION = "dist_lists.expand"
|
||||
_TEMPLATE_CATALOG_INTEGRATION = "templates.catalog"
|
||||
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION = "templates.content_library"
|
||||
_TEMPLATE_RENDERER_INTEGRATION = "templates.renderer"
|
||||
_CALENDAR_INVITATION_INTEGRATION = "calendar.invitations"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
_TASKS_INTEGRATION = "tasks.commands"
|
||||
_ORGANIZATIONS_INTEGRATION = "organizations.directory"
|
||||
_IDM_FUNCTION_ASSIGNMENTS_INTEGRATION = "idm.function_assignments"
|
||||
|
||||
|
||||
def _workflow_topic(
|
||||
@@ -68,6 +88,7 @@ def _workflow_topic(
|
||||
links: tuple[DocumentationLink, ...] = (DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||
related_modules: tuple[str, ...] = (),
|
||||
limitations: tuple[str, ...] = (),
|
||||
translations: dict[str, dict[str, str]] | None = None,
|
||||
) -> DocumentationTopic:
|
||||
metadata: dict[str, object] = {
|
||||
"kind": "workflow",
|
||||
@@ -101,17 +122,18 @@ def _workflow_topic(
|
||||
links=links,
|
||||
related_modules=related_modules,
|
||||
unlocks=(outcome,),
|
||||
translations=translations or {},
|
||||
source_module_id="campaigns",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_USER_DOCUMENTATION = (
|
||||
CAMPAIGN_USER_DOCUMENTATION = localize_documentation_topics((
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.create-campaign",
|
||||
title="Create a campaign",
|
||||
summary="Start a governed campaign as an editable draft and complete its purpose and ownership before adding delivery data.",
|
||||
body="A new campaign starts with one editable working version. Creating it does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized.",
|
||||
body="A new campaign starts with one editable working version. Campaign editors report saved, unsaved, and saving state in the page action bar; Discard remains immediately before Save, and leaving a dirty draft invokes the shared save-or-discard guard. Destructive campaign lifecycle actions are visually separated from ordinary actions. Creating a campaign does not grant access to Mail profiles, managed files, address sources, or delivery actions; those remain separately authorized. Saved changes are acknowledged separately from the follow-up refresh. A failed write or cancelled conflict keeps the draft; repeated Save clicks share one pending operation. Typing during a save keeps newer work unsaved instead of overwriting it with the older acknowledgement. A failed or superseded Discard refresh retains the draft. Unchanged ZIP configuration does not disable saving unrelated attachment source or rule edits. Shared attachment Add actions remain compact. If the Files chooser is temporarily unavailable, the editor explains this instead of silently treating a managed path as manual text; missing rule sources must be selected again. Keyboard Enter or Space opens chooser-backed path fields.",
|
||||
order=30,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||
@@ -123,7 +145,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
"Open Campaigns and select New campaign.",
|
||||
"Use the creation wizard to enter a clear name, identifier, and purpose.",
|
||||
"Open the new campaign and confirm its owner before adding recipient or delivery data.",
|
||||
"Continue through the preparation sections and save the editable working version.",
|
||||
"Continue through the preparation sections; use the stable Discard and Save actions while the bar reports the draft state.",
|
||||
),
|
||||
outcome="An owned campaign draft with an editable working version.",
|
||||
verification="The Campaign overview shows the new campaign as a draft and identifies its current working version.",
|
||||
@@ -154,6 +176,241 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
verification="The Campaign overview identifies a new current version number and the earlier version remains in history.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.mail-profile-user-journey"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.copy-campaign",
|
||||
title="Copy a campaign into a new draft",
|
||||
summary="Reuse a selected campaign version as configuration for a new campaign without copying operational or audit evidence.",
|
||||
body="Copy campaign is different from creating an editable successor. It creates a separately owned campaign with a chosen or generated identifier and one editable version. Recipients, attachment rules, active shares, campaign policies, and the Mail profile reference are explicit independent choices. Delivery jobs, outcomes, locks, reports, and audit evidence always stay exclusively with the source campaign.",
|
||||
order=32,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview",
|
||||
help_contexts=("campaign.overview",),
|
||||
prerequisites=(
|
||||
"You may read the selected campaign. Copying recipient data additionally requires recipient-read authority.",
|
||||
"You may create campaign copies in the active tenant.",
|
||||
),
|
||||
steps=(
|
||||
"Open the campaign overview and choose the current or a historical source version.",
|
||||
"Choose Copy campaign or Copy as new campaign, enter the new identity, and select recipients, files, shares, policies, and Mail profile independently.",
|
||||
"If a choice is unavailable, obtain the corresponding recipient-read or campaign-share authority or leave that content excluded.",
|
||||
"Confirm while the lifecycle state token is current; reload if another actor changed the source state.",
|
||||
"Open the newly created campaign, review its generated identifier and ownership, and validate all inherited configuration before use.",
|
||||
),
|
||||
outcome="A new editable campaign draft containing only the explicitly selected configuration and no copied operational evidence.",
|
||||
verification="The destination has a distinct campaign ID and owner, one editable version, the selected configuration domains, and no source jobs, outcomes, locks, reports, or audit evidence.",
|
||||
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagne als neuen Entwurf kopieren",
|
||||
"summary": "Eine ausgewählte Kampagnenversion als Konfiguration wiederverwenden, ohne Betriebs- oder Auditnachweise zu kopieren.",
|
||||
"body": "Kampagne kopieren erzeugt eine eigenständige Kampagne mit eigener Kennung und einer bearbeitbaren Version. Empfänger, Dateiregeln, aktive Freigaben, Kampagnenrichtlinien und die Mailprofil-Referenz werden unabhängig ausgewählt. Sendeaufträge, Ergebnisse, Sperren, Berichte und Auditnachweise verbleiben immer ausschließlich bei der Quellkampagne.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.transfer-campaign-package",
|
||||
title="Export and import a portable Campaign package",
|
||||
summary="Move selected Campaign configuration into a separately owned draft with an integrity check, compatibility preview, and explicit privacy scopes.",
|
||||
body="Portable Campaign packages are versioned JSON envelopes. Export defaults to metadata plus template/configuration and excludes recipients, attachments, review state, and delivery history until they are explicitly selected. Recipient and delivery scopes require their existing fine-grained export permissions. Transport secrets, credential references, password-field values, local storage paths, and attachment bytes are not exported. Import verifies the SHA-256 package integrity, previews every scope that will be created or skipped, and always creates a new editable draft. Deployment-bound Mail references must be selected locally. Review, approval, and delivery evidence remains historical package provenance and is never replayed as live state.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_configurator", "campaign_migration_operator"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:export"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview and Campaign list",
|
||||
help_contexts=("campaign.overview", "campaigns.action.export-package", "campaigns.action.import-package"),
|
||||
prerequisites=(
|
||||
"You may export the source Campaign; importing additionally requires Campaign create and portable-import authority.",
|
||||
"Recipient and delivery scopes have an approved purpose and destination and the corresponding recipient/report export permissions.",
|
||||
),
|
||||
steps=(
|
||||
"Open the source Campaign overview, select Export package, and keep the privacy-safe metadata plus template/configuration default unless more data is necessary.",
|
||||
"Select any additional recipient, attachment, review, or delivery scopes explicitly and download the integrity-protected JSON package to an approved location.",
|
||||
"On the destination Campaign list select Import package, choose the file, and review compatibility, redactions, destination identity, created scopes, and skipped evidence.",
|
||||
"Change the destination identity or selected scopes as needed, refresh the preview, and create the draft only when the preview is current and compatible.",
|
||||
"Open the draft, reconnect local Mail and file resources, validate recipients and attachments, and complete ordinary review before any delivery.",
|
||||
),
|
||||
outcome="A separately owned Campaign draft containing only the selected portable configuration, with source/package provenance and no replayed operational state.",
|
||||
verification="The destination is a new draft with a distinct ID; its settings retain the package ID, SHA-256, source and created/skipped receipt, while Audit records the matching export/import hashes without storing package content.",
|
||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.export-delivery-report"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Portables Campaign-Paket exportieren und importieren",
|
||||
"summary": "Ausgewaehlte Campaign-Konfiguration mit Integritaetspruefung, Kompatibilitaetsvorschau und expliziten Datenschutzumfaengen in einen eigenstaendigen Entwurf uebernehmen.",
|
||||
"body": "Portable Campaign-Pakete sind versionierte JSON-Umschlaege. Der Export umfasst standardmaessig nur Metadaten sowie Vorlage und Konfiguration. Empfaenger, Anlagen, Pruefstatus und Zustellhistorie werden erst nach expliziter Auswahl aufgenommen und bleiben getrennt berechtigt. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, lokale Speicherpfade und Dateiinhalte werden nicht exportiert. Der Import prueft die SHA-256-Integritaet, zeigt alle erzeugten und uebersprungenen Umfaenge und erstellt immer einen neuen bearbeitbaren Entwurf. Mail-Verweise muessen lokal neu gewaehlt werden; historische Pruef-, Freigabe- und Zustellnachweise werden nie als aktiver Zustand wiedergegeben.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.collaborate-on-campaign",
|
||||
title="Discuss campaign work without changing its evidence",
|
||||
summary="Use the governed collaboration thread for human discussion linked to stable Campaign evidence.",
|
||||
body="Collaboration is governed independently from Campaign editing. A discussion reader still needs access to the parent Campaign; posting and moderation use separate permissions. Posted text is append-only. Authors can withdraw their own entry and moderators can redact an entry, but both actions leave the actor, timestamp, reference, evidence hash, tombstone, and audit record. A comment may reference a Campaign version, recipient import batch, attachment rule, delivery job, or report. The reference never edits the historical version. Mentions create an in-app notification only when Notifications is available and only for active users who already have Campaign access. Human discussion is not system state and never replaces Audit evidence.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:discussion:read"),
|
||||
route="/campaigns/{campaign_id}/activity",
|
||||
screen="Campaign collaboration",
|
||||
help_contexts=(
|
||||
"campaign.activity",
|
||||
"campaign.activity.composer",
|
||||
"campaign.activity.action.post",
|
||||
"campaign.activity.action.withdraw",
|
||||
"campaign.activity.action.redact",
|
||||
),
|
||||
prerequisites=(
|
||||
"You can read the Campaign and its discussion.",
|
||||
"Posting requires the separate campaign discussion-post permission.",
|
||||
),
|
||||
steps=(
|
||||
"Open Collaboration in the selected Campaign workspace.",
|
||||
"Optionally select a stable version or enter the stable ID of another supported Campaign evidence reference.",
|
||||
"Mention only collaborators who already have access, then post the bounded comment.",
|
||||
"Withdraw your own mistaken entry or ask an authorized moderator to redact content that must no longer be displayed.",
|
||||
"Use Tenant audit for system events and durable action evidence; do not treat discussion as workflow state.",
|
||||
),
|
||||
outcome="An attributable human discussion entry that does not mutate Campaign versions or impersonate audit evidence.",
|
||||
verification="Reload Collaboration, follow the typed reference, and confirm any withdrawal or redaction appears as a tombstone while the referenced version remains unchanged.",
|
||||
required_capabilities=(),
|
||||
related_modules=("notifications", "audit"),
|
||||
limitations=(
|
||||
"Notifications is optional; the discussion remains available when mention delivery is not configured.",
|
||||
"Comments do not approve, validate, build, queue, send, or otherwise transition a Campaign.",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagnenarbeit besprechen, ohne Nachweise zu verändern",
|
||||
"summary": "Den geregelten Diskussionsverlauf für menschliche Abstimmung mit stabilen Kampagnennachweisen verwenden.",
|
||||
"body": "Die Zusammenarbeit wird unabhängig von der Kampagnenbearbeitung berechtigt. Lesende benötigen weiterhin Zugriff auf die übergeordnete Kampagne; Veröffentlichung und Moderation verwenden eigene Berechtigungen. Veröffentlichter Text ist unveränderlich. Verfassende können eigene Einträge zurücknehmen, Moderierende können Einträge schwärzen. Dabei bleiben Person, Zeitstempel, Referenz, Nachweis-Hash, Platzhalter und Auditnachweis erhalten. Kommentare können Kampagnenversionen, Empfänger-Importläufe, Anlagenregeln, Sendeaufträge oder Berichte referenzieren, ohne historische Versionen zu verändern. Erwähnungen erzeugen nur bei verfügbarem Benachrichtigungsmodul eine interne Benachrichtigung und nur für aktive Personen mit bestehendem Kampagnenzugriff. Diskussion ist kein Systemzustand und ersetzt keinen Auditnachweis.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.assign-accountable-work",
|
||||
title="Assign accountable Campaign work without granting access",
|
||||
summary="Record bounded work for an account, group, or organization function while keeping authorization and Campaign ownership separate.",
|
||||
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can accept, complete, or reject their own work; rejection is distinct from manager cancellation. Managers can also reassign or cancel it. Workflow-opened work additionally retains the correlation, idempotency, Workflow instance and step, exact Campaign version, and emits a common revision-bearing lifecycle event for assignment, acceptance, start, reassignment, completion, rejection, or cancellation. Workflow rechecks Campaign access before it resumes; the assignment itself never grants access. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:assignment:read"),
|
||||
route="/campaigns/{campaign_id}/work",
|
||||
screen="Campaign work",
|
||||
help_contexts=(
|
||||
"campaign.work",
|
||||
"campaign.work.create",
|
||||
"campaign.work.action.start",
|
||||
"campaign.work.action.complete",
|
||||
"campaign.work.action.reject",
|
||||
"campaign.work.action.reassign",
|
||||
"campaign.work.action.cancel",
|
||||
"campaign.work.history",
|
||||
),
|
||||
prerequisites=(
|
||||
"You can read the Campaign and its work assignments.",
|
||||
"Creating, reassigning, cancelling, or reconciling requires the assignment-manage permission.",
|
||||
"The target account, group, or all current function incumbents already have Campaign access.",
|
||||
),
|
||||
steps=(
|
||||
"Open Work in the selected Campaign workspace and choose Add assignment.",
|
||||
"Enter a bounded purpose, optional due date, typed target, and optional stable Campaign evidence reference.",
|
||||
"Resolve any authorization-neutral rejection by granting access through the separate Campaign sharing workflow or choosing another assignee; creating the assignment itself never grants access.",
|
||||
"Accept and complete your own assignment, reject it explicitly when it cannot be taken on, or use manager actions to reassign or cancel open work.",
|
||||
"Reload and reconcile assignments after account, group, organization-function, or incumbency changes; inspect the retained history before acting on unavailable work.",
|
||||
),
|
||||
outcome="A durable accountability record whose lifecycle is independent from Campaign ownership, authorization, and delivery state.",
|
||||
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. For Workflow-opened work, follow the focused assignment link and verify the exact terminal event and revision resume only the pinned Workflow step. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
|
||||
related_modules=("access", "organizations", "idm", "tasks", "notifications", "audit", "policy"),
|
||||
limitations=(
|
||||
"Organizations and IDM are optional; organization-function assignment is unavailable until both directory and incumbency capabilities are active.",
|
||||
"Tasks mirroring is a convenience projection. Campaign remains the authoritative assignment and history owner.",
|
||||
"Ownership transfer continues to use its separate two-party governance protocol.",
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Verantwortliche Kampagnenarbeit zuweisen, ohne Zugriff zu vergeben",
|
||||
"summary": "Begrenzte Arbeit für Konto, Gruppe oder Organisationsfunktion erfassen und Berechtigung sowie Kampagneneigentum getrennt halten.",
|
||||
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Zugewiesene Personen können Arbeit annehmen, abschließen oder ausdrücklich ablehnen; Ablehnung bleibt von einer administrativen Stornierung getrennt. Durch Workflow eröffnete Arbeit bewahrt Korrelation, Idempotenz, Workflow-Instanz und -Schritt sowie die genaue Kampagnenversion und erzeugt revisionsgebundene Lebenszyklusereignisse. Workflow prüft den Kampagnenzugriff vor der Fortsetzung erneut. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.reuse-content-library",
|
||||
title="Reuse Campaign content through Templates",
|
||||
summary="Insert scoped, versioned fragments or complete message parts and save new content as an unpublished Templates draft.",
|
||||
body="The reusable library is owned by Templates. Loading a fragment inserts it at the selected Campaign field and cursor; applying a complete part replaces the current subject and body only after explicit confirmation. Required fields are compared with the current Campaign fields and shown before content is applied. Saving from Campaign creates a personal or tenant Templates draft, retains placeholder requirements as its data contract, and never publishes it automatically. Neither operation changes an existing Template revision or a historical Campaign version.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update"),
|
||||
required_modules=("campaigns", "templates"),
|
||||
required_capabilities=(
|
||||
_TEMPLATE_CATALOG_INTEGRATION,
|
||||
_TEMPLATE_CONTENT_LIBRARY_INTEGRATION,
|
||||
),
|
||||
route="/campaigns/{campaign_id}/template",
|
||||
screen="Campaign template",
|
||||
help_contexts=("campaign.template", "campaign.template.content-library"),
|
||||
prerequisites=(
|
||||
"The Campaign version is editable.",
|
||||
"Templates is active and you may read its library; saving additionally requires Template write authority.",
|
||||
),
|
||||
steps=(
|
||||
"Open Template and choose Load from library to search content visible in the active Templates scope.",
|
||||
"Review any missing or incompatible required fields, then insert a fragment into its declared subject, text, or HTML target, or explicitly confirm replacement by a complete Campaign part.",
|
||||
"Review placeholders and save the Campaign draft normally.",
|
||||
"To retain new content, choose Save to library, select fragment or complete part plus personal or tenant visibility, and create the unpublished draft.",
|
||||
"Open Templates to review, revise, and publish shared content.",
|
||||
),
|
||||
outcome="Reusable content remains centrally versioned while each Campaign records its own deliberate draft changes.",
|
||||
verification="The Campaign draft shows the inserted content, and saved library content appears in Templates as an unpublished revision with Campaign provenance.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build",),
|
||||
related_modules=("templates",),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Kampagneninhalte über Templates wiederverwenden",
|
||||
"summary": "Bereichsbezogene, versionierte Bausteine oder vollständige Nachrichtenteile einfügen und neue Inhalte als unveröffentlichten Templates-Entwurf speichern.",
|
||||
"body": "Die wiederverwendbare Bibliothek gehört Templates. Ein Baustein wird in das ausgewählte Kampagnenfeld an der Cursorposition eingefügt; ein vollständiger Teil ersetzt Betreff und Nachrichtentext erst nach ausdrücklicher Bestätigung. Pflichtfelder werden vor dem Anwenden mit den aktuellen Kampagnenfeldern verglichen und angezeigt. Das Speichern aus Campaign legt einen persönlichen oder mandantenweiten Templates-Entwurf an, bewahrt Platzhalteranforderungen als Datenvertrag und veröffentlicht ihn niemals automatisch. Bestehende Template-Revisionen und historische Kampagnenversionen bleiben unverändert.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.schedule-drafts",
|
||||
title="Schedule bounded manual or autonomous campaigns",
|
||||
summary="Prepare fresh drafts or explicitly opt in to governed delivery of an exact approved build.",
|
||||
body="Every schedule is bounded, timezone-aware, and fixed to either manual or autonomous mode. Manual mode stores an integrity-sealed configuration snapshot and prepares a separately owned draft per due occurrence without requiring Mail. Autonomous mode never rebuilds or silently changes approved content: it requires a built Mail-only source version with an explicit valid Approval request, seals its execution-snapshot hash, and rechecks approval, policy, credential selection, SMTP transport revision and live transport health, recipient gates, attachment evidence, and snapshot integrity before every occurrence. It then creates one Mail-owned durable command per frozen message with occurrence-scoped idempotency before delivery. Mail never automatically retries accepted or outcome-unknown effects. Campaign records prepared, accepted, uncertain, failed, skipped, and superseded recovery evidence; uncertain, policy, configuration, and systemic failures pause the recurrence and notify the accountable operator. Missed intervals are coalesced instead of causing a catch-up storm, and pause/resume rejects stale browser state.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_author", "operator"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:campaign:schedule"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview",
|
||||
help_contexts=("campaign.overview", "campaigns.action.schedule-drafts"),
|
||||
prerequisites=(
|
||||
"Choose the exact campaign version whose configuration should seed future drafts.",
|
||||
"Recipient data and active shares require their corresponding read or share authority.",
|
||||
"A worker and scheduler process must be running for automatic due-time preparation.",
|
||||
"Autonomous mode additionally requires campaigns:campaign:queue, campaigns:campaign:send, mail:profile:use, Mail's durable outbox, and an explicitly approved built source version.",
|
||||
),
|
||||
steps=(
|
||||
"Open the campaign overview and choose Schedule.",
|
||||
"Set the first occurrence, timezone, recurrence, and bounded maximum occurrence count.",
|
||||
"Choose manual draft preparation or autonomous approved delivery; mode cannot be changed in place.",
|
||||
"For manual mode, select which configuration domains may be copied and review each generated draft independently.",
|
||||
"For autonomous mode, confirm that the selected version is built, Mail-only, and explicitly approved; the API rejects missing or stale evidence.",
|
||||
"Review next occurrence, last outcome, and recovery state. Resolve uncertain or failed Mail commands explicitly before creating or resuming a replacement schedule.",
|
||||
),
|
||||
outcome="A bounded sequence of manual drafts or at-most-once autonomous Mail commands with durable occurrence and recovery evidence.",
|
||||
verification="The Schedules section shows mode, next occurrence, last outcome, recovery state, and any automatic pause; manual occurrences link to distinct drafts while autonomous occurrences retain Mail command identifiers and non-secret outcome totals.",
|
||||
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Begrenzte manuelle oder autonome Kampagnen planen",
|
||||
"summary": "Neue Entwürfe vorbereiten oder den Versand eines exakt freigegebenen Builds ausdrücklich autonom ausführen.",
|
||||
"body": "Jeder Zeitplan ist begrenzt, zeitzonenfest und dauerhaft manuell oder autonom. Der manuelle Modus erzeugt eigenständige Entwürfe und funktioniert ohne Mail. Der autonome Modus verlangt eine gebaute, ausschließlich per Mail versendete und ausdrücklich freigegebene Quellversion. Vor jeder Ausführung werden Freigabe, Richtlinie, Zugangsdatenauswahl, Transportrevision und -erreichbarkeit, Empfänger, Anlagen und Snapshot-Integrität erneut geprüft. Pro eingefrorener Nachricht entsteht vor dem Versand ein dauerhafter Mail-Auftrag mit ausführungsspezifischem Idempotenzschlüssel. Angenommene oder unklare Ergebnisse werden nie automatisch wiederholt; unklare oder systemische Fehler pausieren den Zeitplan und benachrichtigen Verantwortliche. Verpasste Intervalle werden zusammengefasst und als Nachweis erhalten.",
|
||||
"outcome": "Eine begrenzte Folge manueller Entwürfe oder höchstens einmal angenommener autonomer Mail-Aufträge mit dauerhaftem Wiederherstellungsnachweis.",
|
||||
"verification": "Der Abschnitt Zeitpläne zeigt Modus, nächste Ausführung, letztes Ergebnis, Wiederherstellungsstatus und automatische Pausen.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-recipients",
|
||||
title="Import recipients into a campaign",
|
||||
@@ -219,11 +476,89 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||
related_modules=("addresses",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-distribution-list",
|
||||
title="Freeze a Distribution List into a campaign",
|
||||
summary="Resolve a reusable audience, inspect its channel and policy decisions, and copy an immutable snapshot into the current campaign version.",
|
||||
body="A Distribution List stays live and versioned in its owning module. Campaign freezes one exact expansion; later list or provider changes only produce a drift warning and never rewrite the saved Campaign recipients.",
|
||||
order=33,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_modules=("campaigns", "dist_lists"),
|
||||
required_capabilities=(
|
||||
_DISTRIBUTION_LIST_SOURCE_INTEGRATION,
|
||||
_DISTRIBUTION_LIST_EXPAND_INTEGRATION,
|
||||
),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/recipients",
|
||||
screen="Recipient data",
|
||||
help_contexts=("campaign.recipients", "campaign.recipient-data"),
|
||||
prerequisites=(
|
||||
"A visible Distribution List can be expanded for campaign delivery.",
|
||||
"The current Campaign version is editable.",
|
||||
),
|
||||
steps=(
|
||||
"Open Recipient data and select Import Distribution List.",
|
||||
"Choose the list, requested channels, and any declared parameters, then preview the expansion.",
|
||||
"Review included and excluded recipients, stale provider evidence, diagnostics, and every visible primary and optional fallback route.",
|
||||
"Choose append or replace, freeze and import the expansion, inspect the copied rows, and save the Campaign version.",
|
||||
"Use the drift warning for a deliberate refresh when the reusable list changes later.",
|
||||
),
|
||||
outcome="A Campaign-local recipient snapshot with immutable audience, provider, policy, and channel-decision evidence.",
|
||||
verification="The saved recipient rows retain the list revision and snapshot reference, and later list changes do not alter them automatically.",
|
||||
related_topic_ids=("campaigns.workflow.import-recipients", "campaigns.workflow.prepare-validate-and-build"),
|
||||
related_modules=("dist_lists", "templates", "postbox"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.prepare-printable-delivery",
|
||||
title="Prepare governed printable delivery",
|
||||
summary="Select a published output template, build one deterministic artifact, and review its route and hash evidence before postal or internal-mail distribution.",
|
||||
body="Printable delivery is optional and provider-neutral. Campaign freezes recipient route decisions while Templates owns compatibility and rendering; Files may own the resulting managed artifact. Ordered fallback is used only after a confirmed rejection before acceptance and never after an accepted or outcome-unknown digital effect.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
|
||||
required_modules=("campaigns", "templates"),
|
||||
required_capabilities=(
|
||||
_TEMPLATE_CATALOG_INTEGRATION,
|
||||
_TEMPLATE_RENDERER_INTEGRATION,
|
||||
),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:recipient:read",
|
||||
"templates:template:read",
|
||||
"templates:template:render",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/template",
|
||||
screen="Template",
|
||||
help_contexts=("campaign.template", "campaign.review-send"),
|
||||
prerequisites=(
|
||||
"At least one included recipient has an explicit postal or internal-mail route.",
|
||||
"A compatible Templates definition is published and visible to you.",
|
||||
),
|
||||
steps=(
|
||||
"Open Template and select the published printable template, output format, and storage choice.",
|
||||
"Save, validate, and resolve every missing-field or compatibility error.",
|
||||
"Build the Campaign to generate one deterministic artifact for the frozen printable recipients.",
|
||||
"In Review and send, download and inspect the artifact and compare its template, input, and output hashes.",
|
||||
"Complete review and execute delivery; use reports to verify per-recipient print acceptance and route provenance.",
|
||||
),
|
||||
outcome="A reviewed printable artifact and idempotent per-recipient distribution evidence.",
|
||||
verification="The build summary exposes the artifact and hashes, and the Campaign report records print status and one acceptance attempt per routed recipient.",
|
||||
related_topic_ids=("campaigns.workflow.import-distribution-list", "campaigns.workflow.prepare-validate-and-build"),
|
||||
related_modules=("templates", "files", "dist_lists"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.use-managed-attachments",
|
||||
title="Use managed files as campaign attachments",
|
||||
summary="Select governed file versions, preview rule matches, and preserve exactly which files were used for the campaign build.",
|
||||
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths.",
|
||||
body="Managed attachments remain owned by Files. Campaign stores governed references and frozen build evidence; it does not copy Files administration authority or accept arbitrary server paths. Link required files before locking. Review and send explains why a locked version cannot change its file links. Locking is unavailable while the preview is loading and rechecks current matches at the action boundary. Newly unlinked matches require explicit Link and lock confirmation; a failed check leaves the version unlocked. Link missing files in an editable version, then validate, build and review again.",
|
||||
order=34,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_modules=("campaigns", "files"),
|
||||
@@ -258,6 +593,114 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
),
|
||||
related_modules=("files",),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.control-attachment-reuse",
|
||||
title="Control repeated campaign attachment use",
|
||||
summary="Choose whether one resolved file may be reused, produces a warning, requires a reasoned review decision, or blocks delivery.",
|
||||
body="Attachment reuse is a campaign-owned policy. The action can allow and record every repeated file, warn, require explicit review, or block affected messages. An optional exception permits reuse confined to one recipient or one built message. Every repeated-file finding is represented in the build protocol by a path-safe fingerprint, display filename, use count, message count, disposition, and explanation. Review decisions require a reason, bind to the exact message and build fingerprint, and remain available in campaign protocol and audit evidence. Changing the policy requires a new validation and build; it never rewrites historical evidence.",
|
||||
order=35,
|
||||
audience=("campaign_manager", "campaign_author", "campaign_reviewer", "administrator"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build", "campaigns:campaign:review"),
|
||||
route="/campaigns/{campaign_id}/files",
|
||||
screen="Attachments",
|
||||
help_contexts=("campaign.attachments", "campaign.attachments.reuse-policy"),
|
||||
prerequisites=(
|
||||
"Decide which repeated use is acceptable for the campaign's purpose and recipients.",
|
||||
"The current campaign version is editable when changing the policy.",
|
||||
),
|
||||
steps=(
|
||||
"Open Attachments and choose Allow, Warn, Require explicit review, or Block delivery.",
|
||||
"Optionally allow reuse only within the same recipient or the same built message.",
|
||||
"Save, validate, and build the campaign, then inspect the repeated-file summary and affected messages.",
|
||||
"For Review findings, open every affected message and record an explicit reason; for Block findings, correct the rules or policy and rebuild.",
|
||||
),
|
||||
outcome="A campaign build whose repeated attachment use is governed and reviewable under an explicit policy.",
|
||||
verification="Review and send shows the configured action and boundary, repeated-file counts and dispositions; affected messages show warning, review, or blocked state as configured.",
|
||||
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Wiederholte Verwendung von Kampagnenanhängen steuern",
|
||||
"summary": "Festlegen, ob dieselbe aufgelöste Datei wiederverwendet werden darf, eine Warnung erzeugt, eine begründete Prüfentscheidung erfordert oder den Versand sperrt.",
|
||||
"body": "Die Wiederverwendung von Anhängen wird durch eine kampagneneigene Richtlinie gesteuert. Die Aktion kann jede Wiederverwendung zulassen und protokollieren, warnen, eine ausdrückliche Prüfung verlangen oder betroffene Nachrichten sperren. Optional darf die Wiederverwendung innerhalb desselben Empfängers oder derselben erzeugten Nachricht ausgenommen werden. Jeder Befund erscheint mit pfadsicherem Fingerabdruck, Anzeigename, Verwendungs- und Nachrichtenanzahl, Ergebnis und Begründung im Build-Protokoll. Prüfentscheidungen benötigen eine Begründung, sind an Nachricht und Build-Fingerabdruck gebunden und bleiben in Protokoll und Auditnachweis erhalten. Eine Richtlinienänderung erfordert eine neue Validierung und einen neuen Build und verändert keine historischen Nachweise.",
|
||||
"outcome": "Ein Kampagnen-Build, dessen wiederholte Anhangsverwendung durch eine ausdrückliche Richtlinie gesteuert und prüfbar ist.",
|
||||
"verification": "Prüfen und senden zeigt Aktion, Ausnahmegrenze, Anzahlen und Ergebnisse; betroffene Nachrichten tragen entsprechend Warn-, Prüf- oder Sperrstatus.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.route-unassigned-files",
|
||||
title="Review and route unassigned campaign files",
|
||||
summary="Turn files left in a watched source into an explicit report or reviewed attachment message instead of silently overlooking them.",
|
||||
body="Campaign compares watched attachment sources with the exact files assigned to built recipient messages. The configurable validation behavior can block, require review, or explicitly ignore the remaining set. An optional residual-file disposition instead turns it into one additional Campaign row addressed to a configured mailbox. Report mode lists the files; attach mode also includes them. The normalized action, observed file and source counts, routing mode, and configured recipient are visible in build review and retained in the campaign protocol; the audit event records the same policy and counts without copying the recipient address. A routed row always needs review and follows the normal build, approval, delivery, reporting, and audit lifecycle. Saving or building never sends it directly.",
|
||||
order=35,
|
||||
audience=("campaign_manager", "campaign_author", "campaign_reviewer"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:update", "campaigns:campaign:build"),
|
||||
route="/campaigns/{campaign_id}/files",
|
||||
screen="Attachments",
|
||||
help_contexts=("campaign.attachments", "campaign.attachments.residual-files"),
|
||||
prerequisites=(
|
||||
"Enable Unsent on every attachment source that must be checked.",
|
||||
"Configure the ordinary warning or blocking policy even if no routed message is wanted.",
|
||||
"For routing, provide a reviewed recipient, subject, and report body.",
|
||||
),
|
||||
steps=(
|
||||
"Open Attachments and set the unassigned-file action to warning only, report, or report with attachments.",
|
||||
"Save and build the campaign; Campaign compares resolved recipient files with every file in the watched sources.",
|
||||
"Open the residual-file row in Review and send, inspect its exact list and any attached files, and record the review decision.",
|
||||
"Queue or send it through the same controlled Campaign lifecycle, or correct the attachment rules and rebuild instead.",
|
||||
),
|
||||
outcome="Every watched file is either assigned, deliberately reported, or represented by a visible policy finding.",
|
||||
verification="The build contains no hidden residual set: it shows either the configured warning/blocking issue or one needs-review row with residual-file provenance and the configured recipient.",
|
||||
related_topic_ids=("campaigns.workflow.use-managed-attachments", "campaigns.workflow.prepare-validate-and-build"),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Nicht zugeordnete Kampagnendateien prüfen und weiterleiten",
|
||||
"summary": "Übrig gebliebene Dateien aus überwachten Quellen ausdrücklich melden oder als geprüfte Nachricht vorbereiten, statt sie unbemerkt zu übergehen.",
|
||||
"body": "Campaign vergleicht überwachte Anhangsquellen mit den Dateien, die den erzeugten Empfängernachrichten tatsächlich zugeordnet sind. Das konfigurierbare Validierungsverhalten kann die Restmenge sperren, zur Prüfung vorlegen oder ausdrücklich ignorieren. Eine optionale Restdatei-Behandlung erzeugt stattdessen eine zusätzliche Kampagnenzeile an ein konfiguriertes Postfach. Der Berichtsmodus listet die Dateien auf; der Anhangsmodus fügt sie zusätzlich bei. Die normalisierte Aktion, Datei- und Quellenanzahl, Routingart und der konfigurierte Empfänger sind in der Build-Prüfung sichtbar und bleiben im Kampagnenprotokoll erhalten; das Audit-Ereignis speichert Richtlinie und Anzahlen ohne die Empfängeradresse zu kopieren. Die Zeile muss immer geprüft werden und durchläuft den normalen Erzeugungs-, Freigabe-, Versand-, Berichts- und Auditablauf. Speichern oder Erzeugen versendet sie niemals unmittelbar.",
|
||||
"outcome": "Jede überwachte Datei ist zugeordnet, bewusst gemeldet oder durch einen sichtbaren Richtlinienbefund erfasst.",
|
||||
"verification": "Der Build enthält keine verborgene Restmenge: Er zeigt entweder den konfigurierten Warn- oder Sperrbefund oder eine zu prüfende Zeile mit Restdatei-Provenienz und dem konfigurierten Empfänger.",
|
||||
}
|
||||
},
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.send-calendar-invitations",
|
||||
title="Send individualized calendar invitations",
|
||||
summary="Freeze one iCalendar request per recipient, deliver it through Mail, and review live attendee answers from Calendar.",
|
||||
body="Campaign owns the recipient expansion, exact invitation request, message delivery evidence, and report. Calendar owns the mirrored VEVENT and attendee answer state. The mirror is created only after a delivery channel accepts the message; a Calendar failure never rewrites accepted Mail evidence. Mail can forward METHOD:REPLY parts from a configured IMAP delivery-status source.",
|
||||
order=35,
|
||||
audience=("campaign_manager", "campaign_sender"),
|
||||
required_modules=("campaigns", "mail", "calendar"),
|
||||
required_capabilities=(_MAIL_INTEGRATION, _CALENDAR_INVITATION_INTEGRATION),
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:recipient:read",
|
||||
"calendar:calendar:read",
|
||||
),
|
||||
route="/campaigns/{campaign_id}/global-settings",
|
||||
screen="Campaign settings",
|
||||
help_contexts=("campaign.global-settings", "campaign.report"),
|
||||
prerequisites=(
|
||||
"Mail and Calendar are active and you can select a writable calendar.",
|
||||
"Every queueable recipient has a Mail To address and the invitation start template resolves to ISO 8601.",
|
||||
),
|
||||
steps=(
|
||||
"Open Campaign settings, enable individualized Calendar invitations, and select the tracking calendar.",
|
||||
"Enter summary, start, optional end, timezone, location, description, and category templates.",
|
||||
"Validate and build; inspect the frozen METHOD:REQUEST attachment for each recipient before delivery.",
|
||||
"Deliver the reviewed build and use Report to compare Mail delivery with the live RSVP state.",
|
||||
"Configure a Mail IMAP delivery-status source when inbound METHOD:REPLY reconciliation is required.",
|
||||
),
|
||||
outcome="Individually delivered invitations with correlated Calendar events and recipient-level RSVP reporting.",
|
||||
verification="The Campaign job shows accepted delivery, a mirrored Calendar event ID, and the current attendee status; repeated mailbox ingestion does not duplicate the response effect.",
|
||||
related_topic_ids=("campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.view-delivery-report"),
|
||||
related_modules=("mail", "calendar"),
|
||||
limitations=(
|
||||
"Recurring Campaign invitation series require a separate series workflow; this slice creates individual VEVENT requests.",
|
||||
),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.queue-delivery",
|
||||
title="Queue a campaign for controlled delivery",
|
||||
@@ -292,7 +735,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"),
|
||||
),
|
||||
@@ -300,7 +743,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
topic_id="campaigns.workflow.send-small-controlled-run",
|
||||
title="Send a campaign immediately",
|
||||
summary="Run the eligible jobs synchronously only after deliberately confirming that the reviewed campaign is small enough for an interactive request.",
|
||||
body="Send now is protected by an effective deployment/tenant recipient-job maximum. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect.",
|
||||
body="Send now is protected by an effective deployment/system/tenant recipient-job maximum, not a campaign-size limit. The default is 25. Administration → SYSTEM → Campaign delivery can configure 0–500 within any explicit deployment ceiling; TENANT may only narrow the inherited limit. The server counts the exact persisted eligible build, rejects an oversized or empty run before SMTP, and preflights every message and the Mail profile revision before the first provider effect. Worker delivery remains independent and requires working background infrastructure. A successful Mail server test proves that connection only, not the campaign's resource selection or sender/recipient authorization. Preflight distinguishes Mail profile/credential policy, SMTP configuration, authentication, and connectivity failures without exposing secret or raw provider details. SMTP checks its own credential selection; IMAP independently checks its selection when appending to Sent. Full campaign validation still checks all required selections.",
|
||||
order=36,
|
||||
audience=("campaign_sender", "campaign_operator"),
|
||||
required_modules=("campaigns", "mail"),
|
||||
@@ -338,7 +781,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,13 +794,16 @@ 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",
|
||||
title="Review campaign delivery details",
|
||||
summary="Inspect delivery totals and recipient-level job evidence in the current Campaign Report UI.",
|
||||
body="The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface.",
|
||||
body=("The recipient-aware Campaign Report requires campaign-read, report-read, and recipient-read authority. Infrastructure diagnostics remain separately authorized, and the server checks every direct detail route independently of the interface. "
|
||||
"Each job shows all frozen To, Cc and Bcc addresses in their authored order; legacy jobs without a recipient snapshot fall back to the primary recipient. A primary address is a row identity, not proof that only one addressee was sent. The compact list includes recipient data only with recipient-read authority; the separate aggregate report remains address-free. SMTP and IMAP status columns use selectable lists with shared labels. "
|
||||
"Send now, inline retry/continue and inline append-to-Sent share a blocking progress dialog. It polls only a small, campaign-read and object-authorized version-scoped counter endpoint, not the workspace, recipients, attachments or full summary. Processed includes accepted/appended, failed, uncertain and cancelled outcomes; active work is separate from pending so no message disappears between counts. Excluded or non-requested channel work is outside its denominator. A failed progress read retains the last counters and does not imply a failed operation. A disconnected request may still be running: do not repeat it blindly. Acknowledged results survive later refresh failures. "
|
||||
"Append Sent acts on the selected version, never silently on every historical version. Mail reuses a bounded authenticated connection and folder lookup across sequential APPEND commands, with current policy, references, credentials and recovery fences checked per message. Default bounds are 100 APPENDs or 300 seconds per connection; this is connection reuse, not an all-or-nothing mailbox transaction. Unknown APPEND results require evidence-backed reconciliation, never automatic replay."),
|
||||
order=38,
|
||||
audience=("campaign_reader", "campaign_manager", "campaign_reviewer", "campaign_sender"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:report:read", "campaigns:recipient:read"),
|
||||
@@ -449,14 +895,13 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
steps=(
|
||||
"Open Report and resolve every active or outcome-unknown delivery state.",
|
||||
"Confirm that the campaign should leave active work while its evidence remains retained.",
|
||||
"Invoke the authorized Archive action from a supporting client.",
|
||||
"Open the campaign overview, choose Archive campaign, and confirm the retained-evidence consequence.",
|
||||
"Reopen or query the campaign and confirm its state is Archived.",
|
||||
),
|
||||
outcome="An archived campaign whose versions, reports, and audit evidence remain preserved.",
|
||||
verification="The campaign state is Archived and its report remains available. Ask an authorized audit reader to verify the platform audit event.",
|
||||
related_topic_ids=("campaigns.workflow.view-delivery-report", "campaigns.workflow.delete-untouched-draft"),
|
||||
limitations=(
|
||||
"The current Campaign Web UI does not yet expose the archive action; use an authorized supporting client or API.",
|
||||
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||
),
|
||||
),
|
||||
@@ -477,7 +922,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
),
|
||||
steps=(
|
||||
"Confirm that the draft is not needed and contains no evidence that should be retained.",
|
||||
"Invoke the authorized Delete action from a supporting client and confirm the destructive action.",
|
||||
"Choose Delete draft on the campaign overview and confirm the destructive action.",
|
||||
"If deletion is refused because protected evidence exists, archive the campaign after resolving any active delivery state.",
|
||||
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
|
||||
),
|
||||
@@ -485,11 +930,34 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
verification="The draft is no longer returned as an active campaign. Ask an authorized audit reader to verify who deleted it and when through the platform audit surface.",
|
||||
related_topic_ids=("campaigns.workflow.archive-campaign",),
|
||||
limitations=(
|
||||
"The current Campaign Web UI does not yet expose the delete action; use an authorized supporting client or API.",
|
||||
"The Campaign-local Audit page is not integrated yet; audit verification uses the platform audit surface or API.",
|
||||
),
|
||||
),
|
||||
)
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.archive-historical-version",
|
||||
title="Archive a historical campaign version",
|
||||
summary="Hide a non-current version from default history without changing or deleting retained evidence.",
|
||||
body="Historical version archival is presentation lifecycle only. The original workflow state, configuration, reports, delivery outcomes, and audit evidence remain readable to authorized users and are included when archived versions are shown.",
|
||||
order=42,
|
||||
audience=("campaign_owner", "campaign_records_manager"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:archive"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign versions",
|
||||
help_contexts=("campaign.overview", "campaign.report", "campaign.audit"),
|
||||
prerequisites=(
|
||||
"The selected version is historical rather than the current working version.",
|
||||
"You have write access and campaign archive permission.",
|
||||
),
|
||||
steps=(
|
||||
"Open Versions and select Archive historical version for the intended row.",
|
||||
"Review the retained-evidence consequence and confirm while the lifecycle token remains current.",
|
||||
"Use Show archived to include the version in history again when reviewing reports or evidence.",
|
||||
),
|
||||
outcome="The historical version is hidden from default history but remains intact and attributable.",
|
||||
verification="Show archived displays the same version number and original workflow state with its archival timestamp.",
|
||||
related_topic_ids=("campaigns.workflow.archive-campaign", "campaigns.workflow.view-delivery-report"),
|
||||
),
|
||||
))
|
||||
|
||||
|
||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||
@@ -556,12 +1024,17 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:export",), "Export privacy-scoped portable Campaign packages.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:import", "campaigns:campaign:create"), "Preview and import compatible portable Campaign packages as new drafts.", require_all=True)
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
|
||||
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:read",), "Read accountable work attached to campaigns you can already access.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:manage",), "Create, reassign, cancel, and reconcile authorization-neutral Campaign work.")
|
||||
_append_if(capabilities, principal, ("campaigns:assignment:complete",), "Start and complete Campaign work assigned to your account, group, or organization function.")
|
||||
if mail_available:
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||
@@ -669,6 +1142,34 @@ def _integration_summary(registry: object, principal: object) -> tuple[tuple[str
|
||||
else:
|
||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||
|
||||
if _integration_available(registry, _TASKS_INTEGRATION):
|
||||
configured.append("Installed composition: Campaign work assignments may be mirrored into Tasks while Campaign remains the authoritative lifecycle and access boundary.")
|
||||
else:
|
||||
limitations.append("Campaign work remains available, but optional Tasks mirroring is not configured.")
|
||||
if _integration_available(registry, _ORGANIZATIONS_INTEGRATION) and _integration_available(
|
||||
registry, _IDM_FUNCTION_ASSIGNMENTS_INTEGRATION
|
||||
):
|
||||
configured.append("Installed composition: Organization-function assignees can be resolved against active functions and their current IDM incumbencies.")
|
||||
else:
|
||||
limitations.append("Organization-function work assignment requires both Organizations directory and IDM incumbency capabilities; account and group assignment remain available.")
|
||||
|
||||
calendar_available = _integration_available(
|
||||
registry,
|
||||
_CALENDAR_INVITATION_INTEGRATION,
|
||||
)
|
||||
if calendar_available and mail_available:
|
||||
configured.append(
|
||||
"Installed composition: Campaign can freeze individualized iCalendar requests, mirror accepted deliveries into Calendar, and report live attendee answers."
|
||||
)
|
||||
elif calendar_available:
|
||||
limitations.append(
|
||||
"Calendar invitation tracking is active, but Mail is not available to deliver Campaign invitation messages."
|
||||
)
|
||||
else:
|
||||
limitations.append(
|
||||
"Calendar-backed invitation and RSVP tracking is not available in this composition."
|
||||
)
|
||||
|
||||
return tuple(configured), tuple(limitations)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from govoplan_core.core.modules import DocumentationTopic, localize_documentation_topics as _localize_topics
|
||||
|
||||
|
||||
_TRANSLATIONS = {
|
||||
"campaigns.admin.delivery-policy": {
|
||||
"title": "Die Grenze für interaktiven Campaign-Versand konfigurieren",
|
||||
"summary": "Eine auditierte Systemgrenze für „Jetzt senden“ und engere Mandantengrenzen festlegen, ohne Kampagnen zu ändern oder Nachrichten zu senden.",
|
||||
"body": "Administration → SYSTEM → Campaign-Versand erlaubt mit system:settings:read das Lesen und mit system:settings:write das Speichern der Empfängerauftragsgrenze. Der unveränderte Standard bleibt 25; Systemadministrierende dürfen ausdrücklich 0–500 wählen, etwa 200 für einen Lauf mit 183 Aufträgen. Administration → TENANT → Campaign-Versand benötigt admin:policies:read/write und darf die geerbte Systemgrenze nur einschränken. Das Entfernen einer Überschreibung stellt Vererbung wieder her. Eine ausdrücklich gesetzte GOVOPLAN_CAMPAIGN_SYNCHRONOUS_SEND_MAX_RECIPIENTS bleibt einschließlich null eine verbindliche Bereitstellungsgrenze. Ohne diesen Wert verhindert der implizite Standard keine autorisierte Systemüberschreibung. Größere interaktive Läufe dauern länger und können Proxy- oder Anfragezeitlimits erreichen; für große Kampagnen bleiben Hintergrund-Worker mit CELERY_ENABLED und funktionierender Redis-/Celery-Infrastruktur die bevorzugte getrennte Betriebsart. Diese Einstellung begrenzt genau einen gespeicherten geeigneten Lauf für „Jetzt senden“, nicht die Kampagnengröße oder Worker-Verteilung. Speichern ändert ausschließlich die gewählte Einstellung mit Revisionskonfliktschutz, Vorher-/Nachher-Konfigurationshistorie und Audit. Es versendet nichts, ändert keine gespeicherten Prüfungen und umgeht weder Mail-, Prüf-, Freigabe- noch Ausführungsintegritätsprüfungen. Bei Fehlern bleibt der Entwurf erhalten. Neuladen verwendet den zentralen Schutz ungespeicherter Änderungen und liest die frische gespeicherte Richtlinie.",
|
||||
},
|
||||
"campaigns.workflow.create-campaign": {
|
||||
"title": "Eine Kampagne anlegen",
|
||||
"summary": "Eine gesteuerte Kampagne als bearbeitbaren Entwurf beginnen und Zweck sowie Eigentum vor Zustelldaten festlegen.",
|
||||
"body": (
|
||||
"Eine neue Kampagne beginnt mit einer bearbeitbaren Arbeitsversion. Die Aktionsleiste zeigt gespeichert, ungespeichert oder speichernd; Verwerfen steht direkt vor Speichern, und beim Verlassen eines geänderten Entwurfs greift der zentrale Speichern-oder-Verwerfen-Schutz. Destruktive Lebenszyklusaktionen sind von gewöhnlichen Aktionen getrennt. Das Anlegen gewährt keinen Zugriff auf Mail-Profile, verwaltete Dateien, Adressquellen oder Zustellaktionen; diese bleiben eigenständig autorisiert. Bestätigtes Speichern und anschließendes Neuladen sind getrennte Ergebnisse. Ein fehlgeschlagener Schreibvorgang oder abgebrochener Konflikt erhält den Entwurf; wiederholtes Speichern teilt sich einen laufenden Vorgang. Eingaben während des Speicherns bleiben als neuere ungespeicherte Arbeit erhalten. Fehlgeschlagenes oder durch neuere Änderungen überholtes Neuladen beim Verwerfen erhält den Entwurf. Unveränderte ZIP-Konfiguration sperrt nicht das Speichern unabhängiger Anlagenquellen oder Regeln. Hinzufügen-Aktionen bleiben kompakt. Eine vorübergehend fehlende Dateiauswahl wird erklärt und verwandelt verwaltete Pfade nicht stillschweigend in Texteingaben; fehlende Regelquellen müssen neu gewählt werden. Eingabe oder Leertaste öffnet die Auswahl am fokussierten Pfadfeld."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.create-editable-successor": {
|
||||
"title": "Eine bearbeitbare Nachfolgeversion anlegen",
|
||||
"summary": "Nach einer dauerhaften oder zustellungsbedingten Sperre weiterarbeiten, ohne die bewahrte Version umzuschreiben.",
|
||||
"body": (
|
||||
"„Bearbeitbare Kopie anlegen“ erzeugt die nächste Arbeitsversion der Kampagne. Validierungssperren und vorübergehende Benutzersperren werden dagegen an der bestehenden Version aufgehoben und dürfen keine parallelen Entwürfe erzeugen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.import-address-source": {
|
||||
"title": "Eine Adressquelle importieren",
|
||||
"summary": "Ein erlaubtes wiederverwendbares Adressbuch oder eine Liste als nachvollziehbaren versionierten Snapshot in die Kampagne kopieren.",
|
||||
"body": (
|
||||
"Campaign folgt der Adressquelle nicht live. Es speichert die ausgewählte Quellrevision und warnt bei einer neueren Revision. Eine erneute Übernahme ist deshalb immer eine ausdrückliche Aktion der verfassenden Person."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.import-distribution-list": {
|
||||
"title": "Eine Verteilerliste übernehmen",
|
||||
"summary": "Eine wiederverwendbare Zielgruppe auflösen, Kanal- und Policy-Entscheidungen prüfen und einen unveränderlichen Snapshot in die aktuelle Version kopieren.",
|
||||
"body": (
|
||||
"Eine Verteilerliste bleibt in ihrem verantwortlichen Modul live und versioniert. Campaign friert genau eine Auflösung ein; spätere Listen- oder Provideränderungen erzeugen nur eine Driftwarnung und schreiben gespeicherte Empfänger niemals um."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.import-recipients": {
|
||||
"title": "Empfänger importieren",
|
||||
"summary": "Text-, CSV- oder Tabellendaten mit Quellprovenienz in geprüfte kampagnenlokale Empfängerzeilen überführen.",
|
||||
"body": (
|
||||
"Der Import kopiert gültige Zeilen in die bearbeitbare Kampagnenversion. Ungültige Zeilen bleiben in der Vorschau sichtbar, statt still zu verschwinden. Spätere Änderungen der Quelldatei ändern die gespeicherte Kampagne nicht automatisch."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.prepare-printable-delivery": {
|
||||
"title": "Eine druckbare Zustellung vorbereiten",
|
||||
"summary": "Eine veröffentlichte Ausgabevorlage wählen, ein deterministisches Artefakt bauen und Route sowie Hash-Nachweis vor Post- oder Hauspostzustellung prüfen.",
|
||||
"body": (
|
||||
"Druckbare Zustellung ist optional und anbieterneutral. Campaign friert die Routenentscheidungen je Empfänger ein, während Templates Kompatibilität und Rendering verantwortet; Files kann das erzeugte Artefakt verwalten. Eine geordnete Ausweichroute wird nur nach bestätigter Ablehnung vor Annahme verwendet, niemals nach einer angenommenen oder im Ergebnis unbekannten digitalen Wirkung."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.use-managed-attachments": {
|
||||
"title": "Verwaltete Dateien als Kampagnenanhänge verwenden",
|
||||
"summary": "Gesteuerte Dateiversionen wählen, Regelzuordnungen prüfen und exakt verwendete Dateien im Build-Nachweis bewahren.",
|
||||
"body": (
|
||||
"Verwaltete Anhänge bleiben Eigentum von Files. Campaign speichert gesteuerte Referenzen und eingefrorene Build-Nachweise; es übernimmt keine Files-Administrationsbefugnis und akzeptiert keine beliebigen Serverpfade."
|
||||
" Verknüpfen Sie benötigte Dateien vor dem Sperren. Prüfen und Senden erklärt die Reihenfolge und warum eine gesperrte Version keine Dateiverknüpfungen mehr ändern darf. Während die Anhangsvorschau lädt, ist Sperren nicht verfügbar. Vor der Sperraktion werden Treffer frisch geprüft; neue unverbundene Treffer benötigen die ausdrückliche Bestätigung Verknüpfen und sperren. Bei fehlgeschlagener Prüfung bleibt die Version ungesperrt. Verknüpfen Sie fehlende Dateien in einer bearbeitbaren Version und validieren, bauen und prüfen Sie erneut."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.queue-delivery": {
|
||||
"title": "Eine Zustellung einreihen",
|
||||
"summary": "Einen exakt geprüften Build in die Worker-Warteschlange stellen und Empfängerzustände sowie Wiederholungsschutz bewahren.",
|
||||
"body": (
|
||||
"Das Einreihen ist eine kontrollierte Zustandsänderung, kein Zustellnachweis. Gewöhnliche Stapel sollen Hintergrund-Worker verwenden. Angenommene und im Ergebnis unbekannte Wirkungen bleiben vor blinder Wiederholung geschützt."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.send-calendar-invitations": {
|
||||
"title": "Personalisierte Kalendereinladungen senden",
|
||||
"summary": "Je Empfänger eine iCalendar-Anfrage einfrieren, über Mail zustellen und aktuelle Antworten aus Calendar prüfen.",
|
||||
"body": (
|
||||
"Campaign verantwortet Empfängerauflösung, exakte Einladungsanfrage, Zustellnachweis und Bericht. Calendar verantwortet gespiegeltes VEVENT und Antwortstatus. Der Spiegel entsteht erst, nachdem ein Kanal die Nachricht angenommen hat; ein Calendar-Fehler schreibt angenommenen Mail-Nachweis nie um. Mail kann METHOD:REPLY-Teile aus einer konfigurierten IMAP-Quelle für Zustellstatus weiterreichen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.send-small-controlled-run": {
|
||||
"title": "Einen kleinen kontrollierten Lauf sofort senden",
|
||||
"summary": "Geeignete Aufträge nur nach bewusster Bestätigung synchron ausführen, dass die geprüfte Kampagne klein genug ist.",
|
||||
"body": (
|
||||
"„Jetzt senden“ ist durch die wirksame maximale Anzahl von Empfängeraufträgen aus Bereitstellung, System und Mandant geschützt, nicht durch eine Grenze der Kampagnengröße. Standard ist 25. Administration → SYSTEM → Campaign-Versand erlaubt 0–500 innerhalb einer ausdrücklichen Bereitstellungsgrenze; TENANT darf die geerbte Grenze nur einschränken. Der Server zählt den exakt gespeicherten geeigneten Build, lehnt einen zu großen oder leeren Lauf vor SMTP ab und prüft jede Nachricht sowie die Mail-Profilrevision vor der ersten Providerwirkung. Worker-Versand bleibt unabhängig und benötigt funktionierende Hintergrundinfrastruktur."
|
||||
" Ein erfolgreicher Mail-Servertest belegt nur diese Verbindung, nicht die Ressourcenauswahl oder Absender-/Empfängerberechtigung der Kampagne. Die Vorprüfung unterscheidet Mail-Profil-/Zugangsdatenrichtlinien, SMTP-Konfiguration, Authentifizierung und Verbindung, ohne Geheimnisse oder rohe Providerdetails anzuzeigen. SMTP prüft seine eigene Zugangsdatenwahl; IMAP prüft seine Auswahl getrennt beim Ablegen in Gesendet. Die vollständige Kampagnenvalidierung prüft weiterhin alle erforderlichen Auswahlen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.view-aggregate-delivery-report": {
|
||||
"title": "Aggregierte Kampagnenergebnisse prüfen",
|
||||
"summary": "Datenschutzgeschützte Summen ohne Empfängerzeilen, Nachrichteninhalte, Zustelldiagnosen oder Exportbefugnis einsehen.",
|
||||
"body": (
|
||||
"Die aggregierte Berichtssicht zeigt nur freigegebene fachliche Kampagnenergebnisse. Positive Zellen unterhalb des konfigurierten Schwellwerts werden zusammen mit einem ergänzenden Wert oder erforderlichenfalls dem Nenner unterdrückt, damit kleine Gruppen nicht durch Subtraktion rekonstruiert werden können."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.view-delivery-report": {
|
||||
"title": "Detaillierte Zustellergebnisse prüfen",
|
||||
"summary": "Zustellsummen und empfängerbezogene Auftragsnachweise in der aktuellen Campaign-Berichtsoberfläche einsehen.",
|
||||
"body": (
|
||||
"Der empfängerbezogene Bericht erfordert Lesezugriff auf Kampagne, Bericht und Empfänger. Infrastrukturdiagnosen bleiben getrennt autorisiert; der Server prüft jede direkte Detailroute unabhängig von der Oberfläche."
|
||||
" Jeder Auftrag zeigt alle eingefrorenen An-, Cc- und Bcc-Adressen in gespeicherter Reihenfolge; ältere Aufträge ohne Empfängersnapshot verwenden die primäre Adresse. Diese identifiziert die Zeile und beweist nicht, dass nur eine Adresse angeschrieben wurde. Die kompakte Liste erfordert weiterhin Empfänger-Leseberechtigung; der getrennte aggregierte Bericht bleibt ohne Adressen. SMTP- und IMAP-Zustände verwenden Auswahllisten mit gemeinsamen Bezeichnungen."
|
||||
" Jetzt senden, synchrone Wiederholung/Fortsetzung und Kopieren nach Gesendet verwenden denselben sperrenden Fortschrittsdialog. Er liest ausschließlich kleine versionsbezogene Zähler mit Kampagnen-Lese- und Objektberechtigung, nicht Arbeitsbereich, Empfänger, Anhänge oder vollständige Zusammenfassung. Verarbeitet umfasst angenommene/kopierte, fehlgeschlagene, ungewisse und abgebrochene Ergebnisse. Laufende Aufträge werden getrennt von ausstehenden gezählt; ausgeschlossene oder nicht angeforderte Kanäle gehören nicht zum Nenner. Bei Lesefehlern bleiben die letzten Zähler erhalten; dies bedeutet keinen fehlgeschlagenen Versand. Nach einer getrennten Anfrage kann die Verarbeitung weiterlaufen: Wiederholen Sie sie nicht blind. Bestätigte Ergebnisse bleiben bei späteren Aktualisierungsfehlern erhalten."
|
||||
" Kopieren nach Gesendet betrifft nur die ausgewählte Version, nicht stillschweigend historische Versionen. Mail verwendet eine begrenzte authentifizierte Verbindung und Ordnerauflösung für nacheinander ausgeführte APPEND-Befehle; Richtlinie, Referenzen, Zugangsdaten und Wiederherstellungsschutz werden je Nachricht neu geprüft. Standardgrenzen sind 100 APPENDs oder 300 Sekunden pro Verbindung. Dies ist Verbindungswiederverwendung, keine atomare Postfachtransaktion. Ungewisse APPEND-Ergebnisse benötigen nachweisgestützten Abgleich und werden niemals automatisch wiederholt."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.export-delivery-report": {
|
||||
"title": "Zustellergebnisse exportieren",
|
||||
"summary": "Einen autorisierten CSV-Snapshot empfängerbezogener Zustellergebnisse für kontrollierte Weiterverwendung herunterladen.",
|
||||
"body": (
|
||||
"Ein Berichtsexport enthält personenbezogene Daten und Zustellnachweise. Er ist entsprechend dem Kampagnenzweck sowie den geltenden Export- und Aufbewahrungsrichtlinien zu speichern, zu übertragen, aufzubewahren und zu löschen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.share-campaign": {
|
||||
"title": "Eine Kampagne freigeben",
|
||||
"summary": "Einer Person oder Gruppe ausdrücklichen Lese- oder Schreibzugriff auf eine Kampagne geben, ohne Plattformberechtigungen auszuweiten.",
|
||||
"body": (
|
||||
"Eine Freigabe kann den Zugriff nur innerhalb der bestehenden Rolle auf die ausgewählte Kampagne eingrenzen. Sie gewährt niemals Mail-Profilnutzung, Files-Befugnisse, mandantenweiten Empfängerzugriff oder eine fehlende Campaign-Aktion."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.archive-campaign": {
|
||||
"title": "Eine Kampagne archivieren",
|
||||
"summary": "Eine abgeschlossene Kampagne aus der aktiven Arbeit entfernen und Versionen, Ergebnisse sowie Audit-Nachweise bewahren.",
|
||||
"body": (
|
||||
"Eine Kampagne darf erst archiviert werden, nachdem eingereihte, sendende und im Ergebnis unbekannte Arbeiten geklärt sind. Archivierung bewahrt Nachweise und ist für jede Kampagne mit Build-, Sperr- oder Zustellhistorie die richtige Lebenszyklusaktion."
|
||||
),
|
||||
},
|
||||
"campaigns.admin.collaboration-governance": {
|
||||
"title": "Campaign-Zusammenarbeit und Aufbewahrung steuern",
|
||||
"summary": "Diskussionszugriff getrennt von Kampagnenbearbeitung konfigurieren und auditierbare Moderations-Tombstones bewahren.",
|
||||
"body": (
|
||||
"Campaign-Zusammenarbeit verwendet neben dem Lesezugriff auf die Kampagne getrennte Berechtigungen zum Lesen, Schreiben und Moderieren. Die integrierte Managerrolle darf moderieren; Prüf- und Senderollen dürfen lesen und schreiben, ohne Bearbeitungsrechte zu erhalten. Eine Lesefreigabe genügt als übergeordnete Ressourcengewährung; Kommentare werten sie nicht auf. Nur für Moderationen sichtbare Inhalte werden serverseitig gefiltert. Beiträge besitzen keine Bearbeitungs-API. Rückzug und Schwärzung entfernen die Anzeige, erhalten jedoch stabilen Eintrag, SHA-256-Nachweis, Akteursnapshot, Zeitpunkt, typisierte Referenz, Tombstone und begrenztes Audit-Ereignis. Erwähnt werden dürfen nur aktive Personen mit Eigentums- oder Freigabezugriff. Optionale Notifications erhalten inhaltsfreie Hinweise; Providerfehler macht Notifications nicht zur Pflichtabhängigkeit. Institutionelle Aufbewahrungs- und Datenschutzrichtlinien müssen Kollaborationszeilen und Audit-Nachweise gemeinsam behandeln. Kommentare sind weder Freigaben noch Workflow-Übergänge oder Systemereignisse."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.delete-untouched-draft": {
|
||||
"title": "Einen unberührten Kampagnenentwurf löschen",
|
||||
"summary": "Einen Entwurf ohne geschützte Build-, Sperr-, Veröffentlichungs-, Snapshot- oder Zustellnachweise sofort entfernen.",
|
||||
"body": (
|
||||
"Löschen ist bewusst enger als Archivieren. Es markiert einen geeigneten Entwurf als gelöscht und erzeugt einen Audit-Eintrag. Eine Kampagne mit bereits aufbewahrungspflichtigen Nachweisen kann dadurch nicht entfernt werden."
|
||||
),
|
||||
},
|
||||
"campaigns.privacy.data-subject-requests": {
|
||||
"title": "Campaign-Daten in einer Datenschutzanfrage prüfen",
|
||||
"summary": "Empfänger-, Kollaborations-, Versions-, Zustell-, Berichts- und Artefaktmetadaten ermitteln, ohne unveränderliche Nachweise umzuschreiben.",
|
||||
"body": (
|
||||
"Der Campaign-DSAR-Anbieter sucht im wirksamen Mandanten nach normalisierter Empfänger-E-Mail, direkten Mitgliedschaftsreferenzen und namensraumbezogenen Campaign-Kennungen. Er isoliert passende Inline-Empfängerfelder und Auftragsmetadaten und meldet gebaute Versionen, Zustellversuche, Postbox- und Druckergebnisse, Korrekturen, empfängerbezogene Berichte, Nachrichtendigests, Anhangsmetadaten und betroffene Kollaboration. Eigener Beitragstext wird ausgegeben; fremder Text nicht allein wegen einer Erwähnung. EML-Bytes, Objekt- oder lokale Pfade, Providerziele, Worker-Claims, Idempotenzdaten, Geheimnisse, Zugangsdaten und fremde Empfängeradressen bleiben ausgeschlossen. Gebaute, gesperrte, veröffentlichte, abgeschlossene, zugestellte, korrigierte, zurückgezogene oder geschwärzte Datensätze bleiben begründet erhalten. Tombstones, Hashwerte und Audit-Nachweise sind unveränderlich. Entwurfsempfänger und benutzereigene Anhänge benötigen koordinierte manuelle Prüfung. Der Provider kann ein persönliches Import-Mappingprofil idempotent löschen und eine aktive Freigabe für die betroffene Person widerrufen; zugestellte Nachweise und erzeugte Artefakte werden nie direkt gelöscht."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.archive-historical-version": {
|
||||
"title": "Eine historische Kampagnenversion archivieren",
|
||||
"summary": "Eine nicht aktuelle Version aus der Standardhistorie ausblenden, ohne aufbewahrte Nachweise zu ändern oder zu löschen.",
|
||||
"body": (
|
||||
"Die Archivierung einer historischen Version betrifft nur ihre Darstellung. Ursprünglicher Workflow-Zustand, Konfiguration, Berichte, Zustellergebnisse und Audit-Nachweise bleiben für autorisierte Personen lesbar und werden bei eingeblendeten archivierten Versionen mitgeführt."
|
||||
),
|
||||
},
|
||||
"campaigns.search.campaigns": {
|
||||
"title": "Autorisierte Kampagnen durchsuchen",
|
||||
"summary": "Kampagnenidentität und Lebenszyklusmetadaten für die berechtigungsbewusste Plattformsuche bereitstellen.",
|
||||
"body": (
|
||||
"Wenn Search installiert ist, trägt Campaign aktuelle Namen, externe Kennungen, Beschreibungen und Lebenszykluszustände bei. Vor einem Ergebnis werden Mandant, Eigentum, Gruppeneigentum, ausdrückliche Freigaben, Widerruf, Löschung und Campaign-Leseberechtigung erneut geprüft. Bestätigte Kampagnen- und Freigabeänderungen aktualisieren den abgeleiteten Index über den dauerhaften Plattform-Ereignispfad; ein Neuaufbau verändert keine Campaign-Nachweise."
|
||||
),
|
||||
},
|
||||
"campaigns.postbox-delivery": {
|
||||
"title": "Campaign-Nachrichten an Postboxen zustellen",
|
||||
"summary": "Je Empfängerzeile eine oder mehrere exakte oder organisationsabgeleitete Postboxen allein oder neben Mail adressieren.",
|
||||
"body": (
|
||||
"Konfiguriert werden kampagnenweite Ziele und optionale Ergänzungen oder Ersetzungen je Zeile. Abgeleitete Ziele lösen eine veröffentlichte Postbox-Vorlage mit Organisationseinheit, Funktion und optionalen Kontextwerten auf; Werte dürfen aus Campaign-Feldern stammen. Ziele werden beim Build eingefroren. Ein Ausweichen zum zweiten Kanal erfolgt nur nach bestätigter Ablehnung vor Annahme; angenommene oder im Ergebnis unbekannte Wirkungen lösen kein Fallback aus."
|
||||
),
|
||||
},
|
||||
"campaigns.mail-profile-user-journey": {
|
||||
"title": "Ein Mail-Profil für die Kampagnenzustellung wählen",
|
||||
"summary": "Campaign referenziert ein autorisiertes Mail-Profil und speichert niemals SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
||||
"body": (
|
||||
"Mail-Einstellungen → Wiederverwendbares Mail-Profil → SMTP-Zugangsdaten (bei Nutzung auch IMAP-Zugangsdaten) speichert eine ausdrückliche Zugangsdatenkennung. Eine leere Auswahl bedeutet Vererbung nur soweit die Mail-Richtlinie dies erlaubt; ein Profilstandard ist keine gespeicherte Kampagnenauswahl. Fehlende oder inaktive gespeicherte Profile, Server und Zugangsdaten bleiben sichtbar nicht verfügbar und werden nicht stillschweigend ersetzt. "
|
||||
"In den Mail-Einstellungen der Kampagne wird ein verfügbares Profil ausgewählt, über Mail getestet und gespeichert. Bei gemeldeten kampagnenlokalen Alt-Transportdaten wählen Sie nach der autorisierten Profilauswahl „Ausgewähltes Mail-Profil migrieren“. Diese ausdrückliche Aktion funktioniert auch bei unveränderter Profilauswahl und unberührtem Entwurf. Gesperrte historische Nachweise bleiben erhalten; verwenden Sie zuvor die angebotene Entsperrung oder bearbeitbare Nachfolgeversion. „Prüfen und Senden“ zeigt den Migrationsblocker mit Rückweg zu den Mail-Einstellungen, statt wiederholt eine ungültige Anhangsvorschau anzufordern. Die Profilauswahl lädt nur für die Kampagne autorisierte Profile; ein Fehler beim getrennten administrativen Richtlinienkatalog leert sie nicht. Validierung und Zustellung prüfen die Profilberechtigung erneut. Migration speichert Konfiguration, versendet aber keine E-Mail. Validieren, bauen und prüfen Sie die resultierende Version vor der Zustellung erneut."
|
||||
" Mail-Migration und ZIP-Richtlinienkorrekturen lassen sich in beliebiger Reihenfolge speichern. Unveränderte ZIP-Einstellungen blockieren die Migration nicht und erhalten keinen neuen Zustimmungsnachweis. Eine Archiv- oder Inhaltskorrektur mit unveränderten Mail-Referenzen erhält den alten Transport serverseitig und zeigt weiterhin den Migrationshinweis; es erfolgt keine stillschweigende Migration. Ein bestätigtes Speichern und das anschließende Neuladen des Arbeitsbereichs sind getrennte Ergebnisse: Bei fehlgeschlagenem Neuladen bleiben die letzten nutzbaren Daten derselben Kampagne und Version sichtbar, ergänzt um den Fehler. Wiederholen Sie Neuladen; veraltete Antworten einer anderen Kampagne, Version, Identität oder früheren Aktualisierung dürfen den aktuellen Arbeitsbereich nicht ersetzen."
|
||||
),
|
||||
},
|
||||
"campaigns.mail-profile-governance": {
|
||||
"title": "Campaign-zu-Mail-Profilreferenzen steuern",
|
||||
"summary": "Mail besitzt Transportdefinitionen und verschlüsselte Zugangsdaten; Campaign nur die Profilreferenz und Zustellnachweise.",
|
||||
"body": (
|
||||
"Kampagnenverfassende erhalten mail:profile:use; verfügbare Profile werden über Mail-Policy begrenzt und es wird ausdrücklich festgelegt, ob Profil-Zugangsdaten geerbt werden dürfen oder eine Kampagne Mail-eigene Zugangsdaten auswählen muss. Die Mail-Richtlinienseite zeigt die SMTP-/IMAP-Zugangsdatenvererbung mit lokalen, geerbten und wirksamen Werten sowie übergeordneten Sperren. Inline-Transportfelder werden abgelehnt und niemals samt Zugangsdaten an den Browser zurückgegeben. Altbestände bleiben unverändert, bis eine ausdrückliche auditierte Profilmigration eine bearbeitbare Version erzeugt oder aktualisiert. Dafür genügt auch das schon referenzierte Profil, wenn „Ausgewähltes Mail-Profil migrieren“ verwendet wird. Mail-Einstellungen laden nur die für diese Kampagne nutzbaren Profile; der administrative Richtlinienkatalog wird getrennt auf der Mail-Richtlinienseite angefordert und Fehler bleiben dort sichtbar. Diese Trennung verleiht keine Profiladministration und umgeht weder Eigentümer-, Mandanten- noch Mail-Autorisierung. Migration versendet keine E-Mail und stellt keinen alten Ausführungssnapshot wieder her."
|
||||
" Unabhängige Entwurfskorrekturen dürfen das exakt gespeicherte alte Serverobjekt nur bei unveränderten öffentlichen Mail-Referenzen erhalten; Inline-Transport darf nicht mitgesendet werden. Dabei wird Inhalt gespeichert, kein Profil ausgewählt oder genutzt, auch nach Entzug seiner Berechtigung. Ausdrückliche Migration benötigt weiterhin mail:profile:use und aktuelle Mail-Policy. Der Versions-Auditnachweis unterscheidet legacy_mail_settings_preserved und legacy_mail_settings_migrated. Unveränderte ZIP-Konfiguration wird nicht erneut bestätigt; geänderte ZIP-Einstellungen unterliegen allen Richtlinienprüfungen. Erfolgreiche Korrekturen entwerten bisherige Build- und Ausführungsnachweise und lockern weder Validierung noch Prüfung oder Versand."
|
||||
" Bereits migrierte Entwürfe folgen derselben Regel für unveränderte Referenzen: Eine spätere Pflicht zur ausdrücklichen SMTP-/IMAP-Zugangsdatenwahl verhindert keine unabhängige Archiv- oder Inhaltskorrektur. Jede geänderte Mail-Ressourcenauswahl benötigt weiterhin Mail-Berechtigung und aktuelle Richtlinie; verbindliche Validierung und Zustellung prüfen stets die vollständige Auswahl erneut."
|
||||
),
|
||||
},
|
||||
"campaigns.mail-profile-operations": {
|
||||
"title": "Profilbasierte Kampagnenzustellung betreiben",
|
||||
"summary": "Worker autorisieren und lösen Mail-Profile bei Ausführung neu auf; Campaign bewahrt nur undurchsichtige Mail-Revisionen und Ergebnisse.",
|
||||
"body": (
|
||||
"SMTP- und IMAP-Laufzeitaktionen prüfen die Zugangsdatenpflicht getrennt je Protokoll; ein SMTP-Aufruf benötigt keine IMAP-Parameter und umgekehrt. Vollständige Kampagnenvalidierung und Build-Zusammenfassung prüfen weiterhin beide erforderlichen Auswahlen. Die Vorprüfung unterscheidet Mail-Profil-/Zugangsdatenrichtlinienfehler von SMTP-Konfigurations-, Authentifizierungs- und Verbindungsfehlern. Ein erfolgreicher Servertest ersetzt keine kampagnenspezifische Autorisierung. "
|
||||
"Ein Altsnapshot, unautorisiertes oder inaktives Profil, Referenzkonflikt oder eine geänderte SMTP-/IMAP-Revision stoppt die Zustellung. Synchrone Stapel prüfen DNS, Verbindung, TLS und Authentifizierung vor der ersten Wirkung, verwenden eine begrenzte gesunde SMTP-Verbindung wieder und verbinden bei Alterung neu. Oberfläche und Bericht zeigen Stapel-, Verbindungs-, Wiederverbindungs-, Fehler- und Pausenzahlen. Systemische Authentifizierungs-, Absender- oder Verbindungsfehler pausieren übrige Aufträge mit stabilem Grund; das Profil ist zu korrigieren und zu testen, bevor ausdrücklich fortgesetzt wird. Verbindungsverlust nach Beginn von DATA bleibt ergebnisoffen und wird nicht automatisch wiederholt. Der Datensatz wird bewahrt, Profilwahl korrigiert, erneut validiert und gebaut und erst dann neu eingereiht. Reine Passwortrotation kopiert keine Geheimnisse nach Campaign. Unsichere SMTP-/IMAP-Wirkungen bleiben bis zum evidenzbasierten Betriebsabgleich blockiert. Wird Campaign nach Annahme eines Auftrags für den Mandanten unzugänglich, bleibt er unangetastet und wird als Betriebsaktion gemeldet."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.prepare-validate-and-build": {
|
||||
"title": "Eine Kampagne vorbereiten, validieren und bauen",
|
||||
"summary": "Gesteuerte Empfänger-, Vorlagen-, Anhangs- und Mail-Profil-Eingaben in exakte Nachrichten zur Prüfung überführen.",
|
||||
"body": (
|
||||
"Jede Eingabe wird in ihrer verantwortlichen Oberfläche vorbereitet, alle blockierenden Validierungsprobleme werden gelöst und exakte Empfängernachrichten vor der Prüfung gebaut. Empfängerzeilen können als eine ausdrücklich bestätigte Entwurfsänderung gesammelt aktiviert oder deaktiviert werden; Speichern erzeugt normale Versionsnachweise und verwirft veraltete Validierungs-, Build- und Prüfzustände. Passwortfelder verwenden den zentralen sicheren Generator, dessen Vorschlag erst nach „Passwort verwenden“ übernommen wird. Campaign friert Empfänger- und Anhangsnachweise für die ausgewählte Version ein; spätere Quelländerungen ändern den Build nicht. Kennzahlen bieten nur dann einen benannten Drill-down, wenn eine autorisierte Quellsammlung, gefilterte Prüftabelle, Anhangsvorschau oder ein Bericht eine Handlung ermöglicht. Datenschutzunterdrückte Aggregate bleiben nicht interaktiv. Ist Templates installiert, besitzt dessen einziger Navigationseintrag die wiederverwendbare Bibliothek; kampagnenspezifische Komposition bleibt im Arbeitsbereich."
|
||||
" In individuellen und globalen Adressdialogen bestimmen die Auf-/Ab-Aktionen die gespeicherte Adressreihenfolge. Speichern im Dialog übernimmt diese Reihenfolge ohne alphabetische Neusortierung in den Kampagnenentwurf; doppelte E-Mail-Adressen behalten ihre erste Position. Eingefügte Adressen werden in Eingabereihenfolge angehängt, ohne vorhandene Adressen umzuordnen. Die erste individuelle An-Adresse bleibt der primäre Name und die E-Mail-Adresse der Empfängerzeile. Speichern Sie anschließend die Kampagnenseite, um den Entwurf dauerhaft zu übernehmen; bei einem Fehler bleibt die Reihenfolge für einen ausdrücklichen neuen Speicherversuch erhalten. Abbrechen verwirft gezielt nur die noch unbestätigten Dialogänderungen."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.complete-review": {
|
||||
"title": "Die Kampagnenprüfung abschließen",
|
||||
"summary": "Kritische Blocker lösen, einzelne Nachrichten entscheiden und unkritische Punkte für genau einen Build bestätigen.",
|
||||
"body": (
|
||||
"Das Öffnen der Vorlage ohne Bearbeitung, Änderungen des Schreibschutzes und der Wechsel zwischen visueller Ansicht und Quelltext erhalten das gespeicherte HTML unverändert und erfordern beim Verlassen kein Speichern. Der Prüfabschluss bleibt an aktuellen Build-Token, geprüfte Nachrichtenschlüssel, dokumentierte Problementscheidungen und Nachrichtennachweise gebunden. Normales Speichern sendet nur die clientverantworteten Metadaten created_from, field_overrides und opt_ins; review_send und approval_gate sind lesbare Servernachweise, aber keine schreibbaren Editorfelder. Werden sie bei einem Metadaten-Speichern ausgelassen, bleiben sie serverseitig erhalten. Die vorgesehenen Regeln für Entsperrung, Nachfolgeversionen und Build-Invalidierung entfernen veraltete Nachweise weiterhin. Bei notwendiger Mail-Altdatenmigration bleibt „Prüfen und Senden“ schreibgeschützt, unterdrückt inkompatible Anhangsvorschau-Anfragen und bietet „Mail-Einstellungen öffnen“ für genau die ausgewählte Version. Migrieren, validieren, bauen und prüfen Sie vor dem Senden erneut. Ausdrückliche Aktionen auf handlungsfähigen Empfänger-, Anhangs-, Validierungs- und Prüfkennzahlen öffnen Quellseite, Nachweisvorschau oder gefilterte Nachrichtentabelle. Reine Information und datenschutzunterdrückte Werte werden nicht zu versteckten Klickzielen. Änderungen an Empfängern, Inhalt, Anhängen, Eigentümerkontext oder nicht geheimer Transportidentität erfordern erneut Validierung, Build und Prüfung."
|
||||
" Gleichartige Prüfbedingungen bestätigen gruppiert ausschließlich vom Server zugelassene, ungeprüfte Nachrichten aus der aktuell geladenen passenden Auswahl. Wählen Sie eine verständlich benannte Kategorie, prüfen Sie die gezählte Empfängerauswahl und geben Sie bei Anhangsausnahmen eine gemeinsame Begründung an. Jede Anfrage benennt höchstens 200 konkrete Nachrichten und prüft aktuellen Build und Kategorie; wiederholen Sie dies für verbleibende Nachrichten, statt andere Kategorien oder nicht geladene Nachrichten als mitbestätigt anzusehen. Jede ausgewählte Nachricht erhält einen eigenen eingefrorenen, zuordenbaren Entscheidungsnachweis. Bei fehlgeschlagenem Speichern bleiben Begründung und Auswahl für einen ausdrücklichen Wiederholungsversuch erhalten; ein geänderter Build verhindert veraltete Bestätigungen. Die Gruppenbestätigung sendet keine Nachrichten und schließt die abschließende Prüfung nicht ab. Harte Blocker können nicht übergangen werden. Beabsichtigte richtlinienbedingte Ausschlüsse und ausdrücklich erlaubte Anhangsregeln ohne Treffer bleiben informativ und benötigen keine Prüfentscheidung."
|
||||
" Eine einzelne Annahme speichert Begründung und Prüfstatus sofort, schon vor dem vollständigen Prüfabschluss; Neuladen setzt den bestätigten Fortschritt desselben Builds fort. Bei fehlgeschlagenem Speichern oder einem Konflikt bleibt die Begründung für einen ausdrücklichen neuen Versuch erhalten; die Nachricht gilt noch nicht als geprüft. Jeder Speichervorgang ergänzt nur ausgewählte Nachrichten und erhält fremde Prüfnachweise, ohne Nachrichten neu zu bauen, Anhangsdateien zu prüfen oder den gesamten Arbeitsbereich neu zu laden. Teilfortschritt erlaubt keinen Versand; der abschließende Prüfabschluss kontrolliert weiterhin alle erforderlichen Entscheidungen und harten Blocker. Pflichtanhänge und harte Sperrrichtlinien bleiben gegenüber optional erlaubten leeren Treffern vorrangig; auch die getrennte Kampagnenrichtlinie für vollständig anhangslose Nachrichten gilt weiterhin."
|
||||
" Speichern benötigt die Campaign-Prüfberechtigung, die aktuelle Versionsrevision und den sicheren operativen Bezug review_build_token; Diagnoseberechtigung ist nicht erforderlich. Veraltete Builds oder gleichzeitige Änderungen führen zu einem Konflikt ohne Überschreiben gespeicherten Fortschritts."
|
||||
" Bestätigte oder erwartete Anhangsbedingungen bleiben für denselben Build auch bei „Bestätigen und senden“ erfüllt; fehlende oder mehrdeutige Quelltreffer bleiben als Kontext sichtbar, erzeugen aber keine zweite Bestätigungspflicht. Der Mock-Test nach der Prüfung verwendet verifizierte eingefrorene Nachrichten und abgeschlossene Entscheidungen statt einer Neuerstellung. Geänderte Eingaben, Problemnachweise, Nachrichtenbytes oder Mail-Transport stoppen den Test vor der Mock-Aufzeichnung; Entwurfsvorschauen behalten ihren getrennten vorläufigen Build."
|
||||
" Validierungsdetails und Listen mehrfach verwendeter Dateien zeigen alle Einträge über die zentrale DataGrid-Seitensteuerung. Zusammengehörige fehlende Regeltreffer und die Richtlinienfolge einer anhangslosen Nachricht werden gemeinsam erklärt; aufklappbare technische Nachweise bleiben erhalten. Nachrichtentabelle und Filter verwenden vier operative Zustände: Bereit, Prüfung erforderlich, Blockiert und Ausgeschlossen. Angenommene ausdrückliche Entscheidungen werden Bereit; noch unbestätigte Warnungen bleiben Prüfung erforderlich. Eine zweite Spalte erklärt den Zustand. Diese Darstellung löscht oder verändert keine eingefrorenen Probleme oder Auditnachweise."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.retry-and-reconcile": {
|
||||
"title": "Fehler wiederholen und unsichere Wirkungen abgleichen",
|
||||
"summary": "Sicher wiederholbare Fehler von Mail-, Postbox- oder IMAP-Wirkungen mit unbekanntem Ergebnis trennen.",
|
||||
"body": (
|
||||
"Eine Wiederholung erzeugt neuen Versuchsnachweis und ist nur für ausdrücklich geeignete Zustände zulässig. Unbekannte Mail-, Postbox- oder IMAP-Wirkungen dürfen nie blind wiederholt werden. Externe Nachweise sind zu prüfen und der betroffene Kanal vor dem Fortsetzen abzugleichen. Angenommene Mail-Versuche und Postbox-Ziele bleiben bei Teilwiederholungen unveränderlich; die Reparatur von „Gesendet“ versendet angenommene Mail nicht erneut."
|
||||
" Ohne Worker bietet der Bericht ausdrücklich bestätigte, begrenzte Wiederholung und Fortsetzung über dieselben unveränderlichen Aufträge, Ausführungsprüfungen, Prüfnachweise, Freigaben, Mail-Berechtigungen, Ratenbegrenzungen und Wiederherstellungsnachweise wie Jetzt senden. Jede Anfrage bleibt innerhalb der wirksamen synchronen Grenze und meldet verbleibende Arbeit; bereits angenommene, ausgeschlossene, aktive und ungewisse Aufträge werden nicht erneut gesendet. Wiederholung benötigt campaigns:campaign:retry und synchron zusätzlich campaigns:campaign:send; Fortsetzen benötigt campaigns:campaign:queue und campaigns:campaign:send. Abgleich benötigt campaigns:campaign:reconcile und eine sachliche Nachweisnotiz; er sendet nichts."
|
||||
" Ein festhängender übernommener, sendender oder kopierender Auftrag ist nicht allein durch Zeitablauf sicher. Der Bericht bietet die Wiederherstellung eines unterbrochenen Auftrags nur bei abgelaufener dauerhafter Sperre und nachweislich gestoppter oder ersetzter ursprünglicher Laufzeit. Die mitgesendete sichere Revision muss noch passen, und ursprüngliche Wiederherstellungsnachweise müssen gültig sein. Die Aktion setzt das Ergebnis ausschließlich auf ungewiss. Prüfen Sie Provider- beziehungsweise Postfachnachweise und gleichen Sie angenommen/nicht gesendet oder kopiert/nicht kopiert getrennt ab, bevor Sie ausdrücklich wiederholen. Fehlende Sperr- oder Versuchsnachweise und unbestätigte Besitzer bleiben zur betrieblichen Untersuchung gesperrt. Ein doppelter Worker-Aufruf verändert aktive Zustände nicht."
|
||||
),
|
||||
},
|
||||
"campaigns.reference.composition-assurance": {
|
||||
"title": "Die Campaign-Referenzkomposition absichern",
|
||||
"summary": "Campaign nur mit abgestimmten Verträgen, rollensicheren Oberflächen, dauerhaften Wirkungsnachweisen, optionaler Modultrennung und wiederherstellbaren Daten freigeben.",
|
||||
"body": (
|
||||
"Freigabeprüfungen müssen Campaign-Validierung und Anhangsauflösung unabhängig in frischen Prozessen initialisieren, ohne einen früheren Seiten- oder Testimport vorauszusetzen. Diese lokalen Einstiegspunkte bleiben ohne installiertes Mail oder Files nutzbar; ihr Import startet keinen Versand und lockert keine Pfadberechtigungen für verwaltete Dateien. "
|
||||
"Campaign ist nur dann Referenzkomposition, wenn Core, Mail, Files, Addresses, Worker, Speicher, Policies und Dokumentation in genau der installierten Kombination geprüft sind. Gewöhnliche Lesende sehen Fachzustand statt Pfaden, Speicherschlüsseln, Worker-Claims oder rohen Providerdiagnosen; Diagnose- und Exportbefugnis bleiben getrennt."
|
||||
" Prüfen Sie, dass einzelne Begründungen und Prüfzustände schon vor dem vollständigen Abschluss Neuladen überstehen. Teilfortschritt benötigt campaigns:campaign:review und Schreibzugriff, ergänzt genau ausgewählte Nachrichten des aktuellen Builds mit Revisionsprüfung und protokolliert Annahmen ohne fremde Prüfnachweise zu ersetzen. Der operative review_build_token legt keine rohen Diagnosetoken offen. Teilfortschritt erlaubt keinen Versand; harte Blocker sind nicht bestätigbar. Richtlinienbedingte Ausschlüsse und ausdrücklich erlaubte leere optionale Anhangsregeln erzeugen keine neue Prüfpflicht; Pflichtanhänge und globale harte Sperren bleiben wirksam. Diese Auflösungsänderungen gelten nur für neue Builds: Eine bewusste Neuerstellung klassifiziert vorhandene Nachrichten neu und entwertet frühere Prüf- und Freigabenachweise. Eingefrorene historische Auftragsprobleme dürfen nicht aus veränderlichen Richtlinien umgeschrieben werden."
|
||||
" Der ausdrückliche Mock-Modus use_reviewed_build benötigt einen vorhandenen versiegelten Ausführungsnachweis und bei prüfpflichtigen Nachrichten den Abschluss desselben Builds. Vor jeder Mock-Aufzeichnung oder angeforderten Leerung des Mock-Postfachs prüft er Auftrags- und Problemnachweise, EML-Länge, Digest, Message-ID und aktuellen Mail-Transport. Er sendet kein SMTP, verändert keinen Campaign-Zustellstatus und erzeugt keinen fehlenden Altdaten-Snapshot. include_needs_review ist in diesem Modus keine pauschale Umgehung."
|
||||
),
|
||||
},
|
||||
"campaigns.reference.shared-build-artifacts": {
|
||||
"title": "Gemeinsam genutzte Campaign-Build-Artefakte betreiben",
|
||||
"summary": "Erzeugte Nachrichten in gemeinsamem Objektspeicher ablegen und vor der Zustellung verifizieren.",
|
||||
"body": (
|
||||
"Campaign speichert erzeugte EML unter undurchsichtigen gemeinsamen Objektschlüsseln und protokolliert erwartete Größe, SHA-256-Digest und Message-ID je Auftrag. Worker auf anderen Knoten prüfen diesen Nachweis vor Zustellung. Vor Objekt- oder Files-Ausgaben zeichnet eine lease-gebundene Core-Recovery-Operation Quelle, validierte Version und reserviertes Präfix auf, prüft das Objekt und erneuert die Sperre vor dem Fach-Commit. Eine getrennte auftragsgebundene Operation erfasst vor realer Mail-, Postbox- oder Druckwirkung unveränderliche Nachrichten- und Empfängerdigests und verifiziert später den autoritativen Kanalversuch. Ablehnung, Annahme, unbekanntes Ergebnis und Recovery-Bedarf bleiben unterscheidbar. Objektfehler weisen Kompensation nach; Files-Ausgaben und unsichere Bereinigung bleiben Vorwärts-Recovery. Aufbewahrung ändert Locator kontrolliert und prüft Abwesenheit unabhängig. Ein reiner Betriebsabgleich inventarisiert begrenzte Mandantenpräfixe, schützt aktive Builds und mindestens 24 Stunden Karenz und löscht nur weiterhin unreferenzierte Objekte. Laufzeitobjektschlüssel sind keine Fachdaten."
|
||||
),
|
||||
},
|
||||
"campaigns.archive-encryption-governance": {
|
||||
"title": "Passwortgeschützte ZIP-Anhänge gesteuert verwenden",
|
||||
"summary": "Standardmäßig AES einsetzen und schwaches Windows-kompatibles ZipCrypto nur mit Policy, Berechtigung, Bestätigung und Nachweis wählen.",
|
||||
"body": (
|
||||
"Campaign löst Archivverschlüsselung über Policy auf System-, Mandanten-, Eigentümer- und Kampagnenebene auf. Passwortgeschützte Archive verwenden AES, außer die vollständig vererbte Richtlinie erlaubt Legacy ZipCrypto ausdrücklich und die handelnde Person besitzt campaigns:archive:use_legacy_zipcrypto. Die Legacy-Auswahl benötigt eine begründete Bestätigung. Passwörter erscheinen weder im Campaign-Nachweis noch in der Nachricht und müssen über den getrennt ausgewählten, per Policy erlaubten Kanal übermittelt werden. Jeder Build friert Archiv- und Mitglied-Hashes, Implementierungsversion, Policy-Hash und -Quellpfad, bestätigende Person, Begründung, Zeitpunkt und Build-Identität ein. Eine später strengere Policy blockiert Einreihen und Senden bis zum Neubau; nach Fehlern wird nie von AES auf ZipCrypto zurückgefallen. Temporärer Klartext und Archive bleiben im begrenzten Build-Verzeichnis und werden nach Erfolg oder Fehler entfernt."
|
||||
" Kampagneneinstellungen, Richtlinien und Anhänge zeigen die wirksame Richtlinie und führen berechtigte Administrierende direkt zu Administration → SYSTEM → Campaign archive encryption. Aktivieren Sie dort Legacy ZipCrypto und speichern Sie. Frische Systemstandardwerte sind bearbeitbar, ohne dass das bloße Öffnen bereits eine Ausnahme erzeugt. Mandanten- und Eigentümerrichtlinien können das Ergebnis weiter einschränken. Laden Sie anschließend in Campaign die Archivrichtlinie neu, wählen Sie Legacy ZipCrypto unter Anhänge → ZIP-Anhänge, bestätigen Sie die schwache Verschlüsselung und geben Sie eine betriebliche Begründung mit mindestens 10 Zeichen an. Ohne Policy bleibt Legacy gesperrt. Weder Richtlinien- noch Anhangskonfiguration versendet beim Speichern eine E-Mail."
|
||||
" Mail-Migration und Archivkorrekturen lassen sich unabhängig in beliebiger Reihenfolge speichern. Eine exakt unveränderte ZIP-Konfiguration bleibt bei einer anderen Korrektur ohne erneute Bestätigung erhalten, auch nach Entzug von Richtlinie oder Berechtigung; ihre Nutzung wird dadurch nicht erlaubt. Jede geänderte ZIP-Konfiguration muss aktuelle Methoden, Passwortkanäle und Legacy-Berechtigungs- sowie Bestätigungsvorgaben erfüllen. Eine Archivkorrektur mit unveränderten öffentlichen Mail-Referenzen erhält den alten Transport exakt serverseitig bis zur ausdrücklichen autorisierten Migration. Clients dürfen dabei weder Inline-Transport einführen oder zurücksenden noch Mail-Referenzen ändern oder Bestätigungsnachweise erfinden. Beide Korrekturen entwerten Ausführungs- und Build-Nachweise; Validierung, Prüfung, Erstellung und Versand bleiben bis zur Erfüllung aller Bedingungen gesperrt."
|
||||
),
|
||||
},
|
||||
"campaigns.workflow.link-exact-campaign-to-case": {
|
||||
"title": "Eine exakte Kampagnenreferenz mit einem aktiven Fall verknüpfen",
|
||||
"summary": "Eine autorisierte Kampagne und ihre aktuelle unveränderliche Version über Quick Access zurückgeben, ohne Kampagneninhalt zu kopieren.",
|
||||
"body": (
|
||||
"Ist ein Fall das aktive Objekt, stellt Campaigns in Quick Access eine begrenzte Auswahl bereit. Die normale Kampagnenliste prüft Mandant, Eigentum, Gruppe, Freigaben und Administrationszugriff vor der Anzeige. Die Auswahl liefert über den versionierten Ergebnisvertrag nur Eigentümermodul, stabile Kampagnen-ID, aktuelle Versions-ID, Anzeigetext, Mandant und Eigentümerroute. Cases verwirft den Anzeigetext und speichert keine Empfänger-, Nachrichten-, Anhangs-, Zustell-, Berichts- oder Konfigurationsinhalte. Beim Öffnen prüft Campaigns den Zugriff erneut. Deaktivierung, Widerruf oder Entfernung lässt daher nur eine nicht verfügbare historische Fallreferenz zurück und macht Fallzugriff nie zu Kampagnenzugriff."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def localize_documentation_topics(
|
||||
topics: Iterable[DocumentationTopic],
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
return _localize_topics(topics, locale="de", translations=_TRANSLATIONS)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,61 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from govoplan_core.core.approvals import (
|
||||
ApprovalCheck,
|
||||
ApprovalRequestCreateCommand,
|
||||
ApprovalRequestProvider,
|
||||
ApprovalRequestRef,
|
||||
CAPABILITY_APPROVAL_REQUESTS,
|
||||
)
|
||||
from govoplan_core.core.calendar import (
|
||||
CAPABILITY_CALENDAR_INVITATIONS,
|
||||
CalendarInvitationAttendeeRequest,
|
||||
CalendarInvitationCalendarRef,
|
||||
CalendarInvitationProvider,
|
||||
CalendarInvitationRef,
|
||||
CalendarInvitationRequest,
|
||||
)
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
PostboxDeliveryCatalogRef,
|
||||
PostboxDeliveryProvider,
|
||||
PostboxDeliveryReceiptSummaryRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxDeliveryResult,
|
||||
PostboxDirectoryEntryRef,
|
||||
PostboxDirectoryProvider,
|
||||
PostboxEvidenceProvider,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_core.core.templates import (
|
||||
CAPABILITY_TEMPLATE_CATALOG,
|
||||
CAPABILITY_TEMPLATE_CONTENT_LIBRARY,
|
||||
CAPABILITY_TEMPLATE_RENDERER,
|
||||
TemplateCatalogProvider,
|
||||
TemplateCompatibility,
|
||||
TemplateContentDraftRequest,
|
||||
TemplateContentLibraryProvider,
|
||||
TemplateRef,
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResult,
|
||||
TemplateRendererProvider,
|
||||
)
|
||||
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
|
||||
POSTBOX_EVIDENCE_CAPABILITY = CAPABILITY_POSTBOX_EVIDENCE
|
||||
APPROVALS_CAPABILITY = CAPABILITY_APPROVAL_REQUESTS
|
||||
TEMPLATE_CATALOG_CAPABILITY = CAPABILITY_TEMPLATE_CATALOG
|
||||
TEMPLATE_CONTENT_LIBRARY_CAPABILITY = CAPABILITY_TEMPLATE_CONTENT_LIBRARY
|
||||
TEMPLATE_RENDERER_CAPABILITY = CAPABILITY_TEMPLATE_RENDERER
|
||||
CALENDAR_INVITATIONS_CAPABILITY = CAPABILITY_CALENDAR_INVITATIONS
|
||||
|
||||
|
||||
class OptionalModuleUnavailable(RuntimeError):
|
||||
@@ -23,10 +73,22 @@ class SmtpConfigurationError(RuntimeError):
|
||||
|
||||
|
||||
class SmtpSendError(RuntimeError):
|
||||
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool = False,
|
||||
outcome_unknown: bool = False,
|
||||
systemic: bool = False,
|
||||
reason_code: str | None = None,
|
||||
phase: str = "send",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
self.outcome_unknown = outcome_unknown
|
||||
self.systemic = systemic
|
||||
self.reason_code = reason_code
|
||||
self.phase = phase
|
||||
|
||||
|
||||
class ImapConfigurationError(RuntimeError):
|
||||
@@ -50,6 +112,26 @@ class MailProfileError(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class MailDeliveryCommandError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PostboxDeliveryUnavailable(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class ApprovalGateUnavailable(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class TemplateOutputUnavailable(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class CalendarInvitationUnavailable(OptionalModuleUnavailable):
|
||||
pass
|
||||
|
||||
|
||||
class _PreparedCampaignSnapshot:
|
||||
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
|
||||
self._directory = directory
|
||||
@@ -78,20 +160,30 @@ class FilesCampaignIntegration:
|
||||
yield prepared
|
||||
return
|
||||
|
||||
raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
|
||||
raw_json = (
|
||||
kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
|
||||
)
|
||||
prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
|
||||
directory = Path(tempfile.mkdtemp(prefix=prefix))
|
||||
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json)
|
||||
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
snapshot = _PreparedCampaignSnapshot(
|
||||
directory, directory / "campaign.json", raw_json
|
||||
)
|
||||
snapshot.path.write_text(
|
||||
json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
try:
|
||||
yield snapshot
|
||||
finally:
|
||||
snapshot.cleanup()
|
||||
|
||||
def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
def managed_match_payloads(
|
||||
self, matches: Any, managed_files_by_local_path: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
if self._delegate is None:
|
||||
return []
|
||||
return self._delegate.managed_match_payloads(matches, managed_files_by_local_path)
|
||||
return self._delegate.managed_match_payloads(
|
||||
matches, managed_files_by_local_path
|
||||
)
|
||||
|
||||
def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
|
||||
if self._delegate is not None:
|
||||
@@ -102,20 +194,30 @@ class FilesCampaignIntegration:
|
||||
return dict(attachment)
|
||||
return {"path": str(attachment)}
|
||||
|
||||
def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None:
|
||||
def annotate_built_messages_with_managed_files(
|
||||
self, built_messages: Any, managed_files_by_local_path: dict[str, Any]
|
||||
) -> None:
|
||||
if self._delegate is not None:
|
||||
self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path)
|
||||
self._delegate.annotate_built_messages_with_managed_files(
|
||||
built_messages, managed_files_by_local_path
|
||||
)
|
||||
|
||||
def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None:
|
||||
def record_campaign_attachment_uses_for_jobs(
|
||||
self, session: Any, jobs: Any, *, stage: str
|
||||
) -> None:
|
||||
if self._delegate is not None:
|
||||
self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage)
|
||||
self._delegate.record_campaign_attachment_uses_for_jobs(
|
||||
session, jobs, stage=stage
|
||||
)
|
||||
|
||||
def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
|
||||
if self._delegate is None:
|
||||
raise OptionalModuleUnavailable("Files module is not available")
|
||||
return self._delegate.current_version_and_blob(session, asset)
|
||||
|
||||
def share_assets_with_campaign(self, session: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
def share_assets_with_campaign(
|
||||
self, session: Any, **kwargs: Any
|
||||
) -> list[dict[str, Any]]:
|
||||
if self._delegate is None:
|
||||
raise OptionalModuleUnavailable("Files module is not available")
|
||||
return self._delegate.share_assets_with_campaign(session, **kwargs)
|
||||
@@ -129,7 +231,9 @@ class MailCampaignIntegration:
|
||||
def __init__(self, delegate: Any | None = None) -> None:
|
||||
self._delegate = delegate
|
||||
if delegate is not None:
|
||||
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
|
||||
self.MailProfileError = getattr(
|
||||
delegate, "MailProfileError", MailProfileError
|
||||
)
|
||||
|
||||
MailProfileError = MailProfileError
|
||||
SmtpConfigurationError = SmtpConfigurationError
|
||||
@@ -141,31 +245,49 @@ class MailCampaignIntegration:
|
||||
def available(self) -> bool:
|
||||
return self._delegate is not None
|
||||
|
||||
@property
|
||||
def durable_delivery_available(self) -> bool:
|
||||
return self._delegate is not None and callable(
|
||||
getattr(self._delegate, "submit_delivery_command", None)
|
||||
)
|
||||
|
||||
def _require(self) -> Any:
|
||||
if self._delegate is None:
|
||||
raise MailProfileError("Mail module is not available")
|
||||
return self._delegate
|
||||
|
||||
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
|
||||
def assert_campaign_mail_policy_allows_json(
|
||||
self, session: Any, **kwargs: Any
|
||||
) -> None:
|
||||
if self._delegate is None:
|
||||
raw_json = kwargs.get("raw_json")
|
||||
profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {})
|
||||
profile_id = self.mail_profile_id_from_campaign_json(
|
||||
raw_json if isinstance(raw_json, dict) else {}
|
||||
)
|
||||
if profile_id:
|
||||
raise MailProfileError("Campaign mail-server profiles require the mail module")
|
||||
raise MailProfileError(
|
||||
"Campaign mail-server profiles require the mail module"
|
||||
)
|
||||
return None
|
||||
try:
|
||||
return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs)
|
||||
return self._delegate.assert_campaign_mail_policy_allows_json(
|
||||
session, **kwargs
|
||||
)
|
||||
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
|
||||
def mail_profile_id_from_campaign_json(
|
||||
self, raw_json: dict[str, Any]
|
||||
) -> str | None:
|
||||
if self._delegate is not None:
|
||||
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
|
||||
return str(profile_id).strip() if profile_id else None
|
||||
|
||||
def campaign_profile_delivery_summary(self, session: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
def campaign_profile_delivery_summary(
|
||||
self, session: Any, **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
delegate = self._require()
|
||||
try:
|
||||
return delegate.campaign_profile_delivery_summary(session, **kwargs)
|
||||
@@ -182,12 +304,67 @@ class MailCampaignIntegration:
|
||||
try:
|
||||
return delegate.send_campaign_email_bytes(*args, **kwargs)
|
||||
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
|
||||
raise SmtpSendError(
|
||||
str(exc),
|
||||
temporary=bool(getattr(exc, "temporary", False)),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
systemic=bool(getattr(exc, "systemic", False)),
|
||||
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||
phase=str(getattr(exc, "phase", "send") or "send"),
|
||||
) from exc
|
||||
except getattr(
|
||||
delegate, "SmtpConfigurationError", SmtpConfigurationError
|
||||
) as exc:
|
||||
raise SmtpConfigurationError(str(exc)) from exc
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
@contextmanager
|
||||
def campaign_smtp_batch(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
|
||||
delegate = self._require()
|
||||
method = getattr(delegate, "campaign_smtp_batch", None)
|
||||
if not callable(method):
|
||||
yield None
|
||||
return
|
||||
try:
|
||||
with method(*args, **kwargs) as state:
|
||||
yield state
|
||||
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
|
||||
raise SmtpSendError(
|
||||
str(exc),
|
||||
temporary=bool(getattr(exc, "temporary", False)),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
systemic=bool(getattr(exc, "systemic", False)),
|
||||
reason_code=str(getattr(exc, "reason_code", "") or "") or None,
|
||||
phase=str(getattr(exc, "phase", "preflight") or "preflight"),
|
||||
) from exc
|
||||
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
|
||||
raise SmtpConfigurationError(str(exc)) from exc
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
@contextmanager
|
||||
def campaign_imap_batch(self, *, tenant_id: str, campaign_id: str) -> Iterator[Any]:
|
||||
"""Use Mail-owned connection reuse when the installed capability offers it."""
|
||||
delegate = self._require()
|
||||
method = getattr(delegate, "campaign_imap_batch", None)
|
||||
if not callable(method):
|
||||
yield None
|
||||
return
|
||||
try:
|
||||
with method(tenant_id=tenant_id, campaign_id=campaign_id) as state:
|
||||
yield state
|
||||
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
|
||||
raise ImapAppendError(
|
||||
str(exc),
|
||||
temporary=getattr(exc, "temporary", None),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
) from exc
|
||||
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
||||
raise ImapConfigurationError(str(exc)) from exc
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
def append_campaign_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
|
||||
delegate = self._require()
|
||||
try:
|
||||
@@ -198,20 +375,547 @@ class MailCampaignIntegration:
|
||||
temporary=getattr(exc, "temporary", None),
|
||||
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
|
||||
) from exc
|
||||
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
|
||||
except getattr(
|
||||
delegate, "ImapConfigurationError", ImapConfigurationError
|
||||
) as exc:
|
||||
raise ImapConfigurationError(str(exc)) from exc
|
||||
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
|
||||
raise MailProfileError(str(exc)) from exc
|
||||
|
||||
def submit_delivery_command(self, session: Any, **kwargs: Any) -> dict[str, Any]:
|
||||
delegate = self._require()
|
||||
method = getattr(delegate, "submit_delivery_command", None)
|
||||
if not callable(method):
|
||||
raise MailDeliveryCommandError(
|
||||
"The installed Mail module does not provide durable delivery commands"
|
||||
)
|
||||
try:
|
||||
return dict(method(session, **kwargs))
|
||||
except Exception as exc:
|
||||
raise MailDeliveryCommandError(str(exc)) from exc
|
||||
|
||||
def delivery_command_summary(
|
||||
self,
|
||||
session: Any,
|
||||
*,
|
||||
tenant_id: str,
|
||||
command_id: str,
|
||||
) -> dict[str, Any]:
|
||||
delegate = self._require()
|
||||
method = getattr(delegate, "delivery_command_summary", None)
|
||||
if not callable(method):
|
||||
raise MailDeliveryCommandError(
|
||||
"The installed Mail module does not provide durable delivery status"
|
||||
)
|
||||
try:
|
||||
return dict(
|
||||
method(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
command_id=command_id,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
raise MailDeliveryCommandError(str(exc)) from exc
|
||||
|
||||
def mock_mailbox(self) -> Any | None:
|
||||
if self._delegate is None or not hasattr(self._delegate, "mock_mailbox"):
|
||||
return None
|
||||
return self._delegate.mock_mailbox()
|
||||
|
||||
|
||||
class PostboxCampaignIntegration:
|
||||
def __init__(
|
||||
self,
|
||||
delivery_delegate: object | None = None,
|
||||
directory_delegate: object | None = None,
|
||||
evidence_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
|
||||
)
|
||||
self._evidence_delegate = (
|
||||
evidence_delegate
|
||||
if isinstance(evidence_delegate, PostboxEvidenceProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return (
|
||||
self._delivery_delegate is not None and self._directory_delegate is not None
|
||||
)
|
||||
|
||||
@property
|
||||
def receipt_evidence_available(self) -> bool:
|
||||
return self._evidence_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 delivery_receipt_summaries(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
delivery_ids: list[str] | tuple[str, ...],
|
||||
) -> dict[str, PostboxDeliveryReceiptSummaryRef]:
|
||||
if self._evidence_delegate is None:
|
||||
return {}
|
||||
unique_ids = tuple(dict.fromkeys(delivery_ids))
|
||||
summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {}
|
||||
for offset in range(0, len(unique_ids), 500):
|
||||
summaries.update(
|
||||
self._evidence_delegate.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
producer_module="campaigns",
|
||||
delivery_ids=unique_ids[offset : offset + 500],
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
class ApprovalCampaignIntegration:
|
||||
def __init__(self, delegate: object | None = None) -> None:
|
||||
self._delegate = (
|
||||
delegate if isinstance(delegate, ApprovalRequestProvider) else None
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._delegate is not None
|
||||
|
||||
def create_request(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
command: ApprovalRequestCreateCommand,
|
||||
idempotency_key: str,
|
||||
) -> ApprovalRequestRef:
|
||||
if self._delegate is None:
|
||||
raise ApprovalGateUnavailable(
|
||||
"Campaign approval gates require the Approvals module."
|
||||
)
|
||||
return self._delegate.create_request(
|
||||
session,
|
||||
principal,
|
||||
command=command,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
def check_approved(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request_id: str,
|
||||
subject_module: str,
|
||||
subject_type: str,
|
||||
subject_id: str,
|
||||
subject_version: str | None,
|
||||
subject_digest: str,
|
||||
) -> ApprovalCheck:
|
||||
if self._delegate is None:
|
||||
raise ApprovalGateUnavailable(
|
||||
"Campaign delivery is approval-gated, but the Approvals module is unavailable."
|
||||
)
|
||||
return self._delegate.check_approved(
|
||||
session,
|
||||
principal,
|
||||
request_id=request_id,
|
||||
subject_module=subject_module,
|
||||
subject_type=subject_type,
|
||||
subject_id=subject_id,
|
||||
subject_version=subject_version,
|
||||
subject_digest=subject_digest,
|
||||
)
|
||||
|
||||
|
||||
class TemplatesCampaignIntegration:
|
||||
def __init__(
|
||||
self,
|
||||
catalog_delegate: object | None = None,
|
||||
renderer_delegate: object | None = None,
|
||||
content_library_delegate: object | None = None,
|
||||
) -> None:
|
||||
self._catalog = (
|
||||
catalog_delegate
|
||||
if isinstance(catalog_delegate, TemplateCatalogProvider)
|
||||
else None
|
||||
)
|
||||
self._renderer = (
|
||||
renderer_delegate
|
||||
if isinstance(renderer_delegate, TemplateRendererProvider)
|
||||
else None
|
||||
)
|
||||
self._content_library = (
|
||||
content_library_delegate
|
||||
if isinstance(content_library_delegate, TemplateContentLibraryProvider)
|
||||
else None
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._catalog is not None and self._renderer is not None
|
||||
|
||||
@property
|
||||
def content_available(self) -> bool:
|
||||
return self._catalog is not None
|
||||
|
||||
@property
|
||||
def content_writable(self) -> bool:
|
||||
return self._content_library is not None
|
||||
|
||||
def list_templates(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> tuple[TemplateRef, ...]:
|
||||
if self._catalog is None:
|
||||
return ()
|
||||
return tuple(
|
||||
self._catalog.list_templates(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
usage="campaign_print",
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
def list_content_templates(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
query: str = "",
|
||||
limit: int = 100,
|
||||
) -> tuple[TemplateRef, ...]:
|
||||
if self._catalog is None:
|
||||
return ()
|
||||
return tuple(
|
||||
self._catalog.list_templates(
|
||||
session,
|
||||
principal,
|
||||
query=query,
|
||||
usage="campaign.content",
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
def create_content_draft(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TemplateContentDraftRequest,
|
||||
) -> TemplateRef:
|
||||
if self._content_library is None:
|
||||
raise TemplateOutputUnavailable(
|
||||
"Saving reusable Campaign content requires the Templates content-library capability."
|
||||
)
|
||||
return self._content_library.create_content_draft(
|
||||
session,
|
||||
principal,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def check_compatibility(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
revision: int | None,
|
||||
output_format: str,
|
||||
available_fields: dict[str, str] | tuple[str, ...],
|
||||
) -> TemplateCompatibility:
|
||||
if self._catalog is None:
|
||||
raise TemplateOutputUnavailable(
|
||||
"Printable output is unavailable because Templates is not active."
|
||||
)
|
||||
return self._catalog.check_compatibility(
|
||||
session,
|
||||
principal,
|
||||
template_id=template_id,
|
||||
revision=revision,
|
||||
usage="campaign_print",
|
||||
output_format=output_format,
|
||||
available_fields=available_fields,
|
||||
)
|
||||
|
||||
def get_template(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
template_id: str,
|
||||
revision: int,
|
||||
) -> TemplateRef | None:
|
||||
if self._catalog is None:
|
||||
return None
|
||||
return self._catalog.get_template(
|
||||
session,
|
||||
principal,
|
||||
template_id=template_id,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
def render(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: TemplateRenderRequest,
|
||||
) -> TemplateRenderResult:
|
||||
if self._renderer is None:
|
||||
raise TemplateOutputUnavailable(
|
||||
"Printable output is unavailable because Templates is not active."
|
||||
)
|
||||
return self._renderer.render(session, principal, request=request)
|
||||
|
||||
|
||||
class CalendarCampaignIntegration:
|
||||
def __init__(self, delegate: object | None = None) -> None:
|
||||
self._delegate = (
|
||||
delegate if isinstance(delegate, CalendarInvitationProvider) else None
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._delegate is not None
|
||||
|
||||
def list_calendars(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
group_ids: tuple[str, ...] = (),
|
||||
can_admin: bool = False,
|
||||
) -> tuple[CalendarInvitationCalendarRef, ...]:
|
||||
if self._delegate is None:
|
||||
return ()
|
||||
return tuple(
|
||||
self._delegate.list_calendars(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
group_ids=group_ids,
|
||||
can_admin=can_admin,
|
||||
)
|
||||
)
|
||||
|
||||
def render_invitation(self, request: CalendarInvitationRequest) -> str:
|
||||
if self._delegate is None:
|
||||
raise CalendarInvitationUnavailable(
|
||||
"Calendar invitations require the optional Calendar module."
|
||||
)
|
||||
return self._delegate.render_invitation(request)
|
||||
|
||||
def upsert_invitation(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
request: CalendarInvitationRequest,
|
||||
) -> CalendarInvitationRef:
|
||||
if self._delegate is None:
|
||||
raise CalendarInvitationUnavailable(
|
||||
"Calendar invitations require the optional Calendar module."
|
||||
)
|
||||
return self._delegate.upsert_invitation(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
request=request,
|
||||
)
|
||||
|
||||
def get_invitations(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
correlation_ids: tuple[str, ...],
|
||||
) -> dict[str, CalendarInvitationRef]:
|
||||
if self._delegate is None or not correlation_ids:
|
||||
return {}
|
||||
result: dict[str, CalendarInvitationRef] = {}
|
||||
unique_ids = tuple(dict.fromkeys(correlation_ids))
|
||||
for offset in range(0, len(unique_ids), 500):
|
||||
result.update(
|
||||
self._delegate.get_invitations(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
correlation_ids=unique_ids[offset : offset + 500],
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
def summarize_invitations(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_resource_id: str | None,
|
||||
) -> dict[str, object]:
|
||||
if self._delegate is None:
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "The Calendar invitation capability is not active.",
|
||||
}
|
||||
return dict(
|
||||
self._delegate.summarize_invitations(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_version",
|
||||
source_resource_id=source_resource_id,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def request_from_payload(payload: dict[str, Any]) -> CalendarInvitationRequest:
|
||||
from datetime import datetime
|
||||
|
||||
attendees = tuple(
|
||||
CalendarInvitationAttendeeRequest(
|
||||
address=str(item.get("address") or ""),
|
||||
name=str(item["name"]) if item.get("name") else None,
|
||||
role=str(item.get("role") or "REQ-PARTICIPANT"),
|
||||
participation_status=str(
|
||||
item.get("participation_status") or "NEEDS-ACTION"
|
||||
),
|
||||
rsvp=bool(item.get("rsvp", True)),
|
||||
)
|
||||
for item in payload.get("attendees") or []
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return CalendarInvitationRequest(
|
||||
correlation_id=str(payload.get("correlation_id") or ""),
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_version",
|
||||
source_resource_id=(
|
||||
str(payload["source_resource_id"])
|
||||
if payload.get("source_resource_id")
|
||||
else None
|
||||
),
|
||||
calendar_id=(
|
||||
str(payload["calendar_id"]) if payload.get("calendar_id") else None
|
||||
),
|
||||
summary=str(payload.get("summary") or ""),
|
||||
description=(
|
||||
str(payload["description"]) if payload.get("description") else None
|
||||
),
|
||||
location=str(payload["location"]) if payload.get("location") else None,
|
||||
start_at=datetime.fromisoformat(str(payload.get("start_at") or "")),
|
||||
end_at=(
|
||||
datetime.fromisoformat(str(payload["end_at"]))
|
||||
if payload.get("end_at")
|
||||
else None
|
||||
),
|
||||
timezone=str(payload["timezone"]) if payload.get("timezone") else None,
|
||||
organizer=(
|
||||
dict(payload["organizer"])
|
||||
if isinstance(payload.get("organizer"), dict)
|
||||
else None
|
||||
),
|
||||
attendees=attendees,
|
||||
classification=str(payload.get("classification") or "PUBLIC"),
|
||||
categories=tuple(str(value) for value in payload.get("categories") or []),
|
||||
metadata=(
|
||||
dict(payload["metadata"])
|
||||
if isinstance(payload.get("metadata"), dict)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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),
|
||||
capability(POSTBOX_EVIDENCE_CAPABILITY),
|
||||
)
|
||||
|
||||
|
||||
def approvals_integration() -> ApprovalCampaignIntegration:
|
||||
return ApprovalCampaignIntegration(capability(APPROVALS_CAPABILITY))
|
||||
|
||||
|
||||
def templates_integration() -> TemplatesCampaignIntegration:
|
||||
return TemplatesCampaignIntegration(
|
||||
capability(TEMPLATE_CATALOG_CAPABILITY),
|
||||
capability(TEMPLATE_RENDERER_CAPABILITY),
|
||||
capability(TEMPLATE_CONTENT_LIBRARY_CAPABILITY),
|
||||
)
|
||||
|
||||
|
||||
def calendar_integration() -> CalendarCampaignIntegration:
|
||||
return CalendarCampaignIntegration(capability(CALENDAR_INVITATIONS_CAPABILITY))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_campaign.backend.services.filenames import FilenameAllocator
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid, formatdate
|
||||
from pathlib import Path
|
||||
@@ -16,26 +18,34 @@ from govoplan_campaign.backend.attachments.resolver import (
|
||||
EntryAttachmentResolution,
|
||||
MessageAttachmentStatus,
|
||||
ResolvedAttachment,
|
||||
effective_send_without_attachments_behavior,
|
||||
resolve_entry_attachments,
|
||||
)
|
||||
from govoplan_campaign.backend.attachments.reuse import evaluate_attachment_reuse
|
||||
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
|
||||
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
Behavior,
|
||||
AttachmentConfig,
|
||||
BuildStatus,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
MissingAddressBehavior,
|
||||
RecipientConfig,
|
||||
ResidualFileMode,
|
||||
SendStatus,
|
||||
TemplateBodyMode,
|
||||
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
|
||||
from govoplan_campaign.backend.services.zip_service import (
|
||||
create_zip_archive,
|
||||
zip_archive_evidence,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import (
|
||||
find_unresolved_placeholders as _find_unresolved_placeholders,
|
||||
render_template as _render_template,
|
||||
@@ -88,6 +98,14 @@ class _MimeBuildResult:
|
||||
build_status: BuildStatus
|
||||
validation_status: MessageValidationStatus
|
||||
attachment_count: int
|
||||
archive_evidence: list[dict[str, object]] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ResidualFileGroup:
|
||||
source_name: str
|
||||
directory: Path
|
||||
files: list[Path]
|
||||
|
||||
|
||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
@@ -171,6 +189,11 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
|
||||
label=attachment.label,
|
||||
status=attachment.status.value,
|
||||
behavior=attachment.behavior.value if attachment.behavior else None,
|
||||
missing_policy=(
|
||||
attachment.missing_policy.model_dump(mode="json")
|
||||
if attachment.missing_policy
|
||||
else None
|
||||
),
|
||||
required=attachment.required,
|
||||
allow_multiple=attachment.allow_multiple,
|
||||
zip_enabled=attachment.zip_enabled,
|
||||
@@ -199,6 +222,7 @@ def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolu
|
||||
message=issue.message,
|
||||
behavior=issue.behavior.value if issue.behavior else None,
|
||||
source="attachments",
|
||||
details=issue.details,
|
||||
)
|
||||
for issue in resolution.issues
|
||||
]
|
||||
@@ -218,12 +242,6 @@ def _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
|
||||
return config.attachments.send_without_attachments_behavior or (
|
||||
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
|
||||
)
|
||||
|
||||
|
||||
def _safe_filename(value: str | None, fallback: str) -> str:
|
||||
raw = value or fallback
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
|
||||
@@ -287,15 +305,8 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i
|
||||
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
|
||||
|
||||
|
||||
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
|
||||
candidate = filename
|
||||
path = Path(filename)
|
||||
counter = 2
|
||||
while candidate.casefold() in used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
used.add(candidate.casefold())
|
||||
return candidate
|
||||
def _unique_attachment_filename(filename: str, used: FilenameAllocator) -> str:
|
||||
return used.allocate(filename)
|
||||
|
||||
|
||||
def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]:
|
||||
@@ -360,12 +371,13 @@ def _attach_files(
|
||||
resolution: EntryAttachmentResolution,
|
||||
values: dict[str, Any],
|
||||
work_dir: Path,
|
||||
) -> int:
|
||||
) -> tuple[int, list[dict[str, object]]]:
|
||||
attached_count = 0
|
||||
evidence: list[dict[str, object]] = []
|
||||
archive_members: dict[str, list[tuple[Path, str]]] = {}
|
||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||
used_message_filenames: set[str] = set()
|
||||
used_zip_member_filenames: dict[str, set[str]] = {}
|
||||
used_message_filenames = FilenameAllocator()
|
||||
used_zip_member_filenames: dict[str, FilenameAllocator] = {}
|
||||
|
||||
for attachment in resolution.attachments:
|
||||
attachment.message_filenames = []
|
||||
@@ -377,7 +389,7 @@ def _attach_files(
|
||||
continue
|
||||
match_paths = [Path(match) for match in attachment.matches]
|
||||
if attachment.zip_enabled and attachment.zip_archive_id:
|
||||
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set())
|
||||
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, FilenameAllocator())
|
||||
for position, path in enumerate(match_paths, start=1):
|
||||
requested = _render_attachment_filename(
|
||||
template=attachment.zip_entry_name_template,
|
||||
@@ -417,16 +429,47 @@ def _attach_files(
|
||||
password,
|
||||
archive.method.value,
|
||||
)
|
||||
archive_record = zip_archive_evidence(
|
||||
archive_path,
|
||||
members,
|
||||
password_protected=bool(password),
|
||||
method=archive.method.value,
|
||||
)
|
||||
archive_record.update(
|
||||
{
|
||||
"archive_id": archive.id,
|
||||
"filename": filename,
|
||||
"password_delivery_channel": (
|
||||
archive.password_delivery_channel.value if password else None
|
||||
),
|
||||
"legacy_acknowledgement": (
|
||||
{
|
||||
"actor_id": archive.legacy_zipcrypto_acknowledged_by,
|
||||
"reason": archive.legacy_zipcrypto_reason,
|
||||
"recorded_at": archive.legacy_zipcrypto_acknowledged_at,
|
||||
}
|
||||
if archive.method.value == "zip_standard"
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
evidence.append(archive_record)
|
||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
attached_count += 1
|
||||
for attachment in archive_attachments.get(archive.id, []):
|
||||
attachment.zip_filename = filename
|
||||
|
||||
return attached_count
|
||||
return attached_count, evidence
|
||||
|
||||
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
|
||||
|
||||
@@ -509,6 +552,7 @@ def _message_draft(
|
||||
imap_status: ImapStatus | None = None,
|
||||
subject: str | None = None,
|
||||
attachment_count: int = 0,
|
||||
archive_evidence: list[dict[str, object]] | None = None,
|
||||
issues: list[MessageIssue] | None = None,
|
||||
eml_path: str | None = None,
|
||||
eml_size: int | None = None,
|
||||
@@ -520,7 +564,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 +577,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),
|
||||
@@ -540,6 +592,7 @@ def _message_draft(
|
||||
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
|
||||
attachment_count=attachment_count,
|
||||
attachments=_attachment_summaries(context.resolution),
|
||||
archive_evidence=archive_evidence or [],
|
||||
issues=issues if issues is not None else context.issues,
|
||||
eml_path=eml_path,
|
||||
eml_size_bytes=eml_size,
|
||||
@@ -737,7 +790,7 @@ def _build_mime_message(
|
||||
_populate_message_body(message, rendered)
|
||||
if work_dir is None:
|
||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
|
||||
attachment_count = _attach_files(
|
||||
attachment_count, archive_evidence = _attach_files(
|
||||
message=message,
|
||||
config=config,
|
||||
entry=entry,
|
||||
@@ -746,7 +799,11 @@ def _build_mime_message(
|
||||
values=rendered.values,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
|
||||
if (
|
||||
attachment_count == 0
|
||||
and context.resolution.attachments
|
||||
and effective_send_without_attachments_behavior(config) == Behavior.BLOCK
|
||||
):
|
||||
_append_no_attachment_coverage_issue(context.issues)
|
||||
return _MimeBuildResult(
|
||||
message=None,
|
||||
@@ -759,6 +816,7 @@ def _build_mime_message(
|
||||
build_status=BuildStatus.BUILT,
|
||||
validation_status=context.validation_status,
|
||||
attachment_count=attachment_count,
|
||||
archive_evidence=archive_evidence,
|
||||
)
|
||||
except ZipBuildError as exc:
|
||||
context.issues.append(
|
||||
@@ -770,6 +828,16 @@ def _build_mime_message(
|
||||
source="attachments",
|
||||
)
|
||||
)
|
||||
except OSError as exc:
|
||||
context.issues.append(
|
||||
MessageIssue(
|
||||
severity="error",
|
||||
code="attachment_unreadable",
|
||||
message="A resolved attachment could not be read while building the message.",
|
||||
behavior="block",
|
||||
source=f"attachments:{type(exc).__name__}",
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
context.issues.append(
|
||||
MessageIssue(
|
||||
@@ -809,17 +877,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,
|
||||
@@ -851,6 +921,7 @@ def build_entry_message(
|
||||
validation_status=mime_result.validation_status,
|
||||
subject=rendered.subject,
|
||||
attachment_count=mime_result.attachment_count,
|
||||
archive_evidence=mime_result.archive_evidence,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
)
|
||||
@@ -858,17 +929,13 @@ def build_entry_message(
|
||||
|
||||
|
||||
|
||||
def _unsent_attachment_issues(
|
||||
def _residual_attachment_files(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
built_messages: list[BuiltMessage],
|
||||
attachment_match_index: AttachmentMatchIndex | None = None,
|
||||
) -> list[MessageIssue]:
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if behavior == Behavior.CONTINUE.value:
|
||||
return []
|
||||
|
||||
) -> list[_ResidualFileGroup]:
|
||||
matched_files = {
|
||||
Path(match).resolve()
|
||||
for built in built_messages
|
||||
@@ -876,7 +943,7 @@ def _unsent_attachment_issues(
|
||||
for match in attachment.matches
|
||||
}
|
||||
|
||||
issues: list[MessageIssue] = []
|
||||
groups: list[_ResidualFileGroup] = []
|
||||
for base_path in config.attachments.base_paths:
|
||||
if not base_path.unsent_warning:
|
||||
continue
|
||||
@@ -890,20 +957,216 @@ def _unsent_attachment_issues(
|
||||
unsent = [path for path in all_files if path not in matched_files]
|
||||
if not unsent:
|
||||
continue
|
||||
groups.append(
|
||||
_ResidualFileGroup(
|
||||
source_name=base_path.name,
|
||||
directory=directory,
|
||||
files=unsent,
|
||||
)
|
||||
)
|
||||
return groups
|
||||
|
||||
|
||||
def _unsent_attachment_issues(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
residual_groups: list[_ResidualFileGroup],
|
||||
) -> list[MessageIssue]:
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if (
|
||||
behavior == Behavior.CONTINUE.value
|
||||
or config.attachments.residual_files.mode != ResidualFileMode.NONE
|
||||
):
|
||||
return []
|
||||
issues: list[MessageIssue] = []
|
||||
for group in residual_groups:
|
||||
unsent = group.files
|
||||
directory = group.directory
|
||||
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
|
||||
if len(unsent) > 10:
|
||||
shown += f", … (+{len(unsent) - 10} more)"
|
||||
issues.append(
|
||||
_issue_from_behavior(
|
||||
code="unsent_attachment_files",
|
||||
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
|
||||
message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}",
|
||||
behavior=behavior,
|
||||
source=f"attachments:{base_path.name}",
|
||||
source=f"attachments:{group.source_name}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _build_residual_file_message(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: Path,
|
||||
residual_groups: list[_ResidualFileGroup],
|
||||
entry_index: int,
|
||||
output_dir: Path | None,
|
||||
write_eml: bool,
|
||||
work_dir: Path,
|
||||
attachment_match_index: AttachmentMatchIndex,
|
||||
) -> BuiltMessage | None:
|
||||
disposition = config.attachments.residual_files
|
||||
if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None:
|
||||
return None
|
||||
files = sorted({path.resolve() for group in residual_groups for path in group.files})
|
||||
if not files:
|
||||
return None
|
||||
file_lines = [
|
||||
f"{group.source_name}: {path.relative_to(group.directory)}"
|
||||
for group in residual_groups
|
||||
for path in group.files
|
||||
]
|
||||
entry = EntryConfig(
|
||||
id="__residual_files__",
|
||||
to=[disposition.recipient],
|
||||
merge_to=False,
|
||||
combine_attachments=True,
|
||||
fields={
|
||||
"campaign_name": config.campaign.name,
|
||||
"residual_file_count": len(files),
|
||||
"residual_file_list": "\n".join(file_lines),
|
||||
},
|
||||
)
|
||||
residual_config = config.model_copy(deep=True)
|
||||
residual_config.attachments.global_ = []
|
||||
residual_config.attachments.zip.enabled = False
|
||||
if disposition.mode == ResidualFileMode.ATTACH:
|
||||
residual_config.attachments.global_ = [
|
||||
AttachmentConfig(
|
||||
id=f"residual-file-{index}",
|
||||
label=path.name,
|
||||
base_dir=str(path.parent),
|
||||
file_filter=path.name,
|
||||
required=True,
|
||||
allow_multiple=False,
|
||||
)
|
||||
for index, path in enumerate(files, start=1)
|
||||
]
|
||||
context = _entry_message_context(
|
||||
config=residual_config,
|
||||
campaign_file=campaign_file,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
attachment_match_index=attachment_match_index,
|
||||
)
|
||||
context.validation_status = _validate_required_sender(
|
||||
context.senders,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
context.validation_status = _validate_required_recipients(
|
||||
residual_config,
|
||||
context.recipients,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
values = build_template_values(residual_config, entry)
|
||||
rendered = _RenderedMessageTemplate(
|
||||
subject=_render_template(disposition.subject, values, keep_missing=True),
|
||||
text_body=_render_template(disposition.text, values, keep_missing=True),
|
||||
html_body=None,
|
||||
body_mode=TemplateBodyMode.TEXT.value,
|
||||
values=values,
|
||||
)
|
||||
context.validation_status = _validate_rendered_template(
|
||||
residual_config,
|
||||
rendered,
|
||||
context.issues,
|
||||
context.validation_status,
|
||||
)
|
||||
context.issues.append(
|
||||
MessageIssue(
|
||||
severity="warning",
|
||||
code="residual_attachment_disposition",
|
||||
message=(
|
||||
f"{len(files)} unassigned file(s) are routed as a reviewed "
|
||||
f"{disposition.mode.value} message."
|
||||
),
|
||||
behavior="ask",
|
||||
source="attachments:residual_files",
|
||||
details={
|
||||
"mode": disposition.mode.value,
|
||||
"file_count": len(files),
|
||||
"source_count": len(residual_groups),
|
||||
},
|
||||
)
|
||||
)
|
||||
if context.validation_status not in {
|
||||
MessageValidationStatus.BLOCKED,
|
||||
MessageValidationStatus.EXCLUDED,
|
||||
}:
|
||||
context.validation_status = MessageValidationStatus.NEEDS_REVIEW
|
||||
mime_result = _build_mime_message(
|
||||
config=residual_config,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
output_dir=output_dir,
|
||||
work_dir=work_dir,
|
||||
context=context,
|
||||
rendered=rendered,
|
||||
)
|
||||
eml_path: str | None = None
|
||||
eml_size: int | None = None
|
||||
if write_eml and output_dir is not None and mime_result.message is not None:
|
||||
eml_path, eml_size = _write_eml(
|
||||
mime_result.message,
|
||||
output_dir,
|
||||
entry,
|
||||
entry_index,
|
||||
)
|
||||
return BuiltMessage(
|
||||
draft=_message_draft(
|
||||
config=residual_config,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
context=context,
|
||||
build_status=mime_result.build_status,
|
||||
validation_status=mime_result.validation_status,
|
||||
subject=rendered.subject,
|
||||
attachment_count=mime_result.attachment_count,
|
||||
archive_evidence=mime_result.archive_evidence,
|
||||
eml_path=eml_path,
|
||||
eml_size=eml_size,
|
||||
),
|
||||
mime=mime_result.message,
|
||||
)
|
||||
|
||||
|
||||
def _residual_file_disposition_evidence(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
residual_groups: list[_ResidualFileGroup],
|
||||
) -> dict[str, object]:
|
||||
disposition = config.attachments.residual_files
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if disposition.mode == ResidualFileMode.REPORT:
|
||||
action = "route_report"
|
||||
elif disposition.mode == ResidualFileMode.ATTACH:
|
||||
action = "route_with_files"
|
||||
elif behavior == Behavior.BLOCK.value:
|
||||
action = "block"
|
||||
elif behavior in {Behavior.CONTINUE.value, Behavior.DROP.value}:
|
||||
action = "ignore"
|
||||
else:
|
||||
action = "review"
|
||||
recipient = (
|
||||
disposition.recipient.model_dump(mode="json", by_alias=True)
|
||||
if disposition.recipient is not None
|
||||
else None
|
||||
)
|
||||
return {
|
||||
"contract_version": "1",
|
||||
"action": action,
|
||||
"routing_mode": disposition.mode.value,
|
||||
"validation_behavior": behavior,
|
||||
"watched_source_count": len(residual_groups),
|
||||
"residual_file_count": sum(len(group.files) for group in residual_groups),
|
||||
"recipient": recipient,
|
||||
}
|
||||
|
||||
|
||||
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
|
||||
if not issues:
|
||||
return
|
||||
@@ -917,6 +1180,28 @@ def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: lis
|
||||
status = _apply_behavior(status, issue.behavior)
|
||||
built.draft.validation_status = status
|
||||
|
||||
|
||||
def _apply_attachment_reuse_policy(
|
||||
config: CampaignConfig,
|
||||
built_messages: list[BuiltMessage],
|
||||
) -> dict[str, object]:
|
||||
evaluation = evaluate_attachment_reuse(
|
||||
[built.draft for built in built_messages],
|
||||
policy=config.attachments.reuse_policy,
|
||||
)
|
||||
for built in built_messages:
|
||||
issues = evaluation.issues_by_entry_index.get(built.draft.entry_index, [])
|
||||
if not issues:
|
||||
continue
|
||||
built.draft.issues.extend(issues)
|
||||
for issue in issues:
|
||||
if issue.behavior:
|
||||
built.draft.validation_status = _apply_behavior(
|
||||
built.draft.validation_status,
|
||||
issue.behavior,
|
||||
)
|
||||
return evaluation.report
|
||||
|
||||
def build_campaign_messages(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
@@ -946,15 +1231,35 @@ def build_campaign_messages(
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
if entry.active
|
||||
]
|
||||
residual_groups = _residual_attachment_files(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
built_messages=built_messages,
|
||||
attachment_match_index=attachment_match_index,
|
||||
)
|
||||
_apply_campaign_level_issues(
|
||||
built_messages,
|
||||
_unsent_attachment_issues(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
built_messages=built_messages,
|
||||
attachment_match_index=attachment_match_index,
|
||||
residual_groups=residual_groups,
|
||||
),
|
||||
)
|
||||
residual_message = _build_residual_file_message(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
residual_groups=residual_groups,
|
||||
entry_index=len(entries) + 1,
|
||||
output_dir=output_path,
|
||||
write_eml=write_eml,
|
||||
work_dir=work_dir,
|
||||
attachment_match_index=attachment_match_index,
|
||||
)
|
||||
if residual_message is not None:
|
||||
built_messages.append(residual_message)
|
||||
attachment_reuse = _apply_attachment_reuse_policy(
|
||||
config,
|
||||
built_messages,
|
||||
)
|
||||
|
||||
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
|
||||
report = CampaignBuildReport(
|
||||
@@ -968,5 +1273,10 @@ def build_campaign_messages(
|
||||
duration_ms=(time.perf_counter() - started) * 1000,
|
||||
rules_resolved=rules_resolved,
|
||||
),
|
||||
attachment_reuse=attachment_reuse,
|
||||
residual_file_disposition=_residual_file_disposition_evidence(
|
||||
config=config,
|
||||
residual_groups=residual_groups,
|
||||
),
|
||||
)
|
||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||
|
||||
@@ -33,6 +33,7 @@ class MessageIssue(BaseModel):
|
||||
message: str
|
||||
behavior: str | None = None
|
||||
source: str | None = None
|
||||
details: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MessageAddress(BaseModel):
|
||||
@@ -49,6 +50,7 @@ class MessageAttachmentSummary(BaseModel):
|
||||
label: str | None = None
|
||||
status: str
|
||||
behavior: str | None = None
|
||||
missing_policy: dict[str, object] | None = None
|
||||
required: bool
|
||||
allow_multiple: bool
|
||||
zip_enabled: bool
|
||||
@@ -78,6 +80,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")
|
||||
@@ -91,6 +94,7 @@ class MessageDraft(BaseModel):
|
||||
|
||||
attachment_count: int = 0
|
||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||
archive_evidence: list[dict[str, object]] = Field(default_factory=list)
|
||||
issues: list[MessageIssue] = Field(default_factory=list)
|
||||
|
||||
eml_path: str | None = None
|
||||
@@ -114,6 +118,8 @@ class CampaignBuildReport(BaseModel):
|
||||
inactive_entries_count: int = 0
|
||||
messages: list[MessageDraft] = Field(default_factory=list)
|
||||
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
|
||||
attachment_reuse: dict[str, object] = Field(default_factory=dict)
|
||||
residual_file_disposition: dict[str, object] = Field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def built_count(self) -> int:
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""add campaign schedule optimistic-concurrency revisions
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2026-08-07 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"a5b6c7d8e9f0_v0120_campaign_schedule_revisions"
|
||||
)
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_migration.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_migration.downgrade()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
"""Development wrapper for the canonical printable-delivery migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"b7c8d9e0f1a2_campaign_print_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
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""add audit-proof single-message action ledger
|
||||
|
||||
Revision ID: c1a69e4f2b70
|
||||
Revises: f0a1b2c3d4e5
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
message_actions = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions.c1a69e4f2b70_campaign_message_actions"
|
||||
)
|
||||
|
||||
|
||||
revision = message_actions.revision
|
||||
down_revision = message_actions.down_revision
|
||||
branch_labels = message_actions.branch_labels
|
||||
depends_on = message_actions.depends_on
|
||||
upgrade = message_actions.upgrade
|
||||
downgrade = message_actions.downgrade
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""add monotonic campaign version edit revision
|
||||
|
||||
Revision ID: d2b7af503c81
|
||||
Revises: c1a69e4f2b70
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
edit_revision = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions.d2b7af503c81_campaign_version_edit_revision"
|
||||
)
|
||||
|
||||
|
||||
revision = edit_revision.revision
|
||||
down_revision = edit_revision.down_revision
|
||||
branch_labels = edit_revision.branch_labels
|
||||
depends_on = edit_revision.depends_on
|
||||
upgrade = edit_revision.upgrade
|
||||
downgrade = edit_revision.downgrade
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
"""add non-destructive historical campaign version archival
|
||||
|
||||
Revision ID: e3c8f4a5b6d7
|
||||
Revises: b7c8d9e0f1a2, d2b7af503c81
|
||||
Create Date: 2026-08-03 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e3c8f4a5b6d7"
|
||||
down_revision = ("b7c8d9e0f1a2", "d2b7af503c81")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("campaign_versions")
|
||||
}
|
||||
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||
if "archived_at" not in columns:
|
||||
batch_op.add_column(
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
if "archived_by_user_id" not in columns:
|
||||
batch_op.add_column(
|
||||
sa.Column("archived_by_user_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_campaign_versions_archived_by_user_id_access_users",
|
||||
"access_users",
|
||||
["archived_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in sa.inspect(op.get_bind()).get_indexes("campaign_versions")
|
||||
}
|
||||
if "ix_campaign_versions_archived_at" not in indexes:
|
||||
op.create_index(
|
||||
"ix_campaign_versions_archived_at",
|
||||
"campaign_versions",
|
||||
["archived_at"],
|
||||
unique=False,
|
||||
)
|
||||
if "ix_campaign_versions_archived_by_user_id" not in indexes:
|
||||
op.create_index(
|
||||
"ix_campaign_versions_archived_by_user_id",
|
||||
"campaign_versions",
|
||||
["archived_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("campaign_versions")
|
||||
}
|
||||
for index_name in (
|
||||
"ix_campaign_versions_archived_by_user_id",
|
||||
"ix_campaign_versions_archived_at",
|
||||
):
|
||||
if index_name in indexes:
|
||||
op.drop_index(index_name, table_name="campaign_versions")
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in sa.inspect(op.get_bind()).get_columns("campaign_versions")
|
||||
}
|
||||
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||
if "archived_by_user_id" in columns:
|
||||
batch_op.drop_constraint(
|
||||
"fk_campaign_versions_archived_by_user_id_access_users",
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch_op.drop_column("archived_by_user_id")
|
||||
if "archived_at" in columns:
|
||||
batch_op.drop_column("archived_at")
|
||||
+32
@@ -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
|
||||
+22
@@ -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
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
"""add durable campaign schedules and occurrence evidence
|
||||
|
||||
Revision ID: f4a5b6c7d8e9
|
||||
Revises: e3c8f4a5b6d7
|
||||
Create Date: 2026-08-07 10:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
_migration = import_module(
|
||||
"govoplan_campaign.backend.migrations.versions."
|
||||
"f4a5b6c7d8e9_v0119_campaign_schedules"
|
||||
)
|
||||
revision = _migration.revision
|
||||
down_revision = _migration.down_revision
|
||||
branch_labels = _migration.branch_labels
|
||||
depends_on = _migration.depends_on
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_migration.upgrade()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
_migration.downgrade()
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"""add campaign schedule optimistic-concurrency revisions
|
||||
|
||||
Revision ID: a5b6c7d8e9f0
|
||||
Revises: f4a5b6c7d8e9
|
||||
Create Date: 2026-08-07 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "a5b6c7d8e9f0"
|
||||
down_revision = "f4a5b6c7d8e9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||
if "resource_revision" not in columns:
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column(
|
||||
"resource_revision",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
columns = {column["name"] for column in inspector.get_columns("campaign_schedules")}
|
||||
if "resource_revision" in columns:
|
||||
op.drop_column("campaign_schedules", "resource_revision")
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""add governed autonomous Campaign schedule evidence
|
||||
|
||||
revision = "b6c7d8e9f0a1"
|
||||
down_revision = "a5b6c7d8e9f0"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "b6c7d8e9f0a1"
|
||||
down_revision = "a5b6c7d8e9f0"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("delivery_mode", sa.String(length=20), nullable=False, server_default="manual"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedules_delivery_mode",
|
||||
"campaign_schedules",
|
||||
["delivery_mode"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("approved_execution_snapshot_hash", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||
"campaign_schedules",
|
||||
["approved_execution_snapshot_hash"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("last_outcome", sa.String(length=30), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedules",
|
||||
sa.Column("last_recovery_state", sa.String(length=30), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("idempotency_key", sa.String(length=200), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||
"campaign_schedule_occurrences",
|
||||
["idempotency_key"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("delivery_command_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("recovery_state", sa.String(length=30), nullable=False, server_default="none"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_schedule_occurrences_recovery_state",
|
||||
"campaign_schedule_occurrences",
|
||||
["recovery_state"],
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("evidence", sa.JSON(), nullable=False, server_default="{}"),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("last_checked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("campaign_schedule_occurrences", "last_checked_at")
|
||||
op.drop_column("campaign_schedule_occurrences", "evidence")
|
||||
op.drop_index(
|
||||
"ix_campaign_schedule_occurrences_recovery_state",
|
||||
table_name="campaign_schedule_occurrences",
|
||||
)
|
||||
op.drop_column("campaign_schedule_occurrences", "recovery_state")
|
||||
op.drop_column("campaign_schedule_occurrences", "delivery_command_ids")
|
||||
op.drop_index(
|
||||
"ix_campaign_schedule_occurrences_idempotency_key",
|
||||
table_name="campaign_schedule_occurrences",
|
||||
)
|
||||
op.drop_column("campaign_schedule_occurrences", "idempotency_key")
|
||||
op.drop_column("campaign_schedules", "last_recovery_state")
|
||||
op.drop_column("campaign_schedules", "last_outcome")
|
||||
op.drop_index(
|
||||
"ix_campaign_schedules_approved_execution_snapshot_hash",
|
||||
table_name="campaign_schedules",
|
||||
)
|
||||
op.drop_column("campaign_schedules", "approved_execution_snapshot_hash")
|
||||
op.drop_index("ix_campaign_schedules_delivery_mode", table_name="campaign_schedules")
|
||||
op.drop_column("campaign_schedules", "delivery_mode")
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
"""add governed campaign printable delivery
|
||||
|
||||
Revision ID: b7c8d9e0f1a2
|
||||
Revises: f0a1b2c3d4e5
|
||||
Create Date: 2026-08-02 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "b7c8d9e0f1a2"
|
||||
down_revision = "f0a1b2c3d4e5"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"print_status",
|
||||
sa.String(length=50),
|
||||
nullable=False,
|
||||
server_default="not_requested",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"print_attempt_count",
|
||||
sa.Integer(),
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column("resolved_print_output", sa.JSON(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"campaign_jobs",
|
||||
sa.Column(
|
||||
"delivery_provenance",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default="{}",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
op.f("ix_campaign_jobs_print_status"),
|
||||
"campaign_jobs",
|
||||
["print_status"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_table(
|
||||
"campaign_print_output_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("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("render_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("artifact_sha256", sa.String(length=64), nullable=True),
|
||||
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||
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_print_output_attempts_job_id_campaign_jobs"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_campaign_print_output_attempts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"job_id",
|
||||
"attempt_number",
|
||||
name="uq_campaign_print_attempt_job_number",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_campaign_print_attempt_idempotency",
|
||||
),
|
||||
)
|
||||
for column in ("tenant_id", "job_id", "status", "render_id", "artifact_sha256"):
|
||||
op.create_index(
|
||||
op.f(f"ix_campaign_print_output_attempts_{column}"),
|
||||
"campaign_print_output_attempts",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("campaign_print_output_attempts")
|
||||
op.drop_index(
|
||||
op.f("ix_campaign_jobs_print_status"),
|
||||
table_name="campaign_jobs",
|
||||
)
|
||||
op.drop_column("campaign_jobs", "resolved_print_output")
|
||||
op.drop_column("campaign_jobs", "delivery_provenance")
|
||||
op.drop_column("campaign_jobs", "print_attempt_count")
|
||||
op.drop_column("campaign_jobs", "print_status")
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
"""add audit-proof single-message action ledger
|
||||
|
||||
Revision ID: c1a69e4f2b70
|
||||
Revises: f0a1b2c3d4e5
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c1a69e4f2b70"
|
||||
down_revision = "f0a1b2c3d4e5"
|
||||
branch_labels = None
|
||||
depends_on = "c91f0a72be34"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
tables = set(sa.inspect(op.get_bind()).get_table_names())
|
||||
if "campaign_message_actions" not in tables:
|
||||
op.create_table(
|
||||
"campaign_message_actions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("job_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("idempotency_key", sa.String(length=200), nullable=False),
|
||||
sa.Column("canonical_request_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("context", sa.JSON(), nullable=False),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_api_key_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("message_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("message_size_bytes", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"recipient_manifest_sha256",
|
||||
sa.String(length=64),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("recipient_count", sa.Integer(), nullable=False),
|
||||
sa.Column("prior_send_status", sa.String(length=50), nullable=False),
|
||||
sa.Column("prior_attempt_count", sa.Integer(), nullable=False),
|
||||
sa.Column("final_send_status", sa.String(length=50), nullable=True),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("accepted_count", sa.Integer(), nullable=False),
|
||||
sa.Column("refused_count", sa.Integer(), nullable=False),
|
||||
sa.Column("refusal_summary", sa.JSON(), nullable=False),
|
||||
sa.Column("error_type", sa.String(length=120), nullable=True),
|
||||
sa.Column("error_message", sa.String(length=500), nullable=True),
|
||||
sa.Column("linked_send_attempt_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_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(
|
||||
["campaign_id"],
|
||||
["campaigns.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_message_actions_campaign_id_campaigns"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_version_id"],
|
||||
["campaign_versions.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_message_actions_campaign_version_id_campaign_versions"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["job_id"],
|
||||
["campaign_jobs.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_message_actions_job_id_campaign_jobs"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["actor_user_id"],
|
||||
["access_users.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_message_actions_actor_user_id_access_users"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["linked_send_attempt_id"],
|
||||
["send_attempts.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_message_actions_linked_send_attempt_id_send_attempts"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_campaign_message_actions"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
name="uq_campaign_message_actions_idempotency",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_message_actions_job_created",
|
||||
"campaign_message_actions",
|
||||
["job_id", "created_at"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaign_message_actions_campaign_kind",
|
||||
"campaign_message_actions",
|
||||
["campaign_id", "kind", "status"],
|
||||
unique=False,
|
||||
)
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"campaign_id",
|
||||
"campaign_version_id",
|
||||
"job_id",
|
||||
"kind",
|
||||
"actor_user_id",
|
||||
"status",
|
||||
"linked_send_attempt_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_campaign_message_actions_{column}"),
|
||||
"campaign_message_actions",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
tables = set(sa.inspect(op.get_bind()).get_table_names())
|
||||
if "campaign_message_action_attempts" not in tables:
|
||||
op.create_table(
|
||||
"campaign_message_action_attempts",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("action_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("attempt_number", sa.Integer(), nullable=False),
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("effect_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("accepted_count", sa.Integer(), nullable=False),
|
||||
sa.Column("refused_count", sa.Integer(), nullable=False),
|
||||
sa.Column("outcome_code", sa.String(length=80), nullable=True),
|
||||
sa.Column("diagnostic_summary", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["action_id"],
|
||||
["campaign_message_actions.id"],
|
||||
name=op.f(
|
||||
"fk_campaign_message_action_attempts_action_id_campaign_message_actions"
|
||||
),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint(
|
||||
"id",
|
||||
name=op.f("pk_campaign_message_action_attempts"),
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"action_id",
|
||||
"attempt_number",
|
||||
name="uq_campaign_message_action_attempt_number",
|
||||
),
|
||||
)
|
||||
for column in ("action_id", "status"):
|
||||
op.create_index(
|
||||
op.f(f"ix_campaign_message_action_attempts_{column}"),
|
||||
"campaign_message_action_attempts",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
tables = set(sa.inspect(op.get_bind()).get_table_names())
|
||||
if "campaign_message_action_attempts" in tables:
|
||||
op.drop_table("campaign_message_action_attempts")
|
||||
if "campaign_message_actions" in tables:
|
||||
op.drop_table("campaign_message_actions")
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
"""add governed campaign collaboration entries
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "b6c7d8e9f0a1"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "b6c7d8e9f0a1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_collaboration_entries"):
|
||||
return
|
||||
op.create_table(
|
||||
"campaign_collaboration_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"visibility",
|
||||
sa.String(length=30),
|
||||
nullable=False,
|
||||
server_default="collaborators",
|
||||
),
|
||||
sa.Column("content", sa.Text(), nullable=True),
|
||||
sa.Column("content_sha256", sa.String(length=64), nullable=False),
|
||||
sa.Column("mention_user_ids", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("withdrawn_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("redacted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("redacted_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("tombstone_reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["campaign_version_id"],
|
||||
["campaign_versions.id"],
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["redacted_by_user_id"],
|
||||
["access_users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["withdrawn_by_user_id"],
|
||||
["access_users.id"],
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_collaboration_entries_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_collaboration_entries_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_collaboration_entries_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_collaboration_entries_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_collaboration_entries_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_collaboration_entries_visibility", ["visibility"]),
|
||||
("ix_campaign_collaboration_entries_withdrawn_at", ["withdrawn_at"]),
|
||||
("ix_campaign_collaboration_entries_redacted_at", ["redacted_at"]),
|
||||
(
|
||||
"ix_campaign_collaboration_entries_thread",
|
||||
["tenant_id", "campaign_id", "created_at", "id"],
|
||||
),
|
||||
):
|
||||
op.create_index(name, "campaign_collaboration_entries", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_collaboration_entries"):
|
||||
op.drop_table("campaign_collaboration_entries")
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""add monotonic campaign version edit revision
|
||||
|
||||
Revision ID: d2b7af503c81
|
||||
Revises: c1a69e4f2b70
|
||||
Create Date: 2026-07-30 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d2b7af503c81"
|
||||
down_revision = "c1a69e4f2b70"
|
||||
branch_labels = None
|
||||
depends_on = "c91f0a72be34"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("campaign_versions")
|
||||
}
|
||||
if "edit_revision" not in columns:
|
||||
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"edit_revision",
|
||||
sa.Integer(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("campaign_versions")
|
||||
}
|
||||
if "edit_revision" in columns:
|
||||
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||
batch_op.drop_column("edit_revision")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""add accountable campaign work assignments
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "d8e9f0a1b2c3"
|
||||
down_revision = "c7d8e9f0a1b2"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
op.create_table(
|
||||
"campaign_work_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("reference_kind", sa.String(length=40), nullable=True),
|
||||
sa.Column("reference_id", sa.String(length=500), nullable=True),
|
||||
sa.Column("reference_label", sa.String(length=255), nullable=True),
|
||||
sa.Column("purpose", sa.String(length=500), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False, server_default="open"),
|
||||
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("assignee_type", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("assignee_current_label", sa.String(length=500), nullable=True),
|
||||
sa.Column("assignee_resolution_state", sa.String(length=30), nullable=False, server_default="resolved"),
|
||||
sa.Column("resolution_provenance", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("resolution_checked_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("assigned_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("assigned_by_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("task_mirror_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("task_mirror_status", sa.String(length=30), nullable=False, server_default="not_configured"),
|
||||
sa.Column("task_mirror_error", sa.String(length=500), nullable=True),
|
||||
sa.Column("task_mirrored_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["assigned_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignments_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignments_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignments_campaign_version_id", ["campaign_version_id"]),
|
||||
("ix_campaign_work_assignments_reference_kind", ["reference_kind"]),
|
||||
("ix_campaign_work_assignments_status", ["status"]),
|
||||
("ix_campaign_work_assignments_due_at", ["due_at"]),
|
||||
("ix_campaign_work_assignments_assignee_type", ["assignee_type"]),
|
||||
("ix_campaign_work_assignments_assignee_id", ["assignee_id"]),
|
||||
("ix_campaign_work_assignments_assignee_resolution_state", ["assignee_resolution_state"]),
|
||||
("ix_campaign_work_assignments_assigned_by_user_id", ["assigned_by_user_id"]),
|
||||
("ix_campaign_work_assignments_campaign_status", ["tenant_id", "campaign_id", "status", "due_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignments", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignment_events"):
|
||||
op.create_table(
|
||||
"campaign_work_assignment_events",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("event_kind", sa.String(length=40), nullable=False),
|
||||
sa.Column("actor_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("actor_label_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("status_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("assignee_type_snapshot", sa.String(length=40), nullable=False),
|
||||
sa.Column("assignee_id_snapshot", sa.String(length=255), nullable=False),
|
||||
sa.Column("assignee_label_snapshot", sa.String(length=500), nullable=False),
|
||||
sa.Column("resolution_state_snapshot", sa.String(length=30), nullable=False),
|
||||
sa.Column("details", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["actor_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["assignment_id"], ["campaign_work_assignments.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_work_assignment_events_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_work_assignment_events_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_work_assignment_events_assignment_id", ["assignment_id"]),
|
||||
("ix_campaign_work_assignment_events_event_kind", ["event_kind"]),
|
||||
("ix_campaign_work_assignment_events_actor_user_id", ["actor_user_id"]),
|
||||
("ix_campaign_work_assignment_events_history", ["tenant_id", "assignment_id", "created_at", "id"]),
|
||||
):
|
||||
op.create_index(name, "campaign_work_assignment_events", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignment_events"):
|
||||
op.drop_table("campaign_work_assignment_events")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_work_assignments"):
|
||||
op.drop_table("campaign_work_assignments")
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
"""add non-destructive historical campaign version archival
|
||||
|
||||
Revision ID: e3c8f4a5b6d7
|
||||
Revises: b7c8d9e0f1a2, d2b7af503c81
|
||||
Create Date: 2026-08-03 12:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "e3c8f4a5b6d7"
|
||||
down_revision = ("b7c8d9e0f1a2", "d2b7af503c81")
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspector.get_columns("campaign_versions")
|
||||
}
|
||||
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||
if "archived_at" not in columns:
|
||||
batch_op.add_column(
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)
|
||||
)
|
||||
if "archived_by_user_id" not in columns:
|
||||
batch_op.add_column(
|
||||
sa.Column("archived_by_user_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
"fk_campaign_versions_archived_by_user_id_access_users",
|
||||
"access_users",
|
||||
["archived_by_user_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in sa.inspect(op.get_bind()).get_indexes("campaign_versions")
|
||||
}
|
||||
if "ix_campaign_versions_archived_at" not in indexes:
|
||||
op.create_index(
|
||||
"ix_campaign_versions_archived_at",
|
||||
"campaign_versions",
|
||||
["archived_at"],
|
||||
unique=False,
|
||||
)
|
||||
if "ix_campaign_versions_archived_by_user_id" not in indexes:
|
||||
op.create_index(
|
||||
"ix_campaign_versions_archived_by_user_id",
|
||||
"campaign_versions",
|
||||
["archived_by_user_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
indexes = {
|
||||
index["name"]
|
||||
for index in inspector.get_indexes("campaign_versions")
|
||||
}
|
||||
for index_name in (
|
||||
"ix_campaign_versions_archived_by_user_id",
|
||||
"ix_campaign_versions_archived_at",
|
||||
):
|
||||
if index_name in indexes:
|
||||
op.drop_index(index_name, table_name="campaign_versions")
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in sa.inspect(op.get_bind()).get_columns("campaign_versions")
|
||||
}
|
||||
with op.batch_alter_table("campaign_versions") as batch_op:
|
||||
if "archived_by_user_id" in columns:
|
||||
batch_op.drop_constraint(
|
||||
"fk_campaign_versions_archived_by_user_id_access_users",
|
||||
type_="foreignkey",
|
||||
)
|
||||
batch_op.drop_column("archived_by_user_id")
|
||||
if "archived_at" in columns:
|
||||
batch_op.drop_column("archived_at")
|
||||
+32
@@ -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
|
||||
+153
@@ -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")
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
"""add durable Campaign work orchestration provenance
|
||||
|
||||
revision = "f3c7a9d2e6b1"
|
||||
down_revision = "d8e9f0a1b2c3"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "f3c7a9d2e6b1"
|
||||
down_revision = "d8e9f0a1b2c3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_COLUMN_SPECS = (
|
||||
("orchestration_idempotency_key", sa.String(length=255)),
|
||||
("orchestration_request_sha256", sa.String(length=64)),
|
||||
("orchestration_correlation_id", sa.String(length=128)),
|
||||
("workflow_instance_id", sa.String(length=36)),
|
||||
("workflow_step_id", sa.String(length=36)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
return
|
||||
existing = {
|
||||
item["name"]
|
||||
for item in inspector.get_columns("campaign_work_assignments")
|
||||
}
|
||||
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||
for name, column_type in _COLUMN_SPECS:
|
||||
if name not in existing:
|
||||
batch.add_column(sa.Column(name, column_type, nullable=True))
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
indexes = {
|
||||
item["name"]
|
||||
for item in inspector.get_indexes("campaign_work_assignments")
|
||||
}
|
||||
for name, columns in (
|
||||
(
|
||||
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||
["orchestration_idempotency_key"],
|
||||
),
|
||||
(
|
||||
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||
["orchestration_correlation_id"],
|
||||
),
|
||||
(
|
||||
"ix_campaign_work_assignments_workflow_instance_id",
|
||||
["workflow_instance_id"],
|
||||
),
|
||||
(
|
||||
"ix_campaign_work_assignments_workflow_step_id",
|
||||
["workflow_step_id"],
|
||||
),
|
||||
(
|
||||
"uq_campaign_work_assignment_orchestration_key",
|
||||
["tenant_id", "orchestration_idempotency_key"],
|
||||
),
|
||||
):
|
||||
if name not in indexes:
|
||||
op.create_index(
|
||||
name,
|
||||
"campaign_work_assignments",
|
||||
columns,
|
||||
unique=name.startswith("uq_"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_work_assignments"):
|
||||
return
|
||||
indexes = {
|
||||
item["name"]
|
||||
for item in inspector.get_indexes("campaign_work_assignments")
|
||||
}
|
||||
for name in (
|
||||
"uq_campaign_work_assignment_orchestration_key",
|
||||
"ix_campaign_work_assignments_workflow_step_id",
|
||||
"ix_campaign_work_assignments_workflow_instance_id",
|
||||
"ix_campaign_work_assignments_orchestration_correlation_id",
|
||||
"ix_campaign_work_assignments_orchestration_idempotency_key",
|
||||
):
|
||||
if name in indexes:
|
||||
op.drop_index(name, table_name="campaign_work_assignments")
|
||||
existing = {
|
||||
item["name"]
|
||||
for item in sa.inspect(op.get_bind()).get_columns(
|
||||
"campaign_work_assignments"
|
||||
)
|
||||
}
|
||||
with op.batch_alter_table("campaign_work_assignments") as batch:
|
||||
for name, _column_type in reversed(_COLUMN_SPECS):
|
||||
if name in existing:
|
||||
batch.drop_column(name)
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
"""add durable campaign schedules and occurrence evidence
|
||||
|
||||
Revision ID: f4a5b6c7d8e9
|
||||
Revises: e3c8f4a5b6d7
|
||||
Create Date: 2026-08-07 10:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "f4a5b6c7d8e9"
|
||||
down_revision = "e3c8f4a5b6d7"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_schedules"):
|
||||
op.create_table(
|
||||
"campaign_schedules",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("campaign_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_version_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_by_user_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("recurrence_kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("interval_count", sa.Integer(), nullable=False),
|
||||
sa.Column("timezone", sa.String(length=100), nullable=False),
|
||||
sa.Column("starts_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("next_fire_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("ends_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("max_occurrences", sa.Integer(), nullable=False),
|
||||
sa.Column("occurrence_count", sa.Integer(), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("resource_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("copy_options", sa.JSON(), nullable=False),
|
||||
sa.Column("source_snapshot", sa.JSON(), nullable=False),
|
||||
sa.Column("source_snapshot_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("source_base_path", sa.String(length=1000), nullable=True),
|
||||
sa.Column("last_fired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_campaign_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["campaign_id"], ["campaigns.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["source_version_id"], ["campaign_versions.id"], ondelete="RESTRICT"),
|
||||
sa.ForeignKeyConstraint(["created_by_user_id"], ["access_users.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["last_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_schedules_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_schedules_campaign_id", ["campaign_id"]),
|
||||
("ix_campaign_schedules_source_version_id", ["source_version_id"]),
|
||||
("ix_campaign_schedules_created_by_user_id", ["created_by_user_id"]),
|
||||
("ix_campaign_schedules_recurrence_kind", ["recurrence_kind"]),
|
||||
("ix_campaign_schedules_next_fire_at", ["next_fire_at"]),
|
||||
("ix_campaign_schedules_active", ["active"]),
|
||||
("ix_campaign_schedules_source_snapshot_hash", ["source_snapshot_hash"]),
|
||||
("ix_campaign_schedules_last_campaign_id", ["last_campaign_id"]),
|
||||
("ix_campaign_schedules_due", ["tenant_id", "active", "next_fire_at"]),
|
||||
):
|
||||
op.create_index(name, "campaign_schedules", columns, unique=False)
|
||||
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table("campaign_schedule_occurrences"):
|
||||
op.create_table(
|
||||
"campaign_schedule_occurrences",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("schedule_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("status", sa.String(length=30), nullable=False),
|
||||
sa.Column("generated_campaign_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("generated_version_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("error", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["schedule_id"], ["campaign_schedules.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["generated_campaign_id"], ["campaigns.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(["generated_version_id"], ["campaign_versions.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("schedule_id", "scheduled_for", name="uq_campaign_schedule_occurrence"),
|
||||
)
|
||||
for name, columns in (
|
||||
("ix_campaign_schedule_occurrences_tenant_id", ["tenant_id"]),
|
||||
("ix_campaign_schedule_occurrences_schedule_id", ["schedule_id"]),
|
||||
("ix_campaign_schedule_occurrences_status", ["status"]),
|
||||
("ix_campaign_schedule_occurrences_generated_campaign_id", ["generated_campaign_id"]),
|
||||
("ix_campaign_schedule_occurrences_generated_version_id", ["generated_version_id"]),
|
||||
("ix_campaign_schedule_occurrences_schedule", ["schedule_id", "scheduled_for"]),
|
||||
):
|
||||
op.create_index(name, "campaign_schedule_occurrences", columns, unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedule_occurrences"):
|
||||
op.drop_table("campaign_schedule_occurrences")
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table("campaign_schedules"):
|
||||
op.drop_table("campaign_schedules")
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from govoplan_core.core.object_storage import (
|
||||
StorageBackendError,
|
||||
configured_storage_backend,
|
||||
)
|
||||
from govoplan_core.core.operations import OperationalCheck
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
|
||||
|
||||
def generated_eml_storage_check() -> OperationalCheck:
|
||||
"""Verify Campaign evidence against the deployment object-store boundary."""
|
||||
|
||||
storage = configured_storage_backend(get_settings() or core_settings)
|
||||
probe = f"campaign-artifacts/.health/{secrets.token_hex(16)}.probe"
|
||||
payload = secrets.token_bytes(64)
|
||||
try:
|
||||
storage.put_bytes(probe, payload, content_type="application/octet-stream")
|
||||
if storage.get_bytes(probe) != payload:
|
||||
raise OSError("generated EML persistence returned different bytes")
|
||||
except (OSError, StorageBackendError) as exc:
|
||||
return OperationalCheck(
|
||||
id="campaign.generated_eml_storage",
|
||||
label="Generated Campaign EML evidence",
|
||||
state="error",
|
||||
detail=(
|
||||
"The generated EML object store failed a bounded write/read probe "
|
||||
f"({type(exc).__name__})."
|
||||
),
|
||||
readiness_critical=True,
|
||||
metrics={"backend": storage.name},
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
storage.delete(probe)
|
||||
except StorageBackendError:
|
||||
pass
|
||||
|
||||
node_local = storage.name == "local"
|
||||
return OperationalCheck(
|
||||
id="campaign.generated_eml_storage",
|
||||
label="Generated Campaign EML evidence",
|
||||
state="warning" if node_local else "ok",
|
||||
detail=(
|
||||
"Generated EML passed the object-store write/read/delete probe. "
|
||||
+ (
|
||||
"The configured backend is node-local and is suitable only for a single-node profile."
|
||||
if node_local
|
||||
else "The configured backend is shared across application and worker nodes."
|
||||
)
|
||||
),
|
||||
metrics={"backend": storage.name},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,10 @@ class AggregateOutcomeCounts(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
smtp_accepted: AggregateCount
|
||||
postbox_accepted: AggregateCount
|
||||
print_accepted: AggregateCount
|
||||
delivered: AggregateCount
|
||||
partially_accepted: AggregateCount
|
||||
failed: AggregateCount
|
||||
outcome_unknown: AggregateCount
|
||||
queued_or_active: AggregateCount
|
||||
@@ -105,6 +109,10 @@ class AggregateCampaignReport(BaseModel):
|
||||
|
||||
_OUTCOME_KEYS = (
|
||||
"smtp_accepted",
|
||||
"postbox_accepted",
|
||||
"print_accepted",
|
||||
"delivered",
|
||||
"partially_accepted",
|
||||
"failed",
|
||||
"outcome_unknown",
|
||||
"queued_or_active",
|
||||
@@ -175,7 +183,19 @@ 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",
|
||||
"print_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 +208,39 @@ 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 == "print_accepted", 1),
|
||||
else_=0,
|
||||
)
|
||||
).label("print_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 +405,14 @@ 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 == "print_accepted":
|
||||
counts["print_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 +520,16 @@ 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["print_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"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,11 @@ from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_campaign.backend.integrations import SmtpConfigurationError
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
MailDeliveryCommandError,
|
||||
SmtpConfigurationError,
|
||||
mail_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
|
||||
|
||||
|
||||
@@ -32,6 +36,9 @@ class CampaignReportEmailResult:
|
||||
attached_jobs_csv: bool
|
||||
attached_report_json: bool
|
||||
accepted_count: int | None = None
|
||||
command_id: str | None = None
|
||||
delivery_status: str | None = None
|
||||
duplicate: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
@@ -44,6 +51,22 @@ class CampaignReportEmailResult:
|
||||
"attached_jobs_csv": self.attached_jobs_csv,
|
||||
"attached_report_json": self.attached_report_json,
|
||||
"accepted_count": self.accepted_count,
|
||||
"command_id": self.command_id,
|
||||
"delivery_status": self.delivery_status,
|
||||
"duplicate": self.duplicate,
|
||||
}
|
||||
|
||||
def audit_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"campaign_id": self.campaign_id,
|
||||
"version_id": self.version_id,
|
||||
"recipient_count": len(self.to),
|
||||
"dry_run": self.dry_run,
|
||||
"attached_jobs_csv": self.attached_jobs_csv,
|
||||
"attached_report_json": self.attached_report_json,
|
||||
"command_id": self.command_id,
|
||||
"delivery_status": self.delivery_status,
|
||||
"duplicate": self.duplicate,
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +172,8 @@ def send_campaign_report_email(
|
||||
attach_jobs_csv: bool = False,
|
||||
attach_report_json: bool = False,
|
||||
dry_run: bool = False,
|
||||
idempotency_key: str | None = None,
|
||||
created_by_user_id: str | None = None,
|
||||
) -> CampaignReportEmailResult:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
@@ -175,11 +200,16 @@ def send_campaign_report_email(
|
||||
) from exc
|
||||
if not snapshot.smtp_transport_revision:
|
||||
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
|
||||
mail = mail_integration()
|
||||
if not dry_run:
|
||||
raise CampaignReportEmailError(
|
||||
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
|
||||
)
|
||||
|
||||
if not mail.durable_delivery_available:
|
||||
raise CampaignReportEmailError(
|
||||
"Report email delivery requires the durable, idempotent Mail-owned outbox."
|
||||
)
|
||||
if not str(idempotency_key or "").strip():
|
||||
raise CampaignReportEmailError(
|
||||
"Live report email delivery requires an idempotency key"
|
||||
)
|
||||
report = generate_campaign_report(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -202,6 +232,48 @@ def send_campaign_report_email(
|
||||
jobs_csv=jobs_csv,
|
||||
report_json=report_json,
|
||||
)
|
||||
if not dry_run:
|
||||
clean_idempotency_key = str(idempotency_key or "").strip()
|
||||
from_email, _from_name = _effective_from(config)
|
||||
if not snapshot.mail_profile_id:
|
||||
raise CampaignReportEmailError(
|
||||
"Campaign build evidence has no Mail profile reference"
|
||||
)
|
||||
try:
|
||||
command = mail.submit_delivery_command(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
command_type="campaign_report",
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign",
|
||||
source_resource_id=campaign.id,
|
||||
source_version_id=version.id,
|
||||
idempotency_key=clean_idempotency_key,
|
||||
profile_id=snapshot.mail_profile_id,
|
||||
message_bytes=message.as_bytes(),
|
||||
envelope_from=from_email,
|
||||
envelope_recipients=to,
|
||||
from_header=str(message["From"]),
|
||||
expected_smtp_transport_revision=snapshot.smtp_transport_revision,
|
||||
smtp_server_id=snapshot.smtp_server_id,
|
||||
smtp_credential_id=snapshot.smtp_credential_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
except MailDeliveryCommandError as exc:
|
||||
raise CampaignReportEmailError(str(exc)) from exc
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=False,
|
||||
sent=False,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
command_id=str(command["id"]),
|
||||
delivery_status=str(command["status"]),
|
||||
duplicate=bool(command.get("duplicate")),
|
||||
)
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
"""Cross-module provider for Campaign's recipient-free aggregate report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.report_privacy_policy import (
|
||||
effective_campaign_report_privacy_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.aggregate import (
|
||||
generate_aggregate_campaign_report,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_campaign_query_for_principal,
|
||||
_get_campaign_for_principal,
|
||||
)
|
||||
from govoplan_core.auth import has_scope
|
||||
from govoplan_core.core.reporting import (
|
||||
REPORT_PROVIDER_CONTRACT_VERSION,
|
||||
ReportDescriptor,
|
||||
ReportParameterDescriptor,
|
||||
ReportParameterOption,
|
||||
ReportPrivacyTransform,
|
||||
ReportProviderRequest,
|
||||
ReportProviderResult,
|
||||
ReportResultField,
|
||||
)
|
||||
|
||||
|
||||
CAMPAIGN_AGGREGATE_REPORT_ID = "delivery-outcomes"
|
||||
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS = (
|
||||
"server_side_aggregation",
|
||||
"small_cell_suppression",
|
||||
"complementary_suppression",
|
||||
"explicit_denominator",
|
||||
"recipient_payload_exclusion",
|
||||
)
|
||||
|
||||
|
||||
class CampaignAggregateReportProvider:
|
||||
provider_id = "campaigns"
|
||||
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
|
||||
|
||||
def list_reports(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
) -> tuple[ReportDescriptor, ...]:
|
||||
del session
|
||||
if not has_scope(principal, "campaigns:report:read"):
|
||||
return ()
|
||||
return (_descriptor(),)
|
||||
|
||||
def parameter_options(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
report_id: str,
|
||||
parameter_key: str,
|
||||
query: str,
|
||||
limit: int,
|
||||
) -> tuple[ReportParameterOption, ...]:
|
||||
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or parameter_key != "campaign_id":
|
||||
return ()
|
||||
if not has_scope(principal, "campaigns:report:read"):
|
||||
return ()
|
||||
sql_session = _session(session)
|
||||
rows = _campaign_query_for_principal(sql_session, principal)
|
||||
clean_query = query.strip().casefold()
|
||||
campaigns = (
|
||||
rows.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
|
||||
.limit(max(1, min(limit, 200)) if not clean_query else 500)
|
||||
.all()
|
||||
)
|
||||
if clean_query:
|
||||
campaigns = [
|
||||
campaign
|
||||
for campaign in campaigns
|
||||
if clean_query in campaign.name.casefold()
|
||||
][: max(1, min(limit, 200))]
|
||||
return tuple(
|
||||
ReportParameterOption(
|
||||
value=campaign.id,
|
||||
label=campaign.name,
|
||||
description=campaign.status,
|
||||
)
|
||||
for campaign in campaigns
|
||||
)
|
||||
|
||||
def execute_report(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ReportProviderRequest,
|
||||
) -> ReportProviderResult:
|
||||
if request.report_id != CAMPAIGN_AGGREGATE_REPORT_ID:
|
||||
raise LookupError("Campaign report provider does not know this report")
|
||||
if not has_scope(principal, "campaigns:report:read"):
|
||||
raise PermissionError("Missing scope: campaigns:report:read")
|
||||
campaign_id = str(request.parameters.get("campaign_id") or "").strip()
|
||||
if not campaign_id:
|
||||
raise ValueError("campaign_id is required")
|
||||
version_id = str(request.parameters.get("version_id") or "").strip() or None
|
||||
sql_session = _session(session)
|
||||
campaign = _get_campaign_for_principal(sql_session, campaign_id, principal)
|
||||
report = generate_aggregate_campaign_report(
|
||||
sql_session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version_id,
|
||||
)
|
||||
policy = effective_campaign_report_privacy_policy(
|
||||
sql_session,
|
||||
tenant_id=principal.tenant_id,
|
||||
)
|
||||
selected_version_id = version_id or campaign.current_version_id
|
||||
return ReportProviderResult(
|
||||
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
||||
generated_at=report.generated_at,
|
||||
payload=report.model_dump(mode="json"),
|
||||
source_revisions=(
|
||||
{
|
||||
"module_id": "campaigns",
|
||||
"resource_type": "campaign",
|
||||
"resource_id": campaign.id,
|
||||
"revision_type": "campaign_version",
|
||||
"revision_id": selected_version_id,
|
||||
"version_number": report.version_number,
|
||||
"updated_at": campaign.updated_at.isoformat(),
|
||||
},
|
||||
),
|
||||
effective_scope={
|
||||
"tenant_id": principal.tenant_id,
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": selected_version_id,
|
||||
"audience": dict(request.audience_scope),
|
||||
},
|
||||
applied_privacy_transforms=CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
||||
provenance={
|
||||
"provider": "campaigns",
|
||||
"projection": "recipient-free-delivery-outcomes-v1",
|
||||
"privacy_policy": policy.as_dict(),
|
||||
"purpose": request.purpose,
|
||||
},
|
||||
)
|
||||
|
||||
def authorize_result(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
report_id: str,
|
||||
source_revisions: tuple[Mapping[str, object], ...],
|
||||
effective_scope: Mapping[str, object],
|
||||
) -> bool:
|
||||
del source_revisions
|
||||
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or not has_scope(
|
||||
principal, "campaigns:report:read"
|
||||
):
|
||||
return False
|
||||
campaign_id = str(effective_scope.get("campaign_id") or "").strip()
|
||||
tenant_id = str(effective_scope.get("tenant_id") or "").strip()
|
||||
if not campaign_id or tenant_id != str(getattr(principal, "tenant_id", "")):
|
||||
return False
|
||||
return (
|
||||
_campaign_query_for_principal(_session(session), principal)
|
||||
.filter(Campaign.id == campaign_id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _descriptor() -> ReportDescriptor:
|
||||
fields = (
|
||||
("generated_at", "Generated", "datetime", "Campaign"),
|
||||
("campaign.id", "Campaign ID", "string", "Campaign"),
|
||||
("campaign.name", "Campaign", "string", "Campaign"),
|
||||
("campaign.status", "Status", "string", "Campaign"),
|
||||
("version_number", "Version", "integer", "Campaign"),
|
||||
("completion_state", "Completion", "string", "Campaign"),
|
||||
(
|
||||
"population.denominator",
|
||||
"Report denominator",
|
||||
"suppressed_count",
|
||||
"Population",
|
||||
),
|
||||
(
|
||||
"population.denominator_definition",
|
||||
"Denominator definition",
|
||||
"string",
|
||||
"Population",
|
||||
),
|
||||
(
|
||||
"population.inactive_source_entries",
|
||||
"Inactive source entries",
|
||||
"suppressed_count",
|
||||
"Population",
|
||||
),
|
||||
(
|
||||
"population.excluded_or_blocked_jobs",
|
||||
"Excluded or blocked jobs",
|
||||
"suppressed_count",
|
||||
"Population",
|
||||
),
|
||||
("outcomes.smtp_accepted", "SMTP accepted", "suppressed_count", "Outcomes"),
|
||||
(
|
||||
"outcomes.postbox_accepted",
|
||||
"Postbox accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
(
|
||||
"outcomes.print_accepted",
|
||||
"Printable output accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
(
|
||||
"outcomes.delivered",
|
||||
"Both channels accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
(
|
||||
"outcomes.partially_accepted",
|
||||
"Partially accepted",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
("outcomes.failed", "Failed", "suppressed_count", "Outcomes"),
|
||||
("outcomes.outcome_unknown", "Outcome unknown", "suppressed_count", "Outcomes"),
|
||||
(
|
||||
"outcomes.queued_or_active",
|
||||
"Queued or active",
|
||||
"suppressed_count",
|
||||
"Outcomes",
|
||||
),
|
||||
("outcomes.not_attempted", "Not attempted", "suppressed_count", "Outcomes"),
|
||||
("outcomes.cancelled", "Cancelled", "suppressed_count", "Outcomes"),
|
||||
("outcomes.excluded", "Excluded", "suppressed_count", "Outcomes"),
|
||||
("time_range.first_activity_at", "First activity", "datetime", "Activity"),
|
||||
("time_range.last_activity_at", "Last activity", "datetime", "Activity"),
|
||||
("time_range.suppressed", "Activity range suppressed", "boolean", "Activity"),
|
||||
("privacy.small_cell_threshold", "Small-cell threshold", "integer", "Privacy"),
|
||||
("privacy.suppression_applied", "Suppression applied", "boolean", "Privacy"),
|
||||
("privacy.rule", "Privacy rule", "string", "Privacy"),
|
||||
)
|
||||
return ReportDescriptor(
|
||||
provider_id="campaigns",
|
||||
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
||||
revision="campaign.aggregate.v1",
|
||||
title="Campaign delivery outcomes",
|
||||
summary=(
|
||||
"Privacy-protected delivery outcomes without recipient-level records."
|
||||
),
|
||||
parameters=(
|
||||
ReportParameterDescriptor(
|
||||
key="campaign_id",
|
||||
label="Campaign",
|
||||
type="reference",
|
||||
required=True,
|
||||
options_from_provider=True,
|
||||
),
|
||||
ReportParameterDescriptor(
|
||||
key="version_id",
|
||||
label="Campaign version",
|
||||
type="string",
|
||||
required=False,
|
||||
description="Leave empty to use the current campaign version.",
|
||||
),
|
||||
),
|
||||
result_schema=tuple(
|
||||
ReportResultField(
|
||||
path=path,
|
||||
label=label,
|
||||
type=field_type, # type: ignore[arg-type]
|
||||
group=group,
|
||||
nullable=path.startswith("time_range.") or path == "version_number",
|
||||
)
|
||||
for path, label, field_type, group in fields
|
||||
),
|
||||
privacy_transforms=tuple(
|
||||
ReportPrivacyTransform(id=item, label=item.replace("_", " ").title())
|
||||
for item in CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
||||
),
|
||||
retention_class="stored_report_detail",
|
||||
export_formats=("json",),
|
||||
reidentification_risk="low",
|
||||
presentation={"kind": "metric_summary"},
|
||||
)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Campaign report provider requires a SQLAlchemy Session")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CAMPAIGN_AGGREGATE_REPORT_ID",
|
||||
"CAMPAIGN_REPORT_PRIVACY_TRANSFORMS",
|
||||
"CampaignAggregateReportProvider",
|
||||
]
|
||||
@@ -48,7 +48,12 @@ _SEND_NOW_RESULT_KEYS = (
|
||||
"failed_count",
|
||||
"outcome_unknown_count",
|
||||
"skipped_count",
|
||||
"paused_count",
|
||||
"preflight_count",
|
||||
"batch_state",
|
||||
"batch_pause_reason_code",
|
||||
"smtp_connection_count",
|
||||
"smtp_reconnect_count",
|
||||
"delivery_mode",
|
||||
"dry_run",
|
||||
)
|
||||
@@ -71,6 +76,8 @@ _SYNCHRONOUS_POLICY_KEYS = (
|
||||
"source",
|
||||
"deployment_max_recipient_jobs",
|
||||
"tenant_max_recipient_jobs",
|
||||
"system_max_recipient_jobs",
|
||||
"deployment_ceiling_explicit",
|
||||
)
|
||||
_VALIDATION_SUMMARY_KEYS = ("ok", "error_count", "warning_count")
|
||||
_BUILD_SUMMARY_KEYS = (
|
||||
@@ -110,6 +117,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 +125,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"}:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -9,7 +10,35 @@ from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryGuaranteeError,
|
||||
RecoveryMode,
|
||||
RecoveryPlan,
|
||||
RecoveryStatus,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.object_storage import (
|
||||
StorageBackend,
|
||||
StorageBackendError,
|
||||
StorageObjectMissing,
|
||||
configured_storage_backend,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignVersion,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
|
||||
FINAL_VERSION_STATES = {
|
||||
"completed",
|
||||
@@ -28,6 +57,15 @@ FINAL_EML_SEND_STATUSES = {
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _GeneratedArtifactRecovery:
|
||||
operation: DurableRecoveryOperation
|
||||
job_id: str
|
||||
storage_key: str | None
|
||||
local_path: str | None
|
||||
storage: StorageBackend | None
|
||||
|
||||
|
||||
def _cutoff(days: int | None, *, now: datetime) -> datetime | None:
|
||||
if days is None:
|
||||
return None
|
||||
@@ -105,14 +143,233 @@ def _apply_raw_json_retention(
|
||||
return result
|
||||
|
||||
|
||||
def _artifact_locator_sha256(
|
||||
*,
|
||||
storage_key: str | None,
|
||||
local_path: str | None,
|
||||
) -> str:
|
||||
return _json_sha256(
|
||||
{
|
||||
"storage_key": storage_key,
|
||||
"local_path": local_path,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _begin_generated_artifact_recovery(
|
||||
*,
|
||||
job: CampaignJob,
|
||||
storage: StorageBackend | None,
|
||||
) -> _GeneratedArtifactRecovery | None:
|
||||
storage_key = str(job.eml_storage_key) if job.eml_storage_key else None
|
||||
local_path = str(job.eml_local_path) if job.eml_local_path else None
|
||||
locator_sha256 = _artifact_locator_sha256(
|
||||
storage_key=storage_key,
|
||||
local_path=local_path,
|
||||
)
|
||||
try:
|
||||
started = begin_durable_recovery_operation(
|
||||
get_database().SessionLocal,
|
||||
identity=process_runtime_identity(),
|
||||
module_id="campaigns",
|
||||
operation_type="generated-artifact-retention",
|
||||
idempotency_key=(
|
||||
f"campaign-retention:{job.id}:{locator_sha256[:32]}"
|
||||
),
|
||||
request={
|
||||
"tenant_id": job.tenant_id,
|
||||
"campaign_id": job.campaign_id,
|
||||
"version_id": job.campaign_version_id,
|
||||
"job_id": job.id,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"artifact_locator_sha256": locator_sha256,
|
||||
},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=(
|
||||
"the Campaign job is terminal and outside its retention window",
|
||||
"no IMAP append or delivery outcome remains unresolved",
|
||||
),
|
||||
forward_recovery_steps=(
|
||||
"verify whether each recorded artifact still exists",
|
||||
"clear the database locator only after absence is established",
|
||||
),
|
||||
verification_steps=(
|
||||
"reload the Campaign job through an independent session",
|
||||
"probe every original object or local-development path",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"job_id": job.id,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"artifact_locator_sha256": locator_sha256,
|
||||
},
|
||||
lease_resource_key=f"campaign:retention:{job.tenant_id}:{job.id}",
|
||||
lease_ttl_seconds=15 * 60,
|
||||
resource_type="campaign_job",
|
||||
resource_id=job.id,
|
||||
metadata={
|
||||
"resources": [
|
||||
"postgresql",
|
||||
"object-storage" if storage_key else "local-development-storage",
|
||||
],
|
||||
},
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict):
|
||||
return None
|
||||
if started.replayed or started.operation is None:
|
||||
return None
|
||||
return _GeneratedArtifactRecovery(
|
||||
operation=started.operation,
|
||||
job_id=job.id,
|
||||
storage_key=storage_key,
|
||||
local_path=local_path,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
|
||||
def _generated_artifact_recovery_evidence(
|
||||
recovery: _GeneratedArtifactRecovery,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
probes: dict[str, bool | None] = {}
|
||||
if recovery.storage_key:
|
||||
try:
|
||||
if recovery.storage is None:
|
||||
raise StorageBackendError("Artifact storage is unavailable")
|
||||
probes["object_missing"] = not recovery.storage.exists(
|
||||
recovery.storage_key
|
||||
)
|
||||
except (StorageBackendError, OSError):
|
||||
probes["object_missing"] = None
|
||||
if recovery.local_path:
|
||||
try:
|
||||
probes["local_path_missing"] = not Path(recovery.local_path).exists()
|
||||
except OSError:
|
||||
probes["local_path_missing"] = None
|
||||
|
||||
with get_database().SessionLocal() as evidence_session:
|
||||
job = evidence_session.get(CampaignJob, recovery.job_id)
|
||||
job_present = job is not None
|
||||
metadata_cleared = bool(
|
||||
job is None
|
||||
or (
|
||||
(
|
||||
not recovery.storage_key
|
||||
or job.eml_storage_key != recovery.storage_key
|
||||
)
|
||||
and (
|
||||
not recovery.local_path
|
||||
or job.eml_local_path != recovery.local_path
|
||||
)
|
||||
)
|
||||
)
|
||||
metadata_intact = bool(
|
||||
job is not None
|
||||
and job.eml_storage_key == recovery.storage_key
|
||||
and job.eml_local_path == recovery.local_path
|
||||
)
|
||||
|
||||
probe_values = tuple(probes.values())
|
||||
probe_verified = bool(probe_values) and all(
|
||||
value is not None for value in probe_values
|
||||
)
|
||||
artifacts_absent = probe_verified and all(value is True for value in probe_values)
|
||||
artifacts_intact = probe_verified and all(value is False for value in probe_values)
|
||||
evidence = {
|
||||
"verified": probe_verified,
|
||||
"checks": {
|
||||
"job_state_reloaded": True,
|
||||
"artifact_locations_probed": probe_verified,
|
||||
},
|
||||
"job_present": job_present,
|
||||
"metadata_cleared": metadata_cleared,
|
||||
"metadata_intact": metadata_intact,
|
||||
"artifact_probes": probes,
|
||||
}
|
||||
if not probe_verified:
|
||||
return "outcome_unknown", evidence
|
||||
if artifacts_absent and metadata_cleared:
|
||||
return "succeeded", evidence
|
||||
if artifacts_intact and metadata_intact:
|
||||
return "failed", evidence
|
||||
return "recovery_required", evidence
|
||||
|
||||
|
||||
def _finish_generated_artifact_recovery(
|
||||
recovery: _GeneratedArtifactRecovery,
|
||||
) -> None:
|
||||
outcome, evidence = _generated_artifact_recovery_evidence(recovery)
|
||||
if outcome == "succeeded":
|
||||
recovery.operation.succeed(evidence=evidence)
|
||||
elif outcome == "failed":
|
||||
recovery.operation.reject(
|
||||
summary="Generated Campaign artifacts were not deleted",
|
||||
evidence=evidence,
|
||||
)
|
||||
elif outcome == "outcome_unknown":
|
||||
recovery.operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Generated artifact deletion could not be verified",
|
||||
evidence=evidence,
|
||||
failure_summary="Artifact storage availability prevented verification",
|
||||
)
|
||||
else:
|
||||
recovery.operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="Generated artifact retention is only partially complete",
|
||||
evidence=evidence,
|
||||
failure_summary="Artifact and Campaign metadata state require reconciliation",
|
||||
)
|
||||
|
||||
|
||||
def _finish_generated_artifact_recoveries(
|
||||
recoveries: list[_GeneratedArtifactRecovery],
|
||||
) -> None:
|
||||
failures: list[Exception] = []
|
||||
for recovery in recoveries:
|
||||
try:
|
||||
_finish_generated_artifact_recovery(recovery)
|
||||
except Exception as exc: # preserve every operation's chance to close
|
||||
failures.append(exc)
|
||||
if failures:
|
||||
raise RecoveryGuaranteeError(
|
||||
f"{len(failures)} Campaign retention recovery operation(s) could not be finalized"
|
||||
) from failures[0]
|
||||
|
||||
|
||||
def _apply_eml_retention(
|
||||
session: Session,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
storage: StorageBackend | None = None,
|
||||
recovery_operations: list[_GeneratedArtifactRecovery] | None = None,
|
||||
) -> dict[str, int]:
|
||||
result = {"eligible": 0, "metadata_cleared": 0, "files_deleted": 0, "files_missing": 0, "skipped_not_final": 0}
|
||||
result = {
|
||||
"eligible": 0,
|
||||
"metadata_cleared": 0,
|
||||
"files_deleted": 0,
|
||||
"files_missing": 0,
|
||||
"delete_failed": 0,
|
||||
"recovery_blocked": 0,
|
||||
"skipped_not_final": 0,
|
||||
"skipped_schedule_source": 0,
|
||||
}
|
||||
protected_source_versions = {
|
||||
str(version_id)
|
||||
for (version_id,) in (
|
||||
session.query(CampaignSchedule.source_version_id)
|
||||
.filter(
|
||||
CampaignSchedule.delivery_mode == "autonomous",
|
||||
CampaignSchedule.next_fire_at.is_not(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter((CampaignJob.eml_local_path.is_not(None)) | (CampaignJob.eml_storage_key.is_not(None)))
|
||||
@@ -120,6 +377,9 @@ def _apply_eml_retention(
|
||||
.all()
|
||||
)
|
||||
for job in jobs:
|
||||
if getattr(job, "campaign_version_id", None) in protected_source_versions:
|
||||
result["skipped_schedule_source"] += 1
|
||||
continue
|
||||
policy = policy_for_campaign_id(job.campaign_id)
|
||||
cutoff = _cutoff(policy.generated_eml_retention_days, now=now)
|
||||
if not _is_before_cutoff(job.updated_at, cutoff):
|
||||
@@ -137,6 +397,33 @@ def _apply_eml_retention(
|
||||
result["eligible"] += 1
|
||||
if dry_run:
|
||||
continue
|
||||
active_storage = storage
|
||||
if job.eml_storage_key and active_storage is None:
|
||||
active_storage = configured_storage_backend(
|
||||
get_settings() or core_settings
|
||||
)
|
||||
if recovery_operations is not None:
|
||||
recovery = _begin_generated_artifact_recovery(
|
||||
job=job,
|
||||
storage=active_storage,
|
||||
)
|
||||
if recovery is None:
|
||||
result["recovery_blocked"] += 1
|
||||
continue
|
||||
recovery_operations.append(recovery)
|
||||
if job.eml_storage_key:
|
||||
assert active_storage is not None
|
||||
try:
|
||||
if active_storage.exists(job.eml_storage_key):
|
||||
active_storage.delete(job.eml_storage_key)
|
||||
result["files_deleted"] += 1
|
||||
else:
|
||||
result["files_missing"] += 1
|
||||
except StorageObjectMissing:
|
||||
result["files_missing"] += 1
|
||||
except StorageBackendError:
|
||||
result["delete_failed"] += 1
|
||||
continue
|
||||
if job.eml_local_path:
|
||||
path = Path(job.eml_local_path)
|
||||
if path.exists():
|
||||
@@ -188,8 +475,42 @@ def apply_campaign_retention(
|
||||
now: datetime,
|
||||
policy_for_campaign_id: Callable[[str | None], object],
|
||||
) -> dict[str, dict[str, int]]:
|
||||
return {
|
||||
"raw_campaign_json": _apply_raw_json_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||
"generated_eml": _apply_eml_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||
"stored_report_detail": _apply_report_detail_retention(session, dry_run=dry_run, now=now, policy_for_campaign_id=policy_for_campaign_id),
|
||||
}
|
||||
recoveries: list[_GeneratedArtifactRecovery] = []
|
||||
try:
|
||||
# Start external-effect fences before queries for database-only
|
||||
# redaction can autoflush unrelated changes in the caller session.
|
||||
generated_eml = _apply_eml_retention(
|
||||
session,
|
||||
dry_run=dry_run,
|
||||
now=now,
|
||||
policy_for_campaign_id=policy_for_campaign_id,
|
||||
recovery_operations=None if dry_run else recoveries,
|
||||
)
|
||||
result = {
|
||||
"raw_campaign_json": _apply_raw_json_retention(
|
||||
session,
|
||||
dry_run=dry_run,
|
||||
now=now,
|
||||
policy_for_campaign_id=policy_for_campaign_id,
|
||||
),
|
||||
"generated_eml": generated_eml,
|
||||
"stored_report_detail": _apply_report_detail_retention(
|
||||
session,
|
||||
dry_run=dry_run,
|
||||
now=now,
|
||||
policy_for_campaign_id=policy_for_campaign_id,
|
||||
),
|
||||
}
|
||||
if not dry_run:
|
||||
# External artifact deletion and its locator update form one
|
||||
# module-owned recovery boundary. The outer Policy audit commits
|
||||
# separately after Campaign has verified this boundary.
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
if recoveries:
|
||||
_finish_generated_artifact_recoveries(recoveries)
|
||||
raise
|
||||
if recoveries:
|
||||
_finish_generated_artifact_recoveries(recoveries)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
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,
|
||||
campaign_mail_references_unchanged,
|
||||
campaign_preserves_legacy_mail_settings,
|
||||
)
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
stamp_legacy_zipcrypto_acknowledgements,
|
||||
)
|
||||
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.concurrency import (
|
||||
ConcurrencyError,
|
||||
MissingPreconditionError,
|
||||
RevisionConflictError,
|
||||
assert_revision_precondition,
|
||||
)
|
||||
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 RevisionConflictError:
|
||||
session.rollback()
|
||||
raise
|
||||
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,
|
||||
*,
|
||||
if_match: str | None,
|
||||
autosave: bool,
|
||||
audit_action: str,
|
||||
) -> CampaignVersionDetailResponse:
|
||||
campaign = _get_campaign_for_principal(
|
||||
session, campaign_id, principal, write=True
|
||||
)
|
||||
current_version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if payload.base_revision is None:
|
||||
error = MissingPreconditionError(
|
||||
resource_type="campaign_version",
|
||||
resource_id=version_id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||
detail=error.as_dict(),
|
||||
)
|
||||
try:
|
||||
assert_revision_precondition(
|
||||
if_match,
|
||||
resource_type="campaign_version",
|
||||
resource_id=version_id,
|
||||
submitted_base_revision=payload.base_revision,
|
||||
)
|
||||
except MissingPreconditionError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
|
||||
detail=exc.as_dict(),
|
||||
) from exc
|
||||
except ConcurrencyError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "invalid_precondition",
|
||||
"message": str(exc),
|
||||
},
|
||||
) from exc
|
||||
if _recipient_sections_changed(current_version.raw_json, payload.campaign_json):
|
||||
_require_permission(principal, "campaigns:recipient:write")
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
try:
|
||||
payload.campaign_json, acknowledgements = (
|
||||
stamp_legacy_zipcrypto_acknowledgements(
|
||||
session,
|
||||
campaign,
|
||||
current_version.raw_json
|
||||
if isinstance(current_version.raw_json, dict)
|
||||
else {},
|
||||
payload.campaign_json,
|
||||
principal=principal,
|
||||
)
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.archive_encryption_denied",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"campaign_id": campaign_id, "reason": str(exc)},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=(
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if "Missing scope:" in str(exc)
|
||||
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
),
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
preserves_legacy_mail = (
|
||||
not payload.migrate_legacy_mail_settings
|
||||
and campaign_preserves_legacy_mail_settings(
|
||||
current_version.raw_json, payload.campaign_json
|
||||
)
|
||||
)
|
||||
unchanged_mail_selection = (
|
||||
not payload.migrate_legacy_mail_settings
|
||||
and campaign_mail_references_unchanged(current_version.raw_json, payload.campaign_json)
|
||||
)
|
||||
if not unchanged_mail_selection:
|
||||
_require_mail_profile_use_if_needed(principal, payload.campaign_json)
|
||||
try:
|
||||
result = _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,
|
||||
expected_revision=payload.base_revision,
|
||||
commit=False,
|
||||
),
|
||||
audit_action=audit_action,
|
||||
details=lambda version: {
|
||||
"campaign_id": campaign_id,
|
||||
"current_flow": version.current_flow,
|
||||
"current_step": version.current_step,
|
||||
"base_revision": payload.base_revision,
|
||||
"result_revision": version.edit_revision,
|
||||
"reconciliation_kind": payload.reconciliation_kind,
|
||||
"resolved_conflict_path_count": len(payload.resolved_conflict_paths),
|
||||
"resolved_conflict_sections": sorted(
|
||||
{
|
||||
path.strip("/").split("/", 1)[0][:80]
|
||||
for path in payload.resolved_conflict_paths[:100]
|
||||
if path.strip("/")
|
||||
}
|
||||
),
|
||||
"legacy_mail_settings_migrated": payload.migrate_legacy_mail_settings,
|
||||
"legacy_mail_settings_preserved": preserves_legacy_mail,
|
||||
"legacy_zipcrypto_acknowledgements": acknowledgements,
|
||||
},
|
||||
validation_error_status=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
)
|
||||
for acknowledgement in acknowledgements:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.legacy_zipcrypto_acknowledged",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={"campaign_id": campaign_id, **acknowledgement},
|
||||
commit=False,
|
||||
)
|
||||
if acknowledgements:
|
||||
session.commit()
|
||||
return result
|
||||
except RevisionConflictError as exc:
|
||||
session.rollback()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_conflict_detected",
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"submitted_base_revision": exc.submitted_base_revision,
|
||||
"current_revision": exc.current_revision,
|
||||
"retryable": True,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_412_PRECONDITION_FAILED,
|
||||
detail=exc.as_dict(),
|
||||
) from exc
|
||||
|
||||
|
||||
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)
|
||||
editor_state.pop("approval_gate", 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:
|
||||
# Kept as a compatibility no-op for callers outside this package. Campaign
|
||||
# JSON is database-authoritative and no longer mirrored onto an API node.
|
||||
del version
|
||||
|
||||
|
||||
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
@@ -0,0 +1 @@
|
||||
"""Focused HTTP route modules for the campaign API."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
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.campaign.mail_profile_boundary import (
|
||||
CampaignMailProfileBoundaryError,
|
||||
)
|
||||
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, CampaignMailProfileBoundaryError) 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,571 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignCollaborationEntry,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import _attachment_rules
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_access_directory,
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
)
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignCollaborationCreateRequest,
|
||||
CampaignCollaborationEntryResponse,
|
||||
CampaignCollaborationListResponse,
|
||||
CampaignCollaborationModerationRequest,
|
||||
CampaignCollaborationReferenceInput,
|
||||
CampaignCollaborationReferenceResponse,
|
||||
)
|
||||
from govoplan_core.api.v1.schemas import (
|
||||
ReferenceOptionListResponse,
|
||||
ReferenceOptionResponse,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
)
|
||||
from govoplan_core.core.references import (
|
||||
access_scope_reference_page,
|
||||
access_scope_reference_provider_available,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaign collaboration"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/collaboration/mention-options",
|
||||
response_model=ReferenceOptionListResponse,
|
||||
)
|
||||
def search_campaign_collaboration_mentions(
|
||||
campaign_id: str,
|
||||
q: str = "",
|
||||
selected: list[str] = Query(default=[]),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
||||
) -> ReferenceOptionListResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
try:
|
||||
page = access_scope_reference_page(
|
||||
get_registry(),
|
||||
principal,
|
||||
scope_type="user",
|
||||
reference_kind="membership",
|
||||
query=q,
|
||||
selected_values=selected,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
administrative=True,
|
||||
session=session,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
allowed = [
|
||||
option
|
||||
for option in page.options
|
||||
if _mentioned_user_has_campaign_access(
|
||||
session,
|
||||
campaign=campaign,
|
||||
user_id=option.value,
|
||||
)
|
||||
]
|
||||
return ReferenceOptionListResponse(
|
||||
options=[ReferenceOptionResponse(**option.to_dict()) for option in allowed],
|
||||
provider_available=access_scope_reference_provider_available(get_registry()),
|
||||
next_cursor=page.next_cursor,
|
||||
has_more=page.has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/collaboration",
|
||||
response_model=CampaignCollaborationListResponse,
|
||||
)
|
||||
def list_campaign_collaboration(
|
||||
campaign_id: str,
|
||||
limit: int = Query(default=25, ge=1, le=50),
|
||||
cursor: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:read")),
|
||||
) -> CampaignCollaborationListResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
query = session.query(CampaignCollaborationEntry).filter(
|
||||
CampaignCollaborationEntry.tenant_id == principal.tenant_id,
|
||||
CampaignCollaborationEntry.campaign_id == campaign.id,
|
||||
)
|
||||
if not has_scope(principal, "campaigns:discussion:moderate"):
|
||||
query = query.filter(CampaignCollaborationEntry.visibility == "collaborators")
|
||||
if cursor:
|
||||
created_at, entry_id = _decode_cursor(cursor)
|
||||
query = query.filter(
|
||||
or_(
|
||||
CampaignCollaborationEntry.created_at < created_at,
|
||||
and_(
|
||||
CampaignCollaborationEntry.created_at == created_at,
|
||||
CampaignCollaborationEntry.id < entry_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
rows = (
|
||||
query.order_by(
|
||||
CampaignCollaborationEntry.created_at.desc(),
|
||||
CampaignCollaborationEntry.id.desc(),
|
||||
)
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return CampaignCollaborationListResponse(
|
||||
items=[_entry_response(item) for item in items],
|
||||
next_cursor=_encode_cursor(items[-1]) if has_more and items else None,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/collaboration",
|
||||
response_model=CampaignCollaborationEntryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_collaboration_entry(
|
||||
campaign_id: str,
|
||||
payload: CampaignCollaborationCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
||||
) -> CampaignCollaborationEntryResponse:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
if payload.visibility == "moderators":
|
||||
_require_permission(principal, "campaigns:discussion:moderate")
|
||||
reference = _validated_reference(
|
||||
session,
|
||||
campaign=campaign,
|
||||
reference=payload.reference,
|
||||
)
|
||||
mentions = _validated_mentions(
|
||||
session,
|
||||
campaign=campaign,
|
||||
user_ids=payload.mention_user_ids,
|
||||
actor_user_id=principal.user.id,
|
||||
)
|
||||
actor_label = (
|
||||
getattr(principal.user, "display_name", None)
|
||||
or getattr(principal.user, "email", None)
|
||||
or principal.user.id
|
||||
)
|
||||
entry = CampaignCollaborationEntry(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=reference[0] if reference else None,
|
||||
reference_kind=reference[1] if reference else None,
|
||||
reference_id=reference[2] if reference else None,
|
||||
reference_label=reference[3] if reference else None,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_label_snapshot=str(actor_label)[:255],
|
||||
visibility=payload.visibility,
|
||||
content=payload.content,
|
||||
content_sha256=hashlib.sha256(payload.content.encode("utf-8")).hexdigest(),
|
||||
mention_user_ids=mentions,
|
||||
)
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
_enqueue_mention_notifications(
|
||||
session,
|
||||
campaign=campaign,
|
||||
entry=entry,
|
||||
mention_user_ids=mentions,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.collaboration.posted",
|
||||
object_type="campaign_collaboration_entry",
|
||||
object_id=entry.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": entry.campaign_version_id,
|
||||
"visibility": entry.visibility,
|
||||
"reference_kind": entry.reference_kind,
|
||||
"reference_id": entry.reference_id,
|
||||
"mention_count": len(mentions),
|
||||
"content_sha256": entry.content_sha256,
|
||||
"content_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(entry)
|
||||
return _entry_response(entry)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/collaboration/{entry_id}/withdraw",
|
||||
response_model=CampaignCollaborationEntryResponse,
|
||||
)
|
||||
def withdraw_campaign_collaboration_entry(
|
||||
campaign_id: str,
|
||||
entry_id: str,
|
||||
payload: CampaignCollaborationModerationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:post")),
|
||||
) -> CampaignCollaborationEntryResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
|
||||
if entry.actor_user_id != principal.user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the author can withdraw this collaboration entry.",
|
||||
)
|
||||
if entry.redacted_at is not None or entry.withdrawn_at is not None:
|
||||
return _entry_response(entry)
|
||||
entry.content = None
|
||||
entry.withdrawn_at = utc_now()
|
||||
entry.withdrawn_by_user_id = principal.user.id
|
||||
entry.tombstone_reason = payload.reason
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.collaboration.withdrawn",
|
||||
object_type="campaign_collaboration_entry",
|
||||
object_id=entry.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"content_sha256": entry.content_sha256,
|
||||
"reason_recorded": bool(payload.reason),
|
||||
"content_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(entry)
|
||||
return _entry_response(entry)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/collaboration/{entry_id}/redact",
|
||||
response_model=CampaignCollaborationEntryResponse,
|
||||
)
|
||||
def redact_campaign_collaboration_entry(
|
||||
campaign_id: str,
|
||||
entry_id: str,
|
||||
payload: CampaignCollaborationModerationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:discussion:moderate")),
|
||||
) -> CampaignCollaborationEntryResponse:
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:read")
|
||||
entry = _entry_for_campaign(session, campaign_id=campaign_id, entry_id=entry_id, principal=principal)
|
||||
if entry.redacted_at is not None:
|
||||
return _entry_response(entry)
|
||||
entry.content = None
|
||||
entry.redacted_at = utc_now()
|
||||
entry.redacted_by_user_id = principal.user.id
|
||||
entry.tombstone_reason = payload.reason
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.collaboration.redacted",
|
||||
object_type="campaign_collaboration_entry",
|
||||
object_id=entry.id,
|
||||
details={
|
||||
"campaign_id": campaign_id,
|
||||
"content_sha256": entry.content_sha256,
|
||||
"reason_recorded": bool(payload.reason),
|
||||
"previously_withdrawn": entry.withdrawn_at is not None,
|
||||
"content_disclosed": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(entry)
|
||||
return _entry_response(entry)
|
||||
|
||||
|
||||
def _entry_for_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
campaign_id: str,
|
||||
entry_id: str,
|
||||
principal: ApiPrincipal,
|
||||
) -> CampaignCollaborationEntry:
|
||||
entry = session.get(CampaignCollaborationEntry, entry_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.tenant_id != principal.tenant_id
|
||||
or entry.campaign_id != campaign_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collaboration entry not found")
|
||||
return entry
|
||||
|
||||
|
||||
def _validated_reference(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
reference: CampaignCollaborationReferenceInput | None,
|
||||
) -> tuple[str | None, str, str, str] | None:
|
||||
if reference is None:
|
||||
return None
|
||||
kind = reference.kind
|
||||
reference_id = reference.id
|
||||
version: CampaignVersion | None = None
|
||||
default_label = kind.replace("_", " ").title()
|
||||
if kind == "campaign_version":
|
||||
version = session.get(CampaignVersion, reference_id)
|
||||
if version is not None:
|
||||
default_label = f"Version {version.version_number}"
|
||||
elif kind == "delivery_job":
|
||||
job = session.get(CampaignJob, reference_id)
|
||||
if job is None or job.campaign_id != campaign.id or job.tenant_id != campaign.tenant_id:
|
||||
raise _invalid_reference()
|
||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||
default_label = f"Delivery job {job.id[:8]}"
|
||||
elif kind in {"recipient_import_batch", "attachment_rule"}:
|
||||
version_id, separator, child_id = reference_id.partition(":")
|
||||
if not separator or not version_id or not child_id:
|
||||
raise _invalid_reference()
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if version is not None and kind == "recipient_import_batch":
|
||||
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
entries = raw.get("entries") if isinstance(raw.get("entries"), dict) else {}
|
||||
imports = entries.get("imports") if isinstance(entries, dict) else []
|
||||
if not any(isinstance(item, dict) and str(item.get("id") or "") == child_id for item in imports or []):
|
||||
raise _invalid_reference()
|
||||
default_label = "Recipient import batch"
|
||||
elif version is not None:
|
||||
raw = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
if child_id not in {path for path, _rule in _attachment_rules(raw)}:
|
||||
raise _invalid_reference()
|
||||
default_label = "Attachment rule"
|
||||
else:
|
||||
referenced_campaign_id, separator, remainder = reference_id.partition(":")
|
||||
version_id, separator_two, report_kind = remainder.partition(":")
|
||||
if (
|
||||
not separator
|
||||
or not separator_two
|
||||
or referenced_campaign_id != campaign.id
|
||||
or not version_id
|
||||
or not report_kind
|
||||
):
|
||||
raise _invalid_reference()
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
default_label = report_kind.replace("_", " ").title()
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise _invalid_reference()
|
||||
return version.id, kind, reference_id, default_label[:255]
|
||||
|
||||
|
||||
def _invalid_reference() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="The collaboration reference is not stable evidence owned by this campaign.",
|
||||
)
|
||||
|
||||
|
||||
def _validated_mentions(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
user_ids: list[str],
|
||||
actor_user_id: str,
|
||||
) -> list[str]:
|
||||
mentions = [user_id for user_id in user_ids if user_id != actor_user_id]
|
||||
invalid = [
|
||||
user_id
|
||||
for user_id in mentions
|
||||
if not _mentioned_user_has_campaign_access(
|
||||
session,
|
||||
campaign=campaign,
|
||||
user_id=user_id,
|
||||
)
|
||||
]
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Mentioned users must be active and already have access to this campaign.",
|
||||
)
|
||||
return mentions
|
||||
|
||||
|
||||
def _mentioned_user_has_campaign_access(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
user_id: str,
|
||||
) -> bool:
|
||||
directory = _access_directory()
|
||||
user = next(
|
||||
(candidate for candidate in directory.users_for_tenant(campaign.tenant_id) if candidate.id == user_id),
|
||||
None,
|
||||
)
|
||||
if user is None or user.status != "active":
|
||||
return False
|
||||
if campaign.owner_user_id == user_id:
|
||||
return True
|
||||
group_ids = {
|
||||
group.id
|
||||
for group in directory.groups_for_user(user_id, tenant_id=campaign.tenant_id)
|
||||
}
|
||||
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
|
||||
return True
|
||||
clauses = [
|
||||
and_(
|
||||
CampaignShare.target_type == "user",
|
||||
CampaignShare.target_id == user_id,
|
||||
)
|
||||
]
|
||||
if group_ids:
|
||||
clauses.append(
|
||||
and_(
|
||||
CampaignShare.target_type == "group",
|
||||
CampaignShare.target_id.in_(sorted(group_ids)),
|
||||
)
|
||||
)
|
||||
return (
|
||||
session.query(CampaignShare.id)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == campaign.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
or_(*clauses),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _enqueue_mention_notifications(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
entry: CampaignCollaborationEntry,
|
||||
mention_user_ids: list[str],
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None or not mention_user_ids:
|
||||
return
|
||||
try:
|
||||
with session.begin_nested():
|
||||
for user_id in mention_user_ids:
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_collaboration_entry",
|
||||
source_resource_id=entry.id,
|
||||
event_kind="campaign.collaboration.mentioned",
|
||||
channel="inbox",
|
||||
recipient_type="user",
|
||||
recipient_id=user_id,
|
||||
subject=f"Mentioned in campaign: {campaign.name}",
|
||||
body_text=(
|
||||
f"{entry.actor_label_snapshot} mentioned you in the campaign collaboration thread."
|
||||
),
|
||||
action_url=f"/campaigns/{campaign.id}/activity",
|
||||
payload={
|
||||
"campaign_id": campaign.id,
|
||||
"entry_id": entry.id,
|
||||
"content_disclosed": False,
|
||||
},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
except Exception:
|
||||
# Collaboration remains available when the optional Notifications
|
||||
# provider is absent or temporarily unhealthy.
|
||||
return
|
||||
|
||||
|
||||
def _entry_response(entry: CampaignCollaborationEntry) -> CampaignCollaborationEntryResponse:
|
||||
tombstone: Literal["withdrawn", "redacted"] | None = None
|
||||
if entry.redacted_at is not None:
|
||||
tombstone = "redacted"
|
||||
elif entry.withdrawn_at is not None:
|
||||
tombstone = "withdrawn"
|
||||
reference = None
|
||||
if entry.reference_kind and entry.reference_id:
|
||||
reference = CampaignCollaborationReferenceResponse(
|
||||
kind=entry.reference_kind, # type: ignore[arg-type]
|
||||
id=entry.reference_id,
|
||||
label=entry.reference_label,
|
||||
)
|
||||
return CampaignCollaborationEntryResponse(
|
||||
id=entry.id,
|
||||
campaign_id=entry.campaign_id,
|
||||
actor_user_id=entry.actor_user_id,
|
||||
actor_label=entry.actor_label_snapshot,
|
||||
visibility=entry.visibility, # type: ignore[arg-type]
|
||||
content=entry.content if tombstone is None else None,
|
||||
content_sha256=entry.content_sha256,
|
||||
mention_user_ids=list(entry.mention_user_ids or []),
|
||||
reference=reference,
|
||||
tombstone=tombstone,
|
||||
tombstone_reason=entry.tombstone_reason,
|
||||
withdrawn_at=entry.withdrawn_at,
|
||||
redacted_at=entry.redacted_at,
|
||||
created_at=entry.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _encode_cursor(entry: CampaignCollaborationEntry) -> str:
|
||||
created_at = entry.created_at
|
||||
if created_at.tzinfo is None:
|
||||
# SQLite returns timezone-aware columns as naive UTC values.
|
||||
created_at = created_at.replace(tzinfo=UTC)
|
||||
payload = json.dumps(
|
||||
{"created_at": created_at.astimezone(UTC).isoformat(), "id": entry.id},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _decode_cursor(value: str) -> tuple[datetime, str]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
|
||||
created_at = datetime.fromisoformat(str(payload["created_at"]))
|
||||
entry_id = str(payload["id"])
|
||||
if created_at.tzinfo is None or not entry_id or len(entry_id) > 36:
|
||||
raise ValueError
|
||||
return created_at, entry_id
|
||||
except (
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValueError,
|
||||
UnicodeDecodeError,
|
||||
json.JSONDecodeError,
|
||||
binascii.Error,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Invalid collaboration cursor.",
|
||||
) from exc
|
||||
@@ -0,0 +1,799 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
AppendSentRequest,
|
||||
CampaignActionResponse,
|
||||
CampaignRetryJobsRequest,
|
||||
CampaignSendJobRequest,
|
||||
CampaignSendUnattemptedRequest,
|
||||
CampaignResolveOutcomeRequest,
|
||||
CampaignRecoverClaimRequest,
|
||||
CampaignDeliveryOptionsResponse,
|
||||
MockCampaignSendRequest,
|
||||
MockCampaignSendResponse,
|
||||
QueueCampaignRequest,
|
||||
QueueCampaignResponse,
|
||||
SendCampaignNowRequest,
|
||||
SendCampaignNowResponse,
|
||||
)
|
||||
from govoplan_campaign.backend.approval_gate import (
|
||||
CampaignApprovalGateError,
|
||||
campaign_approval_status,
|
||||
request_campaign_approval,
|
||||
)
|
||||
from govoplan_campaign.backend.approval_schemas import CampaignApprovalRequestInput
|
||||
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"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/delivery-progress")
|
||||
def get_campaign_delivery_progress(
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
from govoplan_campaign.backend.services.delivery_progress import campaign_delivery_progress
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
try:
|
||||
return campaign_delivery_progress(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def _public_recovery_result(result: dict) -> dict:
|
||||
if not result.get("run_inline"):
|
||||
return result
|
||||
public = public_send_campaign_now_result(result, validation_summary={}, build_summary={})
|
||||
for key in ("action", "selected_count", "remaining_count", "enqueued_count", "skipped", "run_inline"):
|
||||
if key in result:
|
||||
public[key] = result[key]
|
||||
return public
|
||||
|
||||
|
||||
@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:
|
||||
options = synchronous_send_options(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
version = _get_version_for_tenant(
|
||||
session, str(options["version_id"]), principal.tenant_id
|
||||
)
|
||||
return CampaignDeliveryOptionsResponse(
|
||||
**options,
|
||||
postbox_available=postbox_integration().available,
|
||||
approval_gate=campaign_approval_status(
|
||||
session, tenant_id=principal.tenant_id, version=version
|
||||
),
|
||||
)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/approval-request",
|
||||
response_model=dict[str, object],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_approval_request(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignApprovalRequestInput,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:review")),
|
||||
) -> dict[str, object]:
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if campaign.current_version_id != version_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Approval can only be requested for the current Campaign version.",
|
||||
)
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
if not version.build_summary:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Build Campaign messages before requesting approval.",
|
||||
)
|
||||
try:
|
||||
request = request_campaign_approval(
|
||||
session,
|
||||
principal,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
steps=tuple(step.to_definition() for step in payload.steps),
|
||||
idempotency_key=payload.idempotency_key,
|
||||
template_id=payload.template_id,
|
||||
template_revision=payload.template_revision,
|
||||
unique_actors_across_steps=payload.unique_actors_across_steps,
|
||||
expires_at=payload.expires_at,
|
||||
policy_refs=tuple(payload.policy_refs),
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.approval_requested",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"approval_request_id": request.id,
|
||||
"approval_request_revision": request.revision,
|
||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return {
|
||||
"request_id": request.id,
|
||||
"request_revision": request.revision,
|
||||
"request_state": request.state,
|
||||
"approval_gate": campaign_approval_status(
|
||||
session, tenant_id=principal.tenant_id, version=version
|
||||
),
|
||||
}
|
||||
except (CampaignApprovalGateError, ExecutionSnapshotError) as exc:
|
||||
session.rollback()
|
||||
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()
|
||||
if payload.run_inline:
|
||||
_require_permission(principal, "campaigns:campaign:send")
|
||||
_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,
|
||||
run_inline=payload.run_inline,
|
||||
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=_public_recovery_result(result),
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=_public_recovery_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()
|
||||
if payload.run_inline:
|
||||
_require_permission(principal, "campaigns:campaign:send")
|
||||
_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,
|
||||
run_inline=payload.run_inline,
|
||||
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=_public_recovery_result(result),
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=_public_recovery_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,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_any_scope(
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:send_test",
|
||||
)
|
||||
),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_permission(
|
||||
principal,
|
||||
(
|
||||
"campaigns:campaign:send_test"
|
||||
if payload.kind == "test"
|
||||
else "campaigns:campaign:send"
|
||||
),
|
||||
)
|
||||
_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,
|
||||
kind=payload.kind,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_api_key_id=getattr(principal, "api_key_id", None),
|
||||
reason=payload.reason,
|
||||
action_context=payload.context,
|
||||
include_warnings=payload.include_warnings,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=f"campaign.message_{payload.kind}",
|
||||
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:
|
||||
logger.exception(
|
||||
"Unexpected single-message campaign action failure",
|
||||
extra={
|
||||
"campaign_id": campaign_id,
|
||||
"job_id": job_id,
|
||||
"action_kind": payload.kind,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="The message action failed because of an internal error.",
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/recover-claim", response_model=CampaignActionResponse)
|
||||
def recover_campaign_job_claim(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignRecoverClaimRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
||||
):
|
||||
from govoplan_campaign.backend.services.delivery_recovery import recover_stale_delivery_claim, RecoveryStateConflict
|
||||
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
result = recover_stale_delivery_claim(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id,
|
||||
job_id=job_id, channel=payload.channel,
|
||||
expected_revision=payload.expected_revision, note=payload.note,
|
||||
)
|
||||
audit_from_principal(session, principal, action="campaign.job_claim_recovered", 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=409 if isinstance(exc, RecoveryStateConflict) else 422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
Authoring previews validate and build transiently; reviewed-build mode
|
||||
verifies frozen jobs/EML and completed review instead. Both optionally record
|
||||
mock SMTP deliveries and mock IMAP appends. Neither 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,
|
||||
use_reviewed_build=payload.use_reviewed_build,
|
||||
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,
|
||||
"use_reviewed_build": payload.use_reviewed_build,
|
||||
"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")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
payload = payload or AppendSentRequest()
|
||||
selected_version_id = payload.version_id or campaign.current_version_id
|
||||
_require_campaign_profile_use_if_needed(session, principal, campaign_id, selected_version_id)
|
||||
try:
|
||||
result = enqueue_pending_imap_appends(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=selected_version_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
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Audited, independently editable delivery limits; never a delivery command."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.models import SystemSettings
|
||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID, get_system_settings
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.configuration_control import (
|
||||
ConfigurationControlError, configuration_value_digest,
|
||||
ensure_configuration_change_allowed, record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
from govoplan_campaign.backend.delivery_policy import (
|
||||
ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY, SYNCHRONOUS_SEND_MAX_SETTINGS_KEY,
|
||||
CampaignDeliveryPolicyError, effective_synchronous_send_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import _require_permission
|
||||
|
||||
router = APIRouter(prefix="/campaigns/settings/delivery-policy", tags=["campaigns"])
|
||||
Scope = Literal["system", "tenant"]
|
||||
|
||||
|
||||
class DeliveryPolicyUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
synchronous_send_max_recipients: StrictInt | None = Field(default=None, ge=0, le=500)
|
||||
expected_revision: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _state(session: Session, principal: ApiPrincipal, scope: Scope) -> dict:
|
||||
policy = effective_synchronous_send_policy(session, tenant_id=principal.tenant_id, apply_tenant_override=scope == "tenant")
|
||||
system_limit = policy.system_max_recipient_jobs
|
||||
# Resolve the parent without projecting the tenant's own override into it.
|
||||
parent_limit = min(policy.deployment_max_recipient_jobs, system_limit) if system_limit is not None else (
|
||||
policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS
|
||||
)
|
||||
own = system_limit if scope == "system" else policy.tenant_max_recipient_jobs
|
||||
system = session.get(SystemSettings, SYSTEM_SETTINGS_ID)
|
||||
tenant = session.get(Tenant, principal.tenant_id) if scope == "tenant" else None
|
||||
def stored_revision(row):
|
||||
return ((row.settings or {}).get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {}).get("revision", 0) if row else 0
|
||||
revision = configuration_value_digest({
|
||||
"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None,
|
||||
"own": own, "system": system_limit, "deployment": policy.deployment_max_recipient_jobs,
|
||||
"explicit_deployment": policy.deployment_ceiling_explicit,
|
||||
"system_revision": stored_revision(system), "tenant_revision": stored_revision(tenant),
|
||||
})
|
||||
return {
|
||||
"scope": scope, "synchronous_send_max_recipients": own, "revision": revision,
|
||||
"max_configurable_recipients": policy.deployment_max_recipient_jobs if scope == "system" else parent_limit,
|
||||
"effective_max_recipients": parent_limit if scope == "system" else policy.max_recipient_jobs,
|
||||
"inherited_max_recipients": (policy.deployment_max_recipient_jobs if policy.deployment_ceiling_explicit else DEFAULT_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS) if scope == "system" else parent_limit,
|
||||
"absolute_max_recipients": ABSOLUTE_SYNCHRONOUS_SEND_MAX_RECIPIENT_JOBS,
|
||||
"deployment_ceiling_explicit": policy.deployment_ceiling_explicit,
|
||||
"deployment_max_recipients": policy.deployment_max_recipient_jobs,
|
||||
}
|
||||
|
||||
|
||||
def _scope_permission(principal: ApiPrincipal, scope: Scope, operation: str) -> None:
|
||||
_require_permission(principal, f"system:settings:{operation}" if scope == "system" else f"admin:policies:{operation}")
|
||||
|
||||
|
||||
@router.get("/{scope}")
|
||||
def read_delivery_policy(
|
||||
scope: Scope, session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:settings:read", "admin:policies:read")),
|
||||
):
|
||||
_scope_permission(principal, scope, "read")
|
||||
try:
|
||||
return _state(session, principal, scope)
|
||||
except CampaignDeliveryPolicyError as exc:
|
||||
raise HTTPException(422, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/{scope}")
|
||||
def update_delivery_policy(
|
||||
scope: Scope, payload: DeliveryPolicyUpdate, session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("system:settings:write", "admin:policies:write")),
|
||||
):
|
||||
_scope_permission(principal, scope, "write")
|
||||
try:
|
||||
# Always lock in the same order; history and system policy share one Core row.
|
||||
system = session.query(SystemSettings).filter(SystemSettings.id == SYSTEM_SETTINGS_ID).populate_existing().with_for_update().one_or_none()
|
||||
if system is None:
|
||||
system = get_system_settings(session)
|
||||
target = system
|
||||
if scope == "tenant":
|
||||
target = session.query(Tenant).filter(Tenant.id == principal.tenant_id).populate_existing().with_for_update().one_or_none()
|
||||
if target is None:
|
||||
raise HTTPException(404, detail="Tenant not found")
|
||||
before = _state(session, principal, scope)
|
||||
if payload.expected_revision != before["revision"]:
|
||||
raise HTTPException(409, detail="Campaign delivery policy changed. Reload the saved policy before retrying; your draft has not been saved.")
|
||||
value = payload.synchronous_send_max_recipients
|
||||
if value is not None and value > before["max_configurable_recipients"]:
|
||||
raise HTTPException(422, detail=f"This scope may configure at most {before['max_configurable_recipients']} recipient jobs; inherited or explicit deployment ceilings cannot be raised here.")
|
||||
key = f"campaign_delivery_policy.{scope}"
|
||||
after_value = {SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: value}
|
||||
approval = ensure_configuration_change_allowed(
|
||||
session, key=key, value=after_value, actor_user_id=principal.user.id,
|
||||
actor_scopes=tuple(principal.scopes), target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None},
|
||||
)
|
||||
settings = dict(target.settings or {})
|
||||
saved_policy = dict(settings.get(CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY) or {})
|
||||
saved_policy["revision"] = int(saved_policy.get("revision") or 0) + 1
|
||||
if value is None:
|
||||
saved_policy.pop(SYNCHRONOUS_SEND_MAX_SETTINGS_KEY, None)
|
||||
else:
|
||||
saved_policy[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY] = value
|
||||
settings[CAMPAIGN_DELIVERY_POLICY_SETTINGS_KEY] = saved_policy
|
||||
target.settings = settings
|
||||
session.flush()
|
||||
record_configuration_change_applied(
|
||||
session, key=key, before_value={SYNCHRONOUS_SEND_MAX_SETTINGS_KEY: before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY]},
|
||||
after_value=after_value, actor_user_id=principal.user.id, approval=approval,
|
||||
target={"scope": scope, "tenant_id": principal.tenant_id if scope == "tenant" else None},
|
||||
audit_event="campaign.delivery_policy_updated",
|
||||
)
|
||||
result = _state(session, principal, scope)
|
||||
audit_from_principal(session, principal, action="campaign.delivery_policy_updated", scope=scope, object_type="campaign_delivery_policy",
|
||||
object_id=scope if scope == "system" else principal.tenant_id,
|
||||
details={"scope": scope, "before": before[SYNCHRONOUS_SEND_MAX_SETTINGS_KEY], "after": value, "effective_max_recipients": result["effective_max_recipients"]}, commit=False)
|
||||
session.commit()
|
||||
return result
|
||||
except (CampaignDeliveryPolicyError, ConfigurationControlError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(422, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,532 @@
|
||||
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,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
PrintOutputAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import postbox_integration
|
||||
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,
|
||||
_calendar_invitations_for_jobs,
|
||||
_job_detail_payload,
|
||||
_job_page_recovery_metadata,
|
||||
_job_diagnostics_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _postbox_receipts_for_attempts(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
attempts: list[PostboxDeliveryAttempt],
|
||||
):
|
||||
integration = postbox_integration()
|
||||
if not integration.receipt_evidence_available:
|
||||
return None
|
||||
delivery_ids = [
|
||||
attempt.provider_delivery_id
|
||||
for attempt in attempts
|
||||
if attempt.provider_delivery_id
|
||||
]
|
||||
return integration.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
delivery_ids=delivery_ids,
|
||||
)
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
print_attempts = _job_attempt_rows(
|
||||
session.query(PrintOutputAttempt)
|
||||
.filter(PrintOutputAttempt.job_id == job.id)
|
||||
.order_by(PrintOutputAttempt.attempt_number.asc()),
|
||||
label="Printable output attempts for this campaign job",
|
||||
)
|
||||
message_actions = _job_attempt_rows(
|
||||
session.query(CampaignMessageAction)
|
||||
.filter(CampaignMessageAction.job_id == job.id)
|
||||
.order_by(CampaignMessageAction.created_at.asc()),
|
||||
label="Single-message actions for this campaign job",
|
||||
)
|
||||
action_ids = [action.id for action in message_actions]
|
||||
message_action_attempts = (
|
||||
_job_attempt_rows(
|
||||
session.query(CampaignMessageActionAttempt)
|
||||
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
|
||||
.order_by(CampaignMessageActionAttempt.started_at.asc()),
|
||||
label="Single-message action attempts for this campaign job",
|
||||
)
|
||||
if action_ids
|
||||
else []
|
||||
)
|
||||
return CampaignJobDetailResponse(
|
||||
job=_job_detail_payload(
|
||||
job,
|
||||
recovery=_job_page_recovery_metadata(session, [job]).get(job.id),
|
||||
calendar_invitation=_calendar_invitations_for_jobs(
|
||||
session,
|
||||
[job],
|
||||
).get(job.id),
|
||||
),
|
||||
attempts=_job_attempts_payload(
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts=postbox_attempts,
|
||||
print_attempts=print_attempts,
|
||||
message_actions=message_actions,
|
||||
message_action_attempts=message_action_attempts,
|
||||
postbox_receipts=_postbox_receipts_for_attempts(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
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",
|
||||
)
|
||||
print_attempts = _job_attempt_rows(
|
||||
session.query(PrintOutputAttempt)
|
||||
.filter(PrintOutputAttempt.job_id == job.id)
|
||||
.order_by(PrintOutputAttempt.attempt_number.asc()),
|
||||
label="Printable output diagnostics for this campaign job",
|
||||
)
|
||||
message_actions = _job_attempt_rows(
|
||||
session.query(CampaignMessageAction)
|
||||
.filter(CampaignMessageAction.job_id == job.id)
|
||||
.order_by(CampaignMessageAction.created_at.asc()),
|
||||
label="Single-message action diagnostics for this campaign job",
|
||||
)
|
||||
action_ids = [action.id for action in message_actions]
|
||||
message_action_attempts = (
|
||||
_job_attempt_rows(
|
||||
session.query(CampaignMessageActionAttempt)
|
||||
.filter(CampaignMessageActionAttempt.action_id.in_(action_ids))
|
||||
.order_by(CampaignMessageActionAttempt.started_at.asc()),
|
||||
label="Single-message action-attempt diagnostics for this campaign job",
|
||||
)
|
||||
if action_ids
|
||||
else []
|
||||
)
|
||||
return _job_diagnostics_payload(
|
||||
job,
|
||||
send_attempts,
|
||||
imap_attempts,
|
||||
postbox_attempts=postbox_attempts,
|
||||
print_attempts=print_attempts,
|
||||
message_actions=message_actions,
|
||||
message_action_attempts=message_action_attempts,
|
||||
postbox_receipts=_postbox_receipts_for_attempts(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
attempts=postbox_attempts,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.artifact_reconciliation import (
|
||||
CampaignArtifactReconciliationError,
|
||||
reconcile_campaign_artifacts,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import _object_storage
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignArtifactReconcileRequest,
|
||||
CampaignArtifactReconcileResponse,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.core.object_storage import StorageBackendError
|
||||
from govoplan_core.core.recovery import RecoveryGuaranteeError
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
)
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_core.server.runtime_agent import application_runtime_identity
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns/operations", tags=["campaigns"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/artifacts/reconcile",
|
||||
response_model=CampaignArtifactReconcileResponse,
|
||||
)
|
||||
def reconcile_artifacts(
|
||||
payload: CampaignArtifactReconcileRequest,
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:write")),
|
||||
) -> CampaignArtifactReconcileResponse:
|
||||
"""Inventory or remove old, unreferenced Campaign-owned build objects."""
|
||||
|
||||
try:
|
||||
result = reconcile_campaign_artifacts(
|
||||
get_database().SessionLocal,
|
||||
storage=_object_storage(),
|
||||
identity=application_runtime_identity(request.app),
|
||||
tenant_id=principal.tenant_id,
|
||||
apply=payload.apply,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
grace_period_hours=payload.grace_period_hours,
|
||||
cursor=payload.cursor,
|
||||
page_size=payload.page_size,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except (
|
||||
CampaignArtifactReconciliationError,
|
||||
RecoveryGuaranteeError,
|
||||
StorageBackendError,
|
||||
SQLAlchemyError,
|
||||
OSError,
|
||||
) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"Campaign artifact reconciliation is temporarily unavailable "
|
||||
f"({type(exc).__name__})."
|
||||
),
|
||||
) from exc
|
||||
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"campaign.artifact_orphans_reconciled"
|
||||
if payload.apply
|
||||
else "campaign.artifact_inventory_scanned"
|
||||
),
|
||||
object_type="campaign_artifact_namespace",
|
||||
object_id=principal.tenant_id,
|
||||
details={
|
||||
"apply": payload.apply,
|
||||
"status": result["status"],
|
||||
"scanned_count": result["scanned_count"],
|
||||
"candidate_count": result["candidate_count"],
|
||||
"deleted_count": result["deleted_count"],
|
||||
"failure_count": result["failure_count"],
|
||||
"manifest_sha256": result["manifest_sha256"],
|
||||
"recovery_operation_id": result["recovery_operation_id"],
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return CampaignArtifactReconcileResponse.model_validate(result)
|
||||
@@ -0,0 +1,257 @@
|
||||
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 (
|
||||
MailDeliveryCommandError,
|
||||
MailProfileError,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
mail_integration,
|
||||
)
|
||||
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
def _enqueue_mail_command() -> None:
|
||||
try:
|
||||
from govoplan_core.celery_app import celery
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
if settings.celery_enabled:
|
||||
celery.send_task(
|
||||
"govoplan.mail.dispatch_outbox",
|
||||
args=[None, 25],
|
||||
queue="mail",
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Mail delivery command is durable but immediate worker wake-up failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
idempotency_key=payload.idempotency_key,
|
||||
created_by_user_id=principal.user.id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="report.email_requested"
|
||||
if not payload.dry_run
|
||||
else "report.email_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result.audit_dict(),
|
||||
commit=True,
|
||||
)
|
||||
if not payload.dry_run:
|
||||
_enqueue_mail_command()
|
||||
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,
|
||||
MailDeliveryCommandError,
|
||||
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
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/report/email/{command_id}",
|
||||
response_model=ReportEmailResponse,
|
||||
)
|
||||
def campaign_report_email_status(
|
||||
campaign_id: str,
|
||||
command_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:report:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
try:
|
||||
result = mail_integration().delivery_command_summary(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
command_id=command_id,
|
||||
)
|
||||
except MailDeliveryCommandError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
if (
|
||||
result.get("source_module") != "campaigns"
|
||||
or result.get("source_resource_type") != "campaign"
|
||||
or result.get("source_resource_id") != campaign_id
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign report delivery not found",
|
||||
)
|
||||
return ReportEmailResponse(result=result)
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
campaign_schedule_source_snapshot,
|
||||
canonical_configuration_hash,
|
||||
validate_autonomous_schedule_source,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignScheduleCreateRequest,
|
||||
CampaignScheduleListResponse,
|
||||
CampaignScheduleOccurrenceResponse,
|
||||
CampaignScheduleResponse,
|
||||
CampaignScheduleStateRequest,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{campaign_id}/schedules",
|
||||
response_model=CampaignScheduleListResponse,
|
||||
)
|
||||
def list_campaign_schedules(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
schedules = (
|
||||
session.query(CampaignSchedule)
|
||||
.filter(
|
||||
CampaignSchedule.tenant_id == principal.tenant_id,
|
||||
CampaignSchedule.campaign_id == campaign_id,
|
||||
)
|
||||
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
|
||||
.all()
|
||||
)
|
||||
occurrences = _occurrences_by_schedule(session, schedules)
|
||||
return CampaignScheduleListResponse(
|
||||
items=[
|
||||
_schedule_response(item, occurrences.get(item.id, []))
|
||||
for item in schedules
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/schedules",
|
||||
response_model=CampaignScheduleResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_campaign_schedule(
|
||||
campaign_id: str,
|
||||
payload: CampaignScheduleCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
_require_permission(principal, "campaigns:campaign:copy")
|
||||
if payload.include_recipients:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
if payload.include_shares:
|
||||
_require_permission(principal, "campaigns:campaign:share")
|
||||
if payload.delivery_mode == "autonomous":
|
||||
_require_permission(principal, "campaigns:campaign:queue")
|
||||
_require_permission(principal, "campaigns:campaign:send")
|
||||
_require_permission(principal, "mail:profile:use")
|
||||
source_version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == payload.source_version_id,
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if source_version is None:
|
||||
raise HTTPException(status_code=404, detail="Campaign version not found")
|
||||
starts_at = payload.starts_at.astimezone(UTC)
|
||||
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign schedules cannot start in the past.",
|
||||
)
|
||||
try:
|
||||
ZoneInfo(payload.timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Unknown campaign schedule timezone.",
|
||||
) from exc
|
||||
|
||||
source_shares = (
|
||||
session.query(CampaignShare)
|
||||
.filter(
|
||||
CampaignShare.tenant_id == principal.tenant_id,
|
||||
CampaignShare.campaign_id == campaign.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
.order_by(CampaignShare.id.asc())
|
||||
.all()
|
||||
if payload.include_shares
|
||||
else []
|
||||
)
|
||||
snapshot = campaign_schedule_source_snapshot(
|
||||
configuration=source_version.raw_json,
|
||||
campaign_settings=campaign.settings or {},
|
||||
mail_profile_policy=campaign.mail_profile_policy or {},
|
||||
shares=[
|
||||
{
|
||||
"target_type": item.target_type,
|
||||
"target_id": item.target_id,
|
||||
"permission": item.permission,
|
||||
}
|
||||
for item in source_shares
|
||||
],
|
||||
)
|
||||
autonomous_evidence: dict[str, object] | None = None
|
||||
if payload.delivery_mode == "autonomous":
|
||||
try:
|
||||
autonomous_evidence = validate_autonomous_schedule_source(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=source_version,
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
snapshot["autonomous_delivery"] = autonomous_evidence
|
||||
schedule = CampaignSchedule(
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
source_version_id=source_version.id,
|
||||
created_by_user_id=principal.user.id,
|
||||
name=payload.name.strip(),
|
||||
delivery_mode=payload.delivery_mode,
|
||||
recurrence_kind=payload.recurrence_kind,
|
||||
interval_count=payload.interval_count,
|
||||
timezone=payload.timezone,
|
||||
starts_at=starts_at,
|
||||
next_fire_at=starts_at,
|
||||
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
|
||||
max_occurrences=payload.max_occurrences,
|
||||
copy_options={
|
||||
"include_recipients": payload.include_recipients,
|
||||
"include_files": payload.include_files,
|
||||
"include_shares": payload.include_shares,
|
||||
"include_policies": payload.include_policies,
|
||||
"include_mail_profile": payload.include_mail_profile,
|
||||
},
|
||||
source_snapshot=snapshot,
|
||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||
approved_execution_snapshot_hash=(
|
||||
str(autonomous_evidence["execution_snapshot_hash"])
|
||||
if autonomous_evidence is not None
|
||||
else None
|
||||
),
|
||||
source_base_path=source_version.source_base_path,
|
||||
)
|
||||
session.add(schedule)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.schedule.created",
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"source_version_id": source_version.id,
|
||||
"recurrence_kind": schedule.recurrence_kind,
|
||||
"interval_count": schedule.interval_count,
|
||||
"starts_at": schedule.starts_at.isoformat(),
|
||||
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
|
||||
"max_occurrences": schedule.max_occurrences,
|
||||
"delivery_mode": schedule.delivery_mode,
|
||||
"delivery_started": False,
|
||||
"autonomous_delivery_opted_in": (
|
||||
schedule.delivery_mode == "autonomous"
|
||||
),
|
||||
"approved_execution_snapshot_hash": (
|
||||
schedule.approved_execution_snapshot_hash
|
||||
),
|
||||
"approval_request_id": (
|
||||
autonomous_evidence.get("approval_request_id")
|
||||
if autonomous_evidence is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(schedule)
|
||||
return _schedule_response(schedule, [])
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{campaign_id}/schedules/{schedule_id}",
|
||||
response_model=CampaignScheduleResponse,
|
||||
)
|
||||
def set_campaign_schedule_state(
|
||||
campaign_id: str,
|
||||
schedule_id: str,
|
||||
payload: CampaignScheduleStateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal)
|
||||
schedule = _schedule_for_campaign(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
schedule_id=schedule_id,
|
||||
for_update=True,
|
||||
)
|
||||
if schedule.resource_revision != payload.base_revision:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Campaign schedule changed. Reload it before changing its state.",
|
||||
)
|
||||
if payload.active and schedule.next_fire_at is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A completed campaign schedule cannot be resumed.",
|
||||
)
|
||||
if payload.active:
|
||||
unresolved = (
|
||||
session.query(CampaignScheduleOccurrence.id)
|
||||
.filter(
|
||||
CampaignScheduleOccurrence.schedule_id == schedule.id,
|
||||
CampaignScheduleOccurrence.status == "uncertain",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if unresolved is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"Reconcile the autonomous delivery outcome in Mail before "
|
||||
"resuming this schedule."
|
||||
),
|
||||
)
|
||||
schedule.active = payload.active
|
||||
schedule.last_error = None if payload.active else schedule.last_error
|
||||
schedule.resource_revision += 1
|
||||
session.add(schedule)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=(
|
||||
"campaign.schedule.resumed"
|
||||
if payload.active
|
||||
else "campaign.schedule.paused"
|
||||
),
|
||||
object_type="campaign_schedule",
|
||||
object_id=schedule.id,
|
||||
details={"campaign_id": campaign_id},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(schedule)
|
||||
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
|
||||
return _schedule_response(schedule, occurrences)
|
||||
|
||||
|
||||
def _schedule_for_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
schedule_id: str,
|
||||
for_update: bool = False,
|
||||
) -> CampaignSchedule:
|
||||
query = session.query(CampaignSchedule)
|
||||
if for_update:
|
||||
query = query.with_for_update()
|
||||
schedule = (
|
||||
query
|
||||
.filter(
|
||||
CampaignSchedule.id == schedule_id,
|
||||
CampaignSchedule.tenant_id == tenant_id,
|
||||
CampaignSchedule.campaign_id == campaign_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if schedule is None:
|
||||
raise HTTPException(status_code=404, detail="Campaign schedule not found")
|
||||
return schedule
|
||||
|
||||
|
||||
def _occurrences_by_schedule(
|
||||
session: Session,
|
||||
schedules: list[CampaignSchedule],
|
||||
) -> dict[str, list[CampaignScheduleOccurrence]]:
|
||||
ids = [item.id for item in schedules]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
session.query(CampaignScheduleOccurrence)
|
||||
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
|
||||
.order_by(
|
||||
CampaignScheduleOccurrence.scheduled_for.desc(),
|
||||
CampaignScheduleOccurrence.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
|
||||
for row in rows:
|
||||
grouped.setdefault(row.schedule_id, []).append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
def _schedule_response(
|
||||
schedule: CampaignSchedule,
|
||||
occurrences: list[CampaignScheduleOccurrence],
|
||||
) -> CampaignScheduleResponse:
|
||||
response = CampaignScheduleResponse.model_validate(schedule)
|
||||
return response.model_copy(
|
||||
update={
|
||||
"occurrences": [
|
||||
CampaignScheduleOccurrenceResponse.model_validate(item)
|
||||
for item in occurrences
|
||||
]
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,288 @@
|
||||
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,
|
||||
_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")),
|
||||
):
|
||||
del payload
|
||||
_get_campaign_for_principal(
|
||||
session,
|
||||
campaign_id,
|
||||
principal,
|
||||
write=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"Direct campaign owner mutation has been retired. Use the "
|
||||
"ownership transfer workflow so the target can accept."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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 -------------------------------------------------
|
||||
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from importlib import metadata
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.transfers import (
|
||||
CampaignImportInspection,
|
||||
CampaignTransferError,
|
||||
build_campaign_portable_package,
|
||||
inspect_campaign_portable_package,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
create_campaign_version_from_json,
|
||||
)
|
||||
from govoplan_campaign.backend.route_support import (
|
||||
_campaign_response_context,
|
||||
_get_campaign_for_principal,
|
||||
_require_permission,
|
||||
_write_current_version_snapshot_if_available,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignExportRequest,
|
||||
CampaignImportApplyRequest,
|
||||
CampaignImportApplyResponse,
|
||||
CampaignImportPreviewRequest,
|
||||
CampaignImportPreviewResponse,
|
||||
CampaignPortablePackageResponse,
|
||||
CampaignResponse,
|
||||
CampaignVersionResponse,
|
||||
)
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
|
||||
router = APIRouter(tags=["campaigns"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/campaigns/{campaign_id}/versions/{version_id}/exports",
|
||||
response_model=CampaignPortablePackageResponse,
|
||||
)
|
||||
def export_campaign_package(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignExportRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:campaign:export")
|
||||
),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == version_id,
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign version not found",
|
||||
)
|
||||
scopes = set(payload.scopes)
|
||||
if "recipients" in scopes:
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
if "review_state" in scopes:
|
||||
_require_permission(principal, "campaigns:report:read")
|
||||
if "delivery_history" in scopes:
|
||||
_require_permission(principal, "campaigns:report:export")
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
_require_permission(principal, "campaigns:recipient:export")
|
||||
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.all()
|
||||
if "delivery_history" in scopes
|
||||
else ()
|
||||
)
|
||||
issues = (
|
||||
session.query(CampaignIssue)
|
||||
.filter(CampaignIssue.campaign_version_id == version.id)
|
||||
.order_by(CampaignIssue.id.asc())
|
||||
.all()
|
||||
if "review_state" in scopes
|
||||
else ()
|
||||
)
|
||||
try:
|
||||
package = build_campaign_portable_package(
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
scopes=payload.scopes,
|
||||
jobs=jobs,
|
||||
issues=issues,
|
||||
module_version=_module_version(),
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.portable_export_created",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"package_id": package["package_id"],
|
||||
"package_sha256": package["integrity"]["package_sha256"],
|
||||
"format_version": package["format_version"],
|
||||
"scopes": package["scopes"],
|
||||
"item_counts": package["manifest"]["item_counts"],
|
||||
"redactions": package["manifest"]["redactions"],
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
except CampaignTransferError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
return package
|
||||
|
||||
|
||||
@router.post(
|
||||
"/campaign-transfers/imports/preview",
|
||||
response_model=CampaignImportPreviewResponse,
|
||||
)
|
||||
def preview_campaign_import(
|
||||
payload: CampaignImportPreviewRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:campaign:import")
|
||||
),
|
||||
):
|
||||
_require_permission(principal, "campaigns:campaign:create")
|
||||
inspection = _inspect_import_request(
|
||||
session,
|
||||
principal,
|
||||
package=payload.package,
|
||||
selected_scopes=payload.selected_scopes,
|
||||
external_id=payload.external_id,
|
||||
name=payload.name,
|
||||
)
|
||||
return inspection.preview
|
||||
|
||||
|
||||
@router.post(
|
||||
"/campaign-transfers/imports",
|
||||
response_model=CampaignImportApplyResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def import_campaign_package(
|
||||
payload: CampaignImportApplyRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(
|
||||
require_scope("campaigns:campaign:import")
|
||||
),
|
||||
):
|
||||
_require_permission(principal, "campaigns:campaign:create")
|
||||
inspection = _inspect_import_request(
|
||||
session,
|
||||
principal,
|
||||
package=payload.package,
|
||||
selected_scopes=payload.selected_scopes,
|
||||
external_id=payload.external_id,
|
||||
name=payload.name,
|
||||
)
|
||||
_require_import_scope_permissions(principal, inspection)
|
||||
preview = inspection.preview
|
||||
if payload.expected_package_sha256 != preview.get("package_sha256"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The Campaign package changed after preview. Preview it again before importing.",
|
||||
)
|
||||
if not preview["compatible"] or inspection.configuration is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail={
|
||||
"message": "The Campaign package is not compatible.",
|
||||
"errors": preview["errors"],
|
||||
},
|
||||
)
|
||||
|
||||
destination = preview["destination"]
|
||||
package_id = str(preview["package_id"])
|
||||
package_sha256 = str(preview["package_sha256"])
|
||||
receipt = {
|
||||
"package_id": package_id,
|
||||
"package_sha256": package_sha256,
|
||||
"format_version": preview["format_version"],
|
||||
"source": copy.deepcopy(preview["source"]),
|
||||
"selected_scopes": list(preview["selected_scopes"]),
|
||||
"created": copy.deepcopy(preview["will_create"]),
|
||||
"skipped": copy.deepcopy(preview["will_skip"]),
|
||||
}
|
||||
try:
|
||||
campaign, version = create_campaign_version_from_json(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
raw_json=inspection.configuration,
|
||||
source_filename=f"{package_id}.govoplan-campaign.json",
|
||||
source_base_path=None,
|
||||
commit=False,
|
||||
)
|
||||
campaign.settings = {
|
||||
**inspection.portable_settings,
|
||||
"portable_import": receipt,
|
||||
}
|
||||
session.add(campaign)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.portable_import_applied",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"version_id": version.id,
|
||||
"external_id": destination["external_id"],
|
||||
"package_id": package_id,
|
||||
"package_sha256": package_sha256,
|
||||
"format_version": preview["format_version"],
|
||||
"selected_scopes": preview["selected_scopes"],
|
||||
"created_codes": [item["code"] for item in preview["will_create"]],
|
||||
"skipped_codes": [item["code"] for item in preview["will_skip"]],
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(campaign)
|
||||
session.refresh(version)
|
||||
_write_current_version_snapshot_if_available(version)
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
return CampaignImportApplyResponse(
|
||||
campaign=CampaignResponse.model_validate(campaign),
|
||||
version=CampaignVersionResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
),
|
||||
receipt=receipt,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_import_request(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
package: dict[str, Any],
|
||||
selected_scopes: list[str] | None,
|
||||
external_id: str | None,
|
||||
name: str | None,
|
||||
) -> CampaignImportInspection:
|
||||
source = package.get("source")
|
||||
source = source if isinstance(source, dict) else {}
|
||||
metadata_payload = package.get("payload")
|
||||
metadata_payload = metadata_payload if isinstance(metadata_payload, dict) else {}
|
||||
metadata_scope = metadata_payload.get("metadata")
|
||||
metadata_scope = metadata_scope if isinstance(metadata_scope, dict) else {}
|
||||
source_external_id = str(
|
||||
metadata_scope.get("external_id")
|
||||
or source.get("campaign_external_id")
|
||||
or "campaign"
|
||||
)
|
||||
destination_external_id = _portable_import_external_id(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
source_external_id=source_external_id,
|
||||
requested=external_id,
|
||||
)
|
||||
destination_name = str(
|
||||
name
|
||||
or metadata_scope.get("name")
|
||||
or source.get("campaign_name")
|
||||
or "Imported campaign"
|
||||
).strip()
|
||||
if not destination_name:
|
||||
destination_name = "Imported campaign"
|
||||
inspection = inspect_campaign_portable_package(
|
||||
package,
|
||||
selected_scopes=selected_scopes,
|
||||
external_id=destination_external_id,
|
||||
name=destination_name,
|
||||
)
|
||||
if _campaign_external_id_exists(
|
||||
session, principal.tenant_id, destination_external_id
|
||||
):
|
||||
inspection.preview["compatible"] = False
|
||||
inspection.preview["errors"].append(
|
||||
"The destination Campaign ID already exists in this tenant."
|
||||
)
|
||||
return CampaignImportInspection(
|
||||
preview=inspection.preview,
|
||||
configuration=None,
|
||||
portable_settings=inspection.portable_settings,
|
||||
)
|
||||
return inspection
|
||||
|
||||
|
||||
def _portable_import_external_id(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_external_id: str,
|
||||
requested: str | None,
|
||||
) -> str:
|
||||
if requested is not None:
|
||||
candidate = requested.strip()
|
||||
if not candidate:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign ID cannot be empty.",
|
||||
)
|
||||
return candidate
|
||||
stem = f"{source_external_id[:238]}-import"
|
||||
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
|
||||
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
|
||||
if not _campaign_external_id_exists(session, tenant_id, candidate):
|
||||
return candidate
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="No available Campaign import identifier could be generated.",
|
||||
)
|
||||
|
||||
|
||||
def _campaign_external_id_exists(
|
||||
session: Session, tenant_id: str, external_id: str
|
||||
) -> bool:
|
||||
return (
|
||||
session.query(Campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == tenant_id,
|
||||
Campaign.external_id == external_id,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _require_import_scope_permissions(
|
||||
principal: ApiPrincipal, inspection: CampaignImportInspection
|
||||
) -> None:
|
||||
selected = set(inspection.preview.get("selected_scopes") or [])
|
||||
if "recipients" in selected:
|
||||
_require_permission(principal, "campaigns:recipient:import")
|
||||
_require_permission(principal, "campaigns:recipient:write")
|
||||
|
||||
|
||||
def _module_version() -> str:
|
||||
try:
|
||||
return metadata.version("govoplan-campaign")
|
||||
except metadata.PackageNotFoundError:
|
||||
return "development"
|
||||
|
||||
|
||||
__all__ = [
|
||||
"export_campaign_package",
|
||||
"import_campaign_package",
|
||||
"preview_campaign_import",
|
||||
"router",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -337,7 +359,7 @@
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
"default": "warn"
|
||||
},
|
||||
"ambiguous_behavior": {
|
||||
"type": "string",
|
||||
@@ -349,6 +371,13 @@
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"reuse_policy": {
|
||||
"$ref": "#/$defs/attachment_reuse_policy",
|
||||
"description": "Controls whether resolving the same source file more than once is allowed, warned, reviewed, or blocked, with an optional same-recipient or same-message allowance."
|
||||
},
|
||||
"residual_files": {
|
||||
"$ref": "#/$defs/residual_file_disposition"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -433,7 +462,7 @@
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
"default": "block"
|
||||
},
|
||||
"missing_optional_attachment": {
|
||||
"type": "string",
|
||||
@@ -509,6 +538,15 @@
|
||||
"delivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel_policy": {
|
||||
"$ref": "#/$defs/delivery_channel_policy"
|
||||
},
|
||||
"postbox": {
|
||||
"$ref": "#/$defs/postbox_delivery"
|
||||
},
|
||||
"print": {
|
||||
"$ref": "#/$defs/print_delivery"
|
||||
},
|
||||
"rate_limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -626,6 +664,249 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"delivery_channel_policy": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mail",
|
||||
"postbox",
|
||||
"print",
|
||||
"mail_and_postbox",
|
||||
"mail_then_postbox",
|
||||
"postbox_then_mail",
|
||||
"mail_then_print",
|
||||
"postbox_then_print"
|
||||
],
|
||||
"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": {}
|
||||
},
|
||||
"print_target": {
|
||||
"type": "object",
|
||||
"required": ["channel", "target", "target_key"],
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"enum": ["postal", "internal_mail"]
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 4000
|
||||
},
|
||||
"target_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 500
|
||||
},
|
||||
"contact_point_id": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 36
|
||||
},
|
||||
"locale": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 35
|
||||
},
|
||||
"decision_provenance": {
|
||||
"type": "object",
|
||||
"default": {}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"print_delivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"template_id": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 36
|
||||
},
|
||||
"template_revision": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1
|
||||
},
|
||||
"usage": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 100,
|
||||
"default": "campaign_print"
|
||||
},
|
||||
"output_format": {
|
||||
"type": "string",
|
||||
"enum": ["html", "text"],
|
||||
"default": "html"
|
||||
},
|
||||
"profile_id": {
|
||||
"type": ["string", "null"],
|
||||
"maxLength": 120
|
||||
},
|
||||
"persist_to_files": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {}
|
||||
},
|
||||
"attachment_config": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -869,6 +1150,36 @@
|
||||
"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
|
||||
},
|
||||
"print_target": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/print_target" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -885,6 +1196,11 @@
|
||||
"additionalProperties": true,
|
||||
"default": {}
|
||||
},
|
||||
"distribution_source": {
|
||||
"type": "object",
|
||||
"description": "Immutable Distribution List recipient, route-decision, and source evidence captured for this Campaign version.",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"last_sent": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
@@ -971,7 +1287,8 @@
|
||||
"csv",
|
||||
"xlsx",
|
||||
"text",
|
||||
"addresses"
|
||||
"addresses",
|
||||
"distribution_list"
|
||||
]
|
||||
},
|
||||
"source_id": {
|
||||
@@ -1145,6 +1462,53 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"attachment_reuse_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "warn", "review", "block"],
|
||||
"default": "allow",
|
||||
"description": "Action when one resolved source file is used more than once outside the configured allowance. Review creates an explicit, reasoned review decision."
|
||||
},
|
||||
"allow_within": {
|
||||
"type": "string",
|
||||
"enum": ["none", "same_recipient", "same_message"],
|
||||
"default": "none",
|
||||
"description": "Optional exception that permits reuse confined to one recipient or one built message."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {"action": "allow", "allow_within": "none"}
|
||||
},
|
||||
"residual_file_disposition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["none", "report", "attach"],
|
||||
"default": "none",
|
||||
"description": "Keep normal warning policy, prepare a reviewed report message, or prepare a reviewed message with the residual files attached."
|
||||
},
|
||||
"recipient": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/recipient" },
|
||||
{ "type": "null" }
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"default": "Unassigned files in campaign {{local:campaign_name}}"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"default": "The campaign build found {{local:residual_file_count}} file(s) that were not assigned to a recipient.\n\n{{local:residual_file_list}}"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": { "mode": "none", "recipient": null }
|
||||
},
|
||||
"zip_config": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1261,6 +1625,44 @@
|
||||
],
|
||||
"default": "aes"
|
||||
},
|
||||
"password_delivery_channel": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"separate_mail",
|
||||
"sms",
|
||||
"letter",
|
||||
"phone",
|
||||
"in_person"
|
||||
],
|
||||
"default": "separate_mail"
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"legacy_zipcrypto_reason": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 1000
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged_by": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 255,
|
||||
"readOnly": true
|
||||
},
|
||||
"legacy_zipcrypto_acknowledged_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"maxLength": 80,
|
||||
"readOnly": true
|
||||
},
|
||||
"password_mode": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -61,6 +61,16 @@
|
||||
}
|
||||
],
|
||||
"fields": {
|
||||
"/attachments/reuse_policy/action": {
|
||||
"label": "Duplicate-file action",
|
||||
"control": "select",
|
||||
"description": "Allow and record, warn, require a reasoned review decision, or block repeated use of the same resolved file."
|
||||
},
|
||||
"/attachments/reuse_policy/allow_within": {
|
||||
"label": "Allowed reuse boundary",
|
||||
"control": "select",
|
||||
"description": "Optionally exempt reuse confined to one recipient or one built message."
|
||||
},
|
||||
"/attachments/global[]/message_filename_template": {
|
||||
"label": "Direct attachment filename",
|
||||
"control": "text",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchIndexChange,
|
||||
SearchResourceReference,
|
||||
SearchResourceType,
|
||||
)
|
||||
from govoplan_campaign.backend.capabilities import CampaignAccessService
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
|
||||
|
||||
PROVIDER_ID = "campaigns.campaigns"
|
||||
RESOURCE_TYPE = "campaign"
|
||||
READ_SCOPE = "campaigns:campaign:read"
|
||||
|
||||
|
||||
class CampaignSearchSource:
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="campaigns",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Campaigns",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
_assert_source(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
statement = select(Campaign).where(
|
||||
Campaign.tenant_id == request.tenant_id,
|
||||
Campaign.status != "deleted",
|
||||
)
|
||||
if request.cursor:
|
||||
statement = statement.where(Campaign.id > request.cursor)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
statement.order_by(Campaign.id).limit(request.limit + 1)
|
||||
)
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
shares = _shares_by_campaign(db, selected)
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(Campaign.updated_at)).where(
|
||||
Campaign.tenant_id == request.tenant_id,
|
||||
Campaign.status != "deleted",
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(
|
||||
_document(row, shares=shares.get(row.id, ()))
|
||||
for row in selected
|
||||
),
|
||||
next_cursor=selected[-1].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=(
|
||||
high_watermark.isoformat()
|
||||
if high_watermark is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
access = CampaignAccessService()
|
||||
user_id = str(getattr(principal.user, "id", "") or principal.membership_id or "")
|
||||
for request in requests:
|
||||
reference = request.reference
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "campaigns"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
decisions[reference.key] = access.can_read_campaign(
|
||||
db,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=reference.resource_id,
|
||||
user_id=user_id,
|
||||
group_ids=principal.group_ids,
|
||||
tenant_admin=principal.has("tenant:*"),
|
||||
)
|
||||
return decisions
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
if (
|
||||
event.module_id != "campaigns"
|
||||
or event.tenant is None
|
||||
or event.resource is None
|
||||
or event.resource.type != RESOURCE_TYPE
|
||||
or event.resource.id is None
|
||||
):
|
||||
return ()
|
||||
db = _session(session)
|
||||
row = db.get(Campaign, event.resource.id)
|
||||
deleted = row is None or row.tenant_id != event.tenant.id or row.status == "deleted"
|
||||
cursor = event.event_id
|
||||
document = None
|
||||
if not deleted:
|
||||
document = _document(
|
||||
row,
|
||||
shares=tuple(
|
||||
db.scalars(
|
||||
select(CampaignShare).where(
|
||||
CampaignShare.campaign_id == row.id,
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
),
|
||||
change_cursor=cursor,
|
||||
)
|
||||
reference = SearchResourceReference(
|
||||
tenant_id=event.tenant.id,
|
||||
module_id="campaigns",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=event.resource.id,
|
||||
)
|
||||
return (
|
||||
SearchIndexChange(
|
||||
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||
provider_id=PROVIDER_ID,
|
||||
kind="delete" if deleted else "upsert",
|
||||
reference=reference,
|
||||
source_revision=(
|
||||
document.source_revision if document is not None else cursor
|
||||
),
|
||||
cursor=cursor,
|
||||
document=document,
|
||||
occurred_at=event.occurred_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_campaign_search_source(_context: ModuleContext) -> CampaignSearchSource:
|
||||
return CampaignSearchSource()
|
||||
|
||||
|
||||
def _document(
|
||||
row: Campaign,
|
||||
*,
|
||||
shares: Sequence[CampaignShare],
|
||||
change_cursor: str | None = None,
|
||||
) -> SearchDocument:
|
||||
tokens = [f"scope:{READ_SCOPE}"]
|
||||
if row.owner_user_id:
|
||||
tokens.append(f"membership:{row.owner_user_id}")
|
||||
if row.owner_group_id:
|
||||
tokens.append(f"group:{row.owner_group_id}")
|
||||
for share in shares:
|
||||
prefix = "membership" if share.target_type == "user" else share.target_type
|
||||
if prefix in {"membership", "group"}:
|
||||
tokens.append(f"{prefix}:{share.target_id}")
|
||||
updated_at = row.updated_at or row.created_at
|
||||
return SearchDocument(
|
||||
tenant_id=row.tenant_id,
|
||||
module_id="campaigns",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=row.id,
|
||||
title=row.name,
|
||||
url=f"/campaigns/{quote(row.id, safe='')}",
|
||||
summary=row.description[:4000] if row.description else None,
|
||||
body=" ".join(
|
||||
value for value in (row.external_id, row.description) if value
|
||||
)[:200_000],
|
||||
keywords=(row.external_id[:200], row.status[:200]),
|
||||
visibility="restricted",
|
||||
acl_tokens=tuple(dict.fromkeys(tokens)),
|
||||
metadata={
|
||||
"external_id": row.external_id,
|
||||
"status": row.status,
|
||||
"current_version_id": row.current_version_id,
|
||||
},
|
||||
source_revision=f"{row.current_version_id or 'none'}:{updated_at.isoformat()}",
|
||||
change_cursor=change_cursor,
|
||||
source_updated_at=updated_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _shares_by_campaign(
|
||||
session: Session,
|
||||
rows: Sequence[Campaign],
|
||||
) -> dict[str, tuple[CampaignShare, ...]]:
|
||||
ids = [row.id for row in rows]
|
||||
grouped: dict[str, list[CampaignShare]] = {item: [] for item in ids}
|
||||
if not ids:
|
||||
return {}
|
||||
for share in session.scalars(
|
||||
select(CampaignShare).where(
|
||||
CampaignShare.campaign_id.in_(ids),
|
||||
CampaignShare.revoked_at.is_(None),
|
||||
)
|
||||
):
|
||||
grouped.setdefault(share.campaign_id, []).append(share)
|
||||
return {key: tuple(value) for key, value in grouped.items()}
|
||||
|
||||
|
||||
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Campaign search source.")
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Campaign search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignSearchSource",
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"create_campaign_search_source",
|
||||
]
|
||||
@@ -9,16 +9,25 @@ 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.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
assert_archive_encryption_allowed,
|
||||
)
|
||||
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 = "9"
|
||||
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", "8", SNAPSHOT_VERSION}
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
@@ -40,7 +49,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
|
||||
@@ -48,8 +61,12 @@ class ExecutionSnapshot(BaseModel):
|
||||
queueable_job_count: int = 0
|
||||
job_manifest_sha256: str | None = None
|
||||
effective_policy_sha256: str | None = None
|
||||
archive_encryption: dict[str, Any] | None = None
|
||||
smtp_transport_revision: str | None = None
|
||||
imap_transport_revision: str | None = None
|
||||
uses_mail: bool = True
|
||||
uses_postbox: bool = False
|
||||
uses_print: bool = False
|
||||
delivery: DeliveryConfig
|
||||
|
||||
|
||||
@@ -67,7 +84,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 +92,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 +118,63 @@ 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)
|
||||
if snapshot_version in {"6", "7"}:
|
||||
delivery_payload.pop("print", 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 +182,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 +202,54 @@ 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 []
|
||||
),
|
||||
}
|
||||
)
|
||||
if snapshot_version not in {"6", "7"}:
|
||||
payload["delivery_provenance_sha256"] = _sha256(
|
||||
getattr(job, "delivery_provenance", None) or {}
|
||||
)
|
||||
payload["resolved_print_output_sha256"] = _sha256(
|
||||
getattr(job, "resolved_print_output", 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 +258,71 @@ 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,
|
||||
archive_encryption: 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)
|
||||
uses_print = any(policy.uses_print 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,
|
||||
),
|
||||
archive_encryption=archive_encryption,
|
||||
smtp_transport_revision=smtp_transport_revision,
|
||||
imap_transport_revision=imap_transport_revision,
|
||||
uses_mail=uses_mail,
|
||||
uses_postbox=uses_postbox,
|
||||
uses_print=uses_print,
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
delivery=delivery,
|
||||
).model_dump(mode="json")
|
||||
@@ -207,24 +346,64 @@ 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."
|
||||
)
|
||||
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Execution snapshot Campaign no longer exists")
|
||||
try:
|
||||
current_archive_policy = assert_archive_encryption_allowed(
|
||||
session,
|
||||
campaign,
|
||||
raw_json,
|
||||
)
|
||||
except CampaignArchiveEncryptionError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
archive_snapshot = snapshot.archive_encryption
|
||||
configured_archives = (
|
||||
((raw_json.get("attachments") or {}).get("zip") or {}).get("archives")
|
||||
if isinstance(raw_json.get("attachments"), dict)
|
||||
else None
|
||||
)
|
||||
if configured_archives and not isinstance(archive_snapshot, dict):
|
||||
raise ExecutionSnapshotError(
|
||||
"Execution snapshot has no governed archive-encryption evidence; rebuild before delivery."
|
||||
)
|
||||
if isinstance(archive_snapshot, dict):
|
||||
frozen_policy = archive_snapshot.get("policy")
|
||||
frozen_hash = (
|
||||
frozen_policy.get("policy_hash")
|
||||
if isinstance(frozen_policy, dict)
|
||||
else None
|
||||
)
|
||||
if frozen_hash != current_archive_policy.policy_hash:
|
||||
raise ExecutionSnapshotError(
|
||||
"The effective archive-encryption policy changed after build. Revalidate and rebuild before delivery."
|
||||
)
|
||||
|
||||
if effect_job is not None:
|
||||
if effect_job.campaign_version_id != version.id:
|
||||
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 +425,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 +466,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 +496,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,17 +504,42 @@ 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 {},
|
||||
archive_encryption=(
|
||||
version.build_summary.get("archive_encryption")
|
||||
if isinstance(version.build_summary, dict)
|
||||
and isinstance(version.build_summary.get("archive_encryption"), dict)
|
||||
else None
|
||||
),
|
||||
)
|
||||
version.execution_snapshot = payload
|
||||
version.execution_snapshot_hash = digest
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Small, address-free delivery counters for an explicitly selected version."""
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
|
||||
from govoplan_campaign.backend.sending.jobs import _get_campaign_for_tenant, _get_version_for_campaign
|
||||
|
||||
|
||||
def campaign_delivery_progress(session: Session, *, tenant_id: str, campaign_id: str, version_id: str | None = None) -> dict[str, Any]:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_version_for_campaign(session, campaign, version_id=version_id)
|
||||
# Aggregate status columns only. Never load recipient/attachment JSON, EML,
|
||||
# issues, or attempts for a progress poll.
|
||||
accepted_attempt = session.query(SendAttempt.id).filter(
|
||||
SendAttempt.job_id == CampaignJob.id,
|
||||
SendAttempt.status.in_(("smtp_accepted", "smtp_accepted_with_refusals", "reconciled_smtp_accepted")),
|
||||
).exists()
|
||||
mail_attempted = session.query(SendAttempt.id).filter(SendAttempt.job_id == CampaignJob.id).exists()
|
||||
rows = session.query(
|
||||
CampaignJob.send_status, CampaignJob.queue_status,
|
||||
CampaignJob.imap_status, CampaignJob.delivery_channel_policy,
|
||||
CampaignJob.validation_status, accepted_attempt, mail_attempted, func.count(CampaignJob.id),
|
||||
).filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
).group_by(
|
||||
CampaignJob.send_status, CampaignJob.queue_status,
|
||||
CampaignJob.imap_status, CampaignJob.delivery_channel_policy,
|
||||
CampaignJob.validation_status, accepted_attempt, mail_attempted,
|
||||
).all()
|
||||
smtp = dict.fromkeys(("total", "processed", "accepted", "active", "pending", "failed", "outcome_unknown", "excluded", "paused", "cancelled"), 0)
|
||||
imap = dict.fromkeys(("total", "processed", "appended", "active", "pending", "failed", "outcome_unknown", "excluded"), 0)
|
||||
raw = {"send": Counter(), "queue": Counter(), "imap": Counter()}
|
||||
total = 0
|
||||
for send, queue, append, channel, validation, mail_accepted, attempted_mail, count in rows:
|
||||
total += count
|
||||
raw["send"][send] += count
|
||||
raw["queue"][queue] += count
|
||||
raw["imap"][append] += count
|
||||
# Multi-channel jobs retain their channel policy. Pure Postbox/Print
|
||||
# jobs and policy-excluded mail are not SMTP work.
|
||||
fallback_completed = channel in {"mail_then_postbox", "postbox_then_mail", "mail_then_print"} and send in {"delivered", "sent", "postbox_accepted", "print_accepted"}
|
||||
if channel not in {"mail", "mail_and_postbox", "mail_then_postbox", "postbox_then_mail", "mail_then_print"} or send == "skipped" or validation in {"excluded", "inactive"} or (fallback_completed and not attempted_mail and not mail_accepted):
|
||||
smtp["excluded"] += count
|
||||
else:
|
||||
smtp["total"] += count
|
||||
if mail_accepted or send == "smtp_accepted" or (channel == "mail" and send in {"sent", "delivered"}):
|
||||
bucket = "accepted"
|
||||
elif send in {"claimed", "sending"}:
|
||||
bucket = "active"
|
||||
elif send == "outcome_unknown" or (channel != "mail" and send in {"sent", "delivered"}):
|
||||
bucket = "outcome_unknown"
|
||||
elif send in {"failed_temporary", "failed_permanent", "partially_accepted", "postbox_accepted", "print_accepted"}:
|
||||
bucket = "failed"
|
||||
elif send == "cancelled" or queue == "cancelled":
|
||||
bucket = "cancelled"
|
||||
elif queue == "paused":
|
||||
bucket = "paused"
|
||||
else:
|
||||
bucket = "pending"
|
||||
smtp[bucket] += count
|
||||
if append in {"not_requested", "skipped"}:
|
||||
imap["excluded"] += count
|
||||
else:
|
||||
imap["total"] += count
|
||||
bucket = {"appended": "appended", "appending": "active", "failed": "failed", "outcome_unknown": "outcome_unknown"}.get(append, "pending")
|
||||
imap[bucket] += count
|
||||
smtp["processed"] = sum(smtp[key] for key in ("accepted", "failed", "outcome_unknown", "cancelled"))
|
||||
imap["processed"] = sum(imap[key] for key in ("appended", "failed", "outcome_unknown"))
|
||||
return {
|
||||
"campaign_id": campaign.id, "version_id": version.id,
|
||||
"total_jobs": total, "generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"smtp": smtp, "imap": imap,
|
||||
"status_counts": {kind: dict(counts) for kind, counts in raw.items()},
|
||||
"workflow_state": version.workflow_state,
|
||||
"delivery_mode": version.delivery_mode,
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Explicit, fenced recovery of claims left by a proven stopped runtime."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease, RuntimeNode, acquire_lease, release_lease, process_runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.recovery import (
|
||||
RecoveryOperation, RecoveryStatus, record_recovery_checkpoint,
|
||||
transition_recovery_operation, verify_recovery_evidence_chain,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt, ImapAppendAttempt
|
||||
from govoplan_campaign.backend.sending.jobs import QueueingError, _update_campaign_after_job
|
||||
|
||||
|
||||
class RecoveryStateConflict(QueueingError):
|
||||
pass
|
||||
|
||||
|
||||
def _utc(value: datetime) -> datetime:
|
||||
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _key(job: CampaignJob, channel: str) -> str:
|
||||
return f"campaign:{'delivery' if channel == 'smtp' else 'imap'}:{job.tenant_id}:{job.id}"
|
||||
|
||||
|
||||
def _claim_metadata(job: CampaignJob, channel: str, lease: DistributedLease | None, node: RuntimeNode | None) -> dict[str, Any]:
|
||||
state = job.send_status if channel == "smtp" else job.imap_status
|
||||
claim = job.claim_token if channel == "smtp" else job.imap_claim_token
|
||||
active = state in ({"claimed", "sending"} if channel == "smtp" else {"appending"})
|
||||
reason = "not_active"
|
||||
if active:
|
||||
if lease is None or not claim or not lease.holder_node_id or node is None:
|
||||
reason = "owner_not_confirmed_stopped"
|
||||
elif _utc(lease.expires_at) > datetime.now(timezone.utc):
|
||||
reason = "live_claim"
|
||||
elif node.incarnation == lease.holder_incarnation and node.state != "stopped":
|
||||
# Heartbeat age alone is NOT proof that a slow worker is dead.
|
||||
reason = "owner_not_confirmed_stopped"
|
||||
else:
|
||||
reason = "recoverable"
|
||||
revision = hashlib.sha256(json.dumps({
|
||||
"job": job.id, "channel": channel, "state": state, "claim": claim,
|
||||
"attempts": job.attempt_count,
|
||||
"lease": [lease.id, lease.fencing_token, str(lease.expires_at), lease.holder_node_id, lease.holder_incarnation] if lease else None,
|
||||
"owner": [node.incarnation, node.state] if node else None,
|
||||
}, sort_keys=True).encode()).hexdigest()
|
||||
return {"eligible": reason == "recoverable", "revision": revision, "reason": reason}
|
||||
|
||||
|
||||
def job_recovery_metadata(session: Session, jobs: list[CampaignJob]) -> dict[str, dict[str, Any]]:
|
||||
"""Two bounded metadata reads per loaded page, not per recipient."""
|
||||
if not jobs:
|
||||
return {}
|
||||
installation_id = process_runtime_identity().installation_id
|
||||
keys = [_key(job, channel) for job in jobs for channel in ("smtp", "imap")]
|
||||
leases = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == installation_id,
|
||||
DistributedLease.resource_key.in_(keys),
|
||||
).all()
|
||||
by_key = {lease.resource_key: lease for lease in leases}
|
||||
owner_ids = {lease.holder_node_id for lease in leases if lease.holder_node_id}
|
||||
nodes = session.query(RuntimeNode).filter(
|
||||
RuntimeNode.installation_id == installation_id,
|
||||
RuntimeNode.node_id.in_(owner_ids),
|
||||
).all() if owner_ids else []
|
||||
by_owner = {node.node_id: node for node in nodes}
|
||||
result = {}
|
||||
for job in jobs:
|
||||
channels = {}
|
||||
for channel in ("smtp", "imap"):
|
||||
lease = by_key.get(_key(job, channel))
|
||||
channels[channel] = _claim_metadata(job, channel, lease, by_owner.get(lease.holder_node_id) if lease else None)
|
||||
result[job.id] = channels
|
||||
return result
|
||||
|
||||
|
||||
def recover_stale_delivery_claim(
|
||||
session: Session, *, tenant_id: str, campaign_id: str, job_id: str,
|
||||
channel: str, expected_revision: str, note: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Freeze an abandoned effect as unknown; NEVER infer that it was not sent."""
|
||||
if channel not in {"smtp", "imap"} or not note.strip():
|
||||
raise QueueingError("Claim recovery requires a channel and an evidence note.")
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if job is None or job.tenant_id != tenant_id or job.campaign_id != campaign_id:
|
||||
raise QueueingError("Campaign job not found or not accessible")
|
||||
identity = process_runtime_identity()
|
||||
resource_key = _key(job, channel)
|
||||
# Same lock order as runtime authority: lease, operation, domain row.
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == identity.installation_id,
|
||||
DistributedLease.resource_key == resource_key,
|
||||
).with_for_update().populate_existing().one_or_none()
|
||||
node = session.query(RuntimeNode).filter(
|
||||
RuntimeNode.installation_id == identity.installation_id,
|
||||
RuntimeNode.node_id == lease.holder_node_id,
|
||||
).with_for_update().populate_existing().one_or_none() if lease and lease.holder_node_id else None
|
||||
session.refresh(job)
|
||||
metadata = _claim_metadata(job, channel, lease, node)
|
||||
if metadata["revision"] != expected_revision:
|
||||
raise RecoveryStateConflict("Delivery claim changed; reload its current evidence before recovery.")
|
||||
if not metadata["eligible"]:
|
||||
raise RecoveryStateConflict("Recovery is blocked until the lease expires and its owning runtime is confirmed stopped or replaced.")
|
||||
claim_token = job.claim_token if channel == "smtp" else job.imap_claim_token
|
||||
state = job.send_status if channel == "smtp" else job.imap_status
|
||||
assert claim_token is not None
|
||||
claim_sha = hashlib.sha256(claim_token.encode()).hexdigest()
|
||||
operation_key = f"campaign-{'delivery' if channel == 'smtp' else 'imap'}:{job.id}:{claim_sha[:32]}"
|
||||
operation = session.query(RecoveryOperation).filter(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.module_id == "campaigns",
|
||||
RecoveryOperation.idempotency_key == operation_key,
|
||||
RecoveryOperation.lease_resource_key == resource_key,
|
||||
).with_for_update().one_or_none()
|
||||
if operation is None or operation.status not in {"running", "outcome_unknown"}:
|
||||
raise RecoveryStateConflict("The original durable delivery evidence cannot be recovered safely.")
|
||||
authority = acquire_lease(
|
||||
session, installation_id=identity.installation_id, resource_key=resource_key,
|
||||
holder_node_id=identity.node_id, holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=300, metadata={"module_id": "campaigns", "recovery_operation_id": operation.id},
|
||||
)
|
||||
if authority is None:
|
||||
raise RecoveryStateConflict("Another runtime acquired this delivery claim.")
|
||||
operation.holder_node_id = authority.holder_node_id
|
||||
operation.holder_incarnation = authority.holder_incarnation
|
||||
operation.fencing_token = authority.fencing_token
|
||||
session.add(operation)
|
||||
session.flush()
|
||||
evidence = {"job_id": job.id, "channel": channel, "previous_state": state, "evidence_note_sha256": hashlib.sha256(note.strip().encode()).hexdigest(), "claim_sha256": claim_sha}
|
||||
record_recovery_checkpoint(session, operation, kind="campaign-claim-recovery", summary="An operator fenced a claim owned by a stopped runtime", evidence=evidence, lease_claim=authority)
|
||||
if operation.status != "outcome_unknown":
|
||||
transition_recovery_operation(session, operation, status=RecoveryStatus.OUTCOME_UNKNOWN, kind="campaign-claim-outcome-unknown", summary="The abandoned provider effect requires explicit reconciliation", evidence=evidence, failure_summary="The original runtime stopped before recording a final provider result", lease_claim=authority)
|
||||
if not verify_recovery_evidence_chain(session, operation.id):
|
||||
raise RecoveryStateConflict("Durable delivery evidence verification failed.")
|
||||
state_column = CampaignJob.send_status if channel == "smtp" else CampaignJob.imap_status
|
||||
claim_column = CampaignJob.claim_token if channel == "smtp" else CampaignJob.imap_claim_token
|
||||
changes = {state_column: "outcome_unknown", claim_column: None, CampaignJob.last_error: note.strip()}
|
||||
if channel == "smtp":
|
||||
changes.update({CampaignJob.queue_status: "draft", CampaignJob.outcome_unknown_at: datetime.now(timezone.utc)})
|
||||
else:
|
||||
changes[CampaignJob.imap_claimed_at] = None
|
||||
changed = session.query(CampaignJob).filter(
|
||||
CampaignJob.id == job.id, state_column == state, claim_column == claim_token,
|
||||
).update(changes, synchronize_session=False)
|
||||
if changed != 1:
|
||||
raise RecoveryStateConflict("The delivery claim changed before recovery could be recorded.")
|
||||
attempt_model = SendAttempt if channel == "smtp" else ImapAppendAttempt
|
||||
attempt = session.query(attempt_model).filter(attempt_model.job_id == job.id, attempt_model.claim_token == claim_token).order_by(attempt_model.attempt_number.desc()).first()
|
||||
if attempt is not None:
|
||||
attempt.status = "outcome_unknown"
|
||||
attempt.error_message = note.strip()
|
||||
if channel == "smtp":
|
||||
attempt.finished_at = datetime.now(timezone.utc)
|
||||
session.add(attempt)
|
||||
release_lease(session, authority)
|
||||
session.expire(job)
|
||||
_update_campaign_after_job(session, campaign_id, job.campaign_version_id)
|
||||
session.flush()
|
||||
return {"campaign_id": campaign_id, "version_id": job.campaign_version_id, "job_id": job.id, "channel": channel, "send_status": job.send_status, "imap_status": job.imap_status, "note": note.strip(), "reconciliation_required": True}
|
||||
|
||||
|
||||
def reconcile_campaign_delivery_operation(
|
||||
session: Session, *, job: CampaignJob, channel: str, claim_token: str | None,
|
||||
effect_occurred: bool, note: str,
|
||||
) -> None:
|
||||
"""Resolve only the original Campaign ledger in the caller's audit transaction.
|
||||
|
||||
Older jobs without a claim-bound operation remain supported. Mail's nested
|
||||
provider-effect ledgers are separate evidence and are never rewritten here.
|
||||
"""
|
||||
# A compound external-channel operation may include Postbox/Print effects.
|
||||
# One SMTP decision cannot verify or negate that entire operation.
|
||||
if not claim_token or (channel == "smtp" and getattr(job, "delivery_channel_policy", "mail") != "mail"):
|
||||
return
|
||||
identity = process_runtime_identity()
|
||||
key = _key(job, channel)
|
||||
claim_sha = hashlib.sha256(claim_token.encode()).hexdigest()
|
||||
operation_key = f"campaign-{'delivery' if channel == 'smtp' else 'imap'}:{job.id}:{claim_sha[:32]}"
|
||||
# Acquire the same lock order as effect execution and claim recovery.
|
||||
lease = session.query(DistributedLease).filter(
|
||||
DistributedLease.installation_id == identity.installation_id,
|
||||
DistributedLease.resource_key == key,
|
||||
).with_for_update().populate_existing().one_or_none()
|
||||
operation = session.query(RecoveryOperation).filter(
|
||||
RecoveryOperation.installation_id == identity.installation_id,
|
||||
RecoveryOperation.module_id == "campaigns",
|
||||
RecoveryOperation.idempotency_key == operation_key,
|
||||
RecoveryOperation.lease_resource_key == key,
|
||||
RecoveryOperation.resource_type == "campaign_job",
|
||||
RecoveryOperation.resource_id == job.id,
|
||||
).with_for_update().populate_existing().one_or_none()
|
||||
if operation is None:
|
||||
return
|
||||
if operation.status in {"succeeded", "recovered"}:
|
||||
if (operation.status == "succeeded") != effect_occurred:
|
||||
raise RecoveryStateConflict("The original durable operation already records a different verified outcome.")
|
||||
return
|
||||
if operation.status != "outcome_unknown" or lease is None:
|
||||
raise RecoveryStateConflict("The original durable operation requires guarded claim recovery before reconciliation.")
|
||||
# Even the same API process must not borrow another active operation's
|
||||
# lease merely because its runtime identity happens to match.
|
||||
if lease.holder_node_id is not None:
|
||||
raise RecoveryStateConflict("The original operation still has a runtime owner; recover its stopped claim before reconciliation.")
|
||||
authority = acquire_lease(session, installation_id=identity.installation_id, resource_key=key,
|
||||
holder_node_id=identity.node_id, holder_incarnation=identity.incarnation,
|
||||
ttl_seconds=300, metadata={"module_id": "campaigns", "recovery_operation_id": operation.id})
|
||||
if authority is None:
|
||||
raise RecoveryStateConflict("Another runtime owns the original delivery operation.")
|
||||
operation.holder_node_id = authority.holder_node_id
|
||||
operation.holder_incarnation = authority.holder_incarnation
|
||||
operation.fencing_token = authority.fencing_token
|
||||
session.add(operation)
|
||||
session.flush()
|
||||
evidence = {"verified": True, "checks": {"operator_provider_evidence_recorded": True, "matching_claim_attempt": True},
|
||||
"job_id": job.id, "channel": channel, "effect_occurred": effect_occurred,
|
||||
"claim_sha256": claim_sha, "evidence_note_sha256": hashlib.sha256(note.strip().encode()).hexdigest()}
|
||||
record_recovery_checkpoint(session, operation, kind="campaign-reconciliation-fence", summary="An operator acquired authority for the original Campaign attempt", evidence=evidence, lease_claim=authority)
|
||||
if effect_occurred:
|
||||
transition_recovery_operation(session, operation, status=RecoveryStatus.SUCCEEDED,
|
||||
kind="campaign-reconciled-provider-acceptance", summary="Operator evidence confirms the Campaign effect was accepted", evidence=evidence, lease_claim=authority)
|
||||
else:
|
||||
for next_status in (RecoveryStatus.RECOVERY_REQUIRED, RecoveryStatus.RECOVERING, RecoveryStatus.RECOVERED):
|
||||
transition_recovery_operation(session, operation, status=next_status,
|
||||
kind=f"campaign-reconciled-absence-{next_status.value}", summary="Operator evidence confirms the Campaign effect did not occur",
|
||||
evidence=evidence, failure_summary="The original external effect was verified absent" if next_status == RecoveryStatus.RECOVERY_REQUIRED else None, lease_claim=authority)
|
||||
if not verify_recovery_evidence_chain(session, operation.id):
|
||||
raise RecoveryStateConflict("Original Campaign recovery evidence verification failed.")
|
||||
release_lease(session, authority)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"""Deterministic collision naming shared by message and ZIP construction."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class FilenameAllocator:
|
||||
"""Append-only names with the original first-free, casefolded suffix rule."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.used: set[str] = set()
|
||||
self._next: dict[str, int] = {}
|
||||
|
||||
def allocate(self, filename: str) -> str:
|
||||
key = filename.casefold()
|
||||
candidate = filename
|
||||
path = Path(filename)
|
||||
counter = self._next.get(key, 2)
|
||||
while candidate.casefold() in self.used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
self.used.add(candidate.casefold())
|
||||
self._next[key] = counter
|
||||
return candidate
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
"""Shared, side-effect-free eligibility for individual and grouped review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
def review_decision_metadata(job: Any) -> dict[str, Any]:
|
||||
issues = [item for item in (job.issues_snapshot or []) if isinstance(item, dict)]
|
||||
eligible = (
|
||||
getattr(job, "build_status", "built") == "built"
|
||||
and job.validation_status == "needs_review"
|
||||
and not any(str(item.get("behavior") or "").lower() == "block" for item in issues)
|
||||
)
|
||||
reviewable = [item for item in issues if str(item.get("behavior") or "").lower() == "ask"]
|
||||
evidence = reviewable or issues
|
||||
categories = sorted({
|
||||
(str(item.get("code") or ""), str(item.get("behavior") or ""), str(item.get("source") or ""))
|
||||
for item in evidence
|
||||
})
|
||||
return {
|
||||
"eligible": eligible,
|
||||
"category_key": hashlib.sha256(json.dumps(categories, separators=(",", ":")).encode()).hexdigest() if eligible else "",
|
||||
"reason_required": eligible and any(str(item.get("source") or "").startswith("attachments") for item in reviewable),
|
||||
"issue_codes": sorted({str(item.get("code")) for item in evidence if item.get("code")}),
|
||||
}
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import binascii
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
from importlib import metadata
|
||||
import secrets
|
||||
import stat
|
||||
import struct
|
||||
@@ -10,6 +12,8 @@ from pathlib import Path
|
||||
from typing import Iterable
|
||||
import zlib
|
||||
|
||||
from govoplan_campaign.backend.services.filenames import FilenameAllocator
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -22,18 +26,11 @@ ZIP_METHOD_STANDARD = "zip_standard"
|
||||
|
||||
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
|
||||
members: list[ArchiveMember] = []
|
||||
used_names: set[str] = set()
|
||||
names = FilenameAllocator()
|
||||
for item in files:
|
||||
path, requested_name = item if isinstance(item, tuple) else (item, item.name)
|
||||
requested = Path(requested_name).name or path.name
|
||||
stem = Path(requested).stem
|
||||
suffix = Path(requested).suffix
|
||||
candidate = requested
|
||||
counter = 2
|
||||
while candidate.casefold() in used_names:
|
||||
candidate = f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
used_names.add(candidate.casefold())
|
||||
candidate = names.allocate(requested)
|
||||
members.append((path, candidate))
|
||||
return members
|
||||
|
||||
@@ -49,6 +46,8 @@ def create_zip_archive(
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
members = _normalized_members(files)
|
||||
if password:
|
||||
if method not in {ZIP_METHOD_AES, ZIP_METHOD_STANDARD}:
|
||||
raise ValueError(f"Unsupported password-encryption method: {method}")
|
||||
if method == ZIP_METHOD_STANDARD:
|
||||
_create_zipcrypto_archive(output_path, members, password)
|
||||
return output_path
|
||||
@@ -61,6 +60,51 @@ def create_zip_archive(
|
||||
return output_path
|
||||
|
||||
|
||||
def zip_archive_evidence(
|
||||
output_path: Path,
|
||||
members: Iterable[Path | ArchiveMember],
|
||||
*,
|
||||
password_protected: bool,
|
||||
method: str,
|
||||
) -> dict[str, object]:
|
||||
"""Return password-free, content-addressed evidence for one built archive."""
|
||||
|
||||
normalized = _normalized_members(members)
|
||||
archive_bytes = output_path.read_bytes()
|
||||
if password_protected and method == ZIP_METHOD_AES:
|
||||
try:
|
||||
implementation_version = metadata.version("pyzipper")
|
||||
except metadata.PackageNotFoundError: # pragma: no cover - guarded by writer
|
||||
implementation_version = "unknown"
|
||||
implementation = "pyzipper"
|
||||
archive_format = "WinZip AES"
|
||||
elif password_protected and method == ZIP_METHOD_STANDARD:
|
||||
implementation = "govoplan-campaign.zipcrypto"
|
||||
implementation_version = "1"
|
||||
archive_format = "Legacy ZipCrypto"
|
||||
else:
|
||||
implementation = "python.zipfile"
|
||||
implementation_version = "stdlib"
|
||||
archive_format = "ZIP (unencrypted)"
|
||||
return {
|
||||
"format": archive_format,
|
||||
"method": method if password_protected else "none",
|
||||
"password_protected": password_protected,
|
||||
"implementation": implementation,
|
||||
"implementation_version": implementation_version,
|
||||
"archive_sha256": hashlib.sha256(archive_bytes).hexdigest(),
|
||||
"archive_size_bytes": len(archive_bytes),
|
||||
"members": [
|
||||
{
|
||||
"name": archive_name,
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
||||
"size_bytes": path.stat().st_size,
|
||||
}
|
||||
for path, archive_name in normalized
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str, method: str = ZIP_METHOD_AES) -> Path:
|
||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user