Add bulk calendar invitation delivery

This commit is contained in:
2026-08-02 16:38:48 +02:00
parent 7733265cc8
commit bf6e07f307
17 changed files with 1356 additions and 20 deletions
@@ -30,7 +30,7 @@ from govoplan_campaign.backend.db.models import (
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.integrations import calendar_integration, postbox_integration
from govoplan_campaign.backend.runtime import capability
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
@@ -38,6 +38,10 @@ from govoplan_campaign.backend.response_security import (
public_delivery_result_message,
public_source_filename,
)
from govoplan_campaign.backend.services.job_queries import (
_calendar_invitation_state_from_provenance,
_calendar_invitations_for_jobs,
)
class CampaignReportError(RuntimeError):
@@ -586,7 +590,9 @@ def _job_row(
*,
include_diagnostics: bool = False,
review_decision: dict[str, Any] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, Any]:
invitation = calendar_invitation or _calendar_invitation_state_from_provenance(job)
row = {
"job_id": job.id,
"entry_index": job.entry_index,
@@ -639,6 +645,10 @@ def _job_row(
review_decision,
include_diagnostics=include_diagnostics,
),
"calendar_invitation": invitation,
"calendar_rsvp_status": (
invitation.get("rsvp_status") if invitation is not None else None
),
}
if include_diagnostics:
row.update(
@@ -713,13 +723,16 @@ def _job_evidence_row(
latest_message_action: CampaignMessageAction | None = None,
include_diagnostics: bool = False,
review_decision: dict[str, Any] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, Any]:
row = _job_row(
job,
include_diagnostics=include_diagnostics,
review_decision=review_decision,
calendar_invitation=calendar_invitation,
)
public_decision = row.pop("review_decision") or {}
invitation = row.pop("calendar_invitation") or {}
recipients = job.resolved_recipients or {}
row.update({
"campaign_id": job.campaign_id,
@@ -752,6 +765,13 @@ def _job_evidence_row(
str(code)
for code in public_decision.get("issue_codes") or []
),
"calendar_invitation_state": invitation.get("state"),
"calendar_rsvp_status": invitation.get("rsvp_status"),
"calendar_event_id": invitation.get("event_id"),
"calendar_id": invitation.get("calendar_id"),
"calendar_uid": invitation.get("uid"),
"calendar_external_state": invitation.get("external_state"),
"calendar_reply_ingress": invitation.get("reply_ingress"),
})
if include_diagnostics:
row.update(
@@ -870,6 +890,7 @@ def generate_campaign_report(
)
aggregate = _JobReportAggregate()
job_rows: list[dict[str, Any]] = []
included_jobs: list[CampaignJob] = []
review_decisions = _review_decisions_by_job(version)
retry_max_attempts = _retry_max_attempts(version)
iterator = jobs.yield_per(500) if hasattr(jobs, "yield_per") else jobs
@@ -880,19 +901,13 @@ def generate_campaign_report(
include_recent_failures=include_recent_failures,
)
if include_jobs:
if len(job_rows) >= CAMPAIGN_JSON_JOB_LIMIT:
if len(included_jobs) >= CAMPAIGN_JSON_JOB_LIMIT:
raise CampaignReportError(
"The recipient-level JSON report exceeds the safe row "
f"limit of {CAMPAIGN_JSON_JOB_LIMIT}; use the CSV export "
"or the paginated recipient table."
)
job_rows.append(
_job_row(
job,
include_diagnostics=include_diagnostics,
review_decision=review_decisions.get(job.id),
)
)
included_jobs.append(job)
report = _campaign_report_payload(
session,
campaign=campaign,
@@ -904,6 +919,19 @@ def generate_campaign_report(
review_decisions=review_decisions,
)
if include_jobs:
calendar_invitations = _calendar_invitations_for_jobs(
session,
included_jobs,
)
job_rows.extend(
_job_row(
job,
include_diagnostics=include_diagnostics,
review_decision=review_decisions.get(job.id),
calendar_invitation=calendar_invitations.get(job.id),
)
for job in included_jobs
)
report["jobs"] = job_rows
return report
@@ -943,6 +971,11 @@ def _campaign_report_payload(
campaign_id=campaign.id,
version=version,
)
calendar_invitations = calendar_integration().summarize_invitations(
session,
tenant_id=tenant_id,
source_resource_id=version.id if version else None,
)
report = {
"generated_at": _utcnow_iso(),
"campaign": _campaign_report_campaign_payload(campaign),
@@ -982,6 +1015,7 @@ def _campaign_report_payload(
version=version,
),
"postbox_receipts": postbox_receipts,
"calendar_invitations": calendar_invitations,
"message_actions": _campaign_message_action_summary(
session,
tenant_id=tenant_id,
@@ -1620,6 +1654,7 @@ def generate_jobs_csv(
job_ids,
)
review_decisions = _review_decisions_by_job(version)
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
rows = [
_job_evidence_row(
job,
@@ -1628,6 +1663,7 @@ def generate_jobs_csv(
latest_message_action=latest_message_actions.get(job.id),
include_diagnostics=include_diagnostics,
review_decision=review_decisions.get(job.id),
calendar_invitation=calendar_invitations.get(job.id),
)
for job in jobs
]
@@ -1705,6 +1741,13 @@ def generate_jobs_csv(
"review_actor_user_id",
"review_decided_at",
"review_issue_codes",
"calendar_invitation_state",
"calendar_rsvp_status",
"calendar_event_id",
"calendar_id",
"calendar_uid",
"calendar_external_state",
"calendar_reply_ingress",
]
if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at")