Coverage for app/backend/src/couchers/notifications/render_push.py: 88%
300 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-19 15:47 +0000
1"""
2Renders a Notification model into a localized push notification.
3"""
5import logging
6from datetime import date
7from typing import Any, assert_never
8from zoneinfo import ZoneInfo
10from google.protobuf.timestamp_pb2 import Timestamp
12from couchers import urls
13from couchers.i18n import LocalizationContext
14from couchers.i18n.i18next import LocalizationError
15from couchers.i18n.localize import format_phone_number
16from couchers.models import Notification, NotificationTopicAction
17from couchers.notifications.locales import get_notifs_i18next
18from couchers.notifications.push import PushNotificationContent
19from couchers.proto import api_pb2, notification_data_pb2
21logger = logging.getLogger(__name__)
23# See PushNotificationContent's documentation for notification writing guidelines.
26def render_push_notification(notification: Notification, loc_context: LocalizationContext) -> PushNotificationContent:
27 data: Any = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined]
29 match notification.topic_action:
30 # Using a match statement enable mypy's exhaustiveness checking.
31 # Every case is has its own function so that they can declare different types for "data",
32 # as mypy wouldn't allow that in a single function.
33 # Keep topics sorted (actions can follow logical ordering)
34 case NotificationTopicAction.account_deletion__start:
35 return _render_account_deletion__start(data, loc_context)
36 case NotificationTopicAction.account_deletion__complete:
37 return _render_account_deletion__complete(data, loc_context)
38 case NotificationTopicAction.account_deletion__recovered:
39 return _render_account_deletion__recovered(loc_context)
40 case NotificationTopicAction.activeness__probe:
41 return _render_activeness__probe(data, loc_context)
42 case NotificationTopicAction.api_key__create:
43 return _render_api_key__create(data, loc_context)
44 case NotificationTopicAction.badge__add:
45 return _render_badge__add(data, loc_context)
46 case NotificationTopicAction.badge__remove:
47 return _render_badge__remove(data, loc_context)
48 case NotificationTopicAction.birthdate__change:
49 return _render_birthdate__change(data, loc_context)
50 case NotificationTopicAction.chat__message:
51 return _render_chat__message(data, loc_context)
52 case NotificationTopicAction.chat__missed_messages:
53 return _render_chat__missed_messages(data, loc_context)
54 case NotificationTopicAction.donation__received:
55 return _render_donation__received(data, loc_context)
56 case NotificationTopicAction.discussion__create:
57 return _render_discussion__create(data, loc_context)
58 case NotificationTopicAction.discussion__comment:
59 return _render_discussion__comment(data, loc_context)
60 case NotificationTopicAction.email_address__change:
61 return _render_email_address__change(data, loc_context)
62 case NotificationTopicAction.email_address__verify:
63 return _render_email_address__verify(loc_context)
64 case NotificationTopicAction.event__create_any: 64 ↛ 65line 64 didn't jump to line 65 because the pattern on line 64 never matched
65 return _render_event__create_any(data, loc_context)
66 case NotificationTopicAction.event__create_approved:
67 return _render_event__create_approved(data, loc_context)
68 case NotificationTopicAction.event__update:
69 return _render_event__update(data, loc_context)
70 case NotificationTopicAction.event__invite_organizer: 70 ↛ 71line 70 didn't jump to line 71 because the pattern on line 70 never matched
71 return _render_event__invite_organizer(data, loc_context)
72 case NotificationTopicAction.event__comment:
73 return _render_event__comment(data, loc_context)
74 case NotificationTopicAction.event__reminder:
75 return _render_event__reminder(data, loc_context)
76 case NotificationTopicAction.event__cancel:
77 return _render_event__cancel(data, loc_context)
78 case NotificationTopicAction.event__delete: 78 ↛ 79line 78 didn't jump to line 79 because the pattern on line 78 never matched
79 return _render_event__delete(data, loc_context)
80 case NotificationTopicAction.friend_request__create:
81 return _render_friend_request__create(data, loc_context)
82 case NotificationTopicAction.friend_request__accept:
83 return _render_friend_request__accept(data, loc_context)
84 case NotificationTopicAction.gender__change:
85 return _render_gender__change(data, loc_context)
86 case NotificationTopicAction.general__new_blog_post:
87 return _render_general__new_blog_post(data, loc_context)
88 case NotificationTopicAction.host_request__create:
89 return _render_host_request__create(data, loc_context)
90 case NotificationTopicAction.host_request__message:
91 return _render_host_request__message(data, loc_context)
92 case NotificationTopicAction.host_request__missed_messages: 92 ↛ 93line 92 didn't jump to line 93 because the pattern on line 92 never matched
93 return _render_host_request__missed_messages(data, loc_context)
94 case NotificationTopicAction.host_request__reminder:
95 return _render_host_request__reminder(data, loc_context)
96 case NotificationTopicAction.host_request__accept:
97 return _render_host_request__accept(data, loc_context)
98 case NotificationTopicAction.host_request__reject: 98 ↛ 99line 98 didn't jump to line 99 because the pattern on line 98 never matched
99 return _render_host_request__reject(data, loc_context)
100 case NotificationTopicAction.host_request__cancel:
101 return _render_host_request__cancel(data, loc_context)
102 case NotificationTopicAction.host_request__confirm:
103 return _render_host_request__confirm(data, loc_context)
104 case NotificationTopicAction.modnote__create:
105 return _render_modnote__create(loc_context)
106 case NotificationTopicAction.onboarding__reminder:
107 return _render_onboarding__reminder(notification.key, loc_context)
108 case NotificationTopicAction.password__change:
109 return _render_password__change(loc_context)
110 case NotificationTopicAction.password_reset__start:
111 return _render_password_reset__start(data, loc_context)
112 case NotificationTopicAction.password_reset__complete:
113 return _render_password_reset__complete(loc_context)
114 case NotificationTopicAction.phone_number__change:
115 return _render_phone_number__change(data, loc_context)
116 case NotificationTopicAction.phone_number__verify:
117 return _render_phone_number__verify(data, loc_context)
118 case NotificationTopicAction.postal_verification__postcard_sent:
119 return _render_postal_verification__postcard_sent(data, loc_context)
120 case NotificationTopicAction.postal_verification__success: 120 ↛ 121line 120 didn't jump to line 121 because the pattern on line 120 never matched
121 return _render_postal_verification__success(loc_context)
122 case NotificationTopicAction.postal_verification__failed: 122 ↛ 123line 122 didn't jump to line 123 because the pattern on line 122 never matched
123 return _render_postal_verification__failed(data, loc_context)
124 case NotificationTopicAction.reference__receive_friend:
125 return _render_reference__receive_friend(data, loc_context)
126 case NotificationTopicAction.reference__receive_hosted: 126 ↛ 127line 126 didn't jump to line 127 because the pattern on line 126 never matched
127 return _render_reference__receive_hosted(data, loc_context)
128 case NotificationTopicAction.reference__receive_surfed:
129 return _render_reference__receive_surfed(data, loc_context)
130 case NotificationTopicAction.reference__reminder_hosted:
131 return _render_reference__reminder_hosted(data, loc_context)
132 case NotificationTopicAction.reference__reminder_surfed:
133 return _render_reference__reminder_surfed(data, loc_context)
134 case NotificationTopicAction.thread__reply:
135 return _render_thread__reply(data, loc_context)
136 case NotificationTopicAction.verification__sv_success:
137 return _render_verification__sv_success(loc_context)
138 case NotificationTopicAction.verification__sv_fail: 138 ↛ 140line 138 didn't jump to line 140 because the pattern on line 138 always matched
139 return _render_verification__sv_fail(data, loc_context)
140 case _:
141 # Enables mypy's exhaustiveness checking for the cases above.
142 assert_never(notification.topic_action)
145def render_adhoc_push_notification(name: str, loc_context: LocalizationContext) -> PushNotificationContent:
146 """Renders a push notification that doesn't have an assigned topic-action."""
147 return _get_content(string_group=f"_adhoc.{name}.push", loc_context=loc_context)
150def _get_content(
151 string_group: NotificationTopicAction | str,
152 loc_context: LocalizationContext,
153 title: str | None = None,
154 ios_title: str | None = None,
155 ios_subtitle: str | None = None,
156 body: str | None = None,
157 substitutions: dict[str, str | int] | None = None,
158 icon_user: api_pb2.User | None = None,
159 action_url: str | None = None,
160) -> PushNotificationContent:
161 """
162 Fills a PushNotificationContent by looking up localized
163 string based on the topic_action key, unless other strings
164 are provided by the caller.
166 Localized strings have the provided substitutions applied.
167 """
168 # Look up the localized string for any string that was not provided
169 if title is None: 169 ↛ 171line 169 didn't jump to line 171 because the condition on line 169 was always true
170 title = _get_string(string_group, "title", loc_context, substitutions)
171 if ios_title is None:
172 ios_title = _get_string(string_group, "ios_title", loc_context, substitutions)
173 if ios_subtitle is None:
174 try:
175 ios_subtitle = _get_string(string_group, "ios_subtitle", loc_context, substitutions)
176 except LocalizationError:
177 # Not all notifications have subtitles
178 pass
179 if body is None:
180 body = _get_string(string_group, "body", loc_context, substitutions)
182 icon_url = _avatar_url_or_default(icon_user) if icon_user else None
184 return PushNotificationContent(
185 title=title, ios_title=ios_title, ios_subtitle=ios_subtitle, body=body, icon_url=icon_url, action_url=action_url
186 )
189def _get_string(
190 string_group: NotificationTopicAction | str,
191 key: str,
192 loc_context: LocalizationContext,
193 substitutions: dict[str, str | int] | None = None,
194) -> str:
195 if isinstance(string_group, NotificationTopicAction):
196 full_key = f"{string_group.topic}.{string_group.action}.push.{key}"
197 else:
198 full_key = f"{string_group}.{key}"
199 return get_notifs_i18next().localize(full_key, loc_context.locale_list, substitutions)
202def _avatar_url_or_default(user: api_pb2.User) -> str:
203 return user.avatar_thumbnail_url or urls.icon_url()
206def _render_account_deletion__start(
207 data: notification_data_pb2.AccountDeletionStart, loc_context: LocalizationContext
208) -> PushNotificationContent:
209 return _get_content(NotificationTopicAction.account_deletion__start, loc_context)
212def _render_account_deletion__complete(
213 data: notification_data_pb2.AccountDeletionComplete, loc_context: LocalizationContext
214) -> PushNotificationContent:
215 return _get_content(
216 NotificationTopicAction.account_deletion__complete, loc_context, substitutions={"count": data.undelete_days}
217 )
220def _render_account_deletion__recovered(loc_context: LocalizationContext) -> PushNotificationContent:
221 return _get_content(NotificationTopicAction.account_deletion__recovered, loc_context)
224def _render_activeness__probe(
225 data: notification_data_pb2.ActivenessProbe, loc_context: LocalizationContext
226) -> PushNotificationContent:
227 return _get_content(NotificationTopicAction.activeness__probe, loc_context)
230def _render_api_key__create(
231 data: notification_data_pb2.ApiKeyCreate, loc_context: LocalizationContext
232) -> PushNotificationContent:
233 return _get_content(NotificationTopicAction.api_key__create, loc_context)
236def _render_badge__add(
237 data: notification_data_pb2.BadgeAdd, loc_context: LocalizationContext
238) -> PushNotificationContent:
239 return _get_content(
240 NotificationTopicAction.badge__add,
241 loc_context,
242 substitutions={"badge_name": data.badge_name},
243 action_url=urls.profile_link(),
244 )
247def _render_badge__remove(
248 data: notification_data_pb2.BadgeRemove, loc_context: LocalizationContext
249) -> PushNotificationContent:
250 return _get_content(
251 NotificationTopicAction.badge__remove,
252 loc_context,
253 substitutions={"badge_name": data.badge_name},
254 action_url=urls.profile_link(),
255 )
258def _render_birthdate__change(
259 data: notification_data_pb2.BirthdateChange, loc_context: LocalizationContext
260) -> PushNotificationContent:
261 return _get_content(
262 NotificationTopicAction.birthdate__change,
263 loc_context,
264 substitutions={"birthdate": loc_context.localize_date_from_iso(data.birthdate)},
265 action_url=urls.account_settings_link(),
266 )
269def _render_chat__message(
270 data: notification_data_pb2.ChatMessage, loc_context: LocalizationContext
271) -> PushNotificationContent:
272 if data.group_chat_title:
273 # Group chat message: title/subtitle specifies user and group
274 return _get_content(
275 "chat.message.push_group",
276 loc_context,
277 body=data.text,
278 substitutions={"user": data.author.name, "group": data.group_chat_title},
279 icon_user=data.author,
280 action_url=urls.chat_link(chat_id=data.group_chat_id),
281 )
282 # Direct message: all strings are dynamic, no need to use _get_content
283 return PushNotificationContent(
284 title=data.author.name,
285 ios_title=data.author.name,
286 body=data.text,
287 icon_url=_avatar_url_or_default(data.author),
288 action_url=urls.chat_link(chat_id=data.group_chat_id),
289 )
292def _render_chat__missed_messages(
293 data: notification_data_pb2.ChatMissedMessages, loc_context: LocalizationContext
294) -> PushNotificationContent:
295 # Each message is from a different chat, so this counts conversations.
296 missed_count: int = len(data.messages)
298 # Newer version of protos include a per-chat unseen message count (1 or more)
299 if data.messages and data.messages[0].unseen_count: 299 ↛ 302line 299 didn't jump to line 302 because the condition on line 299 was always true
300 missed_count = sum(message.unseen_count for message in data.messages)
302 return _get_content(
303 NotificationTopicAction.chat__missed_messages,
304 loc_context,
305 substitutions={"count": missed_count},
306 action_url=urls.messages_link(),
307 )
310def _render_donation__received(
311 data: notification_data_pb2.DonationReceived, loc_context: LocalizationContext
312) -> PushNotificationContent:
313 return _get_content(
314 NotificationTopicAction.donation__received,
315 loc_context,
316 # Other currencies are not yet supported
317 substitutions={"amount_with_currency": f"${data.amount}"},
318 action_url=data.receipt_url,
319 )
322def _render_discussion__create(
323 data: notification_data_pb2.DiscussionCreate, loc_context: LocalizationContext
324) -> PushNotificationContent:
325 return _get_content(
326 NotificationTopicAction.discussion__create,
327 loc_context,
328 substitutions={
329 "title": data.discussion.title,
330 "user": data.author.name,
331 "group_or_community": data.discussion.owner_title,
332 },
333 icon_user=data.author,
334 action_url=urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug),
335 )
338def _render_discussion__comment(
339 data: notification_data_pb2.DiscussionComment, loc_context: LocalizationContext
340) -> PushNotificationContent:
341 return _get_content(
342 NotificationTopicAction.discussion__comment,
343 loc_context,
344 ios_title=data.author.name,
345 ios_subtitle=data.discussion.title,
346 body=data.reply.content,
347 substitutions={"user": data.author.name, "title": data.discussion.title},
348 icon_user=data.author,
349 action_url=urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug),
350 )
353def _render_email_address__change(
354 data: notification_data_pb2.EmailAddressChange, loc_context: LocalizationContext
355) -> PushNotificationContent:
356 return _get_content(
357 NotificationTopicAction.email_address__change,
358 loc_context,
359 substitutions={"email": data.new_email},
360 action_url=urls.account_settings_link(),
361 )
364def _render_email_address__verify(loc_context: LocalizationContext) -> PushNotificationContent:
365 return _get_content(
366 NotificationTopicAction.email_address__verify,
367 loc_context,
368 action_url=urls.account_settings_link(),
369 )
372def _render_event__create_any(
373 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext
374) -> PushNotificationContent:
375 return _get_content(
376 NotificationTopicAction.event__create_any,
377 loc_context,
378 substitutions={
379 "title": data.event.title,
380 "user": data.inviting_user.name,
381 "date_and_time": _format_event_start_datetime(data.event.start_time, data.event.timezone, loc_context),
382 },
383 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
384 )
387def _render_event__create_approved(
388 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext
389) -> PushNotificationContent:
390 return _get_content(
391 NotificationTopicAction.event__create_approved,
392 loc_context,
393 substitutions={
394 "title": data.event.title,
395 "user": data.inviting_user.name,
396 "date_and_time": _format_event_start_datetime(data.event.start_time, data.event.timezone, loc_context),
397 },
398 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
399 )
402def _render_event__update(
403 data: notification_data_pb2.EventUpdate, loc_context: LocalizationContext
404) -> PushNotificationContent:
405 # updated_items can include: title, content, start_time, end_time, location,
406 # but a list like that is tricky to localize.
407 return _get_content(
408 NotificationTopicAction.event__update,
409 loc_context,
410 substitutions={
411 "title": data.event.title,
412 "user": data.updating_user.name,
413 },
414 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
415 )
418def _render_event__invite_organizer(
419 data: notification_data_pb2.EventInviteOrganizer, loc_context: LocalizationContext
420) -> PushNotificationContent:
421 return _get_content(
422 NotificationTopicAction.event__invite_organizer,
423 loc_context,
424 substitutions={
425 "title": data.event.title,
426 "user": data.inviting_user.name,
427 },
428 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
429 )
432def _render_event__comment(
433 data: notification_data_pb2.EventComment, loc_context: LocalizationContext
434) -> PushNotificationContent:
435 return _get_content(
436 NotificationTopicAction.event__comment,
437 loc_context,
438 substitutions={
439 "title": data.event.title,
440 "user": data.author.name,
441 },
442 body=data.reply.content,
443 icon_user=data.author,
444 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
445 )
448def _render_event__reminder(
449 data: notification_data_pb2.EventReminder, loc_context: LocalizationContext
450) -> PushNotificationContent:
451 return _get_content(
452 NotificationTopicAction.event__reminder,
453 loc_context,
454 substitutions={
455 "title": data.event.title,
456 "date_and_time": _format_event_start_datetime(data.event.start_time, data.event.timezone, loc_context),
457 },
458 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
459 )
462def _render_event__cancel(
463 data: notification_data_pb2.EventCancel, loc_context: LocalizationContext
464) -> PushNotificationContent:
465 return _get_content(
466 NotificationTopicAction.event__cancel,
467 loc_context,
468 substitutions={
469 "title": data.event.title,
470 "user": data.cancelling_user.name,
471 },
472 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug),
473 )
476def _render_event__delete(
477 data: notification_data_pb2.EventDelete, loc_context: LocalizationContext
478) -> PushNotificationContent:
479 return _get_content(NotificationTopicAction.event__delete, loc_context, substitutions={"title": data.event.title})
482def _render_friend_request__create(
483 data: notification_data_pb2.FriendRequestCreate, loc_context: LocalizationContext
484) -> PushNotificationContent:
485 return _get_content(
486 NotificationTopicAction.friend_request__create,
487 loc_context,
488 substitutions={"from_user": data.other_user.name},
489 icon_user=data.other_user,
490 action_url=urls.friend_requests_link(from_user_id=data.other_user.user_id),
491 )
494def _render_friend_request__accept(
495 data: notification_data_pb2.FriendRequestAccept, loc_context: LocalizationContext
496) -> PushNotificationContent:
497 return _get_content(
498 NotificationTopicAction.friend_request__accept,
499 loc_context,
500 substitutions={"friend": data.other_user.name},
501 icon_user=data.other_user,
502 action_url=urls.user_link(username=data.other_user.username),
503 )
506def _render_gender__change(
507 data: notification_data_pb2.GenderChange, loc_context: LocalizationContext
508) -> PushNotificationContent:
509 return _get_content(
510 NotificationTopicAction.gender__change,
511 loc_context,
512 substitutions={"gender": data.gender},
513 action_url=urls.account_settings_link(),
514 )
517def _render_general__new_blog_post(
518 data: notification_data_pb2.GeneralNewBlogPost, loc_context: LocalizationContext
519) -> PushNotificationContent:
520 return _get_content(
521 NotificationTopicAction.general__new_blog_post,
522 loc_context,
523 body=data.blurb,
524 substitutions={"title": data.title},
525 action_url=data.url,
526 )
529def _render_host_request__create(
530 data: notification_data_pb2.HostRequestCreate, loc_context: LocalizationContext
531) -> PushNotificationContent:
532 night_count = (date.fromisoformat(data.host_request.to_date) - date.fromisoformat(data.host_request.from_date)).days
533 return _get_content(
534 NotificationTopicAction.host_request__create,
535 loc_context,
536 substitutions={
537 "user": data.surfer.name,
538 "start_date": _format_host_request_start_date(data.host_request.from_date, loc_context),
539 "count": night_count,
540 },
541 icon_user=data.surfer,
542 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
543 )
546def _render_host_request__message(
547 data: notification_data_pb2.HostRequestMessage, loc_context: LocalizationContext
548) -> PushNotificationContent:
549 # All strings are dynamic, no need to use _get_content
550 return PushNotificationContent(
551 title=data.user.name,
552 ios_title=data.user.name,
553 body=data.text,
554 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
555 icon_url=_avatar_url_or_default(data.user),
556 )
559def _render_host_request__missed_messages(
560 data: notification_data_pb2.HostRequestMissedMessages, loc_context: LocalizationContext
561) -> PushNotificationContent:
562 return _get_content(
563 NotificationTopicAction.host_request__missed_messages,
564 loc_context,
565 substitutions={"user": data.user.name},
566 icon_user=data.user,
567 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
568 )
571def _render_host_request__reminder(
572 data: notification_data_pb2.HostRequestReminder, loc_context: LocalizationContext
573) -> PushNotificationContent:
574 return _get_content(
575 NotificationTopicAction.host_request__reminder,
576 loc_context,
577 substitutions={"user": data.surfer.name},
578 icon_user=data.surfer,
579 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
580 )
583def _render_host_request__accept(
584 data: notification_data_pb2.HostRequestAccept, loc_context: LocalizationContext
585) -> PushNotificationContent:
586 return _get_content(
587 NotificationTopicAction.host_request__accept,
588 loc_context,
589 substitutions={
590 "user": data.host.name,
591 "date": _format_host_request_start_date(data.host_request.from_date, loc_context),
592 },
593 icon_user=data.host,
594 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
595 )
598def _render_host_request__reject(
599 data: notification_data_pb2.HostRequestReject, loc_context: LocalizationContext
600) -> PushNotificationContent:
601 return _get_content(
602 NotificationTopicAction.host_request__reject,
603 loc_context,
604 substitutions={
605 "user": data.host.name,
606 "date": _format_host_request_start_date(data.host_request.from_date, loc_context),
607 },
608 icon_user=data.host,
609 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
610 )
613def _render_host_request__cancel(
614 data: notification_data_pb2.HostRequestCancel, loc_context: LocalizationContext
615) -> PushNotificationContent:
616 return _get_content(
617 NotificationTopicAction.host_request__cancel,
618 loc_context,
619 substitutions={
620 "user": data.surfer.name,
621 "date": _format_host_request_start_date(data.host_request.from_date, loc_context),
622 },
623 icon_user=data.surfer,
624 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
625 )
628def _render_host_request__confirm(
629 data: notification_data_pb2.HostRequestConfirm, loc_context: LocalizationContext
630) -> PushNotificationContent:
631 return _get_content(
632 NotificationTopicAction.host_request__confirm,
633 loc_context,
634 substitutions={
635 "user": data.surfer.name,
636 "date": _format_host_request_start_date(data.host_request.from_date, loc_context),
637 },
638 icon_user=data.surfer,
639 action_url=urls.host_request(host_request_id=data.host_request.host_request_id),
640 )
643def _render_modnote__create(loc_context: LocalizationContext) -> PushNotificationContent:
644 return _get_content(NotificationTopicAction.modnote__create, loc_context)
647def _render_onboarding__reminder(key: str, loc_context: LocalizationContext) -> PushNotificationContent:
648 variant = "first" if key == "1" else "subsequent"
649 return _get_content(
650 f"onboarding.reminder.push.{variant}",
651 loc_context,
652 action_url=urls.edit_profile_link(),
653 )
656def _render_password__change(loc_context: LocalizationContext) -> PushNotificationContent:
657 return _get_content(NotificationTopicAction.password__change, loc_context, action_url=urls.account_settings_link())
660def _render_password_reset__start(
661 data: notification_data_pb2.PasswordResetStart, loc_context: LocalizationContext
662) -> PushNotificationContent:
663 return _get_content(
664 NotificationTopicAction.password_reset__start,
665 loc_context,
666 action_url=urls.account_settings_link(),
667 )
670def _render_password_reset__complete(loc_context: LocalizationContext) -> PushNotificationContent:
671 return _get_content(
672 NotificationTopicAction.password_reset__complete,
673 loc_context,
674 action_url=urls.account_settings_link(),
675 )
678def _render_phone_number__change(
679 data: notification_data_pb2.PhoneNumberChange, loc_context: LocalizationContext
680) -> PushNotificationContent:
681 return _get_content(
682 NotificationTopicAction.phone_number__change,
683 loc_context,
684 substitutions={"phone_number": format_phone_number(data.phone)},
685 action_url=urls.account_settings_link(),
686 )
689def _render_phone_number__verify(
690 data: notification_data_pb2.PhoneNumberVerify, loc_context: LocalizationContext
691) -> PushNotificationContent:
692 return _get_content(
693 NotificationTopicAction.phone_number__verify,
694 loc_context,
695 substitutions={"phone_number": format_phone_number(data.phone)},
696 action_url=urls.account_settings_link(),
697 )
700def _render_postal_verification__postcard_sent(
701 data: notification_data_pb2.PostalVerificationPostcardSent, loc_context: LocalizationContext
702) -> PushNotificationContent:
703 return _get_content(
704 NotificationTopicAction.postal_verification__postcard_sent,
705 loc_context,
706 substitutions={"city": data.city, "country": data.country},
707 action_url=urls.account_settings_link(),
708 )
711def _render_postal_verification__success(loc_context: LocalizationContext) -> PushNotificationContent:
712 return _get_content(
713 NotificationTopicAction.postal_verification__success,
714 loc_context,
715 action_url=urls.account_settings_link(),
716 )
719def _render_postal_verification__failed(
720 data: notification_data_pb2.PostalVerificationFailed, loc_context: LocalizationContext
721) -> PushNotificationContent:
722 body_key: str
723 match data.reason:
724 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED:
725 body_key = "body_code_expired"
726 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS:
727 body_key = "body_too_many_attempts"
728 case _:
729 body_key = "body_generic"
731 return _get_content(
732 NotificationTopicAction.postal_verification__failed,
733 loc_context,
734 body=_get_string(NotificationTopicAction.postal_verification__failed, body_key, loc_context),
735 action_url=urls.account_settings_link(),
736 )
739def _render_reference__receive_friend(
740 data: notification_data_pb2.ReferenceReceiveFriend, loc_context: LocalizationContext
741) -> PushNotificationContent:
742 return _get_content(
743 NotificationTopicAction.reference__receive_friend,
744 loc_context,
745 body=data.text,
746 substitutions={"user": data.from_user.name},
747 icon_user=data.from_user,
748 action_url=urls.profile_references_link(),
749 )
752def _render_reference__receive(
753 data: notification_data_pb2.ReferenceReceiveHostRequest, leave_reference_type: str, loc_context: LocalizationContext
754) -> PushNotificationContent:
755 body: str
756 if data.text: 756 ↛ 757line 756 didn't jump to line 757 because the condition on line 756 was never true
757 body = data.text
758 action_url = urls.profile_references_link()
759 else:
760 body = _get_string(
761 "reference._receive_any.push",
762 "body_must_write_yours",
763 loc_context,
764 substitutions={"user": data.from_user.name},
765 )
766 action_url = urls.leave_reference_link(
767 reference_type=leave_reference_type,
768 to_user_id=data.from_user.user_id,
769 host_request_id=str(data.host_request_id),
770 )
771 return _get_content(
772 string_group="reference._receive_any.push",
773 loc_context=loc_context,
774 body=body,
775 substitutions={"user": data.from_user.name},
776 icon_user=data.from_user,
777 action_url=action_url,
778 )
781def _render_reference__receive_hosted(
782 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext
783) -> PushNotificationContent:
784 # Receiving a hosted reminder means I need to leave a surfed reference
785 return _render_reference__receive(data, leave_reference_type="surfed", loc_context=loc_context)
788def _render_reference__receive_surfed(
789 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext
790) -> PushNotificationContent:
791 return _render_reference__receive(data, leave_reference_type="hosted", loc_context=loc_context)
794def _render_reference__reminder(
795 data: notification_data_pb2.ReferenceReminder, leave_reference_type: str, loc_context: LocalizationContext
796) -> PushNotificationContent:
797 leave_reference_link = urls.leave_reference_link(
798 reference_type=leave_reference_type,
799 to_user_id=data.other_user.user_id,
800 host_request_id=str(data.host_request_id),
801 )
802 return _get_content(
803 string_group="reference._reminder_any.push",
804 loc_context=loc_context,
805 substitutions={"count": data.days_left, "user": data.other_user.name},
806 icon_user=data.other_user,
807 action_url=leave_reference_link,
808 )
811def _render_reference__reminder_surfed(
812 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext
813) -> PushNotificationContent:
814 # Surfed reminder means I need to leave a surfed reference
815 return _render_reference__reminder(data, leave_reference_type="surfed", loc_context=loc_context)
818def _render_reference__reminder_hosted(
819 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext
820) -> PushNotificationContent:
821 return _render_reference__reminder(data, leave_reference_type="hosted", loc_context=loc_context)
824def _render_thread__reply(
825 data: notification_data_pb2.ThreadReply, loc_context: LocalizationContext
826) -> PushNotificationContent:
827 parent_title: str
828 view_link: str
829 match data.WhichOneof("reply_parent"):
830 case "event":
831 parent_title = data.event.title
832 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug)
833 case "discussion": 833 ↛ 836line 833 didn't jump to line 836 because the pattern on line 833 always matched
834 parent_title = data.discussion.title
835 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug)
836 case _:
837 raise Exception("Can only do replies to events and discussions")
839 return _get_content(
840 NotificationTopicAction.thread__reply,
841 loc_context=loc_context,
842 body=data.reply.content,
843 substitutions={"user": data.author.name, "title": parent_title},
844 icon_user=data.author,
845 action_url=view_link,
846 )
849def _render_verification__sv_success(loc_context: LocalizationContext) -> PushNotificationContent:
850 return _get_content(
851 NotificationTopicAction.verification__sv_success,
852 loc_context,
853 action_url=urls.account_settings_link(),
854 )
857def _render_verification__sv_fail(
858 data: notification_data_pb2.VerificationSVFail, loc_context: LocalizationContext
859) -> PushNotificationContent:
860 body_key: str
861 match data.reason:
862 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 862 ↛ 863line 862 didn't jump to line 863 because the pattern on line 862 never matched
863 body_key = "body_wrong_birthdate_gender"
864 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT:
865 body_key = "body_not_a_passport"
866 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 866 ↛ 868line 866 didn't jump to line 868 because the pattern on line 866 always matched
867 body_key = "body_duplicate"
868 case _:
869 raise Exception("Shouldn't get here")
871 return _get_content(
872 NotificationTopicAction.verification__sv_fail,
873 loc_context,
874 body=_get_string(NotificationTopicAction.verification__sv_fail, body_key, loc_context),
875 action_url=urls.account_settings_link(),
876 )
879def _format_host_request_start_date(date: str, loc_context: LocalizationContext) -> str:
880 # Events are typically in the near future future,
881 # so the year is not useful but the day of week is.
882 return loc_context.localize_date_from_iso(date, with_year=False, with_day_of_week=True)
885def _format_event_start_datetime(timestamp: Timestamp, timezone: str, loc_context: LocalizationContext) -> str:
886 # Events are typically in the near future future,
887 # so the year is not useful but the day of week is.
888 # Event start/end times are displayed in their local timezone.
889 return loc_context.localize_datetime(
890 timestamp, display_timezone=ZoneInfo(timezone), with_year=False, with_day_of_week=True
891 )