inital commit
This commit is contained in:
346
server/app/mailer/persistence/versions.py
Normal file
346
server/app/mailer/persistence/versions.py
Normal file
@@ -0,0 +1,346 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
CampaignVersionFlow,
|
||||
CampaignVersionWorkflowState,
|
||||
)
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.persistence.campaigns import (
|
||||
CAMPAIGN_SNAPSHOT_DIR,
|
||||
CampaignPersistenceError,
|
||||
_ensure_dirs,
|
||||
_next_version_number,
|
||||
_write_campaign_snapshot,
|
||||
normalize_campaign_paths,
|
||||
)
|
||||
|
||||
|
||||
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": {
|
||||
"smtp": {
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": False,
|
||||
"host": "",
|
||||
"port": 993,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"security": "tls",
|
||||
"sent_folder": "auto",
|
||||
},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"name": "", "email": ""},
|
||||
"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": ".",
|
||||
"allow_individual": True,
|
||||
"send_without_attachments": False,
|
||||
"global": [],
|
||||
"missing_behavior": "ask",
|
||||
"ambiguous_behavior": "ask",
|
||||
},
|
||||
"entries": {
|
||||
"inline": [],
|
||||
"defaults": {
|
||||
"active": True,
|
||||
"combine_to": False,
|
||||
"combine_cc": True,
|
||||
"combine_bcc": True,
|
||||
"combine_reply_to": True,
|
||||
"combine_bounce_to": True,
|
||||
"combine_disposition_notification_to": True,
|
||||
"combine_attachments": True,
|
||||
"attachments": [],
|
||||
},
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_required_attachment": "ask",
|
||||
"missing_optional_attachment": "warn",
|
||||
"ambiguous_attachment_match": "ask",
|
||||
"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,
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
version.raw_json = runtime_json
|
||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||
campaign_meta = runtime_json.get("campaign") if isinstance(runtime_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
|
||||
|
||||
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
|
||||
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 publish_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> 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
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
version.published_at = datetime.now(UTC)
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
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 {}
|
||||
if 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,
|
||||
}
|
||||
Reference in New Issue
Block a user