Coverage for app/backend/src/couchers/email/emails.py: 98%
1029 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-22 15:22 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-22 15:22 +0000
1"""
2Defines data models for each email we sent out to users.
3"""
5import re
6from dataclasses import dataclass, replace
7from datetime import UTC, date, datetime
8from typing import Self, assert_never
10from markupsafe import Markup, escape
12from couchers import urls
13from couchers.config import config
14from couchers.constants import LATEST_RELEASE_BLOG_URL
15from couchers.email.blocks import (
16 ActionBlock,
17 EmailBase,
18 EmailBlock,
19 ParaBlock,
20 QuoteBlock,
21 UserInfo,
22)
23from couchers.email.locales import get_emails_i18next
24from couchers.i18n import LocalizationContext
25from couchers.i18n.localize import format_phone_number
26from couchers.notifications.quick_links import generate_quick_decline_link
27from couchers.proto import conversations_pb2, events_pb2, notification_data_pb2
28from couchers.utils import now, to_aware_datetime
30# Common string keys
31_do_not_reply_request_string_key = "generic.do_not_reply_request"
33# Specific email definitions
36@dataclass(kw_only=True, slots=True)
37class AccountDeletionStartedEmail(EmailBase):
38 """Sent to a user to confirm their account deletion request."""
40 deletion_link: str
42 @property
43 def string_key_base(self) -> str:
44 return "account_deletion.started"
46 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
47 builder = self._body_builder(loc_context, security_warning=True)
48 builder.para(".request_description")
49 builder.para(".confirmation_instructions")
50 builder.action(self.deletion_link, ".confirm_action")
51 return builder.build()
53 @classmethod
54 def from_notification(cls, data: notification_data_pb2.AccountDeletionStart, *, user_name: str) -> Self:
55 return cls(
56 user_name=user_name,
57 deletion_link=urls.delete_account_link(account_deletion_token=data.deletion_token),
58 )
60 @classmethod
61 def test_instances(cls) -> list[Self]:
62 return [
63 cls(
64 user_name="Alice",
65 deletion_link="https://couchers.org/delete-account?token=xxx",
66 )
67 ]
70@dataclass(kw_only=True, slots=True)
71class AccountDeletionCompletedEmail(EmailBase):
72 """Sent to a user after their account has been deleted."""
74 undelete_link: str
75 days: int
77 @property
78 def string_key_base(self) -> str:
79 return "account_deletion.completed"
81 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
82 builder = self._body_builder(loc_context, security_warning=True)
83 builder.para(".confirmation")
84 builder.para(".farewell")
85 builder.para(".recovery_instructions_days", {"count": self.days})
86 builder.action(self.undelete_link, ".recover_action")
87 return builder.build()
89 @classmethod
90 def from_notification(cls, data: notification_data_pb2.AccountDeletionComplete, *, user_name: str) -> Self:
91 return cls(
92 user_name=user_name,
93 undelete_link=urls.recover_account_link(account_undelete_token=data.undelete_token),
94 days=data.undelete_days,
95 )
97 @classmethod
98 def test_instances(cls) -> list[Self]:
99 return [
100 cls(
101 user_name="Alice",
102 undelete_link="https://couchers.org/recover-account?token=xxx",
103 days=30,
104 )
105 ]
108@dataclass(kw_only=True, slots=True)
109class AccountDeletionRecoveredEmail(EmailBase):
110 """Sent to a user after their account deletion has been cancelled."""
112 @property
113 def string_key_base(self) -> str:
114 return "account_deletion.recovered"
116 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
117 builder = self._body_builder(loc_context, security_warning=True)
118 builder.para(".confirmation")
119 builder.para(".login_instructions")
120 builder.action(urls.app_link(), ".login_action")
121 builder.para(".redelete_instructions")
122 return builder.build()
124 @classmethod
125 def test_instances(cls) -> list[Self]:
126 return [cls(user_name="Alice")]
129@dataclass(kw_only=True, slots=True)
130class ActivenessProbeEmail(EmailBase):
131 """Sent to a host to check if they are still open to hosting."""
133 days_left: int
135 @property
136 def string_key_base(self) -> str:
137 return "activeness_probe"
139 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
140 builder = self._body_builder(loc_context)
141 builder.para(".body")
142 builder.para(".instructions_days", {"count": self.days_left})
143 builder.action(urls.app_link(), ".login_action")
144 builder.para(".encouragement")
146 # Extract major.minor from the version string. "v1.3.18927" -> "1.3"
147 version = config.VERSION
148 if version_match := re.search(r"^v?(\d+\.\d+)\b", version): 148 ↛ 149line 148 didn't jump to line 149 because the condition on line 148 was never true
149 version = version_match[1]
151 builder.para(".latest_release", {"version": version})
152 builder.action(LATEST_RELEASE_BLOG_URL, ".read_blog_action")
153 return builder.build()
155 @classmethod
156 def from_notification(cls, data: notification_data_pb2.ActivenessProbe, *, user_name: str) -> Self:
157 days_left = (to_aware_datetime(data.deadline) - now()).days
158 return cls(user_name=user_name, days_left=days_left)
160 @classmethod
161 def test_instances(cls) -> list[Self]:
162 return [cls(user_name="Alice", days_left=7)]
165@dataclass(kw_only=True, slots=True)
166class APIKeyIssuedEmail(EmailBase):
167 """Sent to a user to notify them that their API key was issued."""
169 api_key: str
170 expiry: datetime
172 @property
173 def string_key_base(self) -> str:
174 return "api_key_issued"
176 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
177 builder = self._body_builder(loc_context, security_warning=True)
178 builder.para(".header")
179 builder.quote(self.api_key, markdown=False)
180 builder.para(".expiry", {"datetime": loc_context.localize_datetime(self.expiry)})
181 builder.para(".usage_warning")
182 builder.para(".policy_warning")
183 return builder.build()
185 @classmethod
186 def from_notification(cls, data: notification_data_pb2.ApiKeyCreate, *, user_name: str) -> Self:
187 return cls(user_name=user_name, api_key=data.api_key, expiry=data.expiry.ToDatetime(tzinfo=UTC))
189 @classmethod
190 def test_instances(cls) -> list[Self]:
191 return [cls(user_name="Alice", api_key="my_api_key_123", expiry=datetime(2099, 12, 31, 23, 59, 59, tzinfo=UTC))]
194@dataclass(kw_only=True, slots=True)
195class BadgeChangedEmail(EmailBase):
196 """Sent to a user to notify them that a badge was added or removed from their profile."""
198 badge_name: str
199 added: bool
201 @property
202 def string_key_base(self) -> str:
203 return "badges.added" if self.added else "badges.removed"
205 def get_subject_line(self, loc_context: LocalizationContext) -> str:
206 return self._localize(loc_context, ".subject", {"name": self.badge_name})
208 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
209 builder = self._body_builder(loc_context)
210 builder.para(".body", {"name": self.badge_name})
211 return builder.build()
213 @classmethod
214 def from_notification(
215 cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str
216 ) -> Self:
217 return cls(
218 user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd)
219 )
221 @classmethod
222 def test_instances(cls) -> list[Self]:
223 prototype = cls(user_name="Alice", badge_name="Founder", added=True)
224 return [replace(prototype, added=True), replace(prototype, added=False)]
227@dataclass(kw_only=True, slots=True)
228class BirthdateChangedEmail(EmailBase):
229 """Sent to a user to notify them that their birthdate was changed."""
231 new_birthdate: date
233 @property
234 def string_key_base(self) -> str:
235 return "birthdate_changed"
237 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
238 builder = self._body_builder(loc_context, security_warning=True)
239 builder.para(".body", {"date": loc_context.localize_date(self.new_birthdate)})
240 return builder.build()
242 @classmethod
243 def from_notification(cls, data: notification_data_pb2.BirthdateChange, *, user_name: str) -> Self:
244 return cls(user_name=user_name, new_birthdate=date.fromisoformat(data.birthdate))
246 @classmethod
247 def test_instances(cls) -> list[Self]:
248 return [
249 cls(
250 user_name="Alice",
251 new_birthdate=date(1990, 1, 1),
252 )
253 ]
256@dataclass(kw_only=True, slots=True)
257class ChatMessageReceivedEmail(EmailBase):
258 """Sent to a user when they receive a new chat message."""
260 group_chat_title: str | None # None if direct message
261 author: UserInfo
262 text: str
263 view_url: str
265 @property
266 def string_key_base(self) -> str:
267 return f"chat_messages.received.{'direct' if self.group_chat_title is None else 'group'}"
269 def get_subject_line(self, loc_context: LocalizationContext) -> str:
270 return self._localize(
271 loc_context, ".subject", {"author": self.author.name, "group": self.group_chat_title or ""}
272 )
274 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
275 builder = self._body_builder(loc_context)
276 builder.para(".body", {"author": self.author.name, "group": self.group_chat_title or ""})
277 builder.user(self.author)
278 builder.quote(self.text, markdown=False)
279 builder.action(self.view_url, ".view_action")
280 return builder.build()
282 @classmethod
283 def from_notification(cls, data: notification_data_pb2.ChatMessage, *, user_name: str) -> Self:
284 return cls(
285 user_name,
286 author=UserInfo.from_protobuf(data.author),
287 text=data.text,
288 group_chat_title=data.group_chat_title or None,
289 view_url=urls.chat_link(chat_id=data.group_chat_id),
290 )
292 @classmethod
293 def test_instances(cls) -> list[Self]:
294 prototype = cls(
295 user_name="Alice",
296 group_chat_title=None,
297 author=UserInfo.dummy_bob(),
298 text="Hi Alice!",
299 view_url="https://couchers.org/messages/chats/123",
300 )
301 return [
302 replace(prototype, group_chat_title=None),
303 replace(prototype, group_chat_title="Best friends"),
304 ]
307@dataclass(kw_only=True, slots=True)
308class ChatMessagesMissedEmail(EmailBase):
309 """Sent to a user after they've missed new chat messages."""
311 @dataclass(kw_only=True, slots=True)
312 class Entry:
313 """Entry for each chat with missed messages."""
315 group_chat_title: str | None # None if direct message
316 missed_count: int
317 latest_message_author: UserInfo
318 latest_message_text: str
319 view_url: str
321 entries: list[Entry]
323 @property
324 def string_key_base(self) -> str:
325 return "chat_messages.missed"
327 def get_subject_line(self, loc_context: LocalizationContext) -> str:
328 return self._localize(loc_context, ".subject")
330 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
331 builder = self._body_builder(loc_context)
332 for entry in self.entries:
333 if entry.group_chat_title is None:
334 builder.para(".in_dm", {"count": entry.missed_count, "author": entry.latest_message_author.name})
335 else:
336 builder.para(".in_group", {"count": entry.missed_count, "group": entry.group_chat_title})
337 builder.user(entry.latest_message_author)
338 builder.quote(entry.latest_message_text, markdown=False)
339 builder.action(entry.view_url, ".view_action")
340 return builder.build()
342 @classmethod
343 def from_notification(cls, data: notification_data_pb2.ChatMissedMessages, *, user_name: str) -> Self:
344 missed_entries = [
345 cls.Entry(
346 group_chat_title=message.group_chat_title or None,
347 missed_count=message.unseen_count,
348 latest_message_author=UserInfo.from_protobuf(message.author),
349 latest_message_text=message.text,
350 view_url=urls.chat_link(chat_id=message.group_chat_id),
351 )
352 for message in data.messages
353 ]
355 return cls(user_name, entries=missed_entries)
357 @classmethod
358 def test_instances(cls) -> list[Self]:
359 entry_prototype = ChatMessagesMissedEmail.Entry(
360 group_chat_title=None,
361 missed_count=1,
362 latest_message_author=UserInfo.dummy_bob(),
363 latest_message_text="Hello!",
364 view_url="https://couchers.org/messages/chats/123",
365 )
366 return [
367 cls(
368 user_name="Alice",
369 entries=[
370 replace(entry_prototype, group_chat_title=None),
371 replace(entry_prototype, group_chat_title="Best friends"),
372 ],
373 )
374 ]
377@dataclass(kw_only=True, slots=True)
378class DiscussionCreatedEmail(EmailBase):
379 """Sent to a user when a new discussion is created in a community they follow."""
381 author: UserInfo
382 title: str
383 parent_context: str # Community or group name
384 markdown_text: str
385 view_link: str
387 @property
388 def string_key_base(self) -> str:
389 return "discussions.created"
391 def get_subject_line(self, loc_context: LocalizationContext) -> str:
392 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.title})
394 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
395 builder = self._body_builder(loc_context)
396 builder.para(
397 ".body",
398 {
399 "author": self.author.name,
400 "title": self.title,
401 "parent_context": self.parent_context,
402 },
403 )
404 builder.user(self.author)
405 builder.quote(self.markdown_text, markdown=True)
406 builder.action(self.view_link, ".view_action")
407 return builder.build()
409 @classmethod
410 def from_notification(cls, data: notification_data_pb2.DiscussionCreate, *, user_name: str) -> Self:
411 discussion = data.discussion
412 return cls(
413 user_name=user_name,
414 author=UserInfo.from_protobuf(data.author),
415 title=discussion.title,
416 parent_context=discussion.owner_title,
417 markdown_text=discussion.content,
418 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
419 )
421 @classmethod
422 def test_instances(cls) -> list[Self]:
423 return [
424 cls(
425 user_name="Alice",
426 author=UserInfo.dummy_bob(),
427 title="Best hiking trails near Berlin",
428 parent_context="Berlin",
429 markdown_text="I've been exploring the area and found some **great** spots...",
430 view_link="https://couchers.org/discussions/123",
431 )
432 ]
435@dataclass(kw_only=True, slots=True)
436class DiscussionCommentEmail(EmailBase):
437 """Sent to a user when someone comments on a discussion they follow."""
439 author: UserInfo
440 discussion_title: str
441 discussion_parent_context: str # Community or group name
442 markdown_text: str
443 view_link: str
445 @property
446 def string_key_base(self) -> str:
447 return "discussions.comment"
449 def get_subject_line(self, loc_context: LocalizationContext) -> str:
450 return self._localize(
451 loc_context, ".subject", {"author": self.author.name, "discussion_title": self.discussion_title}
452 )
454 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
455 builder = self._body_builder(loc_context)
456 builder.para(
457 ".body",
458 {
459 "author": self.author.name,
460 "discussion_title": self.discussion_title,
461 "parent_context": self.discussion_parent_context,
462 },
463 )
464 builder.user(self.author)
465 builder.quote(self.markdown_text, markdown=True)
466 builder.action(self.view_link, ".view_action")
467 return builder.build()
469 @classmethod
470 def from_notification(cls, data: notification_data_pb2.DiscussionComment, *, user_name: str) -> Self:
471 discussion = data.discussion
472 return cls(
473 user_name=user_name,
474 author=UserInfo.from_protobuf(data.author),
475 discussion_title=discussion.title,
476 discussion_parent_context=discussion.owner_title,
477 markdown_text=data.reply.content,
478 view_link=urls.discussion_link(discussion_id=discussion.discussion_id, slug=discussion.slug),
479 )
481 @classmethod
482 def test_instances(cls) -> list[Self]:
483 return [
484 cls(
485 user_name="Alice",
486 author=UserInfo.dummy_bob(),
487 discussion_title="Best hiking trails near Berlin",
488 discussion_parent_context="Berlin",
489 markdown_text="Great recommendations, I also **love** the Grünewald forest!",
490 view_link="https://couchers.org/discussions/123",
491 )
492 ]
495@dataclass(kw_only=True, slots=True)
496class DonationReceivedEmail(EmailBase):
497 """Sent to a user to thank them for a donation."""
499 amount: int
500 receipt_url: str
502 @property
503 def string_key_base(self) -> str:
504 return "donation_received"
506 def get_preview_line(self, loc_context: LocalizationContext) -> str:
507 return self._localize(loc_context, ".thanks_amount", {"amount": self.amount})
509 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
510 builder = self._body_builder(loc_context, standard_closing=False)
511 builder.para(".thanks_amount", {"amount": self.amount})
512 builder.para(".purpose")
513 builder.para(".invoice_receipt_info")
514 builder.action(self.receipt_url, ".download_invoice")
515 builder.para(".tax_acknowledgment")
516 builder.para(".questions_contact")
517 builder.para(".generosity_helps")
518 builder.para(".thank_you")
519 builder.para("generic.founders_signature")
520 return builder.build()
522 @classmethod
523 def from_notification(cls, data: notification_data_pb2.DonationReceived, *, user_name: str) -> Self:
524 return cls(user_name=user_name, amount=data.amount, receipt_url=data.receipt_url)
526 @classmethod
527 def test_instances(cls) -> list[Self]:
528 return [cls(user_name="Alice", amount=25, receipt_url="https://couchers.org/receipts/123")]
531@dataclass(kw_only=True, slots=True)
532class EmailChangedEmail(EmailBase):
533 """Sent to a user to notify them that their email address was changed."""
535 new_email: str
537 @property
538 def string_key_base(self) -> str:
539 return "email_change.initiated"
541 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
542 builder = self._body_builder(loc_context, security_warning=True)
543 builder.para(".body", {"email_address": self.new_email})
544 return builder.build()
546 @classmethod
547 def from_notification(cls, data: notification_data_pb2.EmailAddressChange, *, user_name: str) -> Self:
548 return cls(user_name=user_name, new_email=data.new_email)
550 @classmethod
551 def test_instances(cls) -> list[Self]:
552 return [cls(user_name="Alice", new_email="alice@example.com")]
555@dataclass(kw_only=True, slots=True)
556class EmailChangeConfirmationEmail(EmailBase):
557 """Sent to a user to confirm their new email address."""
559 old_email: str
560 confirm_url: str
562 @property
563 def string_key_base(self) -> str:
564 return "email_change.confirmation"
566 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
567 builder = self._body_builder(loc_context, security_warning=True)
568 builder.para(".context", {"old_email": self.old_email})
569 builder.para(".instructions")
570 builder.action(self.confirm_url, ".confirm_action")
571 return builder.build()
573 @classmethod
574 def test_instances(cls) -> list[Self]:
575 return [cls(user_name="Alice", old_email="alice@example.com", confirm_url="https://example.com")]
578@dataclass(kw_only=True, slots=True)
579class EmailVerifiedEmail(EmailBase):
580 """Sent to a user to notify them that their new email address has been verified."""
582 @property
583 def string_key_base(self) -> str:
584 return "email_change.verified"
586 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
587 builder = self._body_builder(loc_context, security_warning=True)
588 builder.para(".body")
589 return builder.build()
591 @classmethod
592 def test_instances(cls) -> list[Self]:
593 return [cls(user_name="Alice")]
596@dataclass(kw_only=True, slots=True)
597class EventInfo:
598 """Common display fields for an event, extracted from its proto representation."""
600 title: str
601 start_time: datetime
602 end_time: datetime
603 online_link: str | None
604 address: str | None
605 view_url: str
606 description_markdown: str
608 def get_details_block(self, loc_context: LocalizationContext) -> EmailBlock:
609 # TODO(#8695): Support localized time ranges
610 start_time_display = loc_context.localize_datetime(self.start_time, with_year=False, with_day_of_week=True)
611 end_time_display = loc_context.localize_datetime(self.end_time, with_year=False, with_day_of_week=True)
612 time_range_display = f"{start_time_display} - {end_time_display}"
614 # Format the following. Only "Online" is translated and it's on its own line,
615 # so the string concatenation is fine.
616 # **<title>**
617 # <datetime-range>
618 # *<address> / [Online](<online_link>)*
619 html = f"<b>{escape(self.title)}</b>"
620 html += "<br>"
621 html += time_range_display
622 if self.online_link: 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 html += "<br>"
624 online_link_text = get_emails_i18next().localize("events.generic.online_link", loc_context.locale)
625 html += f'<i><a href="{escape(self.online_link)}">{escape(online_link_text)}</a></i>'
626 elif self.address:
627 html += "<br>"
628 html += f"<i>{escape(self.address)}</i>"
630 return ParaBlock(text=Markup(html))
632 def get_description_block(self) -> EmailBlock:
633 return QuoteBlock(text=Markup(self.description_markdown), markdown=True)
635 def get_view_action_block(self, loc_context: LocalizationContext) -> EmailBlock:
636 view_action_text = get_emails_i18next().localize("events.generic.view_action", loc_context.locale)
637 return ActionBlock(text=view_action_text, target_url=self.view_url)
639 @classmethod
640 def from_proto(cls, event: events_pb2.Event) -> EventInfo:
641 return cls(
642 title=event.title,
643 start_time=event.start_time.ToDatetime(tzinfo=UTC),
644 end_time=event.end_time.ToDatetime(tzinfo=UTC),
645 online_link=event.online_information.link or None,
646 address=event.offline_information.address or None,
647 view_url=urls.event_link(occurrence_id=event.event_id, slug=event.slug),
648 description_markdown=event.content or "",
649 )
651 @staticmethod
652 def dummy() -> EventInfo:
653 return EventInfo(
654 title="Berlin Meetup",
655 start_time=datetime(2025, 7, 15, 18, 0, 0, tzinfo=UTC),
656 end_time=datetime(2025, 7, 15, 21, 0, 0, tzinfo=UTC),
657 online_link=None,
658 address="Alexanderplatz, Berlin",
659 view_url="https://couchers.org/events/123/berlin-community-meetup",
660 description_markdown="Come join us for our monthly meetup!",
661 )
664@dataclass(kw_only=True, slots=True)
665class EventCreatedEmail(EmailBase):
666 """Sent when a user is invited to an event (create_approved) or a new event is created (create_any)."""
668 inviting_user: UserInfo
669 event_info: EventInfo
670 community_name: str | None
671 community_url: str | None
672 is_invite: bool # True = create_approved (invitation), False = create_any
674 @property
675 def string_key_base(self) -> str:
676 return f"events.created.{'invitation' if self.is_invite else 'notification'}"
678 def get_subject_line(self, loc_context: LocalizationContext) -> str:
679 return self._localize(
680 loc_context, ".subject", {"user": self.inviting_user.name, "title": self.event_info.title}
681 )
683 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
684 builder = self._body_builder(loc_context)
685 if self.community_name:
686 builder.para(".body_with_community", {"community": self.community_name})
687 else:
688 builder.para(".body_no_community")
689 builder.block(self.event_info.get_details_block(loc_context))
690 builder.user(self.inviting_user)
691 builder.block(self.event_info.get_description_block())
692 builder.block(self.event_info.get_view_action_block(loc_context))
693 return builder.build()
695 @classmethod
696 def from_notification(cls, data: notification_data_pb2.EventCreate, *, user_name: str, is_invite: bool) -> Self:
697 has_community = bool(data.in_community.community_id)
698 community_url = (
699 urls.community_link(node_id=data.in_community.community_id, slug=data.in_community.slug)
700 if has_community
701 else None
702 )
703 return cls(
704 user_name=user_name,
705 inviting_user=UserInfo.from_protobuf(data.inviting_user),
706 event_info=EventInfo.from_proto(data.event),
707 community_name=data.in_community.name if has_community else None,
708 community_url=community_url,
709 is_invite=is_invite,
710 )
712 @classmethod
713 def test_instances(cls) -> list[Self]:
714 prototype = cls(
715 user_name="Alice",
716 inviting_user=UserInfo.dummy_bob(),
717 event_info=EventInfo.dummy(),
718 community_name="Berlin",
719 community_url="https://couchers.org/community/1/berlin-community",
720 is_invite=True,
721 )
722 return [
723 replace(prototype, is_invite=True),
724 replace(prototype, is_invite=True, community_name=None, community_url=None),
725 replace(prototype, is_invite=False),
726 replace(prototype, is_invite=False, community_name=None, community_url=None),
727 ]
730@dataclass(kw_only=True, slots=True)
731class EventUpdatedEmail(EmailBase):
732 """Sent to subscribers when an event is updated."""
734 updating_user: UserInfo
735 event_info: EventInfo
736 updated_items: list[str]
738 @property
739 def string_key_base(self) -> str:
740 return "events.updated"
742 def get_subject_line(self, loc_context: LocalizationContext) -> str:
743 return self._localize(
744 loc_context, ".subject", {"user": self.updating_user.name, "title": self.event_info.title}
745 )
747 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
748 builder = self._body_builder(loc_context)
749 builder.para(".body")
751 # TODO(#8875): Localize the updated items
752 updated_items_text = ", ".join(self.updated_items)
753 builder.para(".updated_items", {"items_list": updated_items_text})
754 builder.block(self.event_info.get_details_block(loc_context))
755 builder.user(self.updating_user)
756 builder.block(self.event_info.get_description_block())
757 builder.block(self.event_info.get_view_action_block(loc_context))
758 return builder.build()
760 @classmethod
761 def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self:
762 return cls(
763 user_name=user_name,
764 updating_user=UserInfo.from_protobuf(data.updating_user),
765 event_info=EventInfo.from_proto(data.event),
766 updated_items=list(data.updated_items),
767 )
769 @classmethod
770 def test_instances(cls) -> list[Self]:
771 return [
772 cls(
773 user_name="Alice",
774 updating_user=UserInfo.dummy_bob(),
775 event_info=EventInfo.dummy(),
776 updated_items=["time", "location"],
777 )
778 ]
781@dataclass(kw_only=True, slots=True)
782class EventOrganizerInvitedEmail(EmailBase):
783 """Sent when a user is invited to co-organize an event."""
785 inviting_user: UserInfo
786 event_info: EventInfo
788 @property
789 def string_key_base(self) -> str:
790 return "events.organizer_invited"
792 def get_subject_line(self, loc_context: LocalizationContext) -> str:
793 return self._localize(
794 loc_context,
795 ".subject",
796 {"user": self.inviting_user.name, "title": self.event_info.title},
797 )
799 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
800 builder = self._body_builder(loc_context)
801 builder.para(".body", {"user": self.inviting_user.name, "title": self.event_info.title})
802 builder.block(self.event_info.get_details_block(loc_context))
803 builder.user(self.inviting_user, comment_key=".user_card_text")
804 builder.block(self.event_info.get_description_block())
805 builder.block(self.event_info.get_view_action_block(loc_context))
806 builder.para(_do_not_reply_request_string_key)
807 return builder.build()
809 @classmethod
810 def from_notification(cls, data: notification_data_pb2.EventInviteOrganizer, *, user_name: str) -> Self:
811 return cls(
812 user_name=user_name,
813 inviting_user=UserInfo.from_protobuf(data.inviting_user),
814 event_info=EventInfo.from_proto(data.event),
815 )
817 @classmethod
818 def test_instances(cls) -> list[Self]:
819 return [cls(user_name="Alice", inviting_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
822@dataclass(kw_only=True, slots=True)
823class EventCommentEmail(EmailBase):
824 """Sent to subscribers when someone comments on an event."""
826 author: UserInfo
827 event_info: EventInfo
828 comment_markdown: str
830 @property
831 def string_key_base(self) -> str:
832 return "events.comment"
834 def get_subject_line(self, loc_context: LocalizationContext) -> str:
835 return self._localize(loc_context, ".subject", {"author": self.author.name, "title": self.event_info.title})
837 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
838 builder = self._body_builder(loc_context)
839 builder.para(".body", {"author": self.author.name, "title": self.event_info.title})
840 builder.user(self.author)
841 builder.quote(self.comment_markdown, markdown=True)
842 builder.para(".event_details")
843 builder.block(self.event_info.get_details_block(loc_context))
844 builder.block(self.event_info.get_view_action_block(loc_context))
845 return builder.build()
847 @classmethod
848 def from_notification(cls, data: notification_data_pb2.EventComment, *, user_name: str) -> Self:
849 return cls(
850 user_name=user_name,
851 author=UserInfo.from_protobuf(data.author),
852 event_info=EventInfo.from_proto(data.event),
853 comment_markdown=data.reply.content,
854 )
856 @classmethod
857 def test_instances(cls) -> list[Self]:
858 return [
859 cls(
860 user_name="Alice",
861 author=UserInfo.dummy_bob(),
862 event_info=EventInfo.dummy(),
863 comment_markdown="Looking forward to it, see you all there!",
864 )
865 ]
868@dataclass(kw_only=True, slots=True)
869class EventReminderEmail(EmailBase):
870 """Sent to subscribers as a reminder that an event starts soon."""
872 event_info: EventInfo
874 @property
875 def string_key_base(self) -> str:
876 return "events.reminder"
878 def get_subject_line(self, loc_context: LocalizationContext) -> str:
879 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
881 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
882 builder = self._body_builder(loc_context)
883 builder.para(".body")
884 builder.block(self.event_info.get_details_block(loc_context))
885 builder.block(self.event_info.get_description_block())
886 builder.block(self.event_info.get_view_action_block(loc_context))
887 return builder.build()
889 @classmethod
890 def from_notification(cls, data: notification_data_pb2.EventReminder, *, user_name: str) -> Self:
891 return cls(
892 user_name=user_name,
893 event_info=EventInfo.from_proto(data.event),
894 )
896 @classmethod
897 def test_instances(cls) -> list[Self]:
898 return [cls(user_name="Alice", event_info=EventInfo.dummy())]
901@dataclass(kw_only=True, slots=True)
902class EventCancelledEmail(EmailBase):
903 """Sent to subscribers when an event is cancelled."""
905 cancelling_user: UserInfo
906 event_info: EventInfo
908 @property
909 def string_key_base(self) -> str:
910 return "events.cancel"
912 def get_subject_line(self, loc_context: LocalizationContext) -> str:
913 return self._localize(
914 loc_context,
915 ".subject",
916 {"user": self.cancelling_user.name, "title": self.event_info.title},
917 )
919 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
920 builder = self._body_builder(loc_context)
921 builder.para(".body")
922 builder.block(self.event_info.get_details_block(loc_context))
923 builder.user(self.cancelling_user, ".user_card_text")
924 builder.quote(self.event_info.description_markdown, markdown=True)
925 builder.block(self.event_info.get_view_action_block(loc_context))
926 return builder.build()
928 @classmethod
929 def from_notification(cls, data: notification_data_pb2.EventCancel, *, user_name: str) -> Self:
930 return cls(
931 user_name=user_name,
932 cancelling_user=UserInfo.from_protobuf(data.cancelling_user),
933 event_info=EventInfo.from_proto(data.event),
934 )
936 @classmethod
937 def test_instances(cls) -> list[Self]:
938 return [cls(user_name="Alice", cancelling_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy())]
941@dataclass(kw_only=True, slots=True)
942class EventDeletedEmail(EmailBase):
943 """Sent to subscribers when a moderator deletes an event."""
945 event_info: EventInfo
947 @property
948 def string_key_base(self) -> str:
949 return "events.deleted"
951 def get_subject_line(self, loc_context: LocalizationContext) -> str:
952 return self._localize(loc_context, ".subject", {"title": self.event_info.title})
954 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
955 builder = self._body_builder(loc_context)
956 builder.para(".body")
957 builder.block(self.event_info.get_details_block(loc_context))
958 return builder.build()
960 @classmethod
961 def from_notification(cls, data: notification_data_pb2.EventDelete, *, user_name: str) -> Self:
962 return cls(
963 user_name=user_name,
964 event_info=EventInfo.from_proto(data.event),
965 )
967 @classmethod
968 def test_instances(cls) -> list[Self]:
969 return [
970 cls(
971 user_name="Alice",
972 event_info=EventInfo.dummy(),
973 )
974 ]
977@dataclass(kw_only=True, slots=True)
978class FriendReferenceReceivedEmail(EmailBase):
979 """Sent to a user when they receive a friend reference."""
981 from_user: UserInfo
982 text: str
984 @property
985 def string_key_base(self) -> str:
986 return "references.received.friend"
988 def get_subject_line(self, loc_context: LocalizationContext) -> str:
989 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
991 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
992 builder = self._body_builder(loc_context)
993 builder.para(".body", {"name": self.from_user.name})
994 builder.user(self.from_user)
995 builder.quote(self.text, markdown=False)
996 builder.action(urls.profile_references_link(), "references.received.view_action")
997 return builder.build()
999 @classmethod
1000 def from_notification(cls, data: notification_data_pb2.ReferenceReceiveFriend, *, user_name: str) -> Self:
1001 return cls(user_name=user_name, from_user=UserInfo.from_protobuf(data.from_user), text=data.text)
1003 @classmethod
1004 def test_instances(cls) -> list[Self]:
1005 return [
1006 cls(
1007 user_name="Alice",
1008 from_user=UserInfo.dummy_bob(),
1009 text="Alice is a wonderful person and a great travel companion!",
1010 )
1011 ]
1014@dataclass(kw_only=True, slots=True)
1015class FriendRequestReceivedEmail(EmailBase):
1016 """Sent to a user when they receive a friend request."""
1018 befriender: UserInfo
1020 @property
1021 def string_key_base(self) -> str:
1022 return "friend_requests.received"
1024 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1025 return self._localize(loc_context, ".subject", {"name": self.befriender.name})
1027 def get_preview_line(self, loc_context: LocalizationContext) -> str:
1028 return self._localize(loc_context, ".body", {"name": self.befriender.name})
1030 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1031 builder = self._body_builder(loc_context)
1032 builder.para(".body", {"name": self.befriender.name})
1033 builder.user(self.befriender)
1034 builder.action(urls.friend_requests_link(), ".view_action")
1035 builder.para(".closing")
1036 builder.para(_do_not_reply_request_string_key)
1037 return builder.build()
1039 @classmethod
1040 def from_notification(cls, data: notification_data_pb2.FriendRequestCreate, *, user_name: str) -> Self:
1041 return cls(user_name=user_name, befriender=UserInfo.from_protobuf(data.other_user))
1043 @classmethod
1044 def test_instances(cls) -> list[Self]:
1045 return [
1046 cls(
1047 user_name="Alice",
1048 befriender=UserInfo.dummy_bob(),
1049 )
1050 ]
1053@dataclass(kw_only=True, slots=True)
1054class FriendRequestAcceptedEmail(EmailBase):
1055 """Sent to a user when their friend request is accepted."""
1057 new_friend: UserInfo
1059 @property
1060 def string_key_base(self) -> str:
1061 return "friend_requests.accepted"
1063 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1064 return self._localize(loc_context, ".subject", {"name": self.new_friend.name})
1066 def get_preview_line(self, loc_context: LocalizationContext) -> str:
1067 return self._localize(loc_context, ".body", {"name": self.new_friend.name})
1069 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1070 builder = self._body_builder(loc_context)
1071 builder.para(".body", {"name": self.new_friend.name})
1072 builder.user(self.new_friend)
1073 builder.action(self.new_friend.profile_url, ".view_action")
1074 builder.para(".closing")
1075 return builder.build()
1077 @classmethod
1078 def from_notification(cls, data: notification_data_pb2.FriendRequestAccept, *, user_name: str) -> Self:
1079 return cls(user_name=user_name, new_friend=UserInfo.from_protobuf(data.other_user))
1081 @classmethod
1082 def test_instances(cls) -> list[Self]:
1083 return [
1084 cls(
1085 user_name="Alice",
1086 new_friend=UserInfo.dummy_bob(),
1087 )
1088 ]
1091@dataclass(kw_only=True, slots=True)
1092class GenderChangedEmail(EmailBase):
1093 """Sent to a user to notify them that their gender was changed."""
1095 new_gender: str
1097 @property
1098 def string_key_base(self) -> str:
1099 return "gender_changed"
1101 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1102 builder = self._body_builder(loc_context, security_warning=True)
1103 builder.para(".body", {"gender": self.new_gender})
1104 return builder.build()
1106 @classmethod
1107 def from_notification(cls, data: notification_data_pb2.GenderChange, *, user_name: str) -> Self:
1108 return cls(user_name=user_name, new_gender=data.gender)
1110 @classmethod
1111 def test_instances(cls) -> list[Self]:
1112 return [
1113 cls(
1114 user_name="Alice",
1115 new_gender="Male",
1116 )
1117 ]
1120@dataclass(kw_only=True, slots=True)
1121class HostRequestCreatedEmail(EmailBase):
1122 """Sent to a host when a surfer sends them a new host request."""
1124 surfer: UserInfo
1125 from_date: date
1126 to_date: date
1127 text: str
1128 quick_decline_link: str
1129 view_link: str
1131 @property
1132 def string_key_base(self) -> str:
1133 return "host_requests.created"
1135 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1136 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1138 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1139 builder = self._body_builder(loc_context)
1140 builder.para(".body", {"surfer_name": self.surfer.name})
1141 builder.user(
1142 self.surfer,
1143 "host_requests.generic.date_range",
1144 {
1145 "from_date": _localize_host_request_date(self.from_date, loc_context),
1146 "to_date": _localize_host_request_date(self.to_date, loc_context),
1147 },
1148 )
1149 builder.quote(self.text, markdown=False)
1150 builder.action(self.view_link, "host_requests.generic.view_action")
1151 builder.action(self.quick_decline_link, ".quick_decline_action")
1152 builder.para(".respond_encouragement")
1153 builder.para(_do_not_reply_request_string_key)
1154 return builder.build()
1156 @classmethod
1157 def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self:
1158 return cls(
1159 user_name,
1160 surfer=UserInfo.from_protobuf(data.surfer),
1161 from_date=date.fromisoformat(data.host_request.from_date),
1162 to_date=date.fromisoformat(data.host_request.to_date),
1163 text=data.text,
1164 quick_decline_link=generate_quick_decline_link(data.host_request),
1165 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1166 )
1168 @classmethod
1169 def test_instances(cls) -> list[Self]:
1170 return [
1171 cls(
1172 user_name="Alice",
1173 surfer=UserInfo.dummy_bob(),
1174 from_date=date(2025, 6, 1),
1175 to_date=date(2025, 6, 7),
1176 text="Hey, I'd love to stay for a few nights!",
1177 quick_decline_link="https://couchers.org/requests/123/decline?token=xxx",
1178 view_link="https://couchers.org/requests/123",
1179 )
1180 ]
1183@dataclass(kw_only=True, slots=True)
1184class HostRequestReminderEmail(EmailBase):
1185 """Sent to a host as a reminder to respond to a pending host request."""
1187 surfer: UserInfo
1188 from_date: date
1189 to_date: date
1190 view_link: str
1192 @property
1193 def string_key_base(self) -> str:
1194 return "host_requests.reminder"
1196 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1197 return self._localize(loc_context, ".subject", {"surfer_name": self.surfer.name})
1199 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1200 builder = self._body_builder(loc_context)
1201 builder.para(".body")
1202 builder.user(
1203 self.surfer,
1204 "host_requests.generic.date_range",
1205 {
1206 "from_date": _localize_host_request_date(self.from_date, loc_context),
1207 "to_date": _localize_host_request_date(self.to_date, loc_context),
1208 },
1209 )
1210 builder.action(self.view_link, "host_requests.generic.view_action")
1211 builder.para(_do_not_reply_request_string_key)
1212 return builder.build()
1214 @classmethod
1215 def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self:
1216 return cls(
1217 user_name,
1218 surfer=UserInfo.from_protobuf(data.surfer),
1219 from_date=date.fromisoformat(data.host_request.from_date),
1220 to_date=date.fromisoformat(data.host_request.to_date),
1221 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1222 )
1224 @classmethod
1225 def test_instances(cls) -> list[Self]:
1226 return [
1227 cls(
1228 user_name="Alice",
1229 surfer=UserInfo.dummy_bob(),
1230 from_date=date(2025, 6, 1),
1231 to_date=date(2025, 6, 7),
1232 view_link="https://couchers.org/requests/123",
1233 )
1234 ]
1237@dataclass(kw_only=True, slots=True)
1238class HostRequestMessageEmail(EmailBase):
1239 """Sent when a user sends a message in an existing host request."""
1241 other_user: UserInfo
1242 from_date: date
1243 to_date: date
1244 text: str
1245 from_host: bool
1246 view_link: str
1248 @property
1249 def string_key_base(self) -> str:
1250 variant = "from_host" if self.from_host else "from_surfer"
1251 return f"host_requests.message.{variant}"
1253 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1254 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1256 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1257 builder = self._body_builder(loc_context)
1258 builder.para(".body", {"other_name": self.other_user.name})
1259 builder.user(
1260 self.other_user,
1261 "host_requests.generic.date_range",
1262 {
1263 "from_date": _localize_host_request_date(self.from_date, loc_context),
1264 "to_date": _localize_host_request_date(self.to_date, loc_context),
1265 },
1266 )
1267 builder.quote(self.text, markdown=False)
1268 builder.action(self.view_link, "host_requests.generic.view_action")
1269 builder.para(_do_not_reply_request_string_key)
1270 return builder.build()
1272 @classmethod
1273 def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self:
1274 return cls(
1275 user_name,
1276 other_user=UserInfo.from_protobuf(data.user),
1277 from_date=date.fromisoformat(data.host_request.from_date),
1278 to_date=date.fromisoformat(data.host_request.to_date),
1279 text=data.text,
1280 from_host=not data.am_host,
1281 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1282 )
1284 @classmethod
1285 def test_instances(cls) -> list[Self]:
1286 prototype = cls(
1287 user_name="Alice",
1288 other_user=UserInfo.dummy_bob(),
1289 from_date=date(2025, 6, 1),
1290 to_date=date(2025, 6, 7),
1291 text="Looking forward to it, see you soon!",
1292 from_host=True,
1293 view_link="https://couchers.org/requests/123",
1294 )
1295 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1298@dataclass(kw_only=True, slots=True)
1299class HostRequestMissedMessagesEmail(EmailBase):
1300 """Sent as a digest when a user has missed messages in a host request."""
1302 other_user: UserInfo
1303 from_date: date
1304 to_date: date
1305 from_host: bool
1306 view_link: str
1308 @property
1309 def string_key_base(self) -> str:
1310 variant = "from_host" if self.from_host else "from_surfer"
1311 return f"host_requests.missed_messages.{variant}"
1313 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1314 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1316 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1317 builder = self._body_builder(loc_context)
1318 builder.para(".body", {"other_name": self.other_user.name})
1319 builder.user(
1320 self.other_user,
1321 "host_requests.generic.date_range",
1322 {
1323 "from_date": _localize_host_request_date(self.from_date, loc_context),
1324 "to_date": _localize_host_request_date(self.to_date, loc_context),
1325 },
1326 )
1327 builder.action(self.view_link, "host_requests.generic.view_action")
1328 builder.para(_do_not_reply_request_string_key)
1329 return builder.build()
1331 @classmethod
1332 def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self:
1333 return cls(
1334 user_name,
1335 other_user=UserInfo.from_protobuf(data.user),
1336 from_date=date.fromisoformat(data.host_request.from_date),
1337 to_date=date.fromisoformat(data.host_request.to_date),
1338 from_host=not data.am_host,
1339 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1340 )
1342 @classmethod
1343 def test_instances(cls) -> list[Self]:
1344 prototype = cls(
1345 user_name="Alice",
1346 other_user=UserInfo.dummy_bob(),
1347 from_date=date(2025, 6, 1),
1348 to_date=date(2025, 6, 7),
1349 from_host=True,
1350 view_link="https://couchers.org/requests/123",
1351 )
1352 return [replace(prototype, from_host=True), replace(prototype, from_host=False)]
1355@dataclass(kw_only=True, slots=True)
1356class HostRequestStatusChangedEmail(EmailBase):
1357 """Sent when a host request is accepted, declined, confirmed, or cancelled."""
1359 other_user: UserInfo
1360 from_date: date
1361 to_date: date
1362 new_status: conversations_pb2.HostRequestStatus.ValueType
1363 view_link: str
1365 @property
1366 def string_key_base(self) -> str:
1367 base_key = "host_requests.status_changed"
1368 match self.new_status:
1369 case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED:
1370 return f"{base_key}.accepted_by_host"
1371 case conversations_pb2.HOST_REQUEST_STATUS_REJECTED:
1372 return f"{base_key}.declined_by_host"
1373 case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED:
1374 return f"{base_key}.confirmed_by_surfer"
1375 case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED: 1375 ↛ 1377line 1375 didn't jump to line 1377 because the pattern on line 1375 always matched
1376 return f"{base_key}.cancelled_by_surfer"
1377 case _:
1378 raise ValueError(f"Unexpected host request status: {self.new_status}")
1380 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1381 return self._localize(loc_context, ".subject", {"other_name": self.other_user.name})
1383 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1384 builder = self._body_builder(loc_context)
1385 builder.para(".body", {"other_name": self.other_user.name})
1386 builder.user(
1387 self.other_user,
1388 "host_requests.generic.date_range",
1389 {
1390 "from_date": _localize_host_request_date(self.from_date, loc_context),
1391 "to_date": _localize_host_request_date(self.to_date, loc_context),
1392 },
1393 )
1394 builder.action(self.view_link, "host_requests.generic.view_action")
1395 builder.para(_do_not_reply_request_string_key)
1396 return builder.build()
1398 @classmethod
1399 def from_notification(
1400 cls,
1401 data: notification_data_pb2.HostRequestAccept
1402 | notification_data_pb2.HostRequestReject
1403 | notification_data_pb2.HostRequestConfirm
1404 | notification_data_pb2.HostRequestCancel,
1405 *,
1406 user_name: str,
1407 ) -> Self:
1408 other_user: UserInfo
1409 new_status: conversations_pb2.HostRequestStatus.ValueType
1410 match data:
1411 case notification_data_pb2.HostRequestAccept():
1412 other_user = UserInfo.from_protobuf(data.host)
1413 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED
1414 case notification_data_pb2.HostRequestReject(): 1414 ↛ 1415line 1414 didn't jump to line 1415 because the pattern on line 1414 never matched
1415 other_user = UserInfo.from_protobuf(data.host)
1416 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED
1417 case notification_data_pb2.HostRequestConfirm():
1418 other_user = UserInfo.from_protobuf(data.surfer)
1419 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED
1420 case notification_data_pb2.HostRequestCancel(): 1420 ↛ 1423line 1420 didn't jump to line 1423 because the pattern on line 1420 always matched
1421 other_user = UserInfo.from_protobuf(data.surfer)
1422 new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED
1423 case _:
1424 # Enable mypy's exhaustiveness checking
1425 assert_never("Unexpected host request status changed notification data type.")
1427 return cls(
1428 user_name,
1429 other_user=other_user,
1430 from_date=date.fromisoformat(data.host_request.from_date),
1431 to_date=date.fromisoformat(data.host_request.to_date),
1432 new_status=new_status,
1433 view_link=urls.host_request(host_request_id=data.host_request.host_request_id),
1434 )
1436 @classmethod
1437 def test_instances(cls) -> list[Self]:
1438 prototype = cls(
1439 user_name="Alice",
1440 other_user=UserInfo.dummy_bob(),
1441 from_date=date(2025, 6, 1),
1442 to_date=date(2025, 6, 7),
1443 new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED,
1444 view_link="https://couchers.org/requests/123",
1445 )
1446 return [
1447 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED),
1448 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_REJECTED),
1449 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED),
1450 replace(prototype, new_status=conversations_pb2.HOST_REQUEST_STATUS_CANCELLED),
1451 ]
1454@dataclass(kw_only=True, slots=True)
1455class HostReferenceReceivedEmail(EmailBase):
1456 """Sent to a user when they receive a reference from a past host or surfer."""
1458 from_user: UserInfo
1459 text: str | None # None if hidden because receiver hasn't written their reference yet.
1460 surfed: bool # True if I was the surfer, False if I was the host
1461 leave_reference_url: str
1463 @property
1464 def string_key_base(self) -> str:
1465 return "references.received"
1467 @property
1468 def string_role_subkey(self) -> str:
1469 return "surfed" if self.surfed else "hosted"
1471 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1472 return self._localize(loc_context, ".subject", {"name": self.from_user.name})
1474 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1475 builder = self._body_builder(loc_context)
1476 builder.para(f".{self.string_role_subkey}.body", {"name": self.from_user.name})
1477 builder.user(self.from_user)
1478 if self.text:
1479 builder.para(".before_quote")
1480 builder.quote(self.text, markdown=False)
1481 builder.action(urls.profile_references_link(), ".view_action")
1482 else:
1483 builder.para(".reciprocate_encouragement", {"name": self.from_user.name})
1484 builder.action(self.leave_reference_url, "references.write_action", {"name": self.from_user.name})
1485 return builder.build()
1487 @classmethod
1488 def from_notification(
1489 cls, data: notification_data_pb2.ReferenceReceiveHostRequest, *, user_name: str, surfed: bool
1490 ) -> Self:
1491 return cls(
1492 user_name=user_name,
1493 from_user=UserInfo.from_protobuf(data.from_user),
1494 text=data.text or None,
1495 surfed=surfed,
1496 leave_reference_url=urls.leave_reference_link(
1497 reference_type="surfed" if surfed else "hosted",
1498 to_user_id=str(data.from_user.user_id),
1499 host_request_id=str(data.host_request_id),
1500 ),
1501 )
1503 @classmethod
1504 def test_instances(cls) -> list[Self]:
1505 prototype = cls(
1506 user_name="Alice",
1507 from_user=UserInfo.dummy_bob(),
1508 text="Alice was a fantastic guest!",
1509 surfed=True,
1510 leave_reference_url="https://couchers.org/leave-reference/123",
1511 )
1512 return [
1513 replace(prototype, surfed=True, text="Alice was a fantastic guest!"),
1514 replace(prototype, surfed=True, text=None),
1515 replace(prototype, surfed=False, text="Bob was a wonderful host!"),
1516 replace(prototype, surfed=False, text=None),
1517 ]
1520@dataclass(kw_only=True, slots=True)
1521class HostReferenceReminderEmail(EmailBase):
1522 """Sent as a reminder to write a reference after a stay."""
1524 other_user: UserInfo
1525 days_left: int
1526 surfed: bool # True if I was the surfer, False if I was the host
1527 leave_reference_url: str
1529 @property
1530 def string_key_base(self) -> str:
1531 return "references.reminder"
1533 @property
1534 def string_role_subkey(self) -> str:
1535 return "surfed" if self.surfed else "hosted"
1537 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1538 return self._localize(
1539 loc_context,
1540 ".subject_days",
1541 {"name": self.other_user.name, "count": self.days_left},
1542 )
1544 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1545 builder = self._body_builder(loc_context)
1546 builder.para(f".{self.string_role_subkey}.body_days", {"name": self.other_user.name, "count": self.days_left})
1547 builder.para(".no_meeting_note", {"name": self.other_user.name})
1548 builder.user(self.other_user)
1549 builder.action(
1550 self.leave_reference_url,
1551 "references.write_action",
1552 {"name": self.other_user.name},
1553 )
1554 builder.para(".via_messaging_note")
1555 builder.para(".importance_note")
1556 builder.para(".visibility_note")
1557 return builder.build()
1559 @classmethod
1560 def from_notification(cls, data: notification_data_pb2.ReferenceReminder, *, user_name: str, surfed: bool) -> Self:
1561 return cls(
1562 user_name=user_name,
1563 other_user=UserInfo.from_protobuf(data.other_user),
1564 days_left=data.days_left,
1565 surfed=surfed,
1566 leave_reference_url=urls.leave_reference_link(
1567 reference_type="surfed" if surfed else "hosted",
1568 to_user_id=str(data.other_user.user_id),
1569 host_request_id=str(data.host_request_id),
1570 ),
1571 )
1573 @classmethod
1574 def test_instances(cls) -> list[Self]:
1575 prototype = cls(
1576 user_name="Alice",
1577 other_user=UserInfo.dummy_bob(),
1578 days_left=7,
1579 surfed=True,
1580 leave_reference_url="https://couchers.org/leave-reference/123",
1581 )
1582 return [replace(prototype, surfed=True), replace(prototype, surfed=False)]
1585@dataclass(kw_only=True, slots=True)
1586class ModeratorNoteEmail(EmailBase):
1587 """Sent to a user to notify them they have received a moderator note."""
1589 @property
1590 def string_key_base(self) -> str:
1591 return "moderator_note"
1593 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1594 builder = self._body_builder(loc_context)
1595 builder.para(".body")
1596 return builder.build()
1598 @classmethod
1599 def test_instances(cls) -> list[Self]:
1600 return [cls(user_name="Alice")]
1603@dataclass(kw_only=True, slots=True)
1604class NewBlogPostEmail(EmailBase):
1605 """Sent to notify users of a new blog post."""
1607 title: str
1608 blurb: str
1609 url: str
1611 @property
1612 def string_key_base(self) -> str:
1613 return "new_blog_post"
1615 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1616 return self._localize(loc_context, ".subject", {"title": self.title})
1618 def get_preview_line(self, loc_context: LocalizationContext) -> str | None:
1619 return self.blurb
1621 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1622 builder = self._body_builder(loc_context)
1623 builder.para(".intro")
1624 builder.para(".post_title", {"title": self.title})
1625 builder.quote(self.blurb, markdown=False)
1626 builder.action(self.url, ".read_action")
1627 return builder.build()
1629 @classmethod
1630 def from_notification(cls, data: notification_data_pb2.GeneralNewBlogPost, *, user_name: str) -> Self:
1631 return cls(user_name=user_name, title=data.title, blurb=data.blurb, url=data.url)
1633 @classmethod
1634 def test_instances(cls) -> list[Self]:
1635 return [
1636 cls(
1637 user_name="Alice",
1638 title="Exciting new features on Couchers.org",
1639 blurb="We've launched some great new features including improved messaging and event discovery.",
1640 url="https://couchers.org/blog/2025/01/01/new-features",
1641 )
1642 ]
1645@dataclass(kw_only=True, slots=True)
1646class OnboardingReminderEmail(EmailBase):
1647 """Onboarding email sent to new users; initial=True for the first email, False for the second."""
1649 initial: bool
1651 @property
1652 def string_key_base(self) -> str:
1653 return f"onboarding_reminder.{'initial' if self.initial else 'follow_up'}"
1655 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1656 builder = self._body_builder(loc_context, standard_closing=False)
1657 edit_profile_url = urls.edit_profile_link()
1658 if self.initial:
1659 builder.para(".welcome")
1660 builder.para(".early_user_role")
1661 builder.para(".fill_in_profile")
1662 builder.para(".edit_profile_prompt")
1663 builder.action(edit_profile_url, ".edit_profile_action")
1664 builder.para(".share_with_friends")
1665 builder.para(".link", {"url": urls.app_link()})
1666 builder.para(".platform_under_development")
1667 builder.para(".thanks_for_joining")
1668 builder.para(".signature")
1669 else:
1670 builder.para(".intro")
1671 builder.para(".fill_in_profile")
1672 builder.action(edit_profile_url, ".edit_profile_action")
1673 builder.para(".no_empty_accounts")
1674 builder.para(".profile_importance")
1675 builder.para(".signature")
1676 return builder.build()
1678 @classmethod
1679 def test_instances(cls) -> list[Self]:
1680 prototype = cls(user_name="Alice", initial=True)
1681 return [replace(prototype, initial=True), replace(prototype, initial=False)]
1684@dataclass(kw_only=True, slots=True)
1685class PasswordChangedEmail(EmailBase):
1686 """Sent to a user to notify them that their login password was changed."""
1688 @property
1689 def string_key_base(self) -> str:
1690 return "password_changed"
1692 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1693 builder = self._body_builder(loc_context, security_warning=True)
1694 builder.para(".body")
1695 return builder.build()
1697 @classmethod
1698 def test_instances(cls) -> list[Self]:
1699 return [cls(user_name="Alice")]
1702@dataclass(kw_only=True, slots=True)
1703class PasswordResetCompletedEmail(EmailBase):
1704 """Sent to a user to confirm their password was successfully reset."""
1706 @property
1707 def string_key_base(self) -> str:
1708 return "password_reset.completed"
1710 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1711 builder = self._body_builder(loc_context, security_warning=True)
1712 builder.para(".body")
1713 return builder.build()
1715 @classmethod
1716 def test_instances(cls) -> list[Self]:
1717 return [cls(user_name="Alice")]
1720@dataclass(kw_only=True, slots=True)
1721class PasswordResetStartedEmail(EmailBase):
1722 """Sent to a user with a link to complete their password reset."""
1724 password_reset_link: str
1726 @property
1727 def string_key_base(self) -> str:
1728 return "password_reset.started"
1730 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1731 builder = self._body_builder(loc_context, security_warning=True)
1732 builder.para(".request_description")
1733 builder.para(".confirmation_instructions")
1734 builder.action(self.password_reset_link, ".reset_action")
1735 return builder.build()
1737 @classmethod
1738 def from_notification(cls, data: notification_data_pb2.PasswordResetStart, *, user_name: str) -> Self:
1739 return cls(
1740 user_name=user_name,
1741 password_reset_link=urls.password_reset_link(password_reset_token=data.password_reset_token),
1742 )
1744 @classmethod
1745 def test_instances(cls) -> list[Self]:
1746 return [cls(user_name="Alice", password_reset_link="https://couchers.org/reset-password")]
1749@dataclass(kw_only=True, slots=True)
1750class PhoneNumberChangeEmail(EmailBase):
1751 """Sent to a user to notify them that their phone number verification status was changed."""
1753 new_phone_number: str
1754 completed: bool # False = started, True = completed
1756 @property
1757 def string_key_base(self) -> str:
1758 return "phone_number_verification.verified" if self.completed else "phone_number_verification.started"
1760 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1761 builder = self._body_builder(loc_context, security_warning=True)
1762 builder.para(".body", {"phone_number": format_phone_number(self.new_phone_number)})
1763 return builder.build()
1765 @classmethod
1766 def from_change_notification(cls, data: notification_data_pb2.PhoneNumberChange, *, user_name: str) -> Self:
1767 return cls(user_name=user_name, new_phone_number=data.phone, completed=False)
1769 @classmethod
1770 def from_verify_notification(cls, data: notification_data_pb2.PhoneNumberVerify, *, user_name: str) -> Self:
1771 return cls(user_name=user_name, new_phone_number=data.phone, completed=True)
1773 @classmethod
1774 def test_instances(cls) -> list[Self]:
1775 prototype = cls(
1776 user_name="Alice",
1777 new_phone_number="+12223334444",
1778 completed=False,
1779 )
1780 return [replace(prototype, completed=False), replace(prototype, completed=True)]
1783@dataclass(kw_only=True, slots=True)
1784class PostalVerificationFailedEmail(EmailBase):
1785 """Sent to a user when their postal verification attempt has failed."""
1787 reason: notification_data_pb2.PostalVerificationFailReason.ValueType
1789 @property
1790 def string_key_base(self) -> str:
1791 return "postal_verification.failed"
1793 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1794 builder = self._body_builder(loc_context, security_warning=True)
1795 match self.reason:
1796 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
1797 reason_string_key = ".reason_code_expired"
1798 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
1799 reason_string_key = ".reason_too_many_attempts"
1800 case _:
1801 reason_string_key = ".reason_unknown"
1802 builder.para(reason_string_key)
1803 return builder.build()
1805 @classmethod
1806 def from_notification(cls, data: notification_data_pb2.PostalVerificationFailed, *, user_name: str) -> Self:
1807 return cls(user_name=user_name, reason=data.reason)
1809 @classmethod
1810 def test_instances(cls) -> list[Self]:
1811 prototype = cls(
1812 user_name="Alice",
1813 reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED,
1814 )
1815 return [
1816 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED),
1817 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS),
1818 replace(prototype, reason=notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_UNKNOWN),
1819 ]
1822@dataclass(kw_only=True, slots=True)
1823class PostalVerificationPostcardSentEmail(EmailBase):
1824 """Sent to a user to notify them that their verification postcard has been sent."""
1826 city: str
1827 country: str
1829 @property
1830 def string_key_base(self) -> str:
1831 return "postal_verification.postcard_sent"
1833 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1834 builder = self._body_builder(loc_context, security_warning=True)
1835 builder.para(".body", {"city": self.city, "country": self.country})
1836 return builder.build()
1838 @classmethod
1839 def from_notification(cls, data: notification_data_pb2.PostalVerificationPostcardSent, *, user_name: str) -> Self:
1840 return cls(user_name=user_name, city=data.city, country=data.country)
1842 @classmethod
1843 def test_instances(cls) -> list[Self]:
1844 return [cls(user_name="Alice", city="New York", country="United States")]
1847@dataclass(kw_only=True, slots=True)
1848class PostalVerificationSucceededEmail(EmailBase):
1849 """Sent to a user when their postal verification has succeeded."""
1851 @property
1852 def string_key_base(self) -> str:
1853 return "postal_verification.succeeded"
1855 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1856 builder = self._body_builder(loc_context, security_warning=True)
1857 builder.para(".body")
1858 return builder.build()
1860 @classmethod
1861 def test_instances(cls) -> list[Self]:
1862 return [cls(user_name="Alice")]
1865@dataclass(kw_only=True, slots=True)
1866class SignupVerifyEmail(EmailBase):
1867 """Sent to a user to verify their email address."""
1869 verify_url: str
1871 @property
1872 def string_key_base(self) -> str:
1873 return "signup.verify"
1875 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1876 return self._localize(loc_context, "signup.subject")
1878 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1879 builder = self._body_builder(loc_context)
1880 builder.para(".thanks")
1881 builder.para(".instructions")
1882 builder.action(self.verify_url, ".confirm_action")
1883 builder.para("signup.closing")
1884 return builder.build()
1886 @classmethod
1887 def test_instances(cls) -> list[Self]:
1888 return [cls(user_name="Alice", verify_url="https://example.com")]
1891@dataclass(kw_only=True, slots=True)
1892class SignupContinueEmail(EmailBase):
1893 """Sent to a user to ask them to continue the signup process."""
1895 continue_url: str
1897 @property
1898 def string_key_base(self) -> str:
1899 return "signup.continue"
1901 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1902 return self._localize(loc_context, "signup.subject")
1904 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1905 builder = self._body_builder(loc_context)
1906 builder.para(".request")
1907 builder.para(".instructions")
1908 builder.action(self.continue_url, ".continue_action")
1909 builder.para("signup.closing")
1910 builder.para(".ignore_if_unexpected")
1911 return builder.build()
1913 @classmethod
1914 def test_instances(cls) -> list[Self]:
1915 return [cls(user_name="Alice", continue_url="https://example.com")]
1918@dataclass(kw_only=True, slots=True)
1919class StrongVerificationFailedEmail(EmailBase):
1920 """Sent to a user when their strong verification attempt has failed."""
1922 reason: notification_data_pb2.SVFailReason.ValueType
1924 @property
1925 def string_key_base(self) -> str:
1926 return "strong_verification.failed"
1928 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1929 builder = self._body_builder(loc_context, security_warning=True)
1930 match self.reason:
1931 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER:
1932 reason_string_key = ".reason_wrong_birthdate_or_gender"
1933 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
1934 reason_string_key = ".reason_not_a_passport"
1935 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 1935 ↛ 1937line 1935 didn't jump to line 1937 because the pattern on line 1935 always matched
1936 reason_string_key = ".reason_duplicate"
1937 case _:
1938 raise Exception("Shouldn't get here")
1939 builder.para(reason_string_key)
1940 return builder.build()
1942 @classmethod
1943 def from_notification(cls, data: notification_data_pb2.VerificationSVFail, *, user_name: str) -> Self:
1944 return cls(user_name=user_name, reason=data.reason)
1946 @classmethod
1947 def test_instances(cls) -> list[Self]:
1948 prototype = cls(
1949 user_name="Alice",
1950 reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT,
1951 )
1952 return [
1953 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER),
1954 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT),
1955 replace(prototype, reason=notification_data_pb2.SV_FAIL_REASON_DUPLICATE),
1956 ]
1959@dataclass(kw_only=True, slots=True)
1960class StrongVerificationSucceededEmail(EmailBase):
1961 """Sent to a user when their strong verification has succeeded."""
1963 @property
1964 def string_key_base(self) -> str:
1965 return "strong_verification.succeeded"
1967 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
1968 builder = self._body_builder(loc_context, security_warning=True)
1969 builder.para(".success_message")
1970 builder.para(".thanks_message")
1971 builder.para(".cost_explanation")
1972 builder.para(".donation_request")
1973 donate_link = urls.donation_url() + "?utm_source=strong-verification-email"
1974 builder.action(donate_link, ".donate_action")
1975 return builder.build()
1977 @classmethod
1978 def test_instances(cls) -> list[Self]:
1979 return [cls(user_name="Alice")]
1982@dataclass(kw_only=True, slots=True)
1983class ThreadReplyEmail(EmailBase):
1984 """Sent to a user when someone replies in a comment thread they participated in."""
1986 author: UserInfo
1987 parent_context: str # Title of the event or discussion being replied in
1988 markdown_text: str
1989 view_link: str
1991 @property
1992 def string_key_base(self) -> str:
1993 return "thread_reply"
1995 def get_subject_line(self, loc_context: LocalizationContext) -> str:
1996 return self._localize(
1997 loc_context, ".subject", {"author": self.author.name, "parent_context": self.parent_context}
1998 )
2000 def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
2001 builder = self._body_builder(loc_context)
2002 builder.para(".body", {"author": self.author.name, "parent_context": self.parent_context})
2003 builder.user(self.author)
2004 builder.quote(self.markdown_text, markdown=True)
2005 builder.action(self.view_link, ".view_action")
2006 return builder.build()
2008 @classmethod
2009 def from_notification(cls, data: notification_data_pb2.ThreadReply, *, user_name: str) -> Self:
2010 parent = data.WhichOneof("reply_parent")
2011 if parent == "event":
2012 parent_context = data.event.title
2013 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
2014 elif parent == "discussion": 2014 ↛ 2018line 2014 didn't jump to line 2018 because the condition on line 2014 was always true
2015 parent_context = data.discussion.title
2016 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
2017 else:
2018 raise Exception("Can only do replies to events and discussions")
2019 return cls(
2020 user_name=user_name,
2021 author=UserInfo.from_protobuf(data.author),
2022 parent_context=parent_context,
2023 markdown_text=data.reply.content,
2024 view_link=view_link,
2025 )
2027 @classmethod
2028 def test_instances(cls) -> list[Self]:
2029 return [
2030 cls(
2031 user_name="Alice",
2032 author=UserInfo.dummy_bob(),
2033 parent_context="Best hiking trails near Berlin",
2034 markdown_text="I agree, the Grünewald is **amazing**!",
2035 view_link="https://couchers.org/discussions/123",
2036 )
2037 ]
2040def _localize_host_request_date(value: date, loc_context: LocalizationContext) -> str:
2041 return loc_context.localize_date(value, with_year=False, with_day_of_week=True)