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
|
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,
|
||||||
@@ -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,
|
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
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/campaign-webui",
|
"name": "@govoplan/campaign-webui",
|
||||||
"version": "0.1.12",
|
"version": "0.1.15",
|
||||||
"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.15",
|
||||||
"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.15"
|
||||||
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.15",
|
||||||
"jsonschema>=4,<5",
|
"jsonschema>=4,<5",
|
||||||
"pydantic>=2,<3",
|
"pydantic>=2,<3",
|
||||||
"SQLAlchemy>=2,<3",
|
"SQLAlchemy>=2,<3",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.15",
|
||||||
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",
|
||||||
@@ -592,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",
|
||||||
@@ -756,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"),
|
||||||
@@ -804,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. 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",
|
layer="configured",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("campaign_manager", "campaign_author"),
|
audience=("campaign_manager", "campaign_author"),
|
||||||
@@ -860,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.",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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)
|
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.15",
|
||||||
"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.15",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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))}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ 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\}/);
|
||||||
@@ -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\}\/archive/);
|
||||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
|
||||||
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
|
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