feat(campaigns): add portable campaign transfers
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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.")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user