added backends, improved templating, rbac
This commit is contained in:
62
server/app/mailer/campaign/field_values.py
Normal file
62
server/app/mailer/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
|
||||
@@ -91,6 +91,7 @@ class FieldDefinition(StrictModel):
|
||||
type: FieldType = FieldType.STRING
|
||||
label: str | None = None
|
||||
required: bool = False
|
||||
can_override: bool = True
|
||||
|
||||
|
||||
class SmtpConfig(StrictModel):
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .models import CampaignConfig, SourceType
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import CampaignConfig, EntryConfig, SourceType
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
@@ -61,6 +62,12 @@ def _resolve(campaign_file: Path, raw_path: str) -> 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",
|
||||
@@ -129,6 +136,18 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s
|
||||
return paths
|
||||
|
||||
|
||||
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,
|
||||
*,
|
||||
@@ -139,7 +158,8 @@ def validate_campaign_config(
|
||||
issues: list[SemanticIssue] = []
|
||||
|
||||
field_names = config.field_names
|
||||
declared_names = {field.name for field in config.fields}
|
||||
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:
|
||||
@@ -187,10 +207,13 @@ def validate_campaign_config(
|
||||
))
|
||||
|
||||
if config.entries.is_inline:
|
||||
entries_count = len(config.entries.inline or [])
|
||||
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):
|
||||
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'}"
|
||||
@@ -205,6 +228,16 @@ def validate_campaign_config(
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user