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",
|
||||
]
|
||||
Reference in New Issue
Block a user