added backends, improved templating, rbac

This commit is contained in:
2026-06-10 14:40:22 +02:00
parent d9ca48addc
commit ce43f2658f
28 changed files with 1183 additions and 78 deletions

View 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

View File

@@ -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):

View File

@@ -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():