From 3934e7fedb0d1dce9aa2065c4dccdb148b370367 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 03:56:50 +0200 Subject: [PATCH] feat(campaigns): add portable campaign transfers --- README.md | 12 + docs/CAMPAIGN_HANDBOOK.md | 26 + pyproject.toml | 2 +- .../backend/campaign/transfers.py | 730 ++++++++++++++++++ .../backend/documentation.py | 37 + src/govoplan_campaign/backend/manifest.py | 79 +- src/govoplan_campaign/backend/router.py | 2 + .../backend/routes/transfers.py | 378 +++++++++ src/govoplan_campaign/backend/schemas.py | 90 +++ tests/test_campaign_transfers.py | 528 +++++++++++++ tests/test_documentation.py | 4 +- tests/test_route_registration.py | 8 +- webui/package.json | 2 +- webui/src/api/campaigns.ts | 89 +++ .../features/campaigns/CampaignListPage.tsx | 241 +++++- .../features/campaigns/CampaignModulePage.tsx | 2 +- .../campaigns/CampaignOverviewPage.tsx | 80 +- .../features/campaigns/utils/draftEditor.ts | 2 +- webui/src/styles/campaign-workspace.css | 5 + .../campaign-lifecycle-ui-structure.test.mjs | 11 + 20 files changed, 2310 insertions(+), 18 deletions(-) create mode 100644 src/govoplan_campaign/backend/campaign/transfers.py create mode 100644 src/govoplan_campaign/backend/routes/transfers.py create mode 100644 tests/test_campaign_transfers.py diff --git a/README.md b/README.md index bbe2983..12a1c59 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,18 @@ has a remaining occurrence, including while it is paused; once the schedule finishes, already accepted Mail commands retain their own encrypted payload and evidence under Mail policy. +Campaign versions can also be exported as versioned portable JSON packages and +imported as independently owned drafts. The privacy-safe export default is +metadata plus template/configuration. Recipients, attachment rules, aggregate +review state, and recipient-level delivery history are separate scopes with +their existing fine-grained permissions. Packages include source provenance, +scope/item/redaction manifests, and a SHA-256 integrity digest. They never +contain attachment bytes, transport secrets, credential references, +password-field values, local storage locators, shares, or ownership grants. +Import previews schema and checksum compatibility plus every created/skipped +domain. It clears deployment-bound Mail references and never replays locks, +approvals, review decisions, jobs, attempts, or sent state. + Public campaign, version, job, and report responses expose business data and delivery evidence, but never process-local paths, storage-backend keys, or worker claim tokens. Operational troubleshooting uses the dedicated job diff --git a/docs/CAMPAIGN_HANDBOOK.md b/docs/CAMPAIGN_HANDBOOK.md index 2d51a3f..f87c93f 100644 --- a/docs/CAMPAIGN_HANDBOOK.md +++ b/docs/CAMPAIGN_HANDBOOK.md @@ -332,6 +332,32 @@ default. ## Data and evidence model +### Portable Campaign transfer + +Campaign offers two reuse paths with different boundaries. **Copy campaign** +creates another campaign inside the same installation and can reuse selected +local shares, policies, and Mail profile references. **Export package** creates +a versioned JSON hand-off whose selected scopes can cross an installation +boundary; **Import package** always creates a separately owned draft. + +The export dialog starts with only metadata and template/configuration. Add +recipients, attachment rules, review state, or delivery history only when the +handoff requires them and the destination and retention are approved. Recipient +and delivery scopes remain protected by recipient/report export permissions. +Transport secrets, credential references, password-field values, local storage +locators, and attachment bytes are always removed. The manifest records scope +counts and redactions, while the envelope carries source Campaign/version +provenance and a SHA-256 digest. + +Import verifies format, scope, checksum, schema, and destination identity before +showing the plan. Editing the destination identity or selected scopes makes the +preview stale and requires a new check. The apply step clears source Mail +references, creates one editable draft, and stores a bounded source/package and +created/skipped receipt. Historical validation/build summaries, review state, +approvals, delivery jobs, attempts, and sent outcomes are never replayed. File +content is never embedded, so reconnect managed files and local Mail profiles, +then validate, build, review, and approve normally. + ### Versions and snapshots Editable campaign JSON is versioned. Build creates recipient jobs and an diff --git a/pyproject.toml b/pyproject.toml index cf755f2..ac8fe74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-campaign" -version = "0.1.23" +version = "0.1.24" description = "GovOPlaN campaigns module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_campaign/backend/campaign/transfers.py b/src/govoplan_campaign/backend/campaign/transfers.py new file mode 100644 index 0000000..5e835c9 --- /dev/null +++ b/src/govoplan_campaign/backend/campaign/transfers.py @@ -0,0 +1,730 @@ +from __future__ import annotations + +import copy +import hashlib +import json +from collections import Counter +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from govoplan_campaign.backend.campaign.loader import validate_against_schema +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignVersion, +) +from govoplan_campaign.backend.persistence.versions import minimal_campaign_json +from govoplan_campaign.backend.response_security import ( + public_campaign_configuration, + public_campaign_payload, +) + + +PORTABLE_CAMPAIGN_FORMAT = "govoplan.campaign-portable" +PORTABLE_CAMPAIGN_FORMAT_VERSION = "1.0" +PORTABLE_CAMPAIGN_SCOPE_ORDER = ( + "metadata", + "template_config", + "recipients", + "attachments", + "review_state", + "delivery_history", +) +DEFAULT_PORTABLE_CAMPAIGN_SCOPES = ("metadata", "template_config") +OPERATIONAL_EVIDENCE_SCOPES = frozenset(("review_state", "delivery_history")) +_CONFIG_STRUCTURAL_KEYS = frozenset( + ("version", "campaign", "recipients", "entries", "attachments") +) +_SENSITIVE_SETTING_FRAGMENTS = ( + "api_key", + "credential", + "password", + "private_key", + "secret", + "token", +) + + +class CampaignTransferError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class CampaignImportInspection: + preview: dict[str, Any] + configuration: dict[str, Any] | None + portable_settings: dict[str, Any] + + +def canonical_sha256(value: object) -> str: + encoded = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def normalize_transfer_scopes(scopes: Iterable[str]) -> tuple[str, ...]: + selected = set(scopes) + invalid = sorted(selected.difference(PORTABLE_CAMPAIGN_SCOPE_ORDER)) + if invalid: + raise CampaignTransferError( + f"Unsupported campaign transfer scope(s): {', '.join(invalid)}" + ) + if not selected: + raise CampaignTransferError("Select at least one campaign transfer scope.") + return tuple(scope for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER if scope in selected) + + +def build_campaign_portable_package( + *, + campaign: Campaign, + version: CampaignVersion, + scopes: Iterable[str], + jobs: Iterable[CampaignJob] = (), + issues: Iterable[CampaignIssue] = (), + module_version: str, +) -> dict[str, Any]: + selected = normalize_transfer_scopes(scopes) + configuration = public_campaign_configuration(version.raw_json) + if not isinstance(configuration, dict): + raise CampaignTransferError("The campaign configuration is not portable JSON.") + configuration, password_redactions = _redact_password_field_values(configuration) + payload: dict[str, Any] = {} + item_counts: dict[str, int] = {} + redactions: Counter[str] = Counter(password_redactions) + + if "metadata" in selected: + payload["metadata"] = { + "external_id": campaign.external_id, + "name": campaign.name, + "description": campaign.description, + "source_status": campaign.status, + } + item_counts["metadata"] = 1 + + if "template_config" in selected: + settings, setting_redactions = _redact_sensitive_settings( + campaign.settings or {} + ) + mail_policy, mail_policy_redactions = _redact_sensitive_settings( + campaign.mail_profile_policy or {} + ) + template_configuration = { + key: copy.deepcopy(value) + for key, value in configuration.items() + if key not in _CONFIG_STRUCTURAL_KEYS + } + server = template_configuration.get("server") + if isinstance(server, dict): + for key in ("smtp_credential_id", "imap_credential_id"): + if server.pop(key, None) is not None: + redactions["deployment_credential_reference"] += 1 + payload["template_config"] = { + "schema_version": version.schema_version, + "configuration": template_configuration, + "campaign_settings": settings, + "mail_profile_policy": mail_policy, + } + redactions.update(setting_redactions) + redactions.update(mail_policy_redactions) + item_counts["template_config"] = len(template_configuration) + + if "recipients" in selected: + entries = copy.deepcopy(configuration.get("entries") or {}) + _remove_entry_attachments(entries) + payload["recipients"] = { + "recipients": copy.deepcopy(configuration.get("recipients") or {}), + "entries": entries, + } + item_counts["recipients"] = _recipient_entry_count(entries) + + if "attachments" in selected: + entry_attachments = _entry_attachment_projection( + configuration.get("entries") + ) + payload["attachments"] = { + "configuration": copy.deepcopy(configuration.get("attachments") or {}), + "entry_attachments": entry_attachments, + "content_included": False, + } + item_counts["attachments"] = _attachment_rule_count( + payload["attachments"] + ) + + issue_rows = tuple(issues) + if "review_state" in selected: + review_state = _review_state_projection(version, issue_rows) + payload["review_state"] = review_state + item_counts["review_state"] = int(review_state["decision_count"]) + + job_rows = tuple(jobs) + if "delivery_history" in selected: + payload["delivery_history"] = { + "jobs": [_delivery_job_projection(job) for job in job_rows], + "counts": _delivery_counts(job_rows), + } + item_counts["delivery_history"] = len(job_rows) + + exported_at = datetime.now(UTC) + package: dict[str, Any] = { + "format": PORTABLE_CAMPAIGN_FORMAT, + "format_version": PORTABLE_CAMPAIGN_FORMAT_VERSION, + "package_id": str(uuid4()), + "exported_at": exported_at.isoformat(), + "source": { + "module": "campaigns", + "module_version": module_version, + "tenant_ref_sha256": hashlib.sha256( + campaign.tenant_id.encode("utf-8") + ).hexdigest(), + "campaign_id": campaign.id, + "campaign_external_id": campaign.external_id, + "campaign_name": campaign.name, + "version_id": version.id, + "version_number": version.version_number, + "campaign_schema_version": version.schema_version, + }, + "scopes": list(selected), + "manifest": { + "item_counts": item_counts, + "redactions": dict(sorted(redactions.items())), + "privacy_default_scopes": list(DEFAULT_PORTABLE_CAMPAIGN_SCOPES), + "attachments_are_references_only": True, + "operational_evidence_is_not_replayed": True, + "secrets_included": False, + }, + "payload": payload, + } + package["integrity"] = { + "algorithm": "sha256", + "package_sha256": canonical_sha256(package), + } + return package + + +def inspect_campaign_portable_package( + package: Mapping[str, Any], + *, + selected_scopes: Iterable[str] | None, + external_id: str, + name: str, +) -> CampaignImportInspection: + errors: list[str] = [] + warnings: list[str] = [] + package_dict = copy.deepcopy(dict(package)) + package_id = _optional_text(package_dict.get("package_id")) + format_version = _optional_text(package_dict.get("format_version")) + source = package_dict.get("source") + source_dict = copy.deepcopy(source) if isinstance(source, dict) else {} + integrity = package_dict.get("integrity") + expected_hash = ( + _optional_text(integrity.get("package_sha256")) + if isinstance(integrity, dict) + else None + ) + hash_input = copy.deepcopy(package_dict) + hash_input.pop("integrity", None) + actual_hash = canonical_sha256(hash_input) + + if package_dict.get("format") != PORTABLE_CAMPAIGN_FORMAT: + errors.append("The file is not a GovOPlaN portable Campaign package.") + if format_version != PORTABLE_CAMPAIGN_FORMAT_VERSION: + errors.append( + "The Campaign package format version is not supported by this installation." + ) + if not package_id: + errors.append("The Campaign package has no package identifier.") + if not expected_hash or expected_hash != actual_hash: + errors.append("The Campaign package integrity checksum does not match its content.") + if not isinstance(integrity, dict) or integrity.get("algorithm") != "sha256": + errors.append("The Campaign package does not use the supported SHA-256 integrity algorithm.") + if not source_dict: + errors.append("The Campaign package has no source provenance.") + elif source_dict.get("campaign_schema_version") != "1.0": + errors.append("The Campaign configuration schema version is not supported by this installation.") + + available: tuple[str, ...] = () + try: + raw_scopes = package_dict.get("scopes") + if not isinstance(raw_scopes, list): + raise CampaignTransferError("The Campaign package has no valid scope list.") + available = normalize_transfer_scopes(str(item) for item in raw_scopes) + except CampaignTransferError as exc: + errors.append(str(exc)) + + try: + selected = normalize_transfer_scopes( + available if selected_scopes is None else selected_scopes + ) + except CampaignTransferError as exc: + errors.append(str(exc)) + selected = () + unavailable = sorted(set(selected).difference(available)) + if unavailable: + errors.append( + f"Selected scope(s) are absent from the package: {', '.join(unavailable)}" + ) + + payload = package_dict.get("payload") + payload_dict = payload if isinstance(payload, dict) else {} + if not isinstance(payload, dict): + errors.append("The Campaign package has no valid payload object.") + if not isinstance(package_dict.get("manifest"), dict): + errors.append("The Campaign package has no valid manifest.") + for scope in available: + if scope not in payload_dict: + errors.append(f"The Campaign package payload is missing scope '{scope}'.") + elif not isinstance(payload_dict[scope], dict): + errors.append(f"The Campaign package scope '{scope}' is not a valid object.") + + template_scope = payload_dict.get("template_config") + if ( + "template_config" in available + and isinstance(template_scope, dict) + and template_scope.get("schema_version") != "1.0" + ): + errors.append("The portable template/configuration schema version is not supported.") + + configuration: dict[str, Any] | None = None + portable_settings: dict[str, Any] = {} + will_create: list[dict[str, Any]] = [] + will_skip: list[dict[str, Any]] = [] + if not errors: + configuration, portable_settings, created, skipped, materialize_warnings = ( + _materialize_import( + payload_dict, + available=available, + selected=selected, + external_id=external_id, + name=name, + ) + ) + will_create.extend(created) + will_skip.extend(skipped) + warnings.extend(materialize_warnings) + try: + validate_against_schema(configuration) + except Exception as exc: + errors.append(f"The imported Campaign configuration is incompatible: {exc}") + configuration = None + + manifest = package_dict.get("manifest") + if isinstance(manifest, dict) and manifest.get("redactions"): + warnings.append( + "The source export redacted sensitive or deployment-bound values; review the package manifest and reconfigure them locally." + ) + + preview = { + "compatible": not errors, + "package_id": package_id, + "package_sha256": actual_hash, + "format_version": format_version, + "source": source_dict, + "available_scopes": list(available), + "selected_scopes": list(selected), + "destination": { + "external_id": external_id, + "name": name, + "status": "draft", + }, + "will_create": will_create, + "will_skip": will_skip, + "warnings": list(dict.fromkeys(warnings)), + "errors": list(dict.fromkeys(errors)), + } + return CampaignImportInspection( + preview=preview, + configuration=configuration, + portable_settings=portable_settings, + ) + + +def _materialize_import( + payload: Mapping[str, Any], + *, + available: tuple[str, ...], + selected: tuple[str, ...], + external_id: str, + name: str, +) -> tuple[ + dict[str, Any], + dict[str, Any], + list[dict[str, Any]], + list[dict[str, Any]], + list[str], +]: + selected_set = set(selected) + configuration = minimal_campaign_json(external_id=external_id, name=name) + portable_settings: dict[str, Any] = {} + created: list[dict[str, Any]] = [ + _plan_item("metadata", "campaign_draft", "A new Campaign draft and editable version will be created.", 1) + ] + skipped: list[dict[str, Any]] = [] + warnings: list[str] = [] + + metadata = payload.get("metadata") + if "metadata" in selected_set and isinstance(metadata, dict): + description = metadata.get("description") + if isinstance(description, str): + configuration["campaign"]["description"] = description + + template_payload = payload.get("template_config") + if "template_config" in selected_set and isinstance(template_payload, dict): + source_configuration = template_payload.get("configuration") + if isinstance(source_configuration, dict): + for key, value in source_configuration.items(): + if key in _CONFIG_STRUCTURAL_KEYS: + continue + configuration[key] = copy.deepcopy(value) + source_server = configuration.get("server") + if isinstance(source_server, dict) and source_server: + configuration["server"] = {} + skipped.append( + _plan_item( + "template_config", + "deployment_bound_mail_profile", + "Mail profile and server references are not applied across installations; select local Mail resources after import.", + len(source_server), + ) + ) + settings = template_payload.get("campaign_settings") + if isinstance(settings, dict): + portable_settings = copy.deepcopy(settings) + created.append( + _plan_item( + "template_config", + "editable_configuration", + "Portable fields, template, delivery settings, and validation policy will be applied to the draft.", + len(source_configuration), + ) + ) + + recipients_payload = payload.get("recipients") + if "recipients" in selected_set and isinstance(recipients_payload, dict): + recipients = recipients_payload.get("recipients") + entries = recipients_payload.get("entries") + if isinstance(recipients, dict): + configuration["recipients"] = copy.deepcopy(recipients) + if isinstance(entries, dict): + configuration["entries"] = copy.deepcopy(entries) + created.append( + _plan_item( + "recipients", + "recipient_rows", + "Campaign-local recipient rows and source provenance will be copied into the draft.", + _recipient_entry_count(entries), + ) + ) + + attachments_payload = payload.get("attachments") + if "attachments" in selected_set and isinstance(attachments_payload, dict): + attachment_configuration = attachments_payload.get("configuration") + if isinstance(attachment_configuration, dict): + configuration["attachments"] = copy.deepcopy(attachment_configuration) + per_entry = attachments_payload.get("entry_attachments") + applied_entry_rules = 0 + if "recipients" in selected_set and isinstance(per_entry, list): + inline = configuration.get("entries", {}).get("inline", []) + if isinstance(inline, list): + for item in per_entry: + if not isinstance(item, dict): + continue + index = item.get("entry_index") + rules = item.get("attachments") + if ( + isinstance(index, int) + and 0 <= index < len(inline) + and isinstance(inline[index], dict) + and isinstance(rules, list) + ): + inline[index]["attachments"] = copy.deepcopy(rules) + applied_entry_rules += len(rules) + elif isinstance(per_entry, list) and per_entry: + skipped.append( + _plan_item( + "attachments", + "recipient_scope_required", + "Per-recipient attachment rules are skipped unless recipient rows are also imported.", + sum( + len(item.get("attachments") or []) + for item in per_entry + if isinstance(item, dict) + ), + ) + ) + created.append( + _plan_item( + "attachments", + "attachment_references", + "Portable attachment rules will be applied; file content is never embedded in the package.", + _attachment_rule_count(attachments_payload) - max(0, _entry_rule_count(per_entry) - applied_entry_rules), + ) + ) + warnings.append( + "Attachment rules contain references only. Reconnect or upload the required files and validate the draft before use." + ) + + for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER: + if scope not in OPERATIONAL_EVIDENCE_SCOPES: + continue + if scope in selected_set: + item_count = _manifest_scope_count(payload.get(scope)) + skipped.append( + _plan_item( + scope, + "operational_evidence_not_replayed", + "Historical review or delivery evidence remains in the source package and import receipt but is never replayed as live Campaign state.", + item_count, + ) + ) + + for scope in available: + if scope not in selected_set: + skipped.append( + _plan_item( + scope, + "scope_not_selected", + "This available package scope was not selected for import.", + _manifest_scope_count(payload.get(scope)), + ) + ) + + campaign_metadata = configuration.get("campaign") + if not isinstance(campaign_metadata, dict): + raise CampaignTransferError("The imported Campaign metadata is invalid.") + campaign_metadata.update({"id": external_id, "name": name, "mode": "draft"}) + return configuration, portable_settings, created, skipped, warnings + + +def _review_state_projection( + version: CampaignVersion, issues: tuple[CampaignIssue, ...] +) -> dict[str, Any]: + editor_state = version.editor_state if isinstance(version.editor_state, dict) else {} + review = editor_state.get("review_send") + review = review if isinstance(review, dict) else {} + decisions = [ + item + for item in (review.get("issue_decisions") or []) + if isinstance(item, dict) + ] + decision_evidence = [ + { + "decision": item.get("decision"), + "issue_codes": sorted(str(code) for code in item.get("issue_codes") or []), + "issue_fingerprint": item.get("issue_fingerprint"), + "message_sha256": item.get("message_sha256"), + "reason_recorded": bool(str(item.get("reason") or "").strip()), + } + for item in decisions + ] + issue_counts = Counter(str(issue.severity) for issue in issues) + return { + "workflow_state": version.workflow_state, + "inspection_complete": bool(review.get("inspection_complete")), + "reviewed_message_count": len(review.get("reviewed_message_keys") or []), + "decision_count": len(decisions), + "decision_evidence_sha256": canonical_sha256(decision_evidence), + "issue_counts": dict(sorted(issue_counts.items())), + "validation_summary": public_campaign_payload(version.validation_summary or {}), + "build_summary": public_campaign_payload(version.build_summary or {}), + } + + +def _delivery_job_projection(job: CampaignJob) -> dict[str, Any]: + return { + "job_id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "message_id_header": job.message_id_header, + "message_sha256": job.eml_sha256, + "build_status": job.build_status, + "validation_status": job.validation_status, + "queue_status": job.queue_status, + "send_status": job.send_status, + "postbox_status": job.postbox_status, + "print_status": job.print_status, + "imap_status": job.imap_status, + "attempt_count": job.attempt_count, + "sent_at": _isoformat(job.sent_at), + "outcome_unknown_at": _isoformat(job.outcome_unknown_at), + "delivery_provenance": public_campaign_payload(job.delivery_provenance or {}), + } + + +def _delivery_counts(jobs: tuple[CampaignJob, ...]) -> dict[str, dict[str, int]]: + return { + field: dict( + sorted(Counter(str(getattr(job, field) or "unknown") for job in jobs).items()) + ) + for field in ("validation_status", "queue_status", "send_status") + } + + +def _redact_sensitive_settings( + value: Mapping[str, Any], +) -> tuple[dict[str, Any], Counter[str]]: + redactions: Counter[str] = Counter() + + def visit(item: Any) -> Any: + if isinstance(item, dict): + result: dict[str, Any] = {} + for raw_key, child in item.items(): + key = str(raw_key) + normalized = key.lower().replace("-", "_") + if any(fragment in normalized for fragment in _SENSITIVE_SETTING_FRAGMENTS): + redactions["sensitive_setting"] += 1 + continue + result[key] = visit(child) + return result + if isinstance(item, list): + return [visit(child) for child in item] + return copy.deepcopy(item) + + return visit(dict(value)), redactions + + +def _redact_password_field_values( + configuration: dict[str, Any], +) -> tuple[dict[str, Any], Counter[str]]: + result = copy.deepcopy(configuration) + password_fields = { + str(field.get("name")) + for field in result.get("fields") or [] + if isinstance(field, dict) + and field.get("type") == "password" + and field.get("name") + } + redactions: Counter[str] = Counter() + if not password_fields: + return result, redactions + global_values = result.get("global_values") + if isinstance(global_values, dict): + for key in password_fields: + if global_values.pop(key, None) is not None: + redactions["password_field_value"] += 1 + entries = result.get("entries") + if isinstance(entries, dict): + for entry in entries.get("inline") or []: + if not isinstance(entry, dict): + continue + fields = entry.get("fields") + if not isinstance(fields, dict): + continue + for key in password_fields: + if fields.pop(key, None) is not None: + redactions["password_field_value"] += 1 + return result, redactions + + +def _remove_entry_attachments(entries: Any) -> None: + if not isinstance(entries, dict): + return + for entry in entries.get("inline") or []: + if isinstance(entry, dict): + entry["attachments"] = [] + defaults = entries.get("defaults") + if isinstance(defaults, dict): + defaults["attachments"] = [] + + +def _entry_attachment_projection(entries: Any) -> list[dict[str, Any]]: + if not isinstance(entries, dict): + return [] + result: list[dict[str, Any]] = [] + for index, entry in enumerate(entries.get("inline") or []): + if not isinstance(entry, dict): + continue + rules = entry.get("attachments") + if isinstance(rules, list) and rules: + result.append( + { + "entry_index": index, + "attachments": copy.deepcopy(rules), + } + ) + return result + + +def _recipient_entry_count(entries: Any) -> int: + if not isinstance(entries, dict): + return 0 + inline = entries.get("inline") + return len(inline) if isinstance(inline, list) else 0 + + +def _attachment_rule_count(value: Any) -> int: + if not isinstance(value, dict): + return 0 + configuration = value.get("configuration") + global_rules = ( + configuration.get("global") if isinstance(configuration, dict) else [] + ) + return (len(global_rules) if isinstance(global_rules, list) else 0) + _entry_rule_count( + value.get("entry_attachments") + ) + + +def _entry_rule_count(value: Any) -> int: + if not isinstance(value, list): + return 0 + return sum( + len(item.get("attachments") or []) + for item in value + if isinstance(item, dict) + ) + + +def _manifest_scope_count(value: Any) -> int: + if not isinstance(value, dict): + return 0 + if isinstance(value.get("jobs"), list): + return len(value["jobs"]) + if "decision_count" in value: + return int(value.get("decision_count") or 0) + if "entries" in value: + return _recipient_entry_count(value.get("entries")) + return 1 + + +def _plan_item( + scope: str, code: str, summary: str, item_count: int | None +) -> dict[str, Any]: + return { + "scope": scope, + "code": code, + "summary": summary, + "item_count": item_count, + } + + +def _optional_text(value: object) -> str | None: + text = str(value or "").strip() + return text or None + + +def _isoformat(value: datetime | None) -> str | None: + return value.isoformat() if value is not None else None + + +__all__ = [ + "CampaignImportInspection", + "CampaignTransferError", + "DEFAULT_PORTABLE_CAMPAIGN_SCOPES", + "OPERATIONAL_EVIDENCE_SCOPES", + "PORTABLE_CAMPAIGN_FORMAT", + "PORTABLE_CAMPAIGN_FORMAT_VERSION", + "PORTABLE_CAMPAIGN_SCOPE_ORDER", + "build_campaign_portable_package", + "canonical_sha256", + "inspect_campaign_portable_package", + "normalize_transfer_scopes", +] diff --git a/src/govoplan_campaign/backend/documentation.py b/src/govoplan_campaign/backend/documentation.py index 36e7066..12cd5a4 100644 --- a/src/govoplan_campaign/backend/documentation.py +++ b/src/govoplan_campaign/backend/documentation.py @@ -13,6 +13,8 @@ _CAMPAIGN_USER_SCOPES = ( "campaigns:campaign:create", "campaigns:campaign:update", "campaigns:campaign:copy", + "campaigns:campaign:export", + "campaigns:campaign:import", "campaigns:campaign:archive", "campaigns:campaign:delete", "campaigns:campaign:share", @@ -204,6 +206,39 @@ CAMPAIGN_USER_DOCUMENTATION = ( } }, ), + _workflow_topic( + topic_id="campaigns.workflow.transfer-campaign-package", + title="Export and import a portable Campaign package", + summary="Move selected Campaign configuration into a separately owned draft with an integrity check, compatibility preview, and explicit privacy scopes.", + body="Portable Campaign packages are versioned JSON envelopes. Export defaults to metadata plus template/configuration and excludes recipients, attachments, review state, and delivery history until they are explicitly selected. Recipient and delivery scopes require their existing fine-grained export permissions. Transport secrets, credential references, password-field values, local storage paths, and attachment bytes are not exported. Import verifies the SHA-256 package integrity, previews every scope that will be created or skipped, and always creates a new editable draft. Deployment-bound Mail references must be selected locally. Review, approval, and delivery evidence remains historical package provenance and is never replayed as live state.", + order=33, + audience=("campaign_manager", "campaign_configurator", "campaign_migration_operator"), + required_scopes=("campaigns:campaign:read", "campaigns:campaign:export"), + route="/campaigns/{campaign_id}", + screen="Campaign overview and Campaign list", + help_contexts=("campaign.overview", "campaigns.action.export-package", "campaigns.action.import-package"), + prerequisites=( + "You may export the source Campaign; importing additionally requires Campaign create and portable-import authority.", + "Recipient and delivery scopes have an approved purpose and destination and the corresponding recipient/report export permissions.", + ), + steps=( + "Open the source Campaign overview, select Export package, and keep the privacy-safe metadata plus template/configuration default unless more data is necessary.", + "Select any additional recipient, attachment, review, or delivery scopes explicitly and download the integrity-protected JSON package to an approved location.", + "On the destination Campaign list select Import package, choose the file, and review compatibility, redactions, destination identity, created scopes, and skipped evidence.", + "Change the destination identity or selected scopes as needed, refresh the preview, and create the draft only when the preview is current and compatible.", + "Open the draft, reconnect local Mail and file resources, validate recipients and attachments, and complete ordinary review before any delivery.", + ), + outcome="A separately owned Campaign draft containing only the selected portable configuration, with source/package provenance and no replayed operational state.", + verification="The destination is a new draft with a distinct ID; its settings retain the package ID, SHA-256, source and created/skipped receipt, while Audit records the matching export/import hashes without storing package content.", + related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.export-delivery-report"), + translations={ + "de": { + "title": "Portables Campaign-Paket exportieren und importieren", + "summary": "Ausgewaehlte Campaign-Konfiguration mit Integritaetspruefung, Kompatibilitaetsvorschau und expliziten Datenschutzumfaengen in einen eigenstaendigen Entwurf uebernehmen.", + "body": "Portable Campaign-Pakete sind versionierte JSON-Umschlaege. Der Export umfasst standardmaessig nur Metadaten sowie Vorlage und Konfiguration. Empfaenger, Anlagen, Pruefstatus und Zustellhistorie werden erst nach expliziter Auswahl aufgenommen und bleiben getrennt berechtigt. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, lokale Speicherpfade und Dateiinhalte werden nicht exportiert. Der Import prueft die SHA-256-Integritaet, zeigt alle erzeugten und uebersprungenen Umfaenge und erstellt immer einen neuen bearbeitbaren Entwurf. Mail-Verweise muessen lokal neu gewaehlt werden; historische Pruef-, Freigabe- und Zustellnachweise werden nie als aktiver Zustand wiedergegeben.", + } + }, + ), _workflow_topic( topic_id="campaigns.workflow.collaborate-on-campaign", title="Discuss campaign work without changing its evidence", @@ -981,6 +1016,8 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str _append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.") _append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.") _append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.") + _append_if(capabilities, principal, ("campaigns:campaign:export",), "Export privacy-scoped portable Campaign packages.") + _append_if(capabilities, principal, ("campaigns:campaign:import", "campaigns:campaign:create"), "Preview and import compatible portable Campaign packages as new drafts.", require_all=True) _append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.") _append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.") _append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.") diff --git a/src/govoplan_campaign/backend/manifest.py b/src/govoplan_campaign/backend/manifest.py index 07e7397..e0fc5d4 100644 --- a/src/govoplan_campaign/backend/manifest.py +++ b/src/govoplan_campaign/backend/manifest.py @@ -164,6 +164,18 @@ PERMISSIONS = ( "Create campaigns or working versions from existing campaigns.", "Campaigns", ), + _permission( + "campaigns:campaign:export", + "Export portable campaigns", + "Create integrity-protected portable Campaign packages with explicitly selected data scopes.", + "Campaigns", + ), + _permission( + "campaigns:campaign:import", + "Import portable campaigns", + "Preview and create new Campaign drafts from compatible portable packages.", + "Campaigns", + ), _permission( "campaigns:campaign:schedule", "Schedule campaigns", @@ -342,6 +354,8 @@ ROLE_TEMPLATES = ( "campaigns:campaign:create", "campaigns:campaign:update", "campaigns:campaign:copy", + "campaigns:campaign:export", + "campaigns:campaign:import", "campaigns:campaign:schedule", "campaigns:campaign:validate", "campaigns:campaign:build", @@ -443,8 +457,8 @@ def _campaigns_router(context: ModuleContext): manifest = ModuleManifest( id="campaigns", name="Campaigns", - version="0.1.23", - workflow_definitions=campaign_workflow_definitions(module_version="0.1.23"), + version="0.1.24", + workflow_definitions=campaign_workflow_definitions(module_version="0.1.24"), required_capabilities=( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, @@ -849,6 +863,67 @@ manifest = ModuleManifest( "help_contexts": ["campaigns.quick_access.campaigns"], }, ), + DocumentationTopic( + id="campaigns.admin.portable-transfer-governance", + title="Govern portable Campaign export and import", + summary="Separate configuration portability from recipient and delivery-data export, and verify every import as a new draft.", + body=( + "Portable Campaign export and import use separate campaign-level permissions. The built-in Campaign manager can move configuration, but recipient rows additionally require recipient read/export on export and recipient write/import on import. Review-state export requires report read; recipient-level delivery history requires report export plus recipient read/export. The UI and API default export to metadata plus template/configuration only. Every package records its format, source Campaign/version, selected scopes, item counts, redaction counts, and SHA-256 integrity digest. Campaign removes transport secrets, credential-envelope references, password-field values, infrastructure paths, and attachment bytes. Import fails closed on format, checksum, schema, scope, or destination-ID conflicts; its preview identifies every created and skipped domain. It always creates a separately owned draft, clears deployment-bound Mail references, and never recreates shares, locks, approvals, review decisions, delivery jobs, attempts, or sent state. The destination retains a bounded import receipt and matching Audit evidence. Operators must govern downloaded package storage and deletion outside GovOPlaN according to the selected data scopes." + ), + layer="configured", + documentation_types=("admin",), + audience=("module_admin", "security_reviewer", "privacy_officer", "campaign_manager"), + order=40, + conditions=( + DocumentationCondition( + required_modules=("campaigns",), + any_scopes=( + "campaigns:campaign:export", + "campaigns:campaign:import", + "access:roles:manage", + ), + ), + ), + links=( + DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"), + DocumentationLink( + label="Campaign handbook", + href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md", + kind="repository", + ), + ), + related_modules=("access", "audit", "files", "mail"), + translations={ + "de": { + "title": "Portablen Campaign-Export und -Import steuern", + "summary": "Konfigurationsportabilitaet vom Export von Empfaenger- und Zustelldaten trennen und jeden Import als neuen Entwurf pruefen.", + "body": ( + "Portabler Campaign-Export und -Import verwenden getrennte Campaign-Berechtigungen. Empfaengerzeilen erfordern beim Export zusaetzlich Empfaenger-Lese- und Exportrecht sowie beim Import Empfaenger-Schreib- und Importrecht. Pruefstatus erfordert Berichtsleserecht; Zustellhistorie erfordert Berichtsexport sowie Empfaenger-Lese- und Exportrecht. Standardmaessig werden nur Metadaten sowie Vorlage und Konfiguration exportiert. Jedes Paket enthaelt Format, Quelle, ausgewaehlte Umfaenge, Zaehler, Redaktionen und SHA-256-Integritaet. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, Infrastrukturpfade und Dateiinhalte werden entfernt. Der Import schlaegt bei Format-, Pruefsummen-, Schema-, Umfangs- oder Kennungskonflikten geschlossen fehl und erstellt immer einen eigenstaendigen Entwurf. Freigaben, Sperren, Genehmigungen, Pruefentscheidungen, Zustellauftraege und Sendezustaende werden nie wiedergegeben." + ), + } + }, + metadata={ + "kind": "configuration", + "route": "/campaigns", + "screen": "Campaign portable transfer", + "help_contexts": [ + "campaigns.action.export-package", + "campaigns.action.import-package", + ], + "permission_scopes": [ + "campaigns:campaign:export", + "campaigns:campaign:import", + "campaigns:recipient:read", + "campaigns:recipient:write", + "campaigns:recipient:import", + "campaigns:recipient:export", + "campaigns:report:read", + "campaigns:report:export", + ], + "privacy_default_scopes": ["metadata", "template_config"], + "verification": "Export the default scopes as a Campaign manager, verify a recipient scope is denied without recipient-export, tamper with the JSON and verify preview rejects it, then import a valid package and confirm a new draft plus matching Audit hashes without jobs or approval state.", + }, + ), DocumentationTopic( id="campaigns.admin.collaboration-governance", title="Govern Campaign collaboration permissions and retention", diff --git a/src/govoplan_campaign/backend/router.py b/src/govoplan_campaign/backend/router.py index a8112c5..a602624 100644 --- a/src/govoplan_campaign/backend/router.py +++ b/src/govoplan_campaign/backend/router.py @@ -12,12 +12,14 @@ from govoplan_campaign.backend.routes.operations import router as operations_rou from govoplan_campaign.backend.routes.reports import router as reports_router from govoplan_campaign.backend.routes.schedules import router as schedules_router from govoplan_campaign.backend.routes.sharing import router as sharing_router +from govoplan_campaign.backend.routes.transfers import router as transfers_router from govoplan_campaign.backend.routes.versions import router as versions_router router = APIRouter() for workflow_router in ( operations_router, + transfers_router, campaigns_router, assignments_router, collaboration_router, diff --git a/src/govoplan_campaign/backend/routes/transfers.py b/src/govoplan_campaign/backend/routes/transfers.py new file mode 100644 index 0000000..0f55004 --- /dev/null +++ b/src/govoplan_campaign/backend/routes/transfers.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import copy +from importlib import metadata +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from govoplan_campaign.backend.campaign.transfers import ( + CampaignImportInspection, + CampaignTransferError, + build_campaign_portable_package, + inspect_campaign_portable_package, +) +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignVersion, +) +from govoplan_campaign.backend.persistence.campaigns import ( + create_campaign_version_from_json, +) +from govoplan_campaign.backend.route_support import ( + _campaign_response_context, + _get_campaign_for_principal, + _require_permission, + _write_current_version_snapshot_if_available, +) +from govoplan_campaign.backend.schemas import ( + CampaignExportRequest, + CampaignImportApplyRequest, + CampaignImportApplyResponse, + CampaignImportPreviewRequest, + CampaignImportPreviewResponse, + CampaignPortablePackageResponse, + CampaignResponse, + CampaignVersionResponse, +) +from govoplan_core.audit.logging import audit_from_principal +from govoplan_core.auth import ApiPrincipal, require_scope +from govoplan_core.db.session import get_session + + +router = APIRouter(tags=["campaigns"]) + + +@router.post( + "/campaigns/{campaign_id}/versions/{version_id}/exports", + response_model=CampaignPortablePackageResponse, +) +def export_campaign_package( + campaign_id: str, + version_id: str, + payload: CampaignExportRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_scope("campaigns:campaign:export") + ), +): + campaign = _get_campaign_for_principal(session, campaign_id, principal) + version = ( + session.query(CampaignVersion) + .filter( + CampaignVersion.id == version_id, + CampaignVersion.campaign_id == campaign.id, + ) + .one_or_none() + ) + if version is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Campaign version not found", + ) + scopes = set(payload.scopes) + if "recipients" in scopes: + _require_permission(principal, "campaigns:recipient:read") + _require_permission(principal, "campaigns:recipient:export") + if "review_state" in scopes: + _require_permission(principal, "campaigns:report:read") + if "delivery_history" in scopes: + _require_permission(principal, "campaigns:report:export") + _require_permission(principal, "campaigns:recipient:read") + _require_permission(principal, "campaigns:recipient:export") + + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.campaign_version_id == version.id) + .order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) + .all() + if "delivery_history" in scopes + else () + ) + issues = ( + session.query(CampaignIssue) + .filter(CampaignIssue.campaign_version_id == version.id) + .order_by(CampaignIssue.id.asc()) + .all() + if "review_state" in scopes + else () + ) + try: + package = build_campaign_portable_package( + campaign=campaign, + version=version, + scopes=payload.scopes, + jobs=jobs, + issues=issues, + module_version=_module_version(), + ) + audit_from_principal( + session, + principal, + action="campaign.portable_export_created", + object_type="campaign_version", + object_id=version.id, + details={ + "campaign_id": campaign.id, + "package_id": package["package_id"], + "package_sha256": package["integrity"]["package_sha256"], + "format_version": package["format_version"], + "scopes": package["scopes"], + "item_counts": package["manifest"]["item_counts"], + "redactions": package["manifest"]["redactions"], + }, + commit=True, + ) + except CampaignTransferError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + except Exception: + session.rollback() + raise + return package + + +@router.post( + "/campaign-transfers/imports/preview", + response_model=CampaignImportPreviewResponse, +) +def preview_campaign_import( + payload: CampaignImportPreviewRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_scope("campaigns:campaign:import") + ), +): + _require_permission(principal, "campaigns:campaign:create") + inspection = _inspect_import_request( + session, + principal, + package=payload.package, + selected_scopes=payload.selected_scopes, + external_id=payload.external_id, + name=payload.name, + ) + return inspection.preview + + +@router.post( + "/campaign-transfers/imports", + response_model=CampaignImportApplyResponse, + status_code=status.HTTP_201_CREATED, +) +def import_campaign_package( + payload: CampaignImportApplyRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends( + require_scope("campaigns:campaign:import") + ), +): + _require_permission(principal, "campaigns:campaign:create") + inspection = _inspect_import_request( + session, + principal, + package=payload.package, + selected_scopes=payload.selected_scopes, + external_id=payload.external_id, + name=payload.name, + ) + _require_import_scope_permissions(principal, inspection) + preview = inspection.preview + if payload.expected_package_sha256 != preview.get("package_sha256"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="The Campaign package changed after preview. Preview it again before importing.", + ) + if not preview["compatible"] or inspection.configuration is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail={ + "message": "The Campaign package is not compatible.", + "errors": preview["errors"], + }, + ) + + destination = preview["destination"] + package_id = str(preview["package_id"]) + package_sha256 = str(preview["package_sha256"]) + receipt = { + "package_id": package_id, + "package_sha256": package_sha256, + "format_version": preview["format_version"], + "source": copy.deepcopy(preview["source"]), + "selected_scopes": list(preview["selected_scopes"]), + "created": copy.deepcopy(preview["will_create"]), + "skipped": copy.deepcopy(preview["will_skip"]), + } + try: + campaign, version = create_campaign_version_from_json( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + raw_json=inspection.configuration, + source_filename=f"{package_id}.govoplan-campaign.json", + source_base_path=None, + commit=False, + ) + campaign.settings = { + **inspection.portable_settings, + "portable_import": receipt, + } + session.add(campaign) + audit_from_principal( + session, + principal, + action="campaign.portable_import_applied", + object_type="campaign", + object_id=campaign.id, + details={ + "version_id": version.id, + "external_id": destination["external_id"], + "package_id": package_id, + "package_sha256": package_sha256, + "format_version": preview["format_version"], + "selected_scopes": preview["selected_scopes"], + "created_codes": [item["code"] for item in preview["will_create"]], + "skipped_codes": [item["code"] for item in preview["will_skip"]], + }, + commit=True, + ) + session.refresh(campaign) + session.refresh(version) + _write_current_version_snapshot_if_available(version) + except Exception: + session.rollback() + raise + return CampaignImportApplyResponse( + campaign=CampaignResponse.model_validate(campaign), + version=CampaignVersionResponse.model_validate( + version, + context=_campaign_response_context(principal), + ), + receipt=receipt, + ) + + +def _inspect_import_request( + session: Session, + principal: ApiPrincipal, + *, + package: dict[str, Any], + selected_scopes: list[str] | None, + external_id: str | None, + name: str | None, +) -> CampaignImportInspection: + source = package.get("source") + source = source if isinstance(source, dict) else {} + metadata_payload = package.get("payload") + metadata_payload = metadata_payload if isinstance(metadata_payload, dict) else {} + metadata_scope = metadata_payload.get("metadata") + metadata_scope = metadata_scope if isinstance(metadata_scope, dict) else {} + source_external_id = str( + metadata_scope.get("external_id") + or source.get("campaign_external_id") + or "campaign" + ) + destination_external_id = _portable_import_external_id( + session, + tenant_id=principal.tenant_id, + source_external_id=source_external_id, + requested=external_id, + ) + destination_name = str( + name + or metadata_scope.get("name") + or source.get("campaign_name") + or "Imported campaign" + ).strip() + if not destination_name: + destination_name = "Imported campaign" + inspection = inspect_campaign_portable_package( + package, + selected_scopes=selected_scopes, + external_id=destination_external_id, + name=destination_name, + ) + if _campaign_external_id_exists( + session, principal.tenant_id, destination_external_id + ): + inspection.preview["compatible"] = False + inspection.preview["errors"].append( + "The destination Campaign ID already exists in this tenant." + ) + return CampaignImportInspection( + preview=inspection.preview, + configuration=None, + portable_settings=inspection.portable_settings, + ) + return inspection + + +def _portable_import_external_id( + session: Session, + *, + tenant_id: str, + source_external_id: str, + requested: str | None, +) -> str: + if requested is not None: + candidate = requested.strip() + if not candidate: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="Campaign ID cannot be empty.", + ) + return candidate + stem = f"{source_external_id[:238]}-import" + for suffix in ("", *(f"-{number}" for number in range(2, 10_000))): + candidate = f"{stem[:255 - len(suffix)]}{suffix}" + if not _campaign_external_id_exists(session, tenant_id, candidate): + return candidate + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="No available Campaign import identifier could be generated.", + ) + + +def _campaign_external_id_exists( + session: Session, tenant_id: str, external_id: str +) -> bool: + return ( + session.query(Campaign.id) + .filter( + Campaign.tenant_id == tenant_id, + Campaign.external_id == external_id, + ) + .first() + is not None + ) + + +def _require_import_scope_permissions( + principal: ApiPrincipal, inspection: CampaignImportInspection +) -> None: + selected = set(inspection.preview.get("selected_scopes") or []) + if "recipients" in selected: + _require_permission(principal, "campaigns:recipient:import") + _require_permission(principal, "campaigns:recipient:write") + + +def _module_version() -> str: + try: + return metadata.version("govoplan-campaign") + except metadata.PackageNotFoundError: + return "development" + + +__all__ = [ + "export_campaign_package", + "import_campaign_package", + "preview_campaign_import", + "router", +] diff --git a/src/govoplan_campaign/backend/schemas.py b/src/govoplan_campaign/backend/schemas.py index d4f1fd5..9a3fdbc 100644 --- a/src/govoplan_campaign/backend/schemas.py +++ b/src/govoplan_campaign/backend/schemas.py @@ -595,6 +595,96 @@ class CampaignCreateResponse(BaseModel): version: CampaignVersionResponse +CampaignTransferScope = Literal[ + "metadata", + "template_config", + "recipients", + "attachments", + "review_state", + "delivery_history", +] + + +class CampaignExportRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + scopes: list[CampaignTransferScope] = Field( + default_factory=lambda: ["metadata", "template_config"], + min_length=1, + max_length=6, + ) + + @field_validator("scopes") + @classmethod + def normalize_scopes( + cls, value: list[CampaignTransferScope] + ) -> list[CampaignTransferScope]: + return list(dict.fromkeys(value)) + + +class CampaignPortablePackageResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + format: Literal["govoplan.campaign-portable"] + format_version: str + package_id: str + exported_at: str + source: dict[str, Any] + scopes: list[CampaignTransferScope] + manifest: dict[str, Any] + payload: dict[str, Any] + integrity: dict[str, str] + + +class CampaignImportPreviewRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + package: dict[str, Any] + selected_scopes: list[CampaignTransferScope] | None = Field( + default=None, + max_length=6, + ) + external_id: str | None = Field(default=None, min_length=1, max_length=255) + name: str | None = Field(default=None, min_length=1, max_length=255) + + @field_validator("selected_scopes") + @classmethod + def normalize_selected_scopes( + cls, value: list[CampaignTransferScope] | None + ) -> list[CampaignTransferScope] | None: + return list(dict.fromkeys(value)) if value is not None else None + + +class CampaignImportApplyRequest(CampaignImportPreviewRequest): + expected_package_sha256: str = Field(min_length=64, max_length=64) + + +class CampaignTransferPlanItem(BaseModel): + scope: CampaignTransferScope + code: str + summary: str + item_count: int | None = None + + +class CampaignImportPreviewResponse(BaseModel): + compatible: bool + package_id: str | None = None + package_sha256: str | None = None + format_version: str | None = None + source: dict[str, Any] = Field(default_factory=dict) + available_scopes: list[CampaignTransferScope] = Field(default_factory=list) + selected_scopes: list[CampaignTransferScope] = Field(default_factory=list) + destination: dict[str, Any] = Field(default_factory=dict) + will_create: list[CampaignTransferPlanItem] = Field(default_factory=list) + will_skip: list[CampaignTransferPlanItem] = Field(default_factory=list) + warnings: list[str] = Field(default_factory=list) + errors: list[str] = Field(default_factory=list) + + +class CampaignImportApplyResponse(CampaignCreateResponse): + receipt: dict[str, Any] + + class CampaignListResponse(BaseModel): campaigns: list[CampaignResponse] diff --git a/tests/test_campaign_transfers.py b/tests/test_campaign_transfers.py new file mode 100644 index 0000000..caf6ab9 --- /dev/null +++ b/tests/test_campaign_transfers.py @@ -0,0 +1,528 @@ +from __future__ import annotations + +import copy +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from fastapi import HTTPException +from sqlalchemy import Column, String, Table, create_engine +from sqlalchemy.orm import Session, sessionmaker +from govoplan_campaign.backend.campaign.transfers import ( + DEFAULT_PORTABLE_CAMPAIGN_SCOPES, + build_campaign_portable_package, + canonical_sha256, + inspect_campaign_portable_package, +) +from govoplan_campaign.backend.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignShare, + CampaignVersion, +) +from govoplan_campaign.backend.persistence.versions import minimal_campaign_json +from govoplan_campaign.backend.routes.transfers import ( + export_campaign_package, + import_campaign_package, + preview_campaign_import, +) +from govoplan_campaign.backend.schemas import ( + CampaignExportRequest, + CampaignImportApplyRequest, + CampaignImportPreviewRequest, + CampaignPortablePackageResponse, +) +from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.db.base import Base + + +def _source() -> tuple[Campaign, CampaignVersion]: + raw_json = minimal_campaign_json( + external_id="monthly-notice", + name="Monthly notice", + description="Portable source", + ) + raw_json["fields"] = [ + {"name": "case_id", "type": "string"}, + {"name": "private_code", "type": "password"}, + ] + raw_json["global_values"] = { + "office": "Permits", + "private_code": "must-not-leave-the-source", + } + raw_json["server"] = { + "mail_profile_id": "mail-profile-source", + "smtp_server_id": "smtp-source", + "smtp_credential_id": "credential-source", + } + raw_json["template"] = { + "subject": "Case {{case_id}}", + "text": "Hello", + "html": None, + } + raw_json["attachments"]["global"] = [ + {"base_dir": ".", "file_filter": "notice.pdf", "required": True} + ] + raw_json["entries"]["inline"] = [ + { + "id": "recipient-1", + "to": [{"email": "person@example.test"}], + "fields": { + "case_id": "A-1", + "private_code": "recipient-secret", + }, + "attachments": [ + {"base_dir": ".", "file_filter": "A-1.pdf", "required": True} + ], + } + ] + campaign = Campaign( + id="campaign-source", + tenant_id="tenant-source", + external_id="monthly-notice", + name="Monthly notice", + description="Portable source", + status="completed", + settings={ + "retention_days": 90, + "provider_token": "must-not-export", + }, + mail_profile_policy={ + "profile_id": "mail-profile-source", + "credential_id": "credential-source", + }, + ) + version = CampaignVersion( + id="version-source", + campaign_id=campaign.id, + version_number=4, + raw_json=raw_json, + schema_version="1.0", + workflow_state="completed", + validation_summary={"ok": True, "error_count": 0}, + build_summary={"built_count": 1}, + editor_state={ + "review_send": { + "inspection_complete": True, + "reviewed_message_keys": ["message-1"], + "issue_decisions": [ + { + "decision": "accept", + "issue_codes": ["attachment_warning"], + "issue_fingerprint": "fingerprint-1", + "message_sha256": "a" * 64, + "reason": "Verified manually", + } + ], + } + }, + ) + return campaign, version + + +def _job(campaign: Campaign, version: CampaignVersion) -> CampaignJob: + return CampaignJob( + id="job-1", + tenant_id=campaign.tenant_id, + campaign_id=campaign.id, + campaign_version_id=version.id, + entry_index=0, + entry_id="recipient-1", + recipient_email="person@example.test", + message_id_header="", + eml_sha256="b" * 64, + build_status="built", + validation_status="ready", + queue_status="cancelled", + send_status="smtp_accepted", + postbox_status="not_requested", + print_status="not_requested", + imap_status="appended", + attempt_count=1, + delivery_provenance={"route": "mail", "storage_key": "hidden"}, + ) + + +def test_privacy_default_export_is_configuration_only_and_redacts_secrets() -> None: + campaign, version = _source() + + package = build_campaign_portable_package( + campaign=campaign, + version=version, + scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES, + module_version="0.1.24", + ) + + assert package["scopes"] == ["metadata", "template_config"] + assert set(package["payload"]) == {"metadata", "template_config"} + assert package["manifest"]["secrets_included"] is False + assert package["manifest"]["redactions"] == { + "deployment_credential_reference": 1, + "password_field_value": 2, + "sensitive_setting": 2, + } + template = package["payload"]["template_config"] + assert "private_code" not in template["configuration"]["global_values"] + assert "smtp_credential_id" not in template["configuration"]["server"] + assert "provider_token" not in template["campaign_settings"] + assert "credential_id" not in template["mail_profile_policy"] + serialized = CampaignPortablePackageResponse.model_validate(package).model_dump( + mode="json" + ) + assert inspect_campaign_portable_package( + serialized, + selected_scopes=None, + external_id="serialized-import", + name="Serialized import", + ).preview["compatible"] is True + + +def test_full_export_import_applies_configuration_but_never_replays_evidence() -> None: + campaign, version = _source() + package = build_campaign_portable_package( + campaign=campaign, + version=version, + scopes=( + "metadata", + "template_config", + "recipients", + "attachments", + "review_state", + "delivery_history", + ), + jobs=(_job(campaign, version),), + issues=( + CampaignIssue( + id="issue-1", + tenant_id=campaign.tenant_id, + campaign_id=campaign.id, + campaign_version_id=version.id, + severity="warning", + code="attachment_warning", + message="Review attachment", + ), + ), + module_version="0.1.24", + ) + + inspection = inspect_campaign_portable_package( + package, + selected_scopes=None, + external_id="monthly-notice-import", + name="Imported monthly notice", + ) + + assert inspection.preview["compatible"] is True + assert inspection.configuration is not None + assert inspection.configuration["campaign"] == { + "id": "monthly-notice-import", + "name": "Imported monthly notice", + "description": "Portable source", + "mode": "draft", + } + assert inspection.configuration["server"] == {} + assert inspection.configuration["entries"]["inline"][0]["to"] == [ + {"email": "person@example.test"} + ] + assert inspection.configuration["entries"]["inline"][0]["attachments"][0][ + "file_filter" + ] == "A-1.pdf" + assert "private_code" not in inspection.configuration["entries"]["inline"][0][ + "fields" + ] + skipped_codes = {item["code"] for item in inspection.preview["will_skip"]} + assert skipped_codes == { + "deployment_bound_mail_profile", + "operational_evidence_not_replayed", + } + assert package["payload"]["review_state"]["decision_count"] == 1 + assert package["payload"]["delivery_history"]["jobs"][0][ + "recipient_email" + ] == "person@example.test" + assert "storage_key" not in package["payload"]["delivery_history"]["jobs"][0][ + "delivery_provenance" + ] + + +def test_import_preview_reports_unselected_recipient_attachment_rules() -> None: + campaign, version = _source() + package = build_campaign_portable_package( + campaign=campaign, + version=version, + scopes=("metadata", "attachments", "recipients"), + module_version="0.1.24", + ) + + inspection = inspect_campaign_portable_package( + package, + selected_scopes=("metadata", "attachments"), + external_id="attachment-import", + name="Attachment import", + ) + + assert inspection.preview["compatible"] is True + skipped = {item["code"]: item for item in inspection.preview["will_skip"]} + assert skipped["recipient_scope_required"]["item_count"] == 1 + assert skipped["scope_not_selected"]["scope"] == "recipients" + assert inspection.configuration is not None + assert inspection.configuration["entries"]["inline"] == [] + + +def test_import_preview_fails_closed_when_package_is_tampered() -> None: + campaign, version = _source() + package = build_campaign_portable_package( + campaign=campaign, + version=version, + scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES, + module_version="0.1.24", + ) + tampered = copy.deepcopy(package) + tampered["payload"]["metadata"]["name"] = "Tampered" + + inspection = inspect_campaign_portable_package( + tampered, + selected_scopes=None, + external_id="tampered-import", + name="Tampered", + ) + + assert inspection.preview["compatible"] is False + assert inspection.configuration is None + assert any("integrity checksum" in error for error in inspection.preview["errors"]) + + +def test_import_preview_rejects_unsupported_campaign_schema_even_with_valid_checksum() -> None: + campaign, version = _source() + package = build_campaign_portable_package( + campaign=campaign, + version=version, + scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES, + module_version="0.1.24", + ) + package["source"]["campaign_schema_version"] = "2.0" + package["integrity"]["package_sha256"] = canonical_package_hash(package) + + inspection = inspect_campaign_portable_package( + package, + selected_scopes=None, + external_id="future-import", + name="Future import", + ) + + assert inspection.preview["compatible"] is False + assert any("schema version" in error for error in inspection.preview["errors"]) + + +def canonical_package_hash(package: dict[str, object]) -> str: + content = copy.deepcopy(package) + content.pop("integrity", None) + return canonical_sha256(content) + + +class _Principal: + tenant_id = "tenant-1" + api_key = None + + def __init__(self, *scopes: str) -> None: + self.user = SimpleNamespace(id="user-1", display_name="Importer") + self.scopes = frozenset(scopes) + + def has(self, scope: str) -> bool: + return scope in self.scopes or "tenant:*" in self.scopes + + +@pytest.fixture() +def route_session() -> Session: + engine = create_engine("sqlite+pysqlite:///:memory:") + access_users = Base.metadata.tables.get("access_users") + if access_users is None: + access_users = Table( + "access_users", + Base.metadata, + Column("id", String(36), primary_key=True), + ) + access_groups = Base.metadata.tables.get("access_groups") + if access_groups is None: + access_groups = Table( + "access_groups", + Base.metadata, + Column("id", String(36), primary_key=True), + ) + Base.metadata.create_all( + engine, + tables=[ + access_users, + access_groups, + Campaign.__table__, + CampaignVersion.__table__, + CampaignShare.__table__, + CampaignJob.__table__, + CampaignIssue.__table__, + ChangeSequenceEntry.__table__, + ], + ) + session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False) + database = session_factory() + user_values = {"id": "user-1"} + if "tenant_id" in access_users.c: + user_values.update( + tenant_id="tenant-1", + account_id="account-1", + email="user-1@example.test", + ) + database.execute(access_users.insert().values(**user_values)) + raw_json = minimal_campaign_json(external_id="source", name="Source") + raw_json["entries"]["inline"] = [ + {"id": "one", "to": [{"email": "one@example.test"}]} + ] + source = Campaign( + id="source-campaign", + tenant_id="tenant-1", + created_by_user_id="user-1", + owner_user_id="user-1", + external_id="source", + name="Source", + status="draft", + current_version_id="source-version", + ) + source_version = CampaignVersion( + id="source-version", + campaign_id=source.id, + version_number=1, + raw_json=raw_json, + ) + database.add_all((source, source_version)) + database.commit() + try: + yield database + finally: + database.close() + engine.dispose() + + +def test_export_route_enforces_recipient_export_scope(route_session: Session) -> None: + principal = _Principal( + "campaigns:campaign:read", + "campaigns:campaign:export", + "campaigns:recipient:read", + ) + + with pytest.raises(HTTPException) as denied: + export_campaign_package( + "source-campaign", + "source-version", + CampaignExportRequest(scopes=["metadata", "recipients"]), + session=route_session, + principal=principal, + ) + + assert denied.value.status_code == 403 + assert denied.value.detail == "Missing scope: campaigns:recipient:export" + + +def test_export_preview_and_apply_routes_keep_matching_provenance( + route_session: Session, +) -> None: + exporter = _Principal( + "campaigns:campaign:read", + "campaigns:campaign:export", + "campaigns:recipient:read", + "campaigns:recipient:export", + ) + + def commit_audit(active_session: Session, *_args, **_kwargs) -> None: + active_session.commit() + + with patch( + "govoplan_campaign.backend.routes.transfers.audit_from_principal", + side_effect=commit_audit, + ): + package = export_campaign_package( + "source-campaign", + "source-version", + CampaignExportRequest(scopes=["metadata", "template_config", "recipients"]), + session=route_session, + principal=exporter, + ) + + limited_importer = _Principal( + "campaigns:campaign:create", + "campaigns:campaign:import", + ) + limited_preview = preview_campaign_import( + CampaignImportPreviewRequest(package=package), + session=route_session, + principal=limited_importer, + ) + assert limited_preview["compatible"] is True + assert "recipients" in limited_preview["selected_scopes"] + + importer = _Principal( + "campaigns:campaign:create", + "campaigns:campaign:import", + "campaigns:recipient:write", + "campaigns:recipient:import", + ) + preview = preview_campaign_import( + CampaignImportPreviewRequest(package=package), + session=route_session, + principal=importer, + ) + assert preview["compatible"] is True + assert preview["destination"]["external_id"] == "source-import" + + def create_import(active_session: Session, **kwargs): + raw_json = kwargs["raw_json"] + destination = Campaign( + id="imported-campaign", + tenant_id="tenant-1", + created_by_user_id="user-1", + owner_user_id="user-1", + external_id=raw_json["campaign"]["id"], + name=raw_json["campaign"]["name"], + status="draft", + current_version_id="imported-version", + ) + version = CampaignVersion( + id="imported-version", + campaign_id=destination.id, + version_number=1, + raw_json=raw_json, + ) + active_session.add_all((destination, version)) + active_session.flush() + return destination, version + + with ( + patch( + "govoplan_campaign.backend.routes.transfers.create_campaign_version_from_json", + side_effect=create_import, + ), + patch( + "govoplan_campaign.backend.routes.transfers.audit_from_principal", + side_effect=commit_audit, + ), + patch( + "govoplan_campaign.backend.routes.transfers._write_current_version_snapshot_if_available" + ), + ): + response = import_campaign_package( + CampaignImportApplyRequest( + package=package, + selected_scopes=preview["selected_scopes"], + external_id=preview["destination"]["external_id"], + name=preview["destination"]["name"], + expected_package_sha256=preview["package_sha256"], + ), + session=route_session, + principal=importer, + ) + + assert response.campaign.external_id == "source-import" + assert response.receipt["package_id"] == package["package_id"] + assert response.receipt["package_sha256"] == package["integrity"]["package_sha256"] + imported = route_session.get(Campaign, "imported-campaign") + assert imported is not None + assert imported.settings["portable_import"]["package_id"] == package["package_id"] + assert route_session.query(CampaignJob).filter_by(campaign_id=imported.id).count() == 0 diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 597ae26..6d19b5b 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -464,7 +464,9 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re "campaign.fields", "campaign.template", "campaign.template.content-library", - "campaigns.action.schedule-drafts", + "campaigns.action.schedule-drafts", + "campaigns.action.export-package", + "campaigns.action.import-package", "campaign.attachments", "campaign.attachments.reuse-policy", "campaign.attachments.residual-files", diff --git a/tests/test_route_registration.py b/tests/test_route_registration.py index 67d744b..ddba70f 100644 --- a/tests/test_route_registration.py +++ b/tests/test_route_registration.py @@ -13,6 +13,7 @@ from govoplan_campaign.backend.routes.operations import router as operations_rou from govoplan_campaign.backend.routes.reports import router as reports_router from govoplan_campaign.backend.routes.schedules import router as schedules_router from govoplan_campaign.backend.routes.sharing import router as sharing_router +from govoplan_campaign.backend.routes.transfers import router as transfers_router from govoplan_campaign.backend.routes.versions import router as versions_router @@ -27,6 +28,7 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]: def test_campaign_router_composes_every_workflow_operation_once() -> None: workflow_routers = ( operations_router, + transfers_router, campaigns_router, assignments_router, collaboration_router, @@ -46,7 +48,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None: actual = _operation_keys(router) assert actual == expected - assert len(actual) == 93 + assert len(actual) == 96 assert not [operation for operation, count in Counter(actual).items() if count > 1] @@ -57,6 +59,10 @@ def test_key_routes_are_owned_by_their_focused_router() -> None: ("POST", "/campaigns/operations/artifacts/reconcile"), ), (campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")), + ( + transfers_router, + ("POST", "/campaign-transfers/imports/preview"), + ), (collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")), (assignments_router, ("POST", "/campaigns/{campaign_id}/assignments")), (versions_router, ("POST", "/campaigns/versions/{version_id}/build")), diff --git a/webui/package.json b/webui/package.json index 9e53c88..e4b7563 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/campaign-webui", - "version": "0.1.23", + "version": "0.1.24", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/api/campaigns.ts b/webui/src/api/campaigns.ts index 8a10a98..c24100e 100644 --- a/webui/src/api/campaigns.ts +++ b/webui/src/api/campaigns.ts @@ -234,6 +234,52 @@ export type CampaignCopyOptions = { include_mail_profile: boolean; }; +export type CampaignTransferScope = + | "metadata" + | "template_config" + | "recipients" + | "attachments" + | "review_state" + | "delivery_history"; + +export type CampaignPortablePackage = { + format: "govoplan.campaign-portable"; + format_version: string; + package_id: string; + exported_at: string; + source: Record; + scopes: CampaignTransferScope[]; + manifest: Record; + payload: Record; + integrity: { algorithm: string; package_sha256: string }; +}; + +export type CampaignTransferPlanItem = { + scope: CampaignTransferScope; + code: string; + summary: string; + item_count?: number | null; +}; + +export type CampaignImportPreview = { + compatible: boolean; + package_id?: string | null; + package_sha256?: string | null; + format_version?: string | null; + source: Record; + available_scopes: CampaignTransferScope[]; + selected_scopes: CampaignTransferScope[]; + destination: { external_id?: string; name?: string; status?: string }; + will_create: CampaignTransferPlanItem[]; + will_skip: CampaignTransferPlanItem[]; + warnings: string[]; + errors: string[]; +}; + +export type CampaignImportApplyResponse = CampaignCreateResponse & { + receipt: Record; +}; + export type CampaignScheduleOccurrence = { id: string; schedule_id: string; @@ -1339,6 +1385,49 @@ options: CampaignCopyOptions) }); } +export async function exportCampaignPackage( +settings: ApiSettings, +campaignId: string, +versionId: string, +scopes: CampaignTransferScope[]) +: Promise { + return apiFetch(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/exports`, { + method: "POST", + body: JSON.stringify({ scopes }) + }); +} + +export async function previewCampaignImport( +settings: ApiSettings, +payload: { + package: Record; + selected_scopes?: CampaignTransferScope[] | null; + external_id?: string; + name?: string; +}) +: Promise { + return apiFetch(settings, "/api/v1/campaign-transfers/imports/preview", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export async function importCampaignPackage( +settings: ApiSettings, +payload: { + package: Record; + selected_scopes: CampaignTransferScope[]; + external_id?: string; + name?: string; + expected_package_sha256: string; +}) +: Promise { + return apiFetch(settings, "/api/v1/campaign-transfers/imports", { + method: "POST", + body: JSON.stringify(payload) + }); +} + export async function listCampaignSchedules( settings: ApiSettings, campaignId: string) diff --git a/webui/src/features/campaigns/CampaignListPage.tsx b/webui/src/features/campaigns/CampaignListPage.tsx index 379ac8f..c0d6a88 100644 --- a/webui/src/features/campaigns/CampaignListPage.tsx +++ b/webui/src/features/campaigns/CampaignListPage.tsx @@ -1,18 +1,28 @@ import { useEffect, useState } from "react"; -import { ExternalLink } from "lucide-react"; +import { ExternalLink, Upload } from "lucide-react"; import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui"; import { Link } from "react-router"; -import type { ApiSettings } from "../../types"; +import type { ApiSettings, AuthInfo } from "../../types"; import { Card } from "@govoplan/core-webui"; import { Button } from "@govoplan/core-webui"; +import { Dialog } from "@govoplan/core-webui"; +import { FormField } from "@govoplan/core-webui"; import { StatusBadge } from "@govoplan/core-webui"; import { LoadingFrame } from "@govoplan/core-webui"; -import { PageActionBar, PageLayout, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui"; +import { DismissibleAlert, PageActionBar, PageLayout, TableActionGroup, ToggleSwitch, hasScope, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui"; import { DataGrid, type DataGridColumn } from "@govoplan/core-webui"; -import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns"; +import { + createNewCampaign, + importCampaignPackage, + listCampaignsDelta, + previewCampaignImport, + type CampaignDeltaResponse, + type CampaignImportPreview, + type CampaignTransferScope +} from "../../api/campaigns"; import type { CampaignListItem } from "../../types"; -export default function CampaignListPage({ settings }: {settings: ApiSettings;}) { +export default function CampaignListPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) { const navigate = useGuardedNavigate(); const [campaigns, setCampaigns] = useState([]); const [error, setError] = useState(""); @@ -20,6 +30,16 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;}) const [creating, setCreating] = useState(false); const [lastLoadedAt, setLastLoadedAt] = useState(""); const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const [importPackage, setImportPackage] = useState | null>(null); + const [importPreview, setImportPreview] = useState(null); + const [importScopes, setImportScopes] = useState([]); + const [importIdentity, setImportIdentity] = useState({ external_id: "", name: "" }); + const [importPreviewStale, setImportPreviewStale] = useState(false); + const [importBusy, setImportBusy] = useState(false); + const [importError, setImportError] = useState(""); + const canImport = hasScope(auth, "campaigns:campaign:import") && hasScope(auth, "campaigns:campaign:create"); + const canImportRecipients = hasScope(auth, "campaigns:recipient:import") && hasScope(auth, "campaigns:recipient:write"); async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) { setLoading(true); @@ -60,6 +80,101 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;}) } } + function openImport() { + setImportPackage(null); + setImportPreview(null); + setImportScopes([]); + setImportIdentity({ external_id: "", name: "" }); + setImportPreviewStale(false); + setImportError(""); + setImportOpen(true); + } + + async function readImportFile(file: File | undefined) { + if (!file) return; + setImportBusy(true); + setImportError(""); + try { + if (file.size > 25 * 1024 * 1024) throw new Error("Campaign packages larger than 25 MB must be reviewed and imported through a governed integration."); + const parsed: unknown = JSON.parse(await file.text()); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Select a JSON object exported as a portable Campaign package."); + const packageData = parsed as Record; + const preview = await previewCampaignImport(settings, { package: packageData }); + const allowedScopes = preview.selected_scopes.filter((scope) => scope !== "recipients" || canImportRecipients); + setImportPackage(packageData); + setImportPreview(preview); + setImportScopes(allowedScopes); + setImportIdentity({ + external_id: preview.destination.external_id ?? "", + name: preview.destination.name ?? "" + }); + setImportPreviewStale(allowedScopes.length !== preview.selected_scopes.length); + } catch (err) { + setImportPackage(null); + setImportPreview(null); + setImportError(err instanceof Error ? err.message : String(err)); + } finally { + setImportBusy(false); + } + } + + function patchImportIdentity(key: "external_id" | "name", value: string) { + setImportIdentity((current) => ({ ...current, [key]: value })); + setImportPreviewStale(true); + } + + function toggleImportScope(scope: CampaignTransferScope, checked: boolean) { + setImportScopes((current) => checked + ? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index) + : current.filter((item) => item !== scope)); + setImportPreviewStale(true); + } + + async function refreshImportPreview() { + if (!importPackage || importBusy || importScopes.length === 0) return; + setImportBusy(true); + setImportError(""); + try { + const preview = await previewCampaignImport(settings, { + package: importPackage, + selected_scopes: importScopes, + external_id: importIdentity.external_id.trim() || undefined, + name: importIdentity.name.trim() || undefined + }); + setImportPreview(preview); + setImportIdentity({ + external_id: preview.destination.external_id ?? importIdentity.external_id, + name: preview.destination.name ?? importIdentity.name + }); + setImportPreviewStale(false); + } catch (err) { + setImportError(err instanceof Error ? err.message : String(err)); + } finally { + setImportBusy(false); + } + } + + async function applyImport() { + if (!importPackage || !importPreview?.compatible || !importPreview.package_sha256 || importPreviewStale || importBusy) return; + setImportBusy(true); + setImportError(""); + try { + const created = await importCampaignPackage(settings, { + package: importPackage, + selected_scopes: importScopes, + external_id: importIdentity.external_id.trim() || undefined, + name: importIdentity.name.trim() || undefined, + expected_package_sha256: importPreview.package_sha256 + }); + setImportOpen(false); + navigate(`/campaigns/${created.campaign.id}`); + } catch (err) { + setImportError(err instanceof Error ? err.message : String(err)); + } finally { + setImportBusy(false); + } + } + useEffect(() => { setCampaignDeltaWatermark(null); load(null); @@ -141,7 +256,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;}) }]; - return ( + return (<> void load(null), loading }} - createAction={} + + } />} > @@ -185,7 +306,75 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;}) } - ); + + + setImportOpen(false)} + footer={<> + + {importPackage && } + + }> +
+ + Import always creates a new draft. Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state. + + + void readImportFile(event.target.files?.[0])} /> + + {importError &&
{importError}
} + {importPreview && <> +
+ + patchImportIdentity("name", event.target.value)} /> + + + patchImportIdentity("external_id", event.target.value)} /> + +
+
+ {importPreview.available_scopes.map((scope) =>
+
+ {transferScopeLabel(scope)} + {transferScopeDescription(scope)} +
+ toggleImportScope(scope, checked)} /> +
)} +
+ {importPreviewStale &&

Identity or scope choices changed. Refresh the preview before importing.

} + {!importPreview.compatible &&
+ This package cannot be imported. +
    {importPreview.errors.map((item) =>
  • {item}
  • )}
+
} + {importPreview.warnings.length > 0 &&
+ Review before import +
    {importPreview.warnings.map((item) =>
  • {item}
  • )}
+
} +
+ + +
+

