2 Commits

Author SHA1 Message Date
a2053518d1 Release v0.1.4 2026-07-02 15:01:18 +02:00
6d391d13bd Generate release WebUI lockfile 2026-06-26 01:42:23 +02:00
14 changed files with 874 additions and 72 deletions

View File

@@ -37,17 +37,24 @@ govoplan-campaign git@git.add-ideas.de:add-ideas/govoplan-campaign.git v0.1.1
Local development uses `webui/package.json`, which may point at sibling module checkouts while active development is happening.
Release WebUI installs should use `webui/package.release.json`. It points module dependencies at the same tagged git repositories. To generate a release lockfile, copy it over `package.json` in a release branch or build workspace and then run `npm install` there:
Release WebUI installs should use `webui/package.release.json`. It points module dependencies at the same tagged git repositories. After the module tags referenced there exist, generate the committed release lockfile without touching the development package files:
```bash
cd /mnt/DATA/git/govoplan-core/webui
cp package.release.json package.json
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm install
cd /mnt/DATA/git/govoplan-core
scripts/generate-release-lock.sh
cd webui
PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versions/node/v22.22.3/bin/npm run build
```
The module repositories include root-level npm package manifests so git installs can resolve `@govoplan/files-webui`, `@govoplan/mail-webui`, and `@govoplan/campaign-webui` from repository roots even though their source lives below `webui/src`.
The normal release path is automated by `scripts/push-release-tag.sh`: it bumps or accepts the target version, updates Python/WebUI/module manifest versions, commits/tags/pushes the module repositories first, regenerates `webui/package-lock.release.json`, and then commits/tags/pushes core. If the working tree has already been bumped, pass the current version explicitly:
```bash
cd /mnt/DATA/git/govoplan-core
scripts/push-release-tag.sh --version 0.1.2
```
### Release lockfile strategy
The supported release composition currently is the full Multi Seal Mail product: core plus files, mail, and campaign. Keep one committed full-product release lockfile at `webui/package-lock.release.json`, generated from `webui/package.release.json` in a clean release workspace. Development `package-lock.json` may continue to point at local `file:` dependencies.
@@ -59,6 +66,6 @@ Frontend module permutations are regression-tested through `GOVOPLAN_WEBUI_MODUL
- Keep Python package versions, WebUI package versions, and git tags aligned.
- Tag core, files, mail, and campaign repositories together.
- Update `requirements-release.txt` and `webui/package.release.json` when the release tag changes.
- Generate the committed full-product release lockfile from `package.release.json` in a clean build workspace.
- Generate the committed full-product release lockfile from `package.release.json` with `scripts/generate-release-lock.sh`.
- Add separate release manifest/lockfile pairs only for module compositions that are shipped as their own products.
- Do not commit local sibling paths into release manifests.

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-core"
version = "0.1.3"
version = "0.1.4"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage:
scripts/generate-release-lock.sh [options]
Generates webui/package-lock.release.json from webui/package.release.json in a
temporary workspace. The normal development package.json and package-lock.json
are left untouched.
Run this after the module git tags referenced by package.release.json exist and
are reachable, and before tagging the core release commit that should contain
the regenerated release lockfile.
Options:
--npm <path> npm executable to use.
-h, --help Show this help.
USAGE
}
fail() {
echo "error: $*" >&2
exit 1
}
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WEBUI="$ROOT/webui"
NPM_BIN="${NPM:-}"
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
else
NPM_BIN="npm"
fi
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--npm)
[[ $# -ge 2 ]] || fail "missing value for $1"
NPM_BIN="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
[[ -f "$WEBUI/package.release.json" ]] || fail "missing $WEBUI/package.release.json"
command -v "$NPM_BIN" >/dev/null 2>&1 || fail "npm executable not found: $NPM_BIN"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/govoplan-release-lock.XXXXXXXX")"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cp "$WEBUI/package.release.json" "$TMP_DIR/package.json"
if [[ -f "$WEBUI/package-lock.release.json" ]]; then
cp "$WEBUI/package-lock.release.json" "$TMP_DIR/package-lock.json"
fi
echo "Generating release lockfile from $WEBUI/package.release.json"
echo "Temporary workspace: $TMP_DIR"
(
cd "$TMP_DIR"
PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" install --package-lock-only --ignore-scripts
)
cp "$TMP_DIR/package-lock.json" "$WEBUI/package-lock.release.json"
echo "Updated $WEBUI/package-lock.release.json"

136
scripts/launch-dev.sh Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${GOVOPLAN_CORE_ROOT:-/mnt/DATA/git/govoplan-core}"
WEBUI_ROOT="$ROOT/webui"
PYTHON="${PYTHON:-$ROOT/.venv/bin/python}"
NODE_BIN="${NODE_BIN:-/home/zemion/.nvm/versions/node/v22.22.3/bin}"
NPM="${NPM:-$NODE_BIN/npm}"
BACKEND_HOST="${GOVOPLAN_BACKEND_HOST:-127.0.0.1}"
BACKEND_PORT="${GOVOPLAN_BACKEND_PORT:-8000}"
FRONTEND_HOST="${GOVOPLAN_FRONTEND_HOST:-127.0.0.1}"
FRONTEND_PORT="${GOVOPLAN_FRONTEND_PORT:-5173}"
OPEN_BROWSER="${OPEN_BROWSER:-1}"
LOG_DIR="$ROOT/runtime/dev-launcher"
BACKEND_LOG="$LOG_DIR/backend.log"
FRONTEND_LOG="$LOG_DIR/frontend.log"
BACKEND_URL="http://$BACKEND_HOST:$BACKEND_PORT"
FRONTEND_URL="http://$FRONTEND_HOST:$FRONTEND_PORT"
backend_pid=""
frontend_pid=""
fail() {
printf 'launch-dev: %s\n' "$*" >&2
exit 1
}
port_is_free() {
"$PYTHON" - "$1" "$2" <<'PY'
import socket
import sys
host = sys.argv[1]
port = int(sys.argv[2])
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind((host, port))
except OSError:
raise SystemExit(1)
PY
}
wait_for_url() {
"$PYTHON" - "$1" <<'PY'
import sys
import time
import urllib.request
url = sys.argv[1]
deadline = time.monotonic() + 60
last_error = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=2) as response:
if 200 <= response.status < 500:
raise SystemExit(0)
except Exception as exc: # noqa: BLE001 - printed only on timeout.
last_error = exc
time.sleep(1)
print(f"Timed out waiting for {url}: {last_error}", file=sys.stderr)
raise SystemExit(1)
PY
}
cleanup() {
if [ -n "${frontend_pid:-}" ] && kill -0 "$frontend_pid" 2>/dev/null; then
kill "$frontend_pid" 2>/dev/null || true
fi
if [ -n "${backend_pid:-}" ] && kill -0 "$backend_pid" 2>/dev/null; then
kill "$backend_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT INT TERM
[ -x "$PYTHON" ] || fail "Python virtualenv not found at $PYTHON. Run: cd $ROOT && ./.venv/bin/python -m pip install -r requirements-dev.txt"
[ -x "$NPM" ] || fail "npm not found at $NPM. Set NODE_BIN or NPM to your Node installation."
[ -f "$WEBUI_ROOT/package.json" ] || fail "WebUI package.json not found at $WEBUI_ROOT/package.json"
[ -d "$WEBUI_ROOT/node_modules" ] || fail "WebUI node_modules missing. Run: cd $WEBUI_ROOT && PATH=$NODE_BIN:\$PATH $NPM install"
mkdir -p "$LOG_DIR"
: > "$BACKEND_LOG"
: > "$FRONTEND_LOG"
port_is_free "$BACKEND_HOST" "$BACKEND_PORT" || fail "$BACKEND_URL is already in use"
port_is_free "$FRONTEND_HOST" "$FRONTEND_PORT" || fail "$FRONTEND_URL is already in use"
printf 'Starting GovOPlaN backend at %s\n' "$BACKEND_URL"
(
cd "$ROOT"
"$PYTHON" -m govoplan_core.devserver --host "$BACKEND_HOST" --port "$BACKEND_PORT"
) >"$BACKEND_LOG" 2>&1 &
backend_pid="$!"
printf 'Waiting for %s/health\n' "$BACKEND_URL"
wait_for_url "$BACKEND_URL/health" || {
tail -n 80 "$BACKEND_LOG" >&2 || true
fail "backend did not become healthy"
}
printf 'Starting GovOPlaN WebUI at %s\n' "$FRONTEND_URL"
(
cd "$WEBUI_ROOT"
PATH="$WEBUI_ROOT/node_modules/.bin:$NODE_BIN:$PATH" \
VITE_API_PROXY_TARGET="$BACKEND_URL" \
"$NPM" run dev
) >"$FRONTEND_LOG" 2>&1 &
frontend_pid="$!"
printf 'Waiting for %s\n' "$FRONTEND_URL"
wait_for_url "$FRONTEND_URL" || {
tail -n 80 "$FRONTEND_LOG" >&2 || true
fail "frontend did not become reachable"
}
if [ "$OPEN_BROWSER" = "1" ] && command -v xdg-open >/dev/null 2>&1; then
xdg-open "$FRONTEND_URL" >/dev/null 2>&1 || true
fi
cat <<EOF
GovOPlaN is running.
Web UI: $FRONTEND_URL
API: $BACKEND_URL/api/v1
Health: $BACKEND_URL/health
Logs:
Backend: $BACKEND_LOG
WebUI: $FRONTEND_LOG
Press Ctrl+C to stop both processes.
EOF
wait

View File

@@ -16,11 +16,14 @@ By default the script asks which version part to bump:
Options:
--bump <kind> Version bump: major, minor, subversion, or patch.
--version <x.y.z> Explicit target version instead of a bump.
--version <x.y.z> Explicit target version instead of a bump. May equal
the current repo versions when they were bumped already.
-r, --remote <name> Remote to push to. Defaults to origin.
-b, --branch <name> Branch to update in every repo. Defaults to each current branch.
-m, --message <text> Commit message. Defaults to "Release v<x.y.z>".
--tag-message <text> Annotated tag message. Defaults to the commit message.
--skip-release-lock Do not regenerate core webui/package-lock.release.json.
--npm <path> npm executable for lockfile generation.
-y, --yes Do not prompt before committing, tagging, and pushing.
-n, --dry-run Print intended actions without changing files or git state.
-h, --help Show this help.
@@ -60,6 +63,8 @@ COMMIT_MESSAGE=""
TAG_MESSAGE=""
DRY_RUN=0
YES=0
GENERATE_RELEASE_LOCK=1
NPM_BIN="${NPM:-}"
if [[ -z "${PYTHON:-}" ]]; then
if [[ -x "$ROOT/.venv/bin/python" ]]; then
@@ -69,6 +74,14 @@ if [[ -z "${PYTHON:-}" ]]; then
fi
fi
if [[ -z "$NPM_BIN" ]]; then
if [[ -x "/home/zemion/.nvm/versions/node/v22.22.3/bin/npm" ]]; then
NPM_BIN="/home/zemion/.nvm/versions/node/v22.22.3/bin/npm"
else
NPM_BIN="npm"
fi
fi
normalize_bump() {
local value="${1,,}"
case "$value" in
@@ -174,6 +187,133 @@ print(f"{name}: {old_version} -> {new_version}")
PYCODE
}
update_manifest_version() {
local repo="$1"
local project_name="$2"
local version="$3"
local manifest_path=""
case "$project_name" in
govoplan-core)
manifest_path="$repo/src/govoplan_core/access/manifest.py"
;;
govoplan-files)
manifest_path="$repo/src/govoplan_files/backend/manifest.py"
;;
govoplan-mail)
manifest_path="$repo/src/govoplan_mail/backend/manifest.py"
;;
govoplan-campaign)
manifest_path="$repo/src/govoplan_campaign/backend/manifest.py"
;;
*)
fail "unknown project for manifest version update: $project_name"
;;
esac
[[ -f "$manifest_path" ]] || fail "missing module manifest: $manifest_path"
"$PYTHON" - "$manifest_path" "$version" <<'PYCODE'
from __future__ import annotations
import pathlib
import re
import sys
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
text = path.read_text()
text, count = re.subn(
r'(?m)^(\s*version=)["\'][^"\']+["\'](,?\s*)$',
rf'\1"{new_version}"\2',
text,
count=1,
)
if count != 1:
raise SystemExit(f"could not update ModuleManifest.version in {path}")
path.write_text(text)
PYCODE
}
update_webui_package() {
local repo="$1"
local project_name="$2"
local version="$3"
local package_path=""
local release_package_path="$repo/webui/package.release.json"
for package_path in "$repo/package.json" "$repo/webui/package.json"; do
[[ -f "$package_path" ]] || continue
"$PYTHON" - "$package_path" "$project_name" "$version" <<'PYCODE'
from __future__ import annotations
import json
import pathlib
import sys
path = pathlib.Path(sys.argv[1])
project_name = sys.argv[2]
new_version = sys.argv[3]
data = json.loads(path.read_text())
data["version"] = new_version
if project_name != "govoplan-core":
peers = data.setdefault("peerDependencies", {})
if "@govoplan/core-webui" in peers:
peers["@govoplan/core-webui"] = f"^{new_version}"
path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE
done
if [[ "$project_name" == "govoplan-core" && -f "$release_package_path" ]]; then
"$PYTHON" - "$release_package_path" "$version" <<'PYCODE'
from __future__ import annotations
import json
import pathlib
import re
import sys
path = pathlib.Path(sys.argv[1])
new_version = sys.argv[2]
data = json.loads(path.read_text())
data["version"] = new_version
dependencies = data.setdefault("dependencies", {})
for name, spec in list(dependencies.items()):
if name in {"@govoplan/files-webui", "@govoplan/mail-webui", "@govoplan/campaign-webui"}:
dependencies[name] = re.sub(r"#v\d+\.\d+\.\d+$", f"#v{new_version}", spec)
path.write_text(json.dumps(data, indent=2) + "\n")
PYCODE
fi
}
update_version_files() {
local repo="$1"
local version="$2"
local project_name="${PROJECT_NAMES[$repo]}"
update_pyproject "$repo" "$version"
update_manifest_version "$repo" "$project_name" "$version"
update_webui_package "$repo" "$project_name" "$version"
}
refresh_development_webui_lock() {
[[ -f "$ROOT/webui/package.json" ]] || return 0
[[ -f "$ROOT/webui/package-lock.json" ]] || return 0
run env PATH="$(dirname "$NPM_BIN"):$PATH" "$NPM_BIN" --prefix "$ROOT/webui" install --package-lock-only --ignore-scripts
}
generate_release_lock() {
if [[ "$GENERATE_RELEASE_LOCK" -eq 0 ]]; then
return
fi
[[ -x "$ROOT/scripts/generate-release-lock.sh" ]] || fail "missing executable release lock generator: $ROOT/scripts/generate-release-lock.sh"
run "$ROOT/scripts/generate-release-lock.sh" --npm "$NPM_BIN"
}
print_command() {
printf '+'
printf ' %q' "$@"
@@ -276,6 +416,15 @@ while [[ $# -gt 0 ]]; do
TAG_MESSAGE="$2"
shift 2
;;
--skip-release-lock)
GENERATE_RELEASE_LOCK=0
shift
;;
--npm)
[[ $# -ge 2 ]] || fail "missing value for $1"
NPM_BIN="$2"
shift 2
;;
-y|--yes)
YES=1
shift
@@ -305,6 +454,9 @@ prompt_for_bump
if ! command -v "$PYTHON" >/dev/null 2>&1; then
fail "Python interpreter not found: $PYTHON"
fi
if ! command -v "$NPM_BIN" >/dev/null 2>&1; then
fail "npm executable not found: $NPM_BIN"
fi
declare -A PROJECT_NAMES
declare -A OLD_VERSIONS
@@ -349,6 +501,9 @@ fi
validate_version "$TARGET_VERSION" || fail "target version must be x.y.z: $TARGET_VERSION"
for repo in "${REPOS[@]}"; do
if [[ "$TARGET_VERSION" == "${OLD_VERSIONS[$repo]}" ]]; then
continue
fi
if ! version_gt "$TARGET_VERSION" "${OLD_VERSIONS[$repo]}"; then
fail "target version $TARGET_VERSION must be greater than ${OLD_VERSIONS[$repo]} for ${PROJECT_NAMES[$repo]}"
fi
@@ -382,6 +537,11 @@ echo " version: $TARGET_VERSION"
echo " tag: $TAG"
echo " remote: $REMOTE"
echo " commit message: $COMMIT_MESSAGE"
if [[ "$GENERATE_RELEASE_LOCK" -eq 1 ]]; then
echo " release lock: regenerate core webui/package-lock.release.json after module tags are pushed"
else
echo " release lock: skipped"
fi
echo
for repo in "${REPOS[@]}"; do
@@ -394,13 +554,15 @@ confirm_release
for repo in "${REPOS[@]}"; do
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Would update $repo/pyproject.toml to $TARGET_VERSION"
echo "Would update version files in $repo to $TARGET_VERSION"
else
update_pyproject "$repo" "$TARGET_VERSION"
update_version_files "$repo" "$TARGET_VERSION"
fi
done
for repo in "${REPOS[@]}"; do
refresh_development_webui_lock
for repo in "${REPOS[@]:0:3}"; do
run git -C "$repo" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
@@ -413,10 +575,24 @@ for repo in "${REPOS[@]}"; do
run git -C "$repo" tag -a "$TAG" -m "$TAG_MESSAGE"
done
for repo in "${REPOS[@]}"; do
for repo in "${REPOS[@]:0:3}"; do
run git -C "$repo" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$repo]}" "refs/tags/$TAG"
done
generate_release_lock
run git -C "$ROOT" add -A
if [[ "$DRY_RUN" -eq 0 ]]; then
if git -C "$ROOT" diff --cached --quiet; then
fail "no staged changes for ${PROJECT_NAMES[$ROOT]} after version bump"
fi
fi
run git -C "$ROOT" commit -m "$COMMIT_MESSAGE"
run git -C "$ROOT" tag -a "$TAG" -m "$TAG_MESSAGE"
run git -C "$ROOT" push --atomic "$REMOTE" "HEAD:refs/heads/${BRANCHES[$ROOT]}" "refs/tags/$TAG"
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry run complete for $TAG across all GovOPlaN repos."
else

View File

@@ -107,7 +107,7 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
manifest = ModuleManifest(
id="access",
name="Access",
version="0.1.2",
version="0.1.4",
permissions=ACCESS_PERMISSIONS,
role_templates=ACCESS_ROLE_TEMPLATES,
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),

View File

@@ -473,33 +473,44 @@ def tenant_owner_user_ids(session: Session, tenant_id: str) -> set[str]:
.all()
)
owner_ids: set[str] = set()
user_ids = [user.id for user in users]
if not user_ids:
return owner_ids
roles_by_user_id: dict[str, list[Role]] = {user_id: [] for user_id in user_ids}
direct_role_rows = (
session.query(UserRoleAssignment.user_id, Role)
.join(Role, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id.in_(user_ids),
Role.tenant_id == tenant_id,
)
.all()
)
for user_id, role in direct_role_rows:
roles_by_user_id.setdefault(user_id, []).append(role)
group_role_rows = (
session.query(UserGroupMembership.user_id, Role)
.join(GroupRoleAssignment, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Role, GroupRoleAssignment.role_id == Role.id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id.in_(user_ids),
Group.is_active.is_(True),
Role.tenant_id == tenant_id,
)
.all()
)
for user_id, role in group_role_rows:
roles_by_user_id.setdefault(user_id, []).append(role)
for user in users:
direct_roles = (
session.query(Role)
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
.filter(
UserRoleAssignment.tenant_id == tenant_id,
UserRoleAssignment.user_id == user.id,
Role.tenant_id == tenant_id,
)
.all()
)
group_roles = (
session.query(Role)
.join(GroupRoleAssignment, GroupRoleAssignment.role_id == Role.id)
.join(UserGroupMembership, UserGroupMembership.group_id == GroupRoleAssignment.group_id)
.join(Group, Group.id == UserGroupMembership.group_id)
.filter(
UserGroupMembership.tenant_id == tenant_id,
UserGroupMembership.user_id == user.id,
Group.is_active.is_(True),
Role.tenant_id == tenant_id,
)
.all()
)
effective = [
scope
for role in direct_roles + group_roles
for role in roles_by_user_id.get(user.id, [])
for scope in (role.permissions or [])
]
if scopes_grant(effective, "admin:roles:write") and scopes_grant(effective, "campaign:send"):

View File

@@ -71,6 +71,123 @@ class ApiSmokeTests(unittest.TestCase):
payload = response.json()
return {"Authorization": f"Bearer {payload['access_token']}"}, payload
def test_recipient_import_mapping_profiles_are_db_backed(self) -> None:
headers, _ = self._login()
payload = {
"name": "ERP export",
"columnCount": 3,
"headers": ["email", "name", "field.customer_id"],
"normalizedHeaders": ["email", "name", "field_customer_id"],
"orderedHeaderFingerprint": "ordered-123",
"unorderedHeaderFingerprint": "unordered-123",
"delimiter": ";",
"headerRows": 1,
"quoted": True,
"valueSeparators": ",;|",
"mappings": [
{"columnIndex": 0, "kind": "to"},
{"columnIndex": 1, "kind": "name"},
{"columnIndex": 2, "kind": "new_field", "newFieldName": "customer_id"},
],
}
created = self.client.post("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers, json=payload)
self.assertEqual(created.status_code, 201, created.text)
created_body = created.json()
self.assertEqual(created_body["name"], "ERP export")
self.assertEqual(created_body["columnCount"], 3)
self.assertEqual(created_body["mappings"][2]["newFieldName"], "customer_id")
profile_id = created_body["id"]
listed = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
self.assertEqual(listed.status_code, 200, listed.text)
self.assertEqual([profile["id"] for profile in listed.json()["profiles"]], [profile_id])
updated_payload = {**payload, "name": "ERP export updated", "valueSeparators": "|"}
updated = self.client.put(
f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}",
headers=headers,
json=updated_payload,
)
self.assertEqual(updated.status_code, 200, updated.text)
self.assertEqual(updated.json()["name"], "ERP export updated")
self.assertEqual(updated.json()["valueSeparators"], "|")
deleted = self.client.delete(f"/api/v1/campaigns/recipient-import/mapping-profiles/{profile_id}", headers=headers)
self.assertEqual(deleted.status_code, 204, deleted.text)
listed_after_delete = self.client.get("/api/v1/campaigns/recipient-import/mapping-profiles", headers=headers)
self.assertEqual(listed_after_delete.status_code, 200, listed_after_delete.text)
self.assertEqual(listed_after_delete.json()["profiles"], [])
def test_campaign_entries_store_recipient_import_provenance(self) -> None:
headers, _ = self._login()
campaign_json = {
"version": "1.0",
"campaign": {"id": "import-provenance", "name": "Import provenance", "mode": "test"},
"fields": [{"name": "department", "type": "string", "required": False}],
"global_values": {},
"server": {},
"recipients": {
"from": {"email": "sender@example.org", "name": "Sender", "type": "to"},
"allow_individual_to": True,
},
"template": {"subject": "Hello", "text": "Hello"},
"attachments": {"base_path": ".", "global": [], "allow_individual": False},
"entries": {
"inline": [
{
"id": "recipient-1",
"to": [{"email": "recipient@example.org", "type": "to"}],
"fields": {"department": "Finance"},
}
],
"imports": [
{
"id": "recipient-import-test",
"imported_at": "2026-06-26T12:00:00Z",
"mode": "replace",
"source_type": "xlsx",
"filename": "recipients.xlsx",
"sheet_name": "Recipients",
"encoding": None,
"delimiter": None,
"header_rows": 1,
"quoted": None,
"value_separators": ",;|",
"rows_total": 1,
"valid_rows": 1,
"invalid_rows": 0,
"imported_rows": 1,
"field_names_created": [],
"attachment_patterns": 0,
"mapping": [
{"column_index": 0, "header": "email", "kind": "to"},
{"column_index": 1, "header": "field.department", "kind": "field", "field_name": "department"},
],
}
],
},
"validation_policy": {"missing_email": "block", "template_error": "block"},
"delivery": {"rate_limit": {"messages_per_minute": 60}},
"status_tracking": {"enabled": True},
}
created = self.client.post("/api/v1/campaigns", headers=headers, json={"config": campaign_json})
self.assertEqual(created.status_code, 200, created.text)
campaign_id = created.json()["campaign"]["id"]
version_id = created.json()["version"]["id"]
detail = self.client.get(f"/api/v1/campaigns/{campaign_id}/versions/{version_id}", headers=headers)
self.assertEqual(detail.status_code, 200, detail.text)
stored_import = detail.json()["raw_json"]["entries"]["imports"][0]
self.assertEqual(stored_import["source_type"], "xlsx")
self.assertEqual(stored_import["sheet_name"], "Recipients")
self.assertEqual(stored_import["mapping"][1]["field_name"], "department")
validated = self.client.post(f"/api/v1/campaigns/versions/{version_id}/validate", headers=headers, json={"check_files": False})
self.assertEqual(validated.status_code, 200, validated.text)
self.assertTrue(validated.json()["ok"], validated.text)
def _create_built_delivery_campaign(
self,
headers: dict[str, str],
@@ -587,6 +704,7 @@ class ApiSmokeTests(unittest.TestCase):
campaign_files = self.client.get("/api/v1/files", headers=headers, params={"campaign_id": campaign_id})
self.assertEqual(campaign_files.status_code, 200, campaign_files.text)
self.assertEqual({item["id"] for item in campaign_files.json()["files"]}, {first_file["id"], second_file["id"]})
self.assertTrue(all(item["shares"] for item in campaign_files.json()["files"]))
download = self.client.get(f"/api/v1/files/{first_file['id']}/download", headers=headers)
@@ -1744,14 +1862,40 @@ class ApiSmokeTests(unittest.TestCase):
set_failures(smtp_reject_recipients_containing=None)
from govoplan_campaign.backend.db.models import CampaignJob, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
with SessionLocal() as session:
job = session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).one()
self.assertEqual(job.send_status, "smtp_accepted")
self.assertIn("reject-me@example.org", job.last_error or "")
job_id = job.id
attempt_count = job.attempt_count
attempt = session.query(SendAttempt).filter(SendAttempt.job_id == job.id).one()
self.assertEqual(attempt.status, "smtp_accepted_with_refusals")
self.assertIn("reject-me@example.org", attempt.smtp_response or "")
retry_accepted = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
headers=headers,
json={"version_id": version_id, "job_ids": [job_id], "include_permanent": True, "enqueue_celery": False},
)
self.assertEqual(retry_accepted.status_code, 200, retry_accepted.text)
self.assertEqual(retry_accepted.json()["result"]["selected_count"], 0)
self.assertIn("not an explicit retry candidate", retry_accepted.json()["result"]["skipped"][0]["reason"])
unattempted_accepted = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/send-unattempted",
headers=headers,
json={"version_id": version_id, "job_ids": [job_id], "enqueue_celery": False},
)
self.assertEqual(unattempted_accepted.status_code, 200, unattempted_accepted.text)
self.assertEqual(unattempted_accepted.json()["result"]["selected_count"], 0)
with SessionLocal() as session:
direct_result = send_campaign_job(session, job_id=job_id, use_rate_limit=False)
self.assertEqual(direct_result.status, "already_accepted")
job = session.get(CampaignJob, job_id)
self.assertEqual(job.attempt_count, attempt_count)
def test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry(self) -> None:
headers, _ = self._login()
campaign_id, version_id = self._create_built_delivery_campaign(
@@ -1767,6 +1911,11 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(queued.status_code, 200, queued.text)
self.assertEqual(queued.json()["queued_count"], 2)
queued_summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
self.assertEqual(queued_summary.status_code, 200, queued_summary.text)
self.assertEqual(queued_summary.json()["cards"]["queued_or_active"], 2)
self.assertEqual(queued_summary.json()["status_counts"]["send"]["queued"], 2)
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, SendAttempt
from govoplan_campaign.backend.sending.jobs import send_campaign_job
@@ -1839,6 +1988,11 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(filtered_page.json()["total_unfiltered"], 2)
self.assertEqual(filtered_page.json()["filtered_counts"]["send"]["outcome_unknown"], 1)
summary = self.client.get(f"/api/v1/campaigns/{campaign_id}/summary", headers=headers, params={"version_id": version_id})
self.assertEqual(summary.status_code, 200, summary.text)
self.assertEqual(summary.json()["cards"]["outcome_unknown"], 1)
self.assertEqual(summary.json()["status_counts"]["send"]["outcome_unknown"], 1)
detail = self.client.get(
f"/api/v1/campaigns/{campaign_id}/jobs/{uncertain_job_id}",
headers=headers,
@@ -1855,6 +2009,12 @@ class ApiSmokeTests(unittest.TestCase):
self.assertEqual(resolved.status_code, 200, resolved.text)
self.assertEqual(resolved.json()["result"]["send_status"], "failed_temporary")
reconciled_report = self.client.get(f"/api/v1/campaigns/{campaign_id}/report", headers=headers, params={"version_id": version_id})
self.assertEqual(reconciled_report.status_code, 200, reconciled_report.text)
self.assertEqual(reconciled_report.json()["cards"]["outcome_unknown"], 0)
self.assertEqual(reconciled_report.json()["cards"]["failed"], 1)
self.assertEqual(reconciled_report.json()["status_counts"]["send"]["failed_temporary"], 1)
retry = self.client.post(
f"/api/v1/campaigns/{campaign_id}/jobs/retry",
headers=headers,

View File

@@ -80,6 +80,8 @@ class DatabaseMigrationTests(unittest.TestCase):
}
system_settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
governance_assignment_columns = {column["name"] for column in inspector.get_columns("governance_template_assignments")}
import_profile_columns = {column["name"] for column in inspector.get_columns("campaign_recipient_import_mapping_profiles")}
import_profile_indexes = {index["name"] for index in inspector.get_indexes("campaign_recipient_import_mapping_profiles")}
account_count = connection.execute(text("SELECT COUNT(*) FROM accounts")).scalar_one()
user_count = connection.execute(text("SELECT COUNT(*) FROM users")).scalar_one()
system_owner_count = connection.execute(text(
@@ -114,8 +116,13 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("governance_templates", tables)
self.assertIn("governance_template_assignments", tables)
self.assertIn("campaign_shares", tables)
self.assertIn("campaign_recipient_import_mapping_profiles", tables)
self.assertIn("owner_user_id", campaign_columns)
self.assertIn("owner_group_id", campaign_columns)
self.assertIn("ordered_header_fingerprint", import_profile_columns)
self.assertIn("unordered_header_fingerprint", import_profile_columns)
self.assertIn("mappings", import_profile_columns)
self.assertIn("ix_recipient_import_profiles_ordered_fp", import_profile_indexes)
self.assertIn("scope", audit_columns)
self.assertIn("ix_audit_log_scope", audit_indexes)
self.assertIn("ix_audit_log_scope_created_at", audit_indexes)
@@ -409,4 +416,3 @@ class DatabaseMigrationTests(unittest.TestCase):
self.assertIn("uq_file_folders_active_group_path", folder_indexes)
finally:
engine.dispose()

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
@@ -20,6 +20,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},
@@ -32,16 +33,15 @@
},
"../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
"read-excel-file": "^9.2.0"
},
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -55,9 +55,9 @@
},
"../../govoplan-files/webui": {
"name": "@govoplan/files-webui",
"version": "0.1.2",
"version": "0.1.4",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
@@ -74,9 +74,12 @@
},
"../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui",
"version": "0.1.2",
"version": "0.1.4",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -1363,6 +1366,16 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.6"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1554,6 +1567,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1579,6 +1599,13 @@
"node": ">=6.9.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1658,6 +1685,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@@ -1790,6 +1824,21 @@
"react-dom": ">=18"
}
},
"node_modules/read-excel-file": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz",
"integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"fflate": "^0.8.3",
"unzipper-esm": "^0.13.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1900,6 +1949,20 @@
"node": ">=14.17"
}
},
"node_modules/unzipper-esm": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
"integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -1,16 +1,16 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"dependencies": {
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2"
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4"
},
"devDependencies": {
"@types/react": "^19.0.2",
@@ -710,14 +710,13 @@
}
},
"node_modules/@govoplan/campaign-webui": {
"version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#39ad3500e2548e74c38a3c75c3cefa85cbf4a00b",
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#fb6fb67d451858cca891d8a2fe5b6b8790f75f0d",
"dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2"
"read-excel-file": "^9.2.0"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -730,10 +729,10 @@
}
},
"node_modules/@govoplan/files-webui": {
"version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#b5f7c43028e7a2b5cc261835b66ec0923cb30970",
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#ee5b06b1e7d60f458ceb9ed943369c55201698ce",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
@@ -749,10 +748,10 @@
}
},
"node_modules/@govoplan/mail-webui": {
"version": "0.1.2",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3cd8c45c42d76265e895466e6ce67ad67221c8df",
"version": "0.1.4",
"resolved": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#3743b236130d6684b5e8d406f5070e3ff9e33d07",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.2",
"@govoplan/core-webui": "^0.1.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -1266,6 +1265,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
"integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==",
"license": "MIT",
"engines": {
"node": ">=14.6"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.38",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
@@ -1447,6 +1455,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1470,6 +1484,12 @@
"node": ">=6.9.0"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1542,6 +1562,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.48",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
@@ -1665,6 +1691,20 @@
"react-dom": ">=18"
}
},
"node_modules/read-excel-file": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz",
"integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.9.10",
"fflate": "^0.8.3",
"unzipper-esm": "^0.13.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -1768,6 +1808,19 @@
"node": ">=14.17"
}
},
"node_modules/unzipper-esm": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz",
"integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -25,18 +25,19 @@
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js"
},
"dependencies": {
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/core-webui",
"version": "0.1.2",
"version": "0.1.4",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -22,9 +22,9 @@
"preview": "vite preview --host 127.0.0.1 --port 4173"
},
"dependencies": {
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.2",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.2",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.2"
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-files.git#v0.1.4",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-mail.git#v0.1.4",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/add-ideas/govoplan-campaign.git#v0.1.4"
},
"devDependencies": {
"lucide-react": "^0.555.0",

View File

@@ -317,8 +317,116 @@ export type FilesFolderTreeProps = {
depth?: number;
};
export type FilesOwnerType = "user" | "group";
export type FilesFileSpace = {
id: string;
label: string;
owner_type: FilesOwnerType;
owner_id: string;
description?: string | null;
};
export type FilesFileShare = {
id: string;
target_type: string;
target_id: string;
permission: string;
created_at: string;
revoked_at?: string | null;
};
export type FilesManagedFile = {
id: string;
tenant_id: string;
owner_type: FilesOwnerType;
owner_id: string;
display_path: string;
filename: string;
description?: string | null;
size_bytes: number;
content_type?: string | null;
checksum_sha256: string;
version_id: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
audit_relevant: boolean;
metadata?: Record<string, unknown> | null;
shares?: FilesFileShare[];
};
export type FilesFileFolder = {
id: string;
tenant_id: string;
owner_type: FilesOwnerType;
owner_id: string;
path: string;
created_at: string;
updated_at: string;
deleted_at?: string | null;
};
export type FilesFileSpacesResponse = { spaces: FilesFileSpace[] };
export type FilesFileListResponse = { files: FilesManagedFile[] };
export type FilesFileFoldersResponse = { folders: FilesFileFolder[] };
export type FilesPatternResolveResponse = {
patterns: { pattern: string; matches: FilesManagedFile[] }[];
unmatched: FilesManagedFile[];
};
export type FilesFileBulkShareResponse = { shares: FilesFileShare[]; shared_count: number };
export type FilesManagedFileLinkTarget = {
type: string;
id: string;
label?: string;
permission?: string;
};
export type FilesManagedFileChooserMode = "folder" | "attachment";
export type FilesManagedAttachmentChoiceMode = "file" | "pattern";
export type FilesManagedFolderSelection = {
space: FilesFileSpace;
folderPath: string;
source: string;
};
export type FilesManagedAttachmentSelection = FilesManagedFolderSelection & {
fileFilter: string;
matchCount: number;
fileIds: string[];
selectionType: FilesManagedAttachmentChoiceMode;
};
export type FilesManagedFileChooserProps = {
open: boolean;
settings: ApiSettings;
mode: FilesManagedFileChooserMode;
storageScope?: string;
source?: string;
basePath?: string;
initialPattern?: string;
rememberKey?: string;
previewContext?: Record<string, string>;
linkTarget?: FilesManagedFileLinkTarget;
renderPatternPreview?: (pattern: string, context?: Record<string, string>) => string;
onClose: () => void;
onSelectFolder?: (selection: FilesManagedFolderSelection) => void;
onSelectAttachment?: (selection: FilesManagedAttachmentSelection) => void;
};
export type FilesFileExplorerUiCapability = {
FolderTree: ComponentType<FilesFolderTreeProps>;
ManagedFileChooser?: ComponentType<FilesManagedFileChooserProps>;
listFileSpaces?: (settings: ApiSettings) => Promise<FilesFileSpacesResponse>;
listFiles?: (settings: ApiSettings, params?: { owner_type?: FilesOwnerType | string; owner_id?: string; campaign_id?: string; path_prefix?: string }) => Promise<FilesFileListResponse>;
listFolders?: (settings: ApiSettings, params: { owner_type: FilesOwnerType; owner_id: string }) => Promise<FilesFileFoldersResponse>;
resolveFilePatterns?: (
settings: ApiSettings,
payload: { patterns: string[]; owner_type?: FilesOwnerType; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean }
) => Promise<FilesPatternResolveResponse>;
shareFilesWithTarget?: (settings: ApiSettings, fileIds: string[], target: FilesManagedFileLinkTarget) => Promise<FilesFileBulkShareResponse>;
};
export type PlatformFrontendRouteInfo = {