initial commit after split
This commit is contained in:
1
src/govoplan_campaign/backend/__init__.py
Normal file
1
src/govoplan_campaign/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend integration for this GovOPlaN module."""
|
||||
462
src/govoplan_campaign/backend/attachments/resolver.py
Normal file
462
src/govoplan_campaign/backend/attachments/resolver.py
Normal file
@@ -0,0 +1,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig, ZipArchiveConfig, ZipRuleMode
|
||||
|
||||
|
||||
class AttachmentScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
ENTRY = "entry"
|
||||
|
||||
|
||||
class AttachmentMatchStatus(StrEnum):
|
||||
OK = "ok"
|
||||
MISSING = "missing"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
|
||||
|
||||
class MessageAttachmentStatus(StrEnum):
|
||||
READY = "ready"
|
||||
WARNING = "warning"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
BLOCKED = "blocked"
|
||||
EXCLUDED = "excluded"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ResolutionSeverity(StrEnum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class AttachmentIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
severity: ResolutionSeverity
|
||||
code: str
|
||||
message: str
|
||||
behavior: Behavior | None = None
|
||||
|
||||
|
||||
class ResolvedAttachment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scope: AttachmentScope
|
||||
index: int
|
||||
attachment_id: str | None = None
|
||||
label: str | None = None
|
||||
base_dir_template: str
|
||||
file_filter_template: str
|
||||
base_path_name: str | None = None
|
||||
base_path: str | None = None
|
||||
base_dir: str
|
||||
file_filter: str
|
||||
directory: str
|
||||
include_subdirs: bool
|
||||
required: bool
|
||||
allow_multiple: bool
|
||||
zip_enabled: bool
|
||||
zip_mode: ZipRuleMode = ZipRuleMode.INHERIT
|
||||
zip_archive_id: str | None = None
|
||||
zip_filename: str | None = None
|
||||
status: AttachmentMatchStatus
|
||||
behavior: Behavior | None = None
|
||||
matches: list[str] = Field(default_factory=list)
|
||||
issues: list[AttachmentIssue] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EntryAttachmentResolution(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
entry_index: int
|
||||
entry_id: str | None = None
|
||||
active: bool
|
||||
status: MessageAttachmentStatus
|
||||
attachments: list[ResolvedAttachment] = Field(default_factory=list)
|
||||
issues: list[AttachmentIssue] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def match_count(self) -> int:
|
||||
return sum(len(item.matches) for item in self.attachments)
|
||||
|
||||
|
||||
class AttachmentResolutionReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
campaign_file: str
|
||||
attachments_base_path: str
|
||||
entries_count: int
|
||||
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ready_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.READY)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.WARNING)
|
||||
|
||||
@property
|
||||
def needs_review_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.NEEDS_REVIEW)
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.BLOCKED)
|
||||
|
||||
@property
|
||||
def excluded_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.EXCLUDED)
|
||||
|
||||
@property
|
||||
def inactive_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.INACTIVE)
|
||||
|
||||
|
||||
def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
campaign_path = Path(campaign_file).resolve()
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_path.parent / path).resolve()
|
||||
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any]) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0)
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
|
||||
rendered = _render_template(config.base_dir, values).strip()
|
||||
return rendered or "."
|
||||
|
||||
|
||||
def _base_path_by_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
|
||||
for base_path in config.attachments.base_paths:
|
||||
if base_path.path == rendered_base_dir:
|
||||
return base_path
|
||||
return None
|
||||
|
||||
|
||||
def _base_path_by_id(config: CampaignConfig, base_path_id: str | None) -> AttachmentBasePathConfig | None:
|
||||
if not base_path_id:
|
||||
return None
|
||||
return next((base_path for base_path in config.attachments.base_paths if base_path.id == base_path_id), None)
|
||||
|
||||
|
||||
def _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig:
|
||||
return config.attachments.base_paths[0]
|
||||
|
||||
|
||||
def _selected_base_path(
|
||||
config: CampaignConfig,
|
||||
attachment_config: AttachmentConfig,
|
||||
rendered_base_dir: str,
|
||||
) -> AttachmentBasePathConfig | None:
|
||||
if not config.attachments.base_paths:
|
||||
return None
|
||||
selected_by_id = _base_path_by_id(config, attachment_config.base_path_id)
|
||||
if selected_by_id is not None:
|
||||
return selected_by_id
|
||||
if rendered_base_dir in {"", "."}:
|
||||
return _default_base_path(config)
|
||||
return _base_path_by_path(config, rendered_base_dir)
|
||||
|
||||
|
||||
def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -> bool:
|
||||
"""Return whether a rule may produce multiple attachments.
|
||||
|
||||
New UI versions no longer expose allow_multiple. Treat wildcard patterns as
|
||||
inherently multi-match-capable while keeping the legacy allow_multiple flag
|
||||
for old campaign JSON.
|
||||
"""
|
||||
|
||||
return config.allow_multiple or any(char in rendered_file_filter for char in "*?[")
|
||||
|
||||
|
||||
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||
if config.missing_behavior is not None:
|
||||
return config.missing_behavior
|
||||
if config.required:
|
||||
return campaign_config.validation_policy.missing_required_attachment
|
||||
return campaign_config.validation_policy.missing_optional_attachment
|
||||
|
||||
|
||||
def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||
return config.ambiguous_behavior or campaign_config.validation_policy.ambiguous_attachment_match
|
||||
|
||||
|
||||
def _attachment_zip_archive(
|
||||
campaign_config: CampaignConfig,
|
||||
attachment_config: AttachmentConfig,
|
||||
) -> ZipArchiveConfig | None:
|
||||
collection = campaign_config.attachments.zip
|
||||
if not collection.enabled:
|
||||
return None
|
||||
|
||||
selection = (attachment_config.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
|
||||
if selection == ZipRuleMode.EXCLUDE.value or attachment_config.zip.mode == ZipRuleMode.EXCLUDE:
|
||||
return None
|
||||
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value}:
|
||||
selected = collection.archive_by_id(selection)
|
||||
if selected is not None:
|
||||
return selected
|
||||
|
||||
# Legacy rule-level include/enabled values map to the campaign standard.
|
||||
if attachment_config.zip.mode == ZipRuleMode.INCLUDE or attachment_config.zip.enabled:
|
||||
return collection.standard_archive
|
||||
return collection.standard_archive
|
||||
|
||||
|
||||
def _render_zip_filename(archive: ZipArchiveConfig | None, values: dict[str, Any]) -> str | None:
|
||||
if archive is None:
|
||||
return None
|
||||
rendered = _render_template(archive.name or "attachments.zip", values).strip() or "attachments.zip"
|
||||
return rendered if rendered.lower().endswith(".zip") else f"{rendered}.zip"
|
||||
|
||||
|
||||
def _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool:
|
||||
rendered_base_dir = _rendered_base_dir(attachment_config, values)
|
||||
selected = _selected_base_path(config, attachment_config, rendered_base_dir)
|
||||
if config.attachments.base_paths:
|
||||
return bool(selected and selected.allow_individual)
|
||||
return config.attachments.allow_individual
|
||||
|
||||
|
||||
def _iter_effective_attachment_configs(
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
values: dict[str, Any],
|
||||
) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
|
||||
if entry.combine_attachments:
|
||||
for index, attachment_config in enumerate(config.attachments.global_):
|
||||
yield AttachmentScope.GLOBAL, index, attachment_config
|
||||
for index, attachment_config in enumerate(entry.attachments):
|
||||
if _entry_attachment_allowed(config, attachment_config, values):
|
||||
yield AttachmentScope.ENTRY, index, attachment_config
|
||||
|
||||
|
||||
def _resolve_attachment_directory(
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
campaign_config: CampaignConfig,
|
||||
attachment_config: AttachmentConfig,
|
||||
rendered_base_dir: str,
|
||||
) -> tuple[Path, AttachmentBasePathConfig | None]:
|
||||
"""Resolve the directory for an attachment rule.
|
||||
|
||||
Legacy campaigns used attachments.base_path as the root and base_dir as a
|
||||
child directory. Current WebUI campaigns select one named base path directly
|
||||
in base_dir. Prefer the new base_paths list when present to avoid resolving
|
||||
e.g. attachments/base_path + base_dir twice.
|
||||
"""
|
||||
|
||||
selected_base_path = _selected_base_path(campaign_config, attachment_config, rendered_base_dir)
|
||||
if selected_base_path is not None:
|
||||
return _resolve_path(campaign_file, selected_base_path.path), selected_base_path
|
||||
|
||||
if campaign_config.attachments.base_paths:
|
||||
return _resolve_path(campaign_file, rendered_base_dir), None
|
||||
|
||||
legacy_root = _resolve_path(campaign_file, campaign_config.attachments.base_path)
|
||||
return (legacy_root / rendered_base_dir).resolve(), None
|
||||
|
||||
|
||||
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
return []
|
||||
if include_subdirs:
|
||||
# pathlib.rglob accepts glob patterns, but fnmatch keeps behavior predictable
|
||||
# when file_filter is supplied as the Java-style filter portion only.
|
||||
return sorted(path for path in directory.rglob("*") if path.is_file() and fnmatch.fnmatch(path.name, file_filter))
|
||||
return sorted(path for path in directory.glob(file_filter) if path.is_file())
|
||||
|
||||
|
||||
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
|
||||
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
||||
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code=code,
|
||||
message=f"No file matched attachment filter {config.file_filter!r}",
|
||||
behavior=behavior,
|
||||
)
|
||||
|
||||
|
||||
def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_count: int) -> AttachmentIssue:
|
||||
severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code="ambiguous_attachment_match",
|
||||
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but it is configured as a direct/single-file selection",
|
||||
behavior=behavior,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_one_config(
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
campaign_config: CampaignConfig,
|
||||
values: dict[str, Any],
|
||||
scope: AttachmentScope,
|
||||
index: int,
|
||||
config: AttachmentConfig,
|
||||
) -> ResolvedAttachment:
|
||||
rendered_base_dir = _rendered_base_dir(config, values)
|
||||
rendered_file_filter = _render_template(config.file_filter, values)
|
||||
directory, selected_base_path = _resolve_attachment_directory(
|
||||
campaign_file=campaign_file,
|
||||
campaign_config=campaign_config,
|
||||
attachment_config=config,
|
||||
rendered_base_dir=rendered_base_dir,
|
||||
)
|
||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
|
||||
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
|
||||
|
||||
issues: list[AttachmentIssue] = []
|
||||
behavior: Behavior | None = None
|
||||
|
||||
if not matches:
|
||||
status = AttachmentMatchStatus.MISSING
|
||||
behavior = _missing_behavior(campaign_config, config)
|
||||
issues.append(_issue_for_missing(config, behavior))
|
||||
elif len(matches) > 1 and not allow_multiple:
|
||||
status = AttachmentMatchStatus.AMBIGUOUS
|
||||
behavior = _ambiguous_behavior(campaign_config, config)
|
||||
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
|
||||
else:
|
||||
status = AttachmentMatchStatus.OK
|
||||
|
||||
archive = _attachment_zip_archive(campaign_config, config)
|
||||
return ResolvedAttachment(
|
||||
scope=scope,
|
||||
index=index,
|
||||
attachment_id=config.id,
|
||||
label=config.label,
|
||||
base_dir_template=config.base_dir,
|
||||
file_filter_template=config.file_filter,
|
||||
base_path_name=selected_base_path.name if selected_base_path else None,
|
||||
base_path=selected_base_path.path if selected_base_path else None,
|
||||
base_dir=rendered_base_dir,
|
||||
file_filter=rendered_file_filter,
|
||||
directory=str(directory),
|
||||
include_subdirs=config.include_subdirs,
|
||||
required=config.required,
|
||||
allow_multiple=allow_multiple,
|
||||
zip_enabled=archive is not None,
|
||||
zip_mode=config.zip.mode,
|
||||
zip_archive_id=archive.id if archive else None,
|
||||
zip_filename=_render_zip_filename(archive, values),
|
||||
status=status,
|
||||
behavior=behavior,
|
||||
matches=[str(path) for path in matches],
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
|
||||
def _status_from_issues(active: bool, issues: list[AttachmentIssue]) -> MessageAttachmentStatus:
|
||||
if not active:
|
||||
return MessageAttachmentStatus.INACTIVE
|
||||
behaviors = {issue.behavior for issue in issues if issue.behavior is not None}
|
||||
if Behavior.BLOCK in behaviors:
|
||||
return MessageAttachmentStatus.BLOCKED
|
||||
if Behavior.DROP in behaviors:
|
||||
return MessageAttachmentStatus.EXCLUDED
|
||||
if Behavior.ASK in behaviors:
|
||||
return MessageAttachmentStatus.NEEDS_REVIEW
|
||||
if Behavior.WARN in behaviors:
|
||||
return MessageAttachmentStatus.WARNING
|
||||
return MessageAttachmentStatus.READY
|
||||
|
||||
|
||||
def resolve_entry_attachments(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
entry: EntryConfig,
|
||||
entry_index: int,
|
||||
) -> EntryAttachmentResolution:
|
||||
values = build_template_values(config, entry)
|
||||
resolved: list[ResolvedAttachment] = []
|
||||
|
||||
if entry.active:
|
||||
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry, values):
|
||||
resolved.append(
|
||||
_resolve_one_config(
|
||||
campaign_file=campaign_file,
|
||||
campaign_config=config,
|
||||
values=values,
|
||||
scope=scope,
|
||||
index=index,
|
||||
config=attachment_config,
|
||||
)
|
||||
)
|
||||
|
||||
issues = [issue for item in resolved for issue in item.issues]
|
||||
return EntryAttachmentResolution(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=entry.active,
|
||||
status=_status_from_issues(entry.active, issues),
|
||||
attachments=resolved,
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
|
||||
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
|
||||
entries = load_campaign_entries(config, campaign_file=campaign_file)
|
||||
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
|
||||
resolved_entries = [
|
||||
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
]
|
||||
return AttachmentResolutionReport(
|
||||
campaign_id=config.campaign.id,
|
||||
campaign_name=config.campaign.name,
|
||||
campaign_file=str(Path(campaign_file).resolve()),
|
||||
attachments_base_path=str(base_path),
|
||||
entries_count=len(entries),
|
||||
entries=resolved_entries,
|
||||
)
|
||||
14
src/govoplan_campaign/backend/campaign/__init__.py
Normal file
14
src/govoplan_campaign/backend/campaign/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Campaign JSON model, loading and validation helpers."""
|
||||
|
||||
from .models import CampaignConfig
|
||||
from .loader import load_campaign_config, load_campaign_json
|
||||
from .validation import validate_campaign_config, SemanticIssue, SemanticReport
|
||||
|
||||
__all__ = [
|
||||
"CampaignConfig",
|
||||
"load_campaign_config",
|
||||
"load_campaign_json",
|
||||
"validate_campaign_config",
|
||||
"SemanticIssue",
|
||||
"SemanticReport",
|
||||
]
|
||||
153
src/govoplan_campaign/backend/campaign/addressing.py
Normal file
153
src/govoplan_campaign/backend/campaign/addressing.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email.utils import formataddr
|
||||
from typing import Iterable
|
||||
|
||||
from .models import CampaignConfig, EntryConfig, RecipientConfig
|
||||
|
||||
ADDRESS_TEMPLATE_FIELDS = ("from", "to", "reply_to", "cc", "bcc")
|
||||
|
||||
|
||||
def _deduplicate(recipients: Iterable[RecipientConfig]) -> list[RecipientConfig]:
|
||||
unique: list[RecipientConfig] = []
|
||||
seen: set[str] = set()
|
||||
for recipient in recipients:
|
||||
key = recipient.email.strip().casefold()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(recipient)
|
||||
return unique
|
||||
|
||||
|
||||
def _effective_list(
|
||||
*,
|
||||
global_recipients: list[RecipientConfig],
|
||||
local_recipients: list[RecipientConfig],
|
||||
allow_individual: bool,
|
||||
merge: bool,
|
||||
) -> list[RecipientConfig]:
|
||||
if not allow_individual:
|
||||
return _deduplicate(global_recipients)
|
||||
if not local_recipients:
|
||||
return _deduplicate(global_recipients)
|
||||
if merge:
|
||||
return _deduplicate([*global_recipients, *local_recipients])
|
||||
return _deduplicate(local_recipients)
|
||||
|
||||
|
||||
|
||||
def _effective_single(
|
||||
*,
|
||||
global_recipients: list[RecipientConfig],
|
||||
local_recipients: list[RecipientConfig],
|
||||
allow_individual: bool,
|
||||
) -> list[RecipientConfig]:
|
||||
"""Resolve a single-mailbox header such as From.
|
||||
|
||||
A recipient-specific value overrides the campaign value when allowed. Any
|
||||
additional legacy values are ignored deliberately; the canonical schema and
|
||||
WebUI prevent creating them.
|
||||
"""
|
||||
|
||||
candidates = local_recipients if allow_individual and local_recipients else global_recipients
|
||||
unique = _deduplicate(candidates)
|
||||
return unique[:1]
|
||||
|
||||
def effective_address_lists(config: CampaignConfig, entry: EntryConfig) -> dict[str, list[RecipientConfig]]:
|
||||
"""Return the exact address lists used by a built message.
|
||||
|
||||
``merge_*`` is the canonical configuration. Older ``combine_*`` values are
|
||||
normalized by EntryConfig before this function is called.
|
||||
"""
|
||||
|
||||
return {
|
||||
"from": _effective_single(
|
||||
global_recipients=config.recipients.from_,
|
||||
local_recipients=entry.from_,
|
||||
allow_individual=config.recipients.allow_individual_from,
|
||||
),
|
||||
"to": _effective_list(
|
||||
global_recipients=config.recipients.to,
|
||||
local_recipients=entry.to,
|
||||
allow_individual=config.recipients.allow_individual_to,
|
||||
merge=entry.merge_to,
|
||||
),
|
||||
"cc": _effective_list(
|
||||
global_recipients=config.recipients.cc,
|
||||
local_recipients=entry.cc,
|
||||
allow_individual=config.recipients.allow_individual_cc,
|
||||
merge=entry.merge_cc,
|
||||
),
|
||||
"bcc": _effective_list(
|
||||
global_recipients=config.recipients.bcc,
|
||||
local_recipients=entry.bcc,
|
||||
allow_individual=config.recipients.allow_individual_bcc,
|
||||
merge=entry.merge_bcc,
|
||||
),
|
||||
"reply_to": _effective_list(
|
||||
global_recipients=config.recipients.reply_to,
|
||||
local_recipients=entry.reply_to,
|
||||
allow_individual=config.recipients.allow_individual_reply_to,
|
||||
merge=entry.merge_reply_to,
|
||||
),
|
||||
"bounce_to": _effective_list(
|
||||
global_recipients=config.recipients.bounce_to,
|
||||
local_recipients=entry.bounce_to,
|
||||
allow_individual=config.recipients.allow_individual_bounce_to,
|
||||
merge=entry.merge_bounce_to,
|
||||
),
|
||||
"disposition_notification_to": _effective_list(
|
||||
global_recipients=config.recipients.disposition_notification_to,
|
||||
local_recipients=entry.disposition_notification_to,
|
||||
allow_individual=config.recipients.allow_individual_disposition_notification_to,
|
||||
merge=entry.merge_disposition_notification_to,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def formatted_recipient(recipient: RecipientConfig) -> str:
|
||||
return formataddr((recipient.name or "", recipient.email))
|
||||
|
||||
|
||||
def recipient_template_values(addresses: dict[str, list[RecipientConfig]]) -> dict[str, str]:
|
||||
"""Expose effective address lists as local template values.
|
||||
|
||||
Common forms:
|
||||
local:to first formatted address
|
||||
local:all_to all formatted addresses, comma separated
|
||||
local:to.email first address only
|
||||
local:all_to.email all email addresses, comma separated
|
||||
local:to[2] second formatted address (one-based)
|
||||
local:to[2].email second email address
|
||||
|
||||
Legacy zero-based keys such as ``local:to.0.email`` remain available.
|
||||
"""
|
||||
|
||||
values: dict[str, str] = {}
|
||||
for field_name in ADDRESS_TEMPLATE_FIELDS:
|
||||
recipients = addresses.get(field_name, [])
|
||||
formatted = [formatted_recipient(recipient) for recipient in recipients]
|
||||
emails = [recipient.email for recipient in recipients]
|
||||
names = [recipient.name or "" for recipient in recipients]
|
||||
|
||||
first_formatted = formatted[0] if formatted else ""
|
||||
first_email = emails[0] if emails else ""
|
||||
first_name = names[0] if names else ""
|
||||
values[f"local::{field_name}"] = first_formatted
|
||||
values[f"local::{field_name}.email"] = first_email
|
||||
values[f"local::{field_name}.name"] = first_name
|
||||
values[f"local::all_{field_name}"] = ", ".join(formatted)
|
||||
values[f"local::all_{field_name}.email"] = ", ".join(emails)
|
||||
values[f"local::all_{field_name}.name"] = ", ".join(name for name in names if name)
|
||||
|
||||
for index, recipient in enumerate(recipients):
|
||||
one_based = index + 1
|
||||
values[f"local::{field_name}[{one_based}]"] = formatted[index]
|
||||
values[f"local::{field_name}[{one_based}].email"] = recipient.email
|
||||
values[f"local::{field_name}[{one_based}].name"] = recipient.name or ""
|
||||
# Preserve the original zero-based recipient field syntax.
|
||||
values[f"local::{field_name}.{index}.email"] = recipient.email
|
||||
values[f"local::{field_name}.{index}.name"] = recipient.name or ""
|
||||
values[f"local::{field_name}.{index}.type"] = recipient.recipient_type.value
|
||||
return values
|
||||
222
src/govoplan_campaign/backend/campaign/entries.py
Normal file
222
src/govoplan_campaign/backend/campaign/entries.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import CampaignConfig, EntryConfig, SourceType
|
||||
|
||||
|
||||
class EntryLoadError(ValueError):
|
||||
"""Raised when campaign entries cannot be loaded from inline or external sources."""
|
||||
|
||||
|
||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
campaign_path = Path(campaign_file).resolve()
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_path.parent / path).resolve()
|
||||
|
||||
|
||||
def _parse_bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
text = str(value).strip().lower()
|
||||
if text in {"1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "n", "nein", "", "inactive", "inaktiv"}:
|
||||
return False
|
||||
raise EntryLoadError(f"cannot parse boolean value: {value!r}")
|
||||
|
||||
|
||||
def _parse_scalar_for_target(target: str, value: Any) -> Any:
|
||||
bool_targets = {
|
||||
"active",
|
||||
"merge_from",
|
||||
"merge_to",
|
||||
"merge_cc",
|
||||
"merge_bcc",
|
||||
"merge_reply_to",
|
||||
"merge_bounce_to",
|
||||
"merge_disposition_notification_to",
|
||||
"combine_to",
|
||||
"combine_cc",
|
||||
"combine_bcc",
|
||||
"combine_reply_to",
|
||||
"combine_bounce_to",
|
||||
"combine_disposition_notification_to",
|
||||
"combine_attachments",
|
||||
}
|
||||
if target in bool_targets:
|
||||
return _parse_bool(value)
|
||||
if target.endswith(".include_subdirs") or target.endswith(".required") or target.endswith(".allow_multiple"):
|
||||
return _parse_bool(value)
|
||||
if target.endswith(".zip.enabled"):
|
||||
return _parse_bool(value)
|
||||
return value
|
||||
|
||||
|
||||
def _ensure_list_length(values: list[Any], index: int, factory: Any) -> None:
|
||||
while len(values) <= index:
|
||||
values.append(factory())
|
||||
|
||||
|
||||
def _set_recipient_value(entry_data: dict[str, Any], target: str, value: Any) -> bool:
|
||||
# Examples: from.email, to.0.email, cc.0.name
|
||||
if target.startswith("from."):
|
||||
entry_data.setdefault("from", {})
|
||||
_, field = target.split(".", 1)
|
||||
if field == "type":
|
||||
field = "type"
|
||||
entry_data["from"][field] = value
|
||||
return True
|
||||
|
||||
for recipient_list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
|
||||
prefix = recipient_list_name + "."
|
||||
if not target.startswith(prefix):
|
||||
continue
|
||||
parts = target.split(".")
|
||||
if len(parts) != 3 or not parts[1].isdigit():
|
||||
raise EntryLoadError(f"invalid recipient mapping target: {target}")
|
||||
index = int(parts[1])
|
||||
field = parts[2]
|
||||
recipients = entry_data.setdefault(recipient_list_name, [])
|
||||
_ensure_list_length(recipients, index, dict)
|
||||
recipients[index][field] = value
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _set_attachment_value(entry_data: dict[str, Any], target: str, value: Any) -> bool:
|
||||
if not target.startswith("attachments."):
|
||||
return False
|
||||
parts = target.split(".")
|
||||
if len(parts) < 3 or not parts[1].isdigit():
|
||||
raise EntryLoadError(f"invalid attachment mapping target: {target}")
|
||||
|
||||
index = int(parts[1])
|
||||
attachments = entry_data.setdefault("attachments", [])
|
||||
_ensure_list_length(attachments, index, dict)
|
||||
attachment = attachments[index]
|
||||
|
||||
if parts[2] == "zip":
|
||||
if len(parts) != 4:
|
||||
raise EntryLoadError(f"invalid zip attachment mapping target: {target}")
|
||||
attachment.setdefault("zip", {})[parts[3]] = value
|
||||
return True
|
||||
|
||||
if len(parts) != 3:
|
||||
raise EntryLoadError(f"invalid attachment mapping target: {target}")
|
||||
attachment[parts[2]] = value
|
||||
return True
|
||||
|
||||
|
||||
def _set_entry_value(entry_data: dict[str, Any], target: str, value: Any) -> None:
|
||||
value = _parse_scalar_for_target(target, value)
|
||||
if value is None:
|
||||
return
|
||||
if isinstance(value, str) and value == "":
|
||||
return
|
||||
|
||||
if target.startswith("fields."):
|
||||
_, field_name = target.split(".", 1)
|
||||
entry_data.setdefault("fields", {})[field_name] = value
|
||||
return
|
||||
|
||||
if _set_recipient_value(entry_data, target, value):
|
||||
return
|
||||
if _set_attachment_value(entry_data, target, value):
|
||||
return
|
||||
|
||||
entry_data[target] = value
|
||||
|
||||
|
||||
def _entry_defaults_data(config: CampaignConfig) -> dict[str, Any]:
|
||||
if config.entries.defaults is None:
|
||||
return {}
|
||||
return config.entries.defaults.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
|
||||
|
||||
def _load_csv_rows(path: Path, *, delimiter: str, encoding: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
with path.open("r", encoding=encoding, newline="") as handle:
|
||||
reader = csv.DictReader(handle, delimiter=delimiter)
|
||||
return [dict(row) for row in reader]
|
||||
except OSError as exc:
|
||||
raise EntryLoadError(f"could not read CSV entries source {path}: {exc}") from exc
|
||||
|
||||
|
||||
def _load_json_rows(path: Path, *, encoding: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
with path.open("r", encoding=encoding) as handle:
|
||||
data = json.load(handle)
|
||||
except OSError as exc:
|
||||
raise EntryLoadError(f"could not read JSON entries source {path}: {exc}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise EntryLoadError(f"invalid JSON entries source {path}: {exc}") from exc
|
||||
|
||||
if isinstance(data, list):
|
||||
rows = data
|
||||
elif isinstance(data, dict) and isinstance(data.get("entries"), list):
|
||||
rows = data["entries"]
|
||||
else:
|
||||
raise EntryLoadError("JSON entries source must be a list or an object with an 'entries' list")
|
||||
|
||||
if not all(isinstance(row, dict) for row in rows):
|
||||
raise EntryLoadError("JSON entries source rows must be objects")
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _row_to_entry(defaults_data: dict[str, Any], mapping: dict[str, str], row: dict[str, Any], row_number: int) -> EntryConfig:
|
||||
entry_data = copy.deepcopy(defaults_data)
|
||||
for target, source_name in mapping.items():
|
||||
if source_name not in row:
|
||||
# Detailed missing-column validation is handled in semantic validation.
|
||||
continue
|
||||
try:
|
||||
_set_entry_value(entry_data, target, row[source_name])
|
||||
except EntryLoadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise EntryLoadError(f"row {row_number}: could not map {source_name!r} to {target!r}: {exc}") from exc
|
||||
try:
|
||||
return EntryConfig.model_validate(entry_data)
|
||||
except Exception as exc:
|
||||
raise EntryLoadError(f"row {row_number}: mapped entry is invalid: {exc}") from exc
|
||||
|
||||
|
||||
def load_campaign_entries(config: CampaignConfig, *, campaign_file: str | Path) -> list[EntryConfig]:
|
||||
"""Load and normalize campaign entries from inline data or external CSV/JSON source.
|
||||
|
||||
The normalized output is always a list of EntryConfig instances. This is intentionally
|
||||
UI/API friendly: a future web interface can generate the same JSON structure and use the
|
||||
same resolver without code changes.
|
||||
"""
|
||||
|
||||
if config.entries.inline is not None:
|
||||
return list(config.entries.inline)
|
||||
|
||||
if config.entries.source is None or config.entries.mapping is None:
|
||||
raise EntryLoadError("external entries require source and mapping")
|
||||
|
||||
source = config.entries.source
|
||||
path = _resolve(campaign_file, source.path)
|
||||
if not path.exists():
|
||||
raise EntryLoadError(f"entries source file does not exist: {path}")
|
||||
|
||||
if source.type == SourceType.CSV:
|
||||
if not source.has_header:
|
||||
raise EntryLoadError("CSV entries currently require has_header=true")
|
||||
rows = _load_csv_rows(path, delimiter=source.delimiter, encoding=source.encoding)
|
||||
elif source.type == SourceType.JSON:
|
||||
rows = _load_json_rows(path, encoding=source.encoding)
|
||||
else: # pragma: no cover - defensive; Pydantic constrains this already.
|
||||
raise EntryLoadError(f"unsupported entries source type: {source.type}")
|
||||
|
||||
defaults_data = _entry_defaults_data(config)
|
||||
return [_row_to_entry(defaults_data, config.entries.mapping, row, index + 2) for index, row in enumerate(rows)]
|
||||
62
src/govoplan_campaign/backend/campaign/field_values.py
Normal file
62
src/govoplan_campaign/backend/campaign/field_values.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import CampaignConfig, EntryConfig, FieldDefinition
|
||||
|
||||
|
||||
def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]:
|
||||
"""Return campaign field definitions keyed by field id/name."""
|
||||
|
||||
return {field.name: field for field in config.fields}
|
||||
|
||||
|
||||
def field_can_override(config: CampaignConfig, field_name: str) -> bool:
|
||||
"""Return whether a recipient/entry value may override the global value.
|
||||
|
||||
Unknown fields remain overridable for backwards compatibility with older
|
||||
campaigns and ad-hoc external mappings. Semantic validation reports unknown
|
||||
field usage separately when a field list is configured.
|
||||
"""
|
||||
|
||||
field = field_definitions_by_name(config).get(field_name)
|
||||
if field is None:
|
||||
return True
|
||||
return field.can_override
|
||||
|
||||
|
||||
def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]:
|
||||
"""Return recipient field keys that are ignored by the override policy."""
|
||||
|
||||
return sorted(name for name in entry.fields if not field_can_override(config, name))
|
||||
|
||||
|
||||
def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
|
||||
"""Return the local/effective field value map for one message entry.
|
||||
|
||||
Global values act as defaults for local template placeholders. Recipient
|
||||
values replace those defaults only when the corresponding field allows
|
||||
overrides. Fields that are unknown to the campaign definition keep the old
|
||||
permissive behavior and remain usable as local values.
|
||||
"""
|
||||
|
||||
values: dict[str, Any] = dict(config.global_values)
|
||||
for key, value in entry.fields.items():
|
||||
if field_can_override(config, key) and entry_field_has_override_value(value):
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def entry_field_has_override_value(value: Any) -> bool:
|
||||
"""Return whether an entry field should override a global default.
|
||||
|
||||
Empty recipient values are treated as "not set" so global_values remain the
|
||||
effective local defaults. Numeric zero and boolean false are valid explicit
|
||||
overrides.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return value.strip() != ""
|
||||
return True
|
||||
79
src/govoplan_campaign/backend/campaign/loader.py
Normal file
79
src/govoplan_campaign/backend/campaign/loader.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from .models import CampaignConfig
|
||||
|
||||
|
||||
class CampaignLoadError(ValueError):
|
||||
"""Raised when the campaign JSON cannot be loaded or parsed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchemaValidationError:
|
||||
path: str
|
||||
message: str
|
||||
|
||||
|
||||
class CampaignSchemaError(CampaignLoadError):
|
||||
def __init__(self, errors: list[SchemaValidationError]) -> None:
|
||||
self.errors = errors
|
||||
details = "; ".join(f"{error.path}: {error.message}" for error in errors[:5])
|
||||
if len(errors) > 5:
|
||||
details += f"; ... and {len(errors) - 5} more"
|
||||
super().__init__(f"campaign schema validation failed: {details}")
|
||||
|
||||
|
||||
def load_campaign_json(path: str | Path) -> dict[str, Any]:
|
||||
campaign_path = Path(path)
|
||||
try:
|
||||
with campaign_path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except OSError as exc:
|
||||
raise CampaignLoadError(f"could not read campaign JSON {campaign_path}: {exc}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CampaignLoadError(f"invalid campaign JSON {campaign_path}: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise CampaignLoadError("campaign JSON root must be an object")
|
||||
return data
|
||||
|
||||
|
||||
def _default_schema_path() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
|
||||
|
||||
|
||||
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
|
||||
path = Path(schema_path) if schema_path else _default_schema_path()
|
||||
return load_campaign_json(path)
|
||||
|
||||
|
||||
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
|
||||
schema = load_campaign_schema(schema_path)
|
||||
validator = Draft202012Validator(schema, format_checker=FormatChecker())
|
||||
errors = sorted(validator.iter_errors(data), key=lambda error: list(error.path))
|
||||
if errors:
|
||||
normalized = [
|
||||
SchemaValidationError(
|
||||
path="/" + "/".join(str(part) for part in error.absolute_path),
|
||||
message=error.message,
|
||||
)
|
||||
for error in errors
|
||||
]
|
||||
raise CampaignSchemaError(normalized)
|
||||
|
||||
|
||||
def load_campaign_config(
|
||||
path: str | Path,
|
||||
*,
|
||||
validate_schema: bool = True,
|
||||
schema_path: str | Path | None = None,
|
||||
) -> CampaignConfig:
|
||||
data = load_campaign_json(path)
|
||||
if validate_schema:
|
||||
validate_against_schema(data, schema_path=schema_path)
|
||||
return CampaignConfig.model_validate(data)
|
||||
551
src/govoplan_campaign/backend/campaign/models.py
Normal file
551
src/govoplan_campaign/backend/campaign/models.py
Normal file
@@ -0,0 +1,551 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
||||
class CampaignMode(StrEnum):
|
||||
DRAFT = "draft"
|
||||
TEST = "test"
|
||||
SEND = "send"
|
||||
|
||||
|
||||
class FieldType(StrEnum):
|
||||
STRING = "string"
|
||||
INTEGER = "integer"
|
||||
DOUBLE = "double"
|
||||
DATE = "date"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
TO = "to"
|
||||
CC = "cc"
|
||||
BCC = "bcc"
|
||||
REPLY_TO = "reply_to"
|
||||
BOUNCE_TO = "bounce_to"
|
||||
DISPOSITION_NOTIFICATION_TO = "disposition_notification_to"
|
||||
|
||||
|
||||
class Behavior(StrEnum):
|
||||
BLOCK = "block"
|
||||
ASK = "ask"
|
||||
DROP = "drop"
|
||||
CONTINUE = "continue"
|
||||
WARN = "warn"
|
||||
|
||||
|
||||
class MissingAddressBehavior(StrEnum):
|
||||
BLOCK = "block"
|
||||
DROP = "drop"
|
||||
|
||||
|
||||
class InactiveEntryBehavior(StrEnum):
|
||||
DROP = "drop"
|
||||
BLOCK = "block"
|
||||
WARN = "warn"
|
||||
|
||||
|
||||
class SourceType(StrEnum):
|
||||
CSV = "csv"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
class ZipMethod(StrEnum):
|
||||
ZIP_STANDARD = "zip_standard"
|
||||
AES = "aes"
|
||||
|
||||
|
||||
class ZipRuleMode(StrEnum):
|
||||
INHERIT = "inherit"
|
||||
INCLUDE = "include"
|
||||
EXCLUDE = "exclude"
|
||||
|
||||
|
||||
class ZipPasswordScope(StrEnum):
|
||||
LOCAL = "local"
|
||||
GLOBAL = "global"
|
||||
|
||||
|
||||
class ZipPasswordMode(StrEnum):
|
||||
NONE = "none"
|
||||
DIRECT = "direct"
|
||||
FIELD = "field"
|
||||
TEMPLATE = "template"
|
||||
|
||||
|
||||
class BuildStatus(StrEnum):
|
||||
BUILT = "built"
|
||||
BUILD_FAILED = "build_failed"
|
||||
|
||||
|
||||
class SendStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
QUEUED = "queued"
|
||||
|
||||
|
||||
class CampaignMeta(StrictModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
mode: CampaignMode = CampaignMode.DRAFT
|
||||
|
||||
|
||||
class FieldDefinition(StrictModel):
|
||||
name: str
|
||||
type: FieldType = FieldType.STRING
|
||||
label: str | None = None
|
||||
required: bool = False
|
||||
can_override: bool = True
|
||||
|
||||
|
||||
class ServerConfig(StrictModel):
|
||||
mail_profile_id: str | None = None
|
||||
inherit_smtp_credentials: bool = True
|
||||
inherit_imap_credentials: bool = True
|
||||
smtp: SmtpConfig | None = None
|
||||
imap: ImapConfig | None = None
|
||||
|
||||
|
||||
class RecipientConfig(StrictModel):
|
||||
email: str
|
||||
name: str | None = None
|
||||
recipient_type: RecipientType = Field(default=RecipientType.TO, alias="type")
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def email_should_look_like_address(cls, value: str) -> str:
|
||||
# JSON Schema's format=email remains the stricter validation layer.
|
||||
# Keep this deliberately lightweight to avoid an extra email-validator dependency.
|
||||
if "@" not in value:
|
||||
raise ValueError("email must contain '@'")
|
||||
return value
|
||||
|
||||
|
||||
class RecipientsConfig(StrictModel):
|
||||
from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1)
|
||||
|
||||
@field_validator("from_", mode="before")
|
||||
@classmethod
|
||||
def normalize_from_list(cls, value: Any) -> Any:
|
||||
# Older campaign files stored From as one object. The canonical model
|
||||
# is now an array so every address header has the same list semantics.
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [] if not any(value.values()) else [value]
|
||||
return value
|
||||
|
||||
allow_individual_from: bool = False
|
||||
|
||||
to: list[RecipientConfig] = Field(default_factory=list)
|
||||
allow_individual_to: bool = False
|
||||
|
||||
cc: list[RecipientConfig] = Field(default_factory=list)
|
||||
allow_individual_cc: bool = False
|
||||
|
||||
bcc: list[RecipientConfig] = Field(default_factory=list)
|
||||
allow_individual_bcc: bool = False
|
||||
|
||||
reply_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
allow_individual_reply_to: bool = False
|
||||
|
||||
bounce_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
allow_individual_bounce_to: bool = False
|
||||
|
||||
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
allow_individual_disposition_notification_to: bool = False
|
||||
|
||||
|
||||
class TemplateSourceConfig(StrictModel):
|
||||
type: Literal["files"] = "files"
|
||||
subject_path: str | None = None
|
||||
text_path: str | None = None
|
||||
html_path: str | None = None
|
||||
encoding: str = "utf-8"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def at_least_one_path(self) -> "TemplateSourceConfig":
|
||||
if not any([self.subject_path, self.text_path, self.html_path]):
|
||||
raise ValueError("template.source must define subject_path, text_path or html_path")
|
||||
return self
|
||||
|
||||
|
||||
class TemplateConfig(StrictModel):
|
||||
subject: str | None = None
|
||||
text: str | None = None
|
||||
html: str | None = None
|
||||
source: TemplateSourceConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def inline_or_source(self) -> "TemplateConfig":
|
||||
inline_values = any(value is not None for value in [self.subject, self.text, self.html])
|
||||
if self.source and inline_values:
|
||||
raise ValueError("template must be either inline or source-based, not both")
|
||||
if self.source:
|
||||
return self
|
||||
if not self.subject:
|
||||
raise ValueError("inline template requires subject")
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_external(self) -> bool:
|
||||
return self.source is not None
|
||||
|
||||
|
||||
class ZipArchiveConfig(StrictModel):
|
||||
id: str
|
||||
name: str = "attachments.zip"
|
||||
standard: bool = False
|
||||
password_enabled: bool = False
|
||||
password_field: str | None = None
|
||||
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
|
||||
# Compatibility fields for campaigns created by the first single-archive
|
||||
# implementation. New WebUI campaigns use password_enabled/field/scope.
|
||||
password_mode: ZipPasswordMode | None = None
|
||||
password: str | None = None
|
||||
password_template: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_archive(cls, value: Any) -> Any:
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
normalized = dict(value)
|
||||
if "name" not in normalized and normalized.get("filename_template"):
|
||||
normalized["name"] = normalized.get("filename_template")
|
||||
normalized.pop("filename_template", None)
|
||||
mode = normalized.get("password_mode")
|
||||
if "password_enabled" not in normalized and mode in {
|
||||
ZipPasswordMode.DIRECT.value,
|
||||
ZipPasswordMode.FIELD.value,
|
||||
ZipPasswordMode.TEMPLATE.value,
|
||||
}:
|
||||
normalized["password_enabled"] = True
|
||||
if mode == ZipPasswordMode.FIELD.value and "password_scope" not in normalized:
|
||||
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||
return normalized
|
||||
|
||||
|
||||
class ZipCollectionConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
archives: list[ZipArchiveConfig] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_single_archive_config(cls, value: Any) -> Any:
|
||||
"""Upgrade the original single-recipient-ZIP object in memory.
|
||||
|
||||
The campaign JSON remains portable: old filename/password settings are
|
||||
converted to one standard archive, while the new format is passed
|
||||
through unchanged.
|
||||
"""
|
||||
|
||||
if not isinstance(value, dict) or "archives" in value:
|
||||
return value
|
||||
legacy = dict(value)
|
||||
enabled = bool(legacy.get("enabled"))
|
||||
meaningful = enabled or any(
|
||||
legacy.get(key) not in (None, "", False, "none", "inherit")
|
||||
for key in ("filename_template", "password_mode", "password", "password_field", "password_template")
|
||||
)
|
||||
if not meaningful:
|
||||
return {"enabled": False, "archives": []}
|
||||
archive = {
|
||||
"id": "default",
|
||||
"name": legacy.get("filename_template") or "attachments.zip",
|
||||
"standard": True,
|
||||
"method": legacy.get("method", ZipMethod.AES.value),
|
||||
"password_mode": legacy.get("password_mode"),
|
||||
"password": legacy.get("password"),
|
||||
"password_field": legacy.get("password_field"),
|
||||
"password_template": legacy.get("password_template"),
|
||||
}
|
||||
mode = legacy.get("password_mode")
|
||||
if not mode and legacy.get("password_template"):
|
||||
mode = ZipPasswordMode.TEMPLATE.value
|
||||
archive["password_mode"] = mode
|
||||
archive["password_enabled"] = mode in {
|
||||
ZipPasswordMode.DIRECT.value,
|
||||
ZipPasswordMode.FIELD.value,
|
||||
ZipPasswordMode.TEMPLATE.value,
|
||||
}
|
||||
archive["password_scope"] = ZipPasswordScope.LOCAL.value
|
||||
return {"enabled": enabled, "archives": [archive]}
|
||||
|
||||
@property
|
||||
def standard_archive(self) -> ZipArchiveConfig | None:
|
||||
return next((archive for archive in self.archives if archive.standard), self.archives[0] if self.archives else None)
|
||||
|
||||
def archive_by_id(self, archive_id: str | None) -> ZipArchiveConfig | None:
|
||||
if not archive_id:
|
||||
return None
|
||||
return next((archive for archive in self.archives if archive.id == archive_id), None)
|
||||
|
||||
|
||||
class ZipRuleConfig(StrictModel):
|
||||
# New format: inherit the campaign standard, select an archive id, or use
|
||||
# the reserved value "exclude" to send the file outside every archive.
|
||||
archive_id: str = ZipRuleMode.INHERIT.value
|
||||
|
||||
# Original per-rule fields remain accepted for imported campaign JSON.
|
||||
enabled: bool = False
|
||||
mode: ZipRuleMode = ZipRuleMode.INHERIT
|
||||
filename_template: str | None = None
|
||||
password_mode: ZipPasswordMode = ZipPasswordMode.NONE
|
||||
password: str | None = None
|
||||
password_field: str | None = None
|
||||
password_template: str | None = None
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_password_mode(cls, value: Any) -> Any:
|
||||
if isinstance(value, dict) and value.get("password_template") and "password_mode" not in value:
|
||||
return {**value, "password_mode": ZipPasswordMode.TEMPLATE.value}
|
||||
return value
|
||||
|
||||
|
||||
class AttachmentBasePathConfig(StrictModel):
|
||||
id: str | None = None
|
||||
name: str
|
||||
path: str = "."
|
||||
allow_individual: bool = False
|
||||
unsent_warning: bool = False
|
||||
# Legacy UI builds briefly wrote a source value. Keep accepting it so older
|
||||
# drafts do not become invalid merely because the current UI no longer shows
|
||||
# or edits that column.
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class AttachmentConfig(StrictModel):
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
base_path_id: str | None = None
|
||||
# Legacy UI helper. Current attachment resolution ignores this value and
|
||||
# treats direct files as plain file_filter patterns without wildcards.
|
||||
# Keep accepting it so existing drafts with {"type": ""}, "direct"
|
||||
# or "pattern" remain valid.
|
||||
type_: str | None = Field(default=None, alias="type")
|
||||
base_dir: str
|
||||
file_filter: str
|
||||
include_subdirs: bool = False
|
||||
required: bool = True
|
||||
allow_multiple: bool = False
|
||||
|
||||
@field_validator("type_", mode="before")
|
||||
@classmethod
|
||||
def empty_type_means_unset(cls, value: Any) -> Any:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
# None means: inherit from validation_policy. Explicit values remain
|
||||
# supported for backwards compatibility and per-rule overrides.
|
||||
missing_behavior: Behavior | None = None
|
||||
ambiguous_behavior: Behavior | None = None
|
||||
zip: ZipRuleConfig = Field(default_factory=ZipRuleConfig)
|
||||
|
||||
|
||||
class AttachmentsConfig(StrictModel):
|
||||
base_path: str = "."
|
||||
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
|
||||
allow_individual: bool = False
|
||||
send_without_attachments: bool = True
|
||||
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
|
||||
@property
|
||||
def individual_base_path_values(self) -> set[str]:
|
||||
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
|
||||
|
||||
|
||||
class EntryConfig(StrictModel):
|
||||
id: str | None = None
|
||||
active: bool = True
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_combine_flags(cls, value: Any) -> Any:
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
normalized = dict(value)
|
||||
for address_field in ("from", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"):
|
||||
merge_key = f"merge_{address_field}"
|
||||
combine_key = f"combine_{address_field}"
|
||||
if merge_key not in normalized and combine_key in normalized:
|
||||
normalized[merge_key] = normalized[combine_key]
|
||||
normalized.pop(combine_key, None)
|
||||
return normalized
|
||||
|
||||
# Compatibility fields written by older/current WebUI recipient rows.
|
||||
# Address routing uses the explicit to/cc/bcc/reply_to/from fields below;
|
||||
# these values are retained for round-tripping but are not used for sending.
|
||||
name: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1)
|
||||
merge_from: bool = False # Deprecated compatibility field; From never merges.
|
||||
|
||||
@field_validator("from_", mode="before")
|
||||
@classmethod
|
||||
def normalize_from_list(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, dict):
|
||||
return [] if not any(value.values()) else [value]
|
||||
return value
|
||||
|
||||
to: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_to: bool = True
|
||||
|
||||
cc: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_cc: bool = True
|
||||
|
||||
bcc: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_bcc: bool = True
|
||||
|
||||
reply_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_reply_to: bool = True
|
||||
|
||||
bounce_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_bounce_to: bool = True
|
||||
|
||||
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
merge_disposition_notification_to: bool = True
|
||||
|
||||
attachments: list[AttachmentConfig] = Field(default_factory=list)
|
||||
combine_attachments: bool = True
|
||||
|
||||
fields: dict[str, Any] = Field(default_factory=dict)
|
||||
last_sent: str | None = None
|
||||
|
||||
|
||||
class SourceConfig(StrictModel):
|
||||
type: SourceType
|
||||
path: str
|
||||
delimiter: str = ";"
|
||||
encoding: str = "utf-8"
|
||||
has_header: bool = True
|
||||
|
||||
|
||||
class EntriesConfig(StrictModel):
|
||||
inline: list[EntryConfig] | None = None
|
||||
source: SourceConfig | None = None
|
||||
mapping: dict[str, str] | None = None
|
||||
defaults: EntryConfig | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def inline_or_external(self) -> "EntriesConfig":
|
||||
has_inline = self.inline is not None
|
||||
has_external_source = self.source is not None or self.mapping is not None
|
||||
# defaults are compatible with both inline and external entries. The
|
||||
# WebUI stores the current per-entry combination defaults here even for
|
||||
# inline campaigns, so treating defaults as an external-source marker
|
||||
# made valid UI drafts fail backend validation.
|
||||
if has_inline and has_external_source:
|
||||
raise ValueError("entries must be either inline or source-based, not both")
|
||||
if has_inline:
|
||||
return self
|
||||
if self.source is None or self.mapping is None:
|
||||
raise ValueError("external entries require source and mapping")
|
||||
return self
|
||||
|
||||
@property
|
||||
def is_inline(self) -> bool:
|
||||
return self.inline is not None
|
||||
|
||||
@property
|
||||
def is_external(self) -> bool:
|
||||
return self.source is not None
|
||||
|
||||
|
||||
class ValidationPolicy(StrictModel):
|
||||
missing_required_attachment: Behavior = Behavior.ASK
|
||||
missing_optional_attachment: Behavior = Behavior.WARN
|
||||
ambiguous_attachment_match: Behavior = Behavior.ASK
|
||||
ignore_empty_fields: bool = False
|
||||
unsent_attachment_files: Behavior = Behavior.WARN
|
||||
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
||||
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
||||
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP
|
||||
|
||||
|
||||
class RateLimitConfig(StrictModel):
|
||||
messages_per_minute: int = Field(default=5, ge=1)
|
||||
concurrency: int = Field(default=1, ge=1)
|
||||
|
||||
|
||||
class ImapAppendSentConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
folder: str = "auto"
|
||||
|
||||
|
||||
class RetryConfig(StrictModel):
|
||||
max_attempts: int = Field(default=3, ge=1)
|
||||
backoff_seconds: list[int] = Field(default_factory=lambda: [60, 300, 900])
|
||||
|
||||
@field_validator("backoff_seconds")
|
||||
@classmethod
|
||||
def backoff_values_must_be_positive(cls, values: list[int]) -> list[int]:
|
||||
if any(value < 1 for value in values):
|
||||
raise ValueError("backoff_seconds values must be >= 1")
|
||||
return values
|
||||
|
||||
|
||||
class DeliveryConfig(StrictModel):
|
||||
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
|
||||
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
|
||||
retry: RetryConfig = Field(default_factory=RetryConfig)
|
||||
|
||||
|
||||
class StatusTrackingConfig(StrictModel):
|
||||
enabled: bool = True
|
||||
initial_build_status: BuildStatus = BuildStatus.BUILT
|
||||
initial_send_status: SendStatus = SendStatus.DRAFT
|
||||
|
||||
|
||||
class CampaignConfig(StrictModel):
|
||||
version: Literal["1.0"]
|
||||
campaign: CampaignMeta
|
||||
fields: list[FieldDefinition] = Field(default_factory=list)
|
||||
global_values: dict[str, Any] = Field(default_factory=dict)
|
||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||
recipients: RecipientsConfig = Field(default_factory=RecipientsConfig)
|
||||
template: TemplateConfig
|
||||
attachments: AttachmentsConfig = Field(default_factory=AttachmentsConfig)
|
||||
entries: EntriesConfig
|
||||
validation_policy: ValidationPolicy = Field(default_factory=ValidationPolicy)
|
||||
delivery: DeliveryConfig = Field(default_factory=DeliveryConfig)
|
||||
status_tracking: StatusTrackingConfig = Field(default_factory=StatusTrackingConfig)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def field_names_must_be_unique(self) -> "CampaignConfig":
|
||||
names = [field.name for field in self.fields]
|
||||
duplicates = sorted({name for name in names if names.count(name) > 1})
|
||||
if duplicates:
|
||||
raise ValueError(f"duplicate field definitions: {', '.join(duplicates)}")
|
||||
return self
|
||||
|
||||
@property
|
||||
def field_names(self) -> set[str]:
|
||||
return {field.name for field in self.fields}
|
||||
|
||||
def resolve_relative_path(self, campaign_file: Path, raw_path: str) -> Path:
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_file.parent / path).resolve()
|
||||
25
src/govoplan_campaign/backend/campaign/template_values.py
Normal file
25
src/govoplan_campaign/backend/campaign/template_values.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .addressing import effective_address_lists, recipient_template_values
|
||||
from .field_values import effective_entry_field_values
|
||||
from .models import CampaignConfig, EntryConfig
|
||||
|
||||
|
||||
def build_template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for field in config.fields:
|
||||
values.setdefault(field.name, "")
|
||||
values.setdefault(f"global::{field.name}", "")
|
||||
values.setdefault(f"local::{field.name}", "")
|
||||
for key, value in config.global_values.items():
|
||||
values[f"global::{key}"] = value
|
||||
for key, value in effective_entry_field_values(config, entry).items():
|
||||
values[key] = value
|
||||
values[f"local::{key}"] = value
|
||||
if entry.id:
|
||||
values["local::id"] = entry.id
|
||||
values["local::active"] = entry.active
|
||||
values.update(recipient_template_values(effective_address_lists(config, entry)))
|
||||
return values
|
||||
437
src/govoplan_campaign/backend/campaign/validation.py
Normal file
437
src/govoplan_campaign/backend/campaign/validation.py
Normal file
@@ -0,0 +1,437 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class SemanticIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
severity: Severity
|
||||
code: str
|
||||
message: str
|
||||
path: str | None = None
|
||||
|
||||
|
||||
class SemanticReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
issues: list[SemanticIssue] = Field(default_factory=list)
|
||||
entries_mode: str
|
||||
entries_count: int | None = None
|
||||
attachments_base_path: str
|
||||
rate_limit: str
|
||||
imap_append_enabled: bool
|
||||
|
||||
@property
|
||||
def error_count(self) -> int:
|
||||
return sum(1 for issue in self.issues if issue.severity == Severity.ERROR)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for issue in self.issues if issue.severity == Severity.WARNING)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.error_count == 0
|
||||
|
||||
|
||||
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
|
||||
return SemanticIssue(severity=severity, code=code, message=message, path=path)
|
||||
|
||||
|
||||
def _resolve(campaign_file: Path, raw_path: str) -> Path:
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_file.parent / path).resolve()
|
||||
|
||||
|
||||
def _mapping_target_field_name(target: str) -> str | None:
|
||||
if target.startswith("fields."):
|
||||
return target.split(".", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _mapping_target_known(target: str, field_names: set[str]) -> bool:
|
||||
direct_targets = {
|
||||
"id",
|
||||
"active",
|
||||
"last_sent",
|
||||
"merge_from",
|
||||
"merge_to",
|
||||
"merge_cc",
|
||||
"merge_bcc",
|
||||
"merge_reply_to",
|
||||
"merge_bounce_to",
|
||||
"merge_disposition_notification_to",
|
||||
"combine_to",
|
||||
"combine_cc",
|
||||
"combine_bcc",
|
||||
"combine_reply_to",
|
||||
"combine_bounce_to",
|
||||
"combine_disposition_notification_to",
|
||||
"combine_attachments",
|
||||
}
|
||||
if target in direct_targets:
|
||||
return True
|
||||
if target.startswith("fields."):
|
||||
name = target.split(".", 1)[1]
|
||||
return not field_names or name in field_names
|
||||
if target.startswith("from."):
|
||||
return target in {"from.email", "from.name", "from.type"}
|
||||
for prefix in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
|
||||
if target.startswith(prefix + "."):
|
||||
parts = target.split(".")
|
||||
return len(parts) == 3 and parts[1].isdigit() and parts[2] in {"email", "name", "type"}
|
||||
if target.startswith("attachments."):
|
||||
parts = target.split(".")
|
||||
# attachments.0.zip.filename_template etc.
|
||||
if len(parts) >= 3 and parts[1].isdigit():
|
||||
if parts[2] in {
|
||||
"id",
|
||||
"label",
|
||||
"base_dir",
|
||||
"file_filter",
|
||||
"include_subdirs",
|
||||
"required",
|
||||
"allow_multiple",
|
||||
"missing_behavior",
|
||||
"ambiguous_behavior",
|
||||
}:
|
||||
return len(parts) == 3
|
||||
if parts[2] == "zip" and len(parts) == 4:
|
||||
return parts[3] in {"enabled", "mode", "filename_template", "password_mode", "password", "password_field", "password_template", "method"}
|
||||
return False
|
||||
|
||||
|
||||
def _csv_header(path: Path, delimiter: str, encoding: str) -> list[str] | None:
|
||||
with path.open("r", encoding=encoding, newline="") as handle:
|
||||
reader = csv.reader(handle, delimiter=delimiter)
|
||||
try:
|
||||
return next(reader)
|
||||
except StopIteration:
|
||||
return []
|
||||
|
||||
|
||||
def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, str]]:
|
||||
if not config.template.source:
|
||||
return []
|
||||
source = config.template.source
|
||||
paths: list[tuple[str, str]] = []
|
||||
if source.subject_path:
|
||||
paths.append(("/template/source/subject_path", source.subject_path))
|
||||
if source.text_path:
|
||||
paths.append(("/template/source/text_path", source.text_path))
|
||||
if source.html_path:
|
||||
paths.append(("/template/source/html_path", source.html_path))
|
||||
return paths
|
||||
|
||||
|
||||
def _attachment_base_path_report_value(config: CampaignConfig) -> str:
|
||||
if config.attachments.base_paths:
|
||||
return ", ".join(f"{base_path.name}: {base_path.path}" for base_path in config.attachments.base_paths)
|
||||
return config.attachments.base_path
|
||||
|
||||
|
||||
def _iter_attachment_rules(config: CampaignConfig) -> Iterable[tuple[str, AttachmentConfig, bool]]:
|
||||
for index, attachment_config in enumerate(config.attachments.global_):
|
||||
yield f"/attachments/global/{index}", attachment_config, False
|
||||
|
||||
inline_entries = config.entries.inline or [] if config.entries.is_inline else []
|
||||
for entry_index, entry in enumerate(inline_entries):
|
||||
if not entry.active:
|
||||
continue
|
||||
for attachment_index, attachment_config in enumerate(entry.attachments):
|
||||
yield f"/entries/inline/{entry_index}/attachments/{attachment_index}", attachment_config, True
|
||||
|
||||
if config.entries.defaults:
|
||||
for attachment_index, attachment_config in enumerate(config.entries.defaults.attachments):
|
||||
yield f"/entries/defaults/attachments/{attachment_index}", attachment_config, True
|
||||
|
||||
|
||||
def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
configured_paths = {base_path.path for base_path in config.attachments.base_paths}
|
||||
individual_paths = config.attachments.individual_base_path_values
|
||||
|
||||
if config.attachments.base_paths:
|
||||
for index, base_path in enumerate(config.attachments.base_paths):
|
||||
if not base_path.name.strip():
|
||||
issues.append(_issue(Severity.WARNING, "attachment_base_path_missing_name", "attachment base path has no display name", f"/attachments/base_paths/{index}/name"))
|
||||
if not base_path.path.strip():
|
||||
issues.append(_issue(Severity.ERROR, "attachment_base_path_missing_path", "attachment base path has no path", f"/attachments/base_paths/{index}/path"))
|
||||
elif not config.attachments.base_path:
|
||||
issues.append(_issue(Severity.INFO, "missing_attachment_base_path", "Attachment base path is not configured yet.", "/attachments/base_path"))
|
||||
|
||||
if configured_paths:
|
||||
for path, attachment_config, is_individual in _iter_attachment_rules(config):
|
||||
if attachment_config.base_dir and attachment_config.base_dir not in configured_paths:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"unknown_attachment_base_path",
|
||||
f"attachment rule refers to base path {attachment_config.base_dir!r}, but it is not listed in attachments.base_paths",
|
||||
f"{path}/base_dir",
|
||||
))
|
||||
if is_individual and individual_paths and attachment_config.base_dir not in individual_paths:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"individual_attachment_base_path_not_allowed",
|
||||
f"individual attachment rule uses base path {attachment_config.base_dir!r}, but that base path does not allow individual attachments",
|
||||
f"{path}/base_dir",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
collection = config.attachments.zip
|
||||
issues: list[SemanticIssue] = []
|
||||
if not collection.enabled:
|
||||
return issues
|
||||
if not collection.archives:
|
||||
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
|
||||
|
||||
archive_ids: set[str] = set()
|
||||
archive_names: set[str] = set()
|
||||
standard_count = 0
|
||||
field_definitions = {field.name: field for field in config.fields}
|
||||
for index, archive in enumerate(collection.archives):
|
||||
path = f"/attachments/zip/archives/{index}"
|
||||
if not archive.id.strip():
|
||||
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
|
||||
elif archive.id in archive_ids:
|
||||
issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id"))
|
||||
archive_ids.add(archive.id)
|
||||
normalized_archive_name = _normalized_zip_archive_name(archive.name)
|
||||
if not normalized_archive_name:
|
||||
issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name"))
|
||||
elif normalized_archive_name in archive_names:
|
||||
issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name"))
|
||||
archive_names.add(normalized_archive_name)
|
||||
if archive.standard:
|
||||
standard_count += 1
|
||||
|
||||
if not archive.password_enabled:
|
||||
continue
|
||||
if archive.password_mode == ZipPasswordMode.DIRECT:
|
||||
if not (archive.password or ""):
|
||||
issues.append(_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password"))
|
||||
continue
|
||||
if archive.password_mode == ZipPasswordMode.TEMPLATE:
|
||||
if not (archive.password_template or ""):
|
||||
issues.append(_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template"))
|
||||
continue
|
||||
|
||||
field_name = (archive.password_field or "").strip()
|
||||
field = field_definitions.get(field_name)
|
||||
if not field_name:
|
||||
issues.append(_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field"))
|
||||
elif field is None:
|
||||
issues.append(_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field"))
|
||||
elif field.type != FieldType.PASSWORD:
|
||||
issues.append(_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field"))
|
||||
elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
|
||||
issues.append(_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}"))
|
||||
|
||||
if standard_count != 1:
|
||||
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
|
||||
|
||||
for path, rule, _is_individual in _iter_attachment_rules(config):
|
||||
selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
|
||||
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids:
|
||||
issues.append(_issue(Severity.ERROR, "zip_archive_unknown", f"Attachment rule selects unknown ZIP archive {selection!r}", f"{path}/zip/archive_id"))
|
||||
return issues
|
||||
|
||||
|
||||
def _normalized_zip_archive_name(value: str) -> str:
|
||||
normalized = value.strip().casefold()
|
||||
if not normalized:
|
||||
return ""
|
||||
return normalized if normalized.endswith(".zip") else f"{normalized}.zip"
|
||||
|
||||
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
|
||||
return [
|
||||
_issue(
|
||||
Severity.WARNING,
|
||||
"field_override_not_allowed",
|
||||
f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides",
|
||||
f"{path_prefix}/fields/{field_name}",
|
||||
)
|
||||
for field_name in ignored_entry_field_overrides(config, entry)
|
||||
]
|
||||
|
||||
|
||||
def validate_campaign_config(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
campaign_file: str | Path | None = None,
|
||||
check_files: bool = False,
|
||||
) -> SemanticReport:
|
||||
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
|
||||
issues: list[SemanticIssue] = []
|
||||
|
||||
field_names = config.field_names
|
||||
field_definitions = {field.name: field for field in config.fields}
|
||||
declared_names = set(field_definitions)
|
||||
|
||||
for key in config.global_values:
|
||||
if declared_names and key not in declared_names:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"unknown_global_value",
|
||||
f"global_values contains {key!r}, but it is not declared in fields",
|
||||
f"/global_values/{key}",
|
||||
))
|
||||
|
||||
issues.extend(_attachment_path_issues(config))
|
||||
issues.extend(_zip_configuration_issues(config))
|
||||
|
||||
if config.server.imap and config.server.imap.enabled:
|
||||
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
|
||||
if missing:
|
||||
issues.append(_issue(
|
||||
Severity.ERROR,
|
||||
"incomplete_imap_config",
|
||||
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
|
||||
"/server/imap",
|
||||
))
|
||||
|
||||
if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled):
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"delivery_imap_enabled_without_server_imap",
|
||||
"delivery.imap_append_sent is enabled, but server.imap.enabled is not true",
|
||||
"/delivery/imap_append_sent/enabled",
|
||||
))
|
||||
|
||||
if config.campaign.mode == "send" and not config.server.smtp:
|
||||
issues.append(_issue(
|
||||
Severity.ERROR,
|
||||
"missing_smtp_config",
|
||||
"campaign mode is 'send', but no server.smtp configuration is present",
|
||||
"/server/smtp",
|
||||
))
|
||||
|
||||
if config.server.smtp:
|
||||
missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")]
|
||||
if missing:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"incomplete_smtp_config",
|
||||
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
|
||||
"/server/smtp",
|
||||
))
|
||||
|
||||
if config.entries.is_inline:
|
||||
inline_entries = config.entries.inline or []
|
||||
entries_count = len(inline_entries)
|
||||
entries_mode = "inline"
|
||||
if entries_count == 0:
|
||||
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
|
||||
for index, entry in enumerate(inline_entries):
|
||||
if entry.active:
|
||||
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
|
||||
else:
|
||||
entries_count = None
|
||||
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
|
||||
mapping = config.entries.mapping or {}
|
||||
if not mapping:
|
||||
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
|
||||
for target in mapping:
|
||||
if not _mapping_target_known(target, field_names):
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"unknown_mapping_target",
|
||||
f"mapping target {target!r} is not recognized by the current campaign model",
|
||||
f"/entries/mapping/{target}",
|
||||
))
|
||||
field_name = _mapping_target_field_name(target)
|
||||
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"mapping_target_not_overridable",
|
||||
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
|
||||
f"/entries/mapping/{target}",
|
||||
))
|
||||
if config.entries.defaults:
|
||||
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
|
||||
if check_files and config.entries.source:
|
||||
source_path = _resolve(campaign_path, config.entries.source.path)
|
||||
if not source_path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.ERROR,
|
||||
"entries_source_not_found",
|
||||
f"entries source file does not exist: {source_path}",
|
||||
"/entries/source/path",
|
||||
))
|
||||
elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header:
|
||||
try:
|
||||
header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding)
|
||||
header_set = set(header or [])
|
||||
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
|
||||
if missing_columns:
|
||||
issues.append(_issue(
|
||||
Severity.ERROR,
|
||||
"mapping_columns_missing",
|
||||
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
|
||||
"/entries/mapping",
|
||||
))
|
||||
except OSError as exc:
|
||||
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
|
||||
|
||||
if check_files:
|
||||
if config.attachments.base_paths:
|
||||
for index, base_path_config in enumerate(config.attachments.base_paths):
|
||||
attachments_base_path = _resolve(campaign_path, base_path_config.path)
|
||||
if not attachments_base_path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"attachments_base_path_not_found",
|
||||
f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}",
|
||||
f"/attachments/base_paths/{index}/path",
|
||||
))
|
||||
else:
|
||||
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
|
||||
if not attachments_base_path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"attachments_base_path_not_found",
|
||||
f"attachments.base_path does not exist: {attachments_base_path}",
|
||||
"/attachments/base_path",
|
||||
))
|
||||
for schema_path, raw_path in _iter_template_source_paths(config):
|
||||
path = _resolve(campaign_path, raw_path)
|
||||
if not path.exists():
|
||||
issues.append(_issue(
|
||||
Severity.ERROR,
|
||||
"template_source_not_found",
|
||||
f"template source file does not exist: {path}",
|
||||
schema_path,
|
||||
))
|
||||
|
||||
report = SemanticReport(
|
||||
campaign_id=config.campaign.id,
|
||||
campaign_name=config.campaign.name,
|
||||
issues=issues,
|
||||
entries_mode=entries_mode,
|
||||
entries_count=entries_count,
|
||||
attachments_base_path=_attachment_base_path_report_value(config),
|
||||
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
|
||||
imap_append_enabled=config.delivery.imap_append_sent.enabled,
|
||||
)
|
||||
return report
|
||||
0
src/govoplan_campaign/backend/db/__init__.py
Normal file
0
src/govoplan_campaign/backend/db/__init__.py
Normal file
334
src/govoplan_campaign/backend/db/models.py
Normal file
334
src/govoplan_campaign/backend/db/models.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
try:
|
||||
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class CampaignStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
VALIDATED = "validated"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
READY_TO_QUEUE = "ready_to_queue"
|
||||
QUEUED = "queued"
|
||||
SENDING = "sending"
|
||||
SENT = "sent"
|
||||
PARTIALLY_COMPLETED = "partially_completed"
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class CampaignVersionWorkflowState(StrEnum):
|
||||
EDITING = "editing"
|
||||
UNDER_REVIEW = "under_review"
|
||||
APPROVED = "approved"
|
||||
BUILT = "built"
|
||||
QUEUED = "queued"
|
||||
SENDING = "sending"
|
||||
COMPLETED = "completed"
|
||||
PARTIALLY_COMPLETED = "partially_completed"
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class CampaignVersionFlow(StrEnum):
|
||||
CREATE = "create"
|
||||
REVIEW = "review"
|
||||
SEND = "send"
|
||||
MANUAL = "manual"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
class JobBuildStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
BUILT = "built"
|
||||
BUILD_FAILED = "build_failed"
|
||||
|
||||
|
||||
class JobValidationStatus(StrEnum):
|
||||
READY = "ready"
|
||||
WARNING = "warning"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
BLOCKED = "blocked"
|
||||
EXCLUDED = "excluded"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class JobQueueStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
QUEUED = "queued"
|
||||
SENDING = "sending"
|
||||
PAUSED = "paused"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobSendStatus(StrEnum):
|
||||
NOT_QUEUED = "not_queued"
|
||||
QUEUED = "queued"
|
||||
CLAIMED = "claimed"
|
||||
SENDING = "sending"
|
||||
SMTP_ACCEPTED = "smtp_accepted"
|
||||
SENT = "sent" # legacy value retained for existing databases/reports
|
||||
OUTCOME_UNKNOWN = "outcome_unknown"
|
||||
FAILED_TEMPORARY = "failed_temporary"
|
||||
FAILED_PERMANENT = "failed_permanent"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class JobImapStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
APPENDED = "appended"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class IssueSeverity(StrEnum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class Campaign(Base, TimestampMixin):
|
||||
__tablename__ = "campaigns"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("groups.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
status: Mapped[str] = mapped_column(String(50), default=CampaignStatus.DRAFT.value, nullable=False, index=True)
|
||||
current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
tenant: Mapped[Tenant] = relationship()
|
||||
versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
||||
jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CampaignShare(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_shares"
|
||||
__table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
|
||||
|
||||
class CampaignVersion(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_versions"
|
||||
__table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False)
|
||||
source_filename: Mapped[str | None] = mapped_column(String(500))
|
||||
source_base_path: Mapped[str | None] = mapped_column(String(1000))
|
||||
|
||||
# Editor/workflow metadata used by the WebUI and future desktop clients.
|
||||
# A campaign version can be the autosaved working copy of a new or existing
|
||||
# campaign, so no separate CampaignDraft entity is needed.
|
||||
workflow_state: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=CampaignVersionWorkflowState.EDITING.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
current_flow: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
default=CampaignVersionFlow.MANUAL.value,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
current_step: Mapped[str | None] = mapped_column(String(100))
|
||||
is_complete: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
editor_state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
# Explicit user-requested lock. This is deliberately separate from
|
||||
# locked_at, which represents the reversible validation lock used by the
|
||||
# build/send workflow. Temporary user locks may later receive a dedicated
|
||||
# RBAC permission for unlocking; permanent locks never unlock in place.
|
||||
user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||
user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
campaign: Mapped[Campaign] = relationship(back_populates="versions")
|
||||
|
||||
|
||||
class CampaignJob(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_jobs"
|
||||
__table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
entry_id: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
|
||||
recipient_email: Mapped[str | None] = mapped_column(String(320), index=True)
|
||||
subject: Mapped[str | None] = mapped_column(String(998))
|
||||
message_id_header: Mapped[str | None] = mapped_column(String(255))
|
||||
eml_storage_key: Mapped[str | None] = mapped_column(String(1000))
|
||||
eml_local_path: Mapped[str | None] = mapped_column(String(1000))
|
||||
eml_size_bytes: Mapped[int | None] = mapped_column(Integer)
|
||||
eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
|
||||
build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True)
|
||||
validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True)
|
||||
queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True)
|
||||
send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True)
|
||||
imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True)
|
||||
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_error: Mapped[str | None] = mapped_column(Text)
|
||||
queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||
issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
||||
|
||||
campaign: Mapped[Campaign] = relationship(back_populates="jobs")
|
||||
|
||||
|
||||
class CampaignIssue(Base, TimestampMixin):
|
||||
__tablename__ = "campaign_issues"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source: Mapped[str | None] = mapped_column(String(255))
|
||||
behavior: Mapped[str | None] = mapped_column(String(50))
|
||||
|
||||
|
||||
class AttachmentBlob(Base, TimestampMixin):
|
||||
__tablename__ = "attachment_blobs"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
mime_type: Mapped[str | None] = mapped_column(String(255))
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
storage_key: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
|
||||
|
||||
class AttachmentInstance(Base, TimestampMixin):
|
||||
__tablename__ = "attachment_instances"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
logical_name: Mapped[str | None] = mapped_column(String(500))
|
||||
filename: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)
|
||||
|
||||
|
||||
class SendAttempt(Base, TimestampMixin):
|
||||
__tablename__ = "send_attempts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(50), default="started", nullable=False, index=True)
|
||||
claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
smtp_status_code: Mapped[int | None] = mapped_column(Integer)
|
||||
smtp_response: Mapped[str | None] = mapped_column(Text)
|
||||
error_type: Mapped[str | None] = mapped_column(String(255))
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ImapAppendAttempt(Base, TimestampMixin):
|
||||
__tablename__ = "imap_append_attempts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
attempt_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
folder: Mapped[str | None] = mapped_column(String(500))
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
error_message: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AttachmentBlob",
|
||||
"AttachmentInstance",
|
||||
"Campaign",
|
||||
"CampaignIssue",
|
||||
"CampaignJob",
|
||||
"CampaignShare",
|
||||
"CampaignStatus",
|
||||
"CampaignVersion",
|
||||
"CampaignVersionFlow",
|
||||
"CampaignVersionWorkflowState",
|
||||
"Group",
|
||||
"ImapAppendAttempt",
|
||||
"IssueSeverity",
|
||||
"JobBuildStatus",
|
||||
"JobImapStatus",
|
||||
"JobQueueStatus",
|
||||
"JobSendStatus",
|
||||
"JobValidationStatus",
|
||||
"SendAttempt",
|
||||
"Tenant",
|
||||
"User",
|
||||
"UserGroupMembership",
|
||||
]
|
||||
0
src/govoplan_campaign/backend/dev/__init__.py
Normal file
0
src/govoplan_campaign/backend/dev/__init__.py
Normal file
290
src/govoplan_campaign/backend/dev/mock_campaign.py
Normal file
290
src/govoplan_campaign/backend/dev/mock_campaign.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from email import policy
|
||||
from email.message import EmailMessage
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
||||
from govoplan_files.backend.storage.campaign_attachments import (
|
||||
annotate_built_messages_with_managed_files,
|
||||
prepared_campaign_snapshot,
|
||||
public_attachment_summary_payload,
|
||||
)
|
||||
from govoplan_mail.backend.dev.mock_mailbox import (
|
||||
clear_records,
|
||||
consume_fail_next_imap,
|
||||
consume_fail_next_smtp,
|
||||
get_failures,
|
||||
list_records,
|
||||
record_imap_append,
|
||||
record_smtp_delivery,
|
||||
)
|
||||
|
||||
|
||||
class MockCampaignSendError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
|
||||
if address is None:
|
||||
return None
|
||||
return {"email": address.email, "name": address.name}
|
||||
|
||||
|
||||
def _message_addresses_payload(addresses: list[MessageAddress]) -> list[dict[str, Any]]:
|
||||
return [{"email": item.email, "name": item.name} for item in addresses]
|
||||
|
||||
|
||||
def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
||||
return [issue.model_dump(mode="json") for issue in message.issues]
|
||||
|
||||
|
||||
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
|
||||
return [public_attachment_summary_payload(attachment) for attachment in message.attachments]
|
||||
|
||||
|
||||
def _message_payload(message: MessageDraft) -> dict[str, Any]:
|
||||
return {
|
||||
"entry_index": message.entry_index,
|
||||
"entry_id": message.entry_id,
|
||||
"active": message.active,
|
||||
"subject": message.subject,
|
||||
"from": _message_address_payload(message.from_),
|
||||
"from_all": _message_addresses_payload(message.from_all),
|
||||
"to": _message_addresses_payload(message.to),
|
||||
"cc": _message_addresses_payload(message.cc),
|
||||
"bcc": _message_addresses_payload(message.bcc),
|
||||
"reply_to": _message_addresses_payload(message.reply_to),
|
||||
"build_status": str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status),
|
||||
"validation_status": message.validation_status.value,
|
||||
"send_status": str(message.send_status.value if hasattr(message.send_status, "value") else message.send_status),
|
||||
"imap_status": message.imap_status.value,
|
||||
"attachment_count": message.attachment_count,
|
||||
"attachments": _attachment_payloads(message),
|
||||
"issues": _issue_payloads(message),
|
||||
"eml_size_bytes": message.eml_size_bytes,
|
||||
"queueable": message.is_queueable,
|
||||
}
|
||||
|
||||
|
||||
def _recipient_emails(message: MessageDraft) -> list[str]:
|
||||
values = [item.email for item in message.to + message.cc + message.bcc if item.email]
|
||||
return list(dict.fromkeys(values))
|
||||
|
||||
|
||||
def _envelope_from(message: MessageDraft, *, fallback: str = "mock-sender@mock.local") -> str:
|
||||
if message.bounce_to:
|
||||
return message.bounce_to[0].email
|
||||
if message.from_ and message.from_.email:
|
||||
return message.from_.email
|
||||
return fallback
|
||||
|
||||
|
||||
def _raw_message_bytes(message: EmailMessage) -> bytes:
|
||||
return message.as_bytes(policy=policy.SMTP)
|
||||
|
||||
|
||||
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
|
||||
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower()
|
||||
if not needle:
|
||||
return []
|
||||
return [recipient for recipient in recipients if needle in recipient.lower()]
|
||||
|
||||
|
||||
def _can_mock_send(
|
||||
message: MessageDraft,
|
||||
*,
|
||||
include_warnings: bool,
|
||||
include_needs_review: bool,
|
||||
) -> tuple[bool, str | None]:
|
||||
if not message.active:
|
||||
return False, "Recipient is inactive"
|
||||
if str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status) != "built":
|
||||
return False, f"Message is not built ({message.build_status})"
|
||||
if message.validation_status == MessageValidationStatus.READY:
|
||||
return True, None
|
||||
if message.validation_status == MessageValidationStatus.WARNING and include_warnings:
|
||||
return True, None
|
||||
if message.validation_status == MessageValidationStatus.NEEDS_REVIEW and include_needs_review:
|
||||
return True, None
|
||||
return False, f"Validation status is {message.validation_status.value}"
|
||||
|
||||
|
||||
def run_mock_campaign_send(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
send: bool = False,
|
||||
include_warnings: bool = True,
|
||||
include_needs_review: bool = False,
|
||||
append_sent: bool = True,
|
||||
clear_mailbox: bool = False,
|
||||
check_files: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Validate, build and optionally mock-send a version without mutating it.
|
||||
|
||||
This is a dev/test route. It does not change campaign/version status, does
|
||||
not queue real jobs and does not use the configured SMTP/IMAP servers. It
|
||||
records mock SMTP deliveries and mock IMAP appends in the integrated mock
|
||||
mailbox only when send=True.
|
||||
"""
|
||||
|
||||
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
|
||||
if not campaign:
|
||||
raise MockCampaignSendError("Campaign not found or not accessible")
|
||||
wanted_version_id = version_id or campaign.current_version_id
|
||||
if not wanted_version_id:
|
||||
raise MockCampaignSendError("Campaign has no current version")
|
||||
version = session.get(CampaignVersion, wanted_version_id)
|
||||
if not version or version.campaign_id != campaign.id:
|
||||
raise MockCampaignSendError("Campaign version not found or not part of campaign")
|
||||
|
||||
if clear_mailbox:
|
||||
clear_records()
|
||||
|
||||
with 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="multimailer-mock-send-",
|
||||
) as prepared:
|
||||
prepared_raw = load_campaign_json(prepared.path)
|
||||
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
|
||||
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
|
||||
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
||||
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
||||
|
||||
send_results: list[dict[str, Any]] = []
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
imap_appended_count = 0
|
||||
imap_failed_count = 0
|
||||
|
||||
for built in build_result.built_messages:
|
||||
draft = built.draft
|
||||
can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review)
|
||||
row: dict[str, Any] = {
|
||||
"entry_index": draft.entry_index,
|
||||
"entry_id": draft.entry_id,
|
||||
"subject": draft.subject,
|
||||
"validation_status": draft.validation_status.value,
|
||||
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
|
||||
"to": _message_addresses_payload(draft.to),
|
||||
"attachments": _attachment_payloads(draft),
|
||||
"issues": _issue_payloads(draft),
|
||||
}
|
||||
|
||||
if not can_send or built.mime is None:
|
||||
skipped_count += 1
|
||||
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
|
||||
send_results.append(row)
|
||||
continue
|
||||
|
||||
recipients = _recipient_emails(draft)
|
||||
envelope_from = _envelope_from(draft)
|
||||
if not recipients:
|
||||
skipped_count += 1
|
||||
row.update({"status": "skipped", "message": "No envelope recipients"})
|
||||
send_results.append(row)
|
||||
continue
|
||||
|
||||
if not send:
|
||||
row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients})
|
||||
send_results.append(row)
|
||||
continue
|
||||
|
||||
try:
|
||||
if consume_fail_next_smtp():
|
||||
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
|
||||
rejected = _smtp_rejection_matches(recipients)
|
||||
if rejected and len(rejected) == len(recipients):
|
||||
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
|
||||
|
||||
accepted = [recipient for recipient in recipients if recipient not in rejected]
|
||||
smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
|
||||
sent_count += 1
|
||||
row.update({
|
||||
"status": "sent",
|
||||
"message": f"Mock SMTP captured as {smtp_record.id}",
|
||||
"smtp_message_id": smtp_record.id,
|
||||
"envelope_from": envelope_from,
|
||||
"envelope_recipients": accepted,
|
||||
"refused_recipients": rejected,
|
||||
})
|
||||
|
||||
if append_sent:
|
||||
try:
|
||||
if consume_fail_next_imap():
|
||||
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
|
||||
folder = "Sent"
|
||||
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
|
||||
folder = config.delivery.imap_append_sent.folder
|
||||
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
|
||||
folder = config.server.imap.sent_folder
|
||||
imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
|
||||
imap_appended_count += 1
|
||||
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
|
||||
except Exception as exc:
|
||||
imap_failed_count += 1
|
||||
row.update({"imap_status": "failed", "imap_error": str(exc)})
|
||||
|
||||
except Exception as exc:
|
||||
failed_count += 1
|
||||
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
|
||||
|
||||
send_results.append(row)
|
||||
|
||||
validation_json = validation_report.model_dump(mode="json")
|
||||
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
|
||||
build_report = build_result.report
|
||||
build_json = build_report.model_dump(mode="json")
|
||||
build_json.update({
|
||||
"built_count": build_report.built_count,
|
||||
"queueable_count": build_report.queueable_count,
|
||||
"needs_review_count": build_report.needs_review_count,
|
||||
"blocked_count": build_report.blocked_count,
|
||||
"warning_count": build_report.warning_count,
|
||||
"ready_count": build_report.ready_count,
|
||||
"messages": [_message_payload(message) for message in build_report.messages],
|
||||
})
|
||||
|
||||
attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"})
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"version_id": version.id,
|
||||
"version_number": version.version_number,
|
||||
"send_requested": send,
|
||||
"include_warnings": include_warnings,
|
||||
"include_needs_review": include_needs_review,
|
||||
"append_sent": append_sent,
|
||||
"steps": [
|
||||
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
|
||||
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
|
||||
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}},
|
||||
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}},
|
||||
],
|
||||
"validation": validation_json,
|
||||
"build": build_json,
|
||||
"send": {
|
||||
"attempted_count": attempted_count,
|
||||
"sent_count": sent_count,
|
||||
"failed_count": failed_count,
|
||||
"skipped_count": skipped_count,
|
||||
"imap_appended_count": imap_appended_count,
|
||||
"imap_failed_count": imap_failed_count,
|
||||
"results": send_results,
|
||||
},
|
||||
"mailbox": {"messages": list_records(limit=200)},
|
||||
}
|
||||
0
src/govoplan_campaign/backend/domain/__init__.py
Normal file
0
src/govoplan_campaign/backend/domain/__init__.py
Normal file
210
src/govoplan_campaign/backend/domain/campaign.py
Normal file
210
src/govoplan_campaign/backend/domain/campaign.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from .fields import Field, FieldConfiguration, FieldContents
|
||||
from .recipients import Recipient, RecipientList
|
||||
from .template import MailTemplate
|
||||
|
||||
|
||||
class TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
TLS = "tls"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
@property
|
||||
def standard_port(self) -> int:
|
||||
return 465 if self == TransportSecurity.TLS else 587
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailServerSettings:
|
||||
server: str = ""
|
||||
port: int | None = None
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
transport_security: TransportSecurity = TransportSecurity.PLAIN
|
||||
|
||||
def use_plain(self) -> "MailServerSettings":
|
||||
self.transport_security = TransportSecurity.PLAIN
|
||||
self.port = self.port or self.transport_security.standard_port
|
||||
return self
|
||||
|
||||
def use_tls(self) -> "MailServerSettings":
|
||||
self.transport_security = TransportSecurity.TLS
|
||||
self.port = self.port or self.transport_security.standard_port
|
||||
return self
|
||||
|
||||
def use_starttls(self) -> "MailServerSettings":
|
||||
self.transport_security = TransportSecurity.STARTTLS
|
||||
self.port = self.port or self.transport_security.standard_port
|
||||
return self
|
||||
|
||||
def resolved_port(self) -> int:
|
||||
return self.port or self.transport_security.standard_port
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailAttachmentConfig:
|
||||
base_dir: Path
|
||||
file_filter: str = "*"
|
||||
include_subdirs: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailEntry:
|
||||
field_config: FieldConfiguration
|
||||
is_active: bool = True
|
||||
from_recipient: Recipient | None = None
|
||||
to: RecipientList = field(default_factory=RecipientList)
|
||||
cc: RecipientList = field(default_factory=RecipientList)
|
||||
bcc: RecipientList = field(default_factory=RecipientList)
|
||||
combine_to: bool = True
|
||||
combine_cc: bool = True
|
||||
combine_bcc: bool = True
|
||||
attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
|
||||
combine_attachments: bool = True
|
||||
field_contents: FieldContents = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.field_contents = FieldContents(self.field_config)
|
||||
|
||||
def add_to(self, recipient: Recipient) -> "MailEntry":
|
||||
self.to.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def add_cc(self, recipient: Recipient) -> "MailEntry":
|
||||
self.cc.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def add_bcc(self, recipient: Recipient) -> "MailEntry":
|
||||
self.bcc.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def no_combine_to(self) -> "MailEntry":
|
||||
self.combine_to = False
|
||||
return self
|
||||
|
||||
def combine_to_recipients(self) -> "MailEntry":
|
||||
self.combine_to = True
|
||||
return self
|
||||
|
||||
def no_combine_attachments(self) -> "MailEntry":
|
||||
self.combine_attachments = False
|
||||
return self
|
||||
|
||||
def combine_attachments_with_global(self) -> "MailEntry":
|
||||
self.combine_attachments = True
|
||||
return self
|
||||
|
||||
def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry":
|
||||
self.attachment_configs.append(config)
|
||||
return self
|
||||
|
||||
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry":
|
||||
if not self.field_contents.set_field_content_for_name(name, value):
|
||||
raise KeyError(f"unknown field: {name}")
|
||||
return self
|
||||
|
||||
def get_field_content_from_name(self, name: str) -> Field:
|
||||
return self.field_contents.get_field_content_from_name(name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailCampaign:
|
||||
mail_server_settings: MailServerSettings | None = None
|
||||
global_from: Recipient | None = None
|
||||
global_to: RecipientList = field(default_factory=RecipientList)
|
||||
global_cc: RecipientList = field(default_factory=RecipientList)
|
||||
global_bcc: RecipientList = field(default_factory=RecipientList)
|
||||
individual_from: bool = False
|
||||
individual_to: bool = False
|
||||
individual_cc: bool = False
|
||||
individual_bcc: bool = False
|
||||
base_attachment_path: Path = Path(".")
|
||||
global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
|
||||
individual_attachments: bool = False
|
||||
send_without_attachments: bool = True
|
||||
field_config: FieldConfiguration = field(default_factory=FieldConfiguration)
|
||||
field_contents: FieldContents = field(init=False)
|
||||
subject_template: MailTemplate = field(default_factory=MailTemplate)
|
||||
mail_template: MailTemplate = field(default_factory=MailTemplate)
|
||||
mail_entries: list[MailEntry] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.field_contents = FieldContents(self.field_config)
|
||||
|
||||
@classmethod
|
||||
def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign":
|
||||
return cls(mail_server_settings=settings)
|
||||
|
||||
def add_field(self, name: str, field_type) -> "MailCampaign":
|
||||
from .fields import FieldDescription
|
||||
self.field_config.add_field_at_end(FieldDescription(name, field_type))
|
||||
self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
|
||||
for entry in self.mail_entries:
|
||||
entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
|
||||
return self
|
||||
|
||||
def set_from(self, recipient: Recipient) -> "MailCampaign":
|
||||
self.global_from = recipient
|
||||
return self
|
||||
|
||||
def add_to(self, recipient: Recipient) -> "MailCampaign":
|
||||
self.global_to.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def allow_individual_to(self) -> "MailCampaign":
|
||||
self.individual_to = True
|
||||
return self
|
||||
|
||||
def disallow_individual_to(self) -> "MailCampaign":
|
||||
self.individual_to = False
|
||||
return self
|
||||
|
||||
def allow_individual_attachments(self) -> "MailCampaign":
|
||||
self.individual_attachments = True
|
||||
return self
|
||||
|
||||
def disallow_individual_attachments(self) -> "MailCampaign":
|
||||
self.individual_attachments = False
|
||||
return self
|
||||
|
||||
def dont_send_without_attachments(self) -> "MailCampaign":
|
||||
self.send_without_attachments = False
|
||||
return self
|
||||
|
||||
def send_without_attachments_allowed(self) -> "MailCampaign":
|
||||
self.send_without_attachments = True
|
||||
return self
|
||||
|
||||
def add_new_mail_entry(self) -> MailEntry:
|
||||
entry = MailEntry(self.field_config)
|
||||
self.mail_entries.append(entry)
|
||||
return entry
|
||||
|
||||
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign":
|
||||
if not self.field_contents.set_field_content_for_name(name, value):
|
||||
raise KeyError(f"unknown field: {name}")
|
||||
return self
|
||||
|
||||
def get_field_content_from_name(self, name: str) -> Field:
|
||||
return self.field_contents.get_field_content_from_name(name)
|
||||
|
||||
def all_recipients_for(self, entry: MailEntry) -> list[Recipient]:
|
||||
recipients: list[Recipient] = []
|
||||
if not self.individual_to or entry.combine_to:
|
||||
recipients.extend(self.global_to.recipients)
|
||||
if not self.individual_cc or entry.combine_cc:
|
||||
recipients.extend(self.global_cc.recipients)
|
||||
if not self.individual_bcc or entry.combine_bcc:
|
||||
recipients.extend(self.global_bcc.recipients)
|
||||
if self.individual_to:
|
||||
recipients.extend(entry.to.recipients)
|
||||
if self.individual_cc:
|
||||
recipients.extend(entry.cc.recipients)
|
||||
if self.individual_bcc:
|
||||
recipients.extend(entry.bcc.recipients)
|
||||
return recipients
|
||||
126
src/govoplan_campaign/backend/domain/fields.py
Normal file
126
src/govoplan_campaign/backend/domain/fields.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class FieldType(StrEnum):
|
||||
STRING = "string"
|
||||
INTEGER = "integer"
|
||||
DOUBLE = "double"
|
||||
DATE = "date"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FieldDescription:
|
||||
name: str
|
||||
type: FieldType = FieldType.STRING
|
||||
can_override: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Field:
|
||||
content: Any
|
||||
|
||||
@classmethod
|
||||
def with_content(cls, content: Any) -> "Field":
|
||||
if content is None:
|
||||
raise ValueError("content must not be None")
|
||||
return cls(content=content)
|
||||
|
||||
@property
|
||||
def type(self) -> FieldType:
|
||||
if isinstance(self.content, bool):
|
||||
return FieldType.STRING
|
||||
if isinstance(self.content, int):
|
||||
return FieldType.INTEGER
|
||||
if isinstance(self.content, float):
|
||||
return FieldType.DOUBLE
|
||||
if isinstance(self.content, (date, datetime)):
|
||||
return FieldType.DATE
|
||||
if isinstance(self.content, (bytes, bytearray)):
|
||||
return FieldType.PASSWORD
|
||||
return FieldType.STRING
|
||||
|
||||
def as_string(self) -> str:
|
||||
if isinstance(self.content, (bytes, bytearray)):
|
||||
return self.content.decode("utf-8")
|
||||
if isinstance(self.content, (date, datetime)):
|
||||
return self.content.isoformat()
|
||||
return str(self.content)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldConfiguration:
|
||||
fields: list[FieldDescription] = field(default_factory=list)
|
||||
|
||||
def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration":
|
||||
return self.add_field_at_position(len(self.fields), field_description)
|
||||
|
||||
def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration":
|
||||
return self.add_field_at_position(0, field_description)
|
||||
|
||||
def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration":
|
||||
if self.has_field(field_description.name):
|
||||
raise ValueError(f"field already exists: {field_description.name}")
|
||||
position = max(0, min(position, len(self.fields)))
|
||||
self.fields.insert(position, field_description)
|
||||
return self
|
||||
|
||||
def has_field(self, name: str) -> bool:
|
||||
return any(f.name == name for f in self.fields)
|
||||
|
||||
def get_field_description(self, name: str) -> FieldDescription | None:
|
||||
return next((f for f in self.fields if f.name == name), None)
|
||||
|
||||
def get_field_names(self) -> list[str]:
|
||||
return [f.name for f in self.fields]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldContents:
|
||||
field_config: FieldConfiguration
|
||||
field_map: dict[str, Field] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_description in self.field_config.fields:
|
||||
self.ensure_field(field_description)
|
||||
|
||||
def ensure_field(self, field_description: FieldDescription) -> None:
|
||||
if field_description.name in self.field_map:
|
||||
return
|
||||
match field_description.type:
|
||||
case FieldType.INTEGER:
|
||||
value = 0
|
||||
case FieldType.DOUBLE:
|
||||
value = 0.0
|
||||
case FieldType.DATE:
|
||||
value = date.today()
|
||||
case FieldType.PASSWORD:
|
||||
value = b""
|
||||
case _:
|
||||
value = ""
|
||||
self.field_map[field_description.name] = Field.with_content(value)
|
||||
|
||||
def get_field_content_from_name(self, name: str) -> Field:
|
||||
try:
|
||||
return self.field_map[name]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"unknown field: {name}") from exc
|
||||
|
||||
def set_field_content_for_name(self, name: str, value: Field | Any) -> bool:
|
||||
if name not in self.field_map:
|
||||
return False
|
||||
if not isinstance(value, Field):
|
||||
value = Field.with_content(value)
|
||||
expected = self.field_map[name].type
|
||||
if expected != value.type and expected != FieldType.PASSWORD:
|
||||
raise TypeError(f"field {name!r} expects {expected}, got {value.type}")
|
||||
self.field_map[name] = value
|
||||
return True
|
||||
|
||||
def as_value_map(self, prefix: str) -> dict[str, str]:
|
||||
return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()}
|
||||
29
src/govoplan_campaign/backend/domain/queue.py
Normal file
29
src/govoplan_campaign/backend/domain/queue.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailQueue:
|
||||
messages: list[EmailMessage] = field(default_factory=list)
|
||||
|
||||
def add_mail(self, message: EmailMessage) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
def remove_mail(self, message: EmailMessage) -> bool:
|
||||
if message in self.messages:
|
||||
self.messages.remove(message)
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def mail_count(self) -> int:
|
||||
return len(self.messages)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self.messages
|
||||
|
||||
def __iter__(self) -> Iterator[EmailMessage]:
|
||||
return iter(self.messages)
|
||||
43
src/govoplan_campaign/backend/domain/recipients.py
Normal file
43
src/govoplan_campaign/backend/domain/recipients.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from email.utils import formataddr
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
TO = "to"
|
||||
CC = "cc"
|
||||
BCC = "bcc"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Recipient:
|
||||
address: str
|
||||
name: str | None = None
|
||||
type: RecipientType = RecipientType.TO
|
||||
|
||||
def formatted(self) -> str:
|
||||
return formataddr((self.name or self.address, self.address))
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipientList:
|
||||
recipients: list[Recipient] = field(default_factory=list)
|
||||
|
||||
def add_recipient(self, recipient: Recipient) -> "RecipientList":
|
||||
if recipient not in self.recipients:
|
||||
self.recipients.append(recipient)
|
||||
return self
|
||||
|
||||
def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList":
|
||||
for recipient in recipients:
|
||||
self.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def clear_all_recipients(self) -> "RecipientList":
|
||||
self.recipients.clear()
|
||||
return self
|
||||
|
||||
def by_type(self, recipient_type: RecipientType) -> list[Recipient]:
|
||||
return [r for r in self.recipients if r.type == recipient_type]
|
||||
28
src/govoplan_campaign/backend/domain/template.py
Normal file
28
src/govoplan_campaign/backend/domain/template.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailTemplate:
|
||||
template_string: str = ""
|
||||
|
||||
def set_template_string(self, template: str) -> "MailTemplate":
|
||||
self.template_string = template
|
||||
return self
|
||||
|
||||
def get_used_fields(self) -> set[str]:
|
||||
return set(_FIELD_PATTERN.findall(self.template_string))
|
||||
|
||||
def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
if key in values:
|
||||
return values[key]
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _FIELD_PATTERN.sub(replace, self.template_string)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
166
src/govoplan_campaign/backend/manifest.py
Normal file
166
src/govoplan_campaign/backend/manifest.py
Normal file
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category=category,
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("campaigns:campaign:read", "View campaigns", "Open campaign metadata, versions and permitted message summaries.", "Campaigns"),
|
||||
_permission("campaigns:campaign:create", "Create campaigns", "Create new campaigns in an accessible tenant scope.", "Campaigns"),
|
||||
_permission("campaigns:campaign:update", "Edit campaigns", "Edit current working campaign versions.", "Campaigns"),
|
||||
_permission("campaigns:campaign:copy", "Copy campaigns", "Create campaigns or working versions from existing campaigns.", "Campaigns"),
|
||||
_permission("campaigns:campaign:archive", "Archive campaigns", "Archive campaigns without destroying audit evidence.", "Campaigns"),
|
||||
_permission("campaigns:campaign:delete", "Delete campaigns", "Delete draft-only campaigns where retention policy allows it.", "Campaigns"),
|
||||
_permission("campaigns:campaign:share", "Share campaigns", "Grant or revoke explicit campaign access.", "Campaigns"),
|
||||
_permission("campaigns:campaign:validate", "Validate campaigns", "Run technical validation and manage validation locks.", "Campaigns"),
|
||||
_permission("campaigns:campaign:build", "Build campaigns", "Build exact messages and attachment evidence.", "Campaigns"),
|
||||
_permission("campaigns:campaign:review", "Approve campaign review", "Approve or reject built messages and review conditions.", "Campaigns"),
|
||||
_permission("campaigns:campaign:send_test", "Mock-send campaigns", "Use mock delivery and verification tools.", "Campaigns"),
|
||||
_permission("campaigns:campaign:queue", "Queue campaigns", "Place approved executions into the delivery queue.", "Campaigns"),
|
||||
_permission("campaigns:campaign:control", "Control delivery", "Pause, resume or cancel queued and sending jobs.", "Campaigns"),
|
||||
_permission("campaigns:campaign:send", "Send campaigns", "Start real SMTP delivery.", "Campaigns"),
|
||||
_permission("campaigns:campaign:retry", "Retry delivery", "Retry failed or unattempted delivery jobs.", "Campaigns"),
|
||||
_permission("campaigns:campaign:reconcile", "Reconcile delivery", "Resolve outcome-unknown SMTP attempts after inspection.", "Campaigns"),
|
||||
_permission("campaigns:recipient:read", "View recipients", "Read recipient lists and recipient-specific campaign data.", "Recipients"),
|
||||
_permission("campaigns:recipient:write", "Edit recipients", "Create and edit recipient rows and field values.", "Recipients"),
|
||||
_permission("campaigns:recipient:import", "Import recipients", "Bulk-import recipient lists.", "Recipients"),
|
||||
_permission("campaigns:recipient:export", "Export recipients", "Export recipient data and reports.", "Recipients"),
|
||||
_permission("campaigns:report:read", "View reports", "View campaign delivery reports and aggregate outcomes.", "Reports"),
|
||||
_permission("campaigns:report:export", "Export reports", "Download detailed campaign reports.", "Reports"),
|
||||
_permission("campaigns:report:send", "Send reports", "Email campaign reports to configured recipients.", "Reports"),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
RoleTemplate(
|
||||
slug="campaign_manager",
|
||||
name="Campaign manager",
|
||||
description="Prepare, validate and build campaigns without approving real delivery.",
|
||||
permissions=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
"campaigns:report:read",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="campaign_reviewer",
|
||||
name="Campaign reviewer",
|
||||
description="Inspect and approve prepared campaign messages.",
|
||||
permissions=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="campaign_sender",
|
||||
name="Campaign sender",
|
||||
description="Queue, test, control, send and reconcile prepared campaigns.",
|
||||
permissions=(
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:send_test",
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:send",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _campaigns_router(context: ModuleContext):
|
||||
del context
|
||||
from govoplan_campaign.backend.router import router
|
||||
|
||||
return router
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="campaigns",
|
||||
name="Campaigns",
|
||||
version="1.0.0",
|
||||
dependencies=("access", "files", "mail"),
|
||||
optional_dependencies=(),
|
||||
permissions=PERMISSIONS,
|
||||
route_factory=_campaigns_router,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
nav_items=(
|
||||
NavItem(path="/campaigns", label="Campaigns", icon="mail-check", required_any=("campaigns:campaign:read",), order=20),
|
||||
NavItem(
|
||||
path="/operator",
|
||||
label="Operator Queue",
|
||||
icon="activity",
|
||||
required_any=(
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
),
|
||||
order=30,
|
||||
),
|
||||
NavItem(path="/reports", label="Reports", icon="file-text", required_any=("campaigns:report:read",), order=70),
|
||||
),
|
||||
frontend=FrontendModule(
|
||||
module_id="campaigns",
|
||||
package_name="@govoplan/campaign-webui",
|
||||
nav_items=(
|
||||
NavItem(path="/campaigns", label="Campaigns", icon="mail-check", required_any=("campaigns:campaign:read",), order=20),
|
||||
NavItem(
|
||||
path="/operator",
|
||||
label="Operator Queue",
|
||||
icon="activity",
|
||||
required_any=(
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
),
|
||||
order=30,
|
||||
),
|
||||
NavItem(path="/reports", label="Reports", icon="file-text", required_any=("campaigns:report:read",), order=70),
|
||||
NavItem(path="/address-book", label="Address Book", icon="users", order=80),
|
||||
NavItem(path="/templates", label="Templates", icon="form", order=90),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="campaigns",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
12
src/govoplan_campaign/backend/messages/__init__.py
Normal file
12
src/govoplan_campaign/backend/messages/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Message building and review helpers."""
|
||||
|
||||
from .builder import build_campaign_messages
|
||||
from .models import CampaignBuildReport, MessageDraft, MessageIssue, MessageValidationStatus
|
||||
|
||||
__all__ = [
|
||||
"build_campaign_messages",
|
||||
"CampaignBuildReport",
|
||||
"MessageDraft",
|
||||
"MessageIssue",
|
||||
"MessageValidationStatus",
|
||||
]
|
||||
618
src/govoplan_campaign/backend/messages/builder.py
Normal file
618
src/govoplan_campaign/backend/messages/builder.py
Normal file
@@ -0,0 +1,618 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import make_msgid, formatdate
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from govoplan_campaign.backend.attachments.resolver import (
|
||||
AttachmentMatchStatus,
|
||||
EntryAttachmentResolution,
|
||||
MessageAttachmentStatus,
|
||||
ResolvedAttachment,
|
||||
resolve_entry_attachments,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
|
||||
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
|
||||
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
|
||||
from govoplan_campaign.backend.campaign.models import (
|
||||
Behavior,
|
||||
BuildStatus,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
MissingAddressBehavior,
|
||||
RecipientConfig,
|
||||
SendStatus,
|
||||
ZipArchiveConfig,
|
||||
ZipPasswordMode,
|
||||
ZipPasswordScope,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.services.zip_service import create_zip_archive
|
||||
|
||||
from .models import (
|
||||
CampaignBuildReport,
|
||||
ImapStatus,
|
||||
MessageAddress,
|
||||
MessageAttachmentSummary,
|
||||
MessageDraft,
|
||||
MessageIssue,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BuiltMessage:
|
||||
draft: MessageDraft
|
||||
mime: EmailMessage | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CampaignBuildResult:
|
||||
report: CampaignBuildReport
|
||||
built_messages: list[BuiltMessage]
|
||||
|
||||
|
||||
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
campaign_path = Path(campaign_file).resolve()
|
||||
path = Path(raw_path).expanduser()
|
||||
if path.is_absolute():
|
||||
return path
|
||||
return (campaign_path.parent / path).resolve()
|
||||
|
||||
|
||||
def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str = "utf-8") -> str | None:
|
||||
if not raw_path:
|
||||
return None
|
||||
path = _resolve(campaign_file, raw_path)
|
||||
return path.read_text(encoding=encoding)
|
||||
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return {
|
||||
_normalize_template_key(match.group(1))
|
||||
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
||||
for match in pattern.finditer(text)
|
||||
}
|
||||
|
||||
|
||||
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
|
||||
if recipient is None:
|
||||
return None
|
||||
return MessageAddress(email=recipient.email, name=recipient.name)
|
||||
|
||||
|
||||
def _message_addresses(recipients: Iterable[RecipientConfig]) -> list[MessageAddress]:
|
||||
return [MessageAddress(email=recipient.email, name=recipient.name) for recipient in recipients]
|
||||
|
||||
|
||||
def _format_recipient(recipient: RecipientConfig) -> str:
|
||||
return formatted_recipient(recipient)
|
||||
|
||||
|
||||
def _format_recipient_header(recipients: Iterable[RecipientConfig]) -> str:
|
||||
return ", ".join(_format_recipient(recipient) for recipient in recipients)
|
||||
|
||||
|
||||
def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> tuple[str, str | None, str | None]:
|
||||
template = config.template
|
||||
if template.source:
|
||||
subject = _read_text(campaign_file, template.source.subject_path, template.source.encoding)
|
||||
text = _read_text(campaign_file, template.source.text_path, template.source.encoding)
|
||||
html = _read_text(campaign_file, template.source.html_path, template.source.encoding)
|
||||
return subject or "", text, html
|
||||
return template.subject or "", template.text, template.html
|
||||
|
||||
|
||||
def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue:
|
||||
severity = "error" if behavior == "block" else "warning"
|
||||
return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source)
|
||||
|
||||
|
||||
def _apply_behavior(current: MessageValidationStatus, behavior: str) -> MessageValidationStatus:
|
||||
if behavior == Behavior.BLOCK.value:
|
||||
return MessageValidationStatus.BLOCKED
|
||||
if behavior == Behavior.DROP.value:
|
||||
return MessageValidationStatus.EXCLUDED
|
||||
if behavior == Behavior.ASK.value:
|
||||
if current not in {MessageValidationStatus.BLOCKED, MessageValidationStatus.EXCLUDED}:
|
||||
return MessageValidationStatus.NEEDS_REVIEW
|
||||
if behavior == Behavior.WARN.value:
|
||||
if current == MessageValidationStatus.READY:
|
||||
return MessageValidationStatus.WARNING
|
||||
# continue leaves status as-is
|
||||
return current
|
||||
|
||||
|
||||
def _validation_status_from_attachment_status(status: MessageAttachmentStatus) -> MessageValidationStatus:
|
||||
return MessageValidationStatus(status.value)
|
||||
|
||||
|
||||
def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[MessageAttachmentSummary]:
|
||||
return [
|
||||
MessageAttachmentSummary(
|
||||
attachment_id=attachment.attachment_id,
|
||||
label=attachment.label,
|
||||
status=attachment.status.value,
|
||||
behavior=attachment.behavior.value if attachment.behavior else None,
|
||||
required=attachment.required,
|
||||
allow_multiple=attachment.allow_multiple,
|
||||
zip_enabled=attachment.zip_enabled,
|
||||
zip_mode=attachment.zip_mode.value,
|
||||
zip_archive_id=attachment.zip_archive_id,
|
||||
zip_filename=attachment.zip_filename,
|
||||
base_path_name=attachment.base_path_name,
|
||||
base_path=attachment.base_path,
|
||||
file_filter=attachment.file_filter,
|
||||
directory=attachment.directory,
|
||||
matches=attachment.matches,
|
||||
)
|
||||
for attachment in resolution.attachments
|
||||
]
|
||||
|
||||
|
||||
def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolution) -> list[MessageIssue]:
|
||||
return [
|
||||
MessageIssue(
|
||||
severity=issue.severity.value,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
behavior=issue.behavior.value if issue.behavior else None,
|
||||
source="attachments",
|
||||
)
|
||||
for issue in resolution.issues
|
||||
]
|
||||
|
||||
|
||||
def _safe_filename(value: str | None, fallback: str) -> str:
|
||||
raw = value or fallback
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
|
||||
return safe or fallback
|
||||
|
||||
|
||||
def _attachment_bytes(path: Path) -> tuple[bytes, str, str]:
|
||||
data = path.read_bytes()
|
||||
mime_type, _ = mimetypes.guess_type(str(path))
|
||||
if not mime_type:
|
||||
return data, "application", "octet-stream"
|
||||
maintype, subtype = mime_type.split("/", 1)
|
||||
return data, maintype, subtype
|
||||
|
||||
|
||||
class ZipBuildError(RuntimeError):
|
||||
def __init__(self, code: str, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _zip_password(archive: ZipArchiveConfig, values: dict[str, Any]) -> str:
|
||||
if not archive.password_enabled:
|
||||
return ""
|
||||
|
||||
# Preserve support for campaigns written by the original single-archive
|
||||
# implementation while all new archives use field + scope.
|
||||
mode = archive.password_mode
|
||||
if mode == ZipPasswordMode.DIRECT:
|
||||
password = archive.password or ""
|
||||
if not password:
|
||||
raise ZipBuildError("zip_password_missing", f"ZIP archive {archive.name!r} has no fixed password")
|
||||
return password
|
||||
if mode == ZipPasswordMode.TEMPLATE:
|
||||
password = _render_template(archive.password_template or "", values, keep_missing=False)
|
||||
if not password:
|
||||
raise ZipBuildError("zip_password_value_missing", f"ZIP archive {archive.name!r} produced an empty password")
|
||||
return password
|
||||
|
||||
field_name = (archive.password_field or "").strip()
|
||||
if not field_name:
|
||||
raise ZipBuildError("zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no password field")
|
||||
scope = archive.password_scope.value if isinstance(archive.password_scope, ZipPasswordScope) else str(archive.password_scope)
|
||||
value = values.get(f"{scope}::{field_name}")
|
||||
# Legacy field-based configurations did not declare a scope.
|
||||
if value in (None, "") and mode == ZipPasswordMode.FIELD:
|
||||
value = values.get(field_name)
|
||||
password = "" if value is None else str(value)
|
||||
if not password:
|
||||
qualifier = "campaign-global" if scope == ZipPasswordScope.GLOBAL.value else "recipient"
|
||||
raise ZipBuildError(
|
||||
"zip_password_value_missing",
|
||||
f"The {qualifier} ZIP password field {field_name!r} is empty for archive {archive.name!r}",
|
||||
)
|
||||
return password
|
||||
|
||||
|
||||
def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_index: int) -> str:
|
||||
rendered = _render_template(archive.name or "attachments.zip", values, keep_missing=False)
|
||||
filename = _safe_filename(rendered, f"entry-{entry_index:04d}-attachments.zip")
|
||||
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
|
||||
|
||||
|
||||
def _deduplicated_paths(paths: list[Path]) -> list[Path]:
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
key = str(path.resolve())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(path)
|
||||
return unique
|
||||
|
||||
|
||||
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
|
||||
candidate = filename
|
||||
path = Path(filename)
|
||||
counter = 2
|
||||
while candidate.casefold() in used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
used.add(candidate.casefold())
|
||||
return candidate
|
||||
|
||||
|
||||
def _attach_files(
|
||||
*,
|
||||
message: EmailMessage,
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
entry_index: int,
|
||||
resolution: EntryAttachmentResolution,
|
||||
values: dict[str, Any],
|
||||
work_dir: Path,
|
||||
) -> int:
|
||||
attached_count = 0
|
||||
archive_members: dict[str, list[Path]] = {}
|
||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||
used_message_filenames: set[str] = set()
|
||||
|
||||
for attachment in resolution.attachments:
|
||||
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
|
||||
continue
|
||||
match_paths = [Path(match) for match in attachment.matches]
|
||||
if attachment.zip_enabled and attachment.zip_archive_id:
|
||||
archive_members.setdefault(attachment.zip_archive_id, []).extend(match_paths)
|
||||
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
|
||||
continue
|
||||
for path in match_paths:
|
||||
filename = _unique_attachment_filename(path.name, used_message_filenames)
|
||||
data, maintype, subtype = _attachment_bytes(path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
attached_count += 1
|
||||
|
||||
for archive in config.attachments.zip.archives:
|
||||
members = _deduplicated_paths(archive_members.get(archive.id, []))
|
||||
if not members:
|
||||
continue
|
||||
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
|
||||
password = _zip_password(archive, values)
|
||||
archive_path = create_zip_archive(
|
||||
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
|
||||
members,
|
||||
password,
|
||||
)
|
||||
data, maintype, subtype = _attachment_bytes(archive_path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
|
||||
attached_count += 1
|
||||
for attachment in archive_attachments.get(archive.id, []):
|
||||
attachment.zip_filename = filename
|
||||
|
||||
return attached_count
|
||||
|
||||
def _imap_initial_status(config: CampaignConfig) -> ImapStatus:
|
||||
if config.delivery.imap_append_sent.enabled:
|
||||
return ImapStatus.PENDING
|
||||
return ImapStatus.NOT_REQUESTED
|
||||
|
||||
|
||||
def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entry_index: int) -> tuple[str, int]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = _safe_filename(entry.id, f"entry-{entry_index:04d}") + ".eml"
|
||||
path = output_dir / filename
|
||||
path.write_bytes(bytes(message))
|
||||
return str(path), path.stat().st_size
|
||||
|
||||
|
||||
def build_entry_message(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
entry: EntryConfig,
|
||||
entry_index: int,
|
||||
output_dir: Path | None = None,
|
||||
write_eml: bool = False,
|
||||
work_dir: Path | None = None,
|
||||
) -> BuiltMessage:
|
||||
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index)
|
||||
effective_addresses = effective_address_lists(config, entry)
|
||||
senders = effective_addresses["from"]
|
||||
sender = senders[0] if senders else None
|
||||
recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
|
||||
issues = _message_issues_from_attachment_resolution(resolution)
|
||||
validation_status = _validation_status_from_attachment_status(resolution.status)
|
||||
|
||||
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
|
||||
if ignored_field_overrides:
|
||||
issues.append(
|
||||
MessageIssue(
|
||||
severity="warning",
|
||||
code="field_override_not_allowed",
|
||||
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
|
||||
behavior="warn",
|
||||
source="fields",
|
||||
)
|
||||
)
|
||||
if validation_status == MessageValidationStatus.READY:
|
||||
validation_status = MessageValidationStatus.WARNING
|
||||
|
||||
if not entry.active:
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=False,
|
||||
build_status=BuildStatus.BUILD_FAILED,
|
||||
validation_status=MessageValidationStatus.INACTIVE,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=ImapStatus.SKIPPED,
|
||||
from_=_message_address(sender),
|
||||
from_all=_message_addresses(senders),
|
||||
to=_message_addresses(recipients["to"]),
|
||||
cc=_message_addresses(recipients["cc"]),
|
||||
bcc=_message_addresses(recipients["bcc"]),
|
||||
reply_to=_message_addresses(recipients["reply_to"]),
|
||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
||||
attachments=_attachment_summaries(resolution),
|
||||
issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")],
|
||||
)
|
||||
return BuiltMessage(draft=draft, mime=None)
|
||||
|
||||
if not recipients["to"]:
|
||||
behavior = config.validation_policy.missing_email.value
|
||||
issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients"))
|
||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||
|
||||
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
||||
values = build_template_values(config, entry)
|
||||
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
|
||||
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
|
||||
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
|
||||
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
|
||||
|
||||
unresolved = sorted(
|
||||
_find_unresolved_placeholders(subject)
|
||||
| _find_unresolved_placeholders(text_body)
|
||||
| _find_unresolved_placeholders(html_body)
|
||||
)
|
||||
if unresolved:
|
||||
behavior = config.validation_policy.template_error.value
|
||||
issues.append(
|
||||
_issue_from_behavior(
|
||||
code="template_error",
|
||||
message="Unresolved template placeholder(s): " + ", ".join(unresolved),
|
||||
behavior=behavior,
|
||||
source="template",
|
||||
)
|
||||
)
|
||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||
|
||||
message = EmailMessage()
|
||||
try:
|
||||
message["Date"] = formatdate(localtime=True)
|
||||
message["Message-ID"] = make_msgid()
|
||||
if senders:
|
||||
message["From"] = _format_recipient_header(senders)
|
||||
if len(senders) > 1:
|
||||
# RFC 5322 requires a singular Sender when From contains more
|
||||
# than one mailbox. The first effective From address remains
|
||||
# the SMTP envelope sender and compatibility primary value.
|
||||
message["Sender"] = _format_recipient(senders[0])
|
||||
if recipients["to"]:
|
||||
message["To"] = _format_recipient_header(recipients["to"])
|
||||
if recipients["cc"]:
|
||||
message["Cc"] = _format_recipient_header(recipients["cc"])
|
||||
# Bcc deliberately remains envelope-only and is tracked in MessageDraft.
|
||||
if recipients["reply_to"]:
|
||||
message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
|
||||
if recipients["disposition_notification_to"]:
|
||||
message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"])
|
||||
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
|
||||
message["Subject"] = subject
|
||||
|
||||
if html_body is not None:
|
||||
message.set_content(text_body or "")
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
else:
|
||||
message.set_content(text_body or "")
|
||||
|
||||
if work_dir is None:
|
||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-"))
|
||||
attachment_count = _attach_files(
|
||||
message=message,
|
||||
config=config,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
resolution=resolution,
|
||||
values=values,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
build_status = BuildStatus.BUILT
|
||||
except ZipBuildError as exc:
|
||||
issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments"))
|
||||
validation_status = MessageValidationStatus.BLOCKED
|
||||
build_status = BuildStatus.BUILD_FAILED
|
||||
attachment_count = 0
|
||||
message = None # type: ignore[assignment]
|
||||
except Exception as exc:
|
||||
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder"))
|
||||
validation_status = MessageValidationStatus.BLOCKED
|
||||
build_status = BuildStatus.BUILD_FAILED
|
||||
attachment_count = 0
|
||||
message = None # type: ignore[assignment]
|
||||
|
||||
eml_path: str | None = None
|
||||
eml_size: int | None = None
|
||||
if write_eml and output_dir is not None and message is not None:
|
||||
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index)
|
||||
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=entry.active,
|
||||
build_status=build_status,
|
||||
validation_status=validation_status,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED,
|
||||
subject=subject,
|
||||
from_=_message_address(sender),
|
||||
from_all=_message_addresses(senders),
|
||||
to=_message_addresses(recipients["to"]),
|
||||
cc=_message_addresses(recipients["cc"]),
|
||||
bcc=_message_addresses(recipients["bcc"]),
|
||||
reply_to=_message_addresses(recipients["reply_to"]),
|
||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
||||
attachment_count=attachment_count,
|
||||
attachments=_attachment_summaries(resolution),
|
||||
issues=issues,
|
||||
eml_path=eml_path,
|
||||
eml_size_bytes=eml_size,
|
||||
)
|
||||
return BuiltMessage(draft=draft, mime=message)
|
||||
|
||||
|
||||
|
||||
def _unsent_attachment_issues(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
built_messages: list[BuiltMessage],
|
||||
) -> list[MessageIssue]:
|
||||
behavior = config.validation_policy.unsent_attachment_files.value
|
||||
if behavior == Behavior.CONTINUE.value:
|
||||
return []
|
||||
|
||||
matched_files = {
|
||||
Path(match).resolve()
|
||||
for built in built_messages
|
||||
for attachment in built.draft.attachments
|
||||
for match in attachment.matches
|
||||
}
|
||||
|
||||
issues: list[MessageIssue] = []
|
||||
for base_path in config.attachments.base_paths:
|
||||
if not base_path.unsent_warning:
|
||||
continue
|
||||
directory = _resolve(campaign_file, base_path.path)
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
continue
|
||||
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
|
||||
unsent = [path for path in all_files if path not in matched_files]
|
||||
if not unsent:
|
||||
continue
|
||||
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
|
||||
if len(unsent) > 10:
|
||||
shown += f", … (+{len(unsent) - 10} more)"
|
||||
issues.append(
|
||||
_issue_from_behavior(
|
||||
code="unsent_attachment_files",
|
||||
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
|
||||
behavior=behavior,
|
||||
source=f"attachments:{base_path.name}",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
|
||||
if not issues:
|
||||
return
|
||||
for built in built_messages:
|
||||
if not built.draft.active:
|
||||
continue
|
||||
built.draft.issues.extend(issues)
|
||||
status = built.draft.validation_status
|
||||
for issue in issues:
|
||||
if issue.behavior:
|
||||
status = _apply_behavior(status, issue.behavior)
|
||||
built.draft.validation_status = status
|
||||
|
||||
def build_campaign_messages(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
output_dir: str | Path | None = None,
|
||||
write_eml: bool = False,
|
||||
) -> CampaignBuildResult:
|
||||
campaign_path = Path(campaign_file).resolve()
|
||||
entries = load_campaign_entries(config, campaign_file=campaign_path)
|
||||
output_path = Path(output_dir).resolve() if output_dir is not None else None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
|
||||
work_dir = output_path or Path(tmp)
|
||||
built_messages = [
|
||||
build_entry_message(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
entry=entry,
|
||||
entry_index=index,
|
||||
output_dir=output_path,
|
||||
write_eml=write_eml,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
if entry.active
|
||||
]
|
||||
_apply_campaign_level_issues(
|
||||
built_messages,
|
||||
_unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages),
|
||||
)
|
||||
|
||||
report = CampaignBuildReport(
|
||||
campaign_id=config.campaign.id,
|
||||
campaign_name=config.campaign.name,
|
||||
campaign_file=str(campaign_path),
|
||||
entries_count=len(entries),
|
||||
inactive_entries_count=sum(1 for entry in entries if not entry.active),
|
||||
messages=[built.draft for built in built_messages],
|
||||
)
|
||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||
147
src/govoplan_campaign/backend/messages/models.py
Normal file
147
src/govoplan_campaign/backend/messages/models.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import BuildStatus, SendStatus
|
||||
|
||||
|
||||
class MessageValidationStatus(StrEnum):
|
||||
READY = "ready"
|
||||
WARNING = "warning"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
BLOCKED = "blocked"
|
||||
EXCLUDED = "excluded"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ImapStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
APPENDED = "appended"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class MessageIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
severity: Literal["info", "warning", "error"]
|
||||
code: str
|
||||
message: str
|
||||
behavior: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class MessageAddress(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class MessageAttachmentSummary(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
attachment_id: str | None = None
|
||||
label: str | None = None
|
||||
status: str
|
||||
behavior: str | None = None
|
||||
required: bool
|
||||
allow_multiple: bool
|
||||
zip_enabled: bool
|
||||
zip_mode: str = "inherit"
|
||||
zip_archive_id: str | None = None
|
||||
zip_filename: str | None = None
|
||||
base_path_name: str | None = None
|
||||
base_path: str | None = None
|
||||
file_filter: str
|
||||
directory: str
|
||||
matches: list[str] = Field(default_factory=list)
|
||||
managed_matches: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
entry_index: int
|
||||
entry_id: str | None = None
|
||||
active: bool
|
||||
|
||||
build_status: BuildStatus
|
||||
validation_status: MessageValidationStatus
|
||||
send_status: SendStatus
|
||||
imap_status: ImapStatus
|
||||
|
||||
subject: str | None = None
|
||||
from_: MessageAddress | None = Field(default=None, alias="from")
|
||||
from_all: list[MessageAddress] = Field(default_factory=list)
|
||||
to: list[MessageAddress] = Field(default_factory=list)
|
||||
cc: list[MessageAddress] = Field(default_factory=list)
|
||||
bcc: list[MessageAddress] = Field(default_factory=list)
|
||||
reply_to: list[MessageAddress] = Field(default_factory=list)
|
||||
bounce_to: list[MessageAddress] = Field(default_factory=list)
|
||||
disposition_notification_to: list[MessageAddress] = Field(default_factory=list)
|
||||
|
||||
attachment_count: int = 0
|
||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||
issues: list[MessageIssue] = Field(default_factory=list)
|
||||
|
||||
eml_path: str | None = None
|
||||
eml_size_bytes: int | None = None
|
||||
|
||||
@property
|
||||
def is_queueable(self) -> bool:
|
||||
return self.active and self.build_status == BuildStatus.BUILT and self.validation_status in {
|
||||
MessageValidationStatus.READY,
|
||||
MessageValidationStatus.WARNING,
|
||||
}
|
||||
|
||||
|
||||
class CampaignBuildReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
campaign_file: str
|
||||
entries_count: int
|
||||
inactive_entries_count: int = 0
|
||||
messages: list[MessageDraft] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def built_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILT)
|
||||
|
||||
@property
|
||||
def build_failed_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILD_FAILED)
|
||||
|
||||
@property
|
||||
def ready_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.READY)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.WARNING)
|
||||
|
||||
@property
|
||||
def needs_review_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.NEEDS_REVIEW)
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.BLOCKED)
|
||||
|
||||
@property
|
||||
def excluded_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.EXCLUDED)
|
||||
|
||||
@property
|
||||
def inactive_count(self) -> int:
|
||||
return self.inactive_entries_count
|
||||
|
||||
@property
|
||||
def queueable_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.is_queueable)
|
||||
508
src/govoplan_campaign/backend/persistence/campaigns.py
Normal file
508
src/govoplan_campaign/backend/persistence/campaigns.py
Normal file
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
CampaignVersionWorkflowState,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json, validate_against_schema
|
||||
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import MessageDraft
|
||||
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_mail.backend.mail_profiles import materialize_campaign_mail_profile_config
|
||||
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
|
||||
from govoplan_files.backend.storage.campaign_attachments import (
|
||||
annotate_built_messages_with_managed_files,
|
||||
prepared_campaign_snapshot,
|
||||
public_attachment_summary_payload,
|
||||
)
|
||||
|
||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
|
||||
BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml"
|
||||
|
||||
|
||||
class CampaignPersistenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_dirs() -> None:
|
||||
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
|
||||
|
||||
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:
|
||||
materialized = materialize_campaign_mail_profile_config(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=raw_json,
|
||||
campaign_id=campaign_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
validate_against_schema(materialized)
|
||||
return CampaignConfig.model_validate(materialized)
|
||||
|
||||
|
||||
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
|
||||
_ensure_dirs()
|
||||
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
|
||||
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
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]:
|
||||
"""Return a DB/runtime-safe campaign JSON snapshot.
|
||||
|
||||
The CLI naturally resolves relative paths against the campaign.json file.
|
||||
Once the campaign is stored in the database, the JSON snapshot lives in
|
||||
app/mailer/runtime/campaign_snapshots. 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.
|
||||
"""
|
||||
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,
|
||||
) -> tuple[Campaign, CampaignVersion]:
|
||||
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)
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
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}")
|
||||
path = _write_campaign_snapshot(version)
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
return version, path, load_campaign_config_from_json(session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id)
|
||||
|
||||
|
||||
def validate_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version_id: str,
|
||||
check_files: bool = False,
|
||||
user_id: str | 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:
|
||||
with 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="multimailer-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)
|
||||
else:
|
||||
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False)
|
||||
report_json = report.model_dump(mode="json")
|
||||
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
|
||||
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:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
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,
|
||||
) -> CampaignJob:
|
||||
recipient_email = message.to[0].email if message.to else None
|
||||
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
|
||||
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_local_path=message.eml_path,
|
||||
eml_size_bytes=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.NOT_QUEUED.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],
|
||||
},
|
||||
resolved_attachments=[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 build_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version_id: str,
|
||||
write_eml: 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="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)
|
||||
|
||||
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
||||
with 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="multimailer-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=output_dir, write_eml=write_eml)
|
||||
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
|
||||
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"] = [public_attachment_summary_payload(item) for item in message.attachments]
|
||||
report_json["built_at"] = datetime.now(UTC).isoformat()
|
||||
report_json["build_token"] = uuid4().hex
|
||||
report_json.update({
|
||||
"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,
|
||||
})
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
|
||||
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
|
||||
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()
|
||||
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||
for built in result.built_messages:
|
||||
job = _job_from_message(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
message=built.draft,
|
||||
)
|
||||
session.add(job)
|
||||
job_build_pairs.append((job, built.draft))
|
||||
|
||||
# Assign all job IDs in one round-trip, then persist exact attachment use
|
||||
# records in bulk. This avoids one flush plus several metadata queries per
|
||||
# recipient for large campaigns.
|
||||
session.flush()
|
||||
record_campaign_attachment_uses_for_jobs(
|
||||
session,
|
||||
[job for job, _message in job_build_pairs],
|
||||
stage="built",
|
||||
)
|
||||
if not managed_config.server.smtp:
|
||||
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
|
||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||
version,
|
||||
smtp=managed_config.server.smtp,
|
||||
imap=managed_config.server.imap,
|
||||
delivery=managed_config.delivery,
|
||||
jobs=[job for job, _message in job_build_pairs],
|
||||
build_summary=report_json,
|
||||
)
|
||||
version.execution_snapshot = execution_snapshot
|
||||
version.execution_snapshot_hash = execution_snapshot_hash
|
||||
version.execution_snapshot_at = datetime.now(UTC)
|
||||
for job, message in job_build_pairs:
|
||||
for issue in message.issues:
|
||||
session.add(
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
if result.report.needs_review_count or result.report.blocked_count:
|
||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
elif result.report.queueable_count > 0:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
||||
else:
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return report_json
|
||||
827
src/govoplan_campaign/backend/persistence/versions.py
Normal file
827
src/govoplan_campaign/backend/persistence/versions.py
Normal file
@@ -0,0 +1,827 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
CampaignVersionFlow,
|
||||
CampaignVersionWorkflowState,
|
||||
CampaignJob,
|
||||
JobSendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
|
||||
from govoplan_mail.backend.mail_profiles import assert_campaign_mail_policy_allows_json
|
||||
from govoplan_campaign.backend.persistence.campaigns import (
|
||||
CampaignPersistenceError,
|
||||
_next_version_number,
|
||||
_write_campaign_snapshot,
|
||||
normalize_campaign_paths,
|
||||
)
|
||||
|
||||
|
||||
class LockedCampaignVersionError(CampaignPersistenceError):
|
||||
"""Raised when a caller tries to edit an immutable campaign version."""
|
||||
|
||||
|
||||
USER_LOCK_TEMPORARY = "temporary"
|
||||
USER_LOCK_PERMANENT = "permanent"
|
||||
USER_LOCK_STATES = {USER_LOCK_TEMPORARY, USER_LOCK_PERMANENT}
|
||||
|
||||
|
||||
def campaign_version_user_lock_state(version: CampaignVersion) -> str | None:
|
||||
"""Return the explicit user-lock state with backwards compatibility.
|
||||
|
||||
Older databases represented a permanent user lock only through
|
||||
published_at. Treat those rows as permanent until the migration has
|
||||
backfilled the explicit state.
|
||||
"""
|
||||
|
||||
state = getattr(version, "user_lock_state", None)
|
||||
if state in USER_LOCK_STATES:
|
||||
return state
|
||||
if version.published_at:
|
||||
return USER_LOCK_PERMANENT
|
||||
return None
|
||||
|
||||
|
||||
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||
"""Return a WebUI-friendly starter campaign JSON.
|
||||
|
||||
It is intentionally usable as an editable working copy. It contains the
|
||||
main sections the UI expects, but it may still be incomplete from the
|
||||
strict send/build perspective until the user configures recipients,
|
||||
template and sender details.
|
||||
"""
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": external_id,
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"mode": "draft",
|
||||
},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"inherit_smtp_credentials": True,
|
||||
"inherit_imap_credentials": True,
|
||||
"smtp": {
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": False,
|
||||
"host": "",
|
||||
"port": 993,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"security": "tls",
|
||||
"sent_folder": "auto",
|
||||
},
|
||||
},
|
||||
"recipients": {
|
||||
"from": [],
|
||||
"allow_individual_from": False,
|
||||
"to": [],
|
||||
"allow_individual_to": True,
|
||||
"cc": [],
|
||||
"allow_individual_cc": False,
|
||||
"bcc": [],
|
||||
"allow_individual_bcc": False,
|
||||
"reply_to": [],
|
||||
"allow_individual_reply_to": False,
|
||||
"bounce_to": [],
|
||||
"allow_individual_bounce_to": False,
|
||||
"disposition_notification_to": [],
|
||||
"allow_individual_disposition_notification_to": False,
|
||||
},
|
||||
"template": {
|
||||
"subject": "",
|
||||
"text": "",
|
||||
"html": None,
|
||||
},
|
||||
"attachments": {
|
||||
"base_path": ".",
|
||||
"base_paths": [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "Campaign files",
|
||||
"path": ".",
|
||||
"allow_individual": True,
|
||||
"unsent_warning": False,
|
||||
}
|
||||
],
|
||||
"allow_individual": True,
|
||||
"send_without_attachments": False,
|
||||
"global": [],
|
||||
"zip": {"enabled": False, "archives": []},
|
||||
"missing_behavior": "ask",
|
||||
"ambiguous_behavior": "ask",
|
||||
},
|
||||
"entries": {
|
||||
"inline": [],
|
||||
"defaults": {
|
||||
"active": True,
|
||||
"merge_to": False,
|
||||
"merge_cc": True,
|
||||
"merge_bcc": True,
|
||||
"merge_reply_to": True,
|
||||
"merge_bounce_to": True,
|
||||
"merge_disposition_notification_to": True,
|
||||
"combine_attachments": True,
|
||||
"attachments": [],
|
||||
},
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_required_attachment": "ask",
|
||||
"missing_optional_attachment": "warn",
|
||||
"ambiguous_attachment_match": "ask",
|
||||
"unsent_attachment_files": "warn",
|
||||
"ignore_empty_fields": False,
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
},
|
||||
"delivery": {
|
||||
"rate_limit": {
|
||||
"messages_per_minute": 5,
|
||||
"concurrency": 1,
|
||||
},
|
||||
"imap_append_sent": {
|
||||
"enabled": False,
|
||||
"folder": "auto",
|
||||
},
|
||||
"retry": {
|
||||
"max_attempts": 3,
|
||||
"backoff_seconds": [60, 300, 900],
|
||||
},
|
||||
},
|
||||
"status_tracking": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_minimal_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
external_id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
current_flow: str = CampaignVersionFlow.CREATE.value,
|
||||
current_step: str = "basics",
|
||||
) -> tuple[Campaign, CampaignVersion]:
|
||||
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
|
||||
if existing:
|
||||
raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant")
|
||||
|
||||
campaign = Campaign(
|
||||
tenant_id=tenant_id,
|
||||
created_by_user_id=user_id,
|
||||
owner_user_id=user_id,
|
||||
external_id=external_id,
|
||||
name=name,
|
||||
description=description,
|
||||
status=CampaignStatus.DRAFT.value,
|
||||
)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
|
||||
version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description),
|
||||
schema_version="1.0",
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow,
|
||||
current_step=current_step,
|
||||
is_complete=False,
|
||||
editor_state={"created_from": "minimal_campaign"},
|
||||
autosaved_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
campaign.current_version_id = version.id
|
||||
session.add(campaign)
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
return campaign, version
|
||||
|
||||
|
||||
def get_campaign_version_for_tenant(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id:
|
||||
raise CampaignPersistenceError("Campaign version not found")
|
||||
return version
|
||||
|
||||
|
||||
|
||||
|
||||
LOCKED_WORKFLOW_STATES = {
|
||||
CampaignVersionWorkflowState.APPROVED.value,
|
||||
CampaignVersionWorkflowState.BUILT.value,
|
||||
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 is_version_locked(version: CampaignVersion) -> bool:
|
||||
"""Return True when a version is immutable and edits must fork/unlock."""
|
||||
|
||||
return bool(
|
||||
version.locked_at
|
||||
or campaign_version_user_lock_state(version)
|
||||
or version.workflow_state in LOCKED_WORKFLOW_STATES
|
||||
)
|
||||
|
||||
|
||||
def ensure_current_working_version(campaign: Campaign, version: CampaignVersion, *, action: str = "modify") -> None:
|
||||
"""Require the campaign's single active working version.
|
||||
|
||||
Historical versions remain reviewable, but they never become writable in
|
||||
place. Continuing from immutable history must create a new working copy,
|
||||
and that copy becomes the campaign's sole current version.
|
||||
"""
|
||||
|
||||
if campaign.current_version_id != version.id:
|
||||
raise LockedCampaignVersionError(
|
||||
f"Historical campaign versions are read-only and cannot be used to {action}. "
|
||||
"Open the current working version instead."
|
||||
)
|
||||
|
||||
|
||||
def campaign_has_active_working_version(session: Session, campaign: Campaign) -> bool:
|
||||
"""Return True while the campaign already has a non-final working version.
|
||||
|
||||
Validation locks and temporary user locks are still the same working
|
||||
version; they must be unlocked rather than forked into parallel drafts.
|
||||
"""
|
||||
|
||||
if not campaign.current_version_id:
|
||||
return False
|
||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if not current or current.campaign_id != campaign.id:
|
||||
return False
|
||||
return not is_audit_safe_version(current)
|
||||
|
||||
|
||||
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
|
||||
campaign_meta = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
if campaign_meta:
|
||||
campaign.name = campaign_meta.get("name") or campaign.name
|
||||
campaign.description = campaign_meta.get("description", campaign.description)
|
||||
campaign.external_id = campaign_meta.get("id") or campaign.external_id
|
||||
|
||||
|
||||
def fork_campaign_version_for_edit(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
raw_json: dict[str, Any] | None = None,
|
||||
current_flow: str | None = None,
|
||||
current_step: str | None = None,
|
||||
editor_state: dict[str, Any] | None = None,
|
||||
source_filename: str | None = None,
|
||||
source_base_path: str | None = None,
|
||||
autosave: bool = True,
|
||||
) -> CampaignVersion:
|
||||
"""Create the next sole working version from immutable campaign history.
|
||||
|
||||
Validation and temporary user locks are still the active working version
|
||||
and must be unlocked in place. A copy is allowed only once the current
|
||||
version is permanently user-locked or delivery-final.
|
||||
"""
|
||||
|
||||
source = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
|
||||
if campaign_has_active_working_version(session, campaign):
|
||||
current = session.get(CampaignVersion, campaign.current_version_id)
|
||||
current_number = current.version_number if current else "current"
|
||||
raise LockedCampaignVersionError(
|
||||
f"Campaign already has active working version #{current_number}. "
|
||||
"Unlock or continue editing that version instead of creating a parallel draft."
|
||||
)
|
||||
if campaign.current_version_id and source.id != campaign.current_version_id:
|
||||
raise LockedCampaignVersionError(
|
||||
"Historical versions remain review-only and cannot become a new branch. "
|
||||
"Create the next working copy from the campaign's current immutable version."
|
||||
)
|
||||
|
||||
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
|
||||
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
|
||||
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||
|
||||
new_version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=_next_version_number(session, campaign.id),
|
||||
raw_json=runtime_json,
|
||||
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
|
||||
source_filename=source_filename if source_filename is not None else source.source_filename,
|
||||
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
|
||||
current_step=current_step if current_step is not None else source.current_step,
|
||||
is_complete=False,
|
||||
editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}),
|
||||
autosaved_at=datetime.now(UTC) if autosave else None,
|
||||
)
|
||||
session.add(new_version)
|
||||
session.flush()
|
||||
|
||||
_apply_campaign_metadata(campaign, runtime_json)
|
||||
campaign.current_version_id = new_version.id
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.add(campaign)
|
||||
_write_campaign_snapshot(new_version)
|
||||
session.commit()
|
||||
return new_version
|
||||
|
||||
|
||||
def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None:
|
||||
if version.locked_at is None:
|
||||
version.locked_at = datetime.now(UTC)
|
||||
version.locked_by_user_id = user_id
|
||||
|
||||
|
||||
def is_version_final_locked(version: CampaignVersion) -> bool:
|
||||
"""Return True when a version is part of or past delivery and must stay immutable."""
|
||||
|
||||
return 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 is_temporary_user_locked_version(version: CampaignVersion) -> bool:
|
||||
return campaign_version_user_lock_state(version) == USER_LOCK_TEMPORARY
|
||||
|
||||
|
||||
def is_permanent_user_locked_version(version: CampaignVersion) -> bool:
|
||||
return campaign_version_user_lock_state(version) == USER_LOCK_PERMANENT
|
||||
|
||||
|
||||
def is_user_locked_version(version: CampaignVersion) -> bool:
|
||||
"""Return True for either reversible or permanent user-requested locks."""
|
||||
|
||||
return campaign_version_user_lock_state(version) is not None
|
||||
|
||||
|
||||
def is_audit_safe_version(version: CampaignVersion) -> bool:
|
||||
"""Return True when a version is immutable and cannot be unlocked."""
|
||||
|
||||
return is_permanent_user_locked_version(version) or is_version_final_locked(version)
|
||||
|
||||
|
||||
def is_version_validated_and_locked(version: CampaignVersion) -> bool:
|
||||
"""Return True when the version was successfully validated and locked as a review snapshot."""
|
||||
|
||||
validation = version.validation_summary if isinstance(version.validation_summary, dict) else {}
|
||||
return bool(version.locked_at and validation.get("ok") is True)
|
||||
|
||||
|
||||
def unlock_validated_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
"""Unlock a validation snapshot so it can be edited again.
|
||||
|
||||
This is only allowed before delivery starts. Unlocking invalidates validation,
|
||||
build output and queued job records for that version. Sent/final versions must
|
||||
be copied instead.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
ensure_current_working_version(campaign, version, action="unlock")
|
||||
|
||||
if is_temporary_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version has a temporary user lock. Remove that lock before unlocking validation.")
|
||||
if is_permanent_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version is permanently locked and cannot be unlocked. Create an editable copy instead.")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("This version is already queued/sent/final and cannot be unlocked. Create an editable copy instead.")
|
||||
|
||||
# A version with sent jobs is final even if workflow_state was not updated for some reason.
|
||||
sent_jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
CampaignJob.send_status.in_([JobSendStatus.SENT.value, JobSendStatus.SMTP_ACCEPTED.value]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if sent_jobs:
|
||||
raise LockedCampaignVersionError("This version has sent messages and cannot be unlocked. Create an editable copy instead.")
|
||||
|
||||
version.locked_at = None
|
||||
version.locked_by_user_id = None
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
clear_execution_snapshot(version)
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
version.is_complete = False
|
||||
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
def update_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
raw_json: dict[str, Any] | None = None,
|
||||
current_flow: str | None = None,
|
||||
current_step: str | None = None,
|
||||
workflow_state: str | None = None,
|
||||
is_complete: bool | None = None,
|
||||
editor_state: dict[str, Any] | None = None,
|
||||
source_filename: str | None = None,
|
||||
source_base_path: str | None = None,
|
||||
autosave: bool = False,
|
||||
) -> CampaignVersion:
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
ensure_current_working_version(campaign, version, action="edit")
|
||||
|
||||
if is_version_locked(version):
|
||||
raise LockedCampaignVersionError(
|
||||
"Campaign version is locked. Create an editable copy before changing campaign data."
|
||||
)
|
||||
|
||||
if raw_json is not None:
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
|
||||
version.raw_json = runtime_json
|
||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||
_apply_campaign_metadata(campaign, runtime_json)
|
||||
|
||||
if current_flow is not None:
|
||||
version.current_flow = current_flow
|
||||
if current_step is not None:
|
||||
version.current_step = current_step
|
||||
if workflow_state is not None:
|
||||
version.workflow_state = workflow_state
|
||||
if is_complete is not None:
|
||||
version.is_complete = is_complete
|
||||
if editor_state is not None:
|
||||
version.editor_state = editor_state
|
||||
if source_filename is not None:
|
||||
version.source_filename = source_filename
|
||||
if source_base_path is not None:
|
||||
version.source_base_path = source_base_path
|
||||
if autosave:
|
||||
version.autosaved_at = datetime.now(UTC)
|
||||
|
||||
# Changes invalidate previous build and validation summaries.
|
||||
if raw_json is not None:
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
clear_execution_snapshot(version)
|
||||
version.locked_at = None
|
||||
version.locked_by_user_id = None
|
||||
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
|
||||
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
|
||||
campaign.status = CampaignStatus.DRAFT.value
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def update_campaign_review_state(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
inspection_complete: bool,
|
||||
reviewed_message_keys: list[str],
|
||||
user_id: str | None,
|
||||
) -> CampaignVersion:
|
||||
"""Persist review acknowledgement without mutating the locked campaign data.
|
||||
|
||||
Validation locks make the campaign JSON immutable, but review metadata is
|
||||
operational state attached to a specific build. It is therefore stored in
|
||||
editor_state and tied to the current build token so a rebuild invalidates it.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
ensure_current_working_version(campaign, version, action="record review state for")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
|
||||
build_summary = version.build_summary if isinstance(version.build_summary, dict) else {}
|
||||
if not build_summary:
|
||||
raise CampaignPersistenceError("Build messages before recording review state.")
|
||||
build_token = str(build_summary.get("build_token") or build_summary.get("built_at") or "").strip()
|
||||
if not build_token:
|
||||
# Backwards-compatible upgrade for build summaries created before
|
||||
# review-state tokens were introduced.
|
||||
build_token = uuid4().hex
|
||||
build_summary = copy.deepcopy(build_summary)
|
||||
build_summary["build_token"] = build_token
|
||||
version.build_summary = build_summary
|
||||
|
||||
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
|
||||
if inspection_complete:
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
blocking = [job for job in jobs if job.build_status != "built" or job.validation_status == "blocked"]
|
||||
if blocking:
|
||||
raise CampaignPersistenceError("Blocked or failed messages must be resolved before review can be completed.")
|
||||
reviewed = set(normalized_reviewed)
|
||||
explicit = {
|
||||
str(job.entry_id or job.entry_index)
|
||||
for job in jobs
|
||||
if job.validation_status == "needs_review"
|
||||
}
|
||||
missing = sorted(explicit - reviewed)
|
||||
if missing:
|
||||
raise CampaignPersistenceError(
|
||||
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
|
||||
)
|
||||
bulk_acceptable = [
|
||||
str(job.entry_id or job.entry_index)
|
||||
for job in jobs
|
||||
if job.validation_status in {"warning", "excluded"}
|
||||
]
|
||||
normalized_reviewed = list(dict.fromkeys([*normalized_reviewed, *bulk_acceptable]))
|
||||
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state["review_send"] = {
|
||||
"build_token": build_token,
|
||||
"inspection_complete": bool(inspection_complete),
|
||||
"reviewed_message_keys": normalized_reviewed,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"updated_by_user_id": user_id,
|
||||
}
|
||||
version.editor_state = editor_state
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def lock_campaign_version_temporarily(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
user_id: str | None,
|
||||
) -> CampaignVersion:
|
||||
"""Apply a reversible user-requested lock without changing workflow state."""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
ensure_current_working_version(campaign, version, action="lock")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery/final versions are permanently locked and cannot receive a temporary user lock.")
|
||||
if is_permanent_user_locked_version(version):
|
||||
raise LockedCampaignVersionError("This version is already permanently locked.")
|
||||
if is_temporary_user_locked_version(version):
|
||||
return version
|
||||
if version.locked_at:
|
||||
raise LockedCampaignVersionError("This version is already temporarily locked by validation. Unlock validation before applying a user lock.")
|
||||
|
||||
version.user_lock_state = USER_LOCK_TEMPORARY
|
||||
version.user_locked_at = datetime.now(UTC)
|
||||
version.user_locked_by_user_id = user_id
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def unlock_user_locked_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
"""Remove a reversible user lock without invalidating campaign data."""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
ensure_current_working_version(campaign, version, action="unlock")
|
||||
state = campaign_version_user_lock_state(version)
|
||||
if state == USER_LOCK_PERMANENT:
|
||||
raise LockedCampaignVersionError("Permanently locked versions cannot be unlocked. Create an editable copy instead.")
|
||||
if state != USER_LOCK_TEMPORARY:
|
||||
raise LockedCampaignVersionError("This version does not have a temporary user lock.")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("Delivery/final versions cannot be unlocked. Create an editable copy instead.")
|
||||
|
||||
version.user_lock_state = None
|
||||
version.user_locked_at = None
|
||||
version.user_locked_by_user_id = None
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def permanently_lock_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
user_id: str | None,
|
||||
) -> CampaignVersion:
|
||||
"""Apply an irreversible user lock.
|
||||
|
||||
The version remains in its current workflow state so the campaign itself is
|
||||
not silently archived. Future changes must be made in an editable copy.
|
||||
"""
|
||||
|
||||
version = get_campaign_version_for_tenant(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
ensure_current_working_version(campaign, version, action="lock permanently")
|
||||
if is_version_final_locked(version):
|
||||
raise LockedCampaignVersionError("This version is already permanently locked by its delivery/final state.")
|
||||
if is_permanent_user_locked_version(version):
|
||||
return version
|
||||
|
||||
now = datetime.now(UTC)
|
||||
version.user_lock_state = USER_LOCK_PERMANENT
|
||||
version.user_locked_at = now
|
||||
version.user_locked_by_user_id = user_id
|
||||
# Retain published_at as a compatibility marker for existing integrations.
|
||||
version.published_at = version.published_at or now
|
||||
session.add(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def publish_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
user_id: str | None = None,
|
||||
) -> CampaignVersion:
|
||||
"""Backwards-compatible alias for the permanent user lock."""
|
||||
|
||||
return permanently_lock_campaign_version(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
||||
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
||||
|
||||
This is intentionally less strict than campaign.schema.json validation. It
|
||||
lets the WebUI autosave and validate one wizard step at a time.
|
||||
"""
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
|
||||
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
|
||||
if section is None or section == sec:
|
||||
issues.append({
|
||||
"severity": severity,
|
||||
"section": sec,
|
||||
"field": field,
|
||||
"code": code,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
if not campaign.get("id"):
|
||||
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
|
||||
if not campaign.get("name"):
|
||||
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
|
||||
|
||||
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
|
||||
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
||||
if not sender.get("email"):
|
||||
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
||||
|
||||
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
|
||||
has_inline = bool(entries.get("inline"))
|
||||
has_source = isinstance(entries.get("source"), dict)
|
||||
if not has_inline and not has_source:
|
||||
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
|
||||
if has_source:
|
||||
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
|
||||
if not any(key in mapping for key in ("to.0.email", "to.email", "email")):
|
||||
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
||||
|
||||
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
|
||||
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
|
||||
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
||||
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
|
||||
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
||||
|
||||
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
|
||||
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
|
||||
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
|
||||
if not has_named_base_path and not attachments.get("base_path"):
|
||||
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
|
||||
|
||||
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
|
||||
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
||||
messages_per_minute = rate_limit.get("messages_per_minute")
|
||||
if messages_per_minute is not None:
|
||||
try:
|
||||
if int(messages_per_minute) < 1:
|
||||
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
|
||||
except (TypeError, ValueError):
|
||||
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
|
||||
|
||||
return {
|
||||
"ok": not any(item["severity"] == "error" for item in issues),
|
||||
"section": section,
|
||||
"error_count": sum(1 for item in issues if item["severity"] == "error"),
|
||||
"warning_count": sum(1 for item in issues if item["severity"] == "warning"),
|
||||
"info_count": sum(1 for item in issues if item["severity"] == "info"),
|
||||
"issues": issues,
|
||||
}
|
||||
1
src/govoplan_campaign/backend/reports/__init__.py
Normal file
1
src/govoplan_campaign/backend/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reporting helpers for campaigns and jobs."""
|
||||
429
src/govoplan_campaign/backend/reports/campaigns.py
Normal file
429
src/govoplan_campaign/backend/reports/campaigns.py
Normal file
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
||||
|
||||
|
||||
class CampaignReportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _counter(values: list[str | None]) -> dict[str, int]:
|
||||
return dict(Counter(value or "unknown" for value in values))
|
||||
|
||||
|
||||
def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign:
|
||||
campaign = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id).one_or_none()
|
||||
if not campaign:
|
||||
raise CampaignReportError(f"Campaign not found or not accessible: {campaign_id}")
|
||||
return campaign
|
||||
|
||||
|
||||
def _selected_version(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
version_id: str | None = None,
|
||||
) -> CampaignVersion | None:
|
||||
wanted = version_id or campaign.current_version_id
|
||||
if not wanted:
|
||||
return None
|
||||
version = session.get(CampaignVersion, wanted)
|
||||
if not version or version.campaign_id != campaign.id:
|
||||
raise CampaignReportError(f"Campaign version not found or not part of campaign: {wanted}")
|
||||
return version
|
||||
|
||||
|
||||
def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
|
||||
if not version:
|
||||
return None
|
||||
return {
|
||||
"id": version.id,
|
||||
"version_number": version.version_number,
|
||||
"schema_version": version.schema_version,
|
||||
"source_filename": version.source_filename,
|
||||
"created_at": version.created_at.isoformat() if version.created_at else None,
|
||||
"validation_summary": version.validation_summary,
|
||||
"build_summary": version.build_summary,
|
||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
"""Read deterministic delivery settings from the immutable execution snapshot."""
|
||||
|
||||
default = {
|
||||
"rate_limit": {"messages_per_minute": None, "concurrency": None},
|
||||
"imap_append_sent": {"enabled": None, "folder": None},
|
||||
"retry": {"max_attempts": None, "backoff_seconds": []},
|
||||
"execution_snapshot_hash": None,
|
||||
"execution_snapshot_at": None,
|
||||
"snapshot_version": None,
|
||||
"build_token": None,
|
||||
"built_at": None,
|
||||
"job_manifest_sha256": None,
|
||||
"job_count": 0,
|
||||
"queueable_job_count": 0,
|
||||
"effective_policy_sha256": None,
|
||||
"smtp_config_fingerprint": None,
|
||||
"imap_config_fingerprint": None,
|
||||
"estimated_remaining_send_seconds": None,
|
||||
"estimated_remaining_send_human": None,
|
||||
}
|
||||
if not version or not isinstance(version.execution_snapshot, dict):
|
||||
default["load_error"] = "No execution snapshot exists; rebuild the current locked version before delivery."
|
||||
return default
|
||||
try:
|
||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||
except Exception as exc: # pragma: no cover - reporting should remain available
|
||||
default["load_error"] = str(exc)
|
||||
return default
|
||||
|
||||
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
|
||||
pending = [job for job in jobs if job.send_status in {"queued", "claimed", "sending"}]
|
||||
estimated_seconds = None
|
||||
if messages_per_minute and pending:
|
||||
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
|
||||
|
||||
return {
|
||||
"rate_limit": {
|
||||
"messages_per_minute": messages_per_minute,
|
||||
"concurrency": snapshot.delivery.rate_limit.concurrency,
|
||||
},
|
||||
"imap_append_sent": {
|
||||
"enabled": snapshot.delivery.imap_append_sent.enabled,
|
||||
"folder": snapshot.delivery.imap_append_sent.folder,
|
||||
},
|
||||
"retry": {
|
||||
"max_attempts": snapshot.delivery.retry.max_attempts,
|
||||
"backoff_seconds": snapshot.delivery.retry.backoff_seconds,
|
||||
},
|
||||
"execution_snapshot_hash": version.execution_snapshot_hash,
|
||||
"execution_snapshot_at": version.execution_snapshot_at.isoformat() if version.execution_snapshot_at else None,
|
||||
"snapshot_version": snapshot.snapshot_version,
|
||||
"build_token": snapshot.build_token,
|
||||
"built_at": snapshot.built_at,
|
||||
"job_manifest_sha256": snapshot.job_manifest_sha256,
|
||||
"job_count": snapshot.job_count,
|
||||
"queueable_job_count": snapshot.queueable_job_count,
|
||||
"effective_policy_sha256": snapshot.effective_policy_sha256,
|
||||
"smtp_config_fingerprint": snapshot.smtp_config_fingerprint,
|
||||
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
|
||||
"estimated_remaining_send_seconds": estimated_seconds,
|
||||
"estimated_remaining_send_human": _human_duration(estimated_seconds),
|
||||
}
|
||||
|
||||
|
||||
def _human_duration(seconds: int | None) -> str | None:
|
||||
if seconds is None:
|
||||
return None
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
minutes, sec = divmod(seconds, 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {sec}s" if sec else f"{minutes}m"
|
||||
hours, minute = divmod(minutes, 60)
|
||||
return f"{hours}h {minute}m" if minute else f"{hours}h"
|
||||
|
||||
|
||||
def _issue_summary_from_jobs(jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
severity_counter: Counter[str] = Counter()
|
||||
code_counter: Counter[str] = Counter()
|
||||
behavior_counter: Counter[str] = Counter()
|
||||
total = 0
|
||||
for job in jobs:
|
||||
for issue in job.issues_snapshot or []:
|
||||
if not isinstance(issue, dict):
|
||||
continue
|
||||
total += 1
|
||||
severity_counter[issue.get("severity") or "unknown"] += 1
|
||||
code_counter[issue.get("code") or "unknown"] += 1
|
||||
if issue.get("behavior"):
|
||||
behavior_counter[issue["behavior"]] += 1
|
||||
return {
|
||||
"total": total,
|
||||
"by_severity": dict(severity_counter),
|
||||
"by_code": dict(code_counter),
|
||||
"by_behavior": dict(behavior_counter),
|
||||
}
|
||||
|
||||
|
||||
def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
status_counter: Counter[str] = Counter()
|
||||
behavior_counter: Counter[str] = Counter()
|
||||
total_configs = 0
|
||||
total_matched_files = 0
|
||||
zip_enabled = 0
|
||||
missing = 0
|
||||
ambiguous = 0
|
||||
for job in jobs:
|
||||
for attachment in job.resolved_attachments or []:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
total_configs += 1
|
||||
status = attachment.get("status") or "unknown"
|
||||
status_counter[status] += 1
|
||||
if attachment.get("behavior"):
|
||||
behavior_counter[attachment["behavior"]] += 1
|
||||
matches = attachment.get("matches") or []
|
||||
if isinstance(matches, list):
|
||||
total_matched_files += len(matches)
|
||||
if attachment.get("zip_enabled"):
|
||||
zip_enabled += 1
|
||||
if status == "missing":
|
||||
missing += 1
|
||||
if status == "ambiguous":
|
||||
ambiguous += 1
|
||||
return {
|
||||
"total_attachment_configs": total_configs,
|
||||
"total_matched_files": total_matched_files,
|
||||
"zip_enabled_configs": zip_enabled,
|
||||
"missing_configs": missing,
|
||||
"ambiguous_configs": ambiguous,
|
||||
"by_status": dict(status_counter),
|
||||
"by_behavior": dict(behavior_counter),
|
||||
}
|
||||
|
||||
|
||||
def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"]
|
||||
failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True)
|
||||
return [
|
||||
{
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"validation_status": job.validation_status,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"last_error": job.last_error,
|
||||
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
|
||||
}
|
||||
for job in failed[:limit]
|
||||
]
|
||||
|
||||
|
||||
def _job_row(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,
|
||||
"subject": job.subject,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
|
||||
"claimed_at": job.claimed_at.isoformat() if job.claimed_at else None,
|
||||
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
|
||||
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
|
||||
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
|
||||
"last_error": job.last_error,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
"issues_count": len(job.issues_snapshot or []),
|
||||
"attachment_config_count": len(job.resolved_attachments or []),
|
||||
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
|
||||
}
|
||||
|
||||
|
||||
def generate_campaign_report(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
include_jobs: bool = False,
|
||||
include_recent_failures: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a dashboard/report payload for one campaign.
|
||||
|
||||
The shape is intentionally web-UI friendly: status counters for cards,
|
||||
issue/attachment summaries for review panels, and optional job rows for
|
||||
tables/export.
|
||||
"""
|
||||
|
||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
version = _selected_version(session, campaign, version_id)
|
||||
jobs_query = session.query(CampaignJob).filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
)
|
||||
if version:
|
||||
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
|
||||
else:
|
||||
jobs_query = jobs_query.filter(False)
|
||||
jobs = (
|
||||
jobs_query
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
job_ids = [job.id for job in jobs]
|
||||
send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
|
||||
imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
|
||||
issue_query = session.query(CampaignIssue).filter(
|
||||
CampaignIssue.tenant_id == tenant_id,
|
||||
CampaignIssue.campaign_id == campaign.id,
|
||||
)
|
||||
if version:
|
||||
issue_query = issue_query.filter(CampaignIssue.campaign_version_id == version.id)
|
||||
else:
|
||||
issue_query = issue_query.filter(False)
|
||||
persisted_issues = issue_query.count()
|
||||
|
||||
validation_counts = _counter([job.validation_status for job in jobs])
|
||||
queue_counts = _counter([job.queue_status for job in jobs])
|
||||
send_counts = _counter([job.send_status for job in jobs])
|
||||
imap_counts = _counter([job.imap_status for job in jobs])
|
||||
build_counts = _counter([job.build_status for job in jobs])
|
||||
|
||||
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
|
||||
needs_attention = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.validation_status in {"needs_review", "blocked"}
|
||||
or job.send_status in {"failed_temporary", "failed_permanent", "outcome_unknown", "claimed", "sending"}
|
||||
or job.imap_status == "failed"
|
||||
)
|
||||
sent = send_counts.get("sent", 0) + send_counts.get("smtp_accepted", 0)
|
||||
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
|
||||
outcome_unknown = send_counts.get("outcome_unknown", 0)
|
||||
not_attempted = send_counts.get("not_queued", 0)
|
||||
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
|
||||
cancelled = send_counts.get("cancelled", 0)
|
||||
build_summary = version.build_summary if version and isinstance(version.build_summary, dict) else {}
|
||||
inactive_entries = int(build_summary.get("inactive_count") or build_summary.get("inactive_entries_count") or 0)
|
||||
if inactive_entries:
|
||||
validation_counts["inactive"] = inactive_entries
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"generated_at": _utcnow_iso(),
|
||||
"campaign": {
|
||||
"id": campaign.id,
|
||||
"external_id": campaign.external_id,
|
||||
"name": campaign.name,
|
||||
"description": campaign.description,
|
||||
"status": campaign.status,
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
|
||||
},
|
||||
"current_version": _version_info(version),
|
||||
"selected_version_id": version.id if version else None,
|
||||
"cards": {
|
||||
"jobs_total": len(jobs),
|
||||
"inactive": inactive_entries,
|
||||
"queueable": queueable,
|
||||
"needs_attention": needs_attention,
|
||||
"sent": sent,
|
||||
"smtp_accepted": sent,
|
||||
"failed": failed,
|
||||
"outcome_unknown": outcome_unknown,
|
||||
"not_attempted": not_attempted,
|
||||
"queued_or_active": queued,
|
||||
"cancelled": cancelled,
|
||||
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
|
||||
"imap_appended": imap_counts.get("appended", 0),
|
||||
"imap_failed": imap_counts.get("failed", 0),
|
||||
},
|
||||
"status_counts": {
|
||||
"build": build_counts,
|
||||
"validation": validation_counts,
|
||||
"queue": queue_counts,
|
||||
"send": send_counts,
|
||||
"imap": imap_counts,
|
||||
},
|
||||
"issues": {
|
||||
**_issue_summary_from_jobs(jobs),
|
||||
"persisted_campaign_issue_count": persisted_issues,
|
||||
},
|
||||
"attachments": _attachment_summary(jobs),
|
||||
"attempts": {
|
||||
"send_attempts": int(send_attempts),
|
||||
"imap_append_attempts": int(imap_attempts),
|
||||
},
|
||||
"delivery": _load_delivery_info(version, jobs),
|
||||
}
|
||||
if include_recent_failures:
|
||||
report["recent_failures"] = _recent_failures(jobs)
|
||||
if include_jobs:
|
||||
report["jobs"] = [_job_row(job) for job in jobs]
|
||||
return report
|
||||
|
||||
|
||||
def generate_jobs_csv(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
) -> str:
|
||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
version = _selected_version(session, campaign, version_id)
|
||||
jobs_query = session.query(CampaignJob).filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
)
|
||||
if version:
|
||||
jobs_query = jobs_query.filter(CampaignJob.campaign_version_id == version.id)
|
||||
else:
|
||||
jobs_query = jobs_query.filter(False)
|
||||
jobs = (
|
||||
jobs_query
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
rows = [_job_row(job) for job in jobs]
|
||||
fieldnames = [
|
||||
"job_id",
|
||||
"entry_index",
|
||||
"entry_id",
|
||||
"recipient_email",
|
||||
"subject",
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"imap_status",
|
||||
"attempt_count",
|
||||
"queued_at",
|
||||
"claimed_at",
|
||||
"smtp_started_at",
|
||||
"outcome_unknown_at",
|
||||
"sent_at",
|
||||
"last_error",
|
||||
"eml_size_bytes",
|
||||
"eml_sha256",
|
||||
"issues_count",
|
||||
"attachment_config_count",
|
||||
"matched_file_count",
|
||||
]
|
||||
buffer = io.StringIO()
|
||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buffer.getvalue()
|
||||
223
src/govoplan_campaign/backend/reports/emailing.py
Normal file
223
src/govoplan_campaign/backend/reports/emailing.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_config
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
|
||||
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message
|
||||
|
||||
|
||||
class CampaignReportEmailError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CampaignReportEmailResult:
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
to: list[str]
|
||||
subject: str
|
||||
dry_run: bool
|
||||
sent: bool
|
||||
attached_jobs_csv: bool
|
||||
attached_report_json: bool
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
accepted_count: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"campaign_id": self.campaign_id,
|
||||
"version_id": self.version_id,
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
"dry_run": self.dry_run,
|
||||
"sent": self.sent,
|
||||
"attached_jobs_csv": self.attached_jobs_csv,
|
||||
"attached_report_json": self.attached_report_json,
|
||||
"smtp_host": self.smtp_host,
|
||||
"smtp_port": self.smtp_port,
|
||||
"accepted_count": self.accepted_count,
|
||||
}
|
||||
|
||||
|
||||
def _selected_version(
|
||||
session: Session,
|
||||
campaign: Campaign,
|
||||
version_id: str | None = None,
|
||||
) -> CampaignVersion:
|
||||
wanted = version_id or campaign.current_version_id
|
||||
version = session.get(CampaignVersion, wanted) if wanted else None
|
||||
if version is None or version.campaign_id != campaign.id:
|
||||
raise CampaignReportEmailError("Campaign version not found")
|
||||
return version
|
||||
|
||||
|
||||
def _load_config(version: CampaignVersion) -> CampaignConfig:
|
||||
snapshot_path = _write_campaign_snapshot(version)
|
||||
return load_campaign_config(snapshot_path)
|
||||
|
||||
|
||||
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
||||
if config.recipients.from_:
|
||||
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
||||
if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username:
|
||||
return config.server.smtp.username, None
|
||||
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
|
||||
|
||||
|
||||
def _text_summary(report: dict[str, Any]) -> str:
|
||||
campaign = report["campaign"]
|
||||
cards = report["cards"]
|
||||
status = report["status_counts"]
|
||||
delivery = report.get("delivery", {})
|
||||
lines = [
|
||||
f"Campaign report: {campaign['name']}",
|
||||
"",
|
||||
f"Campaign ID: {campaign['id']}",
|
||||
f"External ID: {campaign['external_id']}",
|
||||
f"Status: {campaign['status']}",
|
||||
"",
|
||||
"Overview",
|
||||
f"- Jobs total: {cards['jobs_total']}",
|
||||
f"- Queueable: {cards['queueable']}",
|
||||
f"- Needs attention: {cards['needs_attention']}",
|
||||
f"- Sent: {cards['sent']}",
|
||||
f"- Failed: {cards['failed']}",
|
||||
f"- IMAP appended: {cards['imap_appended']}",
|
||||
f"- IMAP failed: {cards['imap_failed']}",
|
||||
"",
|
||||
f"Build status: {status.get('build', {})}",
|
||||
f"Validation status: {status.get('validation', {})}",
|
||||
f"Queue status: {status.get('queue', {})}",
|
||||
f"Send status: {status.get('send', {})}",
|
||||
f"IMAP status: {status.get('imap', {})}",
|
||||
]
|
||||
if delivery.get("estimated_remaining_send_human"):
|
||||
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
|
||||
lines.extend(["", "This report was generated by MultiMailer."])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report_message(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
config: CampaignConfig,
|
||||
report: dict[str, Any],
|
||||
to: list[str],
|
||||
jobs_csv: str | None = None,
|
||||
report_json: dict[str, Any] | None = None,
|
||||
) -> EmailMessage:
|
||||
from_email, from_name = _effective_from(config)
|
||||
subject = f"MultiMailer report: {campaign.name}"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((from_name or from_email, from_email))
|
||||
msg["To"] = ", ".join(to)
|
||||
msg["X-MultiMailer-Report"] = "campaign"
|
||||
msg.set_content(_text_summary(report))
|
||||
|
||||
if jobs_csv is not None:
|
||||
filename = f"multimailer-{campaign.external_id}-jobs.csv"
|
||||
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
|
||||
if report_json is not None:
|
||||
filename = f"multimailer-{campaign.external_id}-report.json"
|
||||
msg.add_attachment(
|
||||
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
|
||||
maintype="application",
|
||||
subtype="json",
|
||||
filename=filename,
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def send_campaign_report_email(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
to: list[str],
|
||||
include_jobs: bool = False,
|
||||
attach_jobs_csv: bool = True,
|
||||
attach_report_json: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> CampaignReportEmailResult:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise CampaignReportError("Campaign not found")
|
||||
if not to:
|
||||
raise CampaignReportEmailError("At least one report recipient is required")
|
||||
|
||||
version = _selected_version(session, campaign, version_id)
|
||||
config = _load_config(version)
|
||||
smtp_config: SmtpConfig | None = config.server.smtp
|
||||
if smtp_config is None:
|
||||
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
||||
|
||||
report = generate_campaign_report(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version.id,
|
||||
include_jobs=include_jobs,
|
||||
)
|
||||
jobs_csv = (
|
||||
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
|
||||
if attach_jobs_csv
|
||||
else None
|
||||
)
|
||||
report_json = report if attach_report_json else None
|
||||
message = build_report_message(
|
||||
campaign=campaign,
|
||||
config=config,
|
||||
report=report,
|
||||
to=to,
|
||||
jobs_csv=jobs_csv,
|
||||
report_json=report_json,
|
||||
)
|
||||
envelope_from, _ = _effective_from(config)
|
||||
|
||||
if dry_run:
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=True,
|
||||
sent=False,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
smtp_host=smtp_config.host,
|
||||
smtp_port=smtp_config.port,
|
||||
)
|
||||
|
||||
result: SmtpSendResult = send_email_message(
|
||||
message,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=to,
|
||||
)
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=False,
|
||||
sent=True,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
smtp_host=result.host,
|
||||
smtp_port=result.port,
|
||||
accepted_count=result.accepted_count,
|
||||
)
|
||||
1953
src/govoplan_campaign/backend/router.py
Normal file
1953
src/govoplan_campaign/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
1277
src/govoplan_campaign/backend/schema/campaign.schema.json
Normal file
1277
src/govoplan_campaign/backend/schema/campaign.schema.json
Normal file
File diff suppressed because it is too large
Load Diff
555
src/govoplan_campaign/backend/schemas.py
Normal file
555
src/govoplan_campaign/backend/schemas.py
Normal file
@@ -0,0 +1,555 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
config: dict[str, Any]
|
||||
source_filename: str | None = None
|
||||
source_base_path: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
class CampaignUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
external_id: str | None = None
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CampaignCreateMinimalRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
external_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
current_flow: str = "create"
|
||||
current_step: str = "basics"
|
||||
|
||||
|
||||
class CampaignVersionUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_json: dict[str, Any] | None = None
|
||||
current_flow: str | None = None
|
||||
current_step: str | None = None
|
||||
workflow_state: str | None = None
|
||||
is_complete: bool | None = None
|
||||
editor_state: dict[str, Any] | None = None
|
||||
source_filename: str | None = None
|
||||
source_base_path: str | None = None
|
||||
|
||||
|
||||
class CampaignVersionSetStepRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
current_flow: str | None = None
|
||||
current_step: str
|
||||
|
||||
|
||||
class CampaignReviewStateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inspection_complete: bool = False
|
||||
reviewed_message_keys: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignPartialValidationRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_json: dict[str, Any] | None = None
|
||||
section: str | None = None
|
||||
|
||||
|
||||
class CampaignVersionResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
campaign_id: str
|
||||
version_number: int
|
||||
schema_version: str
|
||||
source_filename: str | None = None
|
||||
source_base_path: str | None = None
|
||||
workflow_state: str = "editing"
|
||||
current_flow: str = "manual"
|
||||
current_step: str | None = None
|
||||
is_complete: bool = False
|
||||
editor_state: dict[str, Any] = Field(default_factory=dict)
|
||||
autosaved_at: datetime | None = None
|
||||
published_at: datetime | None = None
|
||||
locked_at: datetime | None = None
|
||||
locked_by_user_id: str | None = None
|
||||
user_lock_state: Literal["temporary", "permanent"] | None = None
|
||||
user_locked_at: datetime | None = None
|
||||
user_locked_by_user_id: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
validation_summary: dict[str, Any] | None = None
|
||||
build_summary: dict[str, Any] | None = None
|
||||
execution_snapshot_hash: str | None = None
|
||||
execution_snapshot_at: datetime | None = None
|
||||
|
||||
|
||||
class CampaignVersionDetailResponse(CampaignVersionResponse):
|
||||
raw_json: dict[str, Any]
|
||||
|
||||
|
||||
class CampaignPartialValidationResponse(BaseModel):
|
||||
ok: bool
|
||||
section: str | None = None
|
||||
error_count: int
|
||||
warning_count: int
|
||||
info_count: int
|
||||
issues: list[dict[str, Any]]
|
||||
|
||||
|
||||
class CampaignResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
external_id: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
status: str
|
||||
current_version_id: str | None = None
|
||||
owner_user_id: str | None = None
|
||||
owner_group_id: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CampaignCreateResponse(BaseModel):
|
||||
campaign: CampaignResponse
|
||||
version: CampaignVersionResponse
|
||||
|
||||
|
||||
class CampaignListResponse(BaseModel):
|
||||
campaigns: list[CampaignResponse]
|
||||
|
||||
|
||||
class CampaignShareItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
campaign_id: str
|
||||
target_type: Literal["user", "group"]
|
||||
target_id: str
|
||||
permission: Literal["read", "write"] = "read"
|
||||
revoked_at: datetime | None = None
|
||||
|
||||
|
||||
class CampaignShareListResponse(BaseModel):
|
||||
shares: list[CampaignShareItem]
|
||||
|
||||
|
||||
class CampaignShareTargetItem(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
secondary: str | None = None
|
||||
|
||||
|
||||
class CampaignShareTargetsResponse(BaseModel):
|
||||
users: list[CampaignShareTargetItem] = Field(default_factory=list)
|
||||
groups: list[CampaignShareTargetItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CampaignShareUpsertRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
target_type: Literal["user", "group"]
|
||||
target_id: str
|
||||
permission: Literal["read", "write"] = "read"
|
||||
|
||||
|
||||
class CampaignOwnerUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
owner_user_id: str | None = None
|
||||
owner_group_id: str | None = None
|
||||
|
||||
|
||||
class CampaignJobsResponse(BaseModel):
|
||||
jobs: list[dict[str, Any]]
|
||||
page: int = 1
|
||||
page_size: int = 50
|
||||
total: int = 0
|
||||
total_unfiltered: int = 0
|
||||
pages: int = 0
|
||||
counts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict)
|
||||
review: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignJobDetailResponse(BaseModel):
|
||||
job: dict[str, Any]
|
||||
attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class CampaignRetryJobsRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: str | None = None
|
||||
job_ids: list[str] = Field(default_factory=list)
|
||||
include_permanent: bool = False
|
||||
force_max_attempts: bool = False
|
||||
enqueue_celery: bool = True
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignSendUnattemptedRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: str | None = None
|
||||
job_ids: list[str] = Field(default_factory=list)
|
||||
enqueue_celery: bool = True
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignResolveOutcomeRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
decision: Literal["smtp_accepted", "not_sent"]
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class ValidateCampaignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
check_files: bool = False
|
||||
|
||||
|
||||
class BuildCampaignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
write_eml: bool = True
|
||||
|
||||
|
||||
class MailSmtpTestRequest(SmtpConfig):
|
||||
"""SMTP settings supplied directly from the WebUI mail settings form."""
|
||||
|
||||
|
||||
class MailImapTestRequest(ImapConfig):
|
||||
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
|
||||
|
||||
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
|
||||
|
||||
class MailCredentialPolicyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inherit: bool | None = None
|
||||
allow_override: bool | None = None
|
||||
|
||||
|
||||
class MailProfilePolicyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
allowed_profile_ids: list[str] = Field(default_factory=list)
|
||||
allow_user_profiles: bool | None = None
|
||||
allow_group_profiles: bool | None = None
|
||||
allow_campaign_profiles: bool | None = None
|
||||
smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||
whitelist: dict[str, list[str]] = Field(default_factory=dict)
|
||||
blacklist: dict[str, list[str]] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailProfilePolicyUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload)
|
||||
|
||||
|
||||
class MailProfilePolicyResponse(BaseModel):
|
||||
scope_type: MailProfileScope
|
||||
scope_id: str | None = None
|
||||
policy: dict[str, Any]
|
||||
effective_policy: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class MailServerProfileCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
scope_type: MailProfileScope = "tenant"
|
||||
scope_id: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
|
||||
|
||||
class MailServerProfileUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
smtp: SmtpConfig | None = None
|
||||
imap: ImapConfig | None = None
|
||||
clear_imap: bool = False
|
||||
|
||||
|
||||
class MailServerProfileResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
scope_type: MailProfileScope = "tenant"
|
||||
scope_id: str | None = None
|
||||
name: str
|
||||
slug: str
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
smtp: dict[str, Any]
|
||||
imap: dict[str, Any] | None = None
|
||||
smtp_password_configured: bool = False
|
||||
imap_password_configured: bool = False
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class MailServerProfileListResponse(BaseModel):
|
||||
profiles: list[MailServerProfileResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailConnectionTestResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["smtp", "imap"]
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
message: str
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailImapFolderResponse(BaseModel):
|
||||
name: str
|
||||
flags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MailImapFolderListResponse(BaseModel):
|
||||
ok: bool
|
||||
protocol: Literal["imap"] = "imap"
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
message: str
|
||||
folders: list[MailImapFolderResponse] = Field(default_factory=list)
|
||||
detected_sent_folder: str | None = None
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ApiKeyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ApiKeyCreateResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str]
|
||||
secret: str
|
||||
|
||||
|
||||
class QueueCampaignRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: str | None = None
|
||||
include_warnings: bool = True
|
||||
enqueue_celery: bool = True
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class QueueCampaignResponse(BaseModel):
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
queued_count: int
|
||||
skipped_count: int
|
||||
blocked_count: int
|
||||
enqueued_count: int
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class SendCampaignNowRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: str | None = None
|
||||
include_warnings: bool = True
|
||||
check_files: bool = False
|
||||
validate_before_send: bool = False
|
||||
build_before_send: bool = False
|
||||
dry_run: bool = False
|
||||
use_rate_limit: bool = True
|
||||
enqueue_imap_task: bool = False
|
||||
|
||||
|
||||
class SendCampaignNowResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class MockCampaignSendRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
version_id: str | None = None
|
||||
send: bool = False
|
||||
include_warnings: bool = True
|
||||
include_needs_review: bool = False
|
||||
append_sent: bool = True
|
||||
clear_mailbox: bool = False
|
||||
check_files: bool = False
|
||||
|
||||
|
||||
class MockCampaignSendResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class AppendSentRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enqueue_celery: bool = True
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CampaignActionResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
class ReportEmailRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
to: list[str]
|
||||
version_id: str | None = None
|
||||
include_jobs: bool = False
|
||||
attach_jobs_csv: bool = True
|
||||
attach_report_json: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class ReportEmailResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class AuditLogItemResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
api_key_id: str | None = None
|
||||
action: str
|
||||
object_type: str | None = None
|
||||
object_id: str | None = None
|
||||
details: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
items: list[AuditLogItemResponse]
|
||||
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str
|
||||
password: str
|
||||
# Kept optional for backwards compatibility and future tenant-switch login flows.
|
||||
# The WebUI no longer sends it. If omitted, the backend resolves the user by email.
|
||||
tenant_slug: str | None = None
|
||||
|
||||
|
||||
|
||||
|
||||
class SwitchTenantRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class TenantInfo(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
is_active: bool = True
|
||||
default_locale: str = "en"
|
||||
|
||||
|
||||
class TenantMembershipInfo(TenantInfo):
|
||||
roles: list[str] = Field(default_factory=list)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
email: str
|
||||
# Global account identity used by the title bar and account settings.
|
||||
display_name: str | None = None
|
||||
# Optional tenant-local alias used in tenant administration and ownership.
|
||||
tenant_display_name: str | None = None
|
||||
is_tenant_admin: bool = False
|
||||
password_reset_required: bool = False
|
||||
|
||||
|
||||
class ProfileUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
tenant_display_name: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
level: Literal["tenant", "system"] = "tenant"
|
||||
|
||||
|
||||
class GroupInfo(BaseModel):
|
||||
id: str
|
||||
slug: str
|
||||
name: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_at: datetime
|
||||
user: UserInfo
|
||||
# Backwards-compatible alias for the active tenant.
|
||||
tenant: TenantInfo
|
||||
active_tenant: TenantInfo
|
||||
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
|
||||
scopes: list[str]
|
||||
roles: list[RoleInfo] = Field(default_factory=list)
|
||||
groups: list[GroupInfo] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
user: UserInfo
|
||||
# Backwards-compatible alias for the active tenant.
|
||||
tenant: TenantInfo
|
||||
active_tenant: TenantInfo
|
||||
tenants: list[TenantMembershipInfo] = Field(default_factory=list)
|
||||
scopes: list[str]
|
||||
roles: list[RoleInfo] = Field(default_factory=list)
|
||||
groups: list[GroupInfo] = Field(default_factory=list)
|
||||
0
src/govoplan_campaign/backend/sending/__init__.py
Normal file
0
src/govoplan_campaign/backend/sending/__init__.py
Normal file
282
src/govoplan_campaign/backend/sending/execution.py
Normal file
282
src/govoplan_campaign/backend/sending/execution.py
Normal file
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
|
||||
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.mail_profiles import MailProfileError, apply_campaign_credentials, effective_profile_credentials_inherited, ensure_mail_profile_allowed_for_campaign, imap_config_from_profile, mail_profile_id_from_campaign_json, smtp_config_from_profile
|
||||
|
||||
SNAPSHOT_VERSION = "3"
|
||||
|
||||
|
||||
class ExecutionSnapshotError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ExecutionSnapshot(BaseModel):
|
||||
"""Immutable delivery inputs for one built campaign version.
|
||||
|
||||
Rendered messages and attachment evidence remain normalized in
|
||||
``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the
|
||||
mutable transport/runtime configuration and cryptographically binds it to
|
||||
the build and the exact set of persisted jobs without duplicating all
|
||||
recipient data into one large JSON value.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
snapshot_version: str = SNAPSHOT_VERSION
|
||||
campaign_version_id: str
|
||||
campaign_json_sha256: str
|
||||
created_at: str
|
||||
build_token: str | None = None
|
||||
built_at: str | None = None
|
||||
job_count: int = 0
|
||||
queueable_job_count: int = 0
|
||||
job_manifest_sha256: str | None = None
|
||||
effective_policy_sha256: str | None = None
|
||||
smtp_config_fingerprint: str | None = None
|
||||
imap_config_fingerprint: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
delivery: DeliveryConfig
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def snapshot_hash(payload: dict[str, Any]) -> str:
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def _transport_fingerprint(config: SmtpConfig | ImapConfig | None) -> str | None:
|
||||
if config is None:
|
||||
return None
|
||||
payload = config.model_dump(mode="json")
|
||||
# The fingerprint is safe to expose in reports. It identifies the effective
|
||||
# account/transport settings without incorporating or revealing the secret.
|
||||
if "password" in payload:
|
||||
payload["password"] = "<configured>" if payload.get("password") else None
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpConfig | ImapConfig | None:
|
||||
if config is None:
|
||||
return None
|
||||
payload = config.model_dump(mode="json")
|
||||
payload["password"] = None
|
||||
if isinstance(config, SmtpConfig):
|
||||
return SmtpConfig.model_validate(payload)
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
|
||||
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
config = server.get(name) if isinstance(server, dict) else None
|
||||
password = config.get("password") if isinstance(config, dict) else None
|
||||
if password is None:
|
||||
return None
|
||||
return str(password)
|
||||
|
||||
|
||||
def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any]:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
return server if isinstance(server, dict) else {}
|
||||
|
||||
|
||||
def _profile_for_version(session: Session, version: CampaignVersion):
|
||||
profile_id = mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if not profile_id:
|
||||
return None
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
try:
|
||||
return ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
|
||||
except MailProfileError as exc:
|
||||
raise ExecutionSnapshotError(str(exc)) from exc
|
||||
|
||||
|
||||
def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> SmtpConfig:
|
||||
payload = snapshot.smtp.model_dump(mode="json")
|
||||
if not payload.get("password"):
|
||||
server = _server_from_campaign_json(version.raw_json)
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is not None:
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
profile_payload = smtp_config_from_profile(profile).model_dump(mode="json")
|
||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
|
||||
return SmtpConfig.model_validate(profile_payload)
|
||||
return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp"))
|
||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
|
||||
return SmtpConfig.model_validate(payload)
|
||||
|
||||
|
||||
def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot) -> ImapConfig | None:
|
||||
server = _server_from_campaign_json(version.raw_json)
|
||||
if snapshot.imap is None:
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is None:
|
||||
return None
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is None:
|
||||
return None
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||
return ImapConfig.model_validate(imap_payload)
|
||||
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
|
||||
payload = snapshot.imap.model_dump(mode="json")
|
||||
if not payload.get("password"):
|
||||
profile = _profile_for_version(session, version)
|
||||
if profile is not None:
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is not None:
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if campaign is None:
|
||||
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
|
||||
return ImapConfig.model_validate(imap_payload)
|
||||
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
|
||||
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
|
||||
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
|
||||
return _sha256(
|
||||
{
|
||||
"validation_policy": raw_json.get("validation_policy"),
|
||||
"policy": raw_json.get("policy"),
|
||||
"delivery": delivery.model_dump(mode="json"),
|
||||
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
|
||||
if isinstance(raw_json.get("attachments"), dict)
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
|
||||
"""Hash the immutable per-message execution records in stable order."""
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)):
|
||||
payload.append(
|
||||
{
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"subject": job.subject,
|
||||
"message_id_header": job.message_id_header,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"eml_sha256": job.eml_sha256,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"resolved_recipients_sha256": _sha256(job.resolved_recipients or {}),
|
||||
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
|
||||
"issues_sha256": _sha256(job.issues_snapshot or []),
|
||||
}
|
||||
)
|
||||
return _sha256(payload)
|
||||
|
||||
|
||||
def create_execution_snapshot(
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
smtp: SmtpConfig,
|
||||
imap: ImapConfig | None,
|
||||
delivery: DeliveryConfig,
|
||||
jobs: Iterable[CampaignJob] = (),
|
||||
build_summary: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
job_list = list(jobs)
|
||||
summary = build_summary if isinstance(build_summary, dict) else {}
|
||||
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
|
||||
redacted_smtp = _redacted_transport_config(smtp)
|
||||
redacted_imap = _redacted_transport_config(imap)
|
||||
assert isinstance(redacted_smtp, SmtpConfig)
|
||||
assert redacted_imap is None or isinstance(redacted_imap, ImapConfig)
|
||||
payload = ExecutionSnapshot(
|
||||
campaign_version_id=version.id,
|
||||
campaign_json_sha256=_sha256(raw_json),
|
||||
build_token=str(summary.get("build_token") or "") or None,
|
||||
built_at=str(summary.get("built_at") or "") or None,
|
||||
job_count=len(job_list),
|
||||
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
|
||||
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
|
||||
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
|
||||
smtp_config_fingerprint=_transport_fingerprint(smtp),
|
||||
imap_config_fingerprint=_transport_fingerprint(imap),
|
||||
created_at=datetime.now(timezone.utc).isoformat(),
|
||||
smtp=redacted_smtp,
|
||||
imap=redacted_imap,
|
||||
delivery=delivery,
|
||||
).model_dump(mode="json")
|
||||
return payload, snapshot_hash(payload)
|
||||
|
||||
|
||||
def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> ExecutionSnapshot:
|
||||
"""Return a validated snapshot, creating one for pre-migration builds.
|
||||
|
||||
New builds create the snapshot after persisting their jobs. The fallback is
|
||||
intentionally limited to legacy built versions so they can be operated
|
||||
without a manual data migration.
|
||||
"""
|
||||
|
||||
if isinstance(version.execution_snapshot, dict):
|
||||
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
|
||||
expected = snapshot_hash(snapshot.model_dump(mode="json"))
|
||||
if version.execution_snapshot_hash and version.execution_snapshot_hash != expected:
|
||||
raise ExecutionSnapshotError("Execution snapshot checksum mismatch")
|
||||
return snapshot
|
||||
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
|
||||
_, _, config = load_version_config(session, version.id)
|
||||
if not config.server.smtp:
|
||||
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
|
||||
.all()
|
||||
)
|
||||
if not jobs:
|
||||
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
|
||||
payload, digest = create_execution_snapshot(
|
||||
version,
|
||||
smtp=config.server.smtp,
|
||||
imap=config.server.imap,
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},
|
||||
)
|
||||
version.execution_snapshot = payload
|
||||
version.execution_snapshot_hash = digest
|
||||
version.execution_snapshot_at = datetime.now(timezone.utc)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
return ExecutionSnapshot.model_validate(payload)
|
||||
|
||||
|
||||
def clear_execution_snapshot(version: CampaignVersion) -> None:
|
||||
version.execution_snapshot = None
|
||||
version.execution_snapshot_hash = None
|
||||
version.execution_snapshot_at = None
|
||||
1281
src/govoplan_campaign/backend/sending/jobs.py
Normal file
1281
src/govoplan_campaign/backend/sending/jobs.py
Normal file
File diff suppressed because it is too large
Load Diff
0
src/govoplan_campaign/backend/services/__init__.py
Normal file
0
src/govoplan_campaign/backend/services/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig
|
||||
|
||||
|
||||
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
|
||||
directory = base_path / config.base_dir
|
||||
if not directory.exists():
|
||||
return []
|
||||
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
|
||||
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)
|
||||
135
src/govoplan_campaign/backend/services/campaign_executor.py
Normal file
135
src/govoplan_campaign/backend/services/campaign_executor.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
|
||||
from govoplan_campaign.backend.domain.queue import MailQueue
|
||||
from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType
|
||||
from govoplan_campaign.backend.services.attachment_matching import match_files
|
||||
from govoplan_campaign.backend.services.zip_service import create_encrypted_zip
|
||||
|
||||
|
||||
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
|
||||
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
|
||||
|
||||
|
||||
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
|
||||
def rows(recipient_type: RecipientType) -> list[Recipient]:
|
||||
return [r for r in recipients if r.type == recipient_type]
|
||||
|
||||
def joined(recipient_type: RecipientType, mode: str) -> str:
|
||||
selected = rows(recipient_type)
|
||||
if mode == "address":
|
||||
return ", ".join(r.address for r in selected)
|
||||
if mode == "name":
|
||||
return ", ".join(r.name or r.address for r in selected)
|
||||
return ", ".join(r.formatted() for r in selected)
|
||||
|
||||
return {
|
||||
"mm_recipients": joined(RecipientType.TO, "formatted"),
|
||||
"mm_recipients_address": joined(RecipientType.TO, "address"),
|
||||
"mm_recipients_name": joined(RecipientType.TO, "name"),
|
||||
"mm_cc": joined(RecipientType.CC, "formatted"),
|
||||
"mm_cc_address": joined(RecipientType.CC, "address"),
|
||||
"mm_cc_name": joined(RecipientType.CC, "name"),
|
||||
"mm_bcc": joined(RecipientType.BCC, "formatted"),
|
||||
"mm_bcc_address": joined(RecipientType.BCC, "address"),
|
||||
"mm_bcc_name": joined(RecipientType.BCC, "name"),
|
||||
}
|
||||
|
||||
|
||||
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
if entry.combine_attachments:
|
||||
for config in campaign.global_attachment_configs:
|
||||
paths.extend(match_files(campaign.base_attachment_path, config))
|
||||
if campaign.individual_attachments:
|
||||
for config in entry.attachment_configs:
|
||||
paths.extend(match_files(campaign.base_attachment_path, config))
|
||||
return paths
|
||||
|
||||
|
||||
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
|
||||
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
|
||||
all_files: set[Path] = set()
|
||||
used_files: set[Path] = set()
|
||||
for config in campaign.global_attachment_configs:
|
||||
all_files.update(match_files(campaign.base_attachment_path, config))
|
||||
for entry in campaign.mail_entries:
|
||||
files = set(_message_attachment_paths(campaign, entry))
|
||||
used_files.update(files)
|
||||
all_files.update(files)
|
||||
return all_files - used_files
|
||||
|
||||
|
||||
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
|
||||
recipients = campaign.all_recipients_for(entry)
|
||||
attachments = _message_attachment_paths(campaign, entry)
|
||||
if not attachments and not campaign.send_without_attachments:
|
||||
return None
|
||||
|
||||
values = {}
|
||||
values.update(_recipient_values(recipients))
|
||||
values.update(campaign.field_contents.as_value_map("global"))
|
||||
values.update(entry.field_contents.as_value_map("local"))
|
||||
|
||||
message = EmailMessage()
|
||||
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
|
||||
if sender:
|
||||
message["From"] = sender.formatted()
|
||||
to_header = _recipient_header(recipients, RecipientType.TO)
|
||||
cc_header = _recipient_header(recipients, RecipientType.CC)
|
||||
if to_header:
|
||||
message["To"] = to_header
|
||||
if cc_header:
|
||||
message["Cc"] = cc_header
|
||||
message["Subject"] = campaign.subject_template.apply_values(values)
|
||||
message.set_content(campaign.mail_template.apply_values(values))
|
||||
|
||||
attachment_paths = attachments
|
||||
|
||||
if attachments and zip_attachments:
|
||||
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
|
||||
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
|
||||
zip_path = campaign.base_attachment_path / f"{number}.zip"
|
||||
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
|
||||
|
||||
for attachment_path in attachment_paths:
|
||||
data = attachment_path.read_bytes()
|
||||
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
|
||||
return message
|
||||
|
||||
|
||||
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
|
||||
queue = MailQueue()
|
||||
for entry in campaign.mail_entries:
|
||||
if not entry.is_active:
|
||||
continue
|
||||
message = build_message(campaign, entry, zip_attachments=zip_attachments)
|
||||
if message is not None:
|
||||
queue.add_mail(message)
|
||||
return queue
|
||||
|
||||
|
||||
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
|
||||
retry_queue = MailQueue()
|
||||
if settings.transport_security == TransportSecurity.TLS:
|
||||
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
|
||||
else:
|
||||
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
|
||||
try:
|
||||
if settings.transport_security == TransportSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
if settings.username:
|
||||
smtp.login(settings.username, settings.password)
|
||||
for message in queue:
|
||||
try:
|
||||
smtp.send_message(message)
|
||||
except Exception:
|
||||
retry_queue.add_mail(message)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return retry_queue
|
||||
65
src/govoplan_campaign/backend/services/zip_service.py
Normal file
65
src/govoplan_campaign/backend/services/zip_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
except ImportError: # pragma: no cover
|
||||
pyzipper = None
|
||||
|
||||
ArchiveMember = tuple[Path, str]
|
||||
|
||||
|
||||
def _normalized_members(files: Iterable[Path | ArchiveMember]) -> list[ArchiveMember]:
|
||||
members: list[ArchiveMember] = []
|
||||
used_names: set[str] = set()
|
||||
for item in files:
|
||||
path, requested_name = item if isinstance(item, tuple) else (item, item.name)
|
||||
requested = Path(requested_name).name or path.name
|
||||
stem = Path(requested).stem
|
||||
suffix = Path(requested).suffix
|
||||
candidate = requested
|
||||
counter = 2
|
||||
while candidate.casefold() in used_names:
|
||||
candidate = f"{stem} ({counter}){suffix}"
|
||||
counter += 1
|
||||
used_names.add(candidate.casefold())
|
||||
members.append((path, candidate))
|
||||
return members
|
||||
|
||||
|
||||
def create_zip_archive(
|
||||
output_path: Path,
|
||||
files: Iterable[Path | ArchiveMember],
|
||||
password: str = "",
|
||||
) -> Path:
|
||||
"""Create a ZIP archive, using AES encryption when a password is supplied."""
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
members = _normalized_members(files)
|
||||
if password:
|
||||
if pyzipper is None:
|
||||
raise RuntimeError("pyzipper is required for writing password-protected ZIP files")
|
||||
with pyzipper.AESZipFile(
|
||||
output_path,
|
||||
"w",
|
||||
compression=pyzipper.ZIP_DEFLATED,
|
||||
encryption=pyzipper.WZ_AES,
|
||||
) as zip_file:
|
||||
zip_file.setpassword(password.encode("utf-8"))
|
||||
for file_path, archive_name in members:
|
||||
zip_file.write(file_path, arcname=archive_name)
|
||||
return output_path
|
||||
|
||||
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for file_path, archive_name in members:
|
||||
zip_file.write(file_path, arcname=archive_name)
|
||||
return output_path
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
|
||||
"""Backward-compatible wrapper for the original per-rule ZIP helper."""
|
||||
|
||||
return create_zip_archive(output_path, files, password)
|
||||
21
src/govoplan_campaign/backend/time.py
Normal file
21
src/govoplan_campaign/backend/time.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_aware_utc(value: datetime | None) -> datetime | None:
|
||||
"""Return a timezone-aware UTC datetime.
|
||||
|
||||
SQLite and some DB drivers may return naive datetimes even when SQLAlchemy
|
||||
columns are declared as DateTime(timezone=True). Treat naive values as UTC
|
||||
so comparisons with aware `datetime.now(timezone.utc)` do not crash.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
Reference in New Issue
Block a user