Add bulk calendar invitation delivery
This commit is contained in:
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -58,6 +59,7 @@ from govoplan_campaign.backend.campaign.postbox_targets import (
|
||||
from govoplan_campaign.backend.campaign.field_values import (
|
||||
effective_entry_field_values,
|
||||
)
|
||||
from govoplan_campaign.backend.campaign.template_values import build_template_values
|
||||
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
||||
from govoplan_campaign.backend.messages.models import (
|
||||
MessageDraft,
|
||||
@@ -73,11 +75,14 @@ from govoplan_campaign.backend.campaign.models import (
|
||||
SendStatus,
|
||||
)
|
||||
from govoplan_campaign.backend.integrations import (
|
||||
CalendarInvitationUnavailable,
|
||||
calendar_integration,
|
||||
files_integration,
|
||||
mail_integration,
|
||||
postbox_integration,
|
||||
templates_integration,
|
||||
)
|
||||
from govoplan_campaign.backend.template_rendering import render_template
|
||||
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
||||
from govoplan_campaign.backend.runtime import get_settings
|
||||
|
||||
@@ -564,6 +569,71 @@ def _print_template_compatibility_issues(
|
||||
]
|
||||
|
||||
|
||||
def _calendar_invitation_compatibility_issues(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
principal: ApiPrincipal | None,
|
||||
config: CampaignConfig,
|
||||
) -> list[SemanticIssue]:
|
||||
invitation = config.delivery.calendar_invitation
|
||||
if not invitation.enabled or not invitation.calendar_id:
|
||||
return []
|
||||
integration = calendar_integration()
|
||||
if not integration.available:
|
||||
return []
|
||||
if principal is None:
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="calendar_invitation_principal_missing",
|
||||
message="Calendar invitations must be validated by an authenticated Campaign actor.",
|
||||
path="/delivery/calendar_invitation/calendar_id",
|
||||
)
|
||||
]
|
||||
if not any(
|
||||
principal.has(scope)
|
||||
for scope in (
|
||||
"calendar:calendar:read",
|
||||
"calendar:calendar:write",
|
||||
"calendar:calendar:admin",
|
||||
)
|
||||
):
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="calendar_invitation_calendar_forbidden",
|
||||
message="You are not permitted to select a calendar for invitation tracking.",
|
||||
path="/delivery/calendar_invitation/calendar_id",
|
||||
)
|
||||
]
|
||||
calendars = integration.list_calendars(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
user_id=str(getattr(principal.user, "id", "")) or None,
|
||||
group_ids=tuple(principal.group_ids),
|
||||
can_admin=principal.has("calendar:calendar:admin"),
|
||||
)
|
||||
selected = next(
|
||||
(item for item in calendars if item.id == invitation.calendar_id),
|
||||
None,
|
||||
)
|
||||
if selected is None:
|
||||
message = "The selected invitation calendar is not visible or no longer exists."
|
||||
elif not selected.writable:
|
||||
message = "The selected invitation calendar is read-only."
|
||||
else:
|
||||
return []
|
||||
return [
|
||||
SemanticIssue(
|
||||
severity=Severity.ERROR,
|
||||
code="calendar_invitation_calendar_unavailable",
|
||||
message=message,
|
||||
path="/delivery/calendar_invitation/calendar_id",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def validate_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -623,6 +693,7 @@ def validate_campaign_version(
|
||||
check_files=True,
|
||||
postbox_available=postbox_integration().available,
|
||||
templates_available=templates_integration().available,
|
||||
calendar_available=calendar_integration().available,
|
||||
)
|
||||
else:
|
||||
report = validate_campaign_config(
|
||||
@@ -631,6 +702,7 @@ def validate_campaign_version(
|
||||
check_files=False,
|
||||
postbox_available=postbox_integration().available,
|
||||
templates_available=templates_integration().available,
|
||||
calendar_available=calendar_integration().available,
|
||||
)
|
||||
report.issues.extend(
|
||||
_print_template_compatibility_issues(
|
||||
@@ -639,6 +711,14 @@ def validate_campaign_version(
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
report.issues.extend(
|
||||
_calendar_invitation_compatibility_issues(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
principal=principal,
|
||||
config=config,
|
||||
)
|
||||
)
|
||||
report_json = report.model_dump(mode="json")
|
||||
report_json.update(
|
||||
{
|
||||
@@ -825,6 +905,192 @@ def _resolve_built_postbox_targets(
|
||||
return resolved_by_index
|
||||
|
||||
|
||||
def _render_invitation_value(
|
||||
template: str | None,
|
||||
values: dict[str, Any],
|
||||
*,
|
||||
fallback: str | None = None,
|
||||
) -> str | None:
|
||||
if template is None:
|
||||
return fallback
|
||||
rendered = render_template(template, values, keep_missing=False).strip()
|
||||
return rendered or fallback
|
||||
|
||||
|
||||
def _invitation_datetime(value: str, *, timezone_id: str | None) -> datetime:
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise CampaignPersistenceError(
|
||||
f"Calendar invitation date is not valid ISO 8601: {value!r}"
|
||||
) from exc
|
||||
if parsed.tzinfo is not None:
|
||||
return parsed
|
||||
try:
|
||||
return parsed.replace(tzinfo=ZoneInfo(timezone_id or "UTC"))
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise CampaignPersistenceError(
|
||||
f"Calendar invitation timezone is unknown: {timezone_id}"
|
||||
) from exc
|
||||
|
||||
|
||||
def _calendar_invitation_request_payload(
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
config: CampaignConfig,
|
||||
built: Any,
|
||||
entry: Any,
|
||||
user_id: str | None,
|
||||
) -> dict[str, Any]:
|
||||
invitation = config.delivery.calendar_invitation
|
||||
values = build_template_values(config, entry)
|
||||
start_text = _render_invitation_value(
|
||||
invitation.start_at_template,
|
||||
values,
|
||||
)
|
||||
if not start_text:
|
||||
raise CampaignPersistenceError(
|
||||
"Calendar invitation start resolved to an empty value."
|
||||
)
|
||||
end_text = _render_invitation_value(invitation.end_at_template, values)
|
||||
start_at = _invitation_datetime(start_text, timezone_id=invitation.timezone)
|
||||
end_at = (
|
||||
_invitation_datetime(end_text, timezone_id=invitation.timezone)
|
||||
if end_text
|
||||
else None
|
||||
)
|
||||
if end_at is not None and end_at <= start_at:
|
||||
raise CampaignPersistenceError(
|
||||
"Calendar invitation end must be after its start."
|
||||
)
|
||||
attendee_by_address = {
|
||||
item.email.strip().casefold(): item
|
||||
for item in built.draft.to
|
||||
if item.email.strip()
|
||||
}
|
||||
if not attendee_by_address:
|
||||
raise CampaignPersistenceError(
|
||||
"Calendar invitation has no effective To recipient."
|
||||
)
|
||||
sender = built.draft.from_
|
||||
organizer = None
|
||||
if sender is not None and sender.email.strip():
|
||||
params: dict[str, list[str]] = {}
|
||||
if sender.name:
|
||||
params["CN"] = [sender.name]
|
||||
organizer = {
|
||||
"value": f"mailto:{sender.email.strip()}",
|
||||
"params": params,
|
||||
}
|
||||
entry_key = str(built.draft.entry_id or built.draft.entry_index)
|
||||
correlation_digest = hashlib.sha256(
|
||||
f"{version.id}:{entry_key}".encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
return {
|
||||
"correlation_id": f"campaign:{version.id}:{correlation_digest}",
|
||||
"source_resource_id": version.id,
|
||||
"calendar_id": invitation.calendar_id,
|
||||
"summary": _render_invitation_value(
|
||||
invitation.summary_template,
|
||||
values,
|
||||
fallback=built.draft.subject or config.campaign.name,
|
||||
),
|
||||
"description": _render_invitation_value(
|
||||
invitation.description_template,
|
||||
values,
|
||||
),
|
||||
"location": _render_invitation_value(
|
||||
invitation.location_template,
|
||||
values,
|
||||
),
|
||||
"start_at": start_at.isoformat(),
|
||||
"end_at": end_at.isoformat() if end_at else None,
|
||||
"timezone": invitation.timezone,
|
||||
"organizer": organizer,
|
||||
"attendees": [
|
||||
{
|
||||
"address": item.email.strip(),
|
||||
"name": item.name,
|
||||
"role": "REQ-PARTICIPANT",
|
||||
"participation_status": "NEEDS-ACTION",
|
||||
"rsvp": True,
|
||||
}
|
||||
for item in attendee_by_address.values()
|
||||
],
|
||||
"classification": invitation.classification,
|
||||
"categories": list(invitation.categories),
|
||||
"metadata": {
|
||||
"campaign_id": version.campaign_id,
|
||||
"campaign_version_id": version.id,
|
||||
"entry_id": built.draft.entry_id,
|
||||
"entry_index": built.draft.entry_index,
|
||||
"prepared_by_user_id": user_id,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _prepare_built_calendar_invitations(
|
||||
*,
|
||||
version: CampaignVersion,
|
||||
config: CampaignConfig,
|
||||
built_messages: list[Any],
|
||||
entries_by_index: dict[int, Any],
|
||||
delivery_provenance_by_index: dict[int, dict[str, Any]],
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
if not config.delivery.calendar_invitation.enabled:
|
||||
return
|
||||
integration = calendar_integration()
|
||||
if not integration.available:
|
||||
raise CampaignPersistenceError(
|
||||
"Calendar invitation capability became unavailable after validation."
|
||||
)
|
||||
for built in built_messages:
|
||||
if built.mime is None or not built.draft.is_queueable:
|
||||
continue
|
||||
entry = entries_by_index.get(built.draft.entry_index)
|
||||
if entry is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Built invitation recipient is missing from the campaign input."
|
||||
)
|
||||
request_payload = _calendar_invitation_request_payload(
|
||||
version=version,
|
||||
config=config,
|
||||
built=built,
|
||||
entry=entry,
|
||||
user_id=user_id,
|
||||
)
|
||||
request = integration.request_from_payload(request_payload)
|
||||
try:
|
||||
icalendar = integration.render_invitation(request)
|
||||
except (CalendarInvitationUnavailable, ValueError) as exc:
|
||||
raise CampaignPersistenceError(str(exc)) from exc
|
||||
built.mime.add_attachment(
|
||||
icalendar,
|
||||
subtype="calendar",
|
||||
charset="utf-8",
|
||||
filename="invitation.ics",
|
||||
params={"method": "REQUEST"},
|
||||
)
|
||||
if built.draft.eml_path:
|
||||
path = Path(built.draft.eml_path)
|
||||
path.write_bytes(bytes(built.mime))
|
||||
built.draft.eml_size_bytes = path.stat().st_size
|
||||
built.draft.attachment_count += 1
|
||||
provenance = delivery_provenance_by_index.setdefault(
|
||||
built.draft.entry_index,
|
||||
{},
|
||||
)
|
||||
provenance["calendar_invitation"] = {
|
||||
"state": "prepared",
|
||||
"request": request_payload,
|
||||
"icalendar_sha256": hashlib.sha256(
|
||||
icalendar.encode("utf-8")
|
||||
).hexdigest(),
|
||||
"prepared_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _print_render_item(
|
||||
config: CampaignConfig,
|
||||
entry: Any,
|
||||
@@ -1295,6 +1561,14 @@ def build_campaign_version(
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
)
|
||||
_prepare_built_calendar_invitations(
|
||||
version=version,
|
||||
config=managed_config,
|
||||
built_messages=result.built_messages,
|
||||
entries_by_index=entries_by_index,
|
||||
delivery_provenance_by_index=delivery_provenance_by_index,
|
||||
user_id=user_id,
|
||||
)
|
||||
new_print_storage_keys = sorted(
|
||||
{
|
||||
str(artifact["storage_key"])
|
||||
|
||||
Reference in New Issue
Block a user