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
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.core.calendar import CalendarInvitationRef
from govoplan_core.core.change_sequence import (
encode_sequence_watermark,
max_sequence_id,
@@ -49,6 +50,7 @@ from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
)
from govoplan_campaign.backend.integrations import calendar_integration
from govoplan_campaign.backend.route_support import (
@@ -70,6 +72,7 @@ def _job_summary_payload(
job: CampaignJob,
*,
reviewed_keys: set[str] | None = None,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, object]:
review_key = _job_review_key(job)
return {
@@ -116,12 +119,18 @@ def _job_summary_payload(
for item in (job.resolved_attachments or [])
if isinstance(item, dict)
),
"calendar_invitation": calendar_invitation
or _calendar_invitation_state_from_provenance(job),
}
def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
def _job_detail_payload(
job: CampaignJob,
*,
calendar_invitation: dict[str, object] | None = None,
) -> dict[str, object]:
return {
**_job_summary_payload(job),
**_job_summary_payload(job, calendar_invitation=calendar_invitation),
"message_id_header": job.message_id_header,
"issues": job.issues_snapshot or [],
"attachments": public_campaign_payload(job.resolved_attachments or []),
@@ -135,6 +144,105 @@ def _job_detail_payload(job: CampaignJob) -> dict[str, object]:
}
def _calendar_invitation_state_from_provenance(
job: CampaignJob,
) -> dict[str, object] | None:
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
invitation = provenance.get("calendar_invitation")
if not isinstance(invitation, dict):
return None
request = invitation.get("request")
request_payload = request if isinstance(request, dict) else {}
return {
"available": False,
"state": str(invitation.get("state") or "prepared"),
"correlation_id": request_payload.get("correlation_id"),
"calendar_id": invitation.get("calendar_id")
or request_payload.get("calendar_id"),
"uid": invitation.get("uid"),
"rsvp_status": "NEEDS-ACTION",
"attendees": request_payload.get("attendees") or [],
"last_error": invitation.get("last_error"),
"degraded_reasons": invitation.get("degraded_reasons") or [],
}
def _calendar_invitation_payload(ref: CalendarInvitationRef) -> dict[str, object]:
attendees = [dict(item) for item in ref.attendees]
statuses = [_calendar_attendee_status(item) for item in attendees]
unique_statuses = sorted(set(statuses))
rsvp_status = unique_statuses[0] if len(unique_statuses) == 1 else "MIXED"
return {
"available": True,
"state": "mirrored",
"correlation_id": ref.correlation_id,
"event_id": ref.event_id,
"calendar_id": ref.calendar_id,
"uid": ref.uid,
"rsvp_status": rsvp_status,
"attendees": attendees,
"external_state": ref.external_state,
"outbox_operation_id": ref.outbox_operation_id,
"reply_ingress": ref.reply_ingress,
"recurrence_supported": ref.recurrence_supported,
"degraded_reasons": list(ref.degraded_reasons),
}
def _calendar_attendee_status(attendee: Mapping[str, object]) -> str:
params = attendee.get("params")
if not isinstance(params, Mapping):
return "NEEDS-ACTION"
value = params.get("PARTSTAT")
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
value = value[0] if value else None
return str(value or "NEEDS-ACTION").upper()
def _calendar_invitations_for_jobs(
session: Session,
jobs: Sequence[CampaignJob],
) -> dict[str, dict[str, object]]:
correlations_by_job: dict[str, str] = {}
for job in jobs:
provenance = (
job.delivery_provenance
if isinstance(getattr(job, "delivery_provenance", None), dict)
else {}
)
invitation = provenance.get("calendar_invitation")
request = invitation.get("request") if isinstance(invitation, dict) else None
correlation_id = (
str(request.get("correlation_id") or "").strip()
if isinstance(request, dict)
else ""
)
if correlation_id:
correlations_by_job[job.id] = correlation_id
if not correlations_by_job:
return {}
integration = calendar_integration()
if not integration.available:
return {}
try:
refs = integration.get_invitations(
session,
tenant_id=jobs[0].tenant_id,
correlation_ids=tuple(correlations_by_job.values()),
)
except Exception: # Calendar reporting must not hide Campaign evidence.
return {}
return {
job_id: _calendar_invitation_payload(refs[correlation_id])
for job_id, correlation_id in correlations_by_job.items()
if correlation_id in refs
}
def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
@@ -766,8 +874,16 @@ def _campaign_jobs_page_response(
)
if changed_job_ids is not None:
jobs = [job for job in jobs if job.id in changed_job_ids]
calendar_invitations = _calendar_invitations_for_jobs(session, jobs)
return CampaignJobsResponse(
jobs=[_job_summary_payload(job, reviewed_keys=reviewed_keys) for job in jobs],
jobs=[
_job_summary_payload(
job,
reviewed_keys=reviewed_keys,
calendar_invitation=calendar_invitations.get(job.id),
)
for job in jobs
],
page=page,
page_size=page_size,
total=total,