Coverage for app/backend/src/couchers/email/calendar_events.py: 97%
54 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 16:00 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 16:00 +0000
1from email.headerregistry import Address
3from ics import Calendar, Event
4from ics.grammar.parse import ContentLine # type: ignore[import-untyped]
6from couchers import urls
7from couchers.config import config
8from couchers.email.locales import get_emails_i18next
9from couchers.i18n import LocalizationContext
10from couchers.proto.internal.jobs_pb2 import EmailPart
11from couchers.proto.requests_pb2 import HostRequest
13HOST_REQUEST_ICS_FILENAME = "host_request.ics"
16def create_host_request_attachment(
17 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
18) -> EmailPart:
19 calendar = create_host_request_calendar(host_request, other_name, hosting, loc_context)
20 return calendar_to_attachment(calendar, HOST_REQUEST_ICS_FILENAME)
23def create_host_request_calendar(
24 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
25) -> Calendar:
26 event = create_host_request_event(host_request, other_name, hosting, loc_context)
28 # METHOD:PUBLISH means this is part of a stream of calendar event information.
29 # It allows for later cancellation, and doesn't expose accept/decline functionality.
30 return event_to_calendar(event, "PUBLISH", loc_context)
33def create_host_request_event(
34 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext, sequence: int = 0
35) -> Event:
36 """Creates an ics event for a host request."""
38 event = Event()
39 event.uid = get_host_request_event_uid(host_request.host_request_id)
41 # Explicitly allow later sequencing of a cancellation with SEQUENCE:1
42 event.extra.append(ContentLine(name="SEQUENCE", value=str(sequence)))
44 if hosting:
45 event.name = get_emails_i18next().localize(
46 "calendar_events.host_requests.title_host", loc_context.locale, {"name": other_name}
47 )
48 else:
49 event.name = get_emails_i18next().localize(
50 "calendar_events.host_requests.title_surfer", loc_context.locale, {"name": other_name}
51 )
53 # Our to_date is inclusive, iCalendar's DTEND is exclusive (for full-day events)
54 # make_all_day will adjust the end date by one day accordingly.
55 event.begin = host_request.from_date
56 event.end = host_request.to_date
57 event.make_all_day()
59 event.location = host_request.hosting_city
60 event.url = urls.host_request(host_request_id=str(host_request.host_request_id))
62 # Google Calendar™ will hide the URL if there is a location, so also include it in the description
63 event.description = event.url
65 return event
68def create_host_request_cancellation_attachment(
69 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
70) -> EmailPart:
71 calendar = create_host_request_cancellation_calendar(host_request, other_name, hosting, loc_context)
72 return calendar_to_attachment(calendar, HOST_REQUEST_ICS_FILENAME)
75def create_host_request_cancellation_calendar(
76 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
77) -> Calendar:
78 event = create_host_request_event(host_request, other_name, hosting, loc_context, sequence=1)
79 event.name = get_emails_i18next().localize(
80 "calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
81 )
82 event.status = "CANCELLED"
84 # METHOD:PUBLISH means this is part of a stream of calendar event information.
85 # Gmail™ will immediately remove the event from the user's calendar.
86 # METHOD:CANCEL might leave the event in cancelled state or not work.
87 return event_to_calendar(event, "PUBLISH", loc_context)
90def event_to_calendar(event: Event, method: str | None, loc_context: LocalizationContext) -> Calendar:
91 # PRODID is mandatory and generally follows "-//[Organization]//[Product Name]//[Language]"
92 calendar = Calendar(creator=f"-//Couchers.org//Couchers//{loc_context.locale.upper()}")
93 if method: 93 ↛ 95line 93 didn't jump to line 95 because the condition on line 93 was always true
94 calendar.method = method
95 calendar.events.add(event)
96 return calendar
99def calendar_to_attachment(calendar: Calendar, filename: str) -> EmailPart:
100 data = calendar.serialize().encode("utf-8")
101 content_disposition = f'attachment; filename="{filename}"'
102 content_type = 'text/calendar; charset="utf-8"'
103 if calendar.method: 103 ↛ 108line 103 didn't jump to line 108 because the condition on line 103 was always true
104 # The SMTP Content-Type "method" parameter must match the value in the ics file.
105 # AI recommends avoiding quotes on this parameter for backwards compatibility with old email clients.
106 content_type += f"; method={calendar.method}"
108 return EmailPart(data=data, content_disposition=content_disposition, content_type=content_type)
111def get_host_request_event_uid(host_request_id: int) -> str:
112 uid_domain = Address(addr_spec=config.NOTIFICATION_EMAIL_ADDRESS).domain
113 return f"host_request.{host_request_id}@{uid_domain}"