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

1from email.headerregistry import Address 

2from typing import cast 

3 

4from ics import Calendar, Event # type: ignore[import-untyped] 

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

6 

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 

13 

14HOST_REQUEST_ICS_FILENAME = "host_request.ics" 

15 

16 

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) 

22 

23 

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) 

29 

30 

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

35 

36 event = Event() 

37 event.uid = get_host_request_event_uid(host_request.host_request_id) 

38 

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 ) 

47 

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

53 

54 event.location = host_request.hosting_city 

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

56 

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

58 event.description = event.url 

59 

60 return event 

61 

62 

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) 

68 

69 

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" 

78 

79 # Cancellation is sequenced after creation (which defaults to sequence number 0) 

80 event.extra.append(ContentLine(name="SEQUENCE", value="1")) 

81 

82 return event_to_ics(event, loc_context) 

83 

84 

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

92 

93 

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 ) 

100 

101 

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