first version able to send
This commit is contained in:
@@ -351,6 +351,7 @@ def validate_version(
|
|||||||
tenant_id=principal.tenant_id,
|
tenant_id=principal.tenant_id,
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
check_files=payload.check_files if payload else False,
|
check_files=payload.check_files if payload else False,
|
||||||
|
user_id=principal.user.id,
|
||||||
)
|
)
|
||||||
audit_from_principal(
|
audit_from_principal(
|
||||||
session,
|
session,
|
||||||
@@ -536,7 +537,14 @@ def email_campaign_report(
|
|||||||
|
|
||||||
|
|
||||||
# Queue / delivery control -------------------------------------------------
|
# Queue / delivery control -------------------------------------------------
|
||||||
from app.api.v1.schemas import AppendSentRequest, CampaignActionResponse, QueueCampaignRequest, QueueCampaignResponse
|
from app.api.v1.schemas import (
|
||||||
|
AppendSentRequest,
|
||||||
|
CampaignActionResponse,
|
||||||
|
QueueCampaignRequest,
|
||||||
|
QueueCampaignResponse,
|
||||||
|
SendCampaignNowRequest,
|
||||||
|
SendCampaignNowResponse,
|
||||||
|
)
|
||||||
from app.mailer.sending.jobs import (
|
from app.mailer.sending.jobs import (
|
||||||
QueueingError,
|
QueueingError,
|
||||||
cancel_campaign_jobs,
|
cancel_campaign_jobs,
|
||||||
@@ -544,6 +552,7 @@ from app.mailer.sending.jobs import (
|
|||||||
pause_campaign_jobs,
|
pause_campaign_jobs,
|
||||||
queue_campaign_jobs,
|
queue_campaign_jobs,
|
||||||
resume_campaign_jobs,
|
resume_campaign_jobs,
|
||||||
|
send_campaign_now,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -579,6 +588,78 @@ def queue_campaign(
|
|||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/send-now", response_model=SendCampaignNowResponse)
|
||||||
|
def send_campaign_now_endpoint(
|
||||||
|
campaign_id: str,
|
||||||
|
payload: SendCampaignNowRequest | None = None,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(require_scope("campaign:send")),
|
||||||
|
):
|
||||||
|
"""Validate/build/queue and synchronously send a small campaign version.
|
||||||
|
|
||||||
|
This endpoint is intentionally conservative and suitable for a first small
|
||||||
|
test campaign. Larger campaigns should use the queue/Celery flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = payload or SendCampaignNowRequest()
|
||||||
|
try:
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id)
|
||||||
|
version_id = payload.version_id or campaign.current_version_id
|
||||||
|
if not version_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Campaign has no current version")
|
||||||
|
|
||||||
|
validation_result: dict[str, object] | None = None
|
||||||
|
build_result: dict[str, object] | None = None
|
||||||
|
if payload.validate_before_send:
|
||||||
|
validation_result = validate_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
version_id=version_id,
|
||||||
|
check_files=payload.check_files,
|
||||||
|
user_id=principal.user.id,
|
||||||
|
)
|
||||||
|
if not validation_result.get("ok"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail={"message": "Campaign validation failed", "validation": validation_result})
|
||||||
|
|
||||||
|
if payload.build_before_send:
|
||||||
|
build_result = build_campaign_version(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
version_id=version_id,
|
||||||
|
write_eml=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = send_campaign_now(
|
||||||
|
session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
version_id=version_id,
|
||||||
|
include_warnings=payload.include_warnings,
|
||||||
|
dry_run=payload.dry_run,
|
||||||
|
use_rate_limit=payload.use_rate_limit,
|
||||||
|
enqueue_imap_task=payload.enqueue_imap_task,
|
||||||
|
).as_dict()
|
||||||
|
result["validation"] = validation_result
|
||||||
|
result["build"] = build_result
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="campaign.sent_now" if not payload.dry_run else "campaign.send_now_dry_run",
|
||||||
|
object_type="campaign",
|
||||||
|
object_id=campaign_id,
|
||||||
|
details=result,
|
||||||
|
commit=True,
|
||||||
|
)
|
||||||
|
return SendCampaignNowResponse(result=result)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (CampaignPersistenceError, QueueingError) as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
|
@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse)
|
||||||
def pause_campaign(
|
def pause_campaign(
|
||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
|
|||||||
@@ -199,6 +199,23 @@ class QueueCampaignResponse(BaseModel):
|
|||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SendCampaignNowRequest(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
version_id: str | None = None
|
||||||
|
include_warnings: bool = True
|
||||||
|
check_files: bool = False
|
||||||
|
validate_before_send: bool = True
|
||||||
|
build_before_send: bool = True
|
||||||
|
dry_run: bool = False
|
||||||
|
use_rate_limit: bool = True
|
||||||
|
enqueue_imap_task: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SendCampaignNowResponse(BaseModel):
|
||||||
|
result: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
class AppendSentRequest(BaseModel):
|
class AppendSentRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.mailer.campaign.entries import load_campaign_entries
|
from app.mailer.campaign.entries import load_campaign_entries
|
||||||
from app.mailer.campaign.field_values import effective_entry_field_values
|
from app.mailer.campaign.field_values import effective_entry_field_values
|
||||||
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
|
from app.mailer.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig
|
||||||
|
|
||||||
|
|
||||||
class AttachmentScope(StrEnum):
|
class AttachmentScope(StrEnum):
|
||||||
@@ -57,6 +57,8 @@ class ResolvedAttachment(BaseModel):
|
|||||||
label: str | None = None
|
label: str | None = None
|
||||||
base_dir_template: str
|
base_dir_template: str
|
||||||
file_filter_template: str
|
file_filter_template: str
|
||||||
|
base_path_name: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
base_dir: str
|
base_dir: str
|
||||||
file_filter: str
|
file_filter: str
|
||||||
directory: str
|
directory: str
|
||||||
@@ -192,15 +194,99 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
|
|||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
def _iter_effective_attachment_configs(config: CampaignConfig, entry: EntryConfig) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
|
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
|
||||||
|
rendered = _render_template(config.base_dir, values).strip()
|
||||||
|
return rendered or "."
|
||||||
|
|
||||||
|
|
||||||
|
def _base_path_by_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
|
||||||
|
for base_path in config.attachments.base_paths:
|
||||||
|
if base_path.path == rendered_base_dir:
|
||||||
|
return base_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig:
|
||||||
|
return config.attachments.base_paths[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_base_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
|
||||||
|
if config.attachments.base_paths:
|
||||||
|
if rendered_base_dir in {"", "."}:
|
||||||
|
return _default_base_path(config)
|
||||||
|
return _base_path_by_path(config, rendered_base_dir)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -> bool:
|
||||||
|
"""Return whether a rule may produce multiple attachments.
|
||||||
|
|
||||||
|
New UI versions no longer expose allow_multiple. Treat wildcard patterns as
|
||||||
|
inherently multi-match-capable while keeping the legacy allow_multiple flag
|
||||||
|
for old campaign JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return config.allow_multiple or any(char in rendered_file_filter for char in "*?[")
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||||
|
if config.missing_behavior is not None:
|
||||||
|
return config.missing_behavior
|
||||||
|
if config.required:
|
||||||
|
return campaign_config.validation_policy.missing_required_attachment
|
||||||
|
return campaign_config.validation_policy.missing_optional_attachment
|
||||||
|
|
||||||
|
|
||||||
|
def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
|
||||||
|
return config.ambiguous_behavior or campaign_config.validation_policy.ambiguous_attachment_match
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool:
|
||||||
|
rendered_base_dir = _rendered_base_dir(attachment_config, values)
|
||||||
|
individual_paths = config.attachments.individual_base_path_values
|
||||||
|
if individual_paths:
|
||||||
|
return rendered_base_dir in individual_paths
|
||||||
|
return config.attachments.allow_individual
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_effective_attachment_configs(
|
||||||
|
config: CampaignConfig,
|
||||||
|
entry: EntryConfig,
|
||||||
|
values: dict[str, Any],
|
||||||
|
) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
|
||||||
if entry.combine_attachments:
|
if entry.combine_attachments:
|
||||||
for index, attachment_config in enumerate(config.attachments.global_):
|
for index, attachment_config in enumerate(config.attachments.global_):
|
||||||
yield AttachmentScope.GLOBAL, index, attachment_config
|
yield AttachmentScope.GLOBAL, index, attachment_config
|
||||||
if config.attachments.allow_individual:
|
|
||||||
for index, attachment_config in enumerate(entry.attachments):
|
for index, attachment_config in enumerate(entry.attachments):
|
||||||
|
if _entry_attachment_allowed(config, attachment_config, values):
|
||||||
yield AttachmentScope.ENTRY, index, attachment_config
|
yield AttachmentScope.ENTRY, index, attachment_config
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_attachment_directory(
|
||||||
|
*,
|
||||||
|
campaign_file: str | Path,
|
||||||
|
campaign_config: CampaignConfig,
|
||||||
|
rendered_base_dir: str,
|
||||||
|
) -> tuple[Path, AttachmentBasePathConfig | None]:
|
||||||
|
"""Resolve the directory for an attachment rule.
|
||||||
|
|
||||||
|
Legacy campaigns used attachments.base_path as the root and base_dir as a
|
||||||
|
child directory. Current WebUI campaigns select one named base path directly
|
||||||
|
in base_dir. Prefer the new base_paths list when present to avoid resolving
|
||||||
|
e.g. attachments/base_path + base_dir twice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
selected_base_path = _selected_base_path(campaign_config, rendered_base_dir)
|
||||||
|
if selected_base_path is not None:
|
||||||
|
return _resolve_path(campaign_file, selected_base_path.path), selected_base_path
|
||||||
|
|
||||||
|
if campaign_config.attachments.base_paths:
|
||||||
|
return _resolve_path(campaign_file, rendered_base_dir), None
|
||||||
|
|
||||||
|
legacy_root = _resolve_path(campaign_file, campaign_config.attachments.base_path)
|
||||||
|
return (legacy_root / rendered_base_dir).resolve(), None
|
||||||
|
|
||||||
|
|
||||||
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
|
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
|
||||||
if not directory.exists() or not directory.is_dir():
|
if not directory.exists() or not directory.is_dir():
|
||||||
return []
|
return []
|
||||||
@@ -227,7 +313,7 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
|||||||
return AttachmentIssue(
|
return AttachmentIssue(
|
||||||
severity=severity,
|
severity=severity,
|
||||||
code="ambiguous_attachment_match",
|
code="ambiguous_attachment_match",
|
||||||
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but allow_multiple is false",
|
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but it is configured as a direct/single-file selection",
|
||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -235,27 +321,32 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
|
|||||||
def _resolve_one_config(
|
def _resolve_one_config(
|
||||||
*,
|
*,
|
||||||
campaign_file: str | Path,
|
campaign_file: str | Path,
|
||||||
attachments_base_path: Path,
|
campaign_config: CampaignConfig,
|
||||||
values: dict[str, Any],
|
values: dict[str, Any],
|
||||||
scope: AttachmentScope,
|
scope: AttachmentScope,
|
||||||
index: int,
|
index: int,
|
||||||
config: AttachmentConfig,
|
config: AttachmentConfig,
|
||||||
) -> ResolvedAttachment:
|
) -> ResolvedAttachment:
|
||||||
rendered_base_dir = _render_template(config.base_dir, values)
|
rendered_base_dir = _rendered_base_dir(config, values)
|
||||||
rendered_file_filter = _render_template(config.file_filter, values)
|
rendered_file_filter = _render_template(config.file_filter, values)
|
||||||
directory = (attachments_base_path / rendered_base_dir).resolve()
|
directory, selected_base_path = _resolve_attachment_directory(
|
||||||
|
campaign_file=campaign_file,
|
||||||
|
campaign_config=campaign_config,
|
||||||
|
rendered_base_dir=rendered_base_dir,
|
||||||
|
)
|
||||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
|
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
|
||||||
|
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
|
||||||
|
|
||||||
issues: list[AttachmentIssue] = []
|
issues: list[AttachmentIssue] = []
|
||||||
behavior: Behavior | None = None
|
behavior: Behavior | None = None
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
status = AttachmentMatchStatus.MISSING
|
status = AttachmentMatchStatus.MISSING
|
||||||
behavior = config.missing_behavior
|
behavior = _missing_behavior(campaign_config, config)
|
||||||
issues.append(_issue_for_missing(config, behavior))
|
issues.append(_issue_for_missing(config, behavior))
|
||||||
elif len(matches) > 1 and not config.allow_multiple:
|
elif len(matches) > 1 and not allow_multiple:
|
||||||
status = AttachmentMatchStatus.AMBIGUOUS
|
status = AttachmentMatchStatus.AMBIGUOUS
|
||||||
behavior = config.ambiguous_behavior
|
behavior = _ambiguous_behavior(campaign_config, config)
|
||||||
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
|
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
|
||||||
else:
|
else:
|
||||||
status = AttachmentMatchStatus.OK
|
status = AttachmentMatchStatus.OK
|
||||||
@@ -267,12 +358,14 @@ def _resolve_one_config(
|
|||||||
label=config.label,
|
label=config.label,
|
||||||
base_dir_template=config.base_dir,
|
base_dir_template=config.base_dir,
|
||||||
file_filter_template=config.file_filter,
|
file_filter_template=config.file_filter,
|
||||||
|
base_path_name=selected_base_path.name if selected_base_path else None,
|
||||||
|
base_path=selected_base_path.path if selected_base_path else None,
|
||||||
base_dir=rendered_base_dir,
|
base_dir=rendered_base_dir,
|
||||||
file_filter=rendered_file_filter,
|
file_filter=rendered_file_filter,
|
||||||
directory=str(directory),
|
directory=str(directory),
|
||||||
include_subdirs=config.include_subdirs,
|
include_subdirs=config.include_subdirs,
|
||||||
required=config.required,
|
required=config.required,
|
||||||
allow_multiple=config.allow_multiple,
|
allow_multiple=allow_multiple,
|
||||||
zip_enabled=config.zip.enabled,
|
zip_enabled=config.zip.enabled,
|
||||||
status=status,
|
status=status,
|
||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
@@ -303,16 +396,15 @@ def resolve_entry_attachments(
|
|||||||
entry: EntryConfig,
|
entry: EntryConfig,
|
||||||
entry_index: int,
|
entry_index: int,
|
||||||
) -> EntryAttachmentResolution:
|
) -> EntryAttachmentResolution:
|
||||||
attachments_base_path = _resolve_path(campaign_file, config.attachments.base_path)
|
|
||||||
values = _template_values(config, entry)
|
values = _template_values(config, entry)
|
||||||
resolved: list[ResolvedAttachment] = []
|
resolved: list[ResolvedAttachment] = []
|
||||||
|
|
||||||
if entry.active:
|
if entry.active:
|
||||||
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry):
|
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry, values):
|
||||||
resolved.append(
|
resolved.append(
|
||||||
_resolve_one_config(
|
_resolve_one_config(
|
||||||
campaign_file=campaign_file,
|
campaign_file=campaign_file,
|
||||||
attachments_base_path=attachments_base_path,
|
campaign_config=config,
|
||||||
values=values,
|
values=values,
|
||||||
scope=scope,
|
scope=scope,
|
||||||
index=index,
|
index=index,
|
||||||
@@ -333,7 +425,7 @@ def resolve_entry_attachments(
|
|||||||
|
|
||||||
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
|
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
|
||||||
entries = load_campaign_entries(config, campaign_file=campaign_file)
|
entries = load_campaign_entries(config, campaign_file=campaign_file)
|
||||||
base_path = _resolve_path(campaign_file, config.attachments.base_path)
|
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
|
||||||
resolved_entries = [
|
resolved_entries = [
|
||||||
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
|
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
|
||||||
for index, entry in enumerate(entries, start=1)
|
for index, entry in enumerate(entries, start=1)
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ class RecipientConfig(StrictModel):
|
|||||||
|
|
||||||
class RecipientsConfig(StrictModel):
|
class RecipientsConfig(StrictModel):
|
||||||
from_: RecipientConfig | None = Field(default=None, alias="from")
|
from_: RecipientConfig | None = Field(default=None, alias="from")
|
||||||
|
|
||||||
|
@field_validator("from_", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_from_object_means_unset(cls, value: Any) -> Any:
|
||||||
|
if isinstance(value, dict) and not any(value.values()):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
allow_individual_from: bool = False
|
allow_individual_from: bool = False
|
||||||
|
|
||||||
to: list[RecipientConfig] = Field(default_factory=list)
|
to: list[RecipientConfig] = Field(default_factory=list)
|
||||||
@@ -200,6 +208,17 @@ class ZipConfig(StrictModel):
|
|||||||
method: ZipMethod = ZipMethod.AES
|
method: ZipMethod = ZipMethod.AES
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentBasePathConfig(StrictModel):
|
||||||
|
id: str | None = None
|
||||||
|
name: str
|
||||||
|
path: str = "."
|
||||||
|
allow_individual: bool = False
|
||||||
|
# Legacy UI builds briefly wrote a source value. Keep accepting it so older
|
||||||
|
# drafts do not become invalid merely because the current UI no longer shows
|
||||||
|
# or edits that column.
|
||||||
|
source: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AttachmentConfig(StrictModel):
|
class AttachmentConfig(StrictModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
label: str | None = None
|
label: str | None = None
|
||||||
@@ -208,26 +227,45 @@ class AttachmentConfig(StrictModel):
|
|||||||
include_subdirs: bool = False
|
include_subdirs: bool = False
|
||||||
required: bool = True
|
required: bool = True
|
||||||
allow_multiple: bool = False
|
allow_multiple: bool = False
|
||||||
missing_behavior: Behavior = Behavior.ASK
|
# None means: inherit from validation_policy. Explicit values remain
|
||||||
ambiguous_behavior: Behavior = Behavior.ASK
|
# supported for backwards compatibility and per-rule overrides.
|
||||||
|
missing_behavior: Behavior | None = None
|
||||||
|
ambiguous_behavior: Behavior | None = None
|
||||||
zip: ZipConfig = Field(default_factory=ZipConfig)
|
zip: ZipConfig = Field(default_factory=ZipConfig)
|
||||||
|
|
||||||
|
|
||||||
class AttachmentsConfig(StrictModel):
|
class AttachmentsConfig(StrictModel):
|
||||||
base_path: str = "."
|
base_path: str = "."
|
||||||
|
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
|
||||||
allow_individual: bool = False
|
allow_individual: bool = False
|
||||||
send_without_attachments: bool = True
|
send_without_attachments: bool = True
|
||||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||||
missing_behavior: Behavior = Behavior.ASK
|
missing_behavior: Behavior = Behavior.ASK
|
||||||
ambiguous_behavior: Behavior = Behavior.ASK
|
ambiguous_behavior: Behavior = Behavior.ASK
|
||||||
|
|
||||||
|
@property
|
||||||
|
def individual_base_path_values(self) -> set[str]:
|
||||||
|
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
|
||||||
|
|
||||||
|
|
||||||
class EntryConfig(StrictModel):
|
class EntryConfig(StrictModel):
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
# Compatibility fields written by older/current WebUI recipient rows.
|
||||||
|
# Address routing uses the explicit to/cc/bcc/reply_to/from fields below;
|
||||||
|
# these values are retained for round-tripping but are not used for sending.
|
||||||
|
name: str | None = None
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
from_: RecipientConfig | None = Field(default=None, alias="from")
|
from_: RecipientConfig | None = Field(default=None, alias="from")
|
||||||
|
|
||||||
|
@field_validator("from_", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_from_object_means_unset(cls, value: Any) -> Any:
|
||||||
|
if isinstance(value, dict) and not any(value.values()):
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
to: list[RecipientConfig] = Field(default_factory=list)
|
to: list[RecipientConfig] = Field(default_factory=list)
|
||||||
combine_to: bool = True
|
combine_to: bool = True
|
||||||
|
|
||||||
@@ -270,8 +308,12 @@ class EntriesConfig(StrictModel):
|
|||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def inline_or_external(self) -> "EntriesConfig":
|
def inline_or_external(self) -> "EntriesConfig":
|
||||||
has_inline = self.inline is not None
|
has_inline = self.inline is not None
|
||||||
has_external = self.source is not None or self.mapping is not None or self.defaults is not None
|
has_external_source = self.source is not None or self.mapping is not None
|
||||||
if has_inline and has_external:
|
# defaults are compatible with both inline and external entries. The
|
||||||
|
# WebUI stores the current per-entry combination defaults here even for
|
||||||
|
# inline campaigns, so treating defaults as an external-source marker
|
||||||
|
# made valid UI drafts fail backend validation.
|
||||||
|
if has_inline and has_external_source:
|
||||||
raise ValueError("entries must be either inline or source-based, not both")
|
raise ValueError("entries must be either inline or source-based, not both")
|
||||||
if has_inline:
|
if has_inline:
|
||||||
return self
|
return self
|
||||||
@@ -292,6 +334,7 @@ class ValidationPolicy(StrictModel):
|
|||||||
missing_required_attachment: Behavior = Behavior.ASK
|
missing_required_attachment: Behavior = Behavior.ASK
|
||||||
missing_optional_attachment: Behavior = Behavior.WARN
|
missing_optional_attachment: Behavior = Behavior.WARN
|
||||||
ambiguous_attachment_match: Behavior = Behavior.ASK
|
ambiguous_attachment_match: Behavior = Behavior.ASK
|
||||||
|
ignore_empty_fields: bool = False
|
||||||
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
||||||
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
|
||||||
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP
|
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Iterable
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from .field_values import ignored_entry_field_overrides
|
from .field_values import ignored_entry_field_overrides
|
||||||
from .models import CampaignConfig, EntryConfig, SourceType
|
from .models import AttachmentConfig, CampaignConfig, EntryConfig, SourceType
|
||||||
|
|
||||||
|
|
||||||
class Severity(StrEnum):
|
class Severity(StrEnum):
|
||||||
@@ -136,6 +136,59 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s
|
|||||||
return paths
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_base_path_report_value(config: CampaignConfig) -> str:
|
||||||
|
if config.attachments.base_paths:
|
||||||
|
return ", ".join(f"{base_path.name}: {base_path.path}" for base_path in config.attachments.base_paths)
|
||||||
|
return config.attachments.base_path
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_attachment_rules(config: CampaignConfig) -> Iterable[tuple[str, AttachmentConfig, bool]]:
|
||||||
|
for index, attachment_config in enumerate(config.attachments.global_):
|
||||||
|
yield f"/attachments/global/{index}", attachment_config, False
|
||||||
|
|
||||||
|
inline_entries = config.entries.inline or [] if config.entries.is_inline else []
|
||||||
|
for entry_index, entry in enumerate(inline_entries):
|
||||||
|
for attachment_index, attachment_config in enumerate(entry.attachments):
|
||||||
|
yield f"/entries/inline/{entry_index}/attachments/{attachment_index}", attachment_config, True
|
||||||
|
|
||||||
|
if config.entries.defaults:
|
||||||
|
for attachment_index, attachment_config in enumerate(config.entries.defaults.attachments):
|
||||||
|
yield f"/entries/defaults/attachments/{attachment_index}", attachment_config, True
|
||||||
|
|
||||||
|
|
||||||
|
def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||||
|
issues: list[SemanticIssue] = []
|
||||||
|
configured_paths = {base_path.path for base_path in config.attachments.base_paths}
|
||||||
|
individual_paths = config.attachments.individual_base_path_values
|
||||||
|
|
||||||
|
if config.attachments.base_paths:
|
||||||
|
for index, base_path in enumerate(config.attachments.base_paths):
|
||||||
|
if not base_path.name.strip():
|
||||||
|
issues.append(_issue(Severity.WARNING, "attachment_base_path_missing_name", "attachment base path has no display name", f"/attachments/base_paths/{index}/name"))
|
||||||
|
if not base_path.path.strip():
|
||||||
|
issues.append(_issue(Severity.ERROR, "attachment_base_path_missing_path", "attachment base path has no path", f"/attachments/base_paths/{index}/path"))
|
||||||
|
elif not config.attachments.base_path:
|
||||||
|
issues.append(_issue(Severity.INFO, "missing_attachment_base_path", "Attachment base path is not configured yet.", "/attachments/base_path"))
|
||||||
|
|
||||||
|
if configured_paths:
|
||||||
|
for path, attachment_config, is_individual in _iter_attachment_rules(config):
|
||||||
|
if attachment_config.base_dir and attachment_config.base_dir not in configured_paths:
|
||||||
|
issues.append(_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"unknown_attachment_base_path",
|
||||||
|
f"attachment rule refers to base path {attachment_config.base_dir!r}, but it is not listed in attachments.base_paths",
|
||||||
|
f"{path}/base_dir",
|
||||||
|
))
|
||||||
|
if is_individual and individual_paths and attachment_config.base_dir not in individual_paths:
|
||||||
|
issues.append(_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"individual_attachment_base_path_not_allowed",
|
||||||
|
f"individual attachment rule uses base path {attachment_config.base_dir!r}, but that base path does not allow individual attachments",
|
||||||
|
f"{path}/base_dir",
|
||||||
|
))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
|
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
|
||||||
return [
|
return [
|
||||||
_issue(
|
_issue(
|
||||||
@@ -170,6 +223,8 @@ def validate_campaign_config(
|
|||||||
f"/global_values/{key}",
|
f"/global_values/{key}",
|
||||||
))
|
))
|
||||||
|
|
||||||
|
issues.extend(_attachment_path_issues(config))
|
||||||
|
|
||||||
if config.server.imap and config.server.imap.enabled:
|
if config.server.imap and config.server.imap.enabled:
|
||||||
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
|
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -263,6 +318,17 @@ def validate_campaign_config(
|
|||||||
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
|
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
|
||||||
|
|
||||||
if check_files:
|
if check_files:
|
||||||
|
if config.attachments.base_paths:
|
||||||
|
for index, base_path_config in enumerate(config.attachments.base_paths):
|
||||||
|
attachments_base_path = _resolve(campaign_path, base_path_config.path)
|
||||||
|
if not attachments_base_path.exists():
|
||||||
|
issues.append(_issue(
|
||||||
|
Severity.WARNING,
|
||||||
|
"attachments_base_path_not_found",
|
||||||
|
f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}",
|
||||||
|
f"/attachments/base_paths/{index}/path",
|
||||||
|
))
|
||||||
|
else:
|
||||||
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
|
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
|
||||||
if not attachments_base_path.exists():
|
if not attachments_base_path.exists():
|
||||||
issues.append(_issue(
|
issues.append(_issue(
|
||||||
@@ -287,7 +353,7 @@ def validate_campaign_config(
|
|||||||
issues=issues,
|
issues=issues,
|
||||||
entries_mode=entries_mode,
|
entries_mode=entries_mode,
|
||||||
entries_count=entries_count,
|
entries_count=entries_count,
|
||||||
attachments_base_path=config.attachments.base_path,
|
attachments_base_path=_attachment_base_path_report_value(config),
|
||||||
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
|
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
|
||||||
imap_append_enabled=config.delivery.imap_append_sent.enabled,
|
imap_append_enabled=config.delivery.imap_append_sent.enabled,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
|
|||||||
required=attachment.required,
|
required=attachment.required,
|
||||||
allow_multiple=attachment.allow_multiple,
|
allow_multiple=attachment.allow_multiple,
|
||||||
zip_enabled=attachment.zip_enabled,
|
zip_enabled=attachment.zip_enabled,
|
||||||
|
base_path_name=attachment.base_path_name,
|
||||||
|
base_path=attachment.base_path,
|
||||||
file_filter=attachment.file_filter,
|
file_filter=attachment.file_filter,
|
||||||
directory=attachment.directory,
|
directory=attachment.directory,
|
||||||
matches=attachment.matches,
|
matches=attachment.matches,
|
||||||
@@ -330,7 +332,6 @@ def _iter_attachment_configs_for_resolution(config: CampaignConfig, entry: Entry
|
|||||||
if entry.combine_attachments:
|
if entry.combine_attachments:
|
||||||
for index, attachment_config in enumerate(config.attachments.global_):
|
for index, attachment_config in enumerate(config.attachments.global_):
|
||||||
yield "global", index, attachment_config
|
yield "global", index, attachment_config
|
||||||
if config.attachments.allow_individual:
|
|
||||||
for index, attachment_config in enumerate(entry.attachments):
|
for index, attachment_config in enumerate(entry.attachments):
|
||||||
yield "entry", index, attachment_config
|
yield "entry", index, attachment_config
|
||||||
|
|
||||||
@@ -457,9 +458,10 @@ def build_entry_message(
|
|||||||
|
|
||||||
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
||||||
values = _template_values(config, entry)
|
values = _template_values(config, entry)
|
||||||
subject = _render_template(subject_template, values)
|
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
|
||||||
text_body = _render_template(text_template or "", values) if text_template is not None else None
|
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
|
||||||
html_body = _render_template(html_template or "", values) if html_template is not None else None
|
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
|
||||||
|
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
|
||||||
|
|
||||||
unresolved = sorted(
|
unresolved = sorted(
|
||||||
_find_unresolved_placeholders(subject)
|
_find_unresolved_placeholders(subject)
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ class MessageAttachmentSummary(BaseModel):
|
|||||||
required: bool
|
required: bool
|
||||||
allow_multiple: bool
|
allow_multiple: bool
|
||||||
zip_enabled: bool
|
zip_enabled: bool
|
||||||
|
base_path_name: str | None = None
|
||||||
|
base_path: str | None = None
|
||||||
file_filter: str
|
file_filter: str
|
||||||
directory: str
|
directory: str
|
||||||
matches: list[str] = Field(default_factory=list)
|
matches: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.db.models import (
|
|||||||
CampaignJob,
|
CampaignJob,
|
||||||
CampaignStatus,
|
CampaignStatus,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignVersionWorkflowState,
|
||||||
JobBuildStatus,
|
JobBuildStatus,
|
||||||
JobImapStatus,
|
JobImapStatus,
|
||||||
JobQueueStatus,
|
JobQueueStatus,
|
||||||
@@ -154,7 +155,15 @@ def load_version_config(session: Session, version_id: str):
|
|||||||
return version, path, load_campaign_config(path)
|
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)
|
version, snapshot_path, config = load_version_config(session, version_id)
|
||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_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
|
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
|
||||||
if report.ok:
|
if report.ok:
|
||||||
version.workflow_state = "under_review"
|
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||||
version.is_complete = True
|
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(version)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
session.commit()
|
||||||
@@ -248,6 +264,15 @@ def build_campaign_version(
|
|||||||
campaign = session.get(Campaign, version.campaign_id)
|
campaign = session.get(Campaign, version.campaign_id)
|
||||||
if not campaign or campaign.tenant_id != tenant_id:
|
if not campaign or campaign.tenant_id != tenant_id:
|
||||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
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
|
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)
|
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:
|
if result.report.needs_review_count or result.report.blocked_count:
|
||||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||||
version.workflow_state = "under_review"
|
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||||
elif result.report.queueable_count > 0:
|
elif result.report.queueable_count > 0:
|
||||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||||
version.workflow_state = "built"
|
version.workflow_state = CampaignVersionWorkflowState.BUILT.value
|
||||||
else:
|
else:
|
||||||
campaign.status = CampaignStatus.VALIDATED.value
|
campaign.status = CampaignStatus.VALIDATED.value
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
|||||||
},
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"base_path": ".",
|
"base_path": ".",
|
||||||
|
"base_paths": [
|
||||||
|
{
|
||||||
|
"id": "default",
|
||||||
|
"name": "Campaign files",
|
||||||
|
"path": ".",
|
||||||
|
"allow_individual": True,
|
||||||
|
}
|
||||||
|
],
|
||||||
"allow_individual": True,
|
"allow_individual": True,
|
||||||
"send_without_attachments": False,
|
"send_without_attachments": False,
|
||||||
"global": [],
|
"global": [],
|
||||||
@@ -111,6 +119,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
|||||||
"missing_required_attachment": "ask",
|
"missing_required_attachment": "ask",
|
||||||
"missing_optional_attachment": "warn",
|
"missing_optional_attachment": "warn",
|
||||||
"ambiguous_attachment_match": "ask",
|
"ambiguous_attachment_match": "ask",
|
||||||
|
"ignore_empty_fields": False,
|
||||||
"missing_email": "block",
|
"missing_email": "block",
|
||||||
"template_error": "block",
|
"template_error": "block",
|
||||||
},
|
},
|
||||||
@@ -195,6 +204,92 @@ def get_campaign_version_for_tenant(
|
|||||||
return version
|
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(
|
def update_campaign_version(
|
||||||
session: Session,
|
session: Session,
|
||||||
*,
|
*,
|
||||||
@@ -215,15 +310,28 @@ def update_campaign_version(
|
|||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
assert campaign is not None
|
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:
|
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)
|
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.raw_json = runtime_json
|
||||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
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 {}
|
_apply_campaign_metadata(campaign, runtime_json)
|
||||||
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:
|
if current_flow is not None:
|
||||||
version.current_flow = current_flow
|
version.current_flow = current_flow
|
||||||
@@ -246,6 +354,11 @@ def update_campaign_version(
|
|||||||
if raw_json is not None:
|
if raw_json is not None:
|
||||||
version.validation_summary = None
|
version.validation_summary = None
|
||||||
version.build_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.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||||
|
|
||||||
session.add(version)
|
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.")
|
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 {}
|
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.")
|
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 {}
|
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
|
||||||
|
|||||||
@@ -172,8 +172,19 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"from": {
|
"from": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
"$ref": "#/$defs/recipient"
|
"$ref": "#/$defs/recipient"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"maxProperties": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"allow_individual_from": {
|
"allow_individual_from": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false
|
||||||
@@ -260,10 +271,16 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"text": {
|
"text": {
|
||||||
"type": "string"
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"html": {
|
"html": {
|
||||||
"type": "string"
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -311,7 +328,15 @@
|
|||||||
"base_path": {
|
"base_path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": ".",
|
"default": ".",
|
||||||
"description": "Campaign-level base path. Global and entry attachment base_dir values are resolved relative to this path unless absolute."
|
"description": "Legacy single campaign-level attachment base path. Used when attachments.base_paths is absent."
|
||||||
|
},
|
||||||
|
"base_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/attachment_base_path"
|
||||||
|
},
|
||||||
|
"default": [],
|
||||||
|
"description": "Named selectable attachment base paths. New WebUI campaigns use these instead of the legacy single base_path."
|
||||||
},
|
},
|
||||||
"allow_individual": {
|
"allow_individual": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -355,6 +380,7 @@
|
|||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"default": {
|
"default": {
|
||||||
"base_path": ".",
|
"base_path": ".",
|
||||||
|
"base_paths": [],
|
||||||
"global": []
|
"global": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -371,6 +397,9 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/$defs/entry"
|
"$ref": "#/$defs/entry"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"defaults": {
|
||||||
|
"$ref": "#/$defs/entry"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -436,6 +465,11 @@
|
|||||||
],
|
],
|
||||||
"default": "ask"
|
"default": "ask"
|
||||||
},
|
},
|
||||||
|
"ignore_empty_fields": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Render unresolved/empty template placeholders as an empty string instead of keeping them and raising a template error."
|
||||||
|
},
|
||||||
"missing_email": {
|
"missing_email": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@@ -463,7 +497,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"default": {}
|
"default": {
|
||||||
|
"ignore_empty_fields": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"delivery": {
|
"delivery": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -601,7 +637,7 @@
|
|||||||
},
|
},
|
||||||
"base_dir": {
|
"base_dir": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Directory relative to attachments.base_path unless absolute."
|
"description": "Selected attachment base path for current WebUI campaigns, or a directory relative to attachments.base_path for legacy campaigns."
|
||||||
},
|
},
|
||||||
"file_filter": {
|
"file_filter": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -617,29 +653,40 @@
|
|||||||
},
|
},
|
||||||
"allow_multiple": {
|
"allow_multiple": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": false
|
"default": false,
|
||||||
|
"description": "Legacy compatibility flag. Current UI treats wildcard file_filter patterns as multi-match-capable automatically."
|
||||||
},
|
},
|
||||||
"missing_behavior": {
|
"missing_behavior": {
|
||||||
"type": "string",
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
"enum": [
|
"enum": [
|
||||||
"block",
|
"block",
|
||||||
"ask",
|
"ask",
|
||||||
"drop",
|
"drop",
|
||||||
"continue",
|
"continue",
|
||||||
"warn"
|
"warn",
|
||||||
|
null
|
||||||
],
|
],
|
||||||
"default": "ask"
|
"default": null,
|
||||||
|
"description": "Optional per-rule override. Null or omitted inherits from validation_policy."
|
||||||
},
|
},
|
||||||
"ambiguous_behavior": {
|
"ambiguous_behavior": {
|
||||||
"type": "string",
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
"enum": [
|
"enum": [
|
||||||
"block",
|
"block",
|
||||||
"ask",
|
"ask",
|
||||||
"drop",
|
"drop",
|
||||||
"continue",
|
"continue",
|
||||||
"warn"
|
"warn",
|
||||||
|
null
|
||||||
],
|
],
|
||||||
"default": "ask"
|
"default": null,
|
||||||
|
"description": "Optional per-rule override. Null or omitted inherits from validation_policy."
|
||||||
},
|
},
|
||||||
"zip": {
|
"zip": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -682,8 +729,19 @@
|
|||||||
"default": true
|
"default": true
|
||||||
},
|
},
|
||||||
"from": {
|
"from": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
"$ref": "#/$defs/recipient"
|
"$ref": "#/$defs/recipient"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"maxProperties": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "null"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"to": {
|
"to": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@@ -769,6 +827,19 @@
|
|||||||
"last_sent": {
|
"last_sent": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "email"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
@@ -804,6 +875,41 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"attachment_base_path": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional stable ID for UI/status references."
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Display name for this selectable attachment base path."
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"default": ".",
|
||||||
|
"description": "Base path relative to the campaign file unless absolute."
|
||||||
|
},
|
||||||
|
"allow_individual": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "Whether recipient-level attachments may use this base path."
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"description": "Legacy UI compatibility value. Ignored by the backend."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.db.models import (
|
|||||||
CampaignJob,
|
CampaignJob,
|
||||||
CampaignStatus,
|
CampaignStatus,
|
||||||
CampaignVersion,
|
CampaignVersion,
|
||||||
|
CampaignVersionWorkflowState,
|
||||||
JobBuildStatus,
|
JobBuildStatus,
|
||||||
JobImapStatus,
|
JobImapStatus,
|
||||||
JobQueueStatus,
|
JobQueueStatus,
|
||||||
@@ -61,6 +62,30 @@ class QueueCampaignResult:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SendCampaignNowResult:
|
||||||
|
campaign_id: str
|
||||||
|
version_id: str
|
||||||
|
attempted_count: int
|
||||||
|
sent_count: int
|
||||||
|
failed_count: int
|
||||||
|
skipped_count: int
|
||||||
|
dry_run: bool = False
|
||||||
|
results: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"campaign_id": self.campaign_id,
|
||||||
|
"version_id": self.version_id,
|
||||||
|
"attempted_count": self.attempted_count,
|
||||||
|
"sent_count": self.sent_count,
|
||||||
|
"failed_count": self.failed_count,
|
||||||
|
"skipped_count": self.skipped_count,
|
||||||
|
"dry_run": self.dry_run,
|
||||||
|
"results": self.results or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class SendJobResult:
|
class SendJobResult:
|
||||||
job_id: str
|
job_id: str
|
||||||
@@ -197,6 +222,10 @@ def queue_campaign_jobs(
|
|||||||
if not dry_run:
|
if not dry_run:
|
||||||
if queued:
|
if queued:
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
campaign.status = CampaignStatus.QUEUED.value
|
||||||
|
version.workflow_state = CampaignVersionWorkflowState.QUEUED.value
|
||||||
|
if version.locked_at is None:
|
||||||
|
version.locked_at = _utcnow()
|
||||||
|
session.add(version)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -217,6 +246,91 @@ def queue_campaign_jobs(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def send_campaign_now(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
campaign_id: str,
|
||||||
|
version_id: str | None = None,
|
||||||
|
include_warnings: bool = True,
|
||||||
|
dry_run: bool = False,
|
||||||
|
use_rate_limit: bool = True,
|
||||||
|
enqueue_imap_task: bool = False,
|
||||||
|
) -> SendCampaignNowResult:
|
||||||
|
"""Queue and send a small campaign synchronously.
|
||||||
|
|
||||||
|
This is intended for WebUI test/small-campaign flows. Large campaigns can
|
||||||
|
still use queue_campaign_jobs with Celery workers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
|
version = _get_current_version(session, campaign, version_id=version_id)
|
||||||
|
|
||||||
|
queue_result = queue_campaign_jobs(
|
||||||
|
session,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_id=version.id,
|
||||||
|
include_warnings=include_warnings,
|
||||||
|
enqueue_celery=False,
|
||||||
|
dry_run=dry_run,
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
return SendCampaignNowResult(
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_id=version.id,
|
||||||
|
attempted_count=0,
|
||||||
|
sent_count=0,
|
||||||
|
failed_count=0,
|
||||||
|
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
|
||||||
|
dry_run=True,
|
||||||
|
results=[queue_result.as_dict()],
|
||||||
|
)
|
||||||
|
|
||||||
|
jobs = (
|
||||||
|
session.query(CampaignJob)
|
||||||
|
.filter(
|
||||||
|
CampaignJob.tenant_id == tenant_id,
|
||||||
|
CampaignJob.campaign_version_id == version.id,
|
||||||
|
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||||
|
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
|
||||||
|
)
|
||||||
|
.order_by(CampaignJob.entry_index.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
sent_count = 0
|
||||||
|
failed_count = 0
|
||||||
|
for job in jobs:
|
||||||
|
try:
|
||||||
|
result = send_campaign_job(
|
||||||
|
session,
|
||||||
|
job_id=job.id,
|
||||||
|
dry_run=False,
|
||||||
|
use_rate_limit=use_rate_limit,
|
||||||
|
enqueue_imap_task=enqueue_imap_task,
|
||||||
|
)
|
||||||
|
result_dict = result.as_dict()
|
||||||
|
results.append(result_dict)
|
||||||
|
if result.status in {"sent", "already_sent"}:
|
||||||
|
sent_count += 1
|
||||||
|
except Exception as exc: # keep sending other jobs and return per-job details
|
||||||
|
failed_count += 1
|
||||||
|
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
|
||||||
|
|
||||||
|
return SendCampaignNowResult(
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_id=version.id,
|
||||||
|
attempted_count=len(jobs),
|
||||||
|
sent_count=sent_count,
|
||||||
|
failed_count=failed_count,
|
||||||
|
skipped_count=queue_result.skipped_count + queue_result.blocked_count,
|
||||||
|
dry_run=False,
|
||||||
|
results=results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
|
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
|
||||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||||
jobs = (
|
jobs = (
|
||||||
@@ -360,15 +474,18 @@ def _record_attempt_start(session: Session, job: CampaignJob) -> SendAttempt:
|
|||||||
return attempt
|
return attempt
|
||||||
|
|
||||||
|
|
||||||
def _update_campaign_after_job(session: Session, campaign_id: str) -> None:
|
def _update_campaign_after_job(session: Session, campaign_id: str, version_id: str | None = None) -> None:
|
||||||
session.flush()
|
session.flush()
|
||||||
campaign = session.get(Campaign, campaign_id)
|
campaign = session.get(Campaign, campaign_id)
|
||||||
if not campaign:
|
if not campaign:
|
||||||
return
|
return
|
||||||
|
base_filters = [CampaignJob.campaign_id == campaign_id]
|
||||||
|
if version_id:
|
||||||
|
base_filters.append(CampaignJob.campaign_version_id == version_id)
|
||||||
remaining = (
|
remaining = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(
|
.filter(
|
||||||
CampaignJob.campaign_id == campaign_id,
|
*base_filters,
|
||||||
CampaignJob.queue_status.in_([JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value]),
|
CampaignJob.queue_status.in_([JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value]),
|
||||||
)
|
)
|
||||||
.count()
|
.count()
|
||||||
@@ -376,18 +493,25 @@ def _update_campaign_after_job(session: Session, campaign_id: str) -> None:
|
|||||||
failed = (
|
failed = (
|
||||||
session.query(CampaignJob)
|
session.query(CampaignJob)
|
||||||
.filter(
|
.filter(
|
||||||
CampaignJob.campaign_id == campaign_id,
|
*base_filters,
|
||||||
CampaignJob.send_status.in_([JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value]),
|
CampaignJob.send_status.in_([JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value]),
|
||||||
)
|
)
|
||||||
.count()
|
.count()
|
||||||
)
|
)
|
||||||
sent = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id, CampaignJob.send_status == JobSendStatus.SENT.value).count()
|
sent = session.query(CampaignJob).filter(*base_filters, CampaignJob.send_status == JobSendStatus.SENT.value).count()
|
||||||
if remaining:
|
if remaining:
|
||||||
campaign.status = CampaignStatus.QUEUED.value
|
campaign.status = CampaignStatus.QUEUED.value
|
||||||
elif failed:
|
elif failed:
|
||||||
campaign.status = CampaignStatus.FAILED.value if not sent else CampaignStatus.NEEDS_REVIEW.value
|
campaign.status = CampaignStatus.FAILED.value if not sent else CampaignStatus.NEEDS_REVIEW.value
|
||||||
elif sent:
|
elif sent:
|
||||||
campaign.status = CampaignStatus.SENT.value
|
campaign.status = CampaignStatus.SENT.value
|
||||||
|
if version_id:
|
||||||
|
version = session.get(CampaignVersion, version_id)
|
||||||
|
if version:
|
||||||
|
version.workflow_state = CampaignVersionWorkflowState.COMPLETED.value
|
||||||
|
if version.locked_at is None:
|
||||||
|
version.locked_at = _utcnow()
|
||||||
|
session.add(version)
|
||||||
session.add(campaign)
|
session.add(campaign)
|
||||||
|
|
||||||
|
|
||||||
@@ -452,7 +576,7 @@ def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, u
|
|||||||
job.last_error = None
|
job.last_error = None
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, job.campaign_id)
|
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
||||||
session.commit()
|
session.commit()
|
||||||
if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value:
|
if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value:
|
||||||
_celery_enqueue_append_sent_job(job.id)
|
_celery_enqueue_append_sent_job(job.id)
|
||||||
@@ -473,7 +597,7 @@ def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, u
|
|||||||
job.send_status = JobSendStatus.FAILED_PERMANENT.value
|
job.send_status = JobSendStatus.FAILED_PERMANENT.value
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
session.add(job)
|
session.add(job)
|
||||||
_update_campaign_after_job(session, job.campaign_id)
|
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
|
||||||
session.commit()
|
session.commit()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user