Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.
|
||||
|
||||
@@ -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.
|
||||
@@ -62,14 +62,39 @@ Before the first live send for a sender domain or mail-server profile:
|
||||
6. If a synchronous request is used, keep Review and send open: it polls the
|
||||
durable counters while the request runs. A rejection occurs before SMTP and
|
||||
directs oversized runs to workers.
|
||||
7. Review the SMTP batch line. `ready` means DNS/connectivity/TLS/auth preflight
|
||||
succeeded. Connection and reconnect counts explain reuse. `paused` means a
|
||||
systemic transport failure stopped the remaining jobs before their SMTP
|
||||
effect; test/correct the Mail profile and explicitly resume the queue.
|
||||
|
||||
## Outcome Handling
|
||||
|
||||
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.
|
||||
@@ -134,6 +159,35 @@ 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
|
||||
|
||||
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||
|
||||
+268
-14
@@ -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,12 @@ 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.
|
||||
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,
|
||||
@@ -135,9 +210,31 @@ 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.
|
||||
|
||||
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 +250,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
|
||||
@@ -180,6 +295,8 @@ 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;
|
||||
@@ -215,6 +332,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 +385,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.
|
||||
@@ -356,6 +501,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 +531,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 +542,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 +639,35 @@ purpose, lawful basis, minimization, export control, and retention before the
|
||||
campaign starts; do not use Campaign as a substitute consent or address-master
|
||||
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 +680,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 +722,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.
|
||||
|
||||
|
||||
@@ -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.18",
|
||||
"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.18",
|
||||
"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.24"
|
||||
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.28",
|
||||
"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,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.policy import (
|
||||
CampaignArchiveEncryptionDecision,
|
||||
CampaignArchiveEncryptionRequest,
|
||||
campaign_archive_encryption_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_campaign.backend.runtime import get_registry
|
||||
|
||||
|
||||
LEGACY_ZIPCRYPTO_SCOPE = "campaigns:archive:use_legacy_zipcrypto"
|
||||
LEGACY_ZIPCRYPTO_LABEL = "Legacy ZipCrypto — Windows-compatible, weak encryption"
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EffectiveArchiveEncryptionPolicy:
|
||||
available: bool
|
||||
allowed_password_encryption_methods: frozenset[str]
|
||||
allowed_password_delivery_channels: frozenset[str]
|
||||
policy_hash: str
|
||||
source_path: tuple[Mapping[str, Any], ...]
|
||||
reason: str
|
||||
diagnostics: tuple[Mapping[str, Any], ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"available": self.available,
|
||||
"allowed_password_encryption_methods": sorted(
|
||||
self.allowed_password_encryption_methods
|
||||
),
|
||||
"allowed_password_delivery_channels": sorted(
|
||||
self.allowed_password_delivery_channels
|
||||
),
|
||||
"policy_hash": self.policy_hash,
|
||||
"source_path": [dict(item) for item in self.source_path],
|
||||
"reason": self.reason,
|
||||
"diagnostics": [dict(item) for item in self.diagnostics],
|
||||
"legacy_label": LEGACY_ZIPCRYPTO_LABEL,
|
||||
}
|
||||
|
||||
|
||||
def effective_archive_encryption_policy(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
provider = campaign_archive_encryption_policy(get_registry())
|
||||
if provider is None:
|
||||
payload = {
|
||||
"available": False,
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"allowed_password_delivery_channels": [
|
||||
"in_person",
|
||||
"letter",
|
||||
"phone",
|
||||
"separate_mail",
|
||||
"sms",
|
||||
],
|
||||
"source_path": [
|
||||
{
|
||||
"scope_type": "system",
|
||||
"scope_id": None,
|
||||
"path": "system",
|
||||
"label": "Secure local fallback",
|
||||
"applied_fields": ["allowed_password_encryption_methods"],
|
||||
"policy": {
|
||||
"allowed_password_encryption_methods": ["aes"],
|
||||
"policy_provider": "unavailable",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=False,
|
||||
allowed_password_encryption_methods=frozenset({"aes"}),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
),
|
||||
policy_hash=_hash(payload),
|
||||
source_path=tuple(payload["source_path"]),
|
||||
reason=(
|
||||
"Policy is unavailable. AES remains available through the secure "
|
||||
"local baseline; legacy ZipCrypto fails closed."
|
||||
),
|
||||
)
|
||||
owner_type: str | None = None
|
||||
owner_id: str | None = None
|
||||
if campaign.owner_group_id:
|
||||
owner_type, owner_id = "group", campaign.owner_group_id
|
||||
elif campaign.owner_user_id:
|
||||
owner_type, owner_id = "user", campaign.owner_user_id
|
||||
decision: CampaignArchiveEncryptionDecision = (
|
||||
provider.resolve_campaign_archive_encryption(
|
||||
session,
|
||||
request=CampaignArchiveEncryptionRequest(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
owner_type=owner_type, # type: ignore[arg-type]
|
||||
owner_id=owner_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
return EffectiveArchiveEncryptionPolicy(
|
||||
available=True,
|
||||
allowed_password_encryption_methods=frozenset(
|
||||
decision.allowed_password_encryption_methods
|
||||
),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
decision.allowed_password_delivery_channels
|
||||
),
|
||||
policy_hash=decision.policy_hash,
|
||||
source_path=tuple(step.to_dict() for step in decision.source_path),
|
||||
reason=decision.reason or "Effective archive-encryption policy resolved.",
|
||||
diagnostics=decision.diagnostics,
|
||||
)
|
||||
|
||||
|
||||
def assert_archive_encryption_allowed(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
raw_json: Mapping[str, Any],
|
||||
*,
|
||||
principal: ApiPrincipal | None = None,
|
||||
) -> EffectiveArchiveEncryptionPolicy:
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
for archive in _archive_configs(raw_json):
|
||||
method = str(archive.get("method") or "aes")
|
||||
if method not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{_method_label(method)} is blocked. {policy.reason}"
|
||||
)
|
||||
if method == "zip_standard":
|
||||
if not policy.available:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto cannot be used while Policy is unavailable."
|
||||
)
|
||||
if principal is not None and not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged"):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires explicit acknowledgement."
|
||||
)
|
||||
if len(str(archive.get("legacy_zipcrypto_reason") or "").strip()) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires a reason of at least 10 characters."
|
||||
)
|
||||
if not archive.get("legacy_zipcrypto_acknowledged_by") or not archive.get(
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
"Legacy ZipCrypto acknowledgement has no server-recorded actor or time. Save the campaign again."
|
||||
)
|
||||
if archive.get("password_enabled"):
|
||||
# Existing campaign revisions predate the explicit field. Their
|
||||
# model default is the separate-mail channel; apply the same
|
||||
# normalization before policy enforcement so saved revisions do
|
||||
# not become unusable merely because the field was omitted.
|
||||
channel = str(
|
||||
archive.get("password_delivery_channel") or "separate_mail"
|
||||
)
|
||||
if channel not in policy.allowed_password_delivery_channels:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Password-delivery channel {channel!r} is blocked by the effective policy."
|
||||
)
|
||||
return policy
|
||||
|
||||
|
||||
def stamp_legacy_zipcrypto_acknowledgements(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
current_raw_json: Mapping[str, Any],
|
||||
candidate_raw_json: dict[str, Any] | None,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]:
|
||||
if candidate_raw_json is None:
|
||||
return None, []
|
||||
candidate = copy.deepcopy(candidate_raw_json)
|
||||
current_by_id = {
|
||||
str(item.get("id") or index): item
|
||||
for index, item in enumerate(_archive_configs(current_raw_json))
|
||||
}
|
||||
acknowledgements: list[dict[str, Any]] = []
|
||||
policy = effective_archive_encryption_policy(session, campaign)
|
||||
archives = _archive_configs(candidate)
|
||||
for index, archive in enumerate(archives):
|
||||
if str(archive.get("method") or "aes") != "zip_standard":
|
||||
archive.pop("legacy_zipcrypto_acknowledged_by", None)
|
||||
archive.pop("legacy_zipcrypto_acknowledged_at", None)
|
||||
continue
|
||||
if not has_scope(principal, LEGACY_ZIPCRYPTO_SCOPE):
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"Missing scope: {LEGACY_ZIPCRYPTO_SCOPE}"
|
||||
)
|
||||
if not policy.available or "zip_standard" not in policy.allowed_password_encryption_methods:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} is blocked. {policy.reason}"
|
||||
)
|
||||
reason = str(archive.get("legacy_zipcrypto_reason") or "").strip()
|
||||
if not archive.get("legacy_zipcrypto_acknowledged") or len(reason) < 10:
|
||||
raise CampaignArchiveEncryptionError(
|
||||
f"{LEGACY_ZIPCRYPTO_LABEL} requires acknowledgement and a reason of at least 10 characters."
|
||||
)
|
||||
key = str(archive.get("id") or index)
|
||||
previous = current_by_id.get(key, {})
|
||||
unchanged = (
|
||||
previous.get("method") == "zip_standard"
|
||||
and previous.get("legacy_zipcrypto_acknowledged") is True
|
||||
and str(previous.get("legacy_zipcrypto_reason") or "").strip() == reason
|
||||
and previous.get("legacy_zipcrypto_acknowledged_by")
|
||||
and previous.get("legacy_zipcrypto_acknowledged_at")
|
||||
)
|
||||
if unchanged:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_by"
|
||||
]
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = previous[
|
||||
"legacy_zipcrypto_acknowledged_at"
|
||||
]
|
||||
else:
|
||||
archive["legacy_zipcrypto_acknowledged_by"] = principal.user.id
|
||||
archive["legacy_zipcrypto_acknowledged_at"] = datetime.now(UTC).isoformat()
|
||||
acknowledgements.append(
|
||||
{
|
||||
"archive_id": key,
|
||||
"reason": reason,
|
||||
"policy_hash": policy.policy_hash,
|
||||
}
|
||||
)
|
||||
return candidate, acknowledgements
|
||||
|
||||
|
||||
def has_password_archives(raw_json: Mapping[str, Any]) -> bool:
|
||||
return any(bool(item.get("password_enabled")) for item in _archive_configs(raw_json))
|
||||
|
||||
|
||||
def _archive_configs(raw_json: Mapping[str, Any]) -> list[dict[str, Any]]:
|
||||
attachments = raw_json.get("attachments")
|
||||
zip_config = attachments.get("zip") if isinstance(attachments, Mapping) else None
|
||||
archives = zip_config.get("archives") if isinstance(zip_config, Mapping) else None
|
||||
if isinstance(archives, list):
|
||||
return [item for item in archives if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _method_label(method: str) -> str:
|
||||
return LEGACY_ZIPCRYPTO_LABEL if method == "zip_standard" else method.upper()
|
||||
|
||||
|
||||
def _hash(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CampaignArchiveEncryptionError",
|
||||
"EffectiveArchiveEncryptionPolicy",
|
||||
"LEGACY_ZIPCRYPTO_LABEL",
|
||||
"LEGACY_ZIPCRYPTO_SCOPE",
|
||||
"assert_archive_encryption_allowed",
|
||||
"effective_archive_encryption_policy",
|
||||
"has_password_archives",
|
||||
"stamp_legacy_zipcrypto_acknowledgements",
|
||||
]
|
||||
@@ -0,0 +1,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,48 @@ 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],
|
||||
)
|
||||
legacy_drop_normalized = configured == Behavior.DROP
|
||||
if legacy_drop_normalized:
|
||||
configured = Behavior.BLOCK if config.required else Behavior.ASK
|
||||
return AttachmentPolicyDecision(
|
||||
requirement_policy=requirement_policy,
|
||||
campaign_policy=campaign_config.attachments.missing_behavior,
|
||||
rule_policy=config.missing_behavior,
|
||||
effective_behavior=configured,
|
||||
legacy_drop_normalized=legacy_drop_normalized,
|
||||
)
|
||||
|
||||
|
||||
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 +404,19 @@ 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.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 +430,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
|
||||
)
|
||||
# Recipient exclusion must be an explicit reviewed action, not an implicit
|
||||
# consequence of a legacy attachment policy value.
|
||||
return Behavior.ASK if configured == Behavior.DROP else configured
|
||||
|
||||
|
||||
def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssue:
|
||||
@@ -395,6 +451,7 @@ def _issue_for_missing_attachment_coverage(behavior: Behavior) -> AttachmentIssu
|
||||
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 +479,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 +499,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 +568,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 +615,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."
|
||||
)
|
||||
@@ -4,8 +4,18 @@ import copy
|
||||
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,18 +24,29 @@ 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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -85,10 +106,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 +128,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 +160,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 +194,48 @@ 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 _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 +258,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 +360,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 +373,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 +384,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 +401,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 +464,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 +478,8 @@ def assert_campaign_uses_mail_profile_reference(
|
||||
def public_campaign_mail_server(raw_json: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""Return the complete public/persisted Campaign-to-Mail contract."""
|
||||
|
||||
profile_id = campaign_mail_profile_id(raw_json)
|
||||
return {"mail_profile_id": profile_id} if profile_id else {}
|
||||
return {
|
||||
key: value
|
||||
for key, value in campaign_mail_resource_ids(raw_json).items()
|
||||
if value
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ class FieldType(StrEnum):
|
||||
DOUBLE = "double"
|
||||
DATE = "date"
|
||||
PASSWORD = "password" # noqa: S105 # nosec B105 - field type vocabulary.
|
||||
ORGANIZATION_UNIT = "organization_unit"
|
||||
ORGANIZATION_FUNCTION = "organization_function"
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
@@ -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,15 +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 .models import (
|
||||
AttachmentConfig,
|
||||
CampaignConfig,
|
||||
DeliveryChannelPolicy,
|
||||
EntryConfig,
|
||||
FieldType,
|
||||
PostboxTargetConfig,
|
||||
SourceType,
|
||||
ZipArchiveConfig,
|
||||
ZipPasswordMode,
|
||||
ZipPasswordScope,
|
||||
ZipRuleMode,
|
||||
effective_delivery_channel_policy,
|
||||
effective_postbox_targets,
|
||||
)
|
||||
from ..attachments.resolver import resolve_campaign_attachments
|
||||
|
||||
|
||||
@@ -90,6 +106,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 +355,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 +508,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 +531,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 +698,11 @@ def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
f"/entries/inline/{index}/from",
|
||||
)
|
||||
for index, entry in enumerate(config.entries.inline or [])
|
||||
if entry.active and not effective_address_lists(config, entry)["from"]
|
||||
if (
|
||||
entry.active
|
||||
and effective_delivery_channel_policy(config, entry).uses_mail
|
||||
and not effective_address_lists(config, entry)["from"]
|
||||
)
|
||||
]
|
||||
if config.recipients.from_:
|
||||
return []
|
||||
@@ -611,6 +928,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 +942,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,31 @@ 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 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 +579,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 +621,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 +699,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 +847,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 +966,18 @@ __all__ = [
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"CampaignWorkAssignment",
|
||||
"CampaignWorkAssignmentEvent",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
"JobImapStatus",
|
||||
"JobPostboxStatus",
|
||||
"JobPrintStatus",
|
||||
"JobQueueStatus",
|
||||
"JobSendStatus",
|
||||
"JobValidationStatus",
|
||||
"SendAttempt",
|
||||
"PostboxDeliveryAttempt",
|
||||
"PrintOutputAttempt",
|
||||
]
|
||||
|
||||
@@ -13,6 +13,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 +27,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 +51,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 +85,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,6 +119,7 @@ def _workflow_topic(
|
||||
links=links,
|
||||
related_modules=related_modules,
|
||||
unlocks=(outcome,),
|
||||
translations=translations or {},
|
||||
source_module_id="campaigns",
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -111,7 +130,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
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.",
|
||||
order=30,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:create"),
|
||||
@@ -123,7 +142,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 +173,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,6 +473,84 @@ 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",
|
||||
@@ -258,6 +590,112 @@ 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 +730,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
related_topic_ids=("campaigns.workflow.complete-review", "campaigns.workflow.retry-and-reconcile"),
|
||||
links=(
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
DocumentationLink(label="Campaign operator queue", href="/operator", kind="runtime"),
|
||||
DocumentationLink(label="Campaign operator queue", href="/campaigns/queue", kind="runtime"),
|
||||
),
|
||||
related_modules=("mail", "notifications"),
|
||||
),
|
||||
@@ -338,7 +776,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
order=37,
|
||||
audience=("campaign_aggregate_reader", "campaign_reader", "campaign_manager"),
|
||||
required_scopes=("campaigns:report:read",),
|
||||
route="/reports",
|
||||
route="/campaigns/reports",
|
||||
screen="Reports",
|
||||
help_contexts=("campaign.report",),
|
||||
prerequisites=("The campaign is owned by or explicitly shared with you, or you hold tenant-wide authority.",),
|
||||
@@ -351,7 +789,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
||||
outcome="A business-level Campaign outcome view with small-group and recipient privacy preserved.",
|
||||
verification="No row, address, message, attachment, diagnostic, filter, drill-down, or export action is available from the aggregate view.",
|
||||
related_topic_ids=("campaigns.workflow.view-delivery-report",),
|
||||
links=(DocumentationLink(label="Aggregate Campaign reports", href="/reports", kind="runtime"),),
|
||||
links=(DocumentationLink(label="Aggregate Campaign reports", href="/campaigns/reports", kind="runtime"),),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.view-delivery-report",
|
||||
@@ -449,14 +887,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 +914,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,10 +922,33 @@ 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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -556,12 +1016,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 +1134,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
@@ -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,7 +304,40 @@ 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:
|
||||
@@ -198,20 +353,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
@@ -4,7 +4,7 @@ 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 +16,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 +96,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 +187,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 +220,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 +240,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("._")
|
||||
@@ -360,8 +376,9 @@ 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()
|
||||
@@ -417,16 +434,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 +557,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 +569,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 +582,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 +597,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 +795,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 +804,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 +821,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 +833,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 +882,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 +926,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 +934,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 +948,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 +962,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 +1185,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 +1236,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 +1278,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",
|
||||
)
|
||||
@@ -110,6 +115,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 +123,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,717 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, exists, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
CAMPAIGN_MAIL_SERVER_KEYS,
|
||||
campaign_mail_profile_id,
|
||||
)
|
||||
from govoplan_campaign.backend.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
|
||||
_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_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,749 @@
|
||||
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,
|
||||
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-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()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_failed_jobs_for_retry(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
include_permanent=payload.include_permanent,
|
||||
force_max_attempts=payload.force_max_attempts,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.jobs_retry_queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.jobs_retry_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/jobs/send-unattempted", response_model=CampaignActionResponse
|
||||
)
|
||||
def send_unattempted_campaign_jobs(
|
||||
campaign_id: str,
|
||||
payload: CampaignSendUnattemptedRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:queue")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
payload = payload or CampaignSendUnattemptedRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = queue_unattempted_jobs(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
job_ids=payload.job_ids or None,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.unattempted_jobs_queued"
|
||||
if not payload.dry_run
|
||||
else "campaign.unattempted_jobs_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/jobs/{job_id}/send", response_model=CampaignActionResponse)
|
||||
def send_single_campaign_job_endpoint(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignSendJobRequest,
|
||||
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}/resolve-outcome",
|
||||
response_model=CampaignActionResponse,
|
||||
)
|
||||
def resolve_campaign_job_outcome(
|
||||
campaign_id: str,
|
||||
job_id: str,
|
||||
payload: CampaignResolveOutcomeRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:reconcile")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
try:
|
||||
result = reconcile_job_outcome(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
job_id=job_id,
|
||||
decision=payload.decision,
|
||||
note=payload.note,
|
||||
attempt_id=payload.attempt_id,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.job_outcome_reconciled",
|
||||
object_type="campaign_job",
|
||||
object_id=job_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except (QueueingError, ExecutionSnapshotError) as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/mock-send", response_model=MockCampaignSendResponse)
|
||||
def mock_send_campaign(
|
||||
campaign_id: str,
|
||||
payload: MockCampaignSendRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send_test")),
|
||||
):
|
||||
"""Run a fully visible mock delivery flow without mutating campaign state.
|
||||
|
||||
The route validates and builds the selected version, then optionally records
|
||||
mock SMTP deliveries and mock IMAP appends. It never talks to the configured
|
||||
real SMTP/IMAP servers and it does not mark the version sent/final.
|
||||
"""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or MockCampaignSendRequest()
|
||||
_require_campaign_profile_use_if_needed(
|
||||
session, principal, campaign_id, payload.version_id
|
||||
)
|
||||
try:
|
||||
result = run_mock_campaign_send(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=payload.version_id,
|
||||
send=payload.send,
|
||||
include_warnings=payload.include_warnings,
|
||||
include_needs_review=payload.include_needs_review,
|
||||
append_sent=payload.append_sent,
|
||||
clear_mailbox=payload.clear_mailbox,
|
||||
check_files=payload.check_files,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.mock_send"
|
||||
if payload.send
|
||||
else "campaign.mock_send_review",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
"version_id": result.get("version_id"),
|
||||
"send_requested": payload.send,
|
||||
"sent_count": result.get("send", {}).get("sent_count"),
|
||||
"failed_count": result.get("send", {}).get("failed_count"),
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
return MockCampaignSendResponse(result=result)
|
||||
except MockCampaignSendError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse)
|
||||
def send_campaign_now_endpoint(
|
||||
campaign_id: str,
|
||||
payload: SendCampaignNowRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
"""Preflight and synchronously send a policy-bounded built execution."""
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
|
||||
payload = payload or SendCampaignNowRequest()
|
||||
try:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||
version_id = payload.version_id or campaign.current_version_id
|
||||
if not version_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign has no current version",
|
||||
)
|
||||
|
||||
version = _get_version_for_tenant(session, version_id, principal.tenant_id)
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
validation_result: dict[str, object] | None = (
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else None
|
||||
)
|
||||
build_result: dict[str, object] | None = (
|
||||
version.build_summary if isinstance(version.build_summary, dict) else None
|
||||
)
|
||||
if is_user_locked_version(version):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="User-locked audit-safe versions cannot be dry-run or sent. Create an editable copy and validate it instead.",
|
||||
)
|
||||
if (
|
||||
not version.locked_at
|
||||
or not validation_result
|
||||
or validation_result.get("ok") is not True
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign version must be validated and locked before dry-run or sending.",
|
||||
)
|
||||
if not build_result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="Campaign version must be built before dry-run or sending.",
|
||||
)
|
||||
|
||||
delivery_result = send_campaign_now(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
include_warnings=payload.include_warnings,
|
||||
dry_run=payload.dry_run,
|
||||
use_rate_limit=payload.use_rate_limit,
|
||||
enqueue_imap_task=payload.enqueue_imap_task,
|
||||
).as_dict()
|
||||
response_result = public_send_campaign_now_result(
|
||||
delivery_result,
|
||||
validation_summary=validation_result,
|
||||
build_summary=build_result,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.sent_now"
|
||||
if not payload.dry_run
|
||||
else "campaign.send_now_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=send_campaign_now_audit_details(delivery_result),
|
||||
commit=True,
|
||||
)
|
||||
return SendCampaignNowResponse(result=response_result)
|
||||
except SynchronousSendRejected as exc:
|
||||
# A synchronous request stages queue state before the all-message
|
||||
# preflight can run. Rejecting that preflight must not leave work
|
||||
# eligible for a background worker when no provider effect occurred.
|
||||
session.rollback()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.send_now_rejected",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details={
|
||||
**exc.audit_details(),
|
||||
"version_id": payload.version_id,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except HTTPException:
|
||||
raise
|
||||
except (CampaignPersistenceError, QueueingError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
|
||||
def pause_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
result = pause_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.paused",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse)
|
||||
def resume_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
try:
|
||||
result = resume_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.resumed",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse)
|
||||
def cancel_campaign(
|
||||
campaign_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:control")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
try:
|
||||
result = cancel_campaign_jobs(
|
||||
session, tenant_id=principal.tenant_id, campaign_id=campaign_id
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.cancelled",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse)
|
||||
def append_sent(
|
||||
campaign_id: str,
|
||||
payload: AppendSentRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:send")),
|
||||
):
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
payload = payload or AppendSentRequest()
|
||||
version_ids = {
|
||||
row[0]
|
||||
for row in session.query(CampaignJob.campaign_version_id)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == principal.tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.send_status.in_(
|
||||
[JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value]
|
||||
),
|
||||
CampaignJob.imap_status.in_(
|
||||
[JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]
|
||||
),
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
}
|
||||
_require_campaign_versions_profile_use(session, principal, campaign_id, version_ids)
|
||||
try:
|
||||
result = enqueue_pending_imap_appends(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
enqueue_celery=payload.enqueue_celery,
|
||||
run_inline=payload.run_inline,
|
||||
dry_run=payload.dry_run,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.append_sent_enqueued"
|
||||
if not payload.dry_run
|
||||
else "campaign.append_sent_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=campaign_id,
|
||||
details=result,
|
||||
commit=True,
|
||||
)
|
||||
return CampaignActionResponse(result=result)
|
||||
except QueueingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
@@ -0,0 +1,530 @@
|
||||
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_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,
|
||||
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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -49,6 +51,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 +65,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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignVersion,
|
||||
CampaignWorkAssignment,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.versions import create_minimal_campaign
|
||||
from govoplan_campaign.backend.route_support import _get_campaign_for_principal
|
||||
from govoplan_campaign.backend.routes.assignments import (
|
||||
_actor_label,
|
||||
_mirror_assignment_to_tasks,
|
||||
_notify_assignment,
|
||||
_record_event,
|
||||
_require_resolved_assignee,
|
||||
_resolve_assignee,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import CampaignWorkAssigneeInput
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
from govoplan_core.core.automation import (
|
||||
ActionDefinition,
|
||||
ActionExecutionRequest,
|
||||
ActionExecutionResult,
|
||||
ActionPreview,
|
||||
EffectDefinition,
|
||||
EffectPreview,
|
||||
ObservedEffect,
|
||||
)
|
||||
from govoplan_core.core.campaigns import (
|
||||
CampaignWorkHandoffInspection,
|
||||
CampaignWorkHandoffRef,
|
||||
CampaignWorkHandoffRequest,
|
||||
)
|
||||
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
ACTION_KEY = "campaigns.work.prepare"
|
||||
ASSIGNMENT_EFFECT = "campaigns.work.assignment_created"
|
||||
CAMPAIGN_EFFECT = "campaigns.work.campaign_created"
|
||||
|
||||
|
||||
class SqlCampaignWorkOrchestrationProvider:
|
||||
"""Campaign-owned adapter used through optional Core capabilities only."""
|
||||
|
||||
def __init__(self, *, registry: object | None = None) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def action_definitions(self) -> tuple[ActionDefinition, ...]:
|
||||
return (
|
||||
ActionDefinition(
|
||||
action_key=ACTION_KEY,
|
||||
owner_module="campaigns",
|
||||
description=(
|
||||
"Reference or create a Campaign and open one authorization-neutral "
|
||||
"accountable work hand-off."
|
||||
),
|
||||
input_schema_ref="govoplan/campaigns/work-handoff.v1",
|
||||
required_scopes=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:assignment:manage",
|
||||
),
|
||||
policy_checks=(
|
||||
"campaign access is checked independently of assignment",
|
||||
"the assignee must already have Campaign access",
|
||||
"the expected Campaign revision must still be current",
|
||||
),
|
||||
risk_level="moderate",
|
||||
reversibility="compensatable",
|
||||
expected_effect_keys=(ASSIGNMENT_EFFECT, CAMPAIGN_EFFECT),
|
||||
idempotency_strategy="caller_supplied",
|
||||
audit_event_types=(
|
||||
"campaign.assignment.created",
|
||||
"campaign.created_minimal",
|
||||
),
|
||||
preview_required=True,
|
||||
recovery_mode="atomic",
|
||||
recovery_verification=(
|
||||
"resolve the assignment by tenant and orchestration idempotency key",
|
||||
"verify the exact Campaign version and assignment revisions",
|
||||
"confirm the assigned principal still has independent Campaign access",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def effect_definitions(self) -> tuple[EffectDefinition, ...]:
|
||||
return (
|
||||
EffectDefinition(
|
||||
effect_key=ASSIGNMENT_EFFECT,
|
||||
owner_module="campaigns",
|
||||
operation="created",
|
||||
description="Create an accountable Campaign work assignment.",
|
||||
resource_types=("campaign_work_assignment",),
|
||||
audit_event_types=("campaign.assignment.created",),
|
||||
compensation_hint="Cancel the open assignment through Campaign work.",
|
||||
),
|
||||
EffectDefinition(
|
||||
effect_key=CAMPAIGN_EFFECT,
|
||||
owner_module="campaigns",
|
||||
operation="created",
|
||||
description="Create a minimal Campaign draft when no campaign is referenced.",
|
||||
resource_types=("campaign", "campaign_version"),
|
||||
audit_event_types=("campaign.created_minimal",),
|
||||
compensation_hint=(
|
||||
"Delete the untouched draft under the normal Campaign lifecycle policy."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def preview_action(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ActionExecutionRequest,
|
||||
) -> ActionPreview:
|
||||
if request.action_key != ACTION_KEY:
|
||||
return _blocked_preview("The Campaign work action is not supported.")
|
||||
try:
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
handoff = _request(request)
|
||||
_preview_handoff(sql_session, api_principal, handoff)
|
||||
except (HTTPException, TypeError, ValueError) as exc:
|
||||
return _blocked_preview(_message(exc))
|
||||
creating = handoff.campaign_id is None
|
||||
effects = [
|
||||
EffectPreview(
|
||||
effect_key=ASSIGNMENT_EFFECT,
|
||||
summary="Open one revision-bearing Campaign work assignment.",
|
||||
)
|
||||
]
|
||||
if creating:
|
||||
effects.insert(
|
||||
0,
|
||||
EffectPreview(
|
||||
effect_key=CAMPAIGN_EFFECT,
|
||||
summary="Create one minimal Campaign draft and initial version.",
|
||||
),
|
||||
)
|
||||
return ActionPreview(
|
||||
action_key=ACTION_KEY,
|
||||
allowed=True,
|
||||
summary=(
|
||||
"Create a Campaign draft and open accountable work."
|
||||
if creating
|
||||
else "Reference the current Campaign revision and open accountable work."
|
||||
),
|
||||
risk_level="moderate",
|
||||
reversibility="compensatable",
|
||||
effects=tuple(effects),
|
||||
policy_provenance=(
|
||||
{
|
||||
"code": "campaign_assignment_does_not_grant_access",
|
||||
"assignment_authorization_neutral": True,
|
||||
"campaign_access_rechecked_on_resume": True,
|
||||
},
|
||||
),
|
||||
preview_ref=f"campaign-work-preview:{_request_hash(handoff)}",
|
||||
)
|
||||
|
||||
def execute_action(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: ActionExecutionRequest,
|
||||
) -> ActionExecutionResult:
|
||||
if request.action_key != ACTION_KEY:
|
||||
raise ValueError("The Campaign work action is not supported.")
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
handoff = _request(request)
|
||||
ref = self.prepare_handoff(
|
||||
sql_session,
|
||||
api_principal,
|
||||
request=handoff,
|
||||
)
|
||||
effects = [
|
||||
ObservedEffect(
|
||||
effect_key=ASSIGNMENT_EFFECT,
|
||||
operation="created",
|
||||
resource_ref=ref.assignment_ref,
|
||||
summary=(
|
||||
"Reused the existing idempotent Campaign work assignment."
|
||||
if ref.replayed
|
||||
else "Created the Campaign work assignment."
|
||||
),
|
||||
metadata={"replayed": ref.replayed},
|
||||
)
|
||||
]
|
||||
if not ref.replayed and handoff.campaign_id is None:
|
||||
effects.insert(
|
||||
0,
|
||||
ObservedEffect(
|
||||
effect_key=CAMPAIGN_EFFECT,
|
||||
operation="created",
|
||||
resource_ref=ref.campaign_ref,
|
||||
summary="Created the minimal Campaign draft.",
|
||||
),
|
||||
)
|
||||
return ActionExecutionResult(
|
||||
state="completed",
|
||||
output=_ref_payload(ref),
|
||||
observed_effects=tuple(effects),
|
||||
audit_event_refs=(
|
||||
str(ref.provenance["audit_event_ref"]),
|
||||
)
|
||||
if ref.provenance.get("audit_event_ref")
|
||||
else (),
|
||||
)
|
||||
|
||||
def prepare_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
if api_principal.tenant_id != request.tenant_id:
|
||||
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||
request_hash = _request_hash(request)
|
||||
existing = (
|
||||
sql_session.query(CampaignWorkAssignment)
|
||||
.filter(
|
||||
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||
CampaignWorkAssignment.orchestration_idempotency_key
|
||||
== request.idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.orchestration_request_sha256 != request_hash:
|
||||
raise ValueError(
|
||||
"Campaign hand-off idempotency key was already used for "
|
||||
"different input."
|
||||
)
|
||||
campaign = _get_campaign_for_principal(
|
||||
sql_session,
|
||||
existing.campaign_id,
|
||||
api_principal,
|
||||
)
|
||||
return _handoff_ref(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=existing,
|
||||
registry=self._registry,
|
||||
replayed=True,
|
||||
)
|
||||
|
||||
campaign, version, created = _campaign_and_version(
|
||||
sql_session,
|
||||
api_principal,
|
||||
request,
|
||||
create=True,
|
||||
)
|
||||
resolution = _resolve_assignee(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignee=CampaignWorkAssigneeInput(
|
||||
type=request.assignee_kind,
|
||||
id=request.assignee_id,
|
||||
),
|
||||
)
|
||||
_require_resolved_assignee(resolution)
|
||||
now = utc_now()
|
||||
assignment = CampaignWorkAssignment(
|
||||
tenant_id=campaign.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
reference_kind="campaign_version",
|
||||
reference_id=version.id,
|
||||
reference_label=f"Campaign version {version.version_number}",
|
||||
purpose=request.purpose.strip(),
|
||||
status="open",
|
||||
due_at=request.due_at,
|
||||
assignee_type=request.assignee_kind,
|
||||
assignee_id=request.assignee_id.strip(),
|
||||
assignee_label_snapshot=(resolution.label or request.assignee_id)[:500],
|
||||
assignee_current_label=resolution.label,
|
||||
assignee_resolution_state=resolution.state,
|
||||
resolution_provenance={
|
||||
**resolution.provenance,
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
"expected_campaign_revision": request.expected_campaign_revision,
|
||||
},
|
||||
resolution_checked_at=now,
|
||||
assigned_by_user_id=api_principal.user.id,
|
||||
assigned_by_label_snapshot=_actor_label(api_principal),
|
||||
orchestration_idempotency_key=request.idempotency_key,
|
||||
orchestration_request_sha256=request_hash,
|
||||
orchestration_correlation_id=request.correlation_id,
|
||||
workflow_instance_id=request.workflow_instance_id,
|
||||
workflow_step_id=request.workflow_step_id,
|
||||
)
|
||||
sql_session.add(assignment)
|
||||
sql_session.flush()
|
||||
_record_event(
|
||||
sql_session,
|
||||
assignment=assignment,
|
||||
principal=api_principal,
|
||||
event_kind="assigned",
|
||||
details={
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
},
|
||||
)
|
||||
if request.mirror_to_tasks:
|
||||
_mirror_assignment_to_tasks(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
principal=api_principal,
|
||||
)
|
||||
else:
|
||||
assignment.task_mirror_status = "skipped"
|
||||
_notify_assignment(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
event_kind="assigned",
|
||||
)
|
||||
audit_ref = audit_from_principal(
|
||||
sql_session,
|
||||
api_principal,
|
||||
action="campaign.assignment.created",
|
||||
object_type="campaign_work_assignment",
|
||||
object_id=assignment.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"campaign_version_id": version.id,
|
||||
"campaign_revision": version.edit_revision,
|
||||
"resource_revision": assignment.resource_revision,
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
"assignment_authorization_neutral": True,
|
||||
"purpose_disclosed": False,
|
||||
},
|
||||
correlation_id=request.correlation_id,
|
||||
causation_id=request.workflow_step_id,
|
||||
commit=False,
|
||||
)
|
||||
if created:
|
||||
audit_from_principal(
|
||||
sql_session,
|
||||
api_principal,
|
||||
action="campaign.created_minimal",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"version_id": version.id,
|
||||
"external_id": campaign.external_id,
|
||||
"source": "workflow",
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
},
|
||||
correlation_id=request.correlation_id,
|
||||
causation_id=request.workflow_step_id,
|
||||
commit=False,
|
||||
)
|
||||
sql_session.flush()
|
||||
ref = _handoff_ref(
|
||||
sql_session,
|
||||
campaign=campaign,
|
||||
assignment=assignment,
|
||||
registry=self._registry,
|
||||
)
|
||||
return replace(
|
||||
ref,
|
||||
provenance={**dict(ref.provenance), "audit_event_ref": audit_ref.id},
|
||||
)
|
||||
|
||||
def inspect_handoff(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
assignment_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> CampaignWorkHandoffInspection:
|
||||
try:
|
||||
sql_session, api_principal = _context(session, principal)
|
||||
except TypeError as exc:
|
||||
return CampaignWorkHandoffInspection(allowed=False, reason=str(exc))
|
||||
if api_principal.tenant_id != tenant_id:
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
reason="Campaign hand-off tenant does not match the principal.",
|
||||
provenance={"code": "campaign_handoff_tenant_mismatch"},
|
||||
)
|
||||
assignment = sql_session.get(CampaignWorkAssignment, assignment_id)
|
||||
if assignment is None or assignment.tenant_id != tenant_id:
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
reason="Campaign work assignment is unavailable.",
|
||||
provenance={"code": "campaign_handoff_missing"},
|
||||
)
|
||||
try:
|
||||
_get_campaign_for_principal(
|
||||
sql_session,
|
||||
assignment.campaign_id,
|
||||
api_principal,
|
||||
)
|
||||
except HTTPException as exc:
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
assignment_revision=assignment.resource_revision,
|
||||
reason=_message(exc),
|
||||
provenance={
|
||||
"code": "campaign_handoff_access_revoked",
|
||||
"campaign_id": assignment.campaign_id,
|
||||
"assignment_does_not_grant_access": True,
|
||||
},
|
||||
)
|
||||
if (
|
||||
expected_revision is not None
|
||||
and assignment.resource_revision != expected_revision
|
||||
):
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=False,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
assignment_revision=assignment.resource_revision,
|
||||
action_url=_action_url(assignment),
|
||||
assignment_ref=_assignment_ref(assignment),
|
||||
reason="Campaign work assignment revision changed; reload its event.",
|
||||
provenance={
|
||||
"code": "campaign_handoff_revision_conflict",
|
||||
"expected_revision": expected_revision,
|
||||
"current_revision": assignment.resource_revision,
|
||||
},
|
||||
)
|
||||
return CampaignWorkHandoffInspection(
|
||||
allowed=True,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
assignment_revision=assignment.resource_revision,
|
||||
action_url=_action_url(assignment),
|
||||
assignment_ref=_assignment_ref(assignment),
|
||||
provenance={
|
||||
"code": "campaign_handoff_access_rechecked",
|
||||
"campaign_id": assignment.campaign_id,
|
||||
"assignment_does_not_grant_access": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _context(
|
||||
session: object,
|
||||
principal: object,
|
||||
) -> tuple[Session, ApiPrincipal]:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("Campaign work orchestration requires a SQLAlchemy Session.")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("Campaign work orchestration requires an API principal.")
|
||||
return session, principal
|
||||
|
||||
|
||||
def _request(request: ActionExecutionRequest) -> CampaignWorkHandoffRequest:
|
||||
value = request.input
|
||||
assignee = value.get("assignee")
|
||||
if not isinstance(assignee, Mapping):
|
||||
raise ValueError("Campaign work hand-offs require an assignee object.")
|
||||
create = value.get("create_campaign")
|
||||
if create is not None and not isinstance(create, Mapping):
|
||||
raise ValueError("Campaign creation input must be an object.")
|
||||
due_at = _date(value.get("due_at"))
|
||||
return CampaignWorkHandoffRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
idempotency_key=request.idempotency_key,
|
||||
purpose=str(value.get("purpose") or ""),
|
||||
assignee_kind=str(assignee.get("kind") or ""), # type: ignore[arg-type]
|
||||
assignee_id=str(assignee.get("id") or ""),
|
||||
campaign_id=_optional(value.get("campaign_id")),
|
||||
create_external_id=_optional(create.get("external_id")) if create else None,
|
||||
create_name=_optional(create.get("name")) if create else None,
|
||||
create_description=(
|
||||
_optional(create.get("description")) if create else None
|
||||
),
|
||||
expected_campaign_revision=_integer(
|
||||
value.get("expected_campaign_revision")
|
||||
),
|
||||
due_at=due_at,
|
||||
mirror_to_tasks=bool(value.get("mirror_to_tasks", True)),
|
||||
correlation_id=request.invocation.correlation_id,
|
||||
workflow_instance_id=_reference_id(
|
||||
request.metadata.get("workflow_instance_ref"),
|
||||
"workflow-instance:",
|
||||
),
|
||||
workflow_step_id=_reference_id(
|
||||
request.metadata.get("workflow_step_ref"),
|
||||
"workflow-step:",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _preview_handoff(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
) -> None:
|
||||
if principal.tenant_id != request.tenant_id:
|
||||
raise ValueError("Campaign hand-off tenant does not match the principal")
|
||||
for scope in (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:assignment:manage",
|
||||
):
|
||||
if not has_scope(principal, scope):
|
||||
raise ValueError(f"Campaign work hand-off requires {scope}.")
|
||||
existing = (
|
||||
session.query(CampaignWorkAssignment)
|
||||
.filter(
|
||||
CampaignWorkAssignment.tenant_id == request.tenant_id,
|
||||
CampaignWorkAssignment.orchestration_idempotency_key
|
||||
== request.idempotency_key,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
if existing.orchestration_request_sha256 != _request_hash(request):
|
||||
raise ValueError(
|
||||
"Campaign hand-off idempotency key was already used for different input."
|
||||
)
|
||||
_get_campaign_for_principal(session, existing.campaign_id, principal)
|
||||
return
|
||||
if request.campaign_id is None:
|
||||
if request.assignee_kind != "account" or (
|
||||
request.assignee_id != principal.account_id
|
||||
):
|
||||
raise ValueError(
|
||||
"A newly created Campaign can initially be assigned only to its "
|
||||
"creating account; share it explicitly before assigning other principals."
|
||||
)
|
||||
duplicate = (
|
||||
session.query(Campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == request.tenant_id,
|
||||
Campaign.external_id == request.create_external_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ValueError("Campaign external ID already exists for this tenant.")
|
||||
if request.expected_campaign_revision not in {None, 1}:
|
||||
raise ValueError("A new Campaign starts at revision one.")
|
||||
return
|
||||
campaign, _version, _created = _campaign_and_version(
|
||||
session,
|
||||
principal,
|
||||
request,
|
||||
create=False,
|
||||
)
|
||||
resolution = _resolve_assignee(
|
||||
session,
|
||||
campaign=campaign,
|
||||
assignee=CampaignWorkAssigneeInput(
|
||||
type=request.assignee_kind,
|
||||
id=request.assignee_id,
|
||||
),
|
||||
)
|
||||
_require_resolved_assignee(resolution)
|
||||
|
||||
|
||||
def _campaign_and_version(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
request: CampaignWorkHandoffRequest,
|
||||
*,
|
||||
create: bool,
|
||||
) -> tuple[Campaign, CampaignVersion, bool]:
|
||||
if request.campaign_id is None:
|
||||
if not create:
|
||||
raise ValueError("Campaign creation is not available during preview.")
|
||||
campaign, version = create_minimal_campaign(
|
||||
session,
|
||||
tenant_id=request.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
external_id=str(request.create_external_id),
|
||||
name=str(request.create_name),
|
||||
description=request.create_description,
|
||||
current_flow="create",
|
||||
current_step="basics",
|
||||
commit=False,
|
||||
)
|
||||
return campaign, version, True
|
||||
campaign = _get_campaign_for_principal(
|
||||
session,
|
||||
request.campaign_id,
|
||||
principal,
|
||||
)
|
||||
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise ValueError("The Campaign current version is unavailable.")
|
||||
if (
|
||||
request.expected_campaign_revision is not None
|
||||
and version.edit_revision != request.expected_campaign_revision
|
||||
):
|
||||
raise ValueError(
|
||||
"Campaign revision changed; reload the Campaign before opening work."
|
||||
)
|
||||
return campaign, version, False
|
||||
|
||||
|
||||
def _handoff_ref(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
assignment: CampaignWorkAssignment,
|
||||
registry: object | None,
|
||||
replayed: bool = False,
|
||||
) -> CampaignWorkHandoffRef:
|
||||
version = session.get(CampaignVersion, assignment.campaign_version_id)
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise ValueError("The pinned Campaign hand-off version is unavailable.")
|
||||
return CampaignWorkHandoffRef(
|
||||
tenant_id=assignment.tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
campaign_revision=version.edit_revision,
|
||||
assignment_id=assignment.id,
|
||||
assignment_revision=assignment.resource_revision,
|
||||
status=assignment.status, # type: ignore[arg-type]
|
||||
action_url=_action_url(assignment),
|
||||
campaign_ref=(
|
||||
f"campaign:{campaign.id}:version:{version.id}:r{version.edit_revision}"
|
||||
),
|
||||
assignment_ref=_assignment_ref(assignment),
|
||||
replayed=replayed,
|
||||
optional_capabilities={
|
||||
"tasks": _has_capability(registry, CAPABILITY_TASK_COMMANDS),
|
||||
"notifications": _has_capability(
|
||||
registry,
|
||||
CAPABILITY_NOTIFICATIONS_DISPATCH,
|
||||
),
|
||||
},
|
||||
provenance={
|
||||
"assignment_authorization_neutral": True,
|
||||
"campaign_access_checked": True,
|
||||
"workflow_instance_id": assignment.workflow_instance_id,
|
||||
"workflow_step_id": assignment.workflow_step_id,
|
||||
"correlation_id": assignment.orchestration_correlation_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _ref_payload(ref: CampaignWorkHandoffRef) -> dict[str, object]:
|
||||
return {
|
||||
"campaign_id": ref.campaign_id,
|
||||
"campaign_version_id": ref.campaign_version_id,
|
||||
"campaign_revision": ref.campaign_revision,
|
||||
"assignment_id": ref.assignment_id,
|
||||
"assignment_revision": ref.assignment_revision,
|
||||
"status": ref.status,
|
||||
"action_url": ref.action_url,
|
||||
"campaign_ref": ref.campaign_ref,
|
||||
"assignment_ref": ref.assignment_ref,
|
||||
"event_type": ref.event_type,
|
||||
"replayed": ref.replayed,
|
||||
"optional_capabilities": dict(ref.optional_capabilities),
|
||||
"provenance": dict(ref.provenance),
|
||||
"outcome": "success",
|
||||
}
|
||||
|
||||
|
||||
def _request_hash(request: CampaignWorkHandoffRequest) -> str:
|
||||
payload = {
|
||||
"tenant_id": request.tenant_id,
|
||||
"purpose": request.purpose.strip(),
|
||||
"assignee_kind": request.assignee_kind,
|
||||
"assignee_id": request.assignee_id.strip(),
|
||||
"campaign_id": request.campaign_id,
|
||||
"create_external_id": request.create_external_id,
|
||||
"create_name": request.create_name,
|
||||
"create_description": request.create_description,
|
||||
"expected_campaign_revision": request.expected_campaign_revision,
|
||||
"due_at": request.due_at.isoformat() if request.due_at else None,
|
||||
"mirror_to_tasks": request.mirror_to_tasks,
|
||||
"correlation_id": request.correlation_id,
|
||||
"workflow_instance_id": request.workflow_instance_id,
|
||||
"workflow_step_id": request.workflow_step_id,
|
||||
}
|
||||
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _blocked_preview(reason: str) -> ActionPreview:
|
||||
return ActionPreview(
|
||||
action_key=ACTION_KEY,
|
||||
allowed=False,
|
||||
summary=reason,
|
||||
risk_level="moderate",
|
||||
reversibility="compensatable",
|
||||
blockers=(reason,),
|
||||
policy_provenance=(
|
||||
{
|
||||
"code": "campaign_work_handoff_blocked",
|
||||
"reason": reason,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _message(exc: Exception) -> str:
|
||||
if isinstance(exc, HTTPException):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, Mapping):
|
||||
return str(detail.get("explanation") or detail.get("code") or detail)
|
||||
return str(detail)
|
||||
return str(exc)
|
||||
|
||||
|
||||
def _date(value: object) -> datetime | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ValueError("Campaign hand-off due date must use ISO 8601.") from exc
|
||||
|
||||
|
||||
def _integer(value: object) -> int | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ValueError("Campaign revisions must be integers.")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Campaign revisions must be integers.") from exc
|
||||
|
||||
|
||||
def _optional(value: object) -> str | None:
|
||||
candidate = str(value or "").strip()
|
||||
return candidate or None
|
||||
|
||||
|
||||
def _reference_id(value: object, prefix: str) -> str | None:
|
||||
candidate = str(value or "").strip()
|
||||
return candidate.removeprefix(prefix) or None if candidate.startswith(prefix) else None
|
||||
|
||||
|
||||
def _assignment_ref(assignment: CampaignWorkAssignment) -> str:
|
||||
return f"campaign-work-assignment:{assignment.id}:r{assignment.resource_revision}"
|
||||
|
||||
|
||||
def _action_url(assignment: CampaignWorkAssignment) -> str:
|
||||
return (
|
||||
f"/campaigns/{assignment.campaign_id}/work"
|
||||
f"?assignment={assignment.id}"
|
||||
)
|
||||
|
||||
|
||||
def _has_capability(registry: object | None, name: str) -> bool:
|
||||
return bool(
|
||||
registry is not None
|
||||
and hasattr(registry, "has_capability")
|
||||
and registry.has_capability(name)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["ACTION_KEY", "SqlCampaignWorkOrchestrationProvider"]
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
|
||||
|
||||
def campaign_workflow_definitions(
|
||||
*,
|
||||
module_version: str,
|
||||
) -> tuple[WorkflowDefinitionContribution, ...]:
|
||||
"""Return opt-in Campaign workflow templates owned by this module."""
|
||||
|
||||
return (
|
||||
WorkflowDefinitionContribution(
|
||||
origin_module_id="campaigns",
|
||||
origin_module_version=module_version,
|
||||
definition_key="accountable-campaign-work-handoff",
|
||||
name="Accountable Campaign work hand-off",
|
||||
description=(
|
||||
"Create or reference a Campaign, assign bounded work, and wait "
|
||||
"for its revision-bearing completion, rejection, cancellation, "
|
||||
"or timeout event."
|
||||
),
|
||||
graph=_campaign_work_handoff_graph(),
|
||||
definition_kind="template",
|
||||
scope_type="system",
|
||||
inherit_to_lower_scopes=True,
|
||||
allow_start=True,
|
||||
allow_reuse=True,
|
||||
allow_automation=False,
|
||||
execution_mode="guided",
|
||||
activate_on_install=False,
|
||||
required_capabilities=(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,),
|
||||
required_interfaces=("campaigns.work_orchestration",),
|
||||
metadata={
|
||||
"domain": "campaigns.accountable_work",
|
||||
"state_owner": "campaigns",
|
||||
"template_requires_configuration": True,
|
||||
},
|
||||
policy_metadata={
|
||||
"assignment_authorization_neutral": True,
|
||||
"campaign_access_rechecked_on_resume": True,
|
||||
"navigation_does_not_complete_work": True,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _campaign_work_handoff_graph() -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "start",
|
||||
"type": "workflow.start.manual",
|
||||
"label": "Campaign work requested",
|
||||
"position": {"x": 20, "y": 140},
|
||||
"config": {
|
||||
"input_schema_ref": "govoplan/campaigns/work-handoff.v1",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
"type": "workflow.capability",
|
||||
"label": "Prepare Campaign work",
|
||||
"position": {"x": 250, "y": 140},
|
||||
"config": {
|
||||
"capability": CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
|
||||
"operation": "campaigns.work.prepare",
|
||||
"input_mapping": {
|
||||
"campaign_id": "$input.campaign_id",
|
||||
"create_campaign": "$input.create_campaign",
|
||||
"expected_campaign_revision": (
|
||||
"$input.expected_campaign_revision"
|
||||
),
|
||||
"purpose": "$input.purpose",
|
||||
"assignee": "$input.assignee",
|
||||
"due_at": "$input.due_at",
|
||||
"mirror_to_tasks": "$input.mirror_to_tasks",
|
||||
},
|
||||
"idempotency_key": "workflow-step",
|
||||
"failure_policy": "manual",
|
||||
"view_surface_ids": ["campaigns.page.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "campaign_work",
|
||||
"type": "workflow.external_handoff",
|
||||
"label": "Complete Campaign work",
|
||||
"position": {"x": 510, "y": 140},
|
||||
"config": {
|
||||
"provider_capability": (
|
||||
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
|
||||
),
|
||||
"event_type": "campaign.work.changed",
|
||||
"event_filter": {
|
||||
"payload": {
|
||||
"assignment_id": (
|
||||
"$steps.prepare.execution.output.assignment_id"
|
||||
)
|
||||
}
|
||||
},
|
||||
"outcome_path": "payload.outcome",
|
||||
"terminal_outcomes": {
|
||||
"completed": "completed",
|
||||
"rejected": "rejected",
|
||||
"cancelled": "cancelled",
|
||||
},
|
||||
"observed_outcomes": [
|
||||
"assigned",
|
||||
"accepted",
|
||||
"started",
|
||||
"reassigned",
|
||||
],
|
||||
"external_id": (
|
||||
"$steps.prepare.execution.output.assignment_id"
|
||||
),
|
||||
"expected_revision": (
|
||||
"$steps.prepare.execution.output.assignment_revision"
|
||||
),
|
||||
"action_url": "$steps.prepare.execution.output.action_url",
|
||||
"immutable_ref": (
|
||||
"$steps.prepare.execution.output.assignment_ref"
|
||||
),
|
||||
"optional_capabilities": (
|
||||
"$steps.prepare.execution.output.optional_capabilities"
|
||||
),
|
||||
"timeout_after": "$input.timeout_after",
|
||||
"view_surface_ids": ["campaigns.page.work"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "completed",
|
||||
"type": "workflow.end.completed",
|
||||
"label": "Campaign work completed",
|
||||
"position": {"x": 790, "y": 20},
|
||||
"config": {"output_mapping": {}},
|
||||
},
|
||||
{
|
||||
"id": "rejected",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Campaign work rejected",
|
||||
"position": {"x": 790, "y": 120},
|
||||
"config": {"reason": "Campaign work was rejected"},
|
||||
},
|
||||
{
|
||||
"id": "cancelled",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Campaign work cancelled",
|
||||
"position": {"x": 790, "y": 220},
|
||||
"config": {"reason": "Campaign work was cancelled"},
|
||||
},
|
||||
{
|
||||
"id": "timed_out",
|
||||
"type": "workflow.end.cancelled",
|
||||
"label": "Campaign work timed out",
|
||||
"position": {"x": 790, "y": 320},
|
||||
"config": {"reason": "Campaign work timed out"},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "start-prepare", "source": "start", "target": "prepare"},
|
||||
{
|
||||
"id": "prepare-work",
|
||||
"source": "prepare",
|
||||
"source_port": "success",
|
||||
"target": "campaign_work",
|
||||
},
|
||||
{
|
||||
"id": "work-completed",
|
||||
"source": "campaign_work",
|
||||
"source_port": "completed",
|
||||
"target": "completed",
|
||||
},
|
||||
{
|
||||
"id": "work-rejected",
|
||||
"source": "campaign_work",
|
||||
"source_port": "rejected",
|
||||
"target": "rejected",
|
||||
},
|
||||
{
|
||||
"id": "work-cancelled",
|
||||
"source": "campaign_work",
|
||||
"source_port": "cancelled",
|
||||
"target": "cancelled",
|
||||
},
|
||||
{
|
||||
"id": "work-timeout",
|
||||
"source": "campaign_work",
|
||||
"source_port": "timed_out",
|
||||
"target": "timed_out",
|
||||
},
|
||||
],
|
||||
"metadata": {
|
||||
"notation": "govoplan.workflow.native",
|
||||
"domain": "campaigns.accountable_work",
|
||||
"configuration_notes": (
|
||||
"Provide either campaign_id or create_campaign and explicit null "
|
||||
"values for unused optional inputs."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["campaign_workflow_definitions"]
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core import runtime_coordination
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _bind_test_runtime_identity():
|
||||
previous = runtime_coordination._process_runtime_identity
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="campaign-tests",
|
||||
node_id="campaign-test-process",
|
||||
incarnation="campaign-test-incarnation",
|
||||
role="test",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
bind_process_runtime_identity(previous)
|
||||
+1010
-6
File diff suppressed because it is too large
Load Diff
@@ -9,12 +9,20 @@ from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend import router as campaign_api
|
||||
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||
from govoplan_campaign.backend.routes import jobs as job_routes
|
||||
from govoplan_campaign.backend.routes import reports as report_routes
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion
|
||||
from govoplan_campaign.backend.reports.aggregate import (
|
||||
AggregateCampaignReportError,
|
||||
generate_aggregate_campaign_report,
|
||||
)
|
||||
from govoplan_campaign.backend.reports.provider import (
|
||||
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
||||
CampaignAggregateReportProvider,
|
||||
)
|
||||
from govoplan_core.core.reporting import ReportProviderRequest
|
||||
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
@@ -37,10 +45,12 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(
|
||||
report_routes, "_get_campaign_for_principal", return_value=campaign
|
||||
),
|
||||
pytest.raises(HTTPException) as full_report_denied,
|
||||
):
|
||||
router.campaign_report(
|
||||
report_routes.campaign_report(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
@@ -49,10 +59,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert "campaigns:recipient:read" in full_report_denied.value.detail
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as job_detail_denied,
|
||||
):
|
||||
router.get_job_detail(
|
||||
job_routes.get_job_detail(
|
||||
"campaign-1",
|
||||
"job-1",
|
||||
session=session,
|
||||
@@ -61,10 +71,12 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert job_detail_denied.value.status_code == 403
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(
|
||||
report_routes, "_get_campaign_for_principal", return_value=campaign
|
||||
),
|
||||
pytest.raises(HTTPException) as report_email_denied,
|
||||
):
|
||||
router.email_campaign_report(
|
||||
report_routes.email_campaign_report(
|
||||
"campaign-1",
|
||||
ReportEmailRequest(to=["auditor@example.test"]),
|
||||
session=session,
|
||||
@@ -74,10 +86,10 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
||||
assert "campaigns:recipient:export" in report_email_denied.value.detail
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(job_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
pytest.raises(HTTPException) as diagnostics_denied,
|
||||
):
|
||||
router.get_job_diagnostics(
|
||||
job_routes.get_job_diagnostics(
|
||||
"campaign-1",
|
||||
"job-1",
|
||||
session=session,
|
||||
@@ -93,14 +105,14 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
||||
safe_projection = Mock()
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal") as acl,
|
||||
patch.object(campaign_routes, "_get_campaign_for_principal") as acl,
|
||||
patch.object(
|
||||
router,
|
||||
campaign_routes,
|
||||
"generate_aggregate_campaign_report",
|
||||
return_value=safe_projection,
|
||||
) as generate,
|
||||
):
|
||||
result = router.aggregate_campaign_report(
|
||||
result = campaign_routes.aggregate_campaign_report(
|
||||
"campaign-1",
|
||||
session=session,
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
@@ -116,10 +128,15 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"],
|
||||
)
|
||||
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
||||
route = next(item for item in router.router.routes if item.path == path)
|
||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
||||
route = next(item for item in campaign_api.router.routes if item.path == path)
|
||||
dependency = next(
|
||||
item for item in route.dependant.dependencies if item.name == "principal"
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as denied:
|
||||
dependency.call(_Principal())
|
||||
@@ -145,7 +162,9 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
],
|
||||
)
|
||||
with Session(engine) as session:
|
||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
|
||||
session.add(
|
||||
Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={})
|
||||
)
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -165,20 +184,22 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
campaign.current_version_id = version.id
|
||||
session.add_all([campaign, version])
|
||||
for index in range(5):
|
||||
session.add(CampaignJob(
|
||||
id=f"job-{index}",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=index,
|
||||
recipient_email=f"private-{index}@example.test",
|
||||
subject="Private",
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
queue_status="queued",
|
||||
send_status="smtp_accepted",
|
||||
imap_status="not_requested",
|
||||
))
|
||||
session.add(
|
||||
CampaignJob(
|
||||
id=f"job-{index}",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
entry_index=index,
|
||||
recipient_email=f"private-{index}@example.test",
|
||||
subject="Private",
|
||||
build_status="built",
|
||||
validation_status="ready",
|
||||
queue_status="queued",
|
||||
send_status="smtp_accepted",
|
||||
imap_status="not_requested",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
report = generate_aggregate_campaign_report(
|
||||
@@ -189,6 +210,30 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
||||
assert report.population.denominator.value == 5
|
||||
assert report.outcomes.smtp_accepted.value == 5
|
||||
|
||||
provider = CampaignAggregateReportProvider()
|
||||
provider_principal = _Principal(
|
||||
"campaigns:report:read",
|
||||
"tenant:*",
|
||||
)
|
||||
descriptors = provider.list_reports(session, provider_principal)
|
||||
assert descriptors[0].report_id == "delivery-outcomes"
|
||||
assert descriptors[0].reidentification_risk == "low"
|
||||
provided = provider.execute_report(
|
||||
session,
|
||||
provider_principal,
|
||||
request=ReportProviderRequest(
|
||||
report_id="delivery-outcomes",
|
||||
parameters={"campaign_id": campaign.id},
|
||||
purpose="Tenant delivery overview",
|
||||
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||
),
|
||||
)
|
||||
assert set(provided.applied_privacy_transforms) == set(
|
||||
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
||||
)
|
||||
assert "recipient_email" not in repr(provided.payload)
|
||||
assert provided.source_revisions[0]["revision_id"] == version.id
|
||||
|
||||
with pytest.raises(AggregateCampaignReportError):
|
||||
generate_aggregate_campaign_report(
|
||||
session,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.approvals import (
|
||||
ApprovalActorSelector,
|
||||
ApprovalCheck,
|
||||
ApprovalRequestRef,
|
||||
ApprovalStepDefinition,
|
||||
)
|
||||
from govoplan_campaign.backend.approval_gate import (
|
||||
CampaignApprovalGateError,
|
||||
assert_campaign_approval,
|
||||
request_campaign_approval,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Principal:
|
||||
tenant_id: str = "tenant-1"
|
||||
account_id: str = "requester"
|
||||
|
||||
|
||||
class Session:
|
||||
def add(self, _value: object) -> None:
|
||||
return None
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class ApprovalStub:
|
||||
available = True
|
||||
|
||||
def __init__(self, *, state: str = "pending") -> None:
|
||||
self.state = state
|
||||
self.command = None
|
||||
|
||||
def create_request(self, _session, _principal, *, command, idempotency_key):
|
||||
assert idempotency_key == "request-1"
|
||||
self.command = command
|
||||
return ApprovalRequestRef("approval-1", 1, "pending", "release")
|
||||
|
||||
def check_approved(self, _session, _principal, **kwargs):
|
||||
return ApprovalCheck(
|
||||
request_id=str(kwargs["request_id"]),
|
||||
revision=2,
|
||||
state=self.state,
|
||||
approved=self.state == "approved",
|
||||
subject_module=str(kwargs["subject_module"]),
|
||||
subject_type=str(kwargs["subject_type"]),
|
||||
subject_id=str(kwargs["subject_id"]),
|
||||
subject_version=kwargs["subject_version"],
|
||||
subject_digest=str(kwargs["subject_digest"]),
|
||||
)
|
||||
|
||||
|
||||
def _objects():
|
||||
campaign = SimpleNamespace(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="author",
|
||||
owner_user_id="owner",
|
||||
)
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
campaign_id="campaign-1",
|
||||
version_number=7,
|
||||
execution_snapshot_hash="a" * 64,
|
||||
editor_state={"review_send": {"updated_by_user_id": "reviewer"}},
|
||||
validation_summary={"validated_by_user_id": "validator"},
|
||||
build_summary={"built_by_user_id": "builder"},
|
||||
locked_by_user_id="validator",
|
||||
)
|
||||
return campaign, version
|
||||
|
||||
|
||||
def test_request_binds_snapshot_and_action_evidence() -> None:
|
||||
campaign, version = _objects()
|
||||
provider = ApprovalStub()
|
||||
snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7")
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.approval_gate.ensure_execution_snapshot",
|
||||
return_value=snapshot,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.approval_gate.approvals_integration",
|
||||
return_value=provider,
|
||||
),
|
||||
):
|
||||
request_campaign_approval(
|
||||
Session(),
|
||||
Principal(),
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
title="Release",
|
||||
description=None,
|
||||
steps=(
|
||||
ApprovalStepDefinition(
|
||||
"release",
|
||||
"Release",
|
||||
(ApprovalActorSelector("role", "sender"),),
|
||||
forbidden_evidence_roles=("builder", "reviewer"),
|
||||
),
|
||||
),
|
||||
idempotency_key="request-1",
|
||||
)
|
||||
assert provider.command.subject_digest == "a" * 64
|
||||
assert provider.command.evidence_actors["builder"] == ("builder",)
|
||||
assert provider.command.evidence_actors["reviewer"] == ("reviewer",)
|
||||
assert version.editor_state["approval_gate"]["request_id"] == "approval-1"
|
||||
|
||||
|
||||
def test_gate_fails_closed_until_exact_request_is_approved() -> None:
|
||||
campaign, version = _objects()
|
||||
version.editor_state["approval_gate"] = {
|
||||
"request_id": "approval-1",
|
||||
"request_revision": 1,
|
||||
"subject_version": "build-7",
|
||||
"subject_digest": "a" * 64,
|
||||
"requested_at": "2026-08-01T00:00:00+00:00",
|
||||
"requested_by_user_id": "requester",
|
||||
}
|
||||
snapshot = SimpleNamespace(build_token="build-7", snapshot_version="7")
|
||||
provider = ApprovalStub(state="pending")
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.approval_gate.ensure_execution_snapshot",
|
||||
return_value=snapshot,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.approval_gate.approvals_integration",
|
||||
return_value=provider,
|
||||
),
|
||||
):
|
||||
with pytest.raises(CampaignApprovalGateError, match="pending"):
|
||||
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
|
||||
provider.state = "approved"
|
||||
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
|
||||
version.execution_snapshot_hash = "b" * 64
|
||||
with pytest.raises(CampaignApprovalGateError, match="changed"):
|
||||
assert_campaign_approval(Session(), tenant_id="tenant-1", version=version)
|
||||
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.archive_encryption import (
|
||||
CampaignArchiveEncryptionError,
|
||||
LEGACY_ZIPCRYPTO_SCOPE,
|
||||
assert_archive_encryption_allowed,
|
||||
effective_archive_encryption_policy,
|
||||
stamp_legacy_zipcrypto_acknowledgements,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
||||
CampaignArchiveEncryptionDecision,
|
||||
PolicySourceStep,
|
||||
)
|
||||
|
||||
|
||||
class _PolicyProvider:
|
||||
def __init__(self, methods: set[str]) -> None:
|
||||
self.methods = methods
|
||||
|
||||
def resolve_campaign_archive_encryption(self, session=None, *, request):
|
||||
del session, request
|
||||
return CampaignArchiveEncryptionDecision(
|
||||
allowed_password_encryption_methods=frozenset(self.methods),
|
||||
allowed_password_delivery_channels=frozenset(
|
||||
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
||||
),
|
||||
policy_hash="f" * 64,
|
||||
source_path=(
|
||||
PolicySourceStep(
|
||||
scope_type="system",
|
||||
label="System archive-encryption policy",
|
||||
applied_fields=("allowed_password_encryption_methods",),
|
||||
policy={"allowed_password_encryption_methods": sorted(self.methods)},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider) -> None:
|
||||
self.provider = provider
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
|
||||
|
||||
def capability(self, name: str):
|
||||
return self.provider if self.has_capability(name) else None
|
||||
|
||||
|
||||
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
self.session = Session(self.engine)
|
||||
self.campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
external_id="example",
|
||||
name="Example",
|
||||
owner_user_id="user-1",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
|
||||
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
|
||||
policy = effective_archive_encryption_policy(self.session, self.campaign)
|
||||
self.assertFalse(policy.available)
|
||||
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
|
||||
assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
_raw_archive("aes"),
|
||||
)
|
||||
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
|
||||
assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
_raw_archive("zip_standard", stamped=True),
|
||||
)
|
||||
|
||||
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
|
||||
raw = _raw_archive("aes")
|
||||
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
|
||||
with patch(
|
||||
"govoplan_campaign.backend.archive_encryption.get_registry",
|
||||
return_value=None,
|
||||
):
|
||||
decision = assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
raw,
|
||||
)
|
||||
self.assertIn(
|
||||
"separate_mail",
|
||||
decision.allowed_password_delivery_channels,
|
||||
)
|
||||
|
||||
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
|
||||
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
|
||||
candidate = _raw_archive("zip_standard")
|
||||
candidate["attachments"]["zip"]["archives"][0].update(
|
||||
{
|
||||
"legacy_zipcrypto_acknowledged": True,
|
||||
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
|
||||
}
|
||||
)
|
||||
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
|
||||
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
|
||||
stamp_legacy_zipcrypto_acknowledgements(
|
||||
self.session,
|
||||
self.campaign,
|
||||
{},
|
||||
candidate,
|
||||
principal=_principal(set()),
|
||||
)
|
||||
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
||||
self.session,
|
||||
self.campaign,
|
||||
{},
|
||||
candidate,
|
||||
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||
)
|
||||
archive = stamped["attachments"]["zip"]["archives"][0]
|
||||
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
|
||||
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
|
||||
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
|
||||
assert_archive_encryption_allowed(
|
||||
self.session,
|
||||
self.campaign,
|
||||
stamped,
|
||||
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
||||
)
|
||||
|
||||
|
||||
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
|
||||
archive = {
|
||||
"id": "archive-1",
|
||||
"method": method,
|
||||
"password_enabled": True,
|
||||
"password_delivery_channel": "separate_mail",
|
||||
"legacy_zipcrypto_acknowledged": method == "zip_standard",
|
||||
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
|
||||
if method == "zip_standard"
|
||||
else None,
|
||||
}
|
||||
if stamped:
|
||||
archive.update(
|
||||
{
|
||||
"legacy_zipcrypto_acknowledged_by": "user-1",
|
||||
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
|
||||
}
|
||||
)
|
||||
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_campaign.backend.artifact_reconciliation import (
|
||||
campaign_artifact_inventory,
|
||||
reconcile_campaign_artifacts,
|
||||
)
|
||||
from govoplan_core.core.object_storage import (
|
||||
StorageBackendError,
|
||||
StorageObjectInfo,
|
||||
StorageObjectMissing,
|
||||
StorageObjectPage,
|
||||
)
|
||||
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||
from govoplan_core.core.recovery_runtime import RecoveryOperationBusy
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
acquire_lease,
|
||||
)
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 3, 12, 0, tzinfo=timezone.utc)
|
||||
PREFIX = "campaign-artifacts/tenant-1/campaign-1/version-1/"
|
||||
|
||||
|
||||
class _MemoryStorage:
|
||||
name = "memory"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.objects: dict[str, bytes] = {}
|
||||
self.modified_at: dict[str, datetime | None] = {}
|
||||
self.delete_failures: set[str] = set()
|
||||
self.exists_failures: set[str] = set()
|
||||
|
||||
def add(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
payload: bytes = b"artifact",
|
||||
modified_at: datetime | None = NOW - timedelta(days=2),
|
||||
) -> None:
|
||||
self.objects[key] = payload
|
||||
self.modified_at[key] = modified_at
|
||||
|
||||
def put_bytes(self, key: str, data: bytes, **_kwargs) -> None:
|
||||
self.add(key, payload=data, modified_at=NOW)
|
||||
|
||||
def get_bytes(self, key: str) -> bytes:
|
||||
try:
|
||||
return self.objects[key]
|
||||
except KeyError as exc:
|
||||
raise StorageObjectMissing("missing") from exc
|
||||
|
||||
def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024):
|
||||
del chunk_size
|
||||
yield self.get_bytes(key)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
if key in self.delete_failures:
|
||||
raise StorageBackendError("delete unavailable")
|
||||
self.objects.pop(key, None)
|
||||
self.modified_at.pop(key, None)
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
if key in self.exists_failures:
|
||||
raise StorageBackendError("probe unavailable")
|
||||
return key in self.objects
|
||||
|
||||
def stat(self, key: str) -> StorageObjectInfo:
|
||||
if key not in self.objects:
|
||||
raise StorageObjectMissing("missing")
|
||||
return StorageObjectInfo(
|
||||
key=key,
|
||||
size_bytes=len(self.objects[key]),
|
||||
modified_at=self.modified_at[key],
|
||||
)
|
||||
|
||||
def list_objects(
|
||||
self,
|
||||
*,
|
||||
prefix: str,
|
||||
after: str | None = None,
|
||||
limit: int = 500,
|
||||
) -> StorageObjectPage:
|
||||
keys = [
|
||||
key
|
||||
for key in sorted(self.objects)
|
||||
if key.startswith(prefix) and (after is None or key > after)
|
||||
]
|
||||
selected = keys[:limit]
|
||||
return StorageObjectPage(
|
||||
objects=tuple(self.stat(key) for key in selected),
|
||||
next_cursor=(selected[-1] if len(keys) > len(selected) else None),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recovery_session_factory():
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
DistributedLease.__table__.create(engine)
|
||||
RecoveryOperation.__table__.create(engine)
|
||||
RecoveryCheckpoint.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _identity(*, node_id: str = "node-1", incarnation: str = "run-1"):
|
||||
return RuntimeIdentity(
|
||||
installation_id="campaign-artifact-tests",
|
||||
node_id=node_id,
|
||||
incarnation=incarnation,
|
||||
role="worker",
|
||||
software_version="test",
|
||||
composition_hash="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _without_domain_references():
|
||||
return (
|
||||
patch(
|
||||
"govoplan_campaign.backend.artifact_reconciliation._referenced_artifact_keys",
|
||||
return_value=set(),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.artifact_reconciliation._active_build_ids",
|
||||
return_value=set(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_inventory_classifies_reference_grace_active_build_and_unknown_age() -> None:
|
||||
storage = _MemoryStorage()
|
||||
orphan = f"{PREFIX}build-orphan/message.eml"
|
||||
referenced = f"{PREFIX}build-referenced/message.eml"
|
||||
active = f"{PREFIX}build-active/message.eml"
|
||||
young = f"{PREFIX}build-young/message.eml"
|
||||
unknown_age = f"{PREFIX}build-unknown/message.eml"
|
||||
malformed = "campaign-artifacts/tenant-1/not-a-build-object"
|
||||
storage.add(orphan)
|
||||
storage.add(referenced)
|
||||
storage.add(active)
|
||||
storage.add(young, modified_at=NOW - timedelta(hours=1))
|
||||
storage.add(unknown_age, modified_at=None)
|
||||
storage.add(malformed)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.artifact_reconciliation._referenced_artifact_keys",
|
||||
return_value={referenced},
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.artifact_reconciliation._active_build_ids",
|
||||
return_value={"build-active"},
|
||||
),
|
||||
):
|
||||
inventory = campaign_artifact_inventory(
|
||||
object(), # type: ignore[arg-type]
|
||||
storage=storage,
|
||||
tenant_id="tenant-1",
|
||||
grace_period=timedelta(hours=24),
|
||||
page_size=20,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
assert [candidate.key for candidate in inventory.candidates] == [orphan]
|
||||
assert inventory.referenced_count == 1
|
||||
assert inventory.active_build_count == 1
|
||||
assert inventory.young_count == 1
|
||||
assert inventory.unknown_age_count == 1
|
||||
assert inventory.invalid_shape_count == 1
|
||||
|
||||
|
||||
def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
|
||||
recovery_session_factory,
|
||||
) -> None:
|
||||
storage = _MemoryStorage()
|
||||
orphan = f"{PREFIX}lost-build/message.eml"
|
||||
storage.add(orphan)
|
||||
reference_patch, active_patch = _without_domain_references()
|
||||
with reference_patch, active_patch:
|
||||
dry_run = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
now=NOW,
|
||||
)
|
||||
applied = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="cleanup-lost-build",
|
||||
now=NOW,
|
||||
)
|
||||
replayed = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="cleanup-lost-build",
|
||||
now=NOW,
|
||||
)
|
||||
repeated = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="cleanup-empty-page",
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
assert dry_run["candidate_count"] == 1
|
||||
assert dry_run["deleted_count"] == 0
|
||||
assert applied["status"] == "applied"
|
||||
assert applied["deleted_count"] == 1
|
||||
assert orphan not in storage.objects
|
||||
assert replayed["status"] == "already_completed"
|
||||
assert repeated["candidate_count"] == 0
|
||||
|
||||
|
||||
def test_competing_node_cannot_acquire_cleanup_authority(
|
||||
recovery_session_factory,
|
||||
) -> None:
|
||||
lease_observed_at = datetime.now(timezone.utc)
|
||||
with recovery_session_factory() as session:
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
installation_id="campaign-artifact-tests",
|
||||
resource_key="campaign:artifact-reconcile:tenant-1",
|
||||
holder_node_id="node-other",
|
||||
holder_incarnation="run-other",
|
||||
ttl_seconds=900,
|
||||
now=lease_observed_at,
|
||||
)
|
||||
assert claim is not None
|
||||
session.commit()
|
||||
|
||||
storage = _MemoryStorage()
|
||||
reference_patch, active_patch = _without_domain_references()
|
||||
with reference_patch, active_patch, pytest.raises(RecoveryOperationBusy):
|
||||
reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="competing-cleanup",
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_partial_storage_outage_remains_visible_and_retryable(
|
||||
recovery_session_factory,
|
||||
) -> None:
|
||||
storage = _MemoryStorage()
|
||||
removed = f"{PREFIX}build-a/message.eml"
|
||||
retained = f"{PREFIX}build-b/message.eml"
|
||||
storage.add(removed)
|
||||
storage.add(retained)
|
||||
storage.delete_failures.add(retained)
|
||||
reference_patch, active_patch = _without_domain_references()
|
||||
with reference_patch, active_patch:
|
||||
partial = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="partial-cleanup",
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
storage.delete_failures.clear()
|
||||
retry = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="partial-cleanup-retry",
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
assert partial["status"] == "recovery_required"
|
||||
assert partial["deleted_count"] == 1
|
||||
assert partial["failure_count"] == 1
|
||||
assert removed not in storage.objects
|
||||
assert retry["status"] == "applied"
|
||||
assert retry["deleted_count"] == 1
|
||||
assert storage.objects == {}
|
||||
with recovery_session_factory() as session:
|
||||
states = session.execute(
|
||||
select(RecoveryOperation.status).order_by(RecoveryOperation.created_at)
|
||||
).scalars().all()
|
||||
assert "recovery_required" in states
|
||||
assert states[-1] == "succeeded"
|
||||
|
||||
|
||||
def test_unverifiable_delete_is_outcome_unknown(
|
||||
recovery_session_factory,
|
||||
) -> None:
|
||||
storage = _MemoryStorage()
|
||||
orphan = f"{PREFIX}build-unknown/message.eml"
|
||||
storage.add(orphan)
|
||||
storage.exists_failures.add(orphan)
|
||||
reference_patch, active_patch = _without_domain_references()
|
||||
with reference_patch, active_patch:
|
||||
result = reconcile_campaign_artifacts(
|
||||
recovery_session_factory,
|
||||
storage=storage,
|
||||
identity=_identity(),
|
||||
tenant_id="tenant-1",
|
||||
apply=True,
|
||||
idempotency_key="unknown-cleanup",
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
assert result["status"] == "outcome_unknown"
|
||||
assert result["failure_count"] == 1
|
||||
@@ -7,6 +7,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.loader import validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
|
||||
@@ -56,6 +57,64 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
def _attachment_reuse_config(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
allow_within: str = "none",
|
||||
recipient_emails: tuple[str, ...] = (
|
||||
"first@example.org",
|
||||
"second@example.org",
|
||||
),
|
||||
duplicate_rules: bool = False,
|
||||
) -> CampaignConfig:
|
||||
rules = [{
|
||||
"id": "shared-file",
|
||||
"base_dir": "documents",
|
||||
"file_filter": "shared.pdf",
|
||||
"required": True,
|
||||
}]
|
||||
if duplicate_rules:
|
||||
rules.append({**rules[0], "id": "shared-file-again"})
|
||||
return CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": f"reuse-{action}-{allow_within}",
|
||||
"name": "Attachment reuse",
|
||||
"mode": "test",
|
||||
},
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"profile_capabilities": {"smtp_available": True},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
},
|
||||
"template": {"subject": "Subject", "text": "Body"},
|
||||
"attachments": {
|
||||
"global": rules,
|
||||
"reuse_policy": {
|
||||
"action": action,
|
||||
"allow_within": allow_within,
|
||||
},
|
||||
},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": f"recipient-{index}",
|
||||
"to": [{"email": email, "type": "to"}],
|
||||
}
|
||||
for index, email in enumerate(recipient_emails, start=1)
|
||||
]
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
def test_send_without_attachments_policy_does_not_block_when_no_rules_are_configured(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -100,9 +159,9 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
cases = {
|
||||
"block": ("build_failed", "blocked", 0, "block", False),
|
||||
"ask": ("built", "needs_review", 0, "ask", True),
|
||||
"drop": ("built", "excluded", 0, "drop", True),
|
||||
"drop": ("built", "needs_review", 0, "ask", True),
|
||||
"warn": ("built", "warning", 1, "warn", True),
|
||||
"continue": ("built", "ready", 1, None, True),
|
||||
"continue": ("built", "warning", 1, None, True),
|
||||
}
|
||||
for behavior, (build_status, validation_status, queueable_count, issue_behavior, has_mime) in cases.items():
|
||||
with self.subTest(behavior=behavior):
|
||||
@@ -133,6 +192,71 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
self.assertEqual(coverage_issues[0].behavior, issue_behavior)
|
||||
self.assertEqual(result.built_messages[0].mime is not None, has_mime)
|
||||
|
||||
def test_required_missing_policy_cannot_be_loosened_by_rule(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(
|
||||
behavior="continue",
|
||||
configure_missing_rule=True,
|
||||
)
|
||||
rule = config.attachments.global_[0]
|
||||
rule.required = True
|
||||
rule.missing_behavior = "continue"
|
||||
config.attachments.missing_behavior = "continue"
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=True,
|
||||
)
|
||||
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.validation_status.value, "blocked")
|
||||
issue = next(
|
||||
item
|
||||
for item in message.issues
|
||||
if item.code == "missing_required_attachment"
|
||||
)
|
||||
self.assertEqual(issue.behavior, "block")
|
||||
self.assertEqual(
|
||||
issue.details["effective_policy"]["requirement_policy"],
|
||||
"block",
|
||||
)
|
||||
self.assertEqual(
|
||||
message.attachments[0].missing_policy["effective_behavior"],
|
||||
"block",
|
||||
)
|
||||
|
||||
def test_optional_missing_policy_warns_by_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._no_attachment_config(
|
||||
behavior="continue",
|
||||
configure_missing_rule=True,
|
||||
)
|
||||
config.attachments.global_[0].missing_behavior = None
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=True,
|
||||
)
|
||||
|
||||
message = result.report.messages[0]
|
||||
self.assertEqual(message.validation_status.value, "warning")
|
||||
issue = next(
|
||||
item
|
||||
for item in message.issues
|
||||
if item.code == "missing_optional_attachment"
|
||||
)
|
||||
self.assertEqual(issue.behavior, "warn")
|
||||
|
||||
def test_missing_pattern_does_not_create_zip_member_or_count_as_attachment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
@@ -210,6 +334,264 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||
|
||||
def test_attachment_reuse_action_controls_message_validation(self) -> None:
|
||||
expected = {
|
||||
"allow": ("ready", None, 0, 1),
|
||||
"warn": ("warning", "warn", 1, 0),
|
||||
"review": ("needs_review", "ask", 1, 0),
|
||||
"block": ("blocked", "block", 1, 0),
|
||||
}
|
||||
for action, (status, behavior, violation_count, allowed_count) in expected.items():
|
||||
with self.subTest(action=action), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
documents = root / "documents"
|
||||
documents.mkdir()
|
||||
(documents / "shared.pdf").write_bytes(b"shared")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = self._attachment_reuse_config(action=action)
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=False,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[status, status],
|
||||
[message.validation_status.value for message in result.report.messages],
|
||||
)
|
||||
report = result.report.attachment_reuse
|
||||
self.assertEqual(1, report["duplicate_file_count"])
|
||||
self.assertEqual(violation_count, report["violation_file_count"])
|
||||
self.assertEqual(allowed_count, report["allowed_file_count"])
|
||||
finding = report["findings"][0]
|
||||
self.assertEqual("shared.pdf", finding["file_name"])
|
||||
self.assertNotIn(str(root), str(report))
|
||||
issues = [
|
||||
issue
|
||||
for message in result.report.messages
|
||||
for issue in message.issues
|
||||
if issue.code == "duplicate_attachment_reuse"
|
||||
]
|
||||
if behavior is None:
|
||||
self.assertEqual([], issues)
|
||||
else:
|
||||
self.assertEqual([behavior, behavior], [issue.behavior for issue in issues])
|
||||
self.assertEqual(
|
||||
{"action": action, "allow_within": "none"},
|
||||
issues[0].details["policy"],
|
||||
)
|
||||
|
||||
def test_attachment_reuse_can_be_allowed_within_recipient_or_message(self) -> None:
|
||||
cases = (
|
||||
("same_recipient", ("same@example.org", "same@example.org"), False, 2),
|
||||
("same_message", ("same@example.org",), True, 2),
|
||||
)
|
||||
for allow_within, recipients, duplicate_rules, expected_use_count in cases:
|
||||
with self.subTest(allow_within=allow_within), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
documents = root / "documents"
|
||||
documents.mkdir()
|
||||
(documents / "shared.pdf").write_bytes(b"shared")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
|
||||
result = build_campaign_messages(
|
||||
self._attachment_reuse_config(
|
||||
action="block",
|
||||
allow_within=allow_within,
|
||||
recipient_emails=recipients,
|
||||
duplicate_rules=duplicate_rules,
|
||||
),
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=False,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["ready"] * len(recipients),
|
||||
[message.validation_status.value for message in result.report.messages],
|
||||
)
|
||||
report = result.report.attachment_reuse
|
||||
self.assertEqual(1, report["allowed_file_count"])
|
||||
self.assertEqual(0, report["violation_file_count"])
|
||||
self.assertEqual(expected_use_count, report["findings"][0]["use_count"])
|
||||
|
||||
def test_attachment_reuse_policy_is_part_of_the_json_schema(self) -> None:
|
||||
config = self._attachment_reuse_config(
|
||||
action="review",
|
||||
allow_within="same_recipient",
|
||||
)
|
||||
|
||||
payload = config.model_dump(
|
||||
mode="json",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_defaults=True,
|
||||
)
|
||||
payload["server"].pop("profile_capabilities", None)
|
||||
|
||||
validate_against_schema(payload)
|
||||
|
||||
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
|
||||
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
watched = root / "watched"
|
||||
watched.mkdir()
|
||||
(watched / "assigned.txt").write_text("assigned", encoding="utf-8")
|
||||
(watched / "residual.txt").write_text("residual", encoding="utf-8")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {"id": f"residual-{mode}", "name": "Monthly import", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"profile_capabilities": {"smtp_available": True},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
},
|
||||
"template": {"subject": "Normal message", "text": "Normal body"},
|
||||
"attachments": {
|
||||
"base_paths": [{
|
||||
"id": "watched",
|
||||
"name": "Watched folder",
|
||||
"path": "watched",
|
||||
"unsent_warning": True,
|
||||
}],
|
||||
"global": [{
|
||||
"id": "assigned",
|
||||
"base_path_id": "watched",
|
||||
"base_dir": "watched",
|
||||
"file_filter": "assigned.txt",
|
||||
"required": True,
|
||||
}],
|
||||
"residual_files": {
|
||||
"mode": mode,
|
||||
"recipient": {"email": "operator@example.org", "name": "Operator"},
|
||||
"subject": "Residual files for {{local:campaign_name}}",
|
||||
"text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}",
|
||||
},
|
||||
},
|
||||
"entries": {"inline": [{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
}]},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
"unsent_attachment_files": "block",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=True,
|
||||
)
|
||||
|
||||
self.assertEqual(len(result.report.messages), 2)
|
||||
self.assertEqual(
|
||||
{
|
||||
"contract_version": "1",
|
||||
"action": "route_report" if mode == "report" else "route_with_files",
|
||||
"routing_mode": mode,
|
||||
"validation_behavior": "block",
|
||||
"watched_source_count": 1,
|
||||
"residual_file_count": 1,
|
||||
"recipient": {
|
||||
"email": "operator@example.org",
|
||||
"name": "Operator",
|
||||
"type": "to",
|
||||
},
|
||||
},
|
||||
result.report.residual_file_disposition,
|
||||
)
|
||||
normal, residual = result.report.messages
|
||||
self.assertEqual(normal.validation_status.value, "ready")
|
||||
self.assertEqual(residual.entry_id, "__residual_files__")
|
||||
self.assertEqual(residual.validation_status.value, "needs_review")
|
||||
self.assertEqual(residual.to[0].email, "operator@example.org")
|
||||
self.assertEqual(residual.subject, "Residual files for Monthly import")
|
||||
self.assertEqual(residual.attachment_count, expected_attachment_count)
|
||||
self.assertIn(
|
||||
"residual_attachment_disposition",
|
||||
{issue.code for issue in residual.issues},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"unsent_attachment_files",
|
||||
{issue.code for message in result.report.messages for issue in message.issues},
|
||||
)
|
||||
mime = result.built_messages[1].mime
|
||||
self.assertIsNotNone(mime)
|
||||
self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content())
|
||||
filenames = [part.get_filename() for part in mime.iter_attachments()]
|
||||
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
|
||||
|
||||
def test_residual_file_policy_evidence_normalizes_block_and_ignore(self) -> None:
|
||||
for behavior, action in (("block", "block"), ("continue", "ignore")):
|
||||
with self.subTest(behavior=behavior), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
watched = root / "watched"
|
||||
watched.mkdir()
|
||||
(watched / "residual.txt").write_text("residual", encoding="utf-8")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {"id": f"residual-{behavior}", "name": "Residual", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"profile_capabilities": {"smtp_available": True},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
},
|
||||
"template": {"subject": "Normal", "text": "Body"},
|
||||
"attachments": {
|
||||
"base_paths": [{
|
||||
"id": "watched",
|
||||
"name": "Watched folder",
|
||||
"path": "watched",
|
||||
"unsent_warning": True,
|
||||
}],
|
||||
},
|
||||
"entries": {"inline": [{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
}]},
|
||||
"validation_policy": {"unsent_attachment_files": behavior},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=False,
|
||||
)
|
||||
|
||||
self.assertEqual(action, result.report.residual_file_disposition["action"])
|
||||
self.assertEqual(1, result.report.residual_file_disposition["residual_file_count"])
|
||||
issue_codes = {
|
||||
issue.code
|
||||
for message in result.report.messages
|
||||
for issue in message.issues
|
||||
}
|
||||
self.assertEqual(behavior == "block", "unsent_attachment_files" in issue_codes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
BuildStatus,
|
||||
CampaignConfig,
|
||||
SendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import BuiltMessage
|
||||
from govoplan_campaign.backend.messages.models import (
|
||||
ImapStatus,
|
||||
MessageAddress,
|
||||
MessageDraft,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
_prepare_built_calendar_invitations,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.jobs import _mark_accepted_job_artifacts
|
||||
from govoplan_core.core.calendar import CalendarInvitationRef
|
||||
|
||||
|
||||
def _config() -> CampaignConfig:
|
||||
return CampaignConfig.model_validate(
|
||||
{
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": "campaign-1",
|
||||
"name": "Invitation campaign",
|
||||
"mode": "send",
|
||||
},
|
||||
"template": {"subject": "Planning", "text": "Please reply."},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "recipient-1",
|
||||
"name": "Ada",
|
||||
"to": [{"email": "ada@example.test", "name": "Ada"}],
|
||||
"fields": {"appointment_start": "2026-08-05T09:00:00"},
|
||||
}
|
||||
]
|
||||
},
|
||||
"delivery": {
|
||||
"channel_policy": "mail",
|
||||
"calendar_invitation": {
|
||||
"enabled": True,
|
||||
"calendar_id": "calendar-1",
|
||||
"summary_template": "Planning with {{name}}",
|
||||
"start_at_template": "{{appointment_start}}",
|
||||
"end_at_template": "2026-08-05T10:00:00",
|
||||
"timezone": "Europe/Berlin",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _CalendarIntegration:
|
||||
available = True
|
||||
|
||||
def request_from_payload(self, payload):
|
||||
return payload
|
||||
|
||||
def render_invitation(self, request):
|
||||
assert request["correlation_id"].startswith("campaign:version-1:")
|
||||
return "\r\n".join(
|
||||
(
|
||||
"BEGIN:VCALENDAR",
|
||||
"VERSION:2.0",
|
||||
"METHOD:REQUEST",
|
||||
"BEGIN:VEVENT",
|
||||
"UID:invitation-1@govoplan.local",
|
||||
"SUMMARY:Planning with Ada",
|
||||
"END:VEVENT",
|
||||
"END:VCALENDAR",
|
||||
"",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _MirroringIntegration:
|
||||
available = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.upsert_count = 0
|
||||
|
||||
def request_from_payload(self, payload):
|
||||
return SimpleNamespace(
|
||||
metadata=payload.get("metadata") or {},
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def upsert_invitation(self, _session, **_kwargs):
|
||||
self.upsert_count += 1
|
||||
return CalendarInvitationRef(
|
||||
event_id="event-1",
|
||||
calendar_id="calendar-1",
|
||||
uid="invitation-1@govoplan.local",
|
||||
correlation_id="campaign:version-1:entry-1",
|
||||
source_module="campaigns",
|
||||
source_resource_type="campaign_version",
|
||||
source_resource_id="version-1",
|
||||
)
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self) -> None:
|
||||
self.added: list[object] = []
|
||||
|
||||
def add(self, value) -> None:
|
||||
self.added.append(value)
|
||||
|
||||
|
||||
class CampaignCalendarInvitationTests(unittest.TestCase):
|
||||
def test_validation_requires_calendar_capability(self) -> None:
|
||||
report = validate_campaign_config(_config(), calendar_available=False)
|
||||
|
||||
self.assertIn(
|
||||
"calendar_invitation_unavailable",
|
||||
{issue.code for issue in report.issues},
|
||||
)
|
||||
|
||||
def test_build_freezes_individual_request_and_ics_attachment(self) -> None:
|
||||
config = _config()
|
||||
entry = config.entries.inline[0] # type: ignore[index]
|
||||
message = EmailMessage()
|
||||
message["From"] = "Organizer <organizer@example.test>"
|
||||
message["To"] = "Ada <ada@example.test>"
|
||||
message["Subject"] = "Planning"
|
||||
message.set_content("Please reply.")
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
eml_path = Path(temp_dir) / "message.eml"
|
||||
eml_path.write_bytes(bytes(message))
|
||||
draft = MessageDraft(
|
||||
entry_index=1,
|
||||
entry_id="recipient-1",
|
||||
active=True,
|
||||
build_status=BuildStatus.BUILT,
|
||||
validation_status=MessageValidationStatus.READY,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=ImapStatus.NOT_REQUESTED,
|
||||
subject="Planning",
|
||||
**{
|
||||
"from": MessageAddress(
|
||||
email="organizer@example.test",
|
||||
name="Organizer",
|
||||
)
|
||||
},
|
||||
to=[MessageAddress(email="ada@example.test", name="Ada")],
|
||||
eml_path=str(eml_path),
|
||||
)
|
||||
provenance: dict[int, dict[str, object]] = {1: {}}
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.persistence.campaigns.calendar_integration",
|
||||
return_value=_CalendarIntegration(),
|
||||
):
|
||||
_prepare_built_calendar_invitations(
|
||||
version=SimpleNamespace(
|
||||
id="version-1",
|
||||
campaign_id="campaign-1",
|
||||
),
|
||||
config=config,
|
||||
built_messages=[BuiltMessage(draft=draft, mime=message)],
|
||||
entries_by_index={1: entry},
|
||||
delivery_provenance_by_index=provenance,
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
parsed = BytesParser(policy=policy.default).parsebytes(
|
||||
eml_path.read_bytes()
|
||||
)
|
||||
attachments = list(parsed.iter_attachments())
|
||||
self.assertEqual("text/calendar", attachments[0].get_content_type())
|
||||
self.assertEqual("REQUEST", attachments[0].get_param("method"))
|
||||
invitation = provenance[1]["calendar_invitation"]
|
||||
self.assertEqual("prepared", invitation["state"])
|
||||
self.assertEqual(
|
||||
"2026-08-05T09:00:00+02:00",
|
||||
invitation["request"]["start_at"],
|
||||
)
|
||||
|
||||
def test_delivery_acceptance_mirrors_once_without_reopening_delivery(self) -> None:
|
||||
integration = _MirroringIntegration()
|
||||
job = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
delivery_provenance={
|
||||
"calendar_invitation": {
|
||||
"state": "prepared",
|
||||
"request": {
|
||||
"metadata": {"prepared_by_user_id": "user-1"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
session = _Session()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.calendar_integration",
|
||||
return_value=integration,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.files_integration"
|
||||
),
|
||||
):
|
||||
_mark_accepted_job_artifacts(session, job)
|
||||
_mark_accepted_job_artifacts(session, job)
|
||||
|
||||
invitation = job.delivery_provenance["calendar_invitation"]
|
||||
self.assertEqual("mirrored", invitation["state"])
|
||||
self.assertEqual("event-1", invitation["event_id"])
|
||||
self.assertEqual(1, integration.upsert_count)
|
||||
self.assertEqual([job], session.added)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -6,8 +6,11 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_campaign.backend import router
|
||||
from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.routes import campaigns as campaign_routes
|
||||
from govoplan_campaign.backend.routes import versions as version_routes
|
||||
from govoplan_campaign.backend.schemas import CampaignUpdateRequest, CampaignVersionUpdateRequest
|
||||
from govoplan_core.core.concurrency import strong_resource_etag
|
||||
|
||||
|
||||
def _principal() -> SimpleNamespace:
|
||||
@@ -26,6 +29,7 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
raw_json={},
|
||||
current_flow="manual",
|
||||
current_step="recipients",
|
||||
edit_revision=1,
|
||||
)
|
||||
|
||||
def mutate(*_args, **kwargs):
|
||||
@@ -34,18 +38,26 @@ def test_version_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
return version
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal"),
|
||||
patch.object(router, "_get_version_for_tenant", return_value=version),
|
||||
patch.object(router, "update_campaign_version", side_effect=mutate),
|
||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
patch.object(route_support, "_get_campaign_for_principal"),
|
||||
patch.object(route_support, "_get_version_for_tenant", return_value=version),
|
||||
patch.object(route_support, "update_campaign_version", side_effect=mutate),
|
||||
patch.object(route_support, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
):
|
||||
with pytest.raises(HTTPException, match="audit unavailable") as captured:
|
||||
router._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
||||
route_support._update_campaign_version_detail_response( # noqa: SLF001 - transaction regression test
|
||||
session,
|
||||
principal, # type: ignore[arg-type]
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
CampaignVersionUpdateRequest(current_step="recipients"),
|
||||
CampaignVersionUpdateRequest(
|
||||
current_step="recipients",
|
||||
base_revision=1,
|
||||
),
|
||||
if_match=strong_resource_etag(
|
||||
"campaign_version",
|
||||
"version-1",
|
||||
1,
|
||||
),
|
||||
autosave=True,
|
||||
audit_action="campaign.version_autosaved",
|
||||
)
|
||||
@@ -68,15 +80,15 @@ def test_version_fork_rolls_back_when_its_audit_record_cannot_be_written() -> No
|
||||
return forked
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(router, "_require_permission"),
|
||||
patch.object(router, "_get_version_for_tenant", return_value=source),
|
||||
patch.object(router, "_get_campaign_for_tenant", return_value=campaign),
|
||||
patch.object(router, "fork_campaign_version_for_edit", side_effect=mutate),
|
||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
patch.object(version_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(version_routes, "_require_permission"),
|
||||
patch.object(version_routes, "_get_version_for_tenant", return_value=source),
|
||||
patch.object(version_routes, "_get_campaign_for_tenant", return_value=campaign),
|
||||
patch.object(version_routes, "fork_campaign_version_for_edit", side_effect=mutate),
|
||||
patch.object(version_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||
router.fork_version_for_edit(
|
||||
version_routes.fork_version_for_edit(
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
CampaignVersionUpdateRequest(),
|
||||
@@ -102,12 +114,12 @@ def test_metadata_update_rolls_back_when_its_audit_record_cannot_be_written() ->
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(router, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(router, "_sync_campaign_metadata_to_current_version"),
|
||||
patch.object(router, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
patch.object(campaign_routes, "_get_campaign_for_principal", return_value=campaign),
|
||||
patch.object(campaign_routes, "_sync_campaign_metadata_to_current_version"),
|
||||
patch.object(campaign_routes, "audit_from_principal", side_effect=RuntimeError("audit unavailable")),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="audit unavailable"):
|
||||
router.update_campaign_metadata_endpoint(
|
||||
campaign_routes.update_campaign_metadata_endpoint(
|
||||
"campaign-1",
|
||||
CampaignUpdateRequest(name="New name"),
|
||||
session=session,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user