Skip to content

Commit 588527d

Browse files
authored
Merge branch 'develop' into na/backend/disc-events-exclude-attending
2 parents 2c879f9 + 3e7baf1 commit 588527d

72 files changed

Lines changed: 723 additions & 454 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/backend/.dockerignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
!templates
1010
!alembic.ini
1111
!pyproject.toml
12+
!feature-flags.dev.json
1213

1314
# It's not included into the final image, but is still needed here
1415
# for "--mount=type=bind"

app/backend/proto/internal/jobs.proto

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,11 @@ message GenerateEventCreateNotificationsPayload {
8080
message GenerateEventUpdateNotificationsPayload {
8181
int64 updating_user_id = 1;
8282
int64 occurrence_id = 2;
83-
repeated string updated_items = 3;
83+
// TODO(#9117): Remove once unused. Was not i18n-friendly. Use updated_enum_items.
84+
repeated string updated_str_items = 3 [deprecated = true];
85+
// EventUpdateItem is from the shared protos and cannot be referenced here.
86+
// int32 has the same on-wire encoding.
87+
repeated int32 updated_enum_items = 4;
8488
}
8589

8690
message GenerateEventCancelNotificationsPayload {

app/backend/src/couchers/constants.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
# Must match the frontend values in app/web/utils/validation.ts
1616
VALID_NAME_MIN_LENGTH = 2
1717
VALID_NAME_MAX_LENGTH = 100
18-
VALID_NAME_REGEX = r"^[\p{L}'-]+(\s+[\p{L}'-]+)*$"
18+
19+
# Letters, diacritics, internal spaces, quotes, dashes, commas, dots, and's for two names. See tests!
20+
VALID_NAME_REGEX = r"""^(?!\p{Zs})[\p{L}\p{M}\p{Zs}\p{Pi}\p{Pf}\p{Pd},.'"·・&/|]+(?<!\p{Zs})$"""
1921

2022
BANNED_USERNAME_PHRASES = [
2123
"admin",

app/backend/src/couchers/email/blocks.py

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from abc import ABC, abstractmethod
77
from dataclasses import dataclass
8-
from typing import Any, Self
8+
from typing import Self
99

1010
from markupsafe import Markup
1111

@@ -207,18 +207,6 @@ class EmailFooter:
207207
copyright_year: int = now().year
208208
unsubscribe_info: UnsubscribeInfo | None
209209

210-
def to_template_args(self) -> dict[str, Any]:
211-
args: dict[str, Any] = {
212-
"footer_timezone_name": self.timezone_name,
213-
"footer_copyright_year": self.copyright_year,
214-
"footer_email_is_critical": self.unsubscribe_info is None,
215-
}
216-
217-
if unsubscribe_info := self.unsubscribe_info:
218-
args.update(unsubscribe_info.to_template_args())
219-
220-
return args
221-
222210

223211
@dataclass(kw_only=True)
224212
class UnsubscribeInfo:
@@ -227,20 +215,6 @@ class UnsubscribeInfo:
227215
topic_action_link: UnsubscribeLink
228216
topic_key_link: UnsubscribeLink | None = None
229217

230-
def to_template_args(self) -> dict[str, Any]:
231-
args: dict[str, Any] = {
232-
"footer_manage_notifications_link": self.manage_notifications_url,
233-
"footer_do_not_email_link": self.do_not_email_url,
234-
"footer_notification_topic_action": self.topic_action_link.text,
235-
"footer_notification_topic_action_link": self.topic_action_link.url,
236-
}
237-
238-
if topic_key_link := self.topic_key_link:
239-
args["footer_notification_topic_key"] = topic_key_link.text
240-
args["footer_notification_topic_key_link"] = topic_key_link.url
241-
242-
return args
243-
244218

245219
@dataclass(kw_only=True)
246220
class UnsubscribeLink:

app/backend/src/couchers/email/emails.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ class EventUpdatedEmail(EmailBase):
733733

734734
updating_user: UserInfo
735735
event_info: EventInfo
736-
updated_items: list[str]
736+
updated_items: list[notification_data_pb2.EventUpdateItem.ValueType]
737737

738738
@property
739739
def string_key_base(self) -> str:
@@ -748,9 +748,17 @@ def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
748748
builder = self._body_builder(loc_context)
749749
builder.para(".body")
750750

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})
751+
updated_items_string_keys = list(
752+
filter(None, (type(self)._updated_item_to_string_key(i) for i in self.updated_items))
753+
)
754+
if updated_items_string_keys:
755+
updated_items_text = loc_context.localize_list(
756+
[self._localize(loc_context, key) for key in updated_items_string_keys]
757+
)
758+
builder.para(".updated_items", {"items_list": updated_items_text})
759+
else:
760+
builder.para(".updated_generic")
761+
754762
builder.block(self.event_info.get_details_block(loc_context))
755763
builder.user(self.updating_user)
756764
builder.block(self.event_info.get_description_block())
@@ -759,22 +767,62 @@ def get_body_blocks(self, loc_context: LocalizationContext) -> list[EmailBlock]:
759767

760768
@classmethod
761769
def from_notification(cls, data: notification_data_pb2.EventUpdate, *, user_name: str) -> Self:
770+
updated_items: list[notification_data_pb2.EventUpdateItem.ValueType] = []
771+
if data.updated_enum_items:
772+
updated_items.extend(data.updated_enum_items)
773+
elif data.updated_str_items:
774+
for updated_str_item in data.updated_str_items:
775+
if updated_enum_item := cls._updated_item_str_to_enum(updated_str_item):
776+
updated_items.append(updated_enum_item)
777+
762778
return cls(
763779
user_name=user_name,
764780
updating_user=UserInfo.from_protobuf(data.updating_user),
765781
event_info=EventInfo.from_proto(data.event),
766-
updated_items=list(data.updated_items),
782+
updated_items=updated_items,
767783
)
768784

785+
# TODO(#9117): Backcompat. Remove update_str_items fallback once known unused.
786+
@staticmethod
787+
def _updated_item_str_to_enum(value: str) -> notification_data_pb2.EventUpdateItem.ValueType | None:
788+
match value:
789+
case "title":
790+
return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE
791+
case "content":
792+
return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT
793+
case "location":
794+
return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION
795+
case "start time":
796+
return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME
797+
case "end time":
798+
return notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME
799+
case _:
800+
return None
801+
802+
@staticmethod
803+
def _updated_item_to_string_key(value: notification_data_pb2.EventUpdateItem.ValueType) -> str | None:
804+
match value:
805+
case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_TITLE:
806+
return ".item_names.title"
807+
case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_CONTENT:
808+
return ".item_names.content"
809+
case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_LOCATION:
810+
return ".item_names.location"
811+
case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_START_TIME:
812+
return ".item_names.start_time"
813+
case notification_data_pb2.EventUpdateItem.EVENT_UPDATE_ITEM_END_TIME:
814+
return ".item_names.end_time"
815+
case _:
816+
return None
817+
769818
@classmethod
770819
def test_instances(cls) -> list[Self]:
820+
prototype = cls(
821+
user_name="Alice", updating_user=UserInfo.dummy_bob(), event_info=EventInfo.dummy(), updated_items=[]
822+
)
771823
return [
772-
cls(
773-
user_name="Alice",
774-
updating_user=UserInfo.dummy_bob(),
775-
event_info=EventInfo.dummy(),
776-
updated_items=["time", "location"],
777-
)
824+
replace(prototype, updated_items=[]),
825+
replace(prototype, updated_items=notification_data_pb2.EventUpdateItem.values()),
778826
]
779827

780828

app/backend/src/couchers/email/locales/en.json

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,20 @@
44
"closing_line": "Best,<br>The Couchers.org Team",
55
"founders_signature": "Aapeli and Itsi,<br>Couchers.org Founders",
66
"security_warning_contact_support": "If you did not initiate this action, please contact us by emailing <a href=\"mailto:support@couchers.org\">support@couchers.org</a> so we can sort this out as soon as possible!",
7-
"do_not_reply_request": "<b>Do not reply to this email.</b> Use one of the buttons above to reply to this request."
7+
"do_not_reply_request": "<b>Do not reply to this email.</b> Use one of the buttons above to reply to this request.",
8+
"footer": {
9+
"received_because": "You're receiving this email because you signed up for Couchers.org.",
10+
"contact_support": "You can reach our support team at <a href=\"mailto:support@couchers.org\">support@couchers.org</a>.",
11+
"timezone_note": "All times are in {{ timezone }}, based on your profile location.",
12+
"donate_link": "Donate",
13+
"volunteer_link": "Volunteer",
14+
"blog_link": "Blog",
15+
"nonprofit_note": "Couchers.org is a project of Couchers, Inc. a U.S. 501(c)(3) non-profit, tax-exempt organization.",
16+
"security_email_note": "This is a security email, you cannot unsubscribe from it.",
17+
"notification_settings_link": "Manage notification preferences",
18+
"do_not_email_link": "Do not email me (disables hosting)",
19+
"happy_couch_icon_caption": "The happy Couchers couch hopes you have a great rest of the day."
20+
}
821
},
922
"calendar_events": {
1023
"title_cancelled": "Cancelled: {{ title }}",
@@ -152,7 +165,15 @@
152165
"updated": {
153166
"subject": "{{user}} updated \"{{title}}\"",
154167
"body": "An event you are subscribed to was updated.",
155-
"updated_items": "The following information was updated: <b>{{items_list}}</b>. Here is the updated event:"
168+
"updated_items": "The following information was updated: <b>{{items_list}}</b>. Here is the updated event:",
169+
"updated_generic": "Here is the updated event:",
170+
"item_names": {
171+
"title": "title",
172+
"content": "content",
173+
"location": "location",
174+
"start_time": "start time",
175+
"end_time": "end time"
176+
}
156177
},
157178
"organizer_invited": {
158179
"subject": "{{user}} invited you to co-organize \"{{title}}\"",

app/backend/src/couchers/email/rendering.py

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from functools import cache
99
from html import unescape
1010
from pathlib import Path
11+
from typing import Any
1112

1213
from markdown_it import MarkdownIt
1314
from markupsafe import Markup
@@ -17,6 +18,7 @@
1718
from couchers.email.locales import get_emails_i18next
1819
from couchers.email.smtp import embed_html_relative_images
1920
from couchers.i18n import LocalizationContext
21+
from couchers.i18n.i18next import SubstitutionDict, full_string_key
2022
from couchers.proto.internal import jobs_pb2
2123
from couchers.templating import Jinja2Template
2224

@@ -103,8 +105,10 @@ def render_plaintext_body(*, blocks: list[EmailBlock], footer: EmailFooter, loc_
103105
footer_template = Jinja2Template(
104106
source=(template_folder / "_footer.txt").read_text(encoding="utf8").strip(), html=False
105107
)
106-
footer_template_args = footer.to_template_args()
107-
return "".join(concat) + footer_template.render(footer_template_args)
108+
footer_template_args = _get_footer_template_args(footer, loc_context)
109+
concat.append(footer_template.render(footer_template_args))
110+
111+
return "".join(concat)
108112

109113

110114
def _to_plaintext(text: str | Markup) -> str:
@@ -132,6 +136,47 @@ def _to_plaintext(text: str | Markup) -> str:
132136
return unescape(text)
133137

134138

139+
def _get_footer_template_args(footer: EmailFooter, loc_context: LocalizationContext) -> dict[str, Any]:
140+
i18n = get_emails_i18next()
141+
142+
def localize(key: str, substitutions: SubstitutionDict | None = None) -> Markup:
143+
key = full_string_key(key, relative_base="generic.footer")
144+
return i18n.localize_with_markup(key, loc_context.locale, substitutions)
145+
146+
args: dict[str, Any] = {
147+
"received_because": localize(".received_because"),
148+
"contact_support": localize(".contact_support"),
149+
"timezone_note": localize(".timezone_note", {"timezone": footer.timezone_name}),
150+
"copyright_year": footer.copyright_year,
151+
"donate_link": localize(".donate_link"),
152+
"volunteer_link": localize(".volunteer_link"),
153+
"blog_link": localize(".blog_link"),
154+
"nonprofit_note": localize(".nonprofit_note"),
155+
"is_critical": footer.unsubscribe_info is None,
156+
}
157+
158+
if unsubscribe_info := footer.unsubscribe_info:
159+
# TODO(#7420): Localize "Turn off emails for: " text, avoiding string concatenations.
160+
args.update(
161+
{
162+
"notification_settings_link": localize(".notification_settings_link"),
163+
"manage_notifications_url": unsubscribe_info.manage_notifications_url,
164+
"do_not_email_link": localize(".do_not_email_link"),
165+
"do_not_email_url": unsubscribe_info.do_not_email_url,
166+
"topic_action_description": unsubscribe_info.topic_action_link.text,
167+
"unsubscribe_topic_action_url": unsubscribe_info.topic_action_link.url,
168+
}
169+
)
170+
171+
if topic_key_link := unsubscribe_info.topic_key_link:
172+
args["topic_key_description"] = topic_key_link.text
173+
args["unsubscribe_topic_key_url"] = topic_key_link.url
174+
else:
175+
args["security_email_note"] = localize(".security_email_note")
176+
177+
return args
178+
179+
135180
def render_html_body(
136181
*,
137182
subject: str,
@@ -212,6 +257,7 @@ def render(
212257
"name": block.info.name,
213258
"age": block.info.age,
214259
"city": block.info.city,
260+
"profile_url": block.info.profile_url,
215261
"avatar_url": block.info.avatar_url,
216262
"comment": block.comment,
217263
},
@@ -228,7 +274,7 @@ def render(
228274
raise TypeError(f"Unexpected email block type: {block.__class__}")
229275

230276
# Render the footer
231-
footer_template_args = footer.to_template_args()
277+
footer_template_args = _get_footer_template_args(footer, loc_context)
232278
concats.append(self.footer_template.render(footer_template_args))
233279

234280
return "\n".join(concats)

app/backend/src/couchers/i18n/context.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from collections.abc import Sequence
12
from dataclasses import FrozenInstanceError
23
from datetime import UTC, date, datetime, time, tzinfo
34
from typing import Any
@@ -17,6 +18,7 @@
1718
from couchers.i18n.localize import (
1819
localize_date,
1920
localize_datetime,
21+
localize_list,
2022
localize_time,
2123
localize_timezone,
2224
)
@@ -69,6 +71,9 @@ def localize_string(
6971
i18next = i18next or get_main_i18next()
7072
return i18next.localize(key, self.locale, substitutions=substitutions)
7173

74+
def localize_list(self, items: Sequence[str]) -> str:
75+
return localize_list(items, self.babel_locale)
76+
7277
def localize_date(
7378
self, value: date | datetime, *, abbrev: bool = False, with_year: bool = True, with_day_of_week: bool = False
7479
) -> str:

app/backend/src/couchers/i18n/locales/de.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,10 @@
229229
"discussion_edit_permission_denied": "Du darfst diesen Beitrag nicht bearbeiten.",
230230
"reply_delete_permission_denied": "Du darfst diesen Kommentar nicht löschen.",
231231
"reply_deleted": "Dieser Kommentar wurde gelöscht.",
232-
"reply_edit_permission_denied": "Du darfst diesen Kommentar nicht bearbeiten."
232+
"reply_edit_permission_denied": "Du darfst diesen Kommentar nicht bearbeiten.",
233+
"cannot_combine_attending_and_exclude_attending": "Deine Filter können Events, an denen du teilnimmst, nicht gleichzeitig einschließen und ausschließen.",
234+
"event_community_invite_not_found": "Die Event-Einladung für die Community konnte nicht gefunden werden.",
235+
"public_trips_disabled": "Öffentliche Trips sind derzeit deaktiviert."
233236
},
234237
"quick_links": {
235238
"do_not_email": "Du wirst keine E-Mails mehr erhalten, die nicht sicherheitsrelevant sind, und dein Hosting-Status wurde zurückgesetzt. Der Newsletter muss separat abbestellt werden.",

app/backend/src/couchers/i18n/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"event_cant_update_old_event": "You can't modify, subscribe to, or attend to an event that's more than 1 day old.",
6767
"event_community_invite_already_approved": "A community invite has already been sent out for this event.",
6868
"event_community_invite_already_requested": "You have already requested a community invite for this event.",
69-
"cannot_combine_attending_and_exclude_attending": "You cannot set both attending and exclude_attending.",
69+
"cannot_combine_attending_and_exclude_attending": "Your filters cannot both include and exclude events you are attending.",
7070
"event_community_invite_not_found": "Couldn't find that event community invite.",
7171
"event_edit_permission_denied": "You're not allowed to edit that event.",
7272
"event_ends_before_starts": "The event must end after it starts.",

0 commit comments

Comments
 (0)