initial commit after split
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user