Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696f8f6385 | ||
|
|
6562484d32 | ||
|
|
2db99eaf6a | ||
|
|
ea0efe661f | ||
|
|
6bde11a286 | ||
|
|
585493fe7a | ||
|
|
4133de86cd | ||
|
|
9137300780 | ||
|
|
f98fe06143 | ||
|
|
dc63e35550 | ||
|
|
2dac5570cd | ||
|
|
91890fdaf5 | ||
|
|
8f5231147d | ||
|
|
df5a93d6a3 |
@@ -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
|
||||||
@@ -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
|
implementations that bypass Core's `Dialog`. It complements rather than replaces
|
||||||
browser and assistive-technology testing.
|
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
|
## Known boundary
|
||||||
|
|
||||||
Translation keys may be visible in source because Core resolves them at runtime.
|
Translation keys may be visible in source because Core resolves them at runtime.
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ Important distinctions:
|
|||||||
- **Archive** preserves evidence. Draft-only campaigns without built, locked,
|
- **Archive** preserves evidence. Draft-only campaigns without built, locked,
|
||||||
or delivery evidence may be deleted where policy allows; evidence-bearing
|
or delivery evidence may be deleted where policy allows; evidence-bearing
|
||||||
campaigns are archived instead.
|
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
|
## User tasks
|
||||||
|
|
||||||
@@ -112,6 +124,12 @@ Important distinctions:
|
|||||||
5. Open **Mail settings** and select an available Mail profile. The campaign
|
5. Open **Mail settings** and select an available Mail profile. The campaign
|
||||||
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
|
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
|
||||||
usernames, passwords, or credential references.
|
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
|
6. Save the editable version, validate the relevant sections, and resolve every
|
||||||
blocking issue. Warnings remain explicit review decisions.
|
blocking issue. Warnings remain explicit review decisions.
|
||||||
7. Build the exact messages and inspect recipient, addressing, template,
|
7. Build the exact messages and inspect recipient, addressing, template,
|
||||||
@@ -221,6 +239,8 @@ At a minimum:
|
|||||||
Only the latter becomes explicitly retryable, and neither decision resends
|
Only the latter becomes explicitly retryable, and neither decision resends
|
||||||
the already SMTP-accepted message.
|
the already SMTP-accepted message.
|
||||||
8. Archive only after active and uncertain effects are resolved.
|
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
|
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;
|
progress. Cancel marks work that has not yet produced a protected SMTP outcome;
|
||||||
@@ -283,6 +303,8 @@ as an executable Mail configuration:
|
|||||||
|
|
||||||
- public responses remove legacy transport fields and secrets;
|
- public responses remove legacy transport fields and secrets;
|
||||||
- validation, build, queue, retry, and delivery fail closed;
|
- 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
|
- an editable version changes to profile-only form only through an explicit
|
||||||
Mail-settings save; and
|
Mail-settings save; and
|
||||||
- a locked version is preserved and must be forked to an editable successor.
|
- a locked version is preserved and must be forked to an editable successor.
|
||||||
@@ -438,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,
|
release-composition alignment gate. Optional absence must be tested physically,
|
||||||
not only hidden in navigation.
|
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
|
### Reusable Distribution Lists
|
||||||
|
|
||||||
When Distribution Lists is available, Recipient data offers a separate import
|
When Distribution Lists is available, Recipient data offers a separate import
|
||||||
@@ -538,6 +569,18 @@ lock exists. Evidence-bearing campaigns are archived. Destructive module
|
|||||||
retirement remains a separately confirmed installer operation with backup and
|
retirement remains a separately confirmed installer operation with backup and
|
||||||
retirement evidence.
|
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
|
## Reference-composition acceptance
|
||||||
|
|
||||||
Campaign is ready to serve as the demonstration module only when all of the
|
Campaign is ready to serve as the demonstration module only when all of the
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.12",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -22,11 +22,11 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.12",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@govoplan/core-webui": {
|
"@govoplan/core-webui": {
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-campaign"
|
name = "govoplan-campaign"
|
||||||
version = "0.1.12"
|
version = "0.1.18"
|
||||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.14",
|
"govoplan-core>=0.1.18",
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -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):
|
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]:
|
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
|
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))
|
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: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True)
|
||||||
delivery_mode_selected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=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")
|
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.",
|
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"),
|
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(
|
_workflow_topic(
|
||||||
topic_id="campaigns.workflow.import-recipients",
|
topic_id="campaigns.workflow.import-recipients",
|
||||||
title="Import recipients into a campaign",
|
title="Import recipients into a campaign",
|
||||||
@@ -596,7 +621,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
|
|||||||
),
|
),
|
||||||
steps=(
|
steps=(
|
||||||
"Confirm that the draft is not needed and contains no evidence that should be retained.",
|
"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.",
|
"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.",
|
"Verify that the deleted draft no longer appears in active Campaigns and that the audit event exists.",
|
||||||
),
|
),
|
||||||
@@ -604,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.",
|
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",),
|
related_topic_ids=("campaigns.workflow.archive-campaign",),
|
||||||
limitations=(
|
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.",
|
"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,
|
CAPABILITY_POSTBOX_EVIDENCE,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
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.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.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||||
from govoplan_campaign.backend.documentation import (
|
from govoplan_campaign.backend.documentation import (
|
||||||
CAMPAIGN_USER_DOCUMENTATION,
|
CAMPAIGN_USER_DOCUMENTATION,
|
||||||
documentation_topics,
|
documentation_topics,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||||
|
|
||||||
register_campaign_change_tracking()
|
register_campaign_change_tracking()
|
||||||
|
|
||||||
@@ -358,7 +360,7 @@ def _campaigns_router(context: ModuleContext):
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="campaigns",
|
id="campaigns",
|
||||||
name="Campaigns",
|
name="Campaigns",
|
||||||
version="0.1.12",
|
version="0.1.18",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -378,6 +380,7 @@ manifest = ModuleManifest(
|
|||||||
"postbox",
|
"postbox",
|
||||||
"approvals",
|
"approvals",
|
||||||
"reporting",
|
"reporting",
|
||||||
|
"search",
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
@@ -481,12 +484,24 @@ manifest = ModuleManifest(
|
|||||||
version_max_exclusive="0.2.0",
|
version_max_exclusive="0.2.0",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="search.source",
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
permissions=PERMISSIONS,
|
permissions=PERMISSIONS,
|
||||||
route_factory=_campaigns_router,
|
route_factory=_campaigns_router,
|
||||||
role_templates=ROLE_TEMPLATES,
|
role_templates=ROLE_TEMPLATES,
|
||||||
tenant_summary_providers=(_tenant_summary,),
|
tenant_summary_providers=(_tenant_summary,),
|
||||||
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
||||||
|
search_sources=(
|
||||||
|
SearchSourceProviderRegistration(
|
||||||
|
id="campaigns.campaigns",
|
||||||
|
factory=create_campaign_search_source,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
path="/campaigns",
|
path="/campaigns",
|
||||||
@@ -506,6 +521,14 @@ manifest = ModuleManifest(
|
|||||||
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||||
order=20,
|
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(
|
FrontendRoute(
|
||||||
path="/campaigns/queue",
|
path="/campaigns/queue",
|
||||||
component="OperatorQueuePage",
|
component="OperatorQueuePage",
|
||||||
@@ -527,7 +550,6 @@ manifest = ModuleManifest(
|
|||||||
order=22,
|
order=22,
|
||||||
surface_id=REPORTS_SURFACE_ID,
|
surface_id=REPORTS_SURFACE_ID,
|
||||||
),
|
),
|
||||||
FrontendRoute(path="/templates", component="TemplatesPage", order=90),
|
|
||||||
),
|
),
|
||||||
nav_items=(
|
nav_items=(
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -537,9 +559,6 @@ manifest = ModuleManifest(
|
|||||||
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||||
order=20,
|
order=20,
|
||||||
),
|
),
|
||||||
NavItem(
|
|
||||||
path="/templates", label="Templates", icon="layout-template", order=90
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
@@ -596,6 +615,23 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
*CAMPAIGN_USER_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(
|
DocumentationTopic(
|
||||||
id="campaigns.postbox-delivery",
|
id="campaigns.postbox-delivery",
|
||||||
title="Deliver Campaign messages to Postboxes",
|
title="Deliver Campaign messages to Postboxes",
|
||||||
@@ -760,7 +796,7 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.mail-profile-operations",
|
id="campaigns.mail-profile-operations",
|
||||||
title="Operate profile-backed campaign delivery",
|
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.",
|
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",
|
layer="configured",
|
||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||||
@@ -808,7 +844,7 @@ manifest = ModuleManifest(
|
|||||||
id="campaigns.workflow.prepare-validate-and-build",
|
id="campaigns.workflow.prepare-validate-and-build",
|
||||||
title="Prepare, validate, and build a campaign",
|
title="Prepare, validate, and build a campaign",
|
||||||
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
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",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
@@ -836,7 +872,7 @@ manifest = ModuleManifest(
|
|||||||
kind="repository",
|
kind="repository",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
related_modules=("addresses", "files", "mail"),
|
related_modules=("addresses", "files", "mail", "templates"),
|
||||||
unlocks=(
|
unlocks=(
|
||||||
"A reviewable build whose exact recipient-specific effects can be inspected before delivery.",
|
"A reviewable build whose exact recipient-specific effects can be inspected before delivery.",
|
||||||
),
|
),
|
||||||
@@ -864,8 +900,8 @@ manifest = ModuleManifest(
|
|||||||
],
|
],
|
||||||
"steps": [
|
"steps": [
|
||||||
"Set campaign-wide fields and purpose, then define the recipient fields and templates.",
|
"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.",
|
"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.",
|
"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.",
|
"Validate the relevant sections and resolve every blocker without hiding warnings.",
|
||||||
"Build the selected version and inspect representative and exceptional rendered messages.",
|
"Build the selected version and inspect representative and exceptional rendered messages.",
|
||||||
],
|
],
|
||||||
|
|||||||
+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")
|
||||||
@@ -23,6 +23,9 @@ from govoplan_campaign.backend.path_security import (
|
|||||||
assert_server_safe_campaign_paths,
|
assert_server_safe_campaign_paths,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
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.attachments.resolver import resolve_campaign_attachments
|
||||||
from govoplan_campaign.backend.persistence.versions import (
|
from govoplan_campaign.backend.persistence.versions import (
|
||||||
is_version_final_locked,
|
is_version_final_locked,
|
||||||
@@ -324,7 +327,7 @@ def preview_campaign_attachments(
|
|||||||
include_unmatched=payload.include_unmatched,
|
include_unmatched=payload.include_unmatched,
|
||||||
include_unlinked_candidates=payload.include_unlinked_candidates,
|
include_unlinked_candidates=payload.include_unlinked_candidates,
|
||||||
)
|
)
|
||||||
except CampaignPathSecurityError as exc:
|
except (CampaignPathSecurityError, CampaignMailProfileBoundaryError) as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)
|
||||||
) from exc
|
) from exc
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||||
from sqlalchemy import or_
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_campaign.backend.schemas import (
|
from govoplan_campaign.backend.schemas import (
|
||||||
@@ -12,6 +13,9 @@ from govoplan_campaign.backend.schemas import (
|
|||||||
CampaignUpdateRequest,
|
CampaignUpdateRequest,
|
||||||
CampaignCreateResponse,
|
CampaignCreateResponse,
|
||||||
CampaignCreateMinimalRequest,
|
CampaignCreateMinimalRequest,
|
||||||
|
CampaignCopyRequest,
|
||||||
|
CampaignLifecycleMutationRequest,
|
||||||
|
CampaignLifecyclePolicyResponse,
|
||||||
CampaignAddressLookupCandidate,
|
CampaignAddressLookupCandidate,
|
||||||
CampaignAddressLookupResponse,
|
CampaignAddressLookupResponse,
|
||||||
CampaignCalendarCatalogResponse,
|
CampaignCalendarCatalogResponse,
|
||||||
@@ -56,13 +60,16 @@ from govoplan_campaign.backend.change_tracking import (
|
|||||||
)
|
)
|
||||||
from govoplan_campaign.backend.db.models import (
|
from govoplan_campaign.backend.db.models import (
|
||||||
Campaign,
|
Campaign,
|
||||||
CampaignJob,
|
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
RecipientImportMappingProfile,
|
RecipientImportMappingProfile,
|
||||||
)
|
)
|
||||||
from govoplan_campaign.backend.campaign.postbox_targets import (
|
from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||||
delivery_catalog_payload,
|
delivery_catalog_payload,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.campaign.lifecycle import (
|
||||||
|
assert_lifecycle_state_token,
|
||||||
|
campaign_lifecycle_policy,
|
||||||
|
)
|
||||||
from govoplan_campaign.backend.integrations import (
|
from govoplan_campaign.backend.integrations import (
|
||||||
calendar_integration,
|
calendar_integration,
|
||||||
PostboxDeliveryUnavailable,
|
PostboxDeliveryUnavailable,
|
||||||
@@ -120,6 +127,93 @@ CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
|||||||
CAPABILITY_ADDRESSES_RECIPIENT_SOURCE = "addresses.recipient_source"
|
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)
|
@router.post("", response_model=CampaignCreateResponse)
|
||||||
def create_campaign(
|
def create_campaign(
|
||||||
payload: CampaignCreateRequest,
|
payload: CampaignCreateRequest,
|
||||||
@@ -1524,17 +1618,179 @@ def update_campaign_metadata_endpoint(
|
|||||||
return CampaignResponse.model_validate(campaign)
|
return CampaignResponse.model_validate(campaign)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/archive", response_model=CampaignResponse)
|
@router.get(
|
||||||
def archive_campaign(
|
"/{campaign_id}/lifecycle-policy",
|
||||||
|
response_model=CampaignLifecyclePolicyResponse,
|
||||||
|
)
|
||||||
|
def get_campaign_lifecycle_policy(
|
||||||
campaign_id: str,
|
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),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:archive")),
|
||||||
):
|
):
|
||||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
campaign, _policy = _lifecycle_policy_for_mutation(
|
||||||
if campaign.status in {"queued", "sending", "outcome_unknown"}:
|
session,
|
||||||
raise HTTPException(
|
campaign_id=campaign_id,
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
principal=principal,
|
||||||
detail="Active or uncertain delivery must be resolved before archiving.",
|
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"
|
campaign.status = "archived"
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
@@ -1554,42 +1810,16 @@ def archive_campaign(
|
|||||||
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
def delete_draft_campaign(
|
def delete_draft_campaign(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
payload: CampaignLifecycleMutationRequest,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")),
|
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:delete")),
|
||||||
):
|
):
|
||||||
campaign = _get_campaign_for_principal(session, campaign_id, principal, write=True)
|
campaign, _policy = _lifecycle_policy_for_mutation(
|
||||||
if campaign.status != "draft":
|
session,
|
||||||
raise HTTPException(
|
campaign_id=campaign_id,
|
||||||
status_code=status.HTTP_409_CONFLICT,
|
principal=principal,
|
||||||
detail="Only untouched draft campaigns can be deleted.",
|
expected_state_token=payload.expected_state_token,
|
||||||
)
|
action="delete_campaign",
|
||||||
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()
|
|
||||||
)
|
|
||||||
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"
|
campaign.status = "deleted"
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ class CampaignUpdateRequest(BaseModel):
|
|||||||
description: str | None = None
|
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):
|
class CampaignCreateMinimalRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -141,6 +153,8 @@ class CampaignVersionResponse(BaseModel):
|
|||||||
None
|
None
|
||||||
)
|
)
|
||||||
delivery_mode_selected_at: datetime | None = 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")
|
@field_validator("editor_state", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -200,6 +214,19 @@ class CampaignResponse(BaseModel):
|
|||||||
updated_at: datetime
|
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):
|
class CampaignCreateResponse(BaseModel):
|
||||||
campaign: CampaignResponse
|
campaign: CampaignResponse
|
||||||
version: CampaignVersionResponse
|
version: CampaignVersionResponse
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -7,6 +7,7 @@ import pytest
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from govoplan_campaign.backend import route_support
|
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.routes import versions as router
|
||||||
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
from govoplan_campaign.backend.campaign.loader import CampaignSchemaError, validate_against_schema
|
||||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
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:
|
def test_loader_uses_only_non_secret_mail_profile_capabilities() -> None:
|
||||||
raw = _campaign_json({"mail_profile_id": "profile-1"})
|
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 len(report_surfaces) == 1
|
||||||
assert report_surfaces[0].description == "/campaigns/reports"
|
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,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)
|
self.assertIsNotNone(capability)
|
||||||
configure.assert_called_once_with(registry=registry, settings=settings)
|
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):
|
def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
|
||||||
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
|
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
|
||||||
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
|
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.12",
|
"version": "0.1.18",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"read-excel-file": "9.2.0"
|
"read-excel-file": "9.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.14",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ export type CampaignVersionListItem = {
|
|||||||
execution_snapshot_at?: string | null;
|
execution_snapshot_at?: string | null;
|
||||||
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
|
delivery_mode?: "synchronous" | "worker_queue" | "database_queue" | null;
|
||||||
delivery_mode_selected_at?: string | 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 & {
|
export type CampaignVersionDetail = CampaignVersionListItem & {
|
||||||
@@ -1004,10 +1019,59 @@ payload: CampaignUpdatePayload)
|
|||||||
|
|
||||||
export async function archiveCampaign(
|
export async function archiveCampaign(
|
||||||
settings: ApiSettings,
|
settings: ApiSettings,
|
||||||
campaignId: string)
|
campaignId: string,
|
||||||
|
expectedStateToken: string)
|
||||||
: Promise<CampaignListItem> {
|
: Promise<CampaignListItem> {
|
||||||
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/archive`, {
|
return apiFetch<CampaignListItem>(settings, `/api/v1/campaigns/${campaignId}/archive`, {
|
||||||
method: "POST"
|
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 })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
|
|||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import VersionLine from "./components/VersionLine";
|
import VersionLine from "./components/VersionLine";
|
||||||
import { LoadingFrame } from "@govoplan/core-webui";
|
import { LoadingFrame } from "@govoplan/core-webui";
|
||||||
|
import { ActionBlockerHint, DocumentationHelpLink } from "@govoplan/core-webui";
|
||||||
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
||||||
|
|
||||||
export default function CampaignAuditPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
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">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_audit_data.af52b968">
|
||||||
<Card title="i18n:govoplan-campaign.recent_audit_events.7ec32b1d">
|
<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>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</div>);
|
</div>);
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export default function CampaignJsonView({ settings, campaignId }: {settings: Ap
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
{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">
|
<LoadingFrame loading={loading} label="i18n:govoplan-campaign.loading_json.812c7a50">
|
||||||
<Card>
|
<Card>
|
||||||
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
{!loading || version ? <pre className="code-panel">{JSON.stringify(campaignJson, null, 2)}</pre> : <pre className="code-panel">{"{}"}</pre>}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Archive, ExternalLink, LockKeyhole, LockOpen } from "lucide-react";
|
import { Archive, Copy, ExternalLink, LockKeyhole, LockOpen, Trash2 } from "lucide-react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { ApiSettings, AuthInfo } from "../../types";
|
import type { ApiSettings, AuthInfo } from "../../types";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
@@ -10,14 +10,20 @@ import { LoadingFrame } from "@govoplan/core-webui";
|
|||||||
import { MetricCard } from "@govoplan/core-webui";
|
import { MetricCard } from "@govoplan/core-webui";
|
||||||
import { PageTitle } from "@govoplan/core-webui";
|
import { PageTitle } from "@govoplan/core-webui";
|
||||||
import { StatusBadge } from "@govoplan/core-webui";
|
import { StatusBadge } from "@govoplan/core-webui";
|
||||||
|
import { ToggleSwitch } from "@govoplan/core-webui";
|
||||||
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
import { DismissibleAlert, TableActionGroup, hasScope, i18nMessage, useGuardedNavigate, useUnsavedDraftGuard } from "@govoplan/core-webui";
|
||||||
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
|
||||||
import {
|
import {
|
||||||
archiveCampaign,
|
archiveCampaign,
|
||||||
|
archiveCampaignVersion,
|
||||||
|
copyCampaign,
|
||||||
|
deleteCampaign,
|
||||||
|
getCampaignLifecyclePolicy,
|
||||||
lockCampaignVersionPermanently,
|
lockCampaignVersionPermanently,
|
||||||
lockCampaignVersionTemporarily,
|
lockCampaignVersionTemporarily,
|
||||||
unlockCampaignVersionUserLock,
|
unlockCampaignVersionUserLock,
|
||||||
updateCampaignMetadata,
|
updateCampaignMetadata,
|
||||||
|
type CampaignLifecyclePolicy,
|
||||||
type CampaignVersionDetail,
|
type CampaignVersionDetail,
|
||||||
type CampaignVersionListItem } from
|
type CampaignVersionListItem } from
|
||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
@@ -39,22 +45,32 @@ import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddre
|
|||||||
const campaignModeOptions = ["draft", "test", "send"];
|
const campaignModeOptions = ["draft", "test", "send"];
|
||||||
type LockAction = "temporary" | "unlock" | "permanent";
|
type LockAction = "temporary" | "unlock" | "permanent";
|
||||||
type PendingLockAction = {version: CampaignVersionListItem;action: LockAction;} | null;
|
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, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
export default function CampaignOverviewPage({ settings, auth, campaignId }: {settings: ApiSettings;auth: AuthInfo;campaignId: string;}) {
|
||||||
const navigate = useGuardedNavigate();
|
const navigate = useGuardedNavigate();
|
||||||
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
|
||||||
const campaign = data.campaign;
|
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 [identity, setIdentity] = useState({ external_id: "", name: "", status: "", description: "" });
|
||||||
const [identityDirty, setIdentityDirty] = useState(false);
|
const [identityDirty, setIdentityDirty] = useState(false);
|
||||||
const [savingIdentity, setSavingIdentity] = useState(false);
|
const [savingIdentity, setSavingIdentity] = useState(false);
|
||||||
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
const [pendingLockAction, setPendingLockAction] = useState<PendingLockAction>(null);
|
||||||
const [lockBusy, setLockBusy] = useState(false);
|
const [lockBusy, setLockBusy] = useState(false);
|
||||||
const [archiveDialogOpen, setArchiveDialogOpen] = useState(false);
|
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
|
||||||
const [archiving, setArchiving] = useState(false);
|
const [lifecycleBusy, setLifecycleBusy] = useState(false);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
const versionMetrics = useMemo(() => campaignVersionMetrics(data.currentVersion), [data.currentVersion]);
|
||||||
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
|
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({
|
useUnsavedDraftGuard({
|
||||||
dirty: identityDirty,
|
dirty: identityDirty,
|
||||||
@@ -149,20 +165,56 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
await reload({ force: true });
|
await reload({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyArchive() {
|
async function prepareLifecycleAction(action: LifecycleAction, version?: CampaignVersionListItem) {
|
||||||
if (!campaign || archiving) return;
|
if (!campaign || lifecycleBusy || identityDirty) return;
|
||||||
setArchiving(true);
|
setLifecycleBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
try {
|
try {
|
||||||
await archiveCampaign(settings, campaign.id);
|
const policy = await getCampaignLifecyclePolicy(settings, campaign.id, version?.id);
|
||||||
setArchiveDialogOpen(false);
|
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");
|
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 });
|
await reload({ force: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setArchiving(false);
|
setLifecycleBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,10 +226,25 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
<p className="mono-small">i18n:govoplan-campaign.campaign_overview_version_independent_identity_a.ebaf1113</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="button-row compact-actions">
|
<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
|
{canArchive && <Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => setArchiveDialogOpen(true)}
|
onClick={() => void prepareLifecycleAction("archive_campaign")}
|
||||||
disabled={loading || savingIdentity || lockBusy || identityDirty}
|
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
|
||||||
disabledReason={identityDirty ? "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0" : undefined}>
|
disabledReason={identityDirty ? "i18n:govoplan-campaign.save_or_discard_overview_changes_before_archiving.413ff9e0" : undefined}>
|
||||||
<Archive size={16} aria-hidden="true" />
|
<Archive size={16} aria-hidden="true" />
|
||||||
i18n:govoplan-campaign.archive_campaign.26dcfb8a
|
i18n:govoplan-campaign.archive_campaign.26dcfb8a
|
||||||
@@ -220,14 +287,19 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Versions" collapsible actions={<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}`}
|
to={`send?version=${campaign?.current_version_id}`}
|
||||||
className={`btn btn-primary`}
|
className={`btn btn-primary`}
|
||||||
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
aria-label={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}
|
||||||
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
title={i18nMessage("i18n:govoplan-campaign.open_curent_version.cc1cd678", {})}>
|
||||||
|
|
||||||
i18n:govoplan-campaign.open.cf9b7706
|
i18n:govoplan-campaign.open.cf9b7706
|
||||||
</Link>}>
|
</Link>
|
||||||
|
</div>}>
|
||||||
<div className="metric-grid inside campaign-versions-metrics">
|
<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.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" />
|
<MetricCard label="i18n:govoplan-campaign.fields.e8b68527" value={versionMetrics.fieldCount} tone="info" />
|
||||||
@@ -244,26 +316,33 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
|
|||||||
<DataGrid
|
<DataGrid
|
||||||
id={`campaign-${campaignId}-versions`}
|
id={`campaign-${campaignId}-versions`}
|
||||||
rows={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}
|
getRowKey={(version) => version.id}
|
||||||
initialSort={{ columnId: "version", direction: "desc" }}
|
initialSort={{ columnId: "version", direction: "desc" }}
|
||||||
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
emptyText="i18n:govoplan-campaign.no_versions_found.a8284e9e"
|
||||||
className="version-history-table"
|
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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={archiveDialogOpen}
|
open={Boolean(pendingLifecycleAction)}
|
||||||
title="i18n:govoplan-campaign.archive_campaign.26dcfb8a"
|
title={lifecycleDialogTitle(pendingLifecycleAction)}
|
||||||
message="i18n:govoplan-campaign.archive_campaign_confirmation.c0cc62e1"
|
message={lifecycleDialogMessage(pendingLifecycleAction)}
|
||||||
confirmLabel="i18n:govoplan-campaign.archive_campaign.26dcfb8a"
|
confirmLabel={lifecycleDialogLabel(pendingLifecycleAction)}
|
||||||
tone="danger"
|
tone="danger"
|
||||||
busy={archiving}
|
busy={lifecycleBusy}
|
||||||
onCancel={() => setArchiveDialogOpen(false)}
|
onCancel={() => setPendingLifecycleAction(null)}
|
||||||
onConfirm={() => void applyArchive()} />
|
onConfirm={() => void applyLifecycleAction()} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(pendingLockAction)}
|
open={Boolean(pendingLockAction)}
|
||||||
@@ -336,7 +415,14 @@ function textValue(value: unknown, fallback = ""): string {
|
|||||||
return typeof value === "string" ? value : fallback;
|
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 [
|
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: "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" },
|
{ 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" },
|
||||||
@@ -356,6 +442,8 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
|||||||
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
|
const canTemporarilyLock = isCurrent && !temporarilyLocked && !isPermanentUserLockedVersion(version) && !isFinalLockedVersion(version) && !canUnlockValidationVersion(version) && !version.locked_at;
|
||||||
return <TableActionGroup actions={[
|
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: "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: "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: "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" }) }
|
{ 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" }) }
|
||||||
@@ -367,6 +455,7 @@ function versionColumns(setPendingLockAction: (action: PendingLockAction) => voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
function versionLockLabel(version: CampaignVersionListItem, currentVersionId?: string | null): string {
|
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 (currentVersionId && version.id !== currentVersionId) return "i18n:govoplan-campaign.historical_review_only.5afffe82";
|
||||||
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
if (isTemporaryUserLockedVersion(version)) return "i18n:govoplan-campaign.temporary_user_lock.c2bda6a9";
|
||||||
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
if (isPermanentUserLockedVersion(version)) return "i18n:govoplan-campaign.permanent_user_lock.9d5d8959";
|
||||||
@@ -415,3 +504,27 @@ function lockDialogLabel(pending: PendingLockAction): string {
|
|||||||
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
if (pending?.action === "permanent") return "i18n:govoplan-campaign.lock_permanently.cc0ce9e7";
|
||||||
return "i18n:govoplan-campaign.confirm.04a21221";
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })} />
|
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Password">
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid two">
|
<div className="form-grid two">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { CircleCheck, CircleX } from "lucide-react";
|
||||||
import type { ApiSettings } from "../../types";
|
import type { ApiSettings } from "../../types";
|
||||||
import {
|
import {
|
||||||
getCampaignPostboxCatalog,
|
getCampaignPostboxCatalog,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
"../../api/campaigns";
|
"../../api/campaigns";
|
||||||
import { Button } from "@govoplan/core-webui";
|
import { Button } from "@govoplan/core-webui";
|
||||||
import { Card } from "@govoplan/core-webui";
|
import { Card } from "@govoplan/core-webui";
|
||||||
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||||
import { FormField } from "@govoplan/core-webui";
|
import { FormField } from "@govoplan/core-webui";
|
||||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||||
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
||||||
@@ -39,7 +41,7 @@ import {
|
|||||||
createAddressSourceImportProvenance
|
createAddressSourceImportProvenance
|
||||||
} from "./utils/addressSourceImport";
|
} from "./utils/addressSourceImport";
|
||||||
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
|
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 AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||||
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
|
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +71,12 @@ import {
|
|||||||
} from "./recipients/RecipientAddressEditor";
|
} from "./recipients/RecipientAddressEditor";
|
||||||
import { RecipientImportDialog } from "./recipients/RecipientImportDialog";
|
import { RecipientImportDialog } from "./recipients/RecipientImportDialog";
|
||||||
import { recipientProfileColumns } from "./recipients/recipientProfileColumns";
|
import { recipientProfileColumns } from "./recipients/recipientProfileColumns";
|
||||||
|
|
||||||
|
type RecipientBulkActivation = {
|
||||||
|
active: boolean;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||||
const { translateText } = usePlatformLanguage();
|
const { translateText } = usePlatformLanguage();
|
||||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
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 [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
|
||||||
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
||||||
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
||||||
|
const [bulkActivation, setBulkActivation] = useState<RecipientBulkActivation | null>(null);
|
||||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||||
available: false,
|
available: false,
|
||||||
postboxes: [],
|
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) {
|
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
||||||
if (locked || !draft) return;
|
if (locked || !draft) return;
|
||||||
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
|
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
|
||||||
@@ -492,6 +514,22 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</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>
|
</div>
|
||||||
}>
|
}>
|
||||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||||
@@ -626,6 +664,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
|||||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
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] &&
|
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
|
||||||
<PostboxTargetsDialog
|
<PostboxTargetsDialog
|
||||||
open
|
open
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default function FieldValueInput({ fieldType = "string", value, disabled
|
|||||||
inputClassName={className}
|
inputClassName={className}
|
||||||
value={valueToInputText(value, normalizedType)}
|
value={valueToInputText(value, normalizedType)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
generator
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
onValueChange={(nextValue) => onChange(inputValueToFieldValue(normalizedType, nextValue))}
|
onValueChange={(nextValue) => onChange(inputValueToFieldValue(normalizedType, nextValue))}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link2, X } from "lucide-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 {
|
import type {
|
||||||
CampaignAttachmentPreviewFile,
|
CampaignAttachmentPreviewFile,
|
||||||
@@ -108,7 +108,11 @@ export default function AttachmentLinkingPreview({
|
|||||||
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
value={loading ? "..." : preview?.shared_file_count ?? "—"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="review-flow-inline-note is-danger">{error}</p>}
|
{error && (
|
||||||
|
<DismissibleAlert tone="danger" compact dismissible={false}>
|
||||||
|
{error}
|
||||||
|
</DismissibleAlert>
|
||||||
|
)}
|
||||||
{!error && unlinkedCount > 0 && (
|
{!error && unlinkedCount > 0 && (
|
||||||
<p className="review-flow-inline-note is-stale">
|
<p className="review-flow-inline-note is-stale">
|
||||||
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
|
i18n:govoplan-campaign.unlinked_candidate_files_are_not_yet_part_of_the.b8fd5998
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
|||||||
|
|
||||||
export const generatedTranslations: PlatformTranslations = {
|
export const generatedTranslations: PlatformTranslations = {
|
||||||
"en": {
|
"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.guided_workflows": "Guided workflows",
|
||||||
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
|
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
|
||||||
"i18n:govoplan-campaign.required_action.f2429497": "Required action",
|
"i18n:govoplan-campaign.required_action.f2429497": "Required action",
|
||||||
@@ -1335,6 +1343,14 @@ export const generatedTranslations: PlatformTranslations = {
|
|||||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||||
},
|
},
|
||||||
"de": {
|
"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.guided_workflows": "Geführte Abläufe",
|
||||||
"i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.",
|
"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.required_action.f2429497": "Erforderliche Aktion",
|
||||||
|
|||||||
+2
-5
@@ -17,7 +17,6 @@ import "./styles/campaign-workspace.css";
|
|||||||
|
|
||||||
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
|
const CampaignModulePage = lazy(() => import("./features/campaigns/CampaignModulePage"));
|
||||||
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
const CampaignWorkspace = lazy(() => import("./features/campaigns/CampaignWorkspace"));
|
||||||
const TemplatesPage = lazy(() => import("./features/templates/TemplatesPage"));
|
|
||||||
|
|
||||||
const campaignRead = ["campaigns:campaign:read"];
|
const campaignRead = ["campaigns:campaign:read"];
|
||||||
const reportRead = ["campaigns:report:read"];
|
const reportRead = ["campaigns:report:read"];
|
||||||
@@ -91,16 +90,14 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
navItems: [
|
navItems: [
|
||||||
{ to: "/campaigns", label: "i18n:govoplan-campaign.campaigns.01a23a28", iconName: "campaign", anyOf: campaignModuleRead, order: 20 },
|
{ 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 }],
|
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) },
|
{ 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: "/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/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/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: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) }],
|
||||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
|
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
"dashboard.widgets": campaignDashboardWidgets,
|
"dashboard.widgets": campaignDashboardWidgets,
|
||||||
"wizard.directories": campaignWizardDirectories
|
"wizard.directories": campaignWizardDirectories
|
||||||
|
|||||||
@@ -1285,6 +1285,11 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-history-table .data-grid-body-cell.archived-version-row {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.mock-message-detail {
|
.mock-message-detail {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
border-top: 1px solid var(--line-subtle);
|
border-top: 1px solid var(--line-subtle);
|
||||||
@@ -2761,3 +2766,18 @@
|
|||||||
align-items: start;
|
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"),
|
path.join(sourceRoot, "features/campaigns/components/MessagePreviewOverlay.tsx"),
|
||||||
"utf8",
|
"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"]) {
|
for (const handler of ["onFirst", "onPrevious", "onNext", "onLast"]) {
|
||||||
const buttonLine = preview
|
const buttonLine = preview
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
|||||||
@@ -3,13 +3,29 @@ import { readFileSync } from "node:fs";
|
|||||||
|
|
||||||
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
||||||
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.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");
|
const api = readFileSync("src/api/campaigns.ts", "utf8");
|
||||||
|
|
||||||
assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
|
assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
|
||||||
assert.match(overview, /hasScope\(auth, "campaigns:campaign:archive"\)/);
|
assert.match(overview, /hasScope\(auth, "campaigns:campaign:archive"\)/);
|
||||||
assert.match(overview, /archiveDialogOpen/);
|
assert.match(overview, /getCampaignLifecyclePolicy/);
|
||||||
|
assert.match(overview, /pendingLifecycleAction/);
|
||||||
assert.match(overview, /archive_campaign_confirmation/);
|
assert.match(overview, /archive_campaign_confirmation/);
|
||||||
assert.match(overview, /await archiveCampaign\(settings, campaign\.id\)/);
|
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\}\/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.");
|
console.log("Campaign lifecycle UI structural contract passed.");
|
||||||
|
|||||||
Reference in New Issue
Block a user