Coverage for app / backend / src / couchers / email / calendar_events.py: 100%
50 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 02:44 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-13 02:44 +0000
1from email.headerregistry import Address
2from typing import cast
4from ics import Calendar, Event # type: ignore[import-untyped]
5from ics.grammar.parse import ContentLine # type: ignore[import-untyped]
7from couchers import urls
8from couchers.config import config
9from couchers.email.rendering import get_emails_i18next
10from couchers.i18n import LocalizationContext
11from couchers.proto.internal.jobs_pb2 import EmailAttachment
12from couchers.proto.requests_pb2 import HostRequest
14HOST_REQUEST_ICS_FILENAME = "host_request.ics"
17def create_host_request_attachment(
18 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
19) -> EmailAttachment:
20 ics = create_host_request_ics(host_request, other_name, hosting, loc_context)
21 return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
24def create_host_request_ics(
25 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
26) -> str:
27 event = create_host_request_event(host_request, other_name, hosting, loc_context)
28 return event_to_ics(event, loc_context)
31def create_host_request_event(
32 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
33) -> Event:
34 """Creates an ics event for a host request."""
36 event = Event()
37 event.uid = get_host_request_event_uid(host_request.host_request_id)
39 if hosting:
40 event.name = get_emails_i18next().localize(
41 "calendar_events.host_requests.title_host", loc_context.locale, {"name": other_name}
42 )
43 else:
44 event.name = get_emails_i18next().localize(
45 "calendar_events.host_requests.title_surfer", loc_context.locale, {"name": other_name}
46 )
48 # Our to_date is inclusive, iCalendar's DTEND is exclusive (for full-day events)
49 # make_all_day will adjust the end date by one day accordingly.
50 event.begin = host_request.from_date
51 event.end = host_request.to_date
52 event.make_all_day()
54 event.location = host_request.hosting_city
55 event.url = urls.host_request(host_request_id=str(host_request.host_request_id))
57 # Google Calendar™ will hide the URL if there is a location, so also include it in the description
58 event.description = event.url
60 return event
63def create_host_request_cancellation_attachment(
64 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
65) -> EmailAttachment:
66 ics = create_host_request_cancellation_ics(host_request, other_name, hosting, loc_context)
67 return ics_to_attachment(ics, HOST_REQUEST_ICS_FILENAME)
70def create_host_request_cancellation_ics(
71 host_request: HostRequest, other_name: str, hosting: bool, loc_context: LocalizationContext
72) -> str:
73 event = create_host_request_event(host_request, other_name, hosting, loc_context)
74 event.name = get_emails_i18next().localize(
75 "calendar_events.title_cancelled", loc_context.locale, {"title": event.name}
76 )
77 event.status = "CANCELLED"
79 # Cancellation is sequenced after creation (which defaults to sequence number 0)
80 event.extra.append(ContentLine(name="SEQUENCE", value="1"))
82 return event_to_ics(event, loc_context)
85def event_to_ics(event: Event, loc_context: LocalizationContext) -> str:
86 # PRODID is mandatory and generally follows "-//[Organization]//[Product Name]//[Language]"
87 calendar = Calendar(creator=f"-//Couchers.org//Couchers//{loc_context.locale.upper()}")
88 if event.status == "CANCELLED":
89 calendar.method = "CANCEL"
90 calendar.events.add(event)
91 return cast(str, calendar.serialize())
94def ics_to_attachment(ics: str, filename: str) -> EmailAttachment:
95 return EmailAttachment(
96 filename=filename,
97 mime_type="text/calendar",
98 data=ics.encode("utf-8"),
99 )
102def get_host_request_event_uid(host_request_id: int) -> str:
103 uid_domain = Address(addr_spec=config["NOTIFICATION_EMAIL_ADDRESS"]).domain
104 return f"host_request.{host_request_id}@{uid_domain}"