Refactor campaign delivery decision paths
This commit is contained in:
@@ -465,6 +465,177 @@ def _job_from_message(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_built_postbox_targets(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
config: CampaignConfig,
|
||||
built_messages: list[Any],
|
||||
entries_by_index: dict[int, Any],
|
||||
) -> dict[int, list[dict[str, Any]]]:
|
||||
resolved_by_index: dict[int, list[dict[str, Any]]] = {}
|
||||
for built in built_messages:
|
||||
if not DeliveryChannelPolicy(built.draft.delivery_channel_policy).uses_postbox:
|
||||
continue
|
||||
entry = entries_by_index.get(built.draft.entry_index)
|
||||
if entry is None:
|
||||
raise CampaignPersistenceError("Built recipient row is missing from the campaign input.")
|
||||
resolved, issues, validation_status = resolve_entry_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=config,
|
||||
entry=entry,
|
||||
validation_status=built.draft.validation_status,
|
||||
materialize=True,
|
||||
)
|
||||
built.draft.issues.extend(issues)
|
||||
built.draft.validation_status = validation_status
|
||||
resolved_by_index[built.draft.entry_index] = resolved
|
||||
return resolved_by_index
|
||||
|
||||
|
||||
def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]:
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||
if isinstance(message_payload, dict):
|
||||
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
||||
report_json.update({
|
||||
"built_at": datetime.now(UTC).isoformat(),
|
||||
"build_token": uuid4().hex,
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
return report_json
|
||||
|
||||
|
||||
def _replace_version_jobs(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
built_messages: list[Any],
|
||||
postbox_targets_by_index: dict[int, list[dict[str, Any]]],
|
||||
) -> list[tuple[CampaignJob, MessageDraft]]:
|
||||
session.query(CampaignIssue).filter(
|
||||
CampaignIssue.campaign_version_id == version_id,
|
||||
CampaignIssue.job_id.is_not(None),
|
||||
).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version_id).delete(synchronize_session=False)
|
||||
session.flush()
|
||||
|
||||
pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||
for built in built_messages:
|
||||
job = _job_from_message(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
version_id=version_id,
|
||||
message=built.draft,
|
||||
resolved_postbox_targets=postbox_targets_by_index.get(built.draft.entry_index, []),
|
||||
)
|
||||
session.add(job)
|
||||
pairs.append((job, built.draft))
|
||||
session.flush()
|
||||
return pairs
|
||||
|
||||
|
||||
def _mail_execution_profile(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
config: CampaignConfig,
|
||||
jobs: list[CampaignJob],
|
||||
) -> tuple[str | None, dict[str, Any]]:
|
||||
if not any(DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs):
|
||||
return None, {}
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created.")
|
||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if profile_id is None:
|
||||
raise CampaignPersistenceError("Select an authorized Mail profile before building campaign messages that use Mail.")
|
||||
summary = profile_delivery_summary(session, version)
|
||||
if not summary.get("smtp_transport_revision"):
|
||||
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision.")
|
||||
return profile_id, summary
|
||||
|
||||
|
||||
def _store_execution_snapshot(
|
||||
session: Session,
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
config: CampaignConfig,
|
||||
jobs: list[CampaignJob],
|
||||
build_summary: dict[str, Any],
|
||||
) -> None:
|
||||
profile_id, profile = _mail_execution_profile(session, version=version, config=config, jobs=jobs)
|
||||
snapshot, snapshot_hash = create_execution_snapshot(
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
smtp_server_id=profile.get("smtp_server_id"),
|
||||
smtp_credential_id=profile.get("smtp_credential_id"),
|
||||
imap_server_id=profile.get("imap_server_id"),
|
||||
imap_credential_id=profile.get("imap_credential_id"),
|
||||
smtp_transport_revision=profile.get("smtp_transport_revision"),
|
||||
imap_transport_revision=profile.get("imap_transport_revision"),
|
||||
delivery=config.delivery,
|
||||
jobs=jobs,
|
||||
build_summary=build_summary,
|
||||
)
|
||||
version.execution_snapshot = snapshot
|
||||
version.execution_snapshot_hash = snapshot_hash
|
||||
version.execution_snapshot_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def _store_job_issues(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]],
|
||||
) -> None:
|
||||
for job, message in job_build_pairs:
|
||||
session.add_all([
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
campaign_version_id=version_id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
for issue in message.issues
|
||||
])
|
||||
|
||||
|
||||
def _apply_campaign_build_state(
|
||||
campaign: Campaign,
|
||||
version: CampaignVersion,
|
||||
*,
|
||||
needs_review_count: int,
|
||||
blocked_count: int,
|
||||
queueable_count: int,
|
||||
) -> None:
|
||||
if needs_review_count or blocked_count:
|
||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
elif queueable_count > 0:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
||||
else:
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
|
||||
def build_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -508,154 +679,44 @@ def build_campaign_version(
|
||||
start=1,
|
||||
)
|
||||
}
|
||||
resolved_postbox_targets_by_index: dict[
|
||||
int,
|
||||
list[dict[str, Any]],
|
||||
] = {}
|
||||
for built in result.built_messages:
|
||||
policy = DeliveryChannelPolicy(
|
||||
built.draft.delivery_channel_policy
|
||||
)
|
||||
if not policy.uses_postbox:
|
||||
continue
|
||||
entry = entries_by_index.get(built.draft.entry_index)
|
||||
if entry is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Built recipient row is missing from the campaign input."
|
||||
)
|
||||
resolved_targets, postbox_issues, validation_status = (
|
||||
resolve_entry_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
entry=entry,
|
||||
validation_status=built.draft.validation_status,
|
||||
materialize=True,
|
||||
)
|
||||
)
|
||||
built.draft.issues.extend(postbox_issues)
|
||||
built.draft.validation_status = validation_status
|
||||
resolved_postbox_targets_by_index[built.draft.entry_index] = (
|
||||
resolved_targets
|
||||
)
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
|
||||
if isinstance(message_payload, dict):
|
||||
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
|
||||
report_json["built_at"] = datetime.now(UTC).isoformat()
|
||||
report_json["build_token"] = uuid4().hex
|
||||
report_json.update({
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
resolved_postbox_targets_by_index = _resolve_built_postbox_targets(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
report_json = _campaign_build_report(result, files)
|
||||
version.build_summary = report_json
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
editor_state.pop("review_send", None)
|
||||
version.editor_state = editor_state
|
||||
|
||||
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.flush()
|
||||
|
||||
job_build_pairs: list[tuple[CampaignJob, MessageDraft]] = []
|
||||
for built in result.built_messages:
|
||||
job = _job_from_message(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
message=built.draft,
|
||||
resolved_postbox_targets=resolved_postbox_targets_by_index.get(
|
||||
built.draft.entry_index,
|
||||
[],
|
||||
),
|
||||
)
|
||||
session.add(job)
|
||||
job_build_pairs.append((job, built.draft))
|
||||
|
||||
# Assign all job IDs in one round-trip, then persist exact attachment use
|
||||
# records in bulk. This avoids one flush plus several metadata queries per
|
||||
# recipient for large campaigns.
|
||||
session.flush()
|
||||
files.record_campaign_attachment_uses_for_jobs(
|
||||
job_build_pairs = _replace_version_jobs(
|
||||
session,
|
||||
[job for job, _message in job_build_pairs],
|
||||
stage="built",
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
built_messages=result.built_messages,
|
||||
postbox_targets_by_index=resolved_postbox_targets_by_index,
|
||||
)
|
||||
uses_mail = any(
|
||||
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
|
||||
for job, _message in job_build_pairs
|
||||
jobs = [job for job, _message in job_build_pairs]
|
||||
files.record_campaign_attachment_uses_for_jobs(session, jobs, stage="built")
|
||||
_store_execution_snapshot(session, version=version, config=managed_config, jobs=jobs, build_summary=report_json)
|
||||
_store_job_issues(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
job_build_pairs=job_build_pairs,
|
||||
)
|
||||
profile_id: str | None = None
|
||||
profile_summary: dict[str, Any] = {}
|
||||
if uses_mail:
|
||||
if not managed_config.server.profile_capabilities.smtp_available:
|
||||
raise CampaignPersistenceError(
|
||||
"The selected Mail profile has no SMTP configuration; an "
|
||||
"execution snapshot cannot be created."
|
||||
)
|
||||
profile_id = campaign_mail_profile_id(
|
||||
version.raw_json if isinstance(version.raw_json, dict) else {}
|
||||
)
|
||||
if profile_id is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Select an authorized Mail profile before building campaign "
|
||||
"messages that use Mail."
|
||||
)
|
||||
profile_summary = profile_delivery_summary(session, version)
|
||||
if not profile_summary.get("smtp_transport_revision"):
|
||||
raise CampaignPersistenceError(
|
||||
"The selected Mail profile has no SMTP transport revision."
|
||||
)
|
||||
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
|
||||
_apply_campaign_build_state(
|
||||
campaign,
|
||||
version,
|
||||
mail_profile_id=profile_id,
|
||||
smtp_server_id=profile_summary.get("smtp_server_id"),
|
||||
smtp_credential_id=profile_summary.get("smtp_credential_id"),
|
||||
imap_server_id=profile_summary.get("imap_server_id"),
|
||||
imap_credential_id=profile_summary.get("imap_credential_id"),
|
||||
smtp_transport_revision=profile_summary.get(
|
||||
"smtp_transport_revision"
|
||||
),
|
||||
imap_transport_revision=profile_summary.get("imap_transport_revision"),
|
||||
delivery=managed_config.delivery,
|
||||
jobs=[job for job, _message in job_build_pairs],
|
||||
build_summary=report_json,
|
||||
needs_review_count=result.report.needs_review_count,
|
||||
blocked_count=result.report.blocked_count,
|
||||
queueable_count=result.report.queueable_count,
|
||||
)
|
||||
version.execution_snapshot = execution_snapshot
|
||||
version.execution_snapshot_hash = execution_snapshot_hash
|
||||
version.execution_snapshot_at = datetime.now(UTC)
|
||||
for job, message in job_build_pairs:
|
||||
for issue in message.issues:
|
||||
session.add(
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
)
|
||||
|
||||
if result.report.needs_review_count or result.report.blocked_count:
|
||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
elif result.report.queueable_count > 0:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
||||
else:
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
|
||||
Reference in New Issue
Block a user