Coverage for app/backend/src/couchers/email/calendar_events.py: 97%
54 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +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 = loc_context.localize_string(
46 "calendar_events.host_requests.title_host", i18next=get_emails_i18next(), substitutions={"name": other_name}
47 )
48 else:
49 event.name = loc_context.localize_string(
50 "calendar_events.host_requests.title_surfer",
51 i18next=get_emails_i18next(),
52 substitutions={"name": other_name},
53 )
55 # Our to_date is inclusive, iCalendar's DTEND is exclusive (for full-day events)
56 # make_all_day will adjust the end date by one day accordingly.
57 event.begin = host_request.from_date
58 event.end = host_request.to_date
59 event.make_all_day()
61 event.location = host_request.hosting_city
62 event.url = urls.host_request(host_request_id=str(host_request.host_request_id))
64 # Google Calendar™ will hide the URL if there is a location, so also include it in the description
65 event.description = event.url
67 return event
70def create_host_request_cancellation_attachment(
71 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
72) -> EmailPart:
73 calendar = create_host_request_cancellation_calendar(host_request, other_name, hosting, loc_context)
74 return calendar_to_attachment(calendar, HOST_REQUEST_ICS_FILENAME)
77def create_host_request_cancellation_calendar(
78 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
79) -> Calendar:
80 event = create_host_request_event(host_request, other_name, hosting, loc_context, sequence=1)
81 event.name = loc_context.localize_string(
82 "calendar_events.title_cancelled", i18next=get_emails_i18next(), substitutions={"title": event.name}
83 )
84 event.status = "CANCELLED"
86 # METHOD:PUBLISH means this is part of a stream of calendar event information.
87 # Gmail™ will immediately remove the event from the user's calendar.
88 # METHOD:CANCEL might leave the event in cancelled state or not work.
89 return event_to_calendar(event, "PUBLISH", loc_context)
92def event_to_calendar(event: Event, method: str | None, loc_context: LocalizationContext) -> Calendar:
93 # PRODID is mandatory and generally follows "-//[Organization]//[Product Name]//[Language]"
94 calendar = Calendar(creator=f"-//Couchers.org//Couchers//{loc_context.locale.upper()}")
95 if method: 95 ↛ 97line 95 didn't jump to line 97 because the condition on line 95 was always true
96 calendar.method = method
97 calendar.events.add(event)
98 return calendar
101def calendar_to_attachment(calendar: Calendar, filename: str) -> EmailPart:
102 data = calendar.serialize().encode("utf-8")
103 content_disposition = f'attachment; filename="{filename}"'
104 content_type = 'text/calendar; charset="utf-8"'
105 if calendar.method: 105 ↛ 110line 105 didn't jump to line 110 because the condition on line 105 was always true
106 # The SMTP Content-Type "method" parameter must match the value in the ics file.
107 # AI recommends avoiding quotes on this parameter for backwards compatibility with old email clients.
108 content_type += f"; method={calendar.method}"
110 return EmailPart(data=data, content_disposition=content_disposition, content_type=content_type)
113def get_host_request_event_uid(host_request_id: int) -> str:
114 uid_domain = Address(addr_spec=config.NOTIFICATION_EMAIL_ADDRESS).domain
115 return f"host_request.{host_request_id}@{uid_domain}"