Add bulk calendar invitation delivery
This commit is contained in:
@@ -2,9 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -483,6 +485,7 @@ def _delivery_issues(
|
||||
*,
|
||||
postbox_available: bool,
|
||||
templates_available: bool,
|
||||
calendar_available: bool,
|
||||
) -> list[SemanticIssue]:
|
||||
issues: list[SemanticIssue] = []
|
||||
policies = _delivery_policies(config)
|
||||
@@ -574,9 +577,110 @@ def _delivery_issues(
|
||||
f"/entries/inline/{entry_index}/print_target",
|
||||
)
|
||||
)
|
||||
invitation = config.delivery.calendar_invitation
|
||||
if invitation.enabled:
|
||||
if not calendar_available:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_unavailable",
|
||||
"Calendar invitations require the optional Calendar module.",
|
||||
"/delivery/calendar_invitation/enabled",
|
||||
)
|
||||
)
|
||||
if not policies or any(not policy.uses_mail for policy in policies):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_requires_mail",
|
||||
"Calendar invitations require Mail delivery for every active recipient.",
|
||||
"/delivery/channel_policy",
|
||||
)
|
||||
)
|
||||
if not invitation.calendar_id:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_calendar_missing",
|
||||
"Select a writable calendar for campaign invitation tracking.",
|
||||
"/delivery/calendar_invitation/calendar_id",
|
||||
)
|
||||
)
|
||||
if not invitation.start_at_template:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_start_missing",
|
||||
"Calendar invitations require a start date and time or a field template.",
|
||||
"/delivery/calendar_invitation/start_at_template",
|
||||
)
|
||||
)
|
||||
if invitation.timezone:
|
||||
try:
|
||||
ZoneInfo(invitation.timezone)
|
||||
except ZoneInfoNotFoundError:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_timezone_invalid",
|
||||
f"Unknown calendar invitation timezone: {invitation.timezone}",
|
||||
"/delivery/calendar_invitation/timezone",
|
||||
)
|
||||
)
|
||||
valid_start, fixed_start = _fixed_invitation_datetime(
|
||||
invitation.start_at_template
|
||||
)
|
||||
valid_end, fixed_end = _fixed_invitation_datetime(invitation.end_at_template)
|
||||
if invitation.start_at_template and not valid_start:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_start_invalid",
|
||||
"The fixed invitation start must be ISO 8601; recipient field templates are also supported.",
|
||||
"/delivery/calendar_invitation/start_at_template",
|
||||
)
|
||||
)
|
||||
if invitation.end_at_template and not valid_end:
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_end_invalid",
|
||||
"The fixed invitation end must be ISO 8601; recipient field templates are also supported.",
|
||||
"/delivery/calendar_invitation/end_at_template",
|
||||
)
|
||||
)
|
||||
if fixed_start and fixed_end and _invitation_range_invalid(
|
||||
fixed_start,
|
||||
fixed_end,
|
||||
):
|
||||
issues.append(
|
||||
_issue(
|
||||
Severity.ERROR,
|
||||
"calendar_invitation_range_invalid",
|
||||
"Calendar invitation end must be after its start.",
|
||||
"/delivery/calendar_invitation/end_at_template",
|
||||
)
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _fixed_invitation_datetime(value: str | None) -> tuple[bool, datetime | None]:
|
||||
if not value:
|
||||
return True, None
|
||||
if "${" in value or "{{" in value:
|
||||
return True, None
|
||||
try:
|
||||
return True, datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return False, None
|
||||
|
||||
|
||||
def _invitation_range_invalid(start_at: datetime, end_at: datetime) -> bool:
|
||||
if (start_at.tzinfo is None) != (end_at.tzinfo is None):
|
||||
return True
|
||||
return end_at <= start_at
|
||||
|
||||
|
||||
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
|
||||
"""Require Campaign-owned sender data before a send-mode build."""
|
||||
|
||||
@@ -826,6 +930,7 @@ def validate_campaign_config(
|
||||
check_files: bool = False,
|
||||
postbox_available: bool = False,
|
||||
templates_available: bool = False,
|
||||
calendar_available: bool = False,
|
||||
) -> SemanticReport:
|
||||
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
|
||||
issues: list[SemanticIssue] = []
|
||||
@@ -842,6 +947,7 @@ def validate_campaign_config(
|
||||
config,
|
||||
postbox_available=postbox_available,
|
||||
templates_available=templates_available,
|
||||
calendar_available=calendar_available,
|
||||
)
|
||||
)
|
||||
issues.extend(_sender_issues(config))
|
||||
|
||||
Reference in New Issue
Block a user