1676 lines
59 KiB
Python
1676 lines
59 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import hashlib
|
|
import json
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import UTC, datetime
|
|
from email import policy
|
|
from email.parser import BytesParser
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
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,
|
|
CampaignIssue,
|
|
CampaignJob,
|
|
CampaignStatus,
|
|
CampaignVersion,
|
|
CampaignVersionWorkflowState,
|
|
JobImapStatus,
|
|
JobPostboxStatus,
|
|
JobPrintStatus,
|
|
JobQueueStatus,
|
|
JobSendStatus,
|
|
JobValidationStatus,
|
|
)
|
|
from govoplan_campaign.backend.campaign.loader import (
|
|
load_campaign_json,
|
|
validate_against_schema,
|
|
)
|
|
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
|
|
assert_campaign_uses_mail_profile_reference,
|
|
campaign_mail_profile_id,
|
|
campaign_mail_resource_ids,
|
|
)
|
|
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.campaign.template_values import build_template_values
|
|
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
|
from govoplan_campaign.backend.messages.models import (
|
|
MessageDraft,
|
|
MessageValidationStatus,
|
|
)
|
|
from govoplan_campaign.backend.sending.execution import (
|
|
create_execution_snapshot,
|
|
profile_delivery_summary,
|
|
)
|
|
from govoplan_campaign.backend.campaign.models import (
|
|
CampaignConfig,
|
|
DeliveryChannelPolicy,
|
|
SendStatus,
|
|
)
|
|
from govoplan_campaign.backend.integrations import (
|
|
CalendarInvitationUnavailable,
|
|
calendar_integration,
|
|
files_integration,
|
|
mail_integration,
|
|
postbox_integration,
|
|
templates_integration,
|
|
)
|
|
from govoplan_campaign.backend.template_rendering import render_template
|
|
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
|
from govoplan_campaign.backend.runtime import get_settings
|
|
|
|
|
|
class CampaignPersistenceError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _StoredEmlArtifact:
|
|
storage_key: str
|
|
size_bytes: int
|
|
sha256: str
|
|
message_id_header: str | None
|
|
|
|
|
|
def load_campaign_config_from_json(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
raw_json: dict[str, Any],
|
|
campaign_id: str | None = None,
|
|
owner_user_id: str | None = None,
|
|
owner_group_id: str | None = None,
|
|
) -> CampaignConfig:
|
|
# Validate the persisted Campaign-to-Mail contract before asking Mail for a
|
|
# non-secret capability summary. Campaign never receives resolved transport
|
|
# settings, account identities, or credentials.
|
|
assert_campaign_uses_mail_profile_reference(raw_json)
|
|
validate_against_schema(raw_json)
|
|
materialized = copy.deepcopy(raw_json)
|
|
profile_id = campaign_mail_profile_id(raw_json)
|
|
if profile_id:
|
|
references = campaign_mail_resource_ids(raw_json)
|
|
summary = mail_integration().campaign_profile_delivery_summary(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign_id,
|
|
profile_id=profile_id,
|
|
owner_user_id=owner_user_id,
|
|
owner_group_id=owner_group_id,
|
|
smtp_server_id=references["smtp_server_id"],
|
|
smtp_credential_id=references["smtp_credential_id"],
|
|
imap_server_id=references["imap_server_id"],
|
|
imap_credential_id=references["imap_credential_id"],
|
|
)
|
|
materialized.setdefault("server", {})["profile_capabilities"] = {
|
|
"smtp_available": bool(summary.get("smtp_available")),
|
|
"imap_available": bool(summary.get("imap_available")),
|
|
}
|
|
return CampaignConfig.model_validate(materialized)
|
|
|
|
|
|
def _campaign_reference_path(version: CampaignVersion) -> Path:
|
|
"""Return a path anchor without persisting a second configuration copy."""
|
|
|
|
source_base_path = str(version.source_base_path or "").strip()
|
|
base = (
|
|
Path(source_base_path).expanduser().resolve()
|
|
if source_base_path
|
|
else Path.cwd()
|
|
)
|
|
return base / "campaign.json"
|
|
|
|
|
|
def _object_storage() -> StorageBackend:
|
|
return configured_storage_backend(get_settings() or core_settings)
|
|
|
|
|
|
def _persist_built_eml_artifacts(
|
|
*,
|
|
storage: StorageBackend,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str,
|
|
build_id: str,
|
|
built_messages: list[Any],
|
|
) -> dict[int, _StoredEmlArtifact]:
|
|
artifacts: dict[int, _StoredEmlArtifact] = {}
|
|
try:
|
|
for built in built_messages:
|
|
message = built.draft
|
|
if not message.eml_path:
|
|
continue
|
|
path = Path(message.eml_path)
|
|
if not path.is_file():
|
|
raise CampaignPersistenceError(
|
|
f"Generated EML evidence is missing for entry {message.entry_index}"
|
|
)
|
|
payload = path.read_bytes()
|
|
digest = hashlib.sha256(payload).hexdigest()
|
|
parsed = BytesParser(policy=policy.default).parsebytes(payload)
|
|
message_id = parsed.get("Message-ID")
|
|
storage_key = (
|
|
f"campaign-artifacts/{tenant_id}/{campaign_id}/{version_id}/"
|
|
f"{build_id}/{message.entry_index:08d}-{digest}.eml"
|
|
)
|
|
try:
|
|
storage.put_bytes(
|
|
storage_key,
|
|
payload,
|
|
content_type="message/rfc822",
|
|
)
|
|
except StorageBackendError as exc:
|
|
raise CampaignPersistenceError(
|
|
f"Generated EML evidence could not be persisted: {exc}"
|
|
) from exc
|
|
artifacts[message.entry_index] = _StoredEmlArtifact(
|
|
storage_key=storage_key,
|
|
size_bytes=len(payload),
|
|
sha256=digest,
|
|
message_id_header=str(message_id) if message_id else None,
|
|
)
|
|
message.eml_path = None
|
|
message.eml_size_bytes = len(payload)
|
|
except Exception:
|
|
_delete_storage_keys(
|
|
storage,
|
|
[artifact.storage_key for artifact in artifacts.values()],
|
|
)
|
|
raise
|
|
return artifacts
|
|
|
|
|
|
def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None:
|
|
for key in keys:
|
|
try:
|
|
storage.delete(key)
|
|
except StorageBackendError:
|
|
# A committed build remains authoritative. Reconciliation can
|
|
# remove an orphaned superseded object later.
|
|
continue
|
|
|
|
|
|
def _next_version_number(session: Session, campaign_id: str) -> int:
|
|
current = (
|
|
session.query(func.max(CampaignVersion.version_number))
|
|
.filter(CampaignVersion.campaign_id == campaign_id)
|
|
.scalar()
|
|
)
|
|
return int(current or 0) + 1
|
|
|
|
|
|
def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None:
|
|
if not value or base_path is None:
|
|
return value
|
|
path = Path(value).expanduser()
|
|
if path.is_absolute():
|
|
return str(path)
|
|
return str((base_path / path).resolve())
|
|
|
|
|
|
def normalize_campaign_paths(
|
|
raw_json: dict[str, Any], source_base_path: str | Path | None
|
|
) -> dict[str, Any]:
|
|
"""Resolve paths for an explicitly trusted, file-oriented import.
|
|
|
|
The CLI naturally resolves relative paths against the campaign.json file.
|
|
Once the campaign is stored in the database, its JSON is authoritative.
|
|
To keep existing file-based campaigns working, relative file paths are
|
|
normalized to absolute paths at import time when a source_base_path is
|
|
known. HTTP/API callers are rejected by
|
|
``assert_server_safe_campaign_paths`` before they can reach this helper.
|
|
"""
|
|
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
|
|
data = copy.deepcopy(raw_json)
|
|
|
|
template_source = (
|
|
data.get("template", {}).get("source")
|
|
if isinstance(data.get("template"), dict)
|
|
else None
|
|
)
|
|
if isinstance(template_source, dict):
|
|
for key in ("subject_path", "text_path", "html_path"):
|
|
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
|
|
|
|
entries_source = (
|
|
data.get("entries", {}).get("source")
|
|
if isinstance(data.get("entries"), dict)
|
|
else None
|
|
)
|
|
if isinstance(entries_source, dict):
|
|
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
|
|
|
|
attachments = data.get("attachments")
|
|
if isinstance(attachments, dict):
|
|
attachments["base_path"] = (
|
|
_resolve_runtime_path(base, attachments.get("base_path")) or "."
|
|
)
|
|
|
|
return data
|
|
|
|
|
|
def create_campaign_version_from_json(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
user_id: str | None,
|
|
raw_json: dict[str, Any],
|
|
source_filename: str | None = None,
|
|
source_base_path: str | None = None,
|
|
commit: bool = True,
|
|
) -> tuple[Campaign, CampaignVersion]:
|
|
assert_server_safe_campaign_paths(
|
|
raw_json,
|
|
source_filename=source_filename,
|
|
source_base_path=source_base_path,
|
|
managed_files_available=files_integration().available,
|
|
)
|
|
if source_base_path is None and source_filename:
|
|
source_path = Path(source_filename).expanduser()
|
|
source_base_path = str(
|
|
source_path.parent if source_path.suffix else source_path
|
|
)
|
|
|
|
runtime_json = normalize_campaign_paths(raw_json, source_base_path)
|
|
|
|
config = load_campaign_config_from_json(
|
|
session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id
|
|
)
|
|
|
|
campaign = (
|
|
session.query(Campaign)
|
|
.filter(
|
|
Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id
|
|
)
|
|
.one_or_none()
|
|
)
|
|
if campaign is None:
|
|
campaign = Campaign(
|
|
tenant_id=tenant_id,
|
|
created_by_user_id=user_id,
|
|
owner_user_id=user_id,
|
|
external_id=config.campaign.id,
|
|
name=config.campaign.name,
|
|
description=config.campaign.description,
|
|
status=CampaignStatus.DRAFT.value,
|
|
)
|
|
session.add(campaign)
|
|
session.flush()
|
|
else:
|
|
current = (
|
|
session.get(CampaignVersion, campaign.current_version_id)
|
|
if campaign.current_version_id
|
|
else None
|
|
)
|
|
if current and not _version_is_audit_safe_snapshot(current):
|
|
raise CampaignPersistenceError(
|
|
f"Campaign already has active working version #{current.version_number}. "
|
|
"Continue editing or unlock that version instead of importing a parallel draft."
|
|
)
|
|
campaign.name = config.campaign.name
|
|
campaign.description = config.campaign.description
|
|
|
|
version = CampaignVersion(
|
|
campaign_id=campaign.id,
|
|
version_number=_next_version_number(session, campaign.id),
|
|
raw_json=runtime_json,
|
|
schema_version=raw_json.get("version", "1.0"),
|
|
source_filename=source_filename,
|
|
source_base_path=source_base_path,
|
|
)
|
|
session.add(version)
|
|
session.flush()
|
|
campaign.current_version_id = version.id
|
|
session.add(campaign)
|
|
if commit:
|
|
session.commit()
|
|
else:
|
|
session.flush()
|
|
return campaign, version
|
|
|
|
|
|
def _version_user_lock_state(version: CampaignVersion) -> str | None:
|
|
state = getattr(version, "user_lock_state", None)
|
|
if state in {"temporary", "permanent"}:
|
|
return state
|
|
return "permanent" if version.published_at else None
|
|
|
|
|
|
def _version_is_user_locked(version: CampaignVersion) -> bool:
|
|
return _version_user_lock_state(version) is not None
|
|
|
|
|
|
def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool:
|
|
return _version_user_lock_state(
|
|
version
|
|
) == "permanent" or version.workflow_state in {
|
|
CampaignVersionWorkflowState.QUEUED.value,
|
|
CampaignVersionWorkflowState.SENDING.value,
|
|
CampaignVersionWorkflowState.COMPLETED.value,
|
|
CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value,
|
|
CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value,
|
|
CampaignVersionWorkflowState.FAILED.value,
|
|
CampaignVersionWorkflowState.CANCELLED.value,
|
|
CampaignVersionWorkflowState.ARCHIVED.value,
|
|
}
|
|
|
|
|
|
def _ensure_current_campaign_version(
|
|
campaign: Campaign, version: CampaignVersion, *, action: str
|
|
) -> None:
|
|
if campaign.current_version_id != version.id:
|
|
raise CampaignPersistenceError(
|
|
f"Historical campaign versions are read-only and cannot be used to {action}. "
|
|
"Open the current working version instead."
|
|
)
|
|
|
|
|
|
def _version_is_validated_and_locked(version: CampaignVersion) -> bool:
|
|
validation_summary = (
|
|
version.validation_summary
|
|
if isinstance(version.validation_summary, dict)
|
|
else {}
|
|
)
|
|
return bool(
|
|
version.locked_at
|
|
and validation_summary.get("ok") is True
|
|
and not _version_is_user_locked(version)
|
|
)
|
|
|
|
|
|
def _ensure_version_validated_and_locked(version: CampaignVersion) -> None:
|
|
state = _version_user_lock_state(version)
|
|
if state == "temporary":
|
|
raise CampaignPersistenceError(
|
|
"This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending."
|
|
)
|
|
if state == "permanent":
|
|
raise CampaignPersistenceError(
|
|
"This version is permanently user-locked. Create an editable copy instead."
|
|
)
|
|
if not _version_is_validated_and_locked(version):
|
|
raise CampaignPersistenceError(
|
|
"Campaign version must be validated and locked before building, queueing, dry-run or sending."
|
|
)
|
|
|
|
|
|
def load_version_config(session: Session, version_id: str):
|
|
version = session.get(CampaignVersion, version_id)
|
|
if not version:
|
|
raise CampaignPersistenceError(f"Campaign version not found: {version_id}")
|
|
campaign = session.get(Campaign, version.campaign_id)
|
|
if not campaign:
|
|
raise CampaignPersistenceError(f"Campaign not found for version: {version_id}")
|
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
|
assert_server_safe_campaign_paths(
|
|
raw_json,
|
|
managed_files_available=files_integration().available,
|
|
)
|
|
path = _campaign_reference_path(version)
|
|
return (
|
|
version,
|
|
path,
|
|
load_campaign_config_from_json(
|
|
session,
|
|
tenant_id=campaign.tenant_id,
|
|
raw_json=raw_json,
|
|
campaign_id=campaign.id,
|
|
),
|
|
)
|
|
|
|
|
|
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 _calendar_invitation_compatibility_issues(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
principal: ApiPrincipal | None,
|
|
config: CampaignConfig,
|
|
) -> list[SemanticIssue]:
|
|
invitation = config.delivery.calendar_invitation
|
|
if not invitation.enabled or not invitation.calendar_id:
|
|
return []
|
|
integration = calendar_integration()
|
|
if not integration.available:
|
|
return []
|
|
if principal is None:
|
|
return [
|
|
SemanticIssue(
|
|
severity=Severity.ERROR,
|
|
code="calendar_invitation_principal_missing",
|
|
message="Calendar invitations must be validated by an authenticated Campaign actor.",
|
|
path="/delivery/calendar_invitation/calendar_id",
|
|
)
|
|
]
|
|
if not any(
|
|
principal.has(scope)
|
|
for scope in (
|
|
"calendar:calendar:read",
|
|
"calendar:calendar:write",
|
|
"calendar:calendar:admin",
|
|
)
|
|
):
|
|
return [
|
|
SemanticIssue(
|
|
severity=Severity.ERROR,
|
|
code="calendar_invitation_calendar_forbidden",
|
|
message="You are not permitted to select a calendar for invitation tracking.",
|
|
path="/delivery/calendar_invitation/calendar_id",
|
|
)
|
|
]
|
|
calendars = integration.list_calendars(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
user_id=str(getattr(principal.user, "id", "")) or None,
|
|
group_ids=tuple(principal.group_ids),
|
|
can_admin=principal.has("calendar:calendar:admin"),
|
|
)
|
|
selected = next(
|
|
(item for item in calendars if item.id == invitation.calendar_id),
|
|
None,
|
|
)
|
|
if selected is None:
|
|
message = "The selected invitation calendar is not visible or no longer exists."
|
|
elif not selected.writable:
|
|
message = "The selected invitation calendar is read-only."
|
|
else:
|
|
return []
|
|
return [
|
|
SemanticIssue(
|
|
severity=Severity.ERROR,
|
|
code="calendar_invitation_calendar_unavailable",
|
|
message=message,
|
|
path="/delivery/calendar_invitation/calendar_id",
|
|
)
|
|
]
|
|
|
|
|
|
def validate_campaign_version(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
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)
|
|
campaign = session.get(Campaign, version.campaign_id)
|
|
if not campaign or campaign.tenant_id != tenant_id:
|
|
raise CampaignPersistenceError(
|
|
"Campaign version is not accessible for this tenant"
|
|
)
|
|
_ensure_current_campaign_version(campaign, version, action="validate")
|
|
if _version_is_user_locked(version) or version.workflow_state in {
|
|
CampaignVersionWorkflowState.QUEUED.value,
|
|
CampaignVersionWorkflowState.SENDING.value,
|
|
CampaignVersionWorkflowState.COMPLETED.value,
|
|
CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value,
|
|
CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value,
|
|
CampaignVersionWorkflowState.FAILED.value,
|
|
CampaignVersionWorkflowState.CANCELLED.value,
|
|
CampaignVersionWorkflowState.ARCHIVED.value,
|
|
}:
|
|
lock_label = (
|
|
"temporarily user-locked"
|
|
if _version_user_lock_state(version) == "temporary"
|
|
else "permanently locked/final"
|
|
)
|
|
raise CampaignPersistenceError(
|
|
f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead."
|
|
)
|
|
|
|
if check_files:
|
|
files = files_integration()
|
|
with files.prepared_campaign_snapshot(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
|
include_bytes=False,
|
|
prefix="govoplan-managed-validate-",
|
|
) as prepared:
|
|
managed_raw = load_campaign_json(prepared.path)
|
|
managed_config = load_campaign_config_from_json(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
raw_json=managed_raw,
|
|
campaign_id=campaign.id,
|
|
)
|
|
report = validate_campaign_config(
|
|
managed_config,
|
|
campaign_file=prepared.path,
|
|
check_files=True,
|
|
postbox_available=postbox_integration().available,
|
|
templates_available=templates_integration().available,
|
|
calendar_available=calendar_integration().available,
|
|
)
|
|
else:
|
|
report = validate_campaign_config(
|
|
config,
|
|
campaign_file=snapshot_path,
|
|
check_files=False,
|
|
postbox_available=postbox_integration().available,
|
|
templates_available=templates_integration().available,
|
|
calendar_available=calendar_integration().available,
|
|
)
|
|
report.issues.extend(
|
|
_print_template_compatibility_issues(
|
|
session,
|
|
principal=principal,
|
|
config=config,
|
|
)
|
|
)
|
|
report.issues.extend(
|
|
_calendar_invitation_compatibility_issues(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
principal=principal,
|
|
config=config,
|
|
)
|
|
)
|
|
report_json = report.model_dump(mode="json")
|
|
report_json.update(
|
|
{
|
|
"ok": report.ok,
|
|
"error_count": report.error_count,
|
|
"warning_count": report.warning_count,
|
|
"validated_at": datetime.now(UTC).isoformat(),
|
|
"validated_by_user_id": user_id,
|
|
}
|
|
)
|
|
version.validation_summary = report_json
|
|
|
|
# Replace version-level semantic issues from previous validations.
|
|
(
|
|
session.query(CampaignIssue)
|
|
.filter(
|
|
CampaignIssue.campaign_version_id == version.id,
|
|
CampaignIssue.job_id.is_(None),
|
|
)
|
|
.delete(synchronize_session=False)
|
|
)
|
|
for issue in report.issues:
|
|
session.add(
|
|
CampaignIssue(
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
campaign_version_id=version.id,
|
|
severity=issue.severity.value,
|
|
code=issue.code,
|
|
message=issue.message,
|
|
source=issue.path,
|
|
)
|
|
)
|
|
|
|
campaign.status = (
|
|
CampaignStatus.VALIDATED.value
|
|
if report.ok
|
|
else CampaignStatus.NEEDS_REVIEW.value
|
|
)
|
|
if report.ok:
|
|
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
|
version.is_complete = True
|
|
if lock_on_success and version.locked_at is None:
|
|
version.locked_at = datetime.now(UTC)
|
|
version.locked_by_user_id = user_id
|
|
else:
|
|
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
|
session.add(version)
|
|
session.add(campaign)
|
|
session.commit()
|
|
return report_json
|
|
|
|
|
|
def _job_validation_status(value: str) -> str:
|
|
allowed = {item.value for item in JobValidationStatus}
|
|
return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value
|
|
|
|
|
|
def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]:
|
|
if not eml_path:
|
|
return None, None
|
|
path = Path(eml_path)
|
|
if not path.exists():
|
|
return None, None
|
|
payload = path.read_bytes()
|
|
message_id = (
|
|
BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID")
|
|
)
|
|
return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None
|
|
|
|
|
|
def _job_from_message(
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
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
|
|
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
|
|
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,
|
|
campaign_version_id=version_id,
|
|
entry_index=message.entry_index,
|
|
entry_id=message.entry_id,
|
|
recipient_email=recipient_email,
|
|
subject=message.subject,
|
|
message_id_header=message_id_header,
|
|
eml_storage_key=stored_eml.storage_key if stored_eml else None,
|
|
eml_local_path=message.eml_path if stored_eml is None else None,
|
|
eml_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes,
|
|
eml_sha256=eml_sha256,
|
|
build_status=message.build_status.value
|
|
if hasattr(message.build_status, "value")
|
|
else str(message.build_status),
|
|
validation_status=_job_validation_status(message.validation_status.value),
|
|
queue_status=JobQueueStatus.DRAFT.value,
|
|
send_status=(
|
|
JobSendStatus.SKIPPED.value
|
|
if message.send_status == SendStatus.SKIPPED
|
|
else JobSendStatus.NOT_QUEUED.value
|
|
),
|
|
delivery_channel_policy=message.delivery_channel_policy,
|
|
postbox_status=(
|
|
JobPostboxStatus.PENDING.value
|
|
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,
|
|
resolved_recipients={
|
|
"from": message.from_.model_dump(mode="json") if message.from_ else None,
|
|
"from_all": [item.model_dump(mode="json") for item in message.from_all],
|
|
"to": [item.model_dump(mode="json") for item in message.to],
|
|
"cc": [item.model_dump(mode="json") for item in message.cc],
|
|
"bcc": [item.model_dump(mode="json") for item in message.bcc],
|
|
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
|
|
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
|
"disposition_notification_to": [
|
|
item.model_dump(mode="json")
|
|
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
|
|
],
|
|
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
|
last_error="; ".join(
|
|
issue.message for issue in message.issues if issue.severity == "error"
|
|
)
|
|
or None,
|
|
)
|
|
|
|
|
|
def _resolve_built_postbox_targets(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
config: CampaignConfig,
|
|
built_messages: list[Any],
|
|
entries_by_index: dict[int, Any],
|
|
) -> dict[int, list[dict[str, Any]]]:
|
|
resolved_by_index: dict[int, list[dict[str, Any]]] = {}
|
|
for built in built_messages:
|
|
if not DeliveryChannelPolicy(built.draft.delivery_channel_policy).uses_postbox:
|
|
continue
|
|
entry = entries_by_index.get(built.draft.entry_index)
|
|
if entry is None:
|
|
raise CampaignPersistenceError(
|
|
"Built recipient row is missing from the campaign input."
|
|
)
|
|
resolved, issues, validation_status = resolve_entry_postbox_targets(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
config=config,
|
|
entry=entry,
|
|
validation_status=built.draft.validation_status,
|
|
materialize=True,
|
|
)
|
|
built.draft.issues.extend(issues)
|
|
built.draft.validation_status = validation_status
|
|
resolved_by_index[built.draft.entry_index] = resolved
|
|
return resolved_by_index
|
|
|
|
|
|
def _render_invitation_value(
|
|
template: str | None,
|
|
values: dict[str, Any],
|
|
*,
|
|
fallback: str | None = None,
|
|
) -> str | None:
|
|
if template is None:
|
|
return fallback
|
|
rendered = render_template(template, values, keep_missing=False).strip()
|
|
return rendered or fallback
|
|
|
|
|
|
def _invitation_datetime(value: str, *, timezone_id: str | None) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise CampaignPersistenceError(
|
|
f"Calendar invitation date is not valid ISO 8601: {value!r}"
|
|
) from exc
|
|
if parsed.tzinfo is not None:
|
|
return parsed
|
|
try:
|
|
return parsed.replace(tzinfo=ZoneInfo(timezone_id or "UTC"))
|
|
except ZoneInfoNotFoundError as exc:
|
|
raise CampaignPersistenceError(
|
|
f"Calendar invitation timezone is unknown: {timezone_id}"
|
|
) from exc
|
|
|
|
|
|
def _calendar_invitation_request_payload(
|
|
*,
|
|
version: CampaignVersion,
|
|
config: CampaignConfig,
|
|
built: Any,
|
|
entry: Any,
|
|
user_id: str | None,
|
|
) -> dict[str, Any]:
|
|
invitation = config.delivery.calendar_invitation
|
|
values = build_template_values(config, entry)
|
|
start_text = _render_invitation_value(
|
|
invitation.start_at_template,
|
|
values,
|
|
)
|
|
if not start_text:
|
|
raise CampaignPersistenceError(
|
|
"Calendar invitation start resolved to an empty value."
|
|
)
|
|
end_text = _render_invitation_value(invitation.end_at_template, values)
|
|
start_at = _invitation_datetime(start_text, timezone_id=invitation.timezone)
|
|
end_at = (
|
|
_invitation_datetime(end_text, timezone_id=invitation.timezone)
|
|
if end_text
|
|
else None
|
|
)
|
|
if end_at is not None and end_at <= start_at:
|
|
raise CampaignPersistenceError(
|
|
"Calendar invitation end must be after its start."
|
|
)
|
|
attendee_by_address = {
|
|
item.email.strip().casefold(): item
|
|
for item in built.draft.to
|
|
if item.email.strip()
|
|
}
|
|
if not attendee_by_address:
|
|
raise CampaignPersistenceError(
|
|
"Calendar invitation has no effective To recipient."
|
|
)
|
|
sender = built.draft.from_
|
|
organizer = None
|
|
if sender is not None and sender.email.strip():
|
|
params: dict[str, list[str]] = {}
|
|
if sender.name:
|
|
params["CN"] = [sender.name]
|
|
organizer = {
|
|
"value": f"mailto:{sender.email.strip()}",
|
|
"params": params,
|
|
}
|
|
entry_key = str(built.draft.entry_id or built.draft.entry_index)
|
|
correlation_digest = hashlib.sha256(
|
|
f"{version.id}:{entry_key}".encode("utf-8")
|
|
).hexdigest()[:32]
|
|
return {
|
|
"correlation_id": f"campaign:{version.id}:{correlation_digest}",
|
|
"source_resource_id": version.id,
|
|
"calendar_id": invitation.calendar_id,
|
|
"summary": _render_invitation_value(
|
|
invitation.summary_template,
|
|
values,
|
|
fallback=built.draft.subject or config.campaign.name,
|
|
),
|
|
"description": _render_invitation_value(
|
|
invitation.description_template,
|
|
values,
|
|
),
|
|
"location": _render_invitation_value(
|
|
invitation.location_template,
|
|
values,
|
|
),
|
|
"start_at": start_at.isoformat(),
|
|
"end_at": end_at.isoformat() if end_at else None,
|
|
"timezone": invitation.timezone,
|
|
"organizer": organizer,
|
|
"attendees": [
|
|
{
|
|
"address": item.email.strip(),
|
|
"name": item.name,
|
|
"role": "REQ-PARTICIPANT",
|
|
"participation_status": "NEEDS-ACTION",
|
|
"rsvp": True,
|
|
}
|
|
for item in attendee_by_address.values()
|
|
],
|
|
"classification": invitation.classification,
|
|
"categories": list(invitation.categories),
|
|
"metadata": {
|
|
"campaign_id": version.campaign_id,
|
|
"campaign_version_id": version.id,
|
|
"entry_id": built.draft.entry_id,
|
|
"entry_index": built.draft.entry_index,
|
|
"prepared_by_user_id": user_id,
|
|
},
|
|
}
|
|
|
|
|
|
def _prepare_built_calendar_invitations(
|
|
*,
|
|
version: CampaignVersion,
|
|
config: CampaignConfig,
|
|
built_messages: list[Any],
|
|
entries_by_index: dict[int, Any],
|
|
delivery_provenance_by_index: dict[int, dict[str, Any]],
|
|
user_id: str | None,
|
|
) -> None:
|
|
if not config.delivery.calendar_invitation.enabled:
|
|
return
|
|
integration = calendar_integration()
|
|
if not integration.available:
|
|
raise CampaignPersistenceError(
|
|
"Calendar invitation capability became unavailable after validation."
|
|
)
|
|
for built in built_messages:
|
|
if built.mime is None or not built.draft.is_queueable:
|
|
continue
|
|
entry = entries_by_index.get(built.draft.entry_index)
|
|
if entry is None:
|
|
raise CampaignPersistenceError(
|
|
"Built invitation recipient is missing from the campaign input."
|
|
)
|
|
request_payload = _calendar_invitation_request_payload(
|
|
version=version,
|
|
config=config,
|
|
built=built,
|
|
entry=entry,
|
|
user_id=user_id,
|
|
)
|
|
request = integration.request_from_payload(request_payload)
|
|
try:
|
|
icalendar = integration.render_invitation(request)
|
|
except (CalendarInvitationUnavailable, ValueError) as exc:
|
|
raise CampaignPersistenceError(str(exc)) from exc
|
|
built.mime.add_attachment(
|
|
icalendar,
|
|
subtype="calendar",
|
|
charset="utf-8",
|
|
filename="invitation.ics",
|
|
params={"method": "REQUEST"},
|
|
)
|
|
if built.draft.eml_path:
|
|
path = Path(built.draft.eml_path)
|
|
path.write_bytes(bytes(built.mime))
|
|
built.draft.eml_size_bytes = path.stat().st_size
|
|
built.draft.attachment_count += 1
|
|
provenance = delivery_provenance_by_index.setdefault(
|
|
built.draft.entry_index,
|
|
{},
|
|
)
|
|
provenance["calendar_invitation"] = {
|
|
"state": "prepared",
|
|
"request": request_payload,
|
|
"icalendar_sha256": hashlib.sha256(
|
|
icalendar.encode("utf-8")
|
|
).hexdigest(),
|
|
"prepared_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
|
|
|
|
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(
|
|
report_json.get("messages", []), result.report.messages, strict=False
|
|
):
|
|
if isinstance(message_payload, dict):
|
|
message_payload["attachments"] = [
|
|
files.public_attachment_summary_payload(item)
|
|
for item in message.attachments
|
|
]
|
|
report_json.update(
|
|
{
|
|
"built_at": datetime.now(UTC).isoformat(),
|
|
"build_token": uuid4().hex,
|
|
"built_count": result.report.built_count,
|
|
"build_failed_count": result.report.build_failed_count,
|
|
"ready_count": result.report.ready_count,
|
|
"warning_count": result.report.warning_count,
|
|
"needs_review_count": result.report.needs_review_count,
|
|
"blocked_count": result.report.blocked_count,
|
|
"excluded_count": result.report.excluded_count,
|
|
"inactive_count": result.report.inactive_count,
|
|
"queueable_count": result.report.queueable_count,
|
|
}
|
|
)
|
|
return report_json
|
|
|
|
|
|
def _replace_version_jobs(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
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: list[str] = []
|
|
old_job_artifacts = (
|
|
session.query(
|
|
CampaignJob.eml_storage_key,
|
|
CampaignJob.resolved_print_output,
|
|
)
|
|
.filter(CampaignJob.campaign_version_id == version_id)
|
|
.all()
|
|
)
|
|
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),
|
|
).delete(synchronize_session=False)
|
|
session.query(CampaignJob).filter(
|
|
CampaignJob.campaign_version_id == version_id
|
|
).delete(synchronize_session=False)
|
|
session.flush()
|
|
|
|
pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
|
for built in built_messages:
|
|
job = _job_from_message(
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
message=built.draft,
|
|
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)
|
|
pairs.append((job, built.draft))
|
|
session.flush()
|
|
return pairs, old_storage_keys
|
|
|
|
|
|
def _mail_execution_profile(
|
|
session: Session,
|
|
*,
|
|
version: CampaignVersion,
|
|
config: CampaignConfig,
|
|
jobs: list[CampaignJob],
|
|
) -> tuple[str | None, dict[str, Any]]:
|
|
if not any(
|
|
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs
|
|
):
|
|
return None, {}
|
|
if not config.server.profile_capabilities.smtp_available:
|
|
raise CampaignPersistenceError(
|
|
"The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created."
|
|
)
|
|
profile_id = campaign_mail_profile_id(
|
|
version.raw_json if isinstance(version.raw_json, dict) else {}
|
|
)
|
|
if profile_id is None:
|
|
raise CampaignPersistenceError(
|
|
"Select an authorized Mail profile before building campaign messages that use Mail."
|
|
)
|
|
summary = profile_delivery_summary(session, version)
|
|
if not summary.get("smtp_transport_revision"):
|
|
raise CampaignPersistenceError(
|
|
"The selected Mail profile has no SMTP transport revision."
|
|
)
|
|
return profile_id, summary
|
|
|
|
|
|
def _store_execution_snapshot(
|
|
session: Session,
|
|
*,
|
|
version: CampaignVersion,
|
|
config: CampaignConfig,
|
|
jobs: list[CampaignJob],
|
|
build_summary: dict[str, Any],
|
|
) -> None:
|
|
profile_id, profile = _mail_execution_profile(
|
|
session, version=version, config=config, jobs=jobs
|
|
)
|
|
snapshot, snapshot_hash = create_execution_snapshot(
|
|
version,
|
|
mail_profile_id=profile_id,
|
|
smtp_server_id=profile.get("smtp_server_id"),
|
|
smtp_credential_id=profile.get("smtp_credential_id"),
|
|
imap_server_id=profile.get("imap_server_id"),
|
|
imap_credential_id=profile.get("imap_credential_id"),
|
|
smtp_transport_revision=profile.get("smtp_transport_revision"),
|
|
imap_transport_revision=profile.get("imap_transport_revision"),
|
|
delivery=config.delivery,
|
|
jobs=jobs,
|
|
build_summary=build_summary,
|
|
)
|
|
version.execution_snapshot = snapshot
|
|
version.execution_snapshot_hash = snapshot_hash
|
|
version.execution_snapshot_at = datetime.now(UTC)
|
|
|
|
|
|
def _store_job_issues(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str,
|
|
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
|
) -> None:
|
|
for job, message in job_build_pairs:
|
|
session.add_all(
|
|
[
|
|
CampaignIssue(
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign_id,
|
|
campaign_version_id=version_id,
|
|
job_id=job.id,
|
|
severity=issue.severity,
|
|
code=issue.code,
|
|
message=issue.message,
|
|
source=issue.source,
|
|
behavior=issue.behavior,
|
|
)
|
|
for issue in message.issues
|
|
]
|
|
)
|
|
|
|
|
|
def _apply_campaign_build_state(
|
|
campaign: Campaign,
|
|
version: CampaignVersion,
|
|
*,
|
|
needs_review_count: int,
|
|
blocked_count: int,
|
|
queueable_count: int,
|
|
) -> None:
|
|
if needs_review_count or blocked_count:
|
|
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
|
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
|
elif queueable_count > 0:
|
|
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
|
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
|
else:
|
|
campaign.status = CampaignStatus.VALIDATED.value
|
|
|
|
|
|
def build_campaign_version(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
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)
|
|
if not campaign or campaign.tenant_id != tenant_id:
|
|
raise CampaignPersistenceError(
|
|
"Campaign version is not accessible for this tenant"
|
|
)
|
|
_ensure_current_campaign_version(campaign, version, action="build")
|
|
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
|
|
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
|
|
validation_summary = (
|
|
version.validation_summary
|
|
if isinstance(version.validation_summary, dict)
|
|
else {}
|
|
)
|
|
if not validation_summary.get("ok"):
|
|
raise CampaignPersistenceError(
|
|
"Campaign version must be successfully validated before messages are built"
|
|
)
|
|
_ensure_version_validated_and_locked(version)
|
|
|
|
files = files_integration()
|
|
storage = _object_storage()
|
|
build_id = uuid4().hex
|
|
with TemporaryDirectory(prefix="govoplan-campaign-build-") as output_directory:
|
|
with files.prepared_campaign_snapshot(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
|
include_bytes=True,
|
|
prefix="govoplan-managed-build-",
|
|
) as prepared:
|
|
managed_raw = load_campaign_json(prepared.path)
|
|
managed_config = load_campaign_config_from_json(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
raw_json=managed_raw,
|
|
campaign_id=campaign.id,
|
|
)
|
|
result = build_campaign_messages(
|
|
managed_config,
|
|
campaign_file=prepared.path,
|
|
output_dir=Path(output_directory),
|
|
write_eml=write_eml,
|
|
)
|
|
files.annotate_built_messages_with_managed_files(
|
|
result.built_messages,
|
|
prepared.managed_files_by_local_path,
|
|
)
|
|
entries_by_index = {
|
|
index: entry
|
|
for index, entry in enumerate(
|
|
load_campaign_entries(
|
|
managed_config,
|
|
campaign_file=prepared.path,
|
|
),
|
|
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,
|
|
config=managed_config,
|
|
built_messages=result.built_messages,
|
|
entries_by_index=entries_by_index,
|
|
)
|
|
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,
|
|
)
|
|
_prepare_built_calendar_invitations(
|
|
version=version,
|
|
config=managed_config,
|
|
built_messages=result.built_messages,
|
|
entries_by_index=entries_by_index,
|
|
delivery_provenance_by_index=delivery_provenance_by_index,
|
|
user_id=user_id,
|
|
)
|
|
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")
|
|
}
|
|
)
|
|
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)
|
|
editor_state.pop("approval_gate", None)
|
|
version.editor_state = editor_state
|
|
|
|
job_build_pairs, old_storage_keys = _replace_version_jobs(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
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]
|
|
files.record_campaign_attachment_uses_for_jobs(
|
|
session,
|
|
jobs,
|
|
stage="built",
|
|
)
|
|
_store_execution_snapshot(
|
|
session,
|
|
version=version,
|
|
config=managed_config,
|
|
jobs=jobs,
|
|
build_summary=report_json,
|
|
)
|
|
_store_job_issues(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
version_id=version.id,
|
|
job_build_pairs=job_build_pairs,
|
|
)
|
|
_apply_campaign_build_state(
|
|
campaign,
|
|
version,
|
|
needs_review_count=result.report.needs_review_count,
|
|
blocked_count=result.report.blocked_count,
|
|
queueable_count=result.report.queueable_count,
|
|
)
|
|
|
|
session.add(version)
|
|
session.add(campaign)
|
|
session.commit()
|
|
except Exception:
|
|
session.rollback()
|
|
_delete_storage_keys(storage, new_storage_keys)
|
|
raise
|
|
_delete_storage_keys(storage, old_storage_keys)
|
|
return report_json
|