first version able to send

This commit is contained in:
2026-06-11 00:06:44 +02:00
parent ce43f2658f
commit 3b06f3670e
12 changed files with 740 additions and 67 deletions

View File

@@ -14,6 +14,7 @@ from app.db.models import (
CampaignJob,
CampaignStatus,
CampaignVersion,
CampaignVersionWorkflowState,
JobBuildStatus,
JobImapStatus,
JobQueueStatus,
@@ -154,7 +155,15 @@ def load_version_config(session: Session, version_id: str):
return version, path, load_campaign_config(path)
def validate_campaign_version(session: Session, *, tenant_id: str, version_id: str, check_files: bool = False) -> dict[str, Any]:
def validate_campaign_version(
session: Session,
*,
tenant_id: str,
version_id: str,
check_files: bool = False,
user_id: str | None = None,
lock_on_success: bool = True,
) -> dict[str, Any]:
version, snapshot_path, config = load_version_config(session, version_id)
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
@@ -186,8 +195,15 @@ def validate_campaign_version(session: Session, *, tenant_id: str, version_id: s
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
if report.ok:
version.workflow_state = "under_review"
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
version.is_complete = True
if lock_on_success and version.locked_at is None:
from datetime import UTC, datetime
version.locked_at = datetime.now(UTC)
version.locked_by_user_id = user_id
else:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
session.add(version)
session.add(campaign)
session.commit()
@@ -248,6 +264,15 @@ def build_campaign_version(
campaign = session.get(Campaign, version.campaign_id)
if not campaign or campaign.tenant_id != tenant_id:
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value:
raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt")
validation_summary = version.validation_summary if isinstance(version.validation_summary, dict) else {}
if not validation_summary.get("ok"):
raise CampaignPersistenceError("Campaign version must be successfully validated before messages are built")
if version.locked_at is None:
from datetime import UTC, datetime
version.locked_at = datetime.now(UTC)
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml)
@@ -296,10 +321,10 @@ def build_campaign_version(
if result.report.needs_review_count or result.report.blocked_count:
campaign.status = CampaignStatus.NEEDS_REVIEW.value
version.workflow_state = "under_review"
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
elif result.report.queueable_count > 0:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
version.workflow_state = "built"
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
else:
campaign.status = CampaignStatus.VALIDATED.value

View File

@@ -87,6 +87,14 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
},
"attachments": {
"base_path": ".",
"base_paths": [
{
"id": "default",
"name": "Campaign files",
"path": ".",
"allow_individual": True,
}
],
"allow_individual": True,
"send_without_attachments": False,
"global": [],
@@ -111,6 +119,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
"missing_required_attachment": "ask",
"missing_optional_attachment": "warn",
"ambiguous_attachment_match": "ask",
"ignore_empty_fields": False,
"missing_email": "block",
"template_error": "block",
},
@@ -195,6 +204,92 @@ def get_campaign_version_for_tenant(
return version
LOCKED_WORKFLOW_STATES = {
CampaignVersionWorkflowState.APPROVED.value,
CampaignVersionWorkflowState.BUILT.value,
CampaignVersionWorkflowState.QUEUED.value,
CampaignVersionWorkflowState.SENDING.value,
CampaignVersionWorkflowState.COMPLETED.value,
CampaignVersionWorkflowState.CANCELLED.value,
CampaignVersionWorkflowState.ARCHIVED.value,
}
def is_version_locked(version: CampaignVersion) -> bool:
"""Return True when a version is immutable and edits must fork."""
return bool(version.locked_at or version.workflow_state in LOCKED_WORKFLOW_STATES)
def _apply_campaign_metadata(campaign: Campaign, raw_json: dict[str, Any]) -> None:
campaign_meta = raw_json.get("campaign") if isinstance(raw_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
def fork_campaign_version_for_edit(
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,
editor_state: dict[str, Any] | None = None,
source_filename: str | None = None,
source_base_path: str | None = None,
autosave: bool = True,
) -> CampaignVersion:
"""Create a new editable working version from a locked/validated version.
This preserves the audit value of the validated/sent version while allowing
users to continue editing a campaign. New content starts with the supplied
raw_json when provided, otherwise with a clone of the source version.
"""
source = 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
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
new_version = CampaignVersion(
campaign_id=campaign.id,
version_number=_next_version_number(session, campaign.id),
raw_json=runtime_json,
schema_version=str(runtime_json.get("version", source.schema_version or "1.0")),
source_filename=source_filename if source_filename is not None else source.source_filename,
source_base_path=source_base_path if source_base_path is not None else source.source_base_path,
workflow_state=CampaignVersionWorkflowState.EDITING.value,
current_flow=current_flow if current_flow is not None else (source.current_flow or CampaignVersionFlow.MANUAL.value),
current_step=current_step if current_step is not None else source.current_step,
is_complete=False,
editor_state=editor_state if editor_state is not None else copy.deepcopy(source.editor_state or {}),
autosaved_at=datetime.now(UTC) if autosave else None,
)
session.add(new_version)
session.flush()
_apply_campaign_metadata(campaign, runtime_json)
campaign.current_version_id = new_version.id
campaign.status = CampaignStatus.DRAFT.value
session.add(campaign)
_write_campaign_snapshot(new_version)
session.commit()
return new_version
def lock_validated_version(version: CampaignVersion, *, user_id: str | None = None) -> None:
if version.locked_at is None:
version.locked_at = datetime.now(UTC)
version.locked_by_user_id = user_id
def update_campaign_version(
session: Session,
*,
@@ -215,15 +310,28 @@ def update_campaign_version(
campaign = session.get(Campaign, campaign_id)
assert campaign is not None
if is_version_locked(version):
if raw_json is None:
raise CampaignPersistenceError("Campaign version is locked. Save campaign changes to create a new editable version.")
return fork_campaign_version_for_edit(
session,
tenant_id=tenant_id,
campaign_id=campaign_id,
version_id=version_id,
raw_json=raw_json,
current_flow=current_flow,
current_step=current_step,
editor_state=editor_state,
source_filename=source_filename,
source_base_path=source_base_path,
autosave=autosave,
)
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
_apply_campaign_metadata(campaign, runtime_json)
if current_flow is not None:
version.current_flow = current_flow
@@ -246,6 +354,11 @@ def update_campaign_version(
if raw_json is not None:
version.validation_summary = None
version.build_summary = None
version.locked_at = None
version.locked_by_user_id = None
if version.workflow_state != CampaignVersionWorkflowState.EDITING.value:
version.workflow_state = CampaignVersionWorkflowState.EDITING.value
campaign.status = CampaignStatus.DRAFT.value
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
session.add(version)
@@ -323,7 +436,9 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
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"):
base_paths = attachments.get("base_paths") if isinstance(attachments.get("base_paths"), list) else []
has_named_base_path = any(isinstance(item, dict) and item.get("path") for item in base_paths)
if not has_named_base_path and 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 {}