Compare commits
9
Commits
8f5231147d
...
v0.1.15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0efe661f | ||
|
|
6bde11a286 | ||
|
|
585493fe7a | ||
|
|
4133de86cd | ||
|
|
9137300780 | ||
|
|
f98fe06143 | ||
|
|
dc63e35550 | ||
|
|
2dac5570cd | ||
|
|
91890fdaf5 |
@@ -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
|
||||
@@ -124,6 +124,12 @@ action.
|
||||
5. Open **Mail settings** and select an available Mail profile. The campaign
|
||||
stores only `server.mail_profile_id`; it never accepts SMTP/IMAP settings,
|
||||
usernames, passwords, or credential references.
|
||||
If the selected profile permits a campaign-scoped Mail credential, that
|
||||
credential remains Mail-owned even though it is created from this surface.
|
||||
For that credential and for password-valued campaign fields, the shared
|
||||
password generator keeps its candidate separate from the form until **Use
|
||||
password** is explicitly confirmed. Copying a candidate does not save or
|
||||
submit it.
|
||||
6. Save the editable version, validate the relevant sections, and resolve every
|
||||
blocking issue. Warnings remain explicit review decisions.
|
||||
7. Build the exact messages and inspect recipient, addressing, template,
|
||||
@@ -454,6 +460,15 @@ Breaking payload or ownership changes require an interface-version bump and a
|
||||
release-composition alignment gate. Optional absence must be tested physically,
|
||||
not only hidden in navigation.
|
||||
|
||||
### Bulk recipient activation
|
||||
|
||||
Recipient data can activate all currently inactive rows or deactivate all
|
||||
currently active rows. The action displays the exact affected count and
|
||||
requires confirmation. It changes only the local Campaign draft until the
|
||||
operator saves. Saving follows the ordinary versioned Campaign update path, so
|
||||
the resulting recipient state is retained in version and protocol evidence and
|
||||
any stale validation, build, or review evidence is invalidated.
|
||||
|
||||
### Reusable Distribution Lists
|
||||
|
||||
When Distribution Lists is available, Recipient data offers a separate import
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
@@ -22,11 +22,11 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.12",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.1"
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
"react-router": ">=8.3.0 <9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
|
||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-campaign"
|
||||
version = "0.1.12"
|
||||
version = "0.1.15"
|
||||
description = "GovOPlaN campaigns module with backend and WebUI integration."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { file = "LICENSE" }
|
||||
authors = [{ name = "GovOPlaN" }]
|
||||
dependencies = [
|
||||
"govoplan-core>=0.1.14",
|
||||
"govoplan-core>=0.1.15",
|
||||
"jsonschema>=4,<5",
|
||||
"pydantic>=2,<3",
|
||||
"SQLAlchemy>=2,<3",
|
||||
|
||||
@@ -690,6 +690,10 @@ def policy_context_capability(context: object) -> CampaignPolicyContextService:
|
||||
|
||||
|
||||
class CampaignDeliveryTaskService(CampaignDeliveryTaskProvider):
|
||||
def tenant_id_for_job(self, session: object, *, job_id: str) -> str | None:
|
||||
job = session.get(CampaignJob, job_id) # type: ignore[attr-defined]
|
||||
return job.tenant_id if job is not None else None
|
||||
|
||||
def send_campaign_job(self, session: object, *, job_id: str, enqueue_imap_task: bool = True) -> Mapping[str, object]:
|
||||
from govoplan_campaign.backend.sending.jobs import send_campaign_job
|
||||
|
||||
|
||||
@@ -55,12 +55,14 @@ from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
)
|
||||
from govoplan_core.core.references import CAPABILITY_ACCESS_REFERENCE_OPTIONS
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
from govoplan_campaign.backend.documentation import (
|
||||
CAMPAIGN_USER_DOCUMENTATION,
|
||||
documentation_topics,
|
||||
)
|
||||
from govoplan_campaign.backend.search_source import create_campaign_search_source
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
@@ -358,7 +360,7 @@ def _campaigns_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="0.1.12",
|
||||
version="0.1.15",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
@@ -378,6 +380,7 @@ manifest = ModuleManifest(
|
||||
"postbox",
|
||||
"approvals",
|
||||
"reporting",
|
||||
"search",
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||
@@ -481,12 +484,24 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="0.2.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="search.source",
|
||||
version_min="1.0.0",
|
||||
version_max_exclusive="2.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_campaigns_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
tenant_summary_batch_providers=(_tenant_summary_batch,),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="campaigns.campaigns",
|
||||
factory=create_campaign_search_source,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
path="/campaigns",
|
||||
@@ -506,6 +521,14 @@ manifest = ModuleManifest(
|
||||
required_any=CAMPAIGN_MODULE_REQUIRED_ANY,
|
||||
order=20,
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/operator",
|
||||
component="OperatorQueueRedirect",
|
||||
required_all=("campaigns:campaign:read",),
|
||||
required_any=OPERATOR_QUEUE_REQUIRED_ANY,
|
||||
order=21,
|
||||
surface_id="campaigns.route.operator-redirect",
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/campaigns/queue",
|
||||
component="OperatorQueuePage",
|
||||
@@ -592,6 +615,23 @@ manifest = ModuleManifest(
|
||||
),
|
||||
documentation=(
|
||||
*CAMPAIGN_USER_DOCUMENTATION,
|
||||
DocumentationTopic(
|
||||
id="campaigns.search.campaigns",
|
||||
title="Search authorized campaigns",
|
||||
summary="Expose campaign identity and lifecycle metadata to permission-aware platform Search.",
|
||||
body=(
|
||||
"When Search is installed, Campaign contributes current campaign names, external identifiers, "
|
||||
"descriptions, and lifecycle state. Search rechecks tenant ownership, group ownership, explicit "
|
||||
"shares, revocation, deletion, and the Campaign read permission before returning a result. "
|
||||
"Committed Campaign and share changes update the derived index through the durable platform event "
|
||||
"path; rebuilding Search never changes Campaign evidence."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("campaign_manager", "campaign_operator", "administrator"),
|
||||
related_modules=("search",),
|
||||
order=44,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="campaigns.postbox-delivery",
|
||||
title="Deliver Campaign messages to Postboxes",
|
||||
@@ -756,7 +796,7 @@ manifest = ModuleManifest(
|
||||
id="campaigns.mail-profile-operations",
|
||||
title="Operate profile-backed campaign delivery",
|
||||
summary="Workers re-authorize and resolve Mail profiles at execution time while Campaign retains only opaque Mail-owned revisions and outcomes.",
|
||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation.",
|
||||
body="A legacy snapshot, unauthorized or inactive profile, profile-reference mismatch, or changed SMTP/IMAP transport revision stops delivery. Preserve the record, migrate or correct the profile selection, revalidate, rebuild, and only then queue again. Password-only rotation remains possible without copying secrets into Campaign. Uncertain SMTP and IMAP effects remain blocked until an evidence-backed operator reconciliation. If Campaign becomes unavailable to the tenant after a job was accepted, the worker leaves the job untouched and reports an operator action instead of sending or dropping it.",
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("campaign_sender", "campaign_operator", "mail_admin"),
|
||||
@@ -804,7 +844,7 @@ manifest = ModuleManifest(
|
||||
id="campaigns.workflow.prepare-validate-and-build",
|
||||
title="Prepare, validate, and build a campaign",
|
||||
summary="Turn governed recipient, template, attachment, and Mail-profile inputs into exact built messages for review.",
|
||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
||||
body="Prepare each input in its owning surface, resolve every blocking validation issue, and build exact recipient messages before review. Recipient data can activate or deactivate every currently opposite-state row as one explicitly confirmed draft change; saving it creates the normal Campaign version evidence and invalidates stale validation, build, and review state. New campaign credentials and password-valued fields offer the shared secure generator; its candidate remains separate until Use password is confirmed. Campaign freezes recipient and attachment evidence for the selected version; later source changes do not silently alter that build. When the Templates module is installed, its single Templates navigation entry owns the reusable library while campaign-specific composition remains in the campaign workspace.",
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_manager", "campaign_author"),
|
||||
@@ -860,8 +900,8 @@ manifest = ModuleManifest(
|
||||
],
|
||||
"steps": [
|
||||
"Set campaign-wide fields and purpose, then define the recipient fields and templates.",
|
||||
"Import or select recipients and inspect provenance, exclusions, and review-required rows.",
|
||||
"Select managed attachment versions and an authorized Mail profile when those capabilities are used.",
|
||||
"Import or select recipients, optionally confirm a counted activate-all or deactivate-all draft action, and inspect provenance, exclusions, and review-required rows.",
|
||||
"Select managed attachment versions and an authorized Mail profile when those capabilities are used; generate a new password only when creating a campaign credential or password-valued field, then explicitly confirm the candidate.",
|
||||
"Validate the relevant sections and resolve every blocker without hiding warnings.",
|
||||
"Build the selected version and inspect representative and exceptional rendered messages.",
|
||||
],
|
||||
|
||||
@@ -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,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
|
||||
from govoplan_campaign.backend.search_source import (
|
||||
CampaignSearchSource,
|
||||
PROVIDER_ID,
|
||||
RESOURCE_TYPE,
|
||||
)
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
class CampaignSearchSourceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=(
|
||||
Account.__table__,
|
||||
User.__table__,
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
),
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
self.session.add_all(
|
||||
(
|
||||
Account(
|
||||
id="account-1",
|
||||
email="one@example.test",
|
||||
normalized_email="one@example.test",
|
||||
),
|
||||
User(
|
||||
id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
email="one@example.test",
|
||||
),
|
||||
Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
owner_user_id="user-1",
|
||||
external_id="monthly-letters",
|
||||
name="Monthly letters",
|
||||
),
|
||||
Campaign(
|
||||
id="campaign-other",
|
||||
tenant_id="tenant-2",
|
||||
external_id="other",
|
||||
name="Other tenant",
|
||||
),
|
||||
)
|
||||
)
|
||||
self.session.commit()
|
||||
self.source = CampaignSearchSource()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_backfill_and_live_acl_recheck_do_not_cross_tenants(self) -> None:
|
||||
page = self.source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(("campaign-1",), tuple(doc.resource_id for doc in page.documents))
|
||||
reference = SearchResourceReference(
|
||||
tenant_id="tenant-1",
|
||||
module_id="campaigns",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id="campaign-1",
|
||||
)
|
||||
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
|
||||
self.assertTrue(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal({"campaigns:campaign:read"}),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
self.assertFalse(
|
||||
self.source.authorize(
|
||||
self.session,
|
||||
_principal(set()),
|
||||
requests=(request,),
|
||||
)[reference.key]
|
||||
)
|
||||
|
||||
|
||||
def _principal(scopes: set[str]) -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="user-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(scopes),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,6 +60,19 @@ class CampaignQueueSelectionTests(unittest.TestCase):
|
||||
self.assertIsNotNone(capability)
|
||||
configure.assert_called_once_with(registry=registry, settings=settings)
|
||||
|
||||
def test_delivery_task_capability_resolves_job_tenant_before_effect(self):
|
||||
capability = delivery_tasks_capability(
|
||||
SimpleNamespace(registry=object(), settings=object())
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
get=lambda _model, _job_id: SimpleNamespace(tenant_id="tenant-1")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"tenant-1",
|
||||
capability.tenant_id_for_job(session, job_id="job-1"),
|
||||
)
|
||||
|
||||
def test_selects_queueable_jobs_without_reclassifying_retry_states(self):
|
||||
skipped_send = _job("1", send_status=JobSendStatus.FAILED_TEMPORARY.value)
|
||||
skipped_queue = _job("2", queue_status=JobQueueStatus.PAUSED.value)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/campaign-webui",
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
@@ -17,7 +17,7 @@
|
||||
"read-excel-file": "9.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.14",
|
||||
"@govoplan/core-webui": "^0.1.15",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20",
|
||||
|
||||
@@ -465,7 +465,7 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
|
||||
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Password">
|
||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" />
|
||||
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} generator autoComplete="new-password" />
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="form-grid two">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { CircleCheck, CircleX } from "lucide-react";
|
||||
import type { ApiSettings } from "../../types";
|
||||
import {
|
||||
getCampaignPostboxCatalog,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
"../../api/campaigns";
|
||||
import { Button } from "@govoplan/core-webui";
|
||||
import { Card } from "@govoplan/core-webui";
|
||||
import { ConfirmDialog } from "@govoplan/core-webui";
|
||||
import { FormField } from "@govoplan/core-webui";
|
||||
import { usePlatformModuleInstalled } from "@govoplan/core-webui";
|
||||
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
|
||||
@@ -39,7 +41,7 @@ import {
|
||||
createAddressSourceImportProvenance
|
||||
} from "./utils/addressSourceImport";
|
||||
import { addressesFromValue, type MailboxAddress } from "@govoplan/core-webui";
|
||||
import { insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import { i18nMessage, insertAfter, moveArrayItem, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
|
||||
import AddressSourceImportDialog from "./recipients/AddressSourceImportDialog";
|
||||
import DistributionListImportDialog from "./recipients/DistributionListImportDialog";
|
||||
import {
|
||||
@@ -69,6 +71,12 @@ import {
|
||||
} from "./recipients/RecipientAddressEditor";
|
||||
import { RecipientImportDialog } from "./recipients/RecipientImportDialog";
|
||||
import { recipientProfileColumns } from "./recipients/recipientProfileColumns";
|
||||
|
||||
type RecipientBulkActivation = {
|
||||
active: boolean;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const filesModuleInstalled = usePlatformModuleInstalled("files");
|
||||
@@ -91,6 +99,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
const [recipientProfilesQuery, setRecipientProfilesQuery] = useState<DataGridQueryState>({ sort: null, filters: {} });
|
||||
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
|
||||
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState<number | null>(null);
|
||||
const [bulkActivation, setBulkActivation] = useState<RecipientBulkActivation | null>(null);
|
||||
const [postboxCatalog, setPostboxCatalog] = useState<CampaignPostboxCatalog>({
|
||||
available: false,
|
||||
postboxes: [],
|
||||
@@ -346,6 +355,19 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
});
|
||||
}
|
||||
|
||||
function requestBulkActivation(active: boolean) {
|
||||
if (locked) return;
|
||||
const count = inlineEntries.filter((entry) => (entry.active !== false) !== active).length;
|
||||
if (count === 0) return;
|
||||
setBulkActivation({ active, count });
|
||||
}
|
||||
|
||||
function confirmBulkActivation() {
|
||||
if (!bulkActivation || locked) return;
|
||||
replaceInlineEntries(inlineEntries.map((entry) => ({ ...entry, active: bulkActivation.active })));
|
||||
setBulkActivation(null);
|
||||
}
|
||||
|
||||
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
|
||||
if (locked || !draft) return;
|
||||
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
|
||||
@@ -492,6 +514,22 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
</Button>
|
||||
}
|
||||
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
|
||||
<Button
|
||||
disabled={locked || inlineEntries.every((entry) => entry.active !== false)}
|
||||
onClick={() => requestBulkActivation(true)}>
|
||||
<CircleCheck size={16} aria-hidden="true" />
|
||||
{i18nMessage("i18n:govoplan-campaign.activate_all_value0.7e911e3e", {
|
||||
value0: inlineEntries.filter((entry) => entry.active === false).length
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={locked || inlineEntries.every((entry) => entry.active === false)}
|
||||
onClick={() => requestBulkActivation(false)}>
|
||||
<CircleX size={16} aria-hidden="true" />
|
||||
{i18nMessage("i18n:govoplan-campaign.deactivate_all_value0.87e5d46f", {
|
||||
value0: inlineEntries.filter((entry) => entry.active !== false).length
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
}>
|
||||
{inlineEntries.length === 0 && Boolean(source.type) &&
|
||||
@@ -626,6 +664,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
|
||||
onClose={() => setRecipientAddressEditorIndex(null)} />
|
||||
|
||||
}
|
||||
<ConfirmDialog
|
||||
open={Boolean(bulkActivation)}
|
||||
title={bulkActivation?.active
|
||||
? "i18n:govoplan-campaign.activate_all_recipients.6be687f0"
|
||||
: "i18n:govoplan-campaign.deactivate_all_recipients.5939b005"}
|
||||
message={bulkActivation ? i18nMessage(
|
||||
bulkActivation.active
|
||||
? "i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50"
|
||||
: "i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f",
|
||||
{ value0: bulkActivation.count }
|
||||
) : ""}
|
||||
confirmLabel={bulkActivation?.active
|
||||
? "i18n:govoplan-campaign.activate_recipients.bc3a7878"
|
||||
: "i18n:govoplan-campaign.deactivate_recipients.88d80611"}
|
||||
tone={bulkActivation?.active ? "default" : "danger"}
|
||||
onConfirm={confirmBulkActivation}
|
||||
onCancel={() => setBulkActivation(null)} />
|
||||
{postboxTargetEditorIndex !== null && inlineEntries[postboxTargetEditorIndex] &&
|
||||
<PostboxTargetsDialog
|
||||
open
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function FieldValueInput({ fieldType = "string", value, disabled
|
||||
inputClassName={className}
|
||||
value={valueToInputText(value, normalizedType)}
|
||||
disabled={disabled}
|
||||
generator
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
onValueChange={(nextValue) => onChange(inputValueToFieldValue(normalizedType, nextValue))}
|
||||
|
||||
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
|
||||
|
||||
export const generatedTranslations: PlatformTranslations = {
|
||||
"en": {
|
||||
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Activate all ({value0})",
|
||||
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Deactivate all ({value0})",
|
||||
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Activate all recipients",
|
||||
"i18n:govoplan-campaign.deactivate_all_recipients.5939b005": "Deactivate all recipients",
|
||||
"i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50": "Activate {value0} currently inactive recipients? The change remains a draft until you save.",
|
||||
"i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f": "Deactivate {value0} currently active recipients? The change remains a draft until you save.",
|
||||
"i18n:govoplan-campaign.activate_recipients.bc3a7878": "Activate recipients",
|
||||
"i18n:govoplan-campaign.deactivate_recipients.88d80611": "Deactivate recipients",
|
||||
"i18n:govoplan-campaign.guided_workflows": "Guided workflows",
|
||||
"i18n:govoplan-campaign.no_guided_workflows": "No guided workflows are available.",
|
||||
"i18n:govoplan-campaign.required_action.f2429497": "Required action",
|
||||
@@ -1335,6 +1343,14 @@ export const generatedTranslations: PlatformTranslations = {
|
||||
"i18n:govoplan-campaign.zipcrypto.03bf7fb4": "ZipCrypto"
|
||||
},
|
||||
"de": {
|
||||
"i18n:govoplan-campaign.activate_all_value0.7e911e3e": "Alle aktivieren ({value0})",
|
||||
"i18n:govoplan-campaign.deactivate_all_value0.87e5d46f": "Alle deaktivieren ({value0})",
|
||||
"i18n:govoplan-campaign.activate_all_recipients.6be687f0": "Alle Empfänger aktivieren",
|
||||
"i18n:govoplan-campaign.deactivate_all_recipients.5939b005": "Alle Empfänger deaktivieren",
|
||||
"i18n:govoplan-campaign.activate_value0_currently_inactive_recipients_the_change_r.53787f50": "{value0} derzeit inaktive Empfänger aktivieren? Die Änderung bleibt ein Entwurf, bis Sie speichern.",
|
||||
"i18n:govoplan-campaign.deactivate_value0_currently_active_recipients_the_change_r.3bee469f": "{value0} derzeit aktive Empfänger deaktivieren? Die Änderung bleibt ein Entwurf, bis Sie speichern.",
|
||||
"i18n:govoplan-campaign.activate_recipients.bc3a7878": "Empfänger aktivieren",
|
||||
"i18n:govoplan-campaign.deactivate_recipients.88d80611": "Empfänger deaktivieren",
|
||||
"i18n:govoplan-campaign.guided_workflows": "Geführte Abläufe",
|
||||
"i18n:govoplan-campaign.no_guided_workflows": "Es sind keine geführten Abläufe verfügbar.",
|
||||
"i18n:govoplan-campaign.required_action.f2429497": "Erforderliche Aktion",
|
||||
|
||||
@@ -3,6 +3,9 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
|
||||
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
|
||||
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
|
||||
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
|
||||
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
|
||||
const api = readFileSync("src/api/campaigns.ts", "utf8");
|
||||
|
||||
assert.match(workspace, /<CampaignOverviewPage settings=\{settings\} auth=\{auth\}/);
|
||||
@@ -17,5 +20,12 @@ assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pe
|
||||
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.");
|
||||
|
||||
Reference in New Issue
Block a user