|
5 | 5 | from abc import ABC, abstractmethod |
6 | 6 | from dataclasses import dataclass |
7 | 7 | from datetime import UTC, date, datetime |
8 | | -from typing import Self |
| 8 | +from typing import Self, assert_never |
9 | 9 |
|
10 | 10 | from couchers import urls |
11 | 11 | from couchers.email.rendering import ( |
|
18 | 18 | from couchers.i18n import LocalizationContext |
19 | 19 | from couchers.i18n.i18next import SubstitutionDict |
20 | 20 | from couchers.i18n.localize import format_phone_number |
21 | | -from couchers.proto import notification_data_pb2 |
| 21 | +from couchers.notifications.quick_links import generate_quick_decline_link |
| 22 | +from couchers.proto import conversations_pb2, notification_data_pb2 |
22 | 23 |
|
23 | 24 |
|
24 | 25 | @dataclass |
@@ -212,12 +213,12 @@ def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationConte |
212 | 213 | builder.para("body", {"badge_name": self.badge_name}) |
213 | 214 |
|
214 | 215 | @classmethod |
215 | | - def from_add_notification(cls, data: notification_data_pb2.BadgeAdd, *, user_name: str) -> Self: |
216 | | - return cls(user_name=user_name, badge_name=data.badge_name, added=True) |
217 | | - |
218 | | - @classmethod |
219 | | - def from_remove_notification(cls, data: notification_data_pb2.BadgeRemove, *, user_name: str) -> Self: |
220 | | - return cls(user_name=user_name, badge_name=data.badge_name, added=False) |
| 216 | + def from_notification( |
| 217 | + cls, data: notification_data_pb2.BadgeAdd | notification_data_pb2.BadgeRemove, *, user_name: str |
| 218 | + ) -> Self: |
| 219 | + return cls( |
| 220 | + user_name=user_name, badge_name=data.badge_name, added=isinstance(data, notification_data_pb2.BadgeAdd) |
| 221 | + ) |
221 | 222 |
|
222 | 223 | @classmethod |
223 | 224 | def dummy_data(cls) -> BadgeChangedEmail: |
@@ -495,6 +496,318 @@ def dummy_data(cls) -> GenderChangedEmail: |
495 | 496 | ) |
496 | 497 |
|
497 | 498 |
|
| 499 | +@dataclass(kw_only=True, slots=True) |
| 500 | +class HostRequestCreatedEmail(EmailBase): |
| 501 | + """Sent to a host when a surfer sends them a new host request.""" |
| 502 | + |
| 503 | + surfer: UserInfo |
| 504 | + from_date: date |
| 505 | + to_date: date |
| 506 | + text: str |
| 507 | + quick_decline_link: str |
| 508 | + view_link: str |
| 509 | + |
| 510 | + @property |
| 511 | + def string_key_prefix(self) -> str: |
| 512 | + return "host_request_created" |
| 513 | + |
| 514 | + def get_subject_line(self, loc_context: LocalizationContext) -> str: |
| 515 | + return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name}) |
| 516 | + |
| 517 | + def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: |
| 518 | + builder.para("body", {"surfer_name": self.surfer.name}) |
| 519 | + builder.user( |
| 520 | + self.surfer, |
| 521 | + "date_range", |
| 522 | + { |
| 523 | + "from_date": loc_context.localize_date(self.from_date), |
| 524 | + "to_date": loc_context.localize_date(self.to_date), |
| 525 | + }, |
| 526 | + ) |
| 527 | + builder.quote(self.text, markdown=False) |
| 528 | + builder.action(self.view_link, "view_action") |
| 529 | + builder.action(self.quick_decline_link, "quick_decline_action") |
| 530 | + builder.para("respond_encouragement") |
| 531 | + builder.do_not_reply_request_para() |
| 532 | + |
| 533 | + @classmethod |
| 534 | + def from_notification(cls, data: notification_data_pb2.HostRequestCreate, *, user_name: str) -> Self: |
| 535 | + return cls( |
| 536 | + user_name, |
| 537 | + surfer=UserInfo.from_protobuf(data.surfer), |
| 538 | + from_date=date.fromisoformat(data.host_request.from_date), |
| 539 | + to_date=date.fromisoformat(data.host_request.to_date), |
| 540 | + text=data.text, |
| 541 | + quick_decline_link=generate_quick_decline_link(data.host_request), |
| 542 | + view_link=urls.host_request(host_request_id=data.host_request.host_request_id), |
| 543 | + ) |
| 544 | + |
| 545 | + @classmethod |
| 546 | + def dummy_data(cls) -> HostRequestCreatedEmail: |
| 547 | + return HostRequestCreatedEmail( |
| 548 | + user_name="Alice", |
| 549 | + surfer=UserInfo.dummy_bob(), |
| 550 | + from_date=date(2025, 6, 1), |
| 551 | + to_date=date(2025, 6, 7), |
| 552 | + text="Hey, I'd love to stay for a few nights!", |
| 553 | + quick_decline_link="https://couchers.org/requests/123/decline?token=xxx", |
| 554 | + view_link="https://couchers.org/requests/123", |
| 555 | + ) |
| 556 | + |
| 557 | + |
| 558 | +@dataclass(kw_only=True, slots=True) |
| 559 | +class HostRequestReminderEmail(EmailBase): |
| 560 | + """Sent to a host as a reminder to respond to a pending host request.""" |
| 561 | + |
| 562 | + surfer: UserInfo |
| 563 | + from_date: date |
| 564 | + to_date: date |
| 565 | + view_link: str |
| 566 | + |
| 567 | + @property |
| 568 | + def string_key_prefix(self) -> str: |
| 569 | + return "host_request_reminder" |
| 570 | + |
| 571 | + def get_subject_line(self, loc_context: LocalizationContext) -> str: |
| 572 | + return self._localize(loc_context, "subject", {"surfer_name": self.surfer.name}) |
| 573 | + |
| 574 | + def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: |
| 575 | + builder.para("body") |
| 576 | + builder.user( |
| 577 | + self.surfer, |
| 578 | + ".host_request_generic.date_range", |
| 579 | + { |
| 580 | + "from_date": loc_context.localize_date(self.from_date), |
| 581 | + "to_date": loc_context.localize_date(self.to_date), |
| 582 | + }, |
| 583 | + ) |
| 584 | + builder.action(self.view_link, ".host_request_generic.view_action") |
| 585 | + builder.do_not_reply_request_para() |
| 586 | + |
| 587 | + @classmethod |
| 588 | + def from_notification(cls, data: notification_data_pb2.HostRequestReminder, *, user_name: str) -> Self: |
| 589 | + return cls( |
| 590 | + user_name, |
| 591 | + surfer=UserInfo.from_protobuf(data.surfer), |
| 592 | + from_date=date.fromisoformat(data.host_request.from_date), |
| 593 | + to_date=date.fromisoformat(data.host_request.to_date), |
| 594 | + view_link=urls.host_request(host_request_id=data.host_request.host_request_id), |
| 595 | + ) |
| 596 | + |
| 597 | + @classmethod |
| 598 | + def dummy_data(cls) -> HostRequestReminderEmail: |
| 599 | + return HostRequestReminderEmail( |
| 600 | + user_name="Alice", |
| 601 | + surfer=UserInfo.dummy_bob(), |
| 602 | + from_date=date(2025, 6, 1), |
| 603 | + to_date=date(2025, 6, 7), |
| 604 | + view_link="https://couchers.org/requests/123", |
| 605 | + ) |
| 606 | + |
| 607 | + |
| 608 | +@dataclass(kw_only=True, slots=True) |
| 609 | +class HostRequestMessageEmail(EmailBase): |
| 610 | + """Sent when a user sends a message in an existing host request.""" |
| 611 | + |
| 612 | + other_user: UserInfo |
| 613 | + from_date: date |
| 614 | + to_date: date |
| 615 | + text: str |
| 616 | + from_host: bool |
| 617 | + view_link: str |
| 618 | + |
| 619 | + @property |
| 620 | + def string_key_prefix(self) -> str: |
| 621 | + variant = "from_host" if self.from_host else "from_surfer" |
| 622 | + return f"host_request_message.{variant}" |
| 623 | + |
| 624 | + def get_subject_line(self, loc_context: LocalizationContext) -> str: |
| 625 | + return self._localize(loc_context, "subject", {"other_name": self.other_user.name}) |
| 626 | + |
| 627 | + def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: |
| 628 | + builder.para("body", {"other_name": self.other_user.name}) |
| 629 | + builder.user( |
| 630 | + self.other_user, |
| 631 | + ".host_request_generic.date_range", |
| 632 | + { |
| 633 | + "from_date": loc_context.localize_date(self.from_date), |
| 634 | + "to_date": loc_context.localize_date(self.to_date), |
| 635 | + }, |
| 636 | + ) |
| 637 | + builder.quote(self.text, markdown=False) |
| 638 | + builder.action(self.view_link, ".host_request_generic.view_action") |
| 639 | + builder.do_not_reply_request_para() |
| 640 | + |
| 641 | + @classmethod |
| 642 | + def from_notification(cls, data: notification_data_pb2.HostRequestMessage, *, user_name: str) -> Self: |
| 643 | + return cls( |
| 644 | + user_name, |
| 645 | + other_user=UserInfo.from_protobuf(data.user), |
| 646 | + from_date=date.fromisoformat(data.host_request.from_date), |
| 647 | + to_date=date.fromisoformat(data.host_request.to_date), |
| 648 | + text=data.text, |
| 649 | + from_host=not data.am_host, |
| 650 | + view_link=urls.host_request(host_request_id=data.host_request.host_request_id), |
| 651 | + ) |
| 652 | + |
| 653 | + @classmethod |
| 654 | + def dummy_data(cls) -> HostRequestMessageEmail: |
| 655 | + return HostRequestMessageEmail( |
| 656 | + user_name="Alice", |
| 657 | + other_user=UserInfo.dummy_bob(), |
| 658 | + from_date=date(2025, 6, 1), |
| 659 | + to_date=date(2025, 6, 7), |
| 660 | + text="Looking forward to it, see you soon!", |
| 661 | + from_host=True, |
| 662 | + view_link="https://couchers.org/requests/123", |
| 663 | + ) |
| 664 | + |
| 665 | + |
| 666 | +@dataclass(kw_only=True, slots=True) |
| 667 | +class HostRequestMissedMessagesEmail(EmailBase): |
| 668 | + """Sent as a digest when a user has missed messages in a host request.""" |
| 669 | + |
| 670 | + other_user: UserInfo |
| 671 | + from_date: date |
| 672 | + to_date: date |
| 673 | + from_host: bool |
| 674 | + view_link: str |
| 675 | + |
| 676 | + @property |
| 677 | + def string_key_prefix(self) -> str: |
| 678 | + variant = "from_host" if self.from_host else "from_surfer" |
| 679 | + return f"host_request_missed_messages.{variant}" |
| 680 | + |
| 681 | + def get_subject_line(self, loc_context: LocalizationContext) -> str: |
| 682 | + return self._localize(loc_context, "subject", {"other_name": self.other_user.name}) |
| 683 | + |
| 684 | + def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: |
| 685 | + builder.para("body", {"other_name": self.other_user.name}) |
| 686 | + builder.user( |
| 687 | + self.other_user, |
| 688 | + ".host_request_generic.date_range", |
| 689 | + { |
| 690 | + "from_date": loc_context.localize_date(self.from_date), |
| 691 | + "to_date": loc_context.localize_date(self.to_date), |
| 692 | + }, |
| 693 | + ) |
| 694 | + builder.action(self.view_link, ".host_request_generic.view_action") |
| 695 | + builder.do_not_reply_request_para() |
| 696 | + |
| 697 | + @classmethod |
| 698 | + def from_notification(cls, data: notification_data_pb2.HostRequestMissedMessages, *, user_name: str) -> Self: |
| 699 | + return cls( |
| 700 | + user_name, |
| 701 | + other_user=UserInfo.from_protobuf(data.user), |
| 702 | + from_date=date.fromisoformat(data.host_request.from_date), |
| 703 | + to_date=date.fromisoformat(data.host_request.to_date), |
| 704 | + from_host=not data.am_host, |
| 705 | + view_link=urls.host_request(host_request_id=data.host_request.host_request_id), |
| 706 | + ) |
| 707 | + |
| 708 | + @classmethod |
| 709 | + def dummy_data(cls) -> HostRequestMissedMessagesEmail: |
| 710 | + return HostRequestMissedMessagesEmail( |
| 711 | + user_name="Alice", |
| 712 | + other_user=UserInfo.dummy_bob(), |
| 713 | + from_date=date(2025, 6, 1), |
| 714 | + to_date=date(2025, 6, 7), |
| 715 | + from_host=True, |
| 716 | + view_link="https://couchers.org/requests/123", |
| 717 | + ) |
| 718 | + |
| 719 | + |
| 720 | +@dataclass(kw_only=True, slots=True) |
| 721 | +class HostRequestStatusChangedEmail(EmailBase): |
| 722 | + """Sent when a host request is accepted, declined, confirmed, or cancelled.""" |
| 723 | + |
| 724 | + other_user: UserInfo |
| 725 | + from_date: date |
| 726 | + to_date: date |
| 727 | + new_status: conversations_pb2.HostRequestStatus.ValueType |
| 728 | + view_link: str |
| 729 | + |
| 730 | + @property |
| 731 | + def string_key_prefix(self) -> str: |
| 732 | + base_key = "host_request_status_changed" |
| 733 | + match self.new_status: |
| 734 | + case conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED: |
| 735 | + return f"{base_key}.accepted_by_host" |
| 736 | + case conversations_pb2.HOST_REQUEST_STATUS_REJECTED: |
| 737 | + return f"{base_key}.rejected_by_host" |
| 738 | + case conversations_pb2.HOST_REQUEST_STATUS_CONFIRMED: |
| 739 | + return f"{base_key}.confirmed_by_surfer" |
| 740 | + case conversations_pb2.HOST_REQUEST_STATUS_CANCELLED: |
| 741 | + return f"{base_key}.cancelled_by_surfer" |
| 742 | + case _: |
| 743 | + raise ValueError(f"Unexpected host request status: {self.new_status}") |
| 744 | + |
| 745 | + def get_subject_line(self, loc_context: LocalizationContext) -> str: |
| 746 | + return self._localize(loc_context, "subject", {"other_name": self.other_user.name}) |
| 747 | + |
| 748 | + def build_body(self, builder: EmailBlocksBuilder, loc_context: LocalizationContext) -> None: |
| 749 | + builder.para("body", {"other_name": self.other_user.name}) |
| 750 | + builder.user( |
| 751 | + self.other_user, |
| 752 | + ".host_request_generic.date_range", |
| 753 | + { |
| 754 | + "from_date": loc_context.localize_date(self.from_date), |
| 755 | + "to_date": loc_context.localize_date(self.to_date), |
| 756 | + }, |
| 757 | + ) |
| 758 | + builder.action(self.view_link, ".host_request_generic.view_action") |
| 759 | + builder.do_not_reply_request_para() |
| 760 | + |
| 761 | + @classmethod |
| 762 | + def from_notification( |
| 763 | + cls, |
| 764 | + data: notification_data_pb2.HostRequestAccept |
| 765 | + | notification_data_pb2.HostRequestReject |
| 766 | + | notification_data_pb2.HostRequestConfirm |
| 767 | + | notification_data_pb2.HostRequestCancel, |
| 768 | + *, |
| 769 | + user_name: str, |
| 770 | + ) -> Self: |
| 771 | + other_user: UserInfo |
| 772 | + new_status: conversations_pb2.HostRequestStatus.ValueType |
| 773 | + match data: |
| 774 | + case notification_data_pb2.HostRequestAccept(): |
| 775 | + other_user = UserInfo.from_protobuf(data.host) |
| 776 | + new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_ACCEPTED |
| 777 | + case notification_data_pb2.HostRequestReject(): |
| 778 | + other_user = UserInfo.from_protobuf(data.host) |
| 779 | + new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_REJECTED |
| 780 | + case notification_data_pb2.HostRequestConfirm(): |
| 781 | + other_user = UserInfo.from_protobuf(data.surfer) |
| 782 | + new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CONFIRMED |
| 783 | + case notification_data_pb2.HostRequestCancel(): |
| 784 | + other_user = UserInfo.from_protobuf(data.surfer) |
| 785 | + new_status = conversations_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED |
| 786 | + case _: |
| 787 | + # Enable mypy's exhaustiveness checking |
| 788 | + assert_never("Unexpected host request status changed notification data type.") |
| 789 | + |
| 790 | + return cls( |
| 791 | + user_name, |
| 792 | + other_user=other_user, |
| 793 | + from_date=date.fromisoformat(data.host_request.from_date), |
| 794 | + to_date=date.fromisoformat(data.host_request.to_date), |
| 795 | + new_status=new_status, |
| 796 | + view_link=urls.host_request(host_request_id=data.host_request.host_request_id), |
| 797 | + ) |
| 798 | + |
| 799 | + @classmethod |
| 800 | + def dummy_data(cls) -> HostRequestStatusChangedEmail: |
| 801 | + return HostRequestStatusChangedEmail( |
| 802 | + user_name="Alice", |
| 803 | + other_user=UserInfo.dummy_bob(), |
| 804 | + from_date=date(2025, 6, 1), |
| 805 | + to_date=date(2025, 6, 7), |
| 806 | + new_status=conversations_pb2.HOST_REQUEST_STATUS_ACCEPTED, |
| 807 | + view_link="https://couchers.org/requests/123", |
| 808 | + ) |
| 809 | + |
| 810 | + |
498 | 811 | @dataclass(kw_only=True, slots=True) |
499 | 812 | class ModeratorNoteEmail(EmailBase): |
500 | 813 | """Sent to a user to notify them they have received a moderator note.""" |
|
0 commit comments