Implement governed hybrid campaign delivery
This commit is contained in:
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
@@ -14,11 +15,13 @@ from uuid import uuid4
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.object_storage import (
|
||||
StorageBackend,
|
||||
StorageBackendError,
|
||||
configured_storage_backend,
|
||||
)
|
||||
from govoplan_core.core.templates import TemplateRenderRequest
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
@@ -29,6 +32,7 @@ from govoplan_campaign.backend.db.models import (
|
||||
CampaignVersionWorkflowState,
|
||||
JobImapStatus,
|
||||
JobPostboxStatus,
|
||||
JobPrintStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
@@ -42,13 +46,23 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
||||
campaign_mail_profile_id,
|
||||
campaign_mail_resource_ids,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.campaign.validation import (
|
||||
SemanticIssue,
|
||||
Severity,
|
||||
validate_campaign_config,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||
from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
resolve_entry_postbox_targets,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.field_values import (
|
||||
effective_entry_field_values,
|
||||
)
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||
from govoplan_campaign.backend.messages.models import (
|
||||
MessageDraft,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import (
|
||||
create_execution_snapshot,
|
||||
profile_delivery_summary,
|
||||
@@ -62,6 +76,7 @@ from govoplan_campaign.backend.integrations import (
|
||||
files_integration,
|
||||
mail_integration,
|
||||
postbox_integration,
|
||||
templates_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
@@ -426,6 +441,129 @@ def load_version_config(session: Session, version_id: str):
|
||||
)
|
||||
|
||||
|
||||
def _campaign_uses_print(config: CampaignConfig) -> bool:
|
||||
entries = (
|
||||
config.entries.inline
|
||||
if config.entries.is_inline
|
||||
else [config.entries.defaults]
|
||||
)
|
||||
return any(
|
||||
entry is not None
|
||||
and entry.active
|
||||
and (entry.channel_policy or config.delivery.channel_policy).uses_print
|
||||
for entry in entries or []
|
||||
)
|
||||
|
||||
|
||||
def _print_template_available_fields(config: CampaignConfig) -> dict[str, str]:
|
||||
type_map = {
|
||||
"double": "number",
|
||||
"organization_unit": "string",
|
||||
"organization_function": "string",
|
||||
"password": "string",
|
||||
}
|
||||
fields: dict[str, str] = {
|
||||
"campaign.id": "string",
|
||||
"campaign.name": "string",
|
||||
"recipient_key": "string",
|
||||
"display_name": "string",
|
||||
"print_target.channel": "string",
|
||||
"print_target.target": "string",
|
||||
"print_target.target_key": "string",
|
||||
"distribution.list_id": "string",
|
||||
"distribution.list_revision": "integer",
|
||||
"distribution.expansion_hash": "string",
|
||||
}
|
||||
for field in config.fields:
|
||||
value_type = type_map.get(field.type.value, field.type.value)
|
||||
fields[field.name] = value_type
|
||||
fields[f"fields.{field.name}"] = value_type
|
||||
return fields
|
||||
|
||||
|
||||
def _print_template_compatibility_issues(
|
||||
session: Session,
|
||||
*,
|
||||
principal: ApiPrincipal | None,
|
||||
config: CampaignConfig,
|
||||
) -> list[SemanticIssue]:
|
||||
if not _campaign_uses_print(config) or not config.delivery.print.template_id:
|
||||
return []
|
||||
integration = templates_integration()
|
||||
if not integration.available:
|
||||
return [] # The semantic availability check already explains this.
|
||||
if principal is None:
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="print_template_principal_missing",
|
||||
message="Printable output must be validated by an authenticated Campaign actor.",
|
||||
path="/delivery/print/template_id",
|
||||
)
|
||||
]
|
||||
if not (
|
||||
principal.has("templates:template:render")
|
||||
or principal.has("templates:template:admin")
|
||||
):
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="print_template_render_forbidden",
|
||||
message="You may select this template but are not permitted to render printable output.",
|
||||
path="/delivery/print/template_id",
|
||||
)
|
||||
]
|
||||
if config.delivery.print.template_revision is None:
|
||||
return [] # The semantic validation report already requires an exact revision.
|
||||
try:
|
||||
template = integration.get_template(
|
||||
session,
|
||||
principal,
|
||||
template_id=config.delivery.print.template_id,
|
||||
revision=config.delivery.print.template_revision,
|
||||
)
|
||||
if template is None or template.revision is None:
|
||||
raise ValueError("Template revision not found.")
|
||||
if template.revision.published_at is None:
|
||||
raise ValueError("The selected template revision is not published.")
|
||||
result = integration.check_compatibility(
|
||||
session,
|
||||
principal,
|
||||
template_id=config.delivery.print.template_id,
|
||||
revision=config.delivery.print.template_revision,
|
||||
output_format=config.delivery.print.output_format,
|
||||
available_fields=_print_template_available_fields(config),
|
||||
)
|
||||
except (PermissionError, RuntimeError, ValueError) as exc:
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="print_template_unavailable",
|
||||
message=f"The selected printable template cannot be used: {exc}",
|
||||
path="/delivery/print/template_id",
|
||||
)
|
||||
]
|
||||
if result.compatible:
|
||||
return []
|
||||
details = [*result.missing_fields, *result.incompatible_fields]
|
||||
suffix = (
|
||||
f" Missing or incompatible fields: {', '.join(details)}."
|
||||
if details
|
||||
else ""
|
||||
)
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="print_template_incompatible",
|
||||
message=(
|
||||
"The selected printable template is not compatible with this Campaign."
|
||||
f"{suffix}"
|
||||
),
|
||||
path="/delivery/print/template_id",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def validate_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -433,6 +571,7 @@ def validate_campaign_version(
|
||||
version_id: str,
|
||||
check_files: bool = False,
|
||||
user_id: str | None = None,
|
||||
principal: ApiPrincipal | None = None,
|
||||
lock_on_success: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
@@ -483,6 +622,7 @@ def validate_campaign_version(
|
||||
campaign_file=prepared.path,
|
||||
check_files=True,
|
||||
postbox_available=postbox_integration().available,
|
||||
templates_available=templates_integration().available,
|
||||
)
|
||||
else:
|
||||
report = validate_campaign_config(
|
||||
@@ -490,7 +630,15 @@ def validate_campaign_version(
|
||||
campaign_file=snapshot_path,
|
||||
check_files=False,
|
||||
postbox_available=postbox_integration().available,
|
||||
templates_available=templates_integration().available,
|
||||
)
|
||||
report.issues.extend(
|
||||
_print_template_compatibility_issues(
|
||||
session,
|
||||
principal=principal,
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
report_json = report.model_dump(mode="json")
|
||||
report_json.update(
|
||||
{
|
||||
@@ -569,6 +717,8 @@ def _job_from_message(
|
||||
version_id: str,
|
||||
message: MessageDraft,
|
||||
resolved_postbox_targets: list[dict[str, Any]] | None = None,
|
||||
resolved_print_output: dict[str, Any] | None = None,
|
||||
delivery_provenance: dict[str, Any] | None = None,
|
||||
stored_eml: _StoredEmlArtifact | None = None,
|
||||
) -> CampaignJob:
|
||||
recipient_email = message.to[0].email if message.to else None
|
||||
@@ -576,6 +726,7 @@ def _job_from_message(
|
||||
if stored_eml is not None:
|
||||
eml_sha256 = stored_eml.sha256
|
||||
message_id_header = stored_eml.message_id_header
|
||||
channel_policy = DeliveryChannelPolicy(message.delivery_channel_policy)
|
||||
return CampaignJob(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
@@ -602,9 +753,16 @@ def _job_from_message(
|
||||
delivery_channel_policy=message.delivery_channel_policy,
|
||||
postbox_status=(
|
||||
JobPostboxStatus.PENDING.value
|
||||
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
|
||||
if channel_policy.uses_postbox
|
||||
else JobPostboxStatus.NOT_REQUESTED.value
|
||||
),
|
||||
print_status=(
|
||||
JobPrintStatus.READY.value
|
||||
if channel_policy.uses_print and resolved_print_output
|
||||
else JobPrintStatus.FAILED.value
|
||||
if channel_policy.uses_print
|
||||
else JobPrintStatus.NOT_REQUESTED.value
|
||||
),
|
||||
imap_status=message.imap_status.value
|
||||
if hasattr(message.imap_status, "value")
|
||||
else JobImapStatus.NOT_REQUESTED.value,
|
||||
@@ -621,7 +779,9 @@ def _job_from_message(
|
||||
for item in message.disposition_notification_to
|
||||
],
|
||||
},
|
||||
delivery_provenance=delivery_provenance or {},
|
||||
resolved_postbox_targets=resolved_postbox_targets or [],
|
||||
resolved_print_output=resolved_print_output,
|
||||
resolved_attachments=[
|
||||
files_integration().public_attachment_summary_payload(item)
|
||||
for item in message.attachments
|
||||
@@ -665,6 +825,192 @@ def _resolve_built_postbox_targets(
|
||||
return resolved_by_index
|
||||
|
||||
|
||||
def _print_render_item(
|
||||
config: CampaignConfig,
|
||||
entry: Any,
|
||||
) -> dict[str, Any]:
|
||||
fields = effective_entry_field_values(config, entry)
|
||||
distribution = dict(entry.distribution_source or {})
|
||||
target = entry.print_target.model_dump(mode="json") if entry.print_target else {}
|
||||
return {
|
||||
**fields,
|
||||
"fields": fields,
|
||||
"campaign": {
|
||||
"id": config.campaign.id,
|
||||
"name": config.campaign.name,
|
||||
"description": config.campaign.description,
|
||||
},
|
||||
"recipient_key": str(distribution.get("recipient_key") or entry.id or ""),
|
||||
"display_name": entry.name or "",
|
||||
"print_target": target,
|
||||
"distribution": distribution,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
payload = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _resolve_built_print_outputs(
|
||||
session: Session,
|
||||
*,
|
||||
storage: StorageBackend,
|
||||
tenant_id: str,
|
||||
build_id: str,
|
||||
version: CampaignVersion,
|
||||
principal: ApiPrincipal | None,
|
||||
config: CampaignConfig,
|
||||
built_messages: list[Any],
|
||||
entries_by_index: dict[int, Any],
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
printable: list[tuple[Any, Any, dict[str, Any]]] = []
|
||||
for built in built_messages:
|
||||
policy = DeliveryChannelPolicy(built.draft.delivery_channel_policy)
|
||||
if not policy.uses_print:
|
||||
continue
|
||||
entry = entries_by_index.get(built.draft.entry_index)
|
||||
if entry is None:
|
||||
raise CampaignPersistenceError(
|
||||
"A printable recipient row is missing from the Campaign input."
|
||||
)
|
||||
if built.draft.validation_status not in {
|
||||
MessageValidationStatus.READY,
|
||||
MessageValidationStatus.WARNING,
|
||||
}:
|
||||
continue
|
||||
printable.append((built, entry, _print_render_item(config, entry)))
|
||||
if not printable:
|
||||
return {}
|
||||
if principal is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Printable output requires the authenticated actor that validated the Campaign."
|
||||
)
|
||||
print_config = config.delivery.print
|
||||
if not print_config.template_id:
|
||||
raise CampaignPersistenceError(
|
||||
"Printable output requires a selected template."
|
||||
)
|
||||
items = tuple(item for _built, _entry, item in printable)
|
||||
routes = [
|
||||
{
|
||||
"entry_index": built.draft.entry_index,
|
||||
"entry_id": built.draft.entry_id,
|
||||
"channel_policy": built.draft.delivery_channel_policy,
|
||||
"print_target": item["print_target"],
|
||||
"distribution": item["distribution"],
|
||||
}
|
||||
for built, _entry, item in printable
|
||||
]
|
||||
input_snapshot = {
|
||||
"producer_module": "campaigns",
|
||||
"campaign_id": version.campaign_id,
|
||||
"campaign_version_id": version.id,
|
||||
"campaign_version_number": version.version_number,
|
||||
"actor_account_id": principal.account_id,
|
||||
"routes": routes,
|
||||
}
|
||||
render_key = _canonical_sha256(
|
||||
{
|
||||
"template_id": print_config.template_id,
|
||||
"template_revision": print_config.template_revision,
|
||||
"usage": print_config.usage,
|
||||
"output_format": print_config.output_format,
|
||||
"profile_id": print_config.profile_id,
|
||||
"items": items,
|
||||
"input_snapshot": input_snapshot,
|
||||
}
|
||||
)
|
||||
result = templates_integration().render(
|
||||
session,
|
||||
principal,
|
||||
request=TemplateRenderRequest(
|
||||
template_id=print_config.template_id,
|
||||
revision=print_config.template_revision,
|
||||
usage=print_config.usage,
|
||||
output_format=print_config.output_format,
|
||||
profile_id=print_config.profile_id,
|
||||
items=items,
|
||||
input_snapshot=input_snapshot,
|
||||
mode="final",
|
||||
idempotency_key=f"campaign:{version.id}:print:{render_key}",
|
||||
persist_to_files=print_config.persist_to_files,
|
||||
),
|
||||
)
|
||||
artifact = asdict(result.artifact) if result.artifact is not None else None
|
||||
if artifact and artifact.get("kind") == "bounded_download":
|
||||
if result.payload is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Templates returned a bounded print artifact without its payload."
|
||||
)
|
||||
storage_key = (
|
||||
f"campaign-artifacts/{tenant_id}/{version.campaign_id}/{version.id}/"
|
||||
f"{build_id}/print-{result.output_sha256}"
|
||||
)
|
||||
try:
|
||||
storage.put_bytes(
|
||||
storage_key,
|
||||
result.payload,
|
||||
content_type=result.content_type,
|
||||
)
|
||||
except StorageBackendError as exc:
|
||||
raise CampaignPersistenceError(
|
||||
f"Printable Campaign output could not be persisted: {exc}"
|
||||
) from exc
|
||||
artifact["storage_key"] = storage_key
|
||||
artifact["download_path"] = (
|
||||
f"/api/v1/campaigns/{version.campaign_id}/versions/{version.id}/"
|
||||
"print-output/download"
|
||||
)
|
||||
artifact["provenance"] = {
|
||||
**dict(artifact.get("provenance") or {}),
|
||||
"module": "campaigns",
|
||||
"source_module": "templates",
|
||||
"source_render_id": result.render_id,
|
||||
"bounded": True,
|
||||
}
|
||||
common = {
|
||||
"status": "ready",
|
||||
"render_id": result.render_id,
|
||||
"template_id": result.template_id,
|
||||
"template_revision_id": result.revision_id,
|
||||
"template_revision": result.revision,
|
||||
"template_hash": result.template_hash,
|
||||
"input_hash": result.input_hash,
|
||||
"renderer_version": result.renderer_version,
|
||||
"output_format": result.output_format,
|
||||
"output_sha256": result.output_sha256,
|
||||
"output_size_bytes": result.output_size_bytes,
|
||||
"item_count": result.item_count,
|
||||
"page_count": result.page_count,
|
||||
"artifact": artifact,
|
||||
"diagnostics": [dict(item) for item in result.diagnostics],
|
||||
"actor_account_id": principal.account_id,
|
||||
"render_idempotency_key": f"campaign:{version.id}:print:{render_key}",
|
||||
}
|
||||
resolved: dict[int, dict[str, Any]] = {}
|
||||
for item_index, (built, entry, _item) in enumerate(printable):
|
||||
resolved[built.draft.entry_index] = {
|
||||
**common,
|
||||
"item_index": item_index,
|
||||
"recipient_key": str(
|
||||
entry.distribution_source.get("recipient_key")
|
||||
or entry.id
|
||||
or built.draft.entry_index
|
||||
),
|
||||
"route": entry.print_target.model_dump(mode="json")
|
||||
if entry.print_target
|
||||
else None,
|
||||
}
|
||||
return resolved
|
||||
|
||||
|
||||
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
for message_payload, message in zip(
|
||||
@@ -701,18 +1047,26 @@ def _replace_version_jobs(
|
||||
version_id: str,
|
||||
built_messages: list[Any],
|
||||
postbox_targets_by_index: dict[int, list[dict[str, Any]]],
|
||||
print_outputs_by_index: dict[int, dict[str, Any]],
|
||||
delivery_provenance_by_index: dict[int, dict[str, Any]],
|
||||
stored_eml_by_index: dict[int, _StoredEmlArtifact],
|
||||
) -> tuple[list[tuple[CampaignJob, MessageDraft]], list[str]]:
|
||||
old_storage_keys = [
|
||||
str(key)
|
||||
for (key,) in session.query(CampaignJob.eml_storage_key)
|
||||
.filter(
|
||||
CampaignJob.campaign_version_id == version_id,
|
||||
CampaignJob.eml_storage_key.is_not(None),
|
||||
old_storage_keys: list[str] = []
|
||||
old_job_artifacts = (
|
||||
session.query(
|
||||
CampaignJob.eml_storage_key,
|
||||
CampaignJob.resolved_print_output,
|
||||
)
|
||||
.filter(CampaignJob.campaign_version_id == version_id)
|
||||
.all()
|
||||
if key
|
||||
]
|
||||
)
|
||||
for eml_storage_key, print_output in old_job_artifacts:
|
||||
if eml_storage_key:
|
||||
old_storage_keys.append(str(eml_storage_key))
|
||||
if isinstance(print_output, dict):
|
||||
artifact = print_output.get("artifact")
|
||||
if isinstance(artifact, dict) and artifact.get("storage_key"):
|
||||
old_storage_keys.append(str(artifact["storage_key"]))
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version_id,
|
||||
CampaignIssue.job_id.is_not(None),
|
||||
@@ -732,6 +1086,13 @@ def _replace_version_jobs(
|
||||
resolved_postbox_targets=postbox_targets_by_index.get(
|
||||
built.draft.entry_index, []
|
||||
),
|
||||
resolved_print_output=print_outputs_by_index.get(
|
||||
built.draft.entry_index
|
||||
),
|
||||
delivery_provenance=delivery_provenance_by_index.get(
|
||||
built.draft.entry_index,
|
||||
{},
|
||||
),
|
||||
stored_eml=stored_eml_by_index.get(built.draft.entry_index),
|
||||
)
|
||||
session.add(job)
|
||||
@@ -851,6 +1212,7 @@ def build_campaign_version(
|
||||
version_id: str,
|
||||
write_eml: bool = True,
|
||||
user_id: str | None = None,
|
||||
principal: ApiPrincipal | None = None,
|
||||
) -> dict[str, Any]:
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
@@ -911,6 +1273,10 @@ def build_campaign_version(
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
delivery_provenance_by_index = {
|
||||
index: dict(entry.distribution_source or {})
|
||||
for index, entry in entries_by_index.items()
|
||||
}
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
@@ -918,18 +1284,67 @@ def build_campaign_version(
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
stored_eml_by_index = _persist_built_eml_artifacts(
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
build_id=build_id,
|
||||
built_messages=result.built_messages,
|
||||
resolved_print_outputs_by_index = _resolve_built_print_outputs(
|
||||
session,
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
build_id=build_id,
|
||||
version=version,
|
||||
principal=principal,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
new_print_storage_keys = sorted(
|
||||
{
|
||||
str(artifact["storage_key"])
|
||||
for output in resolved_print_outputs_by_index.values()
|
||||
if isinstance(output, dict)
|
||||
for artifact in [output.get("artifact")]
|
||||
if isinstance(artifact, dict) and artifact.get("storage_key")
|
||||
}
|
||||
)
|
||||
new_storage_keys = [item.storage_key for item in stored_eml_by_index.values()]
|
||||
try:
|
||||
stored_eml_by_index = _persist_built_eml_artifacts(
|
||||
storage=storage,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
build_id=build_id,
|
||||
built_messages=result.built_messages,
|
||||
)
|
||||
except Exception:
|
||||
_delete_storage_keys(storage, new_print_storage_keys)
|
||||
raise
|
||||
new_storage_keys = [
|
||||
*new_print_storage_keys,
|
||||
*(item.storage_key for item in stored_eml_by_index.values()),
|
||||
]
|
||||
try:
|
||||
report_json = _campaign_build_report(result, files)
|
||||
report_json["built_by_user_id"] = user_id
|
||||
if resolved_print_outputs_by_index:
|
||||
first_output = next(iter(resolved_print_outputs_by_index.values()))
|
||||
report_json["print_output"] = {
|
||||
key: first_output.get(key)
|
||||
for key in (
|
||||
"render_id",
|
||||
"template_id",
|
||||
"template_revision_id",
|
||||
"template_revision",
|
||||
"template_hash",
|
||||
"input_hash",
|
||||
"renderer_version",
|
||||
"output_format",
|
||||
"output_sha256",
|
||||
"output_size_bytes",
|
||||
"item_count",
|
||||
"page_count",
|
||||
"artifact",
|
||||
"diagnostics",
|
||||
"actor_account_id",
|
||||
)
|
||||
}
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
@@ -943,6 +1358,8 @@ def build_campaign_version(
|
||||
version_id=version.id,
|
||||
built_messages=result.built_messages,
|
||||
postbox_targets_by_index=resolved_postbox_targets_by_index,
|
||||
print_outputs_by_index=resolved_print_outputs_by_index,
|
||||
delivery_provenance_by_index=delivery_provenance_by_index,
|
||||
stored_eml_by_index=stored_eml_by_index,
|
||||
)
|
||||
jobs = [job for job, _message in job_build_pairs]
|
||||
|
||||
Reference in New Issue
Block a user