Package {importPreview.package_id ?? "unknown"} · SHA-256 {importPreview.package_sha256 ?? "unavailable"}

+ } +
+
+ ); } @@ -194,6 +383,40 @@ function shortId(value: string): string { return `${value.slice(0, 12)}…${value.slice(-6)}`; } +function ImportPlan({ title, items }: {title: string;items: CampaignImportPreview["will_create"];}) { + return
+

{title}

+ {items.length === 0 + ?

Nothing.

+ :
    {items.map((item) =>
  • + {transferScopeLabel(item.scope)}: {item.summary} + {typeof item.item_count === "number" ? ` (${item.item_count})` : ""} +
  • )}
} +
; +} + +function transferScopeLabel(scope: CampaignTransferScope): string { + return ({ + metadata: "Metadata", + template_config: "Template and configuration", + recipients: "Recipients", + attachments: "Attachments", + review_state: "Review state", + delivery_history: "Delivery history" + } satisfies Record)[scope]; +} + +function transferScopeDescription(scope: CampaignTransferScope): string { + return ({ + metadata: "Identity and source description for the new draft.", + template_config: "Portable fields, templates, policies, and delivery settings.", + recipients: "Campaign-local recipient rows and import provenance.", + attachments: "Attachment rules and references; never file content.", + review_state: "Historical evidence retained in the package, never replayed.", + delivery_history: "Historical outcomes retained in the package, never replayed." + } satisfies Record)[scope]; +} + function formatDateTime(value?: string): string { return formatPlatformDateTime(value); } diff --git a/webui/src/features/campaigns/CampaignModulePage.tsx b/webui/src/features/campaigns/CampaignModulePage.tsx index b981707..0d8f053 100644 --- a/webui/src/features/campaigns/CampaignModulePage.tsx +++ b/webui/src/features/campaigns/CampaignModulePage.tsx @@ -77,7 +77,7 @@ export default function CampaignModulePage({ ? : active === "reports" ? - : } + : } ); } diff --git a/webui/src/features/campaigns/CampaignOverviewPage.tsx b/webui/src/features/campaigns/CampaignOverviewPage.tsx index edfefce..c3f6bd4 100644 --- a/webui/src/features/campaigns/CampaignOverviewPage.tsx +++ b/webui/src/features/campaigns/CampaignOverviewPage.tsx @@ -1,6 +1,6 @@ import { MetricGrid } from "@govoplan/core-webui"; import { useEffect, useMemo, useState } from "react"; -import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react"; +import { Archive, CalendarClock, Copy, Download, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react"; import { Link } from "react-router"; import type { ApiSettings, AuthInfo } from "../../types"; import { FormGrid, Button } from "@govoplan/core-webui"; @@ -21,6 +21,7 @@ import { copyCampaign, createCampaignSchedule, deleteCampaign, + exportCampaignPackage, getCampaignLifecyclePolicy, lockCampaignVersionPermanently, lockCampaignVersionTemporarily, @@ -32,6 +33,7 @@ import { type CampaignScheduleCreate, type CampaignLifecyclePolicy, type CampaignCopyOptions, + type CampaignTransferScope, type CampaignVersionDetail, type CampaignVersionListItem } from "../../api/campaigns"; @@ -49,6 +51,7 @@ import { summaryValue } from "./utils/campaignView"; import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders"; +import { downloadJson, safeFileStem } from "./utils/draftEditor"; const campaignModeOptions = ["draft", "test", "send"]; type LockAction = "temporary" | "unlock" | "permanent"; @@ -68,6 +71,7 @@ const defaultCopyOptions: CampaignCopyOptions = { include_policies: true, include_mail_profile: true }; +const defaultExportScopes: CampaignTransferScope[] = ["metadata", "template_config"]; function defaultScheduleDraft(): CampaignScheduleCreate { const start = new Date(Date.now() + 60 * 60 * 1000); @@ -104,6 +108,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se const [pendingLifecycleAction, setPendingLifecycleAction] = useState(null); const [copyOptions, setCopyOptions] = useState(defaultCopyOptions); const [lifecycleBusy, setLifecycleBusy] = useState(false); + const [exportDialogOpen, setExportDialogOpen] = useState(false); + const [exportScopes, setExportScopes] = useState(defaultExportScopes); + const [exportBusy, setExportBusy] = useState(false); const [message, setMessage] = useState(""); const [schedules, setSchedules] = useState([]); const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false); @@ -113,11 +120,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive"); const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete"); const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy"); + const canExport = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:export"); const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy"); const canAutonomousSchedule = canSchedule && hasScope(auth, "campaigns:campaign:queue") && hasScope(auth, "campaigns:campaign:send") && hasScope(auth, "mail:profile:use"); + const canExportRecipients = hasScope(auth, "campaigns:recipient:read") && hasScope(auth, "campaigns:recipient:export"); + const canExportReview = hasScope(auth, "campaigns:report:read"); + const canExportDelivery = canExportRecipients && hasScope(auth, "campaigns:report:export"); function openSection(section: string, fragment = "") { const params = new URLSearchParams(); @@ -352,6 +363,36 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se } } + function toggleExportScope(scope: CampaignTransferScope, checked: boolean) { + setExportScopes((current) => checked + ? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index) + : current.filter((item) => item !== scope)); + } + + async function exportPortablePackage() { + if (!campaign || !data.currentVersion || exportBusy || exportScopes.length === 0) return; + setExportBusy(true); + setError(""); + try { + const portablePackage = await exportCampaignPackage( + settings, + campaign.id, + data.currentVersion.id, + exportScopes + ); + downloadJson( + `${safeFileStem(campaign.external_id || campaign.name)}-v${data.currentVersion.version_number ?? 1}.govoplan-campaign.json`, + portablePackage + ); + setExportDialogOpen(false); + setMessage("Portable Campaign package downloaded. Keep recipient or delivery packages in an approved location."); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setExportBusy(false); + } + } + return ( void reload(), loading }} primaryActions={<> + {canExport && } {canCopy && data.currentVersion && + + }> +
+

+ Configuration-only is the privacy-safe default. The package contains JSON and attachment references, never file content, credentials, or transport secrets. +

+
+ toggleExportScope("metadata", checked)} /> + toggleExportScope("template_config", checked)} /> + toggleExportScope("recipients", checked)} /> + toggleExportScope("attachments", checked)} /> + toggleExportScope("review_state", checked)} /> + toggleExportScope("delivery_history", checked)} /> +
+
+ + , key: string, fallback = return fallback; } -export function downloadJson(filename: string, data: Record) { +export function downloadJson(filename: string, data: unknown) { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css index 2603630..c178239 100644 --- a/webui/src/styles/campaign-workspace.css +++ b/webui/src/styles/campaign-workspace.css @@ -2726,10 +2726,15 @@ .campaign-copy-option strong, .campaign-copy-option small { display: block; } .campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; } .campaign-copy-option.is-disabled { opacity: .62; } +.campaign-import-plan { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.campaign-import-plan h3 { margin: 0 0 8px; font-size: 1rem; } +.campaign-import-plan ul { margin: 0; padding-left: 20px; } +.campaign-import-plan li + li { margin-top: 6px; } @media (max-width: 760px) { .campaign-copy-identity { grid-template-columns: 1fr; } .campaign-copy-option { grid-template-columns: 1fr; } + .campaign-import-plan { grid-template-columns: 1fr; } } .campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); } diff --git a/webui/tests/campaign-lifecycle-ui-structure.test.mjs b/webui/tests/campaign-lifecycle-ui-structure.test.mjs index 5d95217..7ce0d59 100644 --- a/webui/tests/campaign-lifecycle-ui-structure.test.mjs +++ b/webui/tests/campaign-lifecycle-ui-structure.test.mjs @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8"); const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8"); +const campaignList = readFileSync("src/features/campaigns/CampaignListPage.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"); @@ -29,10 +30,20 @@ assert.match(overview, /schedule\.last_outcome/); assert.match(overview, /schedule\.last_recovery_state/); assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/); assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/); +assert.match(overview, /defaultExportScopes: CampaignTransferScope\[\] = \["metadata", "template_config"\]/); +assert.match(overview, /hasScope\(auth, "campaigns:campaign:export"\)/); +assert.match(overview, /await exportCampaignPackage/); +assert.match(overview, /credentials, or transport secrets/); assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/); assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/); assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/); assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/); +assert.match(api, /\/api\/v1\/campaign-transfers\/imports\/preview/); +assert.match(api, /expected_package_sha256/); +assert.match(campaignList, /hasScope\(auth, "campaigns:campaign:import"\)/); +assert.match(campaignList, /setImportPreviewStale\(true\)/); +assert.match(campaignList, /Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state/); +assert.match(campaignList, /expected_package_sha256: importPreview\.package_sha256/); assert.match(recipients, /requestBulkActivation\(true\)/); assert.match(recipients, /requestBulkActivation\(false\)/); assert.match(recipients, /const count = inlineEntries\.filter/);