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

1from email.headerregistry import Address 

2 

3from ics import Calendar, Event 

4from ics.grammar.parse import ContentLine # type: ignore[import-untyped] 

5 

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 

12 

13HOST_REQUEST_ICS_FILENAME = "host_request.ics" 

14 

15 

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) 

21 

22 

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) 

27 

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) 

31 

32 

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.""" 

37 

38 event = Event() 

39 event.uid = get_host_request_event_uid(host_request.host_request_id) 

40 

41 # Explicitly allow later sequencing of a cancellation with SEQUENCE:1 

42 event.extra.append(ContentLine(name="SEQUENCE", value=str(sequence))) 

43 

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 ) 

52 

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() 

58 

59 event.location = host_request.hosting_city 

60 event.url = urls.host_request(host_request_id=str(host_request.host_request_id)) 

61 

62 # Google Calendar™ will hide the URL if there is a location, so also include it in the description 

63 event.description = event.url 

64 

65 return event 

66 

67 

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) 

73 

74 

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" 

83 

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) 

88 

89 

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 

97 

98 

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}" 

107 

108 return EmailPart(data=data, content_disposition=content_disposition, content_type=content_type) 

109 

110 

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}"