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-21 21:52 +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 = 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 ) 

54 

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

60 

61 event.location = host_request.hosting_city 

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

63 

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

65 event.description = event.url 

66 

67 return event 

68 

69 

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) 

75 

76 

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" 

85 

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) 

90 

91 

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 

99 

100 

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

109 

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

111 

112 

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