feat: harden campaign delivery and editing
This commit is contained in:
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.settings import settings as core_settings
|
||||
@@ -17,11 +18,14 @@ from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignMessageAction,
|
||||
CampaignMessageActionAttempt,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
PostboxDeliveryAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import postbox_integration
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
|
||||
from govoplan_campaign.backend.response_security import (
|
||||
public_campaign_payload,
|
||||
@@ -586,6 +590,7 @@ def _job_evidence_row(
|
||||
*,
|
||||
latest_smtp: SendAttempt | None = None,
|
||||
latest_imap: ImapAppendAttempt | None = None,
|
||||
latest_message_action: CampaignMessageAction | None = None,
|
||||
include_diagnostics: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
row = _job_row(job, include_diagnostics=include_diagnostics)
|
||||
@@ -598,6 +603,20 @@ def _job_evidence_row(
|
||||
"postbox_targets": _job_postbox_target_summary(job),
|
||||
"attachment_names": _attachment_names(job.resolved_attachments),
|
||||
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap),
|
||||
"latest_message_action_kind": (
|
||||
latest_message_action.kind if latest_message_action else None
|
||||
),
|
||||
"latest_message_action_status": (
|
||||
latest_message_action.status if latest_message_action else None
|
||||
),
|
||||
"latest_message_action_id": (
|
||||
latest_message_action.id if latest_message_action else None
|
||||
),
|
||||
"latest_message_action_at": (
|
||||
_iso_timestamp(latest_message_action.completed_at)
|
||||
if latest_message_action
|
||||
else None
|
||||
),
|
||||
})
|
||||
return row
|
||||
|
||||
@@ -762,6 +781,18 @@ def _campaign_report_payload(
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
),
|
||||
"postbox_receipts": _campaign_postbox_receipt_summary(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
),
|
||||
"message_actions": _campaign_message_action_summary(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version=version,
|
||||
),
|
||||
"delivery": _load_delivery_info(
|
||||
version,
|
||||
pending_job_count=aggregate.pending,
|
||||
@@ -775,6 +806,85 @@ def _campaign_report_payload(
|
||||
return report
|
||||
|
||||
|
||||
def _campaign_postbox_receipt_summary(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion | None,
|
||||
) -> dict[str, object]:
|
||||
if version is None:
|
||||
return {"status": "not_applicable", "delivery_count": 0}
|
||||
rows = (
|
||||
session.query(PostboxDeliveryAttempt.provider_delivery_id)
|
||||
.join(
|
||||
CampaignJob,
|
||||
PostboxDeliveryAttempt.job_id == CampaignJob.id,
|
||||
)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.campaign_version_id == version.id,
|
||||
PostboxDeliveryAttempt.tenant_id == tenant_id,
|
||||
PostboxDeliveryAttempt.provider_delivery_id.is_not(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
delivery_ids = tuple(
|
||||
dict.fromkeys(
|
||||
str(row[0])
|
||||
for row in rows
|
||||
if row[0]
|
||||
)
|
||||
)
|
||||
if not delivery_ids:
|
||||
return {"status": "not_applicable", "delivery_count": 0}
|
||||
|
||||
integration = postbox_integration()
|
||||
if not integration.receipt_evidence_available:
|
||||
return {
|
||||
"status": "unavailable",
|
||||
"delivery_count": len(delivery_ids),
|
||||
"missing_delivery_count": len(delivery_ids),
|
||||
}
|
||||
summaries = integration.delivery_receipt_summaries(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
delivery_ids=delivery_ids,
|
||||
)
|
||||
values = tuple(summaries.values())
|
||||
return {
|
||||
"status": "available",
|
||||
"delivery_count": len(delivery_ids),
|
||||
"reported_delivery_count": len(values),
|
||||
"missing_delivery_count": len(delivery_ids) - len(values),
|
||||
"currently_readable_delivery_count": sum(
|
||||
summary.currently_readable for summary in values
|
||||
),
|
||||
"read_delivery_count": sum(
|
||||
summary.read_receipt_count > 0 for summary in values
|
||||
),
|
||||
"acknowledged_delivery_count": sum(
|
||||
summary.acknowledged_receipt_count > 0 for summary in values
|
||||
),
|
||||
"read_receipt_count": sum(
|
||||
summary.read_receipt_count for summary in values
|
||||
),
|
||||
"acknowledged_receipt_count": sum(
|
||||
summary.acknowledged_receipt_count for summary in values
|
||||
),
|
||||
"routed_message_count": sum(
|
||||
summary.routed_message_count for summary in values
|
||||
),
|
||||
"withdrawn_message_count": sum(
|
||||
summary.withdrawn_message_count for summary in values
|
||||
),
|
||||
"expired_message_count": sum(
|
||||
summary.expired_message_count for summary in values
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
|
||||
return {
|
||||
"id": campaign.id,
|
||||
@@ -815,6 +925,99 @@ def _campaign_report_attempt_counts(
|
||||
"send_attempts": count(SendAttempt),
|
||||
"imap_append_attempts": count(ImapAppendAttempt),
|
||||
"postbox_delivery_attempts": count(PostboxDeliveryAttempt),
|
||||
"message_actions": count(CampaignMessageAction),
|
||||
"message_action_attempts": int(
|
||||
session.query(CampaignMessageActionAttempt)
|
||||
.join(
|
||||
CampaignMessageAction,
|
||||
CampaignMessageAction.id
|
||||
== CampaignMessageActionAttempt.action_id,
|
||||
)
|
||||
.filter(
|
||||
CampaignMessageAction.tenant_id == tenant_id,
|
||||
CampaignMessageAction.campaign_id == campaign_id,
|
||||
*(
|
||||
[CampaignMessageAction.campaign_version_id == version.id]
|
||||
if version is not None
|
||||
else [False]
|
||||
),
|
||||
)
|
||||
.count()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _campaign_message_action_summary(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version: CampaignVersion | None,
|
||||
) -> dict[str, Any]:
|
||||
base = [
|
||||
CampaignMessageAction.tenant_id == tenant_id,
|
||||
CampaignMessageAction.campaign_id == campaign_id,
|
||||
]
|
||||
if version is not None:
|
||||
base.append(CampaignMessageAction.campaign_version_id == version.id)
|
||||
else:
|
||||
base.append(False)
|
||||
by_kind = {
|
||||
str(kind): int(count)
|
||||
for kind, count in (
|
||||
session.query(
|
||||
CampaignMessageAction.kind,
|
||||
func.count(CampaignMessageAction.id),
|
||||
)
|
||||
.filter(*base)
|
||||
.group_by(CampaignMessageAction.kind)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
by_status = {
|
||||
str(action_status): int(count)
|
||||
for action_status, count in (
|
||||
session.query(
|
||||
CampaignMessageAction.status,
|
||||
func.count(CampaignMessageAction.id),
|
||||
)
|
||||
.filter(*base)
|
||||
.group_by(CampaignMessageAction.status)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
recent = (
|
||||
session.query(CampaignMessageAction)
|
||||
.filter(*base)
|
||||
.order_by(CampaignMessageAction.created_at.desc())
|
||||
.limit(100)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"total": sum(by_kind.values()),
|
||||
"by_kind": by_kind,
|
||||
"by_status": by_status,
|
||||
"recent": [
|
||||
{
|
||||
"id": action.id,
|
||||
"job_id": action.job_id,
|
||||
"kind": action.kind,
|
||||
"status": action.status,
|
||||
"reason": action.reason,
|
||||
"message_sha256": action.message_sha256,
|
||||
"recipient_manifest_sha256": action.recipient_manifest_sha256,
|
||||
"recipient_count": action.recipient_count,
|
||||
"prior_send_status": action.prior_send_status,
|
||||
"final_send_status": action.final_send_status,
|
||||
"accepted_count": action.accepted_count,
|
||||
"refused_count": action.refused_count,
|
||||
"linked_send_attempt_id": action.linked_send_attempt_id,
|
||||
"actor_user_id": action.actor_user_id,
|
||||
"created_at": _iso_timestamp(action.created_at),
|
||||
"completed_at": _iso_timestamp(action.completed_at),
|
||||
}
|
||||
for action in recent
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -992,11 +1195,16 @@ def generate_jobs_csv(
|
||||
ImapAppendAttempt,
|
||||
job_ids,
|
||||
)
|
||||
latest_message_actions = _latest_message_actions_for_jobs(
|
||||
session,
|
||||
job_ids,
|
||||
)
|
||||
rows = [
|
||||
_job_evidence_row(
|
||||
job,
|
||||
latest_smtp=latest_smtp.get(job.id),
|
||||
latest_imap=latest_imap.get(job.id),
|
||||
latest_message_action=latest_message_actions.get(job.id),
|
||||
include_diagnostics=include_diagnostics,
|
||||
)
|
||||
for job in jobs
|
||||
@@ -1046,6 +1254,10 @@ def generate_jobs_csv(
|
||||
"latest_imap_folder",
|
||||
"latest_imap_created_at",
|
||||
"latest_imap_updated_at",
|
||||
"latest_message_action_kind",
|
||||
"latest_message_action_status",
|
||||
"latest_message_action_id",
|
||||
"latest_message_action_at",
|
||||
]
|
||||
if include_diagnostics:
|
||||
sent_at_index = fieldnames.index("outcome_unknown_at")
|
||||
@@ -1082,3 +1294,26 @@ def _latest_attempts_for_jobs(
|
||||
):
|
||||
latest[attempt.job_id] = attempt
|
||||
return latest
|
||||
|
||||
|
||||
def _latest_message_actions_for_jobs(
|
||||
session: Session,
|
||||
job_ids: list[str],
|
||||
*,
|
||||
batch_size: int = 500,
|
||||
) -> dict[str, CampaignMessageAction]:
|
||||
latest: dict[str, CampaignMessageAction] = {}
|
||||
for offset in range(0, len(job_ids), batch_size):
|
||||
batch = job_ids[offset : offset + batch_size]
|
||||
actions = (
|
||||
session.query(CampaignMessageAction)
|
||||
.filter(CampaignMessageAction.job_id.in_(batch))
|
||||
.order_by(
|
||||
CampaignMessageAction.job_id.asc(),
|
||||
CampaignMessageAction.created_at.asc(),
|
||||
)
|
||||
.yield_per(batch_size)
|
||||
)
|
||||
for action in actions:
|
||||
latest[action.job_id] = action
|
||||
return latest
|
||||
|
||||
Reference in New Issue
Block a user