first version able to send
This commit is contained in:
@@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.mailer.campaign.entries import load_campaign_entries
|
||||
from app.mailer.campaign.field_values import effective_entry_field_values
|
||||
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
|
||||
from app.mailer.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig
|
||||
|
||||
|
||||
class AttachmentScope(StrEnum):
|
||||
@@ -57,6 +57,8 @@ class ResolvedAttachment(BaseModel):
|
||||
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
|
||||
@@ -192,15 +194,99 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
|
||||
return values
|
||||
|
||||
|
||||
def _iter_effective_attachment_configs(config: CampaignConfig, entry: EntryConfig) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
|
||||
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 _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig:
|
||||
return config.attachments.base_paths[0]
|
||||
|
||||
|
||||
def _selected_base_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
|
||||
if config.attachments.base_paths:
|
||||
if rendered_base_dir in {"", "."}:
|
||||
return _default_base_path(config)
|
||||
return _base_path_by_path(config, rendered_base_dir)
|
||||
return None
|
||||
|
||||
|
||||
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 _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool:
|
||||
rendered_base_dir = _rendered_base_dir(attachment_config, values)
|
||||
individual_paths = config.attachments.individual_base_path_values
|
||||
if individual_paths:
|
||||
return rendered_base_dir in individual_paths
|
||||
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
|
||||
if config.attachments.allow_individual:
|
||||
for index, attachment_config in enumerate(entry.attachments):
|
||||
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,
|
||||
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, 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 []
|
||||
@@ -227,7 +313,7 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code="ambiguous_attachment_match",
|
||||
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but allow_multiple is false",
|
||||
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but it is configured as a direct/single-file selection",
|
||||
behavior=behavior,
|
||||
)
|
||||
|
||||
@@ -235,27 +321,32 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
||||
def _resolve_one_config(
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
attachments_base_path: Path,
|
||||
campaign_config: CampaignConfig,
|
||||
values: dict[str, Any],
|
||||
scope: AttachmentScope,
|
||||
index: int,
|
||||
config: AttachmentConfig,
|
||||
) -> ResolvedAttachment:
|
||||
rendered_base_dir = _render_template(config.base_dir, values)
|
||||
rendered_base_dir = _rendered_base_dir(config, values)
|
||||
rendered_file_filter = _render_template(config.file_filter, values)
|
||||
directory = (attachments_base_path / rendered_base_dir).resolve()
|
||||
directory, selected_base_path = _resolve_attachment_directory(
|
||||
campaign_file=campaign_file,
|
||||
campaign_config=campaign_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 = config.missing_behavior
|
||||
behavior = _missing_behavior(campaign_config, config)
|
||||
issues.append(_issue_for_missing(config, behavior))
|
||||
elif len(matches) > 1 and not config.allow_multiple:
|
||||
elif len(matches) > 1 and not allow_multiple:
|
||||
status = AttachmentMatchStatus.AMBIGUOUS
|
||||
behavior = config.ambiguous_behavior
|
||||
behavior = _ambiguous_behavior(campaign_config, config)
|
||||
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
|
||||
else:
|
||||
status = AttachmentMatchStatus.OK
|
||||
@@ -267,12 +358,14 @@ def _resolve_one_config(
|
||||
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=config.allow_multiple,
|
||||
allow_multiple=allow_multiple,
|
||||
zip_enabled=config.zip.enabled,
|
||||
status=status,
|
||||
behavior=behavior,
|
||||
@@ -303,16 +396,15 @@ def resolve_entry_attachments(
|
||||
entry: EntryConfig,
|
||||
entry_index: int,
|
||||
) -> EntryAttachmentResolution:
|
||||
attachments_base_path = _resolve_path(campaign_file, config.attachments.base_path)
|
||||
values = _template_values(config, entry)
|
||||
resolved: list[ResolvedAttachment] = []
|
||||
|
||||
if entry.active:
|
||||
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry):
|
||||
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry, values):
|
||||
resolved.append(
|
||||
_resolve_one_config(
|
||||
campaign_file=campaign_file,
|
||||
attachments_base_path=attachments_base_path,
|
||||
campaign_config=config,
|
||||
values=values,
|
||||
scope=scope,
|
||||
index=index,
|
||||
@@ -333,7 +425,7 @@ def resolve_entry_attachments(
|
||||
|
||||
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_path)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user