Compare commits
20
Commits
bf6e07f307
...
v0.1.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0efe661f | ||
|
|
6bde11a286 | ||
|
|
585493fe7a | ||
|
|
4133de86cd | ||
|
|
9137300780 | ||
|
|
f98fe06143 | ||
|
|
dc63e35550 | ||
|
|
2dac5570cd | ||
|
|
91890fdaf5 | ||
|
|
8f5231147d | ||
|
|
df5a93d6a3 | ||
|
|
3f3545f080 | ||
|
|
d9195a2d2b | ||
|
|
d635f3a5fc | ||
|
|
1d6c745991 | ||
|
|
5df26be074 | ||
|
|
50ce8b0acb | ||
|
|
9da03090a7 | ||
|
|
dd09b06c47 | ||
|
|
c6bbdae2e1 |
@@ -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
|
||||
@@ -34,8 +34,13 @@ 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, and retention keeps database references when object
|
||||
deletion fails so cleanup can be retried.
|
||||
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
|
||||
|
||||
|
||||
@@ -31,6 +31,31 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -65,6 +65,24 @@ Before the first live send for a sender domain or mail-server profile:
|
||||
|
||||
## 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.
|
||||
@@ -145,11 +163,17 @@ before attempting delivery.
|
||||
business fields.
|
||||
- A build failure deletes objects written before the database transaction can
|
||||
commit.
|
||||
- Retention clears metadata only after object deletion succeeds; an unavailable
|
||||
backend leaves the reference in place for a later retry.
|
||||
- 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. Reconcile only within the Campaign build prefix and verify that
|
||||
no job references the object before deleting it.
|
||||
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.
|
||||
|
||||
|
||||
@@ -96,6 +96,18 @@ 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
|
||||
|
||||
@@ -112,6 +124,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,
|
||||
@@ -141,6 +159,14 @@ just the authoring form:
|
||||
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
|
||||
@@ -213,6 +239,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;
|
||||
@@ -275,6 +303,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.
|
||||
@@ -389,6 +419,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.
|
||||
@@ -424,6 +460,15 @@ 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
|
||||
@@ -524,6 +569,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
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"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.15",
|
||||
"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.12"
|
||||
version = "0.1.15"
|
||||
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.14",
|
||||
"govoplan-core>=0.1.15",
|
||||
"jsonschema>=4,<5",
|
||||
"pydantic>=2,<3",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -0,0 +1,234 @@
|
||||
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,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope
|
||||
|
||||
|
||||
POLICY_ID = "campaign.lifecycle"
|
||||
POLICY_VERSION = "1"
|
||||
|
||||
_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()
|
||||
)
|
||||
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,
|
||||
"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),
|
||||
"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_share_ids": [share.id for share in shares],
|
||||
"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)
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
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.",
|
||||
)
|
||||
|
||||
copy = LifecycleDecision(True)
|
||||
if not has_scope(principal, "campaigns:campaign:copy"):
|
||||
copy = LifecycleDecision(False, "Missing campaign copy permission.")
|
||||
elif not has_scope(principal, "campaigns:recipient:read"):
|
||||
copy = LifecycleDecision(False, "Recipient read permission is required to copy a campaign.")
|
||||
elif version_id is not None and selected_version is None:
|
||||
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",
|
||||
"optimistic_concurrency",
|
||||
),
|
||||
"evidence_retention": "Versions, 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."
|
||||
)
|
||||
@@ -690,6 +690,10 @@ def policy_context_capability(context: object) -> CampaignPolicyContextService:
|
||||
|
||||
|
||||
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
|
||||
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
|
||||
job = session.get(CampaignJob, job_id) # type: ignore[attr-defined]
|
||||
return job.tenant_id if job is not None else None
|
||||
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||
|
||||
|
||||
@@ -247,6 +247,16 @@ 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")
|
||||
|
||||
|
||||
@@ -159,6 +159,31 @@ 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 generated identifier and one editable version. Delivery jobs, outcomes, explicit shares, locks, and audit evidence stay exclusively with the source campaign.",
|
||||
order=32,
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
required_scopes=("campaigns:campaign:read", "campaigns:campaign:copy", "campaigns:recipient:read"),
|
||||
route="/campaigns/{campaign_id}",
|
||||
screen="Campaign overview",
|
||||
help_contexts=("campaign.overview",),
|
||||
prerequisites=(
|
||||
"You may read the selected campaign and its recipient configuration.",
|
||||
"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 and review the evidence-isolation consequence.",
|
||||
"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 configuration from the selected version and no copied operational evidence.",
|
||||
verification="The destination has a distinct campaign ID and owner, one editable version, and no source jobs, outcomes, shares, or locks.",
|
||||
related_topic_ids=("campaigns.workflow.create-editable-successor", "campaigns.workflow.prepare-validate-and-build"),
|
||||
),
|
||||
_workflow_topic(
|
||||
topic_id="campaigns.workflow.import-recipients",
|
||||
title="Import recipients into a campaign",
|
||||
@@ -569,14 +594,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.",
|
||||
),
|
||||
),
|
||||
@@ -597,7 +621,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.",
|
||||
),
|
||||
@@ -605,10 +629,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"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,12 +55,14 @@ from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
)
|
||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
from govoplan_campaign.backend.documentation import (
|
||||
CAMPAIGN_USER_DOCUMENTATION,
|
||||
documentation_topics,
|
||||
)
|
||||
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
@@ -358,7 +360,7 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.12",
|
||||
version="0.1.15",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -378,6 +380,7 @@ manifest = ModuleManifest(
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
@@ -481,12 +484,24 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_campaigns_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="campaigns.campaigns",
|
||||
factory=create_campaign_search_source,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/campaigns",
|
||||
@@ -506,6 +521,14 @@ manifest = ModuleManifest(
|
||||
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||
order=20,
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/operator",
|
||||
component="OperatorQueueRedirect",
|
||||
required_all=("campaigns:campaign:read",),
|
||||
required_any=OPERATOR_QUEUE_REQUIRED_ANY,
|
||||
order=21,
|
||||
surface_id="campaigns.route.operator-redirect",
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/campaigns/queue",
|
||||
component="OperatorQueuePage",
|
||||
@@ -527,7 +550,6 @@ manifest = ModuleManifest(
|
||||
order=22,
|
||||
surface_id=REPORTS_SURFACE_ID,
|
||||
),
|
||||
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
@@ -537,9 +559,6 @@ manifest = ModuleManifest(
|
||||
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||
order=20,
|
||||
),
|
||||
NavItem(
|
||||
path="/templates", label="Templates", icon="layout-template", order=90
|
||||
),
|
||||
),
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
@@ -596,6 +615,23 @@ manifest = ModuleManifest(
|
||||
),
|
||||
documentation=(
|
||||
*CAMPAIGN_USER_DOCUMENTATION,
|
||||
DocumentationTopic(
|
||||
id="campaigns.search.campaigns",
|
||||
title="Search authorized campaigns",
|
||||
summary="Expose campaign identity and lifecycle metadata to permission-aware platform Search.",
|
||||
body=(
|
||||
"When Search is installed, Campaign contributes current campaign names, external identifiers, "
|
||||
"descriptions, and lifecycle state. Search rechecks tenant ownership, group ownership, explicit "
|
||||
"shares, revocation, deletion, and the Campaign read permission before returning a result. "
|
||||
"Committed Campaign and share changes update the derived index through the durable platform event "
|
||||
"path; rebuilding Search never changes Campaign evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("campaign_manager", "campaign_operator", "administrator"),
|
||||
related_modules=("search",),
|
||||
order=44,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.postbox-delivery",
|
||||
title="Deliver Campaign messages to Postboxes",
|
||||
@@ -760,7 +796,7 @@ manifest = ModuleManifest(
|
||||
id="campaigns.mail-profile-operations",
|
||||
title="Operate profile-backed campaign delivery",
|
||||
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation.",
|
||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||
@@ -808,7 +844,7 @@ manifest = ModuleManifest(
|
||||
id="campaigns.workflow.prepare-validate-and-build",
|
||||
title="Prepare, validate, and build a campaign",
|
||||
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build.",
|
||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
@@ -836,7 +872,7 @@ manifest = ModuleManifest(
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("addresses", "files", "mail"),
|
||||
related_modules=("addresses", "files", "mail", "templates"),
|
||||
unlocks=(
|
||||
"A reviewable build whose exact recipient-specific effects can be inspected before delivery.",
|
||||
),
|
||||
@@ -864,8 +900,8 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Set campaign-wide fields and purpose, then define the recipient fields and templates.",
|
||||
"Import or select recipients and inspect provenance, exclusions, and review-required rows.",
|
||||
"Select managed attachment versions and an authorized Mail profile when those capabilities are used.",
|
||||
"Import or select recipients, optionally confirm a counted activate-all or deactivate-all draft action, and inspect provenance, exclusions, and review-required rows.",
|
||||
"Select managed attachment versions and an authorized Mail profile when those capabilities are used; generate a new password only when creating a campaign credential or password-valued field, then explicitly confirm the candidate.",
|
||||
"Validate the relevant sections and resolve every blocker without hiding warnings.",
|
||||
"Build the selected version and inspect representative and exceptional rendered messages.",
|
||||
],
|
||||
@@ -881,8 +917,8 @@ manifest = ModuleManifest(
|
||||
DocumentationTopic(
|
||||
id="campaigns.workflow.complete-review",
|
||||
title="Inspect built messages and complete review",
|
||||
summary="Review the exact immutable candidate and record which built messages were inspected before delivery is enabled.",
|
||||
body="Review completion records the inspected message keys for the selected build. The current baseline does not persist a separate approve/reject decision or review reason, so do not present completion as a richer decision record. Any material input or non-secret transport-identity change requires validation and a new build.",
|
||||
summary="Resolve critical blockers, record individual message decisions, and acknowledge non-critical review items for one exact build.",
|
||||
body="Review completion remains bound to the current build token, inspected message keys, recorded issue decisions, and message evidence. Changing recipients, content, attachments, owner context, or non-secret transport identity requires validation, building, and review again.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_reviewer",),
|
||||
@@ -918,13 +954,14 @@ manifest = ModuleManifest(
|
||||
"You may read the campaign and complete its review.",
|
||||
],
|
||||
"steps": [
|
||||
"Confirm the campaign, owner, selected version, recipient count, warnings, and exclusions.",
|
||||
"Inspect representative and exceptional messages, addressing, templates, and attachment evidence.",
|
||||
"Confirm that the selected Mail profile is suitable and authorized for the current context.",
|
||||
"Record review completion for the exact message keys inspected.",
|
||||
"Open Review and send and inspect the Critical blockers, Individual review, and Group review summaries.",
|
||||
"Correct every critical blocker in the named campaign workspace, then validate and build again.",
|
||||
"Open each remaining individual review message and record its decision.",
|
||||
"When only non-critical group items remain, review their conditions and explicitly complete review.",
|
||||
"Confirm that Reviewed equals the required review total and Remaining is zero before delivery.",
|
||||
],
|
||||
"outcome": "The reviewed build is eligible for a separately authorized queue or send action.",
|
||||
"verification": "Reload Review and confirm completion is tied to the same version and message build; changed inputs must invalidate or supersede it.",
|
||||
"outcome": "Review evidence for the exact current build, with no unresolved blocker or review decision.",
|
||||
"verification": "Reload Review and send, confirm no critical blocker or remaining decision, and verify that the permitted delivery mode is unlocked for the same version and build.",
|
||||
"related_topic_ids": [
|
||||
"campaigns.workflow.prepare-validate-and-build",
|
||||
"campaigns.workflow.retry-and-reconcile",
|
||||
@@ -1052,7 +1089,7 @@ manifest = ModuleManifest(
|
||||
id="campaigns.reference.shared-build-artifacts",
|
||||
title="Operate Campaign build artifacts across workers",
|
||||
summary="Generated messages use shared object storage and are verified before delivery.",
|
||||
body="Campaign stores generated EML under opaque shared object keys and records expected size, SHA-256 digest, and Message-ID in each job. A worker may run on another node and verifies that evidence before delivery. Failed builds compensate newly written objects; retention retains metadata when deletion fails. Never copy or edit runtime object keys as business data.",
|
||||
body="Campaign stores generated EML under opaque shared object keys and records expected size, SHA-256 digest, and Message-ID in each job. A worker may run on another node and verifies that evidence before delivery. Before object or Files-owned output effects, a fenced Core recovery operation records the canonical source, validated-version evidence, and reserved object prefix, then renews its fence after object verification and before domain commit. Before a real Mail, Postbox, or print effect, a separate job-fenced operation records immutable message and recipient digests and later verifies the authoritative Campaign/channel attempt state. Definitive rejection is distinct from accepted, outcome-unknown, and recovery-required work in Ops. Object-only failures prove compensation; managed Files output and uncertain cleanup remain explicit forward-recovery work. Retention commits locator changes and independently verifies artifact absence. An operator-only reconciler inventories bounded tenant-prefix pages, protects active builds and a minimum 24-hour grace period, and deletes only objects that remain unreferenced. Never copy or edit runtime object keys as business data.",
|
||||
layer="evidence",
|
||||
documentation_types=("admin",),
|
||||
audience=("campaign_operator", "platform_operator", "release_reviewer"),
|
||||
@@ -1074,15 +1111,20 @@ manifest = ModuleManifest(
|
||||
href="govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md",
|
||||
kind="repository",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Campaign build recovery",
|
||||
href="govoplan-campaign/docs/CAMPAIGN_BUILD_RECOVERY.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("files", "ops", "mail"),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/campaigns/queue",
|
||||
"screen": "Campaign operator queue",
|
||||
"verification": "Build on one replica, deliver from another, verify object digest/size evidence, and exercise storage failure during build and retention.",
|
||||
"verification": "Build on one replica, verify the recovery checkpoint chain and database/object manifests, deliver from another, then exercise process loss, dry-run/apply orphan reconciliation, stale fencing, storage failure, and retention cleanup.",
|
||||
"limitations": [
|
||||
"A hard process loss between object creation and database commit can leave an orphan object until an inventory reconciler removes it.",
|
||||
"Artifact inventory and cleanup are currently an operator API/runbook action rather than an Ops WebUI control.",
|
||||
"Database, object storage, and encryption keys require a coordinated deployment backup and restore procedure.",
|
||||
],
|
||||
},
|
||||
@@ -1172,7 +1214,10 @@ manifest = ModuleManifest(
|
||||
"durable address directory",
|
||||
"file storage",
|
||||
),
|
||||
recovery_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
|
||||
recovery_docs=(
|
||||
"docs/CAMPAIGN_DELIVERY_RUNBOOK.md",
|
||||
"docs/CAMPAIGN_BUILD_RECOVERY.md",
|
||||
),
|
||||
security_docs=("docs/ACCESS_EXPLANATION_COVERAGE.md",),
|
||||
operations_docs=("docs/CAMPAIGN_DELIVERY_RUNBOOK.md",),
|
||||
),
|
||||
|
||||
+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")
|
||||
+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")
|
||||
@@ -22,6 +22,8 @@ from govoplan_core.core.object_storage import (
|
||||
StorageBackendError,
|
||||
configured_storage_backend,
|
||||
)
|
||||
from govoplan_core.core.recovery import RecoveryStatus
|
||||
from govoplan_core.core.recovery_runtime import DurableRecoveryOperation
|
||||
from govoplan_core.core.templates import TemplateRenderRequest
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
@@ -160,6 +162,7 @@ def _persist_built_eml_artifacts(
|
||||
version_id: str,
|
||||
build_id: str,
|
||||
built_messages: list[Any],
|
||||
written_storage_keys: list[str] | None = None,
|
||||
) -> dict[int, _StoredEmlArtifact]:
|
||||
artifacts: dict[int, _StoredEmlArtifact] = {}
|
||||
try:
|
||||
@@ -196,6 +199,8 @@ def _persist_built_eml_artifacts(
|
||||
sha256=digest,
|
||||
message_id_header=str(message_id) if message_id else None,
|
||||
)
|
||||
if written_storage_keys is not None:
|
||||
written_storage_keys.append(storage_key)
|
||||
message.eml_path = None
|
||||
message.eml_size_bytes = len(payload)
|
||||
except Exception:
|
||||
@@ -207,14 +212,16 @@ def _persist_built_eml_artifacts(
|
||||
return artifacts
|
||||
|
||||
|
||||
def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None:
|
||||
def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> list[str]:
|
||||
failed: list[str] = []
|
||||
for key in keys:
|
||||
try:
|
||||
storage.delete(key)
|
||||
except StorageBackendError:
|
||||
# A committed build remains authoritative. Reconciliation can
|
||||
# remove an orphaned superseded object later.
|
||||
continue
|
||||
# Keep the failure observable so the caller can require recovery
|
||||
# instead of claiming that best-effort deletion compensated it.
|
||||
failed.append(key)
|
||||
return failed
|
||||
|
||||
|
||||
def _next_version_number(session: Session, campaign_id: str) -> int:
|
||||
@@ -1135,6 +1142,7 @@ def _resolve_built_print_outputs(
|
||||
config: CampaignConfig,
|
||||
built_messages: list[Any],
|
||||
entries_by_index: dict[int, Any],
|
||||
written_storage_keys: list[str] | None = None,
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
printable: list[tuple[Any, Any, dict[str, Any]]] = []
|
||||
for built in built_messages:
|
||||
@@ -1229,6 +1237,8 @@ def _resolve_built_print_outputs(
|
||||
raise CampaignPersistenceError(
|
||||
f"Printable Campaign output could not be persisted: {exc}"
|
||||
) from exc
|
||||
if written_storage_keys is not None:
|
||||
written_storage_keys.append(storage_key)
|
||||
artifact["storage_key"] = storage_key
|
||||
artifact["download_path"] = (
|
||||
f"/api/v1/campaigns/{version.campaign_id}/versions/{version.id}/"
|
||||
@@ -1471,6 +1481,123 @@ def _apply_campaign_build_state(
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
|
||||
def _build_storage_expectations(
|
||||
*,
|
||||
stored_eml_by_index: dict[int, _StoredEmlArtifact],
|
||||
print_outputs_by_index: dict[int, dict[str, Any]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
expectations = {
|
||||
artifact.storage_key: {
|
||||
"size_bytes": artifact.size_bytes,
|
||||
"sha256": artifact.sha256,
|
||||
"kind": "message/rfc822",
|
||||
}
|
||||
for artifact in stored_eml_by_index.values()
|
||||
}
|
||||
for output in print_outputs_by_index.values():
|
||||
artifact = output.get("artifact") if isinstance(output, dict) else None
|
||||
if not isinstance(artifact, dict) or not artifact.get("storage_key"):
|
||||
continue
|
||||
expectations[str(artifact["storage_key"])] = {
|
||||
"size_bytes": int(output.get("output_size_bytes") or 0),
|
||||
"sha256": str(output.get("output_sha256") or ""),
|
||||
"kind": "print-output",
|
||||
}
|
||||
return expectations
|
||||
|
||||
|
||||
def _verify_build_storage_manifest(
|
||||
storage: StorageBackend,
|
||||
expectations: dict[str, dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
manifest: list[dict[str, Any]] = []
|
||||
total_bytes = 0
|
||||
for key in sorted(expectations):
|
||||
expected = expectations[key]
|
||||
expected_size = int(expected["size_bytes"])
|
||||
expected_sha256 = str(expected["sha256"])
|
||||
try:
|
||||
info = storage.stat(key)
|
||||
digest = hashlib.sha256()
|
||||
observed_size = 0
|
||||
for chunk in storage.iter_bytes(key):
|
||||
digest.update(chunk)
|
||||
observed_size += len(chunk)
|
||||
except StorageBackendError as exc:
|
||||
raise CampaignPersistenceError(
|
||||
"A generated Campaign artifact could not be verified in shared storage"
|
||||
) from exc
|
||||
observed_sha256 = digest.hexdigest()
|
||||
if (
|
||||
info.size_bytes != expected_size
|
||||
or observed_size != expected_size
|
||||
or observed_sha256 != expected_sha256
|
||||
):
|
||||
raise CampaignPersistenceError(
|
||||
"A generated Campaign artifact does not match its build evidence"
|
||||
)
|
||||
manifest.append(
|
||||
{
|
||||
"key_sha256": hashlib.sha256(key.encode("utf-8")).hexdigest(),
|
||||
"size_bytes": expected_size,
|
||||
"sha256": expected_sha256,
|
||||
"kind": expected["kind"],
|
||||
}
|
||||
)
|
||||
total_bytes += expected_size
|
||||
return {
|
||||
"object_count": len(manifest),
|
||||
"total_bytes": total_bytes,
|
||||
"manifest_sha256": _canonical_sha256(manifest),
|
||||
}
|
||||
|
||||
|
||||
def _verify_storage_keys_absent(
|
||||
storage: StorageBackend,
|
||||
keys: list[str],
|
||||
) -> tuple[bool, int]:
|
||||
remaining = 0
|
||||
try:
|
||||
for key in sorted(set(keys)):
|
||||
if storage.exists(key):
|
||||
remaining += 1
|
||||
except StorageBackendError:
|
||||
return False, max(1, remaining)
|
||||
return remaining == 0, remaining
|
||||
|
||||
|
||||
def _persisted_build_manifest(
|
||||
session: Session,
|
||||
*,
|
||||
version_id: str,
|
||||
expected_storage_keys: list[str],
|
||||
) -> dict[str, Any]:
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version_id)
|
||||
.order_by(CampaignJob.entry_index)
|
||||
.all()
|
||||
)
|
||||
persisted_keys: set[str] = set()
|
||||
for job in jobs:
|
||||
if job.eml_storage_key:
|
||||
persisted_keys.add(str(job.eml_storage_key))
|
||||
output = job.resolved_print_output
|
||||
artifact = output.get("artifact") if isinstance(output, dict) else None
|
||||
if isinstance(artifact, dict) and artifact.get("storage_key"):
|
||||
persisted_keys.add(str(artifact["storage_key"]))
|
||||
expected_keys = set(expected_storage_keys)
|
||||
if persisted_keys != expected_keys:
|
||||
raise CampaignPersistenceError(
|
||||
"Persisted Campaign jobs do not match the generated artifact manifest"
|
||||
)
|
||||
return {
|
||||
"job_count": len(jobs),
|
||||
"referenced_object_count": len(persisted_keys),
|
||||
"reference_manifest_sha256": _canonical_sha256(sorted(persisted_keys)),
|
||||
}
|
||||
|
||||
|
||||
def build_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -1479,6 +1606,8 @@ def build_campaign_version(
|
||||
write_eml: bool = True,
|
||||
user_id: str | None = None,
|
||||
principal: ApiPrincipal | None = None,
|
||||
recovery_operation: DurableRecoveryOperation | None = None,
|
||||
build_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
@@ -1502,99 +1631,122 @@ def build_campaign_version(
|
||||
|
||||
files = files_integration()
|
||||
storage = _object_storage()
|
||||
build_id = uuid4().hex
|
||||
with TemporaryDirectory(prefix="govoplan-campaign-build-") as output_directory:
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
||||
include_bytes=True,
|
||||
prefix="govoplan-managed-build-",
|
||||
) as prepared:
|
||||
managed_raw = load_campaign_json(prepared.path)
|
||||
managed_config = load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=managed_raw,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
result = build_campaign_messages(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
output_dir=Path(output_directory),
|
||||
write_eml=write_eml,
|
||||
)
|
||||
files.annotate_built_messages_with_managed_files(
|
||||
result.built_messages,
|
||||
prepared.managed_files_by_local_path,
|
||||
)
|
||||
entries_by_index = {
|
||||
index: entry
|
||||
for index, entry in enumerate(
|
||||
load_campaign_entries(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
delivery_provenance_by_index = {
|
||||
index: dict(entry.distribution_source or {})
|
||||
for index, entry in entries_by_index.items()
|
||||
}
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
resolved_print_outputs_by_index = _resolve_built_print_outputs(
|
||||
session,
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
build_id=build_id,
|
||||
version=version,
|
||||
principal=principal,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
_prepare_built_calendar_invitations(
|
||||
version=version,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
delivery_provenance_by_index=delivery_provenance_by_index,
|
||||
user_id=user_id,
|
||||
)
|
||||
new_print_storage_keys = sorted(
|
||||
{
|
||||
str(artifact["storage_key"])
|
||||
for output in resolved_print_outputs_by_index.values()
|
||||
if isinstance(output, dict)
|
||||
for artifact in [output.get("artifact")]
|
||||
if isinstance(artifact, dict) and artifact.get("storage_key")
|
||||
}
|
||||
effective_build_id = build_id or uuid4().hex
|
||||
storage_prefix = (
|
||||
f"campaign-artifacts/{tenant_id}/{campaign.id}/{version.id}/"
|
||||
f"{effective_build_id}/"
|
||||
)
|
||||
written_storage_keys: list[str] = []
|
||||
new_storage_keys: list[str] = []
|
||||
old_storage_keys: list[str] = []
|
||||
domain_committed = False
|
||||
if recovery_operation is not None:
|
||||
recovery_operation.checkpoint(
|
||||
kind="object-prefix-reserved",
|
||||
summary="The Campaign build reserved its reconciliation prefix",
|
||||
evidence={
|
||||
"storage_prefix": storage_prefix,
|
||||
"campaign_version_id": version.id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
try:
|
||||
with TemporaryDirectory(prefix="govoplan-campaign-build-") as output_directory:
|
||||
with files.prepared_campaign_snapshot(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
raw_json=version.raw_json
|
||||
if isinstance(version.raw_json, dict)
|
||||
else {},
|
||||
include_bytes=True,
|
||||
prefix="govoplan-managed-build-",
|
||||
) as prepared:
|
||||
managed_raw = load_campaign_json(prepared.path)
|
||||
managed_config = load_campaign_config_from_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=managed_raw,
|
||||
campaign_id=campaign.id,
|
||||
)
|
||||
result = build_campaign_messages(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
output_dir=Path(output_directory),
|
||||
write_eml=write_eml,
|
||||
)
|
||||
files.annotate_built_messages_with_managed_files(
|
||||
result.built_messages,
|
||||
prepared.managed_files_by_local_path,
|
||||
)
|
||||
entries_by_index = {
|
||||
index: entry
|
||||
for index, entry in enumerate(
|
||||
load_campaign_entries(
|
||||
managed_config,
|
||||
campaign_file=prepared.path,
|
||||
),
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
delivery_provenance_by_index = {
|
||||
index: dict(entry.distribution_source or {})
|
||||
for index, entry in entries_by_index.items()
|
||||
}
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
resolved_print_outputs_by_index = _resolve_built_print_outputs(
|
||||
session,
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
build_id=effective_build_id,
|
||||
version=version,
|
||||
principal=principal,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
written_storage_keys=written_storage_keys,
|
||||
)
|
||||
_prepare_built_calendar_invitations(
|
||||
version=version,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
delivery_provenance_by_index=delivery_provenance_by_index,
|
||||
user_id=user_id,
|
||||
)
|
||||
stored_eml_by_index = _persist_built_eml_artifacts(
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
build_id=build_id,
|
||||
build_id=effective_build_id,
|
||||
built_messages=result.built_messages,
|
||||
written_storage_keys=written_storage_keys,
|
||||
)
|
||||
new_storage_keys = sorted(set(written_storage_keys))
|
||||
storage_manifest = _verify_build_storage_manifest(
|
||||
storage,
|
||||
_build_storage_expectations(
|
||||
stored_eml_by_index=stored_eml_by_index,
|
||||
print_outputs_by_index=resolved_print_outputs_by_index,
|
||||
),
|
||||
)
|
||||
if recovery_operation is not None:
|
||||
recovery_operation.checkpoint(
|
||||
kind="build-storage-ready",
|
||||
summary=(
|
||||
"Generated Campaign objects were verified before domain commit"
|
||||
),
|
||||
evidence={
|
||||
"storage_prefix": storage_prefix,
|
||||
"storage": storage_manifest,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
_delete_storage_keys(storage, new_print_storage_keys)
|
||||
raise
|
||||
new_storage_keys = [
|
||||
*new_print_storage_keys,
|
||||
*(item.storage_key for item in stored_eml_by_index.values()),
|
||||
]
|
||||
try:
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
if resolved_print_outputs_by_index:
|
||||
@@ -1667,9 +1819,94 @@ def build_campaign_version(
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
_delete_storage_keys(storage, new_storage_keys)
|
||||
domain_committed = True
|
||||
|
||||
database_manifest = _persisted_build_manifest(
|
||||
session,
|
||||
version_id=version.id,
|
||||
expected_storage_keys=new_storage_keys,
|
||||
)
|
||||
superseded_delete_failures = _delete_storage_keys(storage, old_storage_keys)
|
||||
if recovery_operation is not None:
|
||||
recovery_operation.checkpoint(
|
||||
kind="build-manifest-verified",
|
||||
summary="Generated objects and committed Campaign rows were compared",
|
||||
evidence={
|
||||
"storage": storage_manifest,
|
||||
"database": database_manifest,
|
||||
"storage_prefix": storage_prefix,
|
||||
},
|
||||
)
|
||||
if superseded_delete_failures:
|
||||
recovery_operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="The new build committed but superseded objects require cleanup",
|
||||
evidence={
|
||||
"failed_object_count": len(superseded_delete_failures),
|
||||
"failed_manifest_sha256": _canonical_sha256(
|
||||
sorted(superseded_delete_failures)
|
||||
),
|
||||
},
|
||||
failure_summary=(
|
||||
"Campaign build succeeded, but superseded object cleanup "
|
||||
"requires reconciliation"
|
||||
),
|
||||
)
|
||||
else:
|
||||
recovery_operation.succeed(
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"storage_manifest": storage_manifest,
|
||||
"database_manifest": database_manifest,
|
||||
"superseded_cleanup": "complete",
|
||||
},
|
||||
}
|
||||
)
|
||||
return report_json
|
||||
except Exception as exc:
|
||||
if not domain_committed:
|
||||
session.rollback()
|
||||
deletion_failures = _delete_storage_keys(
|
||||
storage,
|
||||
sorted(set(written_storage_keys)),
|
||||
)
|
||||
absent, remaining_count = _verify_storage_keys_absent(
|
||||
storage,
|
||||
written_storage_keys,
|
||||
)
|
||||
if recovery_operation is not None and not recovery_operation.closed:
|
||||
if deletion_failures or not absent:
|
||||
recovery_operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="Campaign build compensation could not be verified",
|
||||
evidence={
|
||||
"storage_prefix": storage_prefix,
|
||||
"written_object_count": len(set(written_storage_keys)),
|
||||
"delete_failure_count": len(deletion_failures),
|
||||
"remaining_object_count": remaining_count,
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
failure_summary=(
|
||||
"Generated Campaign objects may remain after a failed build"
|
||||
),
|
||||
)
|
||||
else:
|
||||
recovery_operation.compensate(
|
||||
failure_summary="Campaign build failed before database commit",
|
||||
failure_evidence={
|
||||
"storage_prefix": storage_prefix,
|
||||
"written_object_count": len(set(written_storage_keys)),
|
||||
"exception_type": type(exc).__name__,
|
||||
},
|
||||
recovery_evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"database_transaction": "rolled-back",
|
||||
"written_objects_absent": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
elif recovery_operation is not None and not recovery_operation.closed:
|
||||
recovery_operation.release_unresolved()
|
||||
raise
|
||||
_delete_storage_keys(storage, old_storage_keys)
|
||||
return report_json
|
||||
|
||||
@@ -1762,7 +1762,10 @@ def generate_jobs_csv(
|
||||
buffer = io.StringIO()
|
||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
writer.writerows(
|
||||
{field: row.get(field) for field in fieldnames}
|
||||
for row in rows
|
||||
)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
|
||||
@@ -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,12 +10,26 @@ from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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, CampaignVersion, JobImapStatus, JobQueueStatus
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
@@ -36,6 +51,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
|
||||
@@ -113,6 +137,203 @@ 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,
|
||||
*,
|
||||
@@ -120,6 +341,7 @@ def _apply_eml_retention(
|
||||
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,
|
||||
@@ -127,6 +349,7 @@ def _apply_eml_retention(
|
||||
"files_deleted": 0,
|
||||
"files_missing": 0,
|
||||
"delete_failed": 0,
|
||||
"recovery_blocked": 0,
|
||||
"skipped_not_final": 0,
|
||||
}
|
||||
jobs = (
|
||||
@@ -153,10 +376,22 @@ def _apply_eml_retention(
|
||||
result["eligible"] += 1
|
||||
if dry_run:
|
||||
continue
|
||||
if job.eml_storage_key:
|
||||
active_storage = storage or configured_storage_backend(
|
||||
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)
|
||||
@@ -219,8 +454,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
|
||||
|
||||
@@ -6,6 +6,7 @@ from govoplan_campaign.backend.routes.attachments import router as attachments_r
|
||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
@@ -13,6 +14,7 @@ from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
|
||||
router = APIRouter()
|
||||
for workflow_router in (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
|
||||
@@ -23,6 +23,9 @@ from govoplan_campaign.backend.path_security import (
|
||||
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,
|
||||
@@ -324,7 +327,7 @@ def preview_campaign_attachments(
|
||||
include_unmatched=payload.include_unmatched,
|
||||
include_unlinked_candidates=payload.include_unlinked_candidates,
|
||||
)
|
||||
except CampaignPathSecurityError as exc:
|
||||
except (CampaignPathSecurityError, CampaignMailProfileBoundaryError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
@@ -12,6 +13,9 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignUpdateRequest,
|
||||
CampaignCreateResponse,
|
||||
CampaignCreateMinimalRequest,
|
||||
CampaignCopyRequest,
|
||||
CampaignLifecycleMutationRequest,
|
||||
CampaignLifecyclePolicyResponse,
|
||||
CampaignAddressLookupCandidate,
|
||||
CampaignAddressLookupResponse,
|
||||
CampaignCalendarCatalogResponse,
|
||||
@@ -56,13 +60,16 @@ from govoplan_campaign.backend.change_tracking import (
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
RecipientImportMappingProfile,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
delivery_catalog_payload,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.lifecycle import (
|
||||
assert_lifecycle_state_token,
|
||||
campaign_lifecycle_policy,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
calendar_integration,
|
||||
PostboxDeliveryUnavailable,
|
||||
@@ -120,6 +127,93 @@ CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
||||
|
||||
|
||||
def _lifecycle_policy_for_mutation(
|
||||
session: Session,
|
||||
*,
|
||||
campaign_id: str,
|
||||
principal: ApiPrincipal,
|
||||
expected_state_token: str,
|
||||
action: str,
|
||||
version_id: str | None = None,
|
||||
) -> tuple[Campaign, dict[str, object]]:
|
||||
_get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
campaign = (
|
||||
session.query(Campaign)
|
||||
.filter(
|
||||
Campaign.id == campaign_id,
|
||||
Campaign.tenant_id == principal.tenant_id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
policy = campaign_lifecycle_policy(
|
||||
session,
|
||||
campaign=campaign,
|
||||
principal=principal,
|
||||
version_id=version_id,
|
||||
)
|
||||
try:
|
||||
assert_lifecycle_state_token(
|
||||
str(policy["state_token"]),
|
||||
expected_state_token,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
decision = policy["actions"][action] # type: ignore[index]
|
||||
if not decision["allowed"]: # type: ignore[index]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=decision["reason"], # type: ignore[index]
|
||||
)
|
||||
return campaign, policy
|
||||
|
||||
|
||||
def _campaign_copy_external_id(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
source_external_id: str,
|
||||
requested: str | None,
|
||||
) -> str:
|
||||
if requested is not None:
|
||||
candidate = requested.strip()
|
||||
exists = (
|
||||
session.query(Campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == tenant_id,
|
||||
Campaign.external_id == candidate,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Campaign ID already exists for this tenant",
|
||||
)
|
||||
return candidate
|
||||
|
||||
stem = f"{source_external_id[:240]}-copy"
|
||||
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
|
||||
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
|
||||
exists = (
|
||||
session.query(Campaign.id)
|
||||
.filter(
|
||||
Campaign.tenant_id == tenant_id,
|
||||
Campaign.external_id == candidate,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists is None:
|
||||
return candidate
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="No available campaign copy identifier could be generated.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CampaignCreateResponse)
|
||||
def create_campaign(
|
||||
payload: CampaignCreateRequest,
|
||||
@@ -1524,18 +1618,180 @@ def update_campaign_metadata_endpoint(
|
||||
return CampaignResponse.model_validate(campaign)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/archive", response_model=CampaignResponse)
|
||||
def archive_campaign(
|
||||
@router.get(
|
||||
"/{campaign_id}/lifecycle-policy",
|
||||
response_model=CampaignLifecyclePolicyResponse,
|
||||
)
|
||||
def get_campaign_lifecycle_policy(
|
||||
campaign_id: str,
|
||||
version_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal)
|
||||
return campaign_lifecycle_policy(
|
||||
session,
|
||||
campaign=campaign,
|
||||
principal=principal,
|
||||
version_id=version_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/copies", response_model=CampaignCreateResponse)
|
||||
def copy_campaign(
|
||||
campaign_id: str,
|
||||
payload: CampaignCopyRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:copy")),
|
||||
):
|
||||
source_campaign, _policy = _lifecycle_policy_for_mutation(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
principal=principal,
|
||||
expected_state_token=payload.expected_state_token,
|
||||
action="copy_campaign",
|
||||
version_id=payload.source_version_id,
|
||||
)
|
||||
_require_permission(principal, "campaigns:recipient:read")
|
||||
source_version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == payload.source_version_id,
|
||||
CampaignVersion.campaign_id == source_campaign.id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if source_version is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Campaign version not found",
|
||||
)
|
||||
|
||||
external_id = _campaign_copy_external_id(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
source_external_id=source_campaign.external_id,
|
||||
requested=payload.external_id,
|
||||
)
|
||||
name = (payload.name or f"{source_campaign.name} (copy)").strip()
|
||||
raw_json = copy.deepcopy(source_version.raw_json)
|
||||
campaign_metadata = raw_json.get("campaign")
|
||||
if not isinstance(campaign_metadata, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The selected source version has no valid campaign metadata.",
|
||||
)
|
||||
campaign_metadata["id"] = external_id
|
||||
campaign_metadata["name"] = name
|
||||
campaign_metadata["mode"] = "draft"
|
||||
_require_mail_profile_use_if_needed(principal, raw_json)
|
||||
|
||||
try:
|
||||
campaign, version = create_campaign_version_from_json(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
user_id=principal.user.id,
|
||||
raw_json=raw_json,
|
||||
source_filename=None,
|
||||
source_base_path=source_version.source_base_path,
|
||||
commit=False,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.copied",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={
|
||||
"source_campaign_id": source_campaign.id,
|
||||
"source_version_id": source_version.id,
|
||||
"destination_version_id": version.id,
|
||||
"copied_evidence": False,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
except HTTPException:
|
||||
session.rollback()
|
||||
raise
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
session.refresh(campaign)
|
||||
session.refresh(version)
|
||||
return CampaignCreateResponse(
|
||||
campaign=CampaignResponse.model_validate(campaign),
|
||||
version=CampaignVersionResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{campaign_id}/versions/{version_id}/archive",
|
||||
response_model=CampaignVersionResponse,
|
||||
)
|
||||
def archive_campaign_version(
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
payload: CampaignLifecycleMutationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
if campaign.status in {"queued", "sending", "outcome_unknown"}:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Active or uncertain delivery must be resolved before archiving.",
|
||||
campaign, _policy = _lifecycle_policy_for_mutation(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
principal=principal,
|
||||
expected_state_token=payload.expected_state_token,
|
||||
action="archive_version",
|
||||
version_id=version_id,
|
||||
)
|
||||
version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(
|
||||
CampaignVersion.id == version_id,
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
)
|
||||
.with_for_update()
|
||||
.one()
|
||||
)
|
||||
version.archived_at = datetime.now(UTC)
|
||||
version.archived_by_user_id = principal.user.id
|
||||
session.add(version)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="campaign.version_archived",
|
||||
object_type="campaign_version",
|
||||
object_id=version.id,
|
||||
details={
|
||||
"campaign_id": campaign.id,
|
||||
"version_number": version.version_number,
|
||||
"retained_evidence": True,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
session.refresh(version)
|
||||
return CampaignVersionResponse.model_validate(
|
||||
version,
|
||||
context=_campaign_response_context(principal),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/archive", response_model=CampaignResponse)
|
||||
def archive_campaign(
|
||||
campaign_id: str,
|
||||
payload: CampaignLifecycleMutationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
|
||||
):
|
||||
campaign, _policy = _lifecycle_policy_for_mutation(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
principal=principal,
|
||||
expected_state_token=payload.expected_state_token,
|
||||
action="archive_campaign",
|
||||
)
|
||||
campaign.status = "archived"
|
||||
session.add(campaign)
|
||||
audit_from_principal(
|
||||
@@ -1554,43 +1810,17 @@ def archive_campaign(
|
||||
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_draft_campaign(
|
||||
campaign_id: str,
|
||||
payload: CampaignLifecycleMutationRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")),
|
||||
):
|
||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
||||
if campaign.status != "draft":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Only untouched draft campaigns can be deleted.",
|
||||
)
|
||||
if (
|
||||
session.query(CampaignJob.id)
|
||||
.filter(CampaignJob.campaign_id == campaign.id)
|
||||
.first()
|
||||
is not None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Campaigns with built or delivery jobs must be archived instead of deleted.",
|
||||
)
|
||||
protected_version = (
|
||||
session.query(CampaignVersion.id)
|
||||
.filter(
|
||||
CampaignVersion.campaign_id == campaign.id,
|
||||
or_(
|
||||
CampaignVersion.locked_at.is_not(None),
|
||||
CampaignVersion.user_lock_state.is_not(None),
|
||||
CampaignVersion.published_at.is_not(None),
|
||||
CampaignVersion.execution_snapshot_at.is_not(None),
|
||||
),
|
||||
)
|
||||
.first()
|
||||
campaign, _policy = _lifecycle_policy_for_mutation(
|
||||
session,
|
||||
campaign_id=campaign_id,
|
||||
principal=principal,
|
||||
expected_state_token=payload.expected_state_token,
|
||||
action="delete_campaign",
|
||||
)
|
||||
if protected_version is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Audit-relevant campaign versions must be archived instead of deleted.",
|
||||
)
|
||||
campaign.status = "deleted"
|
||||
session.add(campaign)
|
||||
audit_from_principal(
|
||||
|
||||
@@ -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)
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
@@ -22,10 +23,21 @@ from govoplan_campaign.backend.schemas import (
|
||||
from govoplan_core.auth import ApiPrincipal, has_scope, 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,
|
||||
RecoveryMode,
|
||||
RecoveryPlan,
|
||||
)
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_core.server.runtime_agent import application_runtime_identity
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
)
|
||||
@@ -74,6 +86,47 @@ from govoplan_campaign.backend.routes.attachments import (
|
||||
router = APIRouter(prefix="/campaigns", tags=["campaigns"])
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _campaign_build_recovery_plan(raw_json: dict[str, object]) -> RecoveryPlan:
|
||||
delivery = raw_json.get("delivery")
|
||||
print_config = delivery.get("print") if isinstance(delivery, dict) else None
|
||||
persists_managed_output = bool(
|
||||
isinstance(print_config, dict)
|
||||
and print_config.get("persist_to_files", True)
|
||||
)
|
||||
if persists_managed_output:
|
||||
return RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=("validated Campaign version is locked",),
|
||||
forward_recovery_steps=(
|
||||
"reuse the Templates render idempotency key",
|
||||
"reconcile the managed output and Campaign build manifests",
|
||||
),
|
||||
verification_steps=(
|
||||
"compare committed Campaign jobs with generated object evidence",
|
||||
),
|
||||
)
|
||||
return RecoveryPlan(
|
||||
mode=RecoveryMode.COMPENSATION,
|
||||
preconditions=("validated Campaign version is locked",),
|
||||
compensation_steps=("delete every object under the reserved build prefix",),
|
||||
verification_steps=(
|
||||
"compare committed Campaign jobs with generated object evidence",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}/versions/{version_id}/print-output/download")
|
||||
def download_print_output(
|
||||
campaign_id: str,
|
||||
@@ -728,6 +781,7 @@ def validate_version(
|
||||
@router.post("/versions/{version_id}/build")
|
||||
def build_version(
|
||||
version_id: str,
|
||||
request: Request,
|
||||
payload: BuildCampaignRequest | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:build")),
|
||||
@@ -737,14 +791,80 @@ def build_version(
|
||||
_require_mail_profile_use_if_needed(
|
||||
principal, version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
write_eml = payload.write_eml if payload else True
|
||||
source_sha256 = _canonical_sha256(raw_json)
|
||||
validation_sha256 = _canonical_sha256(
|
||||
version.validation_summary
|
||||
if isinstance(version.validation_summary, dict)
|
||||
else {}
|
||||
)
|
||||
idempotency_key = (
|
||||
payload.idempotency_key
|
||||
if payload and payload.idempotency_key
|
||||
else (
|
||||
f"campaign-build:{version.id}:{source_sha256}:"
|
||||
f"{validation_sha256}:{int(write_eml)}"
|
||||
)
|
||||
)
|
||||
try:
|
||||
identity = application_runtime_identity(request.app)
|
||||
recovery_start = begin_durable_recovery_operation(
|
||||
get_database().SessionLocal,
|
||||
identity=identity,
|
||||
module_id="campaigns",
|
||||
operation_type="build-artifacts",
|
||||
idempotency_key=idempotency_key,
|
||||
request={
|
||||
"tenant_id": principal.tenant_id,
|
||||
"campaign_id": version.campaign_id,
|
||||
"version_id": version.id,
|
||||
"source_sha256": source_sha256,
|
||||
"validation_sha256": validation_sha256,
|
||||
"write_eml": write_eml,
|
||||
},
|
||||
recovery_plan=_campaign_build_recovery_plan(raw_json),
|
||||
precondition_evidence={
|
||||
"campaign_version_id": version.id,
|
||||
"source_sha256": source_sha256,
|
||||
"validation_sha256": validation_sha256,
|
||||
"locked_at": version.locked_at.isoformat()
|
||||
if version.locked_at
|
||||
else None,
|
||||
"workflow_state": version.workflow_state,
|
||||
},
|
||||
lease_resource_key=(
|
||||
f"campaign:build:{principal.tenant_id}:{version.id}"
|
||||
),
|
||||
lease_ttl_seconds=30 * 60,
|
||||
resource_type="campaign_version",
|
||||
resource_id=version.id,
|
||||
metadata={"actor_account_id": principal.account_id},
|
||||
)
|
||||
if recovery_start.replayed:
|
||||
session.refresh(version)
|
||||
if not isinstance(version.build_summary, dict):
|
||||
raise RecoveryGuaranteeError(
|
||||
"A completed build operation has no committed Campaign summary"
|
||||
)
|
||||
return public_campaign_payload(
|
||||
version.build_summary,
|
||||
include_diagnostics=has_scope(
|
||||
principal, "campaigns:diagnostic:read"
|
||||
),
|
||||
)
|
||||
recovery_operation = recovery_start.operation
|
||||
if recovery_operation is None: # pragma: no cover - guarded by replay branch
|
||||
raise RecoveryGuaranteeError("Campaign build authority was not created")
|
||||
result = build_campaign_version(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
version_id=version_id,
|
||||
write_eml=payload.write_eml if payload else True,
|
||||
write_eml=write_eml,
|
||||
user_id=principal.user.id,
|
||||
principal=principal,
|
||||
recovery_operation=recovery_operation,
|
||||
build_id=recovery_start.operation_id,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
@@ -753,8 +873,9 @@ def build_version(
|
||||
object_type="campaign_version",
|
||||
object_id=version_id,
|
||||
details={
|
||||
"write_eml": payload.write_eml if payload else True,
|
||||
"write_eml": write_eml,
|
||||
"built_count": result.get("built_count"),
|
||||
"recovery_operation_id": recovery_start.operation_id,
|
||||
},
|
||||
commit=True,
|
||||
)
|
||||
@@ -766,6 +887,21 @@ def build_version(
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||
) from exc
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except RecoveryGuaranteeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Campaign build coordination is unavailable",
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||
|
||||
@@ -42,6 +42,18 @@ class CampaignUpdateRequest(BaseModel):
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CampaignLifecycleMutationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
expected_state_token: str = Field(min_length=64, max_length=64)
|
||||
|
||||
|
||||
class CampaignCopyRequest(CampaignLifecycleMutationRequest):
|
||||
source_version_id: str = Field(min_length=1, max_length=36)
|
||||
external_id: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
|
||||
|
||||
class CampaignCreateMinimalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -141,6 +153,8 @@ class CampaignVersionResponse(BaseModel):
|
||||
None
|
||||
)
|
||||
delivery_mode_selected_at: datetime | None = None
|
||||
archived_at: datetime | None = None
|
||||
archived_by_user_id: str | None = None
|
||||
|
||||
@field_validator("editor_state", mode="before")
|
||||
@classmethod
|
||||
@@ -200,6 +214,19 @@ class CampaignResponse(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CampaignLifecycleActionResponse(BaseModel):
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class CampaignLifecyclePolicyResponse(BaseModel):
|
||||
policy_id: str
|
||||
policy_version: str
|
||||
state_token: str
|
||||
actions: dict[str, CampaignLifecycleActionResponse]
|
||||
provenance: dict[str, Any]
|
||||
|
||||
|
||||
class CampaignCreateResponse(BaseModel):
|
||||
campaign: CampaignResponse
|
||||
version: CampaignVersionResponse
|
||||
@@ -677,6 +704,56 @@ class BuildCampaignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
write_eml: bool = True
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
|
||||
|
||||
class CampaignArtifactReconcileRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
apply: bool = False
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
grace_period_hours: int = Field(default=24, ge=24, le=24 * 90)
|
||||
cursor: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
page_size: int = Field(default=250, ge=1, le=1000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_apply_idempotency_key(self) -> "CampaignArtifactReconcileRequest":
|
||||
if self.apply and not self.idempotency_key:
|
||||
raise ValueError("Applied artifact cleanup requires an idempotency key")
|
||||
return self
|
||||
|
||||
|
||||
class CampaignArtifactCandidateResponse(BaseModel):
|
||||
key: str
|
||||
size_bytes: int
|
||||
modified_at: datetime
|
||||
age_seconds: int
|
||||
reason: str
|
||||
disposition: str
|
||||
failure_type: str | None = None
|
||||
|
||||
|
||||
class CampaignArtifactReconcileResponse(BaseModel):
|
||||
apply: bool
|
||||
status: str
|
||||
recovery_operation_id: str | None = None
|
||||
tenant_prefix: str
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = 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
|
||||
candidate_count: int
|
||||
candidate_bytes: int
|
||||
deleted_count: int
|
||||
deleted_bytes: int
|
||||
failure_count: int
|
||||
manifest_sha256: str | None = None
|
||||
candidates: list[CampaignArtifactCandidateResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApiKeyCreateRequest(BaseModel):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -15,6 +15,13 @@ from sqlalchemy import func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.recovery import RecoveryMode, RecoveryPlan, RecoveryStatus
|
||||
from govoplan_core.core.recovery_runtime import (
|
||||
DurableRecoveryOperation,
|
||||
RecoveryOperationBusy,
|
||||
RecoveryOperationStateConflict,
|
||||
begin_durable_recovery_operation,
|
||||
)
|
||||
from govoplan_core.core.notifications import (
|
||||
NotificationDispatchRequest,
|
||||
notification_dispatch_provider,
|
||||
@@ -23,7 +30,9 @@ from govoplan_core.core.object_storage import (
|
||||
StorageBackendError,
|
||||
configured_storage_backend,
|
||||
)
|
||||
from govoplan_core.core.runtime_coordination import process_runtime_identity
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.redaction import redact_secret_values
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
@@ -1074,19 +1083,13 @@ def send_campaign_now(
|
||||
skipped_after_queue = 0
|
||||
for job in jobs:
|
||||
try:
|
||||
claimed = _claimed_campaign_job_for_delivery(session, job)
|
||||
if isinstance(claimed, SendJobResult):
|
||||
result = claimed
|
||||
else:
|
||||
claimed_job, claim_token = claimed
|
||||
result = _send_claimed_campaign_job(
|
||||
session,
|
||||
job=claimed_job,
|
||||
claim_token=claim_token,
|
||||
context=delivery_contexts[job.id],
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
result = _deliver_job_with_recovery(
|
||||
session,
|
||||
job=job,
|
||||
context=delivery_contexts[job.id],
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
result_dict = result.as_dict()
|
||||
results.append(result_dict)
|
||||
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
|
||||
@@ -2096,6 +2099,134 @@ def _single_message_action_response(
|
||||
}
|
||||
|
||||
|
||||
def _begin_single_action_delivery_recovery(
|
||||
*,
|
||||
action: CampaignMessageAction,
|
||||
job: CampaignJob,
|
||||
delivery_context: _SendJobDeliveryContext,
|
||||
):
|
||||
return begin_durable_recovery_operation(
|
||||
get_database().SessionLocal,
|
||||
identity=process_runtime_identity(),
|
||||
module_id="campaigns",
|
||||
operation_type="single-message-external-delivery",
|
||||
idempotency_key=f"campaign-message-action:{action.id}",
|
||||
request={
|
||||
"tenant_id": action.tenant_id,
|
||||
"campaign_id": action.campaign_id,
|
||||
"version_id": action.campaign_version_id,
|
||||
"job_id": action.job_id,
|
||||
"action_id": action.id,
|
||||
"action_kind": action.kind,
|
||||
"message_sha256": action.message_sha256,
|
||||
"recipient_manifest_sha256": action.recipient_manifest_sha256,
|
||||
"smtp_transport_revision_sha256": hashlib.sha256(
|
||||
str(delivery_context.snapshot.smtp_transport_revision).encode("utf-8")
|
||||
).hexdigest(),
|
||||
},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=(
|
||||
"the immutable single-message action is durably recorded",
|
||||
"the message and SMTP transport revision passed preflight",
|
||||
),
|
||||
forward_recovery_steps=(
|
||||
"inspect the Campaign message action and SMTP provider evidence",
|
||||
"reconcile acceptance, rejection, or an unknown outcome",
|
||||
),
|
||||
verification_steps=(
|
||||
"reload the message action through an independent session",
|
||||
"verify its terminal outcome and attempt evidence",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"action_id": action.id,
|
||||
"action_status": action.status,
|
||||
"action_kind": action.kind,
|
||||
"job_id": job.id,
|
||||
"message_sha256": action.message_sha256,
|
||||
"recipient_manifest_sha256": action.recipient_manifest_sha256,
|
||||
},
|
||||
lease_resource_key=f"campaign:message-action:{action.tenant_id}:{action.id}",
|
||||
lease_ttl_seconds=15 * 60,
|
||||
resource_type="campaign_message_action",
|
||||
resource_id=action.id,
|
||||
metadata={"resources": ["postgresql", "smtp"]},
|
||||
)
|
||||
|
||||
|
||||
def _single_action_recovery_evidence(
|
||||
action_id: str,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
with get_database().SessionLocal() as evidence_session:
|
||||
action = evidence_session.get(CampaignMessageAction, action_id)
|
||||
if action is None:
|
||||
return "recovery_required", {
|
||||
"verified": False,
|
||||
"action_present": False,
|
||||
}
|
||||
latest = (
|
||||
evidence_session.query(CampaignMessageActionAttempt)
|
||||
.filter(CampaignMessageActionAttempt.action_id == action.id)
|
||||
.order_by(CampaignMessageActionAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
evidence = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"action_state_reloaded": True,
|
||||
"action_attempt_compared": True,
|
||||
},
|
||||
"action_present": True,
|
||||
"action_id": action.id,
|
||||
"action_kind": action.kind,
|
||||
"action_status": action.status,
|
||||
"attempt_status": latest.status if latest else None,
|
||||
"accepted_count": action.accepted_count,
|
||||
"refused_count": action.refused_count,
|
||||
}
|
||||
if action.status in {"accepted", "accepted_with_refusals"}:
|
||||
return "succeeded", evidence
|
||||
if action.status == "outcome_unknown":
|
||||
return "outcome_unknown", evidence
|
||||
if action.status in {
|
||||
"failed_temporary",
|
||||
"failed_permanent",
|
||||
"initiation_failed",
|
||||
}:
|
||||
return "failed", evidence
|
||||
return "recovery_required", evidence
|
||||
|
||||
|
||||
def _finish_single_action_delivery_recovery(
|
||||
operation: DurableRecoveryOperation,
|
||||
*,
|
||||
action_id: str,
|
||||
) -> None:
|
||||
outcome, evidence = _single_action_recovery_evidence(action_id)
|
||||
if outcome == "succeeded":
|
||||
operation.succeed(evidence=evidence)
|
||||
elif outcome == "failed":
|
||||
operation.reject(
|
||||
summary="The single-message SMTP effect was definitively rejected",
|
||||
evidence=evidence,
|
||||
)
|
||||
elif outcome == "outcome_unknown":
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="The single-message SMTP outcome requires reconciliation",
|
||||
evidence=evidence,
|
||||
failure_summary="The message may have been accepted by SMTP",
|
||||
)
|
||||
else:
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="The single-message action did not reach a verified terminal state",
|
||||
evidence=evidence,
|
||||
failure_summary="The message action requires forward recovery",
|
||||
)
|
||||
|
||||
|
||||
def _send_single_message_direct(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -2127,6 +2258,53 @@ def _send_single_message_direct(
|
||||
messages_per_minute=delivery_context.snapshot.delivery.rate_limit.messages_per_minute,
|
||||
enabled=use_rate_limit,
|
||||
)
|
||||
try:
|
||||
recovery_start = _begin_single_action_delivery_recovery(
|
||||
action=action,
|
||||
job=job,
|
||||
delivery_context=delivery_context,
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
_finish_single_message_action(
|
||||
session,
|
||||
action=action,
|
||||
status="initiation_failed",
|
||||
error_type=exc.__class__.__name__,
|
||||
error_message=str(exc),
|
||||
final_send_status=job.send_status,
|
||||
)
|
||||
raise QueueingError(str(exc)) from exc
|
||||
if recovery_start.replayed or recovery_start.operation is None:
|
||||
session.refresh(action)
|
||||
return _single_message_action_response(action, duplicate=True)
|
||||
operation = recovery_start.operation
|
||||
try:
|
||||
result = _perform_single_message_direct_effect(
|
||||
session,
|
||||
campaign=campaign,
|
||||
version=version,
|
||||
job=job,
|
||||
action=action,
|
||||
delivery_context=delivery_context,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
except Exception:
|
||||
_finish_single_action_delivery_recovery(operation, action_id=action.id)
|
||||
raise
|
||||
_finish_single_action_delivery_recovery(operation, action_id=action.id)
|
||||
return result
|
||||
|
||||
|
||||
def _perform_single_message_direct_effect(
|
||||
session: Session,
|
||||
*,
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
job: CampaignJob,
|
||||
action: CampaignMessageAction,
|
||||
delivery_context: _SendJobDeliveryContext,
|
||||
enqueue_imap_task: bool,
|
||||
) -> dict[str, Any]:
|
||||
attempt = _start_single_message_action_attempt(session, action)
|
||||
try:
|
||||
result = mail_integration().send_campaign_email_bytes(
|
||||
@@ -2143,6 +2321,11 @@ def _send_single_message_direct(
|
||||
),
|
||||
smtp_server_id=delivery_context.snapshot.smtp_server_id,
|
||||
smtp_credential_id=delivery_context.snapshot.smtp_credential_id,
|
||||
recovery_effect_id=(
|
||||
f"campaign-single:{action.id}:smtp-attempt:{attempt.attempt_number}"
|
||||
),
|
||||
recovery_resource_type="campaign_message_action",
|
||||
recovery_resource_id=action.id,
|
||||
)
|
||||
except SmtpSendError as exc:
|
||||
if exc.outcome_unknown:
|
||||
@@ -2726,7 +2909,12 @@ def _recipients_from_job(job: CampaignJob) -> list[str]:
|
||||
return list(dict.fromkeys(recipients))
|
||||
|
||||
|
||||
def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
|
||||
def _claim_job_for_sending(
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
claim_token: str | None = None,
|
||||
) -> str | None:
|
||||
"""Atomically claim a queued job and return the claim token.
|
||||
|
||||
A duplicate task can observe CLAIMED/SENDING but cannot acquire a second
|
||||
@@ -2735,7 +2923,7 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
|
||||
could race a slow worker.
|
||||
"""
|
||||
|
||||
claim_token = str(uuid4())
|
||||
effective_claim_token = claim_token or str(uuid4())
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
@@ -2748,7 +2936,7 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
|
||||
CampaignJob.queue_status: JobQueueStatus.SENDING.value,
|
||||
CampaignJob.send_status: JobSendStatus.CLAIMED.value,
|
||||
CampaignJob.claimed_at: _utcnow(),
|
||||
CampaignJob.claim_token: claim_token,
|
||||
CampaignJob.claim_token: effective_claim_token,
|
||||
CampaignJob.last_error: None,
|
||||
},
|
||||
synchronize_session=False,
|
||||
@@ -2756,7 +2944,7 @@ def _claim_job_for_sending(session: Session, job: CampaignJob) -> str | None:
|
||||
)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
return claim_token if changed == 1 else None
|
||||
return effective_claim_token if changed == 1 else None
|
||||
|
||||
|
||||
def _record_attempt_start(
|
||||
@@ -2946,6 +3134,184 @@ def _campaign_delivery_status_pair(
|
||||
return None
|
||||
|
||||
|
||||
def _canonical_delivery_sha256(value: object) -> str:
|
||||
return hashlib.sha256(
|
||||
json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _begin_job_delivery_recovery(
|
||||
*,
|
||||
job: CampaignJob,
|
||||
context: _SendJobDeliveryContext,
|
||||
claim_token: str,
|
||||
):
|
||||
channel_policy = DeliveryChannelPolicy(job.delivery_channel_policy)
|
||||
resources = ["postgresql"]
|
||||
if channel_policy.uses_mail:
|
||||
resources.append("smtp")
|
||||
if channel_policy.uses_postbox:
|
||||
resources.append("postbox")
|
||||
if channel_policy.uses_print:
|
||||
resources.append("print-provider")
|
||||
claim_sha256 = hashlib.sha256(claim_token.encode("utf-8")).hexdigest()
|
||||
recipient_manifest_sha256 = _canonical_delivery_sha256(
|
||||
_recipients_from_job(job)
|
||||
)
|
||||
return begin_durable_recovery_operation(
|
||||
get_database().SessionLocal,
|
||||
identity=process_runtime_identity(),
|
||||
module_id="campaigns",
|
||||
operation_type="external-channel-delivery",
|
||||
idempotency_key=f"campaign-delivery:{job.id}:{claim_sha256[:32]}",
|
||||
request={
|
||||
"tenant_id": job.tenant_id,
|
||||
"campaign_id": job.campaign_id,
|
||||
"version_id": job.campaign_version_id,
|
||||
"job_id": job.id,
|
||||
"channel_policy": channel_policy.value,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"recipient_manifest_sha256": recipient_manifest_sha256,
|
||||
"execution_snapshot_sha256": context.version.execution_snapshot_hash,
|
||||
},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=(
|
||||
"the immutable built job is explicitly queued",
|
||||
"message and transport revisions passed preflight",
|
||||
),
|
||||
forward_recovery_steps=(
|
||||
"inspect provider and module attempt evidence",
|
||||
"reconcile accepted, rejected, or outcome-unknown channel state",
|
||||
),
|
||||
verification_steps=(
|
||||
"reload the Campaign job through an independent session",
|
||||
"compare final channel and attempt states",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"job_id": job.id,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"recipient_manifest_sha256": recipient_manifest_sha256,
|
||||
"claim_sha256": claim_sha256,
|
||||
},
|
||||
lease_resource_key=f"campaign:delivery:{job.tenant_id}:{job.id}",
|
||||
lease_ttl_seconds=30 * 60,
|
||||
resource_type="campaign_job",
|
||||
resource_id=job.id,
|
||||
metadata={
|
||||
"resources": resources,
|
||||
"channel_policy": channel_policy.value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _job_delivery_recovery_evidence(job_id: str) -> tuple[str, dict[str, Any]]:
|
||||
with get_database().SessionLocal() as evidence_session:
|
||||
job = evidence_session.get(CampaignJob, job_id)
|
||||
if job is None:
|
||||
return "recovery_required", {
|
||||
"verified": False,
|
||||
"job_present": False,
|
||||
}
|
||||
latest_smtp = (
|
||||
evidence_session.query(SendAttempt)
|
||||
.filter(SendAttempt.job_id == job.id)
|
||||
.order_by(SendAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
latest_postbox = (
|
||||
evidence_session.query(PostboxDeliveryAttempt)
|
||||
.filter(PostboxDeliveryAttempt.job_id == job.id)
|
||||
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
latest_print = (
|
||||
evidence_session.query(PrintOutputAttempt)
|
||||
.filter(PrintOutputAttempt.job_id == job.id)
|
||||
.order_by(PrintOutputAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
evidence = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"job_state_reloaded": True,
|
||||
"channel_attempts_compared": True,
|
||||
},
|
||||
"job_present": True,
|
||||
"job_id": job.id,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"postbox_status": job.postbox_status,
|
||||
"print_status": job.print_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"postbox_attempt_count": job.postbox_attempt_count,
|
||||
"print_attempt_count": job.print_attempt_count,
|
||||
"latest_attempts": {
|
||||
"smtp": latest_smtp.status if latest_smtp else None,
|
||||
"postbox": latest_postbox.status if latest_postbox else None,
|
||||
"print": latest_print.status if latest_print else None,
|
||||
},
|
||||
}
|
||||
if (
|
||||
job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value
|
||||
or job.postbox_status == JobPostboxStatus.OUTCOME_UNKNOWN.value
|
||||
):
|
||||
return "outcome_unknown", evidence
|
||||
if (
|
||||
job.send_status
|
||||
in {JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value}
|
||||
or job.postbox_status == JobPostboxStatus.DELIVERING.value
|
||||
or job.print_status == JobPrintStatus.ACCEPTING.value
|
||||
):
|
||||
return "recovery_required", evidence
|
||||
if job.send_status in {
|
||||
*FULLY_ACCEPTED_STATUSES,
|
||||
JobSendStatus.PARTIALLY_ACCEPTED.value,
|
||||
}:
|
||||
return "succeeded", evidence
|
||||
return "failed", evidence
|
||||
|
||||
|
||||
def _finish_job_delivery_recovery(
|
||||
operation: DurableRecoveryOperation,
|
||||
*,
|
||||
job_id: str,
|
||||
) -> None:
|
||||
outcome, evidence = _job_delivery_recovery_evidence(job_id)
|
||||
if outcome == "succeeded":
|
||||
operation.succeed(evidence=evidence)
|
||||
elif outcome == "failed":
|
||||
operation.reject(
|
||||
summary="Campaign delivery completed with a verified provider rejection",
|
||||
evidence=evidence,
|
||||
)
|
||||
elif outcome == "outcome_unknown":
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Campaign delivery provider outcome requires reconciliation",
|
||||
evidence=evidence,
|
||||
failure_summary="A Campaign delivery effect may have been accepted",
|
||||
)
|
||||
else:
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="Campaign delivery did not reach a verified terminal state",
|
||||
evidence=evidence,
|
||||
failure_summary="Campaign delivery state requires forward recovery",
|
||||
)
|
||||
|
||||
|
||||
def send_campaign_job(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -2986,20 +3352,82 @@ def send_campaign_job(
|
||||
message=f"Would deliver via {'; '.join(descriptions)}",
|
||||
)
|
||||
|
||||
claimed = _claimed_campaign_job_for_delivery(session, job)
|
||||
if isinstance(claimed, SendJobResult):
|
||||
return claimed
|
||||
claimed_job, claim_token = claimed
|
||||
return _send_claimed_campaign_job(
|
||||
return _deliver_job_with_recovery(
|
||||
session,
|
||||
job=claimed_job,
|
||||
claim_token=claim_token,
|
||||
job=job,
|
||||
context=context,
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
|
||||
|
||||
def _deliver_job_with_recovery(
|
||||
session: Session,
|
||||
*,
|
||||
job: CampaignJob,
|
||||
context: _SendJobDeliveryContext,
|
||||
use_rate_limit: bool,
|
||||
enqueue_imap_task: bool,
|
||||
) -> SendJobResult:
|
||||
claim_token = str(uuid4())
|
||||
try:
|
||||
recovery_start = _begin_job_delivery_recovery(
|
||||
job=job,
|
||||
context=context,
|
||||
claim_token=claim_token,
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
return SendJobResult(
|
||||
job_id=job.id,
|
||||
status="recovery_blocked",
|
||||
attempt_number=job.attempt_count,
|
||||
message=str(exc),
|
||||
)
|
||||
if recovery_start.replayed or recovery_start.operation is None:
|
||||
return SendJobResult(
|
||||
job_id=job.id,
|
||||
status="already_completed",
|
||||
attempt_number=job.attempt_count,
|
||||
message="The durable delivery operation already completed.",
|
||||
)
|
||||
recovery_operation = recovery_start.operation
|
||||
claimed = _claimed_campaign_job_for_delivery(
|
||||
session,
|
||||
job,
|
||||
claim_token=claim_token,
|
||||
)
|
||||
if isinstance(claimed, SendJobResult):
|
||||
recovery_operation.reject(
|
||||
summary="Campaign job could not be claimed before any provider effect",
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"provider_effect_started": False,
|
||||
"claim_rejected": True,
|
||||
},
|
||||
"provider_effect_started": False,
|
||||
"job_id": job.id,
|
||||
"claim_result": claimed.status,
|
||||
},
|
||||
)
|
||||
return claimed
|
||||
claimed_job, claim_token = claimed
|
||||
try:
|
||||
result = _send_claimed_campaign_job(
|
||||
session,
|
||||
job=claimed_job,
|
||||
claim_token=claim_token,
|
||||
context=context,
|
||||
use_rate_limit=use_rate_limit,
|
||||
enqueue_imap_task=enqueue_imap_task,
|
||||
)
|
||||
except Exception:
|
||||
_finish_job_delivery_recovery(recovery_operation, job_id=job.id)
|
||||
raise
|
||||
_finish_job_delivery_recovery(recovery_operation, job_id=job.id)
|
||||
return result
|
||||
|
||||
|
||||
def _preflight_send_campaign_job(
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
@@ -3100,15 +3528,21 @@ def _send_job_delivery_context(
|
||||
def _claimed_campaign_job_for_delivery(
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
claim_token: str | None = None,
|
||||
) -> tuple[CampaignJob, str] | SendJobResult:
|
||||
claim_token = _claim_job_for_sending(session, job)
|
||||
if claim_token is None:
|
||||
effective_claim = _claim_job_for_sending(
|
||||
session,
|
||||
job,
|
||||
claim_token=claim_token,
|
||||
)
|
||||
if effective_claim is None:
|
||||
return _not_claimed_send_job_result(session, job)
|
||||
|
||||
job = session.get(CampaignJob, job.id)
|
||||
if job is None:
|
||||
raise SendJobError("Claimed campaign job disappeared before send.")
|
||||
return job, claim_token
|
||||
return job, effective_claim
|
||||
|
||||
|
||||
def _not_claimed_send_job_result(session: Session, job: CampaignJob) -> SendJobResult:
|
||||
@@ -3204,6 +3638,11 @@ def _send_claimed_mail_only_job(
|
||||
or "",
|
||||
smtp_server_id=context.snapshot.smtp_server_id,
|
||||
smtp_credential_id=context.snapshot.smtp_credential_id,
|
||||
recovery_effect_id=(
|
||||
f"campaign-job:{job.id}:smtp-attempt:{attempt.attempt_number}"
|
||||
),
|
||||
recovery_resource_type="campaign_job",
|
||||
recovery_resource_id=job.id,
|
||||
)
|
||||
if result.accepted_count <= 0:
|
||||
raise SmtpSendError(
|
||||
@@ -3878,7 +4317,12 @@ def _imap_attempt_count(session: Session, job_id: str) -> int:
|
||||
)
|
||||
|
||||
|
||||
def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None:
|
||||
def _claim_job_for_imap_append(
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
claim_token: str | None = None,
|
||||
) -> str | None:
|
||||
"""Atomically grant one worker permission to invoke the IMAP provider."""
|
||||
|
||||
if not _mail_was_accepted(
|
||||
@@ -3891,7 +4335,7 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None
|
||||
),
|
||||
):
|
||||
return None
|
||||
claim_token = str(uuid4())
|
||||
effective_claim_token = claim_token or str(uuid4())
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
@@ -3905,7 +4349,7 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None
|
||||
{
|
||||
CampaignJob.imap_status: JobImapStatus.APPENDING.value,
|
||||
CampaignJob.imap_claimed_at: _utcnow(),
|
||||
CampaignJob.imap_claim_token: claim_token,
|
||||
CampaignJob.imap_claim_token: effective_claim_token,
|
||||
CampaignJob.last_error: None,
|
||||
},
|
||||
synchronize_session=False,
|
||||
@@ -3913,7 +4357,7 @@ def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None
|
||||
)
|
||||
session.commit()
|
||||
session.expire_all()
|
||||
return claim_token if changed == 1 else None
|
||||
return effective_claim_token if changed == 1 else None
|
||||
|
||||
|
||||
def _record_imap_attempt_start(
|
||||
@@ -4269,10 +4713,17 @@ def _imap_append_dry_run(
|
||||
|
||||
|
||||
def _claim_imap_append(
|
||||
session: Session, job: CampaignJob
|
||||
session: Session,
|
||||
job: CampaignJob,
|
||||
*,
|
||||
claim_token: str | None = None,
|
||||
) -> _ClaimedImapAppend | AppendSentResult:
|
||||
claim_token = _claim_job_for_imap_append(session, job)
|
||||
if claim_token is None:
|
||||
effective_claim = _claim_job_for_imap_append(
|
||||
session,
|
||||
job,
|
||||
claim_token=claim_token,
|
||||
)
|
||||
if effective_claim is None:
|
||||
current = session.get(CampaignJob, job.id)
|
||||
if current is None:
|
||||
raise SendJobError(f"Job disappeared while claiming IMAP append: {job.id}")
|
||||
@@ -4290,8 +4741,8 @@ def _claim_imap_append(
|
||||
raise SendJobError("Claimed campaign job disappeared before IMAP append")
|
||||
return _ClaimedImapAppend(
|
||||
job=claimed_job,
|
||||
attempt=_record_imap_attempt_start(session, claimed_job, claim_token),
|
||||
claim_token=claim_token,
|
||||
attempt=_record_imap_attempt_start(session, claimed_job, effective_claim),
|
||||
claim_token=effective_claim,
|
||||
)
|
||||
|
||||
|
||||
@@ -4339,6 +4790,11 @@ def _invoke_imap_append(
|
||||
smtp_credential_id=snapshot.smtp_credential_id,
|
||||
imap_server_id=snapshot.imap_server_id,
|
||||
imap_credential_id=snapshot.imap_credential_id,
|
||||
recovery_effect_id=(
|
||||
f"campaign-job:{job.id}:imap-attempt:{claimed.attempt.attempt_number}"
|
||||
),
|
||||
recovery_resource_type="campaign_job",
|
||||
recovery_resource_id=job.id,
|
||||
)
|
||||
|
||||
|
||||
@@ -4392,6 +4848,127 @@ def _perform_imap_append(
|
||||
)
|
||||
|
||||
|
||||
def _begin_imap_append_recovery(
|
||||
*,
|
||||
job: CampaignJob,
|
||||
context: _ImapAppendContext,
|
||||
claim_token: str,
|
||||
):
|
||||
claim_sha256 = hashlib.sha256(claim_token.encode("utf-8")).hexdigest()
|
||||
return begin_durable_recovery_operation(
|
||||
get_database().SessionLocal,
|
||||
identity=process_runtime_identity(),
|
||||
module_id="campaigns",
|
||||
operation_type="imap-sent-append",
|
||||
idempotency_key=f"campaign-imap:{job.id}:{claim_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,
|
||||
"folder_sha256": hashlib.sha256(
|
||||
context.folder.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"imap_transport_revision_sha256": hashlib.sha256(
|
||||
context.snapshot.imap_transport_revision.encode("utf-8")
|
||||
).hexdigest(),
|
||||
},
|
||||
recovery_plan=RecoveryPlan(
|
||||
mode=RecoveryMode.FORWARD_RECOVERY,
|
||||
preconditions=(
|
||||
"SMTP acceptance is durable",
|
||||
"the exact EML and IMAP transport revision passed preflight",
|
||||
),
|
||||
forward_recovery_steps=(
|
||||
"inspect the target mailbox and append attempt",
|
||||
"reconcile as appended or not appended before retry",
|
||||
),
|
||||
verification_steps=(
|
||||
"reload the Campaign IMAP state through an independent session",
|
||||
"compare the latest append attempt",
|
||||
),
|
||||
),
|
||||
precondition_evidence={
|
||||
"job_id": job.id,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"claim_sha256": claim_sha256,
|
||||
},
|
||||
lease_resource_key=f"campaign:imap:{job.tenant_id}:{job.id}",
|
||||
lease_ttl_seconds=15 * 60,
|
||||
resource_type="campaign_job",
|
||||
resource_id=job.id,
|
||||
metadata={"resources": ["postgresql", "imap"]},
|
||||
)
|
||||
|
||||
|
||||
def _imap_recovery_evidence(job_id: str) -> tuple[str, dict[str, Any]]:
|
||||
with get_database().SessionLocal() as evidence_session:
|
||||
job = evidence_session.get(CampaignJob, job_id)
|
||||
if job is None:
|
||||
return "recovery_required", {
|
||||
"verified": False,
|
||||
"job_present": False,
|
||||
}
|
||||
latest = (
|
||||
evidence_session.query(ImapAppendAttempt)
|
||||
.filter(ImapAppendAttempt.job_id == job.id)
|
||||
.order_by(ImapAppendAttempt.attempt_number.desc())
|
||||
.first()
|
||||
)
|
||||
evidence = {
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"job_state_reloaded": True,
|
||||
"append_attempt_compared": True,
|
||||
},
|
||||
"job_present": True,
|
||||
"job_id": job.id,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": _imap_attempt_count(evidence_session, job.id),
|
||||
"latest_attempt_status": latest.status if latest else None,
|
||||
}
|
||||
if job.imap_status == JobImapStatus.APPENDED.value:
|
||||
return "succeeded", evidence
|
||||
if job.imap_status == JobImapStatus.FAILED.value:
|
||||
return "failed", evidence
|
||||
if job.imap_status == JobImapStatus.OUTCOME_UNKNOWN.value:
|
||||
return "outcome_unknown", evidence
|
||||
return "recovery_required", evidence
|
||||
|
||||
|
||||
def _finish_imap_append_recovery(
|
||||
operation: DurableRecoveryOperation,
|
||||
*,
|
||||
job_id: str,
|
||||
) -> None:
|
||||
outcome, evidence = _imap_recovery_evidence(job_id)
|
||||
if outcome == "succeeded":
|
||||
operation.succeed(evidence=evidence)
|
||||
elif outcome == "failed":
|
||||
operation.reject(
|
||||
summary="The IMAP provider definitively rejected the append",
|
||||
evidence=evidence,
|
||||
)
|
||||
elif outcome == "outcome_unknown":
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="The IMAP append outcome requires mailbox reconciliation",
|
||||
evidence=evidence,
|
||||
failure_summary="The exact message may already exist in the Sent folder",
|
||||
)
|
||||
else:
|
||||
operation.unresolved(
|
||||
status=RecoveryStatus.RECOVERY_REQUIRED,
|
||||
summary="The IMAP append did not reach a verified terminal state",
|
||||
evidence=evidence,
|
||||
failure_summary="The IMAP append requires forward recovery",
|
||||
)
|
||||
|
||||
|
||||
def append_sent_for_job(
|
||||
session: Session, *, job_id: str, dry_run: bool = False
|
||||
) -> AppendSentResult:
|
||||
@@ -4408,10 +4985,53 @@ def append_sent_for_job(
|
||||
return prepared
|
||||
if dry_run:
|
||||
return _imap_append_dry_run(session, job, prepared)
|
||||
claimed = _claim_imap_append(session, job)
|
||||
claim_token = str(uuid4())
|
||||
try:
|
||||
recovery_start = _begin_imap_append_recovery(
|
||||
job=job,
|
||||
context=prepared,
|
||||
claim_token=claim_token,
|
||||
)
|
||||
except (RecoveryOperationBusy, RecoveryOperationStateConflict) as exc:
|
||||
return AppendSentResult(
|
||||
job_id=job.id,
|
||||
status="recovery_blocked",
|
||||
attempt_number=_imap_attempt_count(session, job.id),
|
||||
message=str(exc),
|
||||
)
|
||||
if recovery_start.replayed or recovery_start.operation is None:
|
||||
return AppendSentResult(
|
||||
job_id=job.id,
|
||||
status="already_completed",
|
||||
attempt_number=_imap_attempt_count(session, job.id),
|
||||
)
|
||||
recovery_operation = recovery_start.operation
|
||||
claimed = _claim_imap_append(
|
||||
session,
|
||||
job,
|
||||
claim_token=claim_token,
|
||||
)
|
||||
if isinstance(claimed, AppendSentResult):
|
||||
recovery_operation.reject(
|
||||
summary="The IMAP append claim was rejected before any provider effect",
|
||||
evidence={
|
||||
"verified": True,
|
||||
"checks": {
|
||||
"provider_effect_started": False,
|
||||
"claim_rejected": True,
|
||||
},
|
||||
"provider_effect_started": False,
|
||||
"claim_result": claimed.status,
|
||||
},
|
||||
)
|
||||
return claimed
|
||||
return _perform_imap_append(session, claimed, prepared)
|
||||
try:
|
||||
result = _perform_imap_append(session, claimed, prepared)
|
||||
except Exception:
|
||||
_finish_imap_append_recovery(recovery_operation, job_id=job.id)
|
||||
raise
|
||||
_finish_imap_append_recovery(recovery_operation, job_id=job.id)
|
||||
return result
|
||||
|
||||
|
||||
def enqueue_pending_imap_appends(
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,337 @@
|
||||
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:
|
||||
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=NOW,
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.object_storage import LocalFilesystemStorageBackend
|
||||
from govoplan_core.core.recovery import RecoveryMode
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
_StoredEmlArtifact,
|
||||
_build_storage_expectations,
|
||||
_delete_storage_keys,
|
||||
_verify_build_storage_manifest,
|
||||
_verify_storage_keys_absent,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.versions import _campaign_build_recovery_plan
|
||||
|
||||
|
||||
def test_object_only_and_managed_output_builds_use_distinct_recovery_modes() -> None:
|
||||
assert _campaign_build_recovery_plan({}).mode == RecoveryMode.COMPENSATION
|
||||
assert (
|
||||
_campaign_build_recovery_plan(
|
||||
{"delivery": {"print": {"persist_to_files": True}}}
|
||||
).mode
|
||||
== RecoveryMode.FORWARD_RECOVERY
|
||||
)
|
||||
assert (
|
||||
_campaign_build_recovery_plan(
|
||||
{"delivery": {"print": {"persist_to_files": False}}}
|
||||
).mode
|
||||
== RecoveryMode.COMPENSATION
|
||||
)
|
||||
|
||||
|
||||
def test_generated_object_manifest_verifies_exact_bytes(tmp_path: Path) -> None:
|
||||
storage = LocalFilesystemStorageBackend(tmp_path)
|
||||
payload = b"Message-ID: <build@example.test>\r\n\r\nbody"
|
||||
key = "campaign-artifacts/tenant/campaign/version/build/00000001.eml"
|
||||
storage.put_bytes(key, payload)
|
||||
artifact = _StoredEmlArtifact(
|
||||
storage_key=key,
|
||||
size_bytes=len(payload),
|
||||
sha256=hashlib.sha256(payload).hexdigest(),
|
||||
message_id_header="<build@example.test>",
|
||||
)
|
||||
|
||||
evidence = _verify_build_storage_manifest(
|
||||
storage,
|
||||
_build_storage_expectations(
|
||||
stored_eml_by_index={1: artifact},
|
||||
print_outputs_by_index={},
|
||||
),
|
||||
)
|
||||
|
||||
assert evidence["object_count"] == 1
|
||||
assert evidence["total_bytes"] == len(payload)
|
||||
storage.put_bytes(key, b"tampered")
|
||||
with pytest.raises(CampaignPersistenceError, match="does not match"):
|
||||
_verify_build_storage_manifest(
|
||||
storage,
|
||||
_build_storage_expectations(
|
||||
stored_eml_by_index={1: artifact},
|
||||
print_outputs_by_index={},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_compensation_requires_verified_object_absence(tmp_path: Path) -> None:
|
||||
storage = LocalFilesystemStorageBackend(tmp_path)
|
||||
key = "campaign-artifacts/tenant/campaign/version/build/object"
|
||||
storage.put_bytes(key, b"payload")
|
||||
assert _delete_storage_keys(storage, [key]) == []
|
||||
assert _verify_storage_keys_absent(storage, [key]) == (True, 0)
|
||||
|
||||
|
||||
class _UnremovableStorage:
|
||||
name = "unremovable"
|
||||
|
||||
def delete(self, _key: str) -> None:
|
||||
from govoplan_core.core.object_storage import StorageBackendError
|
||||
|
||||
raise StorageBackendError("unavailable")
|
||||
|
||||
def exists(self, _key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def test_failed_compensation_remains_observable() -> None:
|
||||
storage = _UnremovableStorage()
|
||||
key = "campaign-artifacts/tenant/campaign/version/build/object"
|
||||
assert _delete_storage_keys(storage, [key]) == [key] # type: ignore[arg-type]
|
||||
assert _verify_storage_keys_absent(storage, [key]) == (False, 1) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.recovery import RecoveryStatus
|
||||
from govoplan_campaign.backend.sending import jobs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "expected_method", "expected_status"),
|
||||
[
|
||||
("succeeded", "succeed", None),
|
||||
("failed", "reject", None),
|
||||
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
|
||||
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
|
||||
],
|
||||
)
|
||||
def test_delivery_recovery_maps_verified_job_state(
|
||||
monkeypatch,
|
||||
outcome: str,
|
||||
expected_method: str,
|
||||
expected_status: RecoveryStatus | None,
|
||||
) -> None:
|
||||
evidence = {
|
||||
"verified": outcome != "recovery_required",
|
||||
"checks": {"job_state_reloaded": True},
|
||||
"send_status": outcome,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
jobs,
|
||||
"_job_delivery_recovery_evidence",
|
||||
lambda _job_id: (outcome, evidence),
|
||||
)
|
||||
operation = Mock()
|
||||
|
||||
jobs._finish_job_delivery_recovery(operation, job_id="job-1")
|
||||
|
||||
method = getattr(operation, expected_method)
|
||||
method.assert_called_once()
|
||||
if expected_status is not None:
|
||||
assert method.call_args.kwargs["status"] == expected_status
|
||||
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
|
||||
getattr(operation, other).assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "expected_method", "expected_status"),
|
||||
[
|
||||
("succeeded", "succeed", None),
|
||||
("failed", "reject", None),
|
||||
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
|
||||
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
|
||||
],
|
||||
)
|
||||
def test_imap_recovery_maps_verified_append_state(
|
||||
monkeypatch,
|
||||
outcome: str,
|
||||
expected_method: str,
|
||||
expected_status: RecoveryStatus | None,
|
||||
) -> None:
|
||||
evidence = {
|
||||
"verified": outcome != "recovery_required",
|
||||
"checks": {"job_state_reloaded": True},
|
||||
"imap_status": outcome,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
jobs,
|
||||
"_imap_recovery_evidence",
|
||||
lambda _job_id: (outcome, evidence),
|
||||
)
|
||||
operation = Mock()
|
||||
|
||||
jobs._finish_imap_append_recovery(operation, job_id="job-1")
|
||||
|
||||
method = getattr(operation, expected_method)
|
||||
method.assert_called_once()
|
||||
if expected_status is not None:
|
||||
assert method.call_args.kwargs["status"] == expected_status
|
||||
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
|
||||
getattr(operation, other).assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "expected_method", "expected_status"),
|
||||
[
|
||||
("succeeded", "succeed", None),
|
||||
("failed", "reject", None),
|
||||
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
|
||||
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
|
||||
],
|
||||
)
|
||||
def test_single_action_recovery_maps_verified_action_state(
|
||||
monkeypatch,
|
||||
outcome: str,
|
||||
expected_method: str,
|
||||
expected_status: RecoveryStatus | None,
|
||||
) -> None:
|
||||
evidence = {
|
||||
"verified": outcome != "recovery_required",
|
||||
"checks": {"action_state_reloaded": True},
|
||||
"action_status": outcome,
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
jobs,
|
||||
"_single_action_recovery_evidence",
|
||||
lambda _action_id: (outcome, evidence),
|
||||
)
|
||||
operation = Mock()
|
||||
|
||||
jobs._finish_single_action_delivery_recovery(operation, action_id="action-1")
|
||||
|
||||
method = getattr(operation, expected_method)
|
||||
method.assert_called_once()
|
||||
if expected_status is not None:
|
||||
assert method.call_args.kwargs["status"] == expected_status
|
||||
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
|
||||
getattr(operation, other).assert_not_called()
|
||||
@@ -0,0 +1,295 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_policy
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.campaigns import (
|
||||
archive_campaign_version,
|
||||
copy_campaign,
|
||||
delete_draft_campaign,
|
||||
)
|
||||
from govoplan_campaign.backend.schemas import (
|
||||
CampaignCopyRequest,
|
||||
CampaignLifecycleMutationRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class _Principal:
|
||||
tenant_id = "tenant-1"
|
||||
user = SimpleNamespace(id="user-1")
|
||||
|
||||
def __init__(self, *scopes: str) -> None:
|
||||
self.scopes = frozenset(scopes)
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
return scope in self.scopes or "tenant:*" in self.scopes
|
||||
|
||||
|
||||
class CampaignLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
access_users = Base.metadata.tables.get("access_users")
|
||||
if access_users is None:
|
||||
access_users = Table(
|
||||
"access_users",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
access_groups = Base.metadata.tables.get("access_groups")
|
||||
if access_groups is None:
|
||||
access_groups = Table(
|
||||
"access_groups",
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
access_users,
|
||||
access_groups,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignJob.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
session.execute(access_users.insert().values(id="user-1"))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
external_id="campaign-1",
|
||||
name="Campaign",
|
||||
status="draft",
|
||||
current_version_id="version-2",
|
||||
)
|
||||
historical = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
workflow_state="completed",
|
||||
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
|
||||
)
|
||||
current = CampaignVersion(
|
||||
id="version-2",
|
||||
campaign_id=campaign.id,
|
||||
version_number=2,
|
||||
workflow_state="editing",
|
||||
raw_json={"version": "1.0", "campaign": {"id": "campaign-1", "name": "Campaign"}},
|
||||
)
|
||||
session.add_all((campaign, historical, current))
|
||||
session.commit()
|
||||
self.principal = _Principal(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:archive",
|
||||
"campaigns:campaign:delete",
|
||||
"campaigns:recipient:read",
|
||||
)
|
||||
self.addCleanup(self.engine.dispose)
|
||||
|
||||
def _policy(self, session: Session, version_id: str | None = None):
|
||||
campaign = session.get(Campaign, "campaign-1")
|
||||
assert campaign is not None
|
||||
return campaign_lifecycle_policy(
|
||||
session,
|
||||
campaign=campaign,
|
||||
principal=self.principal,
|
||||
version_id=version_id,
|
||||
)
|
||||
|
||||
def test_policy_explains_retained_evidence_and_changes_token(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
initial = self._policy(session, "version-1")
|
||||
self.assertTrue(initial["actions"]["delete_campaign"]["allowed"])
|
||||
self.assertTrue(initial["actions"]["archive_version"]["allowed"])
|
||||
|
||||
current = session.get(CampaignVersion, "version-2")
|
||||
assert current is not None
|
||||
current.published_at = datetime.now(UTC)
|
||||
session.commit()
|
||||
changed = self._policy(session, "version-1")
|
||||
|
||||
self.assertNotEqual(initial["state_token"], changed["state_token"])
|
||||
self.assertFalse(changed["actions"]["delete_campaign"]["allowed"])
|
||||
self.assertIn("Audit-relevant", changed["actions"]["delete_campaign"]["reason"])
|
||||
|
||||
def test_active_delivery_blocks_campaign_archival(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
session.add(
|
||||
CampaignJob(
|
||||
id="job-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
campaign_version_id="version-2",
|
||||
entry_index=0,
|
||||
queue_status="sending",
|
||||
send_status="sending",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
policy = self._policy(session)
|
||||
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
|
||||
|
||||
def test_stale_delete_token_is_rejected(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
policy = self._policy(session)
|
||||
campaign = session.get(Campaign, "campaign-1")
|
||||
assert campaign is not None
|
||||
campaign.name = "Changed elsewhere"
|
||||
session.commit()
|
||||
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
delete_draft_campaign(
|
||||
"campaign-1",
|
||||
CampaignLifecycleMutationRequest(
|
||||
expected_state_token=policy["state_token"],
|
||||
),
|
||||
session=session,
|
||||
principal=self.principal,
|
||||
)
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
self.assertIn("state changed", str(raised.exception.detail))
|
||||
|
||||
def test_historical_archival_preserves_version_state_and_content(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
policy = self._policy(session, "version-1")
|
||||
|
||||
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
|
||||
active_session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
|
||||
side_effect=commit_audit,
|
||||
):
|
||||
response = archive_campaign_version(
|
||||
"campaign-1",
|
||||
"version-1",
|
||||
CampaignLifecycleMutationRequest(
|
||||
expected_state_token=policy["state_token"],
|
||||
),
|
||||
session=session,
|
||||
principal=self.principal,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(response.archived_at)
|
||||
self.assertEqual(response.archived_by_user_id, "user-1")
|
||||
version = session.get(CampaignVersion, "version-1")
|
||||
assert version is not None
|
||||
self.assertEqual(version.workflow_state, "completed")
|
||||
self.assertEqual(version.raw_json["campaign"]["name"], "Campaign")
|
||||
|
||||
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
session.add_all(
|
||||
(
|
||||
CampaignShare(
|
||||
id="share-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
target_type="user",
|
||||
target_id="user-1",
|
||||
permission="read",
|
||||
),
|
||||
CampaignJob(
|
||||
id="job-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
campaign_version_id="version-2",
|
||||
entry_index=0,
|
||||
queue_status="cancelled",
|
||||
send_status="cancelled",
|
||||
),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
policy = self._policy(session, "version-2")
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def create_copy(active_session: Session, **kwargs):
|
||||
raw_json = kwargs["raw_json"]
|
||||
captured["raw_json"] = raw_json
|
||||
destination = Campaign(
|
||||
id="campaign-copy",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
external_id=raw_json["campaign"]["id"],
|
||||
name=raw_json["campaign"]["name"],
|
||||
status="draft",
|
||||
current_version_id="version-copy",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-copy",
|
||||
campaign_id=destination.id,
|
||||
version_number=1,
|
||||
raw_json=raw_json,
|
||||
)
|
||||
active_session.add_all((destination, version))
|
||||
active_session.flush()
|
||||
return destination, version
|
||||
|
||||
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
|
||||
active_session.commit()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.campaigns.create_campaign_version_from_json",
|
||||
side_effect=create_copy,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.routes.campaigns.audit_from_principal",
|
||||
side_effect=commit_audit,
|
||||
),
|
||||
):
|
||||
response = copy_campaign(
|
||||
"campaign-1",
|
||||
CampaignCopyRequest(
|
||||
source_version_id="version-2",
|
||||
expected_state_token=policy["state_token"],
|
||||
),
|
||||
session=session,
|
||||
principal=self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
|
||||
self.assertEqual(response.campaign.owner_user_id, "user-1")
|
||||
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
|
||||
self.assertEqual(
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_id == "campaign-copy")
|
||||
.count(),
|
||||
0,
|
||||
)
|
||||
self.assertEqual(
|
||||
session.query(CampaignShare)
|
||||
.filter(CampaignShare.campaign_id == "campaign-copy")
|
||||
.count(),
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -54,6 +54,30 @@ def test_campaign_runtime_documentation_provider_is_registered() -> None:
|
||||
assert documentation_topics in get_manifest().documentation_providers
|
||||
|
||||
|
||||
def test_complete_review_workflow_documents_each_attention_class() -> None:
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
|
||||
topic = next(
|
||||
item
|
||||
for item in get_manifest().documentation
|
||||
if item.id == "campaigns.workflow.complete-review"
|
||||
)
|
||||
|
||||
rendered = "\n".join(
|
||||
(
|
||||
topic.summary,
|
||||
topic.body,
|
||||
*topic.metadata["steps"],
|
||||
topic.metadata["verification"],
|
||||
)
|
||||
)
|
||||
assert "Critical blockers" in rendered
|
||||
assert "Individual review" in rendered
|
||||
assert "Group review" in rendered
|
||||
assert "Remaining is zero" in rendered
|
||||
assert topic.metadata["help_contexts"] == ["campaign.review-send"]
|
||||
|
||||
|
||||
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||
assert _topics({"docs:documentation:read"}) == ()
|
||||
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||
|
||||
@@ -159,6 +159,11 @@ def test_post_provider_persistence_failure_freezes_imap_retry() -> None:
|
||||
patch("govoplan_campaign.backend.sending.jobs._load_eml_bytes_for_job", return_value=b"message"),
|
||||
patch("govoplan_campaign.backend.sending.jobs._claim_job_for_imap_append", return_value="claim-1"),
|
||||
patch("govoplan_campaign.backend.sending.jobs._record_imap_attempt_start", return_value=attempt),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._begin_imap_append_recovery",
|
||||
return_value=SimpleNamespace(replayed=False, operation=MagicMock()),
|
||||
),
|
||||
patch("govoplan_campaign.backend.sending.jobs._finish_imap_append_recovery"),
|
||||
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._record_imap_append_success",
|
||||
@@ -289,6 +294,7 @@ def test_imap_reconciliation_preserves_attempt_and_only_not_appended_is_retryabl
|
||||
imap_claimed_at=datetime.now(timezone.utc),
|
||||
imap_claim_token="claim-1",
|
||||
last_error="unknown",
|
||||
delivery_provenance={},
|
||||
)
|
||||
attempt = SimpleNamespace(
|
||||
id="attempt-1",
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from govoplan_campaign.backend import route_support
|
||||
from govoplan_campaign.backend.routes import attachments as attachment_routes
|
||||
from govoplan_campaign.backend.routes import versions as router
|
||||
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
@@ -94,6 +95,42 @@ def test_loader_rejects_inline_transport_before_optional_mail_summary() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_attachment_preview_reports_legacy_mail_boundary_as_validation_error() -> None:
|
||||
campaign = SimpleNamespace(id="campaign-1")
|
||||
version = SimpleNamespace(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
raw_json=_campaign_json({"smtp": {"host": "legacy.example.test"}}),
|
||||
)
|
||||
principal = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(attachment_routes, "_get_campaign_for_principal"),
|
||||
patch.object(attachment_routes, "_require_permission"),
|
||||
patch.object(attachment_routes, "_get_campaign_for_tenant", return_value=campaign),
|
||||
patch.object(attachment_routes, "_get_version_for_tenant", return_value=version),
|
||||
patch.object(attachment_routes, "_require_mail_profile_use_if_needed"),
|
||||
patch.object(
|
||||
attachment_routes,
|
||||
"_attachment_preview_for_version",
|
||||
side_effect=CampaignMailProfileBoundaryError("Select an authorized Mail profile."),
|
||||
),
|
||||
pytest.raises(HTTPException) as captured,
|
||||
):
|
||||
attachment_routes.preview_campaign_attachments(
|
||||
campaign.id,
|
||||
version.id,
|
||||
session=object(), # type: ignore[arg-type]
|
||||
principal=principal, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert captured.value.status_code == 422
|
||||
assert captured.value.detail == "Select an authorized Mail profile."
|
||||
|
||||
|
||||
def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
|
||||
raw = _campaign_json({"mail_profile_id": "profile-1"})
|
||||
|
||||
|
||||
@@ -62,3 +62,12 @@ def test_aggregate_reports_are_an_integrated_campaign_view() -> None:
|
||||
]
|
||||
assert len(report_surfaces) == 1
|
||||
assert report_surfaces[0].description == "/campaigns/reports"
|
||||
|
||||
|
||||
def test_reusable_template_library_is_not_owned_by_campaign() -> None:
|
||||
manifest = get_manifest()
|
||||
assert manifest.frontend is not None
|
||||
|
||||
assert "/templates" not in {item.path for item in manifest.nav_items}
|
||||
assert "/templates" not in {item.path for item in manifest.frontend.nav_items}
|
||||
assert "/templates" not in {route.path for route in manifest.frontend.routes}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import AbstractContextManager
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_core.core.recovery import RecoveryStatus
|
||||
from govoplan_campaign.backend import retention
|
||||
|
||||
|
||||
class _EvidenceSession(AbstractContextManager):
|
||||
def __init__(self, job: object | None) -> None:
|
||||
self.job = job
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args) -> None:
|
||||
return None
|
||||
|
||||
def get(self, _model, _object_id):
|
||||
return self.job
|
||||
|
||||
|
||||
def _recovery(*, storage, storage_key: str | None, local_path: str | None):
|
||||
return retention._GeneratedArtifactRecovery(
|
||||
operation=Mock(),
|
||||
job_id="job-1",
|
||||
storage_key=storage_key,
|
||||
local_path=local_path,
|
||||
storage=storage,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("outcome", "expected_method", "expected_status"),
|
||||
[
|
||||
("succeeded", "succeed", None),
|
||||
("failed", "reject", None),
|
||||
("outcome_unknown", "unresolved", RecoveryStatus.OUTCOME_UNKNOWN),
|
||||
("recovery_required", "unresolved", RecoveryStatus.RECOVERY_REQUIRED),
|
||||
],
|
||||
)
|
||||
def test_retention_recovery_maps_verified_artifact_state(
|
||||
monkeypatch,
|
||||
outcome: str,
|
||||
expected_method: str,
|
||||
expected_status: RecoveryStatus | None,
|
||||
) -> None:
|
||||
recovery = _recovery(
|
||||
storage=Mock(),
|
||||
storage_key="campaigns/message.eml",
|
||||
local_path=None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
retention,
|
||||
"_generated_artifact_recovery_evidence",
|
||||
lambda _recovery: (
|
||||
outcome,
|
||||
{
|
||||
"verified": outcome != "outcome_unknown",
|
||||
"checks": {"artifact_locations_probed": True},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
retention._finish_generated_artifact_recovery(recovery)
|
||||
|
||||
method = getattr(recovery.operation, expected_method)
|
||||
method.assert_called_once()
|
||||
if expected_status is not None:
|
||||
assert method.call_args.kwargs["status"] == expected_status
|
||||
for other in {"succeed", "reject", "unresolved"} - {expected_method}:
|
||||
getattr(recovery.operation, other).assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("object_exists", "metadata_key", "expected"),
|
||||
[
|
||||
(False, None, "succeeded"),
|
||||
(True, "campaigns/message.eml", "failed"),
|
||||
(False, "campaigns/message.eml", "recovery_required"),
|
||||
(True, None, "recovery_required"),
|
||||
],
|
||||
)
|
||||
def test_retention_recovery_compares_storage_and_database_independently(
|
||||
monkeypatch,
|
||||
object_exists: bool,
|
||||
metadata_key: str | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
storage = Mock()
|
||||
storage.exists.return_value = object_exists
|
||||
job = SimpleNamespace(
|
||||
eml_storage_key=metadata_key,
|
||||
eml_local_path=None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
retention,
|
||||
"get_database",
|
||||
lambda: SimpleNamespace(
|
||||
SessionLocal=lambda: _EvidenceSession(job),
|
||||
),
|
||||
)
|
||||
|
||||
outcome, evidence = retention._generated_artifact_recovery_evidence(
|
||||
_recovery(
|
||||
storage=storage,
|
||||
storage_key="campaigns/message.eml",
|
||||
local_path=None,
|
||||
)
|
||||
)
|
||||
|
||||
assert outcome == expected
|
||||
assert evidence["verified"] is True
|
||||
|
||||
|
||||
def test_campaign_retention_commits_before_recovery_verification(monkeypatch) -> None:
|
||||
events: list[str] = []
|
||||
session = Mock()
|
||||
session.commit.side_effect = lambda: events.append("commit")
|
||||
recovery = Mock()
|
||||
|
||||
monkeypatch.setattr(retention, "_apply_raw_json_retention", lambda *_args, **_kwargs: {})
|
||||
monkeypatch.setattr(retention, "_apply_report_detail_retention", lambda *_args, **_kwargs: {})
|
||||
|
||||
def apply_eml(*_args, **kwargs):
|
||||
kwargs["recovery_operations"].append(recovery)
|
||||
return {"metadata_cleared": 1}
|
||||
|
||||
monkeypatch.setattr(retention, "_apply_eml_retention", apply_eml)
|
||||
monkeypatch.setattr(
|
||||
retention,
|
||||
"_finish_generated_artifact_recoveries",
|
||||
lambda _recoveries: events.append("verify"),
|
||||
)
|
||||
|
||||
result = retention.apply_campaign_retention(
|
||||
session,
|
||||
dry_run=False,
|
||||
now=datetime.now(timezone.utc),
|
||||
policy_for_campaign_id=lambda _campaign_id: object(),
|
||||
)
|
||||
|
||||
assert result["generated_eml"] == {"metadata_cleared": 1}
|
||||
assert events == ["commit", "verify"]
|
||||
session.rollback.assert_not_called()
|
||||
@@ -7,6 +7,7 @@ from govoplan_campaign.backend.routes.attachments import router as attachments_r
|
||||
from govoplan_campaign.backend.routes.campaigns import router as campaigns_router
|
||||
from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||
from govoplan_campaign.backend.routes.reports import router as reports_router
|
||||
from govoplan_campaign.backend.routes.sharing import router as sharing_router
|
||||
from govoplan_campaign.backend.routes.versions import router as versions_router
|
||||
@@ -22,6 +23,7 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
|
||||
|
||||
def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
workflow_routers = (
|
||||
operations_router,
|
||||
campaigns_router,
|
||||
versions_router,
|
||||
jobs_router,
|
||||
@@ -38,12 +40,16 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 70
|
||||
assert len(actual) == 72
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||
expectations = (
|
||||
(
|
||||
operations_router,
|
||||
("POST", "/campaigns/operations/artifacts/reconcile"),
|
||||
),
|
||||
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
|
||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
from govoplan_campaign.backend.search_source import (
|
||||
CampaignSearchSource,
|
||||
PROVIDER_ID,
|
||||
RESOURCE_TYPE,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class CampaignSearchSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
),
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
self.session.add_all(
|
||||
(
|
||||
Account(
|
||||
id="account-1",
|
||||
email="one@example.test",
|
||||
normalized_email="one@example.test",
|
||||
),
|
||||
User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
email="one@example.test",
|
||||
),
|
||||
Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_user_id="user-1",
|
||||
external_id="monthly-letters",
|
||||
name="Monthly letters",
|
||||
),
|
||||
Campaign(
|
||||
id="campaign-other",
|
||||
tenant_id="tenant-2",
|
||||
external_id="other",
|
||||
name="Other tenant",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
self.source = CampaignSearchSource()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_backfill_and_live_acl_recheck_do_not_cross_tenants(self) -> None:
|
||||
page = self.source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(("campaign-1",), tuple(doc.resource_id for doc in page.documents))
|
||||
reference = SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="campaigns",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id="campaign-1",
|
||||
)
|
||||
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||
self.assertTrue(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({"campaigns:campaign:read"}),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
self.assertFalse(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal(set()),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -60,6 +60,19 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
||||
self.assertIsNotNone(capability)
|
||||
configure.assert_called_once_with(registry=registry, settings=settings)
|
||||
|
||||
def test_delivery_task_capability_resolves_job_tenant_before_effect(self):
|
||||
capability = delivery_tasks_capability(
|
||||
SimpleNamespace(registry=object(), settings=object())
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
get=lambda _model, _job_id: SimpleNamespace(tenant_id="tenant-1")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"tenant-1",
|
||||
capability.tenant_id_for_job(session, job_id="job-1"),
|
||||
)
|
||||
|
||||
def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
|
||||
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
|
||||
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
|
||||
@@ -181,7 +194,10 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
||||
)
|
||||
with (
|
||||
patch("govoplan_campaign.backend.sending.jobs.mail_integration", return_value=Mail()),
|
||||
patch("govoplan_campaign.backend.sending.jobs._record_attempt_start", return_value=object()),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._record_attempt_start",
|
||||
return_value=SimpleNamespace(attempt_number=1),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._record_smtp_send_success",
|
||||
side_effect=OSError("storage unavailable"),
|
||||
|
||||
@@ -170,6 +170,13 @@ class CampaignSingleMessageActionTests(unittest.TestCase):
|
||||
"govoplan_campaign.backend.sending.jobs.mail_integration",
|
||||
return_value=mail,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._begin_single_action_delivery_recovery",
|
||||
return_value=SimpleNamespace(replayed=False, operation=Mock()),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finish_single_action_delivery_recovery"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.audit_event"
|
||||
),
|
||||
@@ -269,6 +276,13 @@ class CampaignSingleMessageActionTests(unittest.TestCase):
|
||||
"govoplan_campaign.backend.sending.jobs.mail_integration",
|
||||
return_value=mail,
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._begin_single_action_delivery_recovery",
|
||||
return_value=SimpleNamespace(replayed=False, operation=Mock()),
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs._finish_single_action_delivery_recovery"
|
||||
),
|
||||
patch(
|
||||
"govoplan_campaign.backend.sending.jobs.files_integration"
|
||||
) as files,
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
@@ -30,10 +30,12 @@
|
||||
"test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
|
||||
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
|
||||
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
|
||||
"test:review-workflow": "node --experimental-strip-types --test tests/review-workflow-guidance.test.ts && node tests/review-workflow-guidance-ui-structure.test.mjs",
|
||||
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
|
||||
"test:aggregate-report": "tsc -p tsconfig.aggregate-report-tests.json && node tests/aggregate-report-ui-structure.test.mjs",
|
||||
"test:wizards": "node tests/wizard-directory-ui-structure.test.mjs",
|
||||
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs"
|
||||
"test:accessibility-contract": "node tests/accessibility-contract.test.mjs",
|
||||
"test:campaign-lifecycle": "node tests/campaign-lifecycle-ui-structure.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
|
||||
@@ -85,6 +85,21 @@ export type CampaignVersionListItem = {
|
||||
execution_snapshot_at?: string | null;
|
||||
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
|
||||
delivery_mode_selected_at?: string | null;
|
||||
archived_at?: string | null;
|
||||
archived_by_user_id?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignLifecycleAction = {
|
||||
allowed: boolean;
|
||||
reason?: string | null;
|
||||
};
|
||||
|
||||
export type CampaignLifecyclePolicy = {
|
||||
policy_id: string;
|
||||
policy_version: string;
|
||||
state_token: string;
|
||||
actions: Record<string, CampaignLifecycleAction>;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||
@@ -1002,6 +1017,64 @@ payload: CampaignUpdatePayload)
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<CampaignListItem> {
|
||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_state_token: expectedStateToken })
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCampaignLifecyclePolicy(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId?: string | null)
|
||||
: Promise<CampaignLifecyclePolicy> {
|
||||
const suffix = versionId ? `?version_id=${encodeURIComponent(versionId)}` : "";
|
||||
return apiFetch<CampaignLifecyclePolicy>(settings, `/api/v1/campaigns/${campaignId}/lifecycle-policy${suffix}`);
|
||||
}
|
||||
|
||||
export async function deleteCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<void> {
|
||||
await apiFetch<void>(settings, `/api/v1/campaigns/${campaignId}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ expected_state_token: expectedStateToken })
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyCampaign(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
sourceVersionId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<CampaignCreateResponse> {
|
||||
return apiFetch<CampaignCreateResponse>(settings, `/api/v1/campaigns/${campaignId}/copies`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
source_version_id: sourceVersionId,
|
||||
expected_state_token: expectedStateToken
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveCampaignVersion(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
versionId: string,
|
||||
expectedStateToken: string)
|
||||
: Promise<CampaignVersionListItem> {
|
||||
return apiFetch<CampaignVersionListItem>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/archive`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ expected_state_token: expectedStateToken })
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNewCampaign(
|
||||
settings: ApiSettings,
|
||||
overrides: CampaignCreateMinimalPayload = {})
|
||||
@@ -1209,7 +1282,7 @@ writeEml = true)
|
||||
: Promise<Record<string, unknown>> {
|
||||
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/versions/${versionId}/build`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ write_eml: writeEml })
|
||||
body: JSON.stringify({ write_eml: writeEml, idempotency_key: crypto.randomUUID() })
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import VersionLine from "./components/VersionLine";
|
||||
import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { ActionBlockerHint, DocumentationHelpLink } from "@govoplan/core-webui";
|
||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||
|
||||
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
@@ -27,7 +28,27 @@ export default function CampaignAuditPage({ settings, campaignId }: {settings: A
|
||||
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
||||
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
||||
<p className="muted">i18n:govoplan-campaign.campaign_specific_audit_api_integration_will_be_.e53c8280</p>
|
||||
<ActionBlockerHint
|
||||
tone="info"
|
||||
reason={{
|
||||
summary: "Campaign-specific audit projection is not available on this page.",
|
||||
details: "Campaign actions already emit bounded platform audit evidence. Authorized readers can inspect it in the Audit administration surface.",
|
||||
requiredAction: "Open tenant audit and filter by the campaign identifier.",
|
||||
actor: "Audit reader or system operator",
|
||||
target: "Administration > Tenant audit"
|
||||
}}
|
||||
documentation={{
|
||||
topicId: "campaigns.reference.composition-assurance",
|
||||
documentationType: "admin"
|
||||
}}
|
||||
/>
|
||||
<DocumentationHelpLink
|
||||
reference={{
|
||||
topicId: "campaigns.reference.composition-assurance",
|
||||
documentationType: "user"
|
||||
}}
|
||||
label="Open Campaign assurance documentation"
|
||||
/>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
</div>);
|
||||
|
||||
@@ -29,6 +29,11 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
||||
</div>
|
||||
</div>
|
||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
||||
<DismissibleAlert tone="warning" dismissible={false}>
|
||||
This expert view contains the complete authorized campaign configuration,
|
||||
including recipient and message fields that may contain personal data.
|
||||
Download and share it only for an authorized purpose.
|
||||
</DismissibleAlert>
|
||||
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
|
||||
<Card>
|
||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
|
||||
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import type { ApiSettings, AuthInfo } from "../../types";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
@@ -10,13 +10,20 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
||||
import { MetricCard } from "@govoplan/core-webui";
|
||||
import { PageTitle } from "@govoplan/core-webui";
|
||||
import { StatusBadge } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, TableActionGroup, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||
import {
|
||||
archiveCampaign,
|
||||
archiveCampaignVersion,
|
||||
copyCampaign,
|
||||
deleteCampaign,
|
||||
getCampaignLifecyclePolicy,
|
||||
lockCampaignVersionPermanently,
|
||||
lockCampaignVersionTemporarily,
|
||||
unlockCampaignVersionUserLock,
|
||||
updateCampaignMetadata,
|
||||
type CampaignLifecyclePolicy,
|
||||
type CampaignVersionDetail,
|
||||
type CampaignVersionListItem } from
|
||||
"../../api/campaigns";
|
||||
@@ -38,19 +45,32 @@ import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddre
|
||||
const campaignModeOptions = ["draft", "test", "send"];
|
||||
type LockAction = "temporary" | "unlock" | "permanent";
|
||||
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
||||
type LifecycleAction = "archive_campaign" | "delete_campaign" | "copy_campaign" | "archive_version";
|
||||
type PendingLifecycleAction = {
|
||||
action: LifecycleAction;
|
||||
policy: CampaignLifecyclePolicy;
|
||||
version?: CampaignVersionListItem;
|
||||
} | null;
|
||||
|
||||
export default function CampaignOverviewPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||
const navigate = useGuardedNavigate();
|
||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||
const campaign = data.campaign;
|
||||
const versions = useMemo(() => data.versions.slice().sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions]);
|
||||
const [showArchivedVersions, setShowArchivedVersions] = useState(false);
|
||||
const archivedVersionCount = useMemo(() => data.versions.filter((version) => Boolean(version.archived_at)).length, [data.versions]);
|
||||
const versions = useMemo(() => data.versions.filter((version) => showArchivedVersions || !version.archived_at).sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0)), [data.versions, showArchivedVersions]);
|
||||
const [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
||||
const [identityDirty, setIdentityDirty] = useState(false);
|
||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||
const [lockBusy, setLockBusy] = useState(false);
|
||||
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
||||
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
|
||||
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy") && hasScope(auth, "campaigns:recipient:read");
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty: identityDirty,
|
||||
@@ -145,6 +165,59 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
await reload({ force: true });
|
||||
}
|
||||
|
||||
async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) {
|
||||
if (!campaign || lifecycleBusy || identityDirty) return;
|
||||
setLifecycleBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const policy = await getCampaignLifecyclePolicy(settings, campaign.id, version?.id);
|
||||
const decision = policy.actions[action];
|
||||
if (!decision?.allowed) {
|
||||
setError(decision?.reason || "This lifecycle action is not available for the current campaign state.");
|
||||
return;
|
||||
}
|
||||
setPendingLifecycleAction({ action, policy, version });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLifecycleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyLifecycleAction() {
|
||||
if (!campaign || !pendingLifecycleAction || lifecycleBusy) return;
|
||||
const pending = pendingLifecycleAction;
|
||||
setLifecycleBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
if (pending.action === "archive_campaign") {
|
||||
await archiveCampaign(settings, campaign.id, pending.policy.state_token);
|
||||
setMessage("i18n:govoplan-campaign.campaign_archived.3f0ca2b7");
|
||||
} else if (pending.action === "delete_campaign") {
|
||||
await deleteCampaign(settings, campaign.id, pending.policy.state_token);
|
||||
setPendingLifecycleAction(null);
|
||||
navigate("/campaigns");
|
||||
return;
|
||||
} else if (pending.action === "copy_campaign" && pending.version) {
|
||||
const created = await copyCampaign(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
||||
setPendingLifecycleAction(null);
|
||||
navigate(`/campaigns/${created.campaign.id}`);
|
||||
return;
|
||||
} else if (pending.action === "archive_version" && pending.version) {
|
||||
await archiveCampaignVersion(settings, campaign.id, pending.version.id, pending.policy.state_token);
|
||||
setMessage(`Version #${pending.version.version_number} archived.`);
|
||||
}
|
||||
setPendingLifecycleAction(null);
|
||||
await reload({ force: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLifecycleBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content-pad workspace-data-page">
|
||||
<div className="page-heading split workspace-heading">
|
||||
@@ -153,6 +226,29 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||
</div>
|
||||
<div className="button-row compact-actions">
|
||||
{canCopy && data.currentVersion && <Button
|
||||
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before copying." : undefined}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
Copy campaign
|
||||
</Button>}
|
||||
{canDelete && <Button
|
||||
variant="danger"
|
||||
onClick={() => void prepareLifecycleAction("delete_campaign")}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "Save or discard overview changes before deleting." : undefined}>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
Delete draft
|
||||
</Button>}
|
||||
{canArchive && <Button
|
||||
variant="danger"
|
||||
onClick={() => void prepareLifecycleAction("archive_campaign")}
|
||||
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||
disabledReason={identityDirty ? "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0" : undefined}>
|
||||
<Archive size={16} aria-hidden="true" />
|
||||
i18n:govoplan-campaign.archive_campaign.26dcfb8a
|
||||
</Button>}
|
||||
<Button onClick={() => void discardOverview()} disabled={loading || savingIdentity || lockBusy}>Discard</Button>
|
||||
<Button variant="primary" onClick={() => void saveIdentity()} disabled={!campaign || !identityDirty || savingIdentity}>{savingIdentity ? "i18n:govoplan-campaign.saving.56a2285c" : "i18n:govoplan-campaign.save.efc007a3"}</Button>
|
||||
</div>
|
||||
@@ -191,14 +287,19 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Versions" collapsible actions={<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>}>
|
||||
<Card title="Versions" collapsible actions={<div className="button-row compact-actions">
|
||||
{archivedVersionCount > 0 && <ToggleSwitch
|
||||
label={`Show archived (${archivedVersionCount})`}
|
||||
checked={showArchivedVersions}
|
||||
onChange={setShowArchivedVersions} />}
|
||||
<Link
|
||||
to={`send?version=${campaign?.current_version_id}`}
|
||||
className={`btn btn-primary`}
|
||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||
i18n:govoplan-campaign.open.cf9b7706
|
||||
</Link>
|
||||
</div>}>
|
||||
<div className="metric-grid inside campaign-versions-metrics">
|
||||
<MetricCard label="i18n:govoplan-campaign.version.2da600bf" value={data.currentVersion?.version_number ? `#${data.currentVersion.version_number}` : "—"} tone="neutral" />
|
||||
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||
@@ -215,17 +316,34 @@ export default function CampaignOverviewPage({ settings, campaignId }: {settings
|
||||
<DataGrid
|
||||
id={`campaign-${campaignId}-versions`}
|
||||
rows={versions}
|
||||
columns={versionColumns(setPendingLockAction, navigate, campaign?.current_version_id)}
|
||||
columns={versionColumns(
|
||||
setPendingLockAction,
|
||||
navigate,
|
||||
campaign?.current_version_id,
|
||||
canCopy,
|
||||
hasScope(auth, "campaigns:campaign:archive"),
|
||||
(action, version) => void prepareLifecycleAction(action, version)
|
||||
)}
|
||||
getRowKey={(version) => version.id}
|
||||
initialSort={{ columnId: "version", direction: "desc" }}
|
||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||
className="version-history-table"
|
||||
rowClassName={(version) => version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
||||
rowClassName={(version) => version.archived_at ? "archived-version-row" : version.id === data.currentVersion?.id ? "current-version-row" : undefined} />
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
</LoadingFrame>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLifecycleAction)}
|
||||
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
||||
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
||||
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
||||
tone="danger"
|
||||
busy={lifecycleBusy}
|
||||
onCancel={() => setPendingLifecycleAction(null)}
|
||||
onConfirm={() => void applyLifecycleAction()} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(pendingLockAction)}
|
||||
title={lockDialogTitle(pendingLockAction)}
|
||||
@@ -297,7 +415,14 @@ function textValue(value: unknown, fallback = ""): string {
|
||||
return typeof value === "string" ? value : fallback;
|
||||
}
|
||||
|
||||
function versionColumns(setPendingLockAction: (action: PendingLockAction) => void, navigate: (to: string) => void, currentVersionId?: string | null): DataGridColumn<CampaignVersionListItem>[] {
|
||||
function versionColumns(
|
||||
setPendingLockAction: (action: PendingLockAction) => void,
|
||||
navigate: (to: string) => void,
|
||||
currentVersionId: string | null | undefined,
|
||||
canCopy: boolean,
|
||||
canArchive: boolean,
|
||||
onLifecycleAction: (action: LifecycleAction, version: CampaignVersionListItem) => void
|
||||
): DataGridColumn<CampaignVersionListItem>[] {
|
||||
return [
|
||||
{ id: "version", header: "i18n:govoplan-campaign.version.2da600bf", width: 110, sortable: true, filterable: true, filterType: "integer", sticky: "start", render: (version) => `#${version.version_number}`, value: (version) => version.version_number ?? 0 },
|
||||
{ id: "state", header: "i18n:govoplan-campaign.state.a7250206", width: 140, sortable: true, filterable: true, columnType: "from-list", list: { options: ["editing", "validated", "built", "approved", "queued", "sending", "sent", "completed", "partially_completed", "outcome_unknown", "failed", "partially_sent", "failed_partial", "cancelled", "archived"].map((value) => ({ value, label: value.replace(/_/g, " ") })), display: "pill" }, render: (version) => <StatusBadge status={version.workflow_state ?? "editing"} />, value: (version) => version.workflow_state ?? "editing" },
|
||||
@@ -317,6 +442,8 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
|
||||
return <TableActionGroup actions={[
|
||||
{ id: "open", label: i18nMessage("i18n:govoplan-campaign.open_version_value.7ef53546", { value0: version.version_number }), icon: <ExternalLink aria-hidden="true" />, variant: isCurrent ? "primary" : "secondary", onClick: () => navigate(`send?version=${version.id}`) },
|
||||
{ id: "copy-campaign", label: "Copy as new campaign", icon: <Copy aria-hidden="true" />, applicable: canCopy, onClick: () => onLifecycleAction("copy_campaign", version) },
|
||||
{ id: "archive-version", label: "Archive historical version", icon: <Archive aria-hidden="true" />, variant: "danger", applicable: canArchive && !isCurrent && !version.archived_at, onClick: () => onLifecycleAction("archive_version", version) },
|
||||
{ id: "unlock", label: "i18n:govoplan-campaign.unlock.1526a17e", icon: <LockOpen aria-hidden="true" />, applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "unlock" }) },
|
||||
{ id: "permanent-lock", label: "i18n:govoplan-campaign.lock_permanently.cc0ce9e7", icon: <LockKeyhole aria-hidden="true" />, variant: "danger", applicable: temporarilyLocked, onClick: () => setPendingLockAction({ version, action: "permanent" }) },
|
||||
{ id: "temporary-lock", label: i18nMessage("i18n:govoplan-campaign.temporarily_lock_version_value.8019e581", { value0: version.version_number }), icon: <LockKeyhole aria-hidden="true" />, applicable: canTemporarilyLock, onClick: () => setPendingLockAction({ version, action: "temporary" }) }
|
||||
@@ -328,6 +455,7 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
||||
}
|
||||
|
||||
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
||||
if (version.archived_at) return "Archived from default history";
|
||||
if (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
|
||||
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
||||
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
||||
@@ -376,3 +504,27 @@ function lockDialogLabel(pending: PendingLockAction): string {
|
||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
||||
}
|
||||
|
||||
function lifecycleDialogTitle(pending: PendingLifecycleAction): string {
|
||||
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
|
||||
if (pending?.action === "delete_campaign") return "Delete untouched draft";
|
||||
if (pending?.action === "copy_campaign") return "Copy campaign";
|
||||
if (pending?.action === "archive_version") return "Archive historical version";
|
||||
return "Confirm lifecycle action";
|
||||
}
|
||||
|
||||
function lifecycleDialogMessage(pending: PendingLifecycleAction): string {
|
||||
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1";
|
||||
if (pending?.action === "delete_campaign") return "This removes the untouched draft from active work. Drafts with build, delivery, sharing, lock, publication, or snapshot evidence cannot be deleted.";
|
||||
if (pending?.action === "copy_campaign") return `Create a fresh campaign draft from version #${pending.version?.version_number ?? "?"}? Delivery jobs, outcomes, shares, locks, and audit evidence are not copied.`;
|
||||
if (pending?.action === "archive_version") return `Hide historical version #${pending.version?.version_number ?? "?"} from the default history? Its configuration, reports, delivery results, and audit evidence remain available.`;
|
||||
return "Review the lifecycle consequence before continuing.";
|
||||
}
|
||||
|
||||
function lifecycleDialogLabel(pending: PendingLifecycleAction): string {
|
||||
if (pending?.action === "archive_campaign") return "i18n:govoplan-campaign.archive_campaign.26dcfb8a";
|
||||
if (pending?.action === "delete_campaign") return "Delete draft";
|
||||
if (pending?.action === "copy_campaign") return "Create copy";
|
||||
if (pending?.action === "archive_version") return "Archive version";
|
||||
return "Confirm";
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function CampaignWorkspaceInner({ settings, auth }: { settings: ApiSettings; aut
|
||||
<SectionSidebar active={active} onSelect={select} />
|
||||
<section className="workspace-content">
|
||||
<Routes>
|
||||
<Route index element={<CampaignOverviewPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route index element={<CampaignOverviewPage settings={settings} auth={auth} campaignId={campaignId || ""} />} />
|
||||
<Route path="data" element={<Navigate to="../recipients" replace />} />
|
||||
<Route path="fields" element={<CampaignFieldsPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
<Route path="recipients" element={<RecipientDataPage settings={settings} campaignId={campaignId || ""} />} />
|
||||
|
||||
@@ -465,7 +465,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" />
|
||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CircleCheck, CircleX } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getCampaignPostboxCatalog,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
"../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
||||
@@ -39,7 +41,7 @@ import {
|
||||
createAddressSourceImportProvenance
|
||||
} from "./utils/addressSourceImport";
|
||||
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
|
||||
import {
|
||||
@@ -69,6 +71,12 @@ import {
|
||||
} from "./recipients/RecipientAddressEditor";
|
||||
import { RecipientImportDialog } from "./recipients/RecipientImportDialog";
|
||||
import { recipientProfileColumns } from "./recipients/recipientProfileColumns";
|
||||
|
||||
type RecipientBulkActivation = {
|
||||
active: boolean;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
@@ -91,6 +99,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
|
||||
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
||||
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
||||
const [bulkActivation, setBulkActivation] = useState<RecipientBulkActivation | null>(null);
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
@@ -346,6 +355,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
});
|
||||
}
|
||||
|
||||
function requestBulkActivation(active: boolean) {
|
||||
if (locked) return;
|
||||
const count = inlineEntries.filter((entry) => (entry.active !== false) !== active).length;
|
||||
if (count === 0) return;
|
||||
setBulkActivation({ active, count });
|
||||
}
|
||||
|
||||
function confirmBulkActivation() {
|
||||
if (!bulkActivation || locked) return;
|
||||
replaceInlineEntries(inlineEntries.map((entry) => ({ ...entry, active: bulkActivation.active })));
|
||||
setBulkActivation(null);
|
||||
}
|
||||
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
||||
if (locked || !draft) return;
|
||||
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
|
||||
@@ -492,6 +514,22 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button
|
||||
disabled={locked || inlineEntries.every((entry) => entry.active !== false)}
|
||||
onClick={() => requestBulkActivation(true)}>
|
||||
<CircleCheck size={16} aria-hidden="true" />
|
||||
{i18nMessage("i18n:govoplan-campaign.activate_all_value0.7e911e3e", {
|
||||
value0: inlineEntries.filter((entry) => entry.active === false).length
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={locked || inlineEntries.every((entry) => entry.active === false)}
|
||||
onClick={() => requestBulkActivation(false)}>
|
||||
<CircleX size={16} aria-hidden="true" />
|
||||
{i18nMessage("i18n:govoplan-campaign.deactivate_all_value0.87e5d46f", {
|
||||
value0: inlineEntries.filter((entry) => entry.active !== false).length
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
@@ -626,6 +664,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||
|
||||
}
|
||||
<ConfirmDialog
|
||||
open={Boolean(bulkActivation)}
|
||||
title={bulkActivation?.active
|
||||
? "i18n:govoplan-campaign.activate_all_recipients.6be687f0"
|
||||
: "i18n:govoplan-campaign.deactivate_all_recipients.5939b005"}
|
||||
message={bulkActivation ? i18nMessage(
|
||||
bulkActivation.active
|
||||
? "i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50"
|
||||
: "i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f",
|
||||
{ value0: bulkActivation.count }
|
||||
) : ""}
|
||||
confirmLabel={bulkActivation?.active
|
||||
? "i18n:govoplan-campaign.activate_recipients.bc3a7878"
|
||||
: "i18n:govoplan-campaign.deactivate_recipients.88d80611"}
|
||||
tone={bulkActivation?.active ? "default" : "danger"}
|
||||
onConfirm={confirmBulkActivation}
|
||||
onCancel={() => setBulkActivation(null)} />
|
||||
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
|
||||
@@ -72,6 +72,12 @@ import DeliverabilityPreflight, {
|
||||
} from "./review/DeliverabilityPreflight";
|
||||
import DeliveryJobDetailOverlay from "./review/DeliveryJobDetailOverlay";
|
||||
import BuiltMessagePreview from "./review/BuiltMessagePreview";
|
||||
import {
|
||||
BuiltMessageReviewProgress,
|
||||
BuiltMessageWorkflowGuidance,
|
||||
ValidationWorkflowGuidance
|
||||
} from "./review/ReviewWorkflowGuidance";
|
||||
import { calculateBuildReviewProgress } from "./review/reviewProgress";
|
||||
import {
|
||||
WorkflowFact,
|
||||
WorkflowNavigation,
|
||||
@@ -466,7 +472,6 @@ export default function ReviewSendPage({
|
||||
Number(reviewMetadata.reviewed_required_count ?? 0) + newlyReviewedRequiredKeys.size
|
||||
);
|
||||
const reviewRequiredCount = explicitReviewCount + bulkAcceptableCount;
|
||||
const reviewedRequiredCount = reviewedExplicitCount;
|
||||
const automaticInspectionComplete = reviewJobs.total_unfiltered > 0 &&
|
||||
blockingReviewCount === 0 &&
|
||||
reviewRequiredCount === 0;
|
||||
@@ -505,6 +510,13 @@ export default function ReviewSendPage({
|
||||
|
||||
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
||||
const inspectionSatisfied = automaticInspectionComplete || messageReviewComplete || downstreamDeliveryActivity;
|
||||
const buildReviewProgress = calculateBuildReviewProgress({
|
||||
blocking: Math.max(blockingReviewCount, buildBlocked),
|
||||
individualRequired: explicitReviewCount,
|
||||
individualReviewed: reviewedExplicitCount,
|
||||
groupRequired: bulkAcceptableCount,
|
||||
reviewComplete: messageReviewComplete || downstreamDeliveryActivity
|
||||
});
|
||||
|
||||
const buildReviewState: FlowState = !readyForDelivery ?
|
||||
"locked" :
|
||||
@@ -512,7 +524,7 @@ export default function ReviewSendPage({
|
||||
"running" :
|
||||
hasBuild && buildBlocked > 0 ?
|
||||
"danger" :
|
||||
hasBuild && (buildNeedsReview > 0 || buildWarnings > 0 || !inspectionSatisfied) ?
|
||||
hasBuild && !inspectionSatisfied && (buildNeedsReview > 0 || buildWarnings > 0 || buildReviewProgress.remaining > 0) ?
|
||||
"warning" :
|
||||
hasBuild && inspectionSatisfied ?
|
||||
"complete" :
|
||||
@@ -1392,13 +1404,14 @@ export default function ReviewSendPage({
|
||||
<WorkflowFact label="i18n:govoplan-campaign.warnings.1430f976" value={validationPresent ? validationWarnings : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.jobs_needing_attention.95613b02" value={cards?.needs_attention ?? "—"} />
|
||||
</div>
|
||||
{validationStale && <p className="review-flow-inline-note is-stale">i18n:govoplan-campaign.the_stored_validation_result_is_no_longer_an_act.45d2dd14</p>}
|
||||
<ValidationWorkflowGuidance
|
||||
errors={validationErrors}
|
||||
warnings={validationWarnings}
|
||||
stale={validationStale}
|
||||
/>
|
||||
{validationPresent && validationErrors === 0 &&
|
||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.no_blocking_validation_exceptions_remain.73186d0d</p>
|
||||
}
|
||||
{validationErrors > 0 &&
|
||||
<p className="review-flow-inline-note is-danger">i18n:govoplan-campaign.resolve_the_blocking_entries_then_validate_again.e282ae0f</p>
|
||||
}
|
||||
<AttachmentLinkingPreview
|
||||
preview={attachmentPreview}
|
||||
loading={attachmentPreviewLoading}
|
||||
@@ -1447,10 +1460,13 @@ export default function ReviewSendPage({
|
||||
<WorkflowFact label="i18n:govoplan-campaign.built.a6ad3f82" value={hasBuild ? builtCount : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.blocked.99613c74" value={hasBuild ? buildBlocked : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.need_review.201a4493" value={hasBuild ? buildNeedsReview : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? reviewedRequiredCount : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.reviewed.31ef8593" value={hasBuild ? buildReviewProgress.reviewed : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.remaining.cc632b5e" value={hasBuild ? buildReviewProgress.remaining : "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.review_candidates.438b8b57" value={reviewJobs.total || "—"} />
|
||||
<WorkflowFact label="i18n:govoplan-campaign.attachment_issues.69748336" value={missingAttachments + ambiguousAttachments} />
|
||||
</div>
|
||||
{hasBuild && <BuiltMessageReviewProgress progress={buildReviewProgress} />}
|
||||
{hasBuild && <BuiltMessageWorkflowGuidance progress={buildReviewProgress} buildWarnings={buildWarnings} />}
|
||||
<p className="muted">i18n:govoplan-campaign.building_freezes_the_current_recipients_rendered.273a8170</p>
|
||||
{getText(printOutput, "render_id") && (
|
||||
<div className="review-flow-data-section">
|
||||
@@ -1501,9 +1517,6 @@ export default function ReviewSendPage({
|
||||
{automaticInspectionComplete &&
|
||||
<p className="review-flow-inline-note is-complete"><Check size={17} aria-hidden="true" /> i18n:govoplan-campaign.all_built_messages_are_ready_no_manual_review_ac.c5549791</p>
|
||||
}
|
||||
{blockingReviewCount > 0 &&
|
||||
<p className="review-flow-inline-note is-danger">{blockingReviewCount} i18n:govoplan-campaign.blocked_or_failed_message_s_must_be_resolved_bef.5c6b5140</p>
|
||||
}
|
||||
{hasBuild &&
|
||||
<div className="review-flow-data-section">
|
||||
<div className="page-heading split">
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function FieldValueInput({ fieldType = "string", value, disabled
|
||||
inputClassName={className}
|
||||
value={valueToInputText(value, normalizedType)}
|
||||
disabled={disabled}
|
||||
generator
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(nextValue) => onChange(inputValueToFieldValue(normalizedType, nextValue))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Link2, X } from "lucide-react";
|
||||
import { Button, Dialog, i18nMessage } from "@govoplan/core-webui";
|
||||
import { Button, Dialog, DismissibleAlert, i18nMessage } from "@govoplan/core-webui";
|
||||
|
||||
import type {
|
||||
CampaignAttachmentPreviewFile,
|
||||
@@ -108,7 +108,11 @@ export default function AttachmentLinkingPreview({
|
||||
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
{!error && unlinkedCount > 0 && (
|
||||
<p className="review-flow-inline-note is-stale">
|
||||
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
ActionBlockerHint,
|
||||
GuidedReviewList,
|
||||
i18nMessage,
|
||||
type ActionBlockerReason
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
import type { BuildReviewProgress } from "./reviewProgress";
|
||||
|
||||
type InterventionHintProps = {
|
||||
tone: "warning" | "danger";
|
||||
summary: string;
|
||||
requiredAction: string;
|
||||
destination: string;
|
||||
documentationTopicId: string;
|
||||
};
|
||||
|
||||
function InterventionHint({ tone, summary, requiredAction, destination, documentationTopicId }: InterventionHintProps) {
|
||||
const requiredActionLabel = i18nMessage("i18n:govoplan-campaign.required_action.f2429497");
|
||||
const actorLabel = i18nMessage("i18n:govoplan-campaign.who_can_resolve_this.e60939b2");
|
||||
const destinationLabel = i18nMessage("i18n:govoplan-campaign.where_to_go.bb1c6969");
|
||||
const actor = i18nMessage("i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106");
|
||||
const reason: ActionBlockerReason = {
|
||||
summary,
|
||||
requiredAction,
|
||||
actor,
|
||||
target: destination
|
||||
};
|
||||
|
||||
return (
|
||||
<ActionBlockerHint
|
||||
tone={tone}
|
||||
reason={reason}
|
||||
documentation={{ topicId: documentationTopicId }}
|
||||
labels={{
|
||||
requiredAction: requiredActionLabel,
|
||||
actor: actorLabel,
|
||||
target: destinationLabel
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ValidationWorkflowGuidance({
|
||||
errors,
|
||||
warnings,
|
||||
stale
|
||||
}: {
|
||||
errors: number;
|
||||
warnings: number;
|
||||
stale: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="review-workflow-guidance">
|
||||
{errors > 0 && (
|
||||
<InterventionHint
|
||||
tone="danger"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a", { value0: errors })}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0")}
|
||||
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
|
||||
/>
|
||||
)}
|
||||
{stale && (
|
||||
<InterventionHint
|
||||
tone="warning"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930")}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.review_send_validate.c1b403c7")}
|
||||
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
|
||||
/>
|
||||
)}
|
||||
{warnings > 0 && (
|
||||
<InterventionHint
|
||||
tone="warning"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1", { value0: warnings })}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.review_send_validate.c1b403c7")}
|
||||
documentationTopicId="campaigns.workflow.prepare-validate-and-build"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuiltMessageReviewProgress({ progress }: { progress: BuildReviewProgress }) {
|
||||
const blockerDetail = progress.blocking > 0
|
||||
? i18nMessage("i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b")
|
||||
: i18nMessage("i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f");
|
||||
const individualDetail = progress.individualRemaining > 0
|
||||
? i18nMessage("i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9")
|
||||
: i18nMessage("i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc");
|
||||
const groupDetail = progress.groupRemaining > 0
|
||||
? i18nMessage("i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f")
|
||||
: progress.groupRequired > 0
|
||||
? i18nMessage("i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6")
|
||||
: i18nMessage("i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc");
|
||||
|
||||
return (
|
||||
<GuidedReviewList
|
||||
className="review-workflow-progress"
|
||||
items={[
|
||||
{
|
||||
label: i18nMessage("i18n:govoplan-campaign.critical_blockers.8a37e088"),
|
||||
value: progress.blocking,
|
||||
detail: blockerDetail,
|
||||
tone: progress.blocking > 0 ? "danger" : "success"
|
||||
},
|
||||
{
|
||||
label: i18nMessage("i18n:govoplan-campaign.individual_review.402783bf"),
|
||||
value: `${progress.individualReviewed} / ${progress.individualRequired}`,
|
||||
detail: individualDetail,
|
||||
tone: progress.individualRemaining > 0 ? "warning" : "success"
|
||||
},
|
||||
{
|
||||
label: i18nMessage("i18n:govoplan-campaign.group_review.a809a9d9"),
|
||||
value: `${progress.groupReviewed} / ${progress.groupRequired}`,
|
||||
detail: groupDetail,
|
||||
tone: progress.groupRemaining > 0 ? "warning" : "success"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuiltMessageWorkflowGuidance({
|
||||
progress,
|
||||
buildWarnings
|
||||
}: {
|
||||
progress: BuildReviewProgress;
|
||||
buildWarnings: number;
|
||||
}) {
|
||||
const showUnacknowledgedWarning = progress.remaining === 0 && buildWarnings > 0;
|
||||
return (
|
||||
<div className="review-workflow-guidance">
|
||||
{progress.blocking > 0 && (
|
||||
<InterventionHint
|
||||
tone="danger"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60", { value0: progress.blocking })}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62")}
|
||||
documentationTopicId="campaigns.workflow.complete-review"
|
||||
/>
|
||||
)}
|
||||
{progress.remaining > 0 && (
|
||||
<InterventionHint
|
||||
tone="warning"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c", {
|
||||
value0: progress.remaining,
|
||||
value1: progress.individualRemaining,
|
||||
value2: progress.groupRemaining
|
||||
})}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
|
||||
documentationTopicId="campaigns.workflow.complete-review"
|
||||
/>
|
||||
)}
|
||||
{showUnacknowledgedWarning && (
|
||||
<InterventionHint
|
||||
tone="warning"
|
||||
summary={i18nMessage("i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2", { value0: buildWarnings })}
|
||||
requiredAction={i18nMessage("i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4")}
|
||||
destination={i18nMessage("i18n:govoplan-campaign.review_send_built_messages.6b030946")}
|
||||
documentationTopicId="campaigns.workflow.complete-review"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export type BuildReviewProgressInput = {
|
||||
blocking: number;
|
||||
individualRequired: number;
|
||||
individualReviewed: number;
|
||||
groupRequired: number;
|
||||
reviewComplete: boolean;
|
||||
};
|
||||
|
||||
export type BuildReviewProgress = {
|
||||
blocking: number;
|
||||
individualRequired: number;
|
||||
individualReviewed: number;
|
||||
individualRemaining: number;
|
||||
groupRequired: number;
|
||||
groupReviewed: number;
|
||||
groupRemaining: number;
|
||||
required: number;
|
||||
reviewed: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
function count(value: number): number {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||||
}
|
||||
|
||||
export function calculateBuildReviewProgress(input: BuildReviewProgressInput): BuildReviewProgress {
|
||||
const blocking = count(input.blocking);
|
||||
const individualRequired = count(input.individualRequired);
|
||||
const groupRequired = count(input.groupRequired);
|
||||
const individualReviewed = input.reviewComplete
|
||||
? individualRequired
|
||||
: Math.min(individualRequired, count(input.individualReviewed));
|
||||
const groupReviewed = input.reviewComplete ? groupRequired : 0;
|
||||
const individualRemaining = individualRequired - individualReviewed;
|
||||
const groupRemaining = groupRequired - groupReviewed;
|
||||
|
||||
return {
|
||||
blocking,
|
||||
individualRequired,
|
||||
individualReviewed,
|
||||
individualRemaining,
|
||||
groupRequired,
|
||||
groupReviewed,
|
||||
groupRemaining,
|
||||
required: individualRequired + groupRequired,
|
||||
reviewed: individualReviewed + groupReviewed,
|
||||
remaining: individualRemaining + groupRemaining
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,45 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Activate all ({value0})",
|
||||
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})",
|
||||
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients",
|
||||
"i18n:govoplan-campaign.deactivate_all_recipients.5939b005": "Deactivate all recipients",
|
||||
"i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50": "Activate {value0} currently inactive recipients? The change remains a draft until you save.",
|
||||
"i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f": "Deactivate {value0} currently active recipients? The change remains a draft until you save.",
|
||||
"i18n:govoplan-campaign.activate_recipients.bc3a7878": "Activate recipients",
|
||||
"i18n:govoplan-campaign.deactivate_recipients.88d80611": "Deactivate recipients",
|
||||
"i18n:govoplan-campaign.guided_workflows": "Guided workflows",
|
||||
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
|
||||
"i18n:govoplan-campaign.required_action.f2429497": "Required action",
|
||||
"i18n:govoplan-campaign.who_can_resolve_this.e60939b2": "Who can resolve this",
|
||||
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Where to go",
|
||||
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Critical blockers",
|
||||
"i18n:govoplan-campaign.remaining.cc632b5e": "Remaining",
|
||||
"i18n:govoplan-campaign.individual_review.402783bf": "Individual review",
|
||||
"i18n:govoplan-campaign.group_review.a809a9d9": "Group review",
|
||||
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blocking validation issue(s) prevent building.",
|
||||
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Correct the affected campaign data, then validate again.",
|
||||
"i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0": "Campaign identity, Sender & Recipients, Template, or Files",
|
||||
"i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106": "Campaign editor or reviewer",
|
||||
"i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1": "{value0} validation warning(s) need review but do not block building.",
|
||||
"i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4": "Inspect the warning details; correct the data or accept the documented condition.",
|
||||
"i18n:govoplan-campaign.review_send_validate.c1b403c7": "Review & Send - Validate",
|
||||
"i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930": "The validation evidence is stale.",
|
||||
"i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9": "Run validation again before relying on this result.",
|
||||
"i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60": "{value0} built message(s) are blocked or failed.",
|
||||
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Correct the affected recipient, template, mail, or attachment data and rebuild.",
|
||||
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Sender & Recipients, Template, Mail settings, or Files",
|
||||
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} review decision(s) remain ({value1} individual, {value2} group).",
|
||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Open every critical message and record a decision; then explicitly accept any non-critical group.",
|
||||
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Review & Send - Built messages",
|
||||
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} built message warning(s) need acknowledgement.",
|
||||
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Must be corrected before delivery.",
|
||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Requires an individual decision.",
|
||||
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "May be accepted together after critical review is complete.",
|
||||
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "Already acknowledged in the completed review.",
|
||||
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "All required review decisions are complete.",
|
||||
"i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f": "No critical built-message blockers remain.",
|
||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||
"i18n:govoplan-campaign.appended.979f5a82": "Appended",
|
||||
@@ -222,9 +259,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename",
|
||||
"i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.",
|
||||
"i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty",
|
||||
"i18n:govoplan-campaign.archive_campaign.26dcfb8a": "Archive campaign",
|
||||
"i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1": "This removes the campaign from active work while preserving versions, delivery results, and audit evidence. Active or outcome-unknown delivery must be resolved first.",
|
||||
"i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name",
|
||||
"i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.",
|
||||
"i18n:govoplan-campaign.archived.eddc813f": "Archived",
|
||||
"i18n:govoplan-campaign.campaign_archived.3f0ca2b7": "Campaign archived.",
|
||||
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
||||
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
||||
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
||||
@@ -1013,6 +1053,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.save.efc007a3": "Save",
|
||||
"i18n:govoplan-campaign.saved.c0ae8f6e": "Saved",
|
||||
"i18n:govoplan-campaign.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0": "Save or discard overview changes before archiving.",
|
||||
"i18n:govoplan-campaign.scope_field_value": "{value0} field \"{value1}\"",
|
||||
"i18n:govoplan-campaign.scope.4651a34e": "Scope",
|
||||
"i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID",
|
||||
@@ -1302,8 +1343,45 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Alle aktivieren ({value0})",
|
||||
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})",
|
||||
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Alle Empfänger aktivieren",
|
||||
"i18n:govoplan-campaign.deactivate_all_recipients.5939b005": "Alle Empfänger deaktivieren",
|
||||
"i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50": "{value0} derzeit inaktive Empfänger aktivieren? Die Änderung bleibt ein Entwurf, bis Sie speichern.",
|
||||
"i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f": "{value0} derzeit aktive Empfänger deaktivieren? Die Änderung bleibt ein Entwurf, bis Sie speichern.",
|
||||
"i18n:govoplan-campaign.activate_recipients.bc3a7878": "Empfänger aktivieren",
|
||||
"i18n:govoplan-campaign.deactivate_recipients.88d80611": "Empfänger deaktivieren",
|
||||
"i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe",
|
||||
"i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.",
|
||||
"i18n:govoplan-campaign.required_action.f2429497": "Erforderliche Aktion",
|
||||
"i18n:govoplan-campaign.who_can_resolve_this.e60939b2": "Wer kann dies beheben",
|
||||
"i18n:govoplan-campaign.where_to_go.bb1c6969": "Ziel",
|
||||
"i18n:govoplan-campaign.critical_blockers.8a37e088": "Kritische Blockaden",
|
||||
"i18n:govoplan-campaign.remaining.cc632b5e": "Verbleibend",
|
||||
"i18n:govoplan-campaign.individual_review.402783bf": "Einzelprüfung",
|
||||
"i18n:govoplan-campaign.group_review.a809a9d9": "Gruppenprüfung",
|
||||
"i18n:govoplan-campaign.value0_blocking_validation_issue_s_prevent_building.b1b18d9a": "{value0} blockierende Validierungsprobleme verhindern die Erstellung.",
|
||||
"i18n:govoplan-campaign.correct_the_affected_campaign_data_then_validate_again.ae956e99": "Korrigieren Sie die betroffenen Kampagnendaten und validieren Sie erneut.",
|
||||
"i18n:govoplan-campaign.campaign_identity_sender_recipients_template_or_files.69bc29f0": "Kampagnenidentität, Absender & Empfänger, Vorlage oder Dateien",
|
||||
"i18n:govoplan-campaign.campaign_editor_or_reviewer.7869b106": "Kampagnenbearbeitung oder -prüfung",
|
||||
"i18n:govoplan-campaign.value0_validation_warning_s_need_review_but_do_not_block.ff03efd1": "{value0} Validierungswarnungen müssen geprüft werden, verhindern die Erstellung aber nicht.",
|
||||
"i18n:govoplan-campaign.inspect_the_warning_details_correct_the_data_or_accept_t.a558e0d4": "Prüfen Sie die Warnungsdetails; korrigieren Sie die Daten oder akzeptieren Sie die dokumentierte Bedingung.",
|
||||
"i18n:govoplan-campaign.review_send_validate.c1b403c7": "Prüfen & Senden - Validieren",
|
||||
"i18n:govoplan-campaign.the_validation_evidence_is_stale.408e8930": "Der Validierungsnachweis ist veraltet.",
|
||||
"i18n:govoplan-campaign.run_validation_again_before_relying_on_this_result.97116fc9": "Validieren Sie erneut, bevor Sie sich auf dieses Ergebnis verlassen.",
|
||||
"i18n:govoplan-campaign.value0_built_message_s_are_blocked_or_failed.8061ea60": "{value0} erstellte Nachrichten sind blockiert oder fehlgeschlagen.",
|
||||
"i18n:govoplan-campaign.correct_the_affected_recipient_template_mail_or_attachme.5a65e373": "Korrigieren Sie die betroffenen Empfänger-, Vorlagen-, Mail- oder Anhangsdaten und erstellen Sie erneut.",
|
||||
"i18n:govoplan-campaign.sender_recipients_template_mail_settings_or_files.bdd91b62": "Absender & Empfänger, Vorlage, Mail-Einstellungen oder Dateien",
|
||||
"i18n:govoplan-campaign.value0_review_decision_s_remain_value1_individual_value2.4a6a503c": "{value0} Prüfentscheidungen verbleiben ({value1} einzeln, {value2} als Gruppe).",
|
||||
"i18n:govoplan-campaign.open_every_critical_message_and_record_a_decision_then_e.9e08c029": "Öffnen Sie jede kritische Nachricht und dokumentieren Sie eine Entscheidung; akzeptieren Sie anschließend ausdrücklich die nichtkritische Gruppe.",
|
||||
"i18n:govoplan-campaign.review_send_built_messages.6b030946": "Prüfen & Senden - Erstellte Nachrichten",
|
||||
"i18n:govoplan-campaign.value0_built_message_warning_s_need_acknowledgement.7d8e30a2": "{value0} Warnungen zu erstellten Nachrichten müssen bestätigt werden.",
|
||||
"i18n:govoplan-campaign.must_be_corrected_before_delivery.b8e6a54b": "Muss vor dem Versand korrigiert werden.",
|
||||
"i18n:govoplan-campaign.requires_an_individual_decision.44cd81c9": "Erfordert eine Einzelentscheidung.",
|
||||
"i18n:govoplan-campaign.may_be_accepted_together_after_critical_review_is_comple.8753522f": "Kann nach Abschluss der kritischen Prüfung gemeinsam akzeptiert werden.",
|
||||
"i18n:govoplan-campaign.already_acknowledged_in_the_completed_review.76a263f6": "In der abgeschlossenen Prüfung bereits bestätigt.",
|
||||
"i18n:govoplan-campaign.all_required_review_decisions_are_complete.503919dc": "Alle erforderlichen Prüfentscheidungen sind abgeschlossen.",
|
||||
"i18n:govoplan-campaign.no_critical_built_message_blockers_remain.97b3c64f": "Es verbleiben keine kritischen Blockaden für erstellte Nachrichten.",
|
||||
"i18n:govoplan-campaign.1_campaign.ccd70074": "1 campaign",
|
||||
"i18n:govoplan-campaign.2_campaigns.35b84804": "2 campaigns",
|
||||
"i18n:govoplan-campaign.appended.979f5a82": "Abgelegt",
|
||||
@@ -1522,9 +1600,12 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.archive_filename.de958cbd": "archive filename",
|
||||
"i18n:govoplan-campaign.archive_filenames_must_be_present_and_unique.aa2fb639": "Archive filenames must be present and unique.",
|
||||
"i18n:govoplan-campaign.archive_filenames_must_not_be_empty.42e91427": "Archive filenames must not be empty",
|
||||
"i18n:govoplan-campaign.archive_campaign.26dcfb8a": "Kampagne archivieren",
|
||||
"i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1": "Dadurch wird die Kampagne aus der aktiven Arbeit entfernt. Versionen, Versandergebnisse und Pruefnachweise bleiben erhalten. Aktive oder ungeklaerte Versandvorgaenge muessen zuerst abgeschlossen werden.",
|
||||
"i18n:govoplan-campaign.archive_name.6310f9e1": "Archive name",
|
||||
"i18n:govoplan-campaign.archive_names_support_recipient_and_campaign_fie.d5b1b2d1": "Archive names support recipient and campaign fields. Use the pencil action to edit the filename and insert placeholders. Password fields are intentionally not offered for filenames. The campaign standard is used by attachment rows set to Campaign standard.",
|
||||
"i18n:govoplan-campaign.archived.eddc813f": "Archived",
|
||||
"i18n:govoplan-campaign.campaign_archived.3f0ca2b7": "Kampagne archiviert.",
|
||||
"i18n:govoplan-campaign.as_a_campaign_field_remove_this_placeholder_or_c.6bf2dea8": "as a campaign field, remove this placeholder, or continue editing.",
|
||||
"i18n:govoplan-campaign.attach_job_csv.adb76197": "Attach job CSV",
|
||||
"i18n:govoplan-campaign.attach_json_report.d70883b5": "Attach JSON report",
|
||||
@@ -2313,6 +2394,7 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.save.efc007a3": "Speichern",
|
||||
"i18n:govoplan-campaign.saved.c0ae8f6e": "Gespeichert",
|
||||
"i18n:govoplan-campaign.saving.56a2285c": "Saving…",
|
||||
"i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0": "Speichern oder verwerfen Sie die Aenderungen an der Uebersicht vor dem Archivieren.",
|
||||
"i18n:govoplan-campaign.scope_field_value": "{value0}-Feld \"{value1}\"",
|
||||
"i18n:govoplan-campaign.scope.4651a34e": "Geltungsbereich",
|
||||
"i18n:govoplan-campaign.search_recipient_subject_or_entry_id.6d6544f5": "Search recipient, subject or entry ID",
|
||||
|
||||
+2
-5
@@ -17,7 +17,6 @@ import "./styles/campaign-workspace.css";
|
||||
|
||||
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
|
||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
||||
|
||||
const campaignRead = ["campaigns:campaign:read"];
|
||||
const reportRead = ["campaigns:report:read"];
|
||||
@@ -91,16 +90,14 @@ export const campaignModule: PlatformWebModule = {
|
||||
}
|
||||
],
|
||||
navItems: [
|
||||
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 },
|
||||
{ to: "/templates", label: "i18n:govoplan-campaign.templates.f25b700e", iconName: "layout-template", order: 90 }],
|
||||
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 }],
|
||||
|
||||
routes: [
|
||||
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) },
|
||||
{ path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) },
|
||||
{ path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) },
|
||||
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
|
||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
||||
uiCapabilities: {
|
||||
"dashboard.widgets": campaignDashboardWidgets,
|
||||
"wizard.directories": campaignWizardDirectories
|
||||
|
||||
@@ -1285,6 +1285,11 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version-history-table .data-grid-body-cell.archived-version-row {
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.mock-message-detail {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--line-subtle);
|
||||
@@ -1627,6 +1632,16 @@
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.review-workflow-guidance {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.review-workflow-progress {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.attachment-linking-preview {
|
||||
margin-top: 14px;
|
||||
}
|
||||
@@ -2751,3 +2766,18 @@
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.related-link-card,
|
||||
.recipient-import-step-icon {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.related-link-card:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.review-flow-stage[data-state="running"] .review-flow-stage-node {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,47 @@ const preview = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const styles = fs.readFileSync(
|
||||
path.join(sourceRoot, "styles/campaign-workspace.css"),
|
||||
"utf8",
|
||||
);
|
||||
const jsonView = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/CampaignJsonView.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const auditView = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/CampaignAuditPage.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const attachmentPreview = fs.readFileSync(
|
||||
path.join(sourceRoot, "features/campaigns/review/AttachmentLinkingPreview.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.ok(
|
||||
jsonView.includes('DismissibleAlert tone="warning" dismissible={false}'),
|
||||
"The full Campaign JSON projection must retain an explicit privacy warning",
|
||||
);
|
||||
assert.ok(
|
||||
auditView.includes("ActionBlockerHint"),
|
||||
"Unavailable Campaign audit projection must retain an actionable shared blocker",
|
||||
);
|
||||
assert.ok(
|
||||
attachmentPreview.includes('<DismissibleAlert tone="danger" compact dismissible={false}>'),
|
||||
"Attachment-preview validation failures must use the compact shared alert",
|
||||
);
|
||||
assert.ok(
|
||||
styles.includes("@media (prefers-reduced-motion: reduce)"),
|
||||
"Campaign must retain an explicit reduced-motion presentation contract",
|
||||
);
|
||||
assert.ok(
|
||||
styles.includes("@media (max-width:"),
|
||||
"Campaign must retain responsive narrow-viewport layouts",
|
||||
);
|
||||
assert.ok(
|
||||
styles.includes(":focus-visible"),
|
||||
"Campaign-specific interactive controls must retain visible keyboard focus",
|
||||
);
|
||||
for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) {
|
||||
const buttonLine = preview
|
||||
.split("\n")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
||||
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
|
||||
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
|
||||
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
|
||||
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
|
||||
const api = readFileSync("src/api/campaigns.ts", "utf8");
|
||||
|
||||
assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
|
||||
assert.match(overview, /hasScope\(auth, "campaigns:campaign:archive"\)/);
|
||||
assert.match(overview, /getCampaignLifecyclePolicy/);
|
||||
assert.match(overview, /pendingLifecycleAction/);
|
||||
assert.match(overview, /archive_campaign_confirmation/);
|
||||
assert.match(overview, /await archiveCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(overview, /await deleteCampaign\(settings, campaign\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(overview, /await copyCampaign\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
|
||||
assert.match(recipients, /requestBulkActivation\(true\)/);
|
||||
assert.match(recipients, /requestBulkActivation\(false\)/);
|
||||
assert.match(recipients, /const count = inlineEntries\.filter/);
|
||||
assert.match(recipients, /<ConfirmDialog[\s\S]*bulkActivation/);
|
||||
assert.match(recipients, /replaceInlineEntries\(inlineEntries\.map/);
|
||||
assert.match(fieldValueInput, /<PasswordField[\s\S]*generator[\s\S]*autoComplete="new-password"/);
|
||||
assert.match(mailSettings, /disabled=\{credentialSaving\} generator autoComplete="new-password"/);
|
||||
|
||||
console.log("Campaign lifecycle UI structural contract passed.");
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
|
||||
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
|
||||
|
||||
assert.match(guidance, /<ActionBlockerHint/);
|
||||
assert.match(guidance, /<GuidedReviewList/);
|
||||
assert.match(guidance, /tone="danger"/);
|
||||
assert.match(guidance, /tone="warning"/);
|
||||
assert.match(guidance, /requiredAction: requiredActionLabel/);
|
||||
assert.match(guidance, /target: destinationLabel/);
|
||||
assert.match(guidance, /documentation=\{\{ topicId: documentationTopicId \}\}/);
|
||||
assert.match(guidance, /campaigns\.workflow\.prepare-validate-and-build/);
|
||||
assert.match(guidance, /campaigns\.workflow\.complete-review/);
|
||||
assert.match(page, /calculateBuildReviewProgress/);
|
||||
assert.match(page, /<WorkflowFact label="i18n:govoplan-campaign\.remaining\.cc632b5e"/);
|
||||
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
|
||||
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { calculateBuildReviewProgress } from "../src/features/campaigns/review/reviewProgress.ts";
|
||||
|
||||
test("separates blocking, individual, and group review work", () => {
|
||||
assert.deepEqual(calculateBuildReviewProgress({
|
||||
blocking: 2,
|
||||
individualRequired: 3,
|
||||
individualReviewed: 1,
|
||||
groupRequired: 4,
|
||||
reviewComplete: false
|
||||
}), {
|
||||
blocking: 2,
|
||||
individualRequired: 3,
|
||||
individualReviewed: 1,
|
||||
individualRemaining: 2,
|
||||
groupRequired: 4,
|
||||
groupReviewed: 0,
|
||||
groupRemaining: 4,
|
||||
required: 7,
|
||||
reviewed: 1,
|
||||
remaining: 6
|
||||
});
|
||||
});
|
||||
|
||||
test("a recorded review completion acknowledges every review decision", () => {
|
||||
const progress = calculateBuildReviewProgress({
|
||||
blocking: 0,
|
||||
individualRequired: 3,
|
||||
individualReviewed: 3,
|
||||
groupRequired: 4,
|
||||
reviewComplete: true
|
||||
});
|
||||
assert.equal(progress.reviewed, 7);
|
||||
assert.equal(progress.remaining, 0);
|
||||
assert.equal(progress.individualRemaining, 0);
|
||||
assert.equal(progress.groupRemaining, 0);
|
||||
});
|
||||
|
||||
test("normalizes malformed counters without overstating progress", () => {
|
||||
const progress = calculateBuildReviewProgress({
|
||||
blocking: -2,
|
||||
individualRequired: 2.9,
|
||||
individualReviewed: 99,
|
||||
groupRequired: Number.NaN,
|
||||
reviewComplete: false
|
||||
});
|
||||
assert.equal(progress.blocking, 0);
|
||||
assert.equal(progress.individualRequired, 2);
|
||||
assert.equal(progress.individualReviewed, 2);
|
||||
assert.equal(progress.required, 2);
|
||||
assert.equal(progress.remaining, 0);
|
||||
});
|
||||
Reference in New Issue
Block a user