fix(campaign): mark excluded delivery as skipped

This commit is contained in:
2026-07-22 08:48:46 +02:00
parent 3487ec7048
commit 7229fb8e3d
19 changed files with 249 additions and 9 deletions

View File

@@ -89,6 +89,7 @@ class BuildStatus(StrEnum):
class SendStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
SKIPPED = "skipped"
class CampaignMeta(StrictModel):

View File

@@ -78,6 +78,7 @@ class JobQueueStatus(StrEnum):
class JobSendStatus(StrEnum):
NOT_QUEUED = "not_queued"
SKIPPED = "skipped"
QUEUED = "queued"
CLAIMED = "claimed"
SENDING = "sending"

View File

@@ -513,6 +513,12 @@ def _message_draft(
eml_path: str | None = None,
eml_size: int | None = None,
) -> MessageDraft:
if validation_status == MessageValidationStatus.EXCLUDED:
# Exclusion is a completed validation decision, not a pending delivery.
# Keep both transport projections explicit so reports never imply that
# SMTP or IMAP work is still expected for this row.
send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
return MessageDraft(

View File

@@ -0,0 +1,42 @@
"""mark untouched excluded jobs as skipped delivery
Revision ID: d8b3e2c1f4a5
Revises: c7a2f91e4b60
Create Date: 2026-07-22 11:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8b3e2c1f4a5"
down_revision = "c7a2f91e4b60"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Only normalize rows with no recorded transport effect. Unexpected
# historical delivery evidence must remain intact for audit/reconciliation.
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'skipped', imap_status = 'skipped' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'not_queued' "
"AND imap_status IN ('not_requested', 'pending', 'skipped')"
)
)
def downgrade() -> None:
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'not_queued', imap_status = 'not_requested' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'skipped' "
"AND imap_status = 'skipped'"
)
)

View File

@@ -0,0 +1,42 @@
"""mark untouched excluded jobs as skipped delivery
Revision ID: d8b3e2c1f4a5
Revises: c7a2f91e4b60
Create Date: 2026-07-22 11:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8b3e2c1f4a5"
down_revision = "c7a2f91e4b60"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Only normalize rows with no recorded transport effect. Unexpected
# historical delivery evidence must remain intact for audit/reconciliation.
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'skipped', imap_status = 'skipped' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'not_queued' "
"AND imap_status IN ('not_requested', 'pending', 'skipped')"
)
)
def downgrade() -> None:
op.get_bind().execute(
sa.text(
"UPDATE campaign_jobs "
"SET send_status = 'not_queued', imap_status = 'not_requested' "
"WHERE validation_status = 'excluded' "
"AND send_status = 'skipped' "
"AND imap_status = 'skipped'"
)
)

View File

@@ -35,7 +35,7 @@ from govoplan_campaign.backend.campaign.validation import validate_campaign_conf
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
@@ -406,7 +406,11 @@ def _job_from_message(
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
validation_status=_job_validation_status(message.validation_status.value),
queue_status=JobQueueStatus.DRAFT.value,
send_status=JobSendStatus.NOT_QUEUED.value,
send_status=(
JobSendStatus.SKIPPED.value
if message.send_status == SendStatus.SKIPPED
else JobSendStatus.NOT_QUEUED.value
),
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
resolved_recipients={
"from": message.from_.model_dump(mode="json") if message.from_ else None,

View File

@@ -114,10 +114,10 @@ def _load_delivery_info(
"queueable_job_count": 0,
"estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None,
"delivery_mode": version.delivery_mode if version else None,
"delivery_mode": getattr(version, "delivery_mode", None) if version else None,
"delivery_mode_selected_at": (
version.delivery_mode_selected_at.isoformat()
if version and version.delivery_mode_selected_at
getattr(version, "delivery_mode_selected_at", None).isoformat()
if version and getattr(version, "delivery_mode_selected_at", None)
else None
),
}
@@ -577,7 +577,7 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
1
for job in jobs
if job.send_status
not in {"smtp_accepted", "sent", "outcome_unknown", "claimed", "sending", "cancelled"}
not in {"skipped", "smtp_accepted", "sent", "outcome_unknown", "claimed", "sending", "cancelled"}
)
needs_attention = sum(
1
@@ -590,6 +590,7 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
outcome_unknown = send_counts.get("outcome_unknown", 0)
not_attempted = send_counts.get("not_queued", 0)
skipped = send_counts.get("skipped", 0)
queued = send_counts.get("queued", 0) + send_counts.get("claimed", 0) + send_counts.get("sending", 0)
cancelled = send_counts.get("cancelled", 0)
inactive_entries = _inactive_entry_count(version)
@@ -606,11 +607,13 @@ def _campaign_report_cards(version: CampaignVersion | None, jobs: list[CampaignJ
"failed": failed,
"outcome_unknown": outcome_unknown,
"not_attempted": not_attempted,
"skipped": skipped,
"queued_or_active": queued,
"cancelled": cancelled,
"partially_completed": bool(sent and (failed or outcome_unknown or not_attempted or cancelled)),
"imap_appended": imap_counts.get("appended", 0),
"imap_failed": imap_counts.get("failed", 0),
"imap_skipped": imap_counts.get("skipped", 0),
}

View File

@@ -88,8 +88,10 @@ def _text_summary(report: dict[str, Any]) -> str:
f"- Needs attention: {cards['needs_attention']}",
f"- Sent: {cards['sent']}",
f"- Failed: {cards['failed']}",
f"- SMTP skipped (excluded): {cards.get('skipped', status.get('send', {}).get('skipped', 0))}",
f"- IMAP appended: {cards['imap_appended']}",
f"- IMAP failed: {cards['imap_failed']}",
f"- IMAP skipped: {cards.get('imap_skipped', status.get('imap', {}).get('skipped', 0))}",
"",
f"Build status: {status.get('build', {})}",
f"Validation status: {status.get('validation', {})}",

View File

@@ -212,6 +212,7 @@ DELIVERY_MODES = {
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.SKIPPED.value,
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
JobSendStatus.OUTCOME_UNKNOWN.value,
@@ -1001,9 +1002,13 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
)
cancelled_count = 0
protected_count = 0
skipped_count = 0
version_ids: set[str] = set()
for job in jobs:
version_ids.add(job.campaign_version_id)
if job.send_status == JobSendStatus.SKIPPED.value:
skipped_count += 1
continue
if job.send_status in SMTP_ACCEPTED_STATUSES | {
JobSendStatus.OUTCOME_UNKNOWN.value,
JobSendStatus.CLAIMED.value,
@@ -1024,6 +1029,7 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
"campaign_id": campaign.id,
"cancelled_count": cancelled_count,
"protected_count": protected_count,
"skipped_count": skipped_count,
"campaign_status": campaign.status,
}