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

1""" 

2Renders a Notification model into a localized push notification. 

3""" 

4 

5import logging 

6from datetime import date 

7from typing import Any, assert_never 

8from zoneinfo import ZoneInfo 

9 

10from google.protobuf.timestamp_pb2 import Timestamp 

11 

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 

20 

21logger = logging.getLogger(__name__) 

22 

23# See PushNotificationContent's documentation for notification writing guidelines. 

24 

25 

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] 

28 

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) 

143 

144 

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) 

148 

149 

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. 

165 

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) 

181 

182 icon_url = _avatar_url_or_default(icon_user) if icon_user else None 

183 

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 ) 

187 

188 

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) 

200 

201 

202def _avatar_url_or_default(user: api_pb2.User) -> str: 

203 return user.avatar_thumbnail_url or urls.icon_url() 

204 

205 

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) 

210 

211 

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 ) 

218 

219 

220def _render_account_deletion__recovered(loc_context: LocalizationContext) -> PushNotificationContent: 

221 return _get_content(NotificationTopicAction.account_deletion__recovered, loc_context) 

222 

223 

224def _render_activeness__probe( 

225 data: notification_data_pb2.ActivenessProbe, loc_context: LocalizationContext 

226) -> PushNotificationContent: 

227 return _get_content(NotificationTopicAction.activeness__probe, loc_context) 

228 

229 

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) 

234 

235 

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 ) 

245 

246 

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 ) 

256 

257 

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 ) 

267 

268 

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 ) 

290 

291 

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) 

297 

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) 

301 

302 return _get_content( 

303 NotificationTopicAction.chat__missed_messages, 

304 loc_context, 

305 substitutions={"count": missed_count}, 

306 action_url=urls.messages_link(), 

307 ) 

308 

309 

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 ) 

320 

321 

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 ) 

336 

337 

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 ) 

351 

352 

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 ) 

362 

363 

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 ) 

370 

371 

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 ) 

385 

386 

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 ) 

400 

401 

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 ) 

416 

417 

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 ) 

430 

431 

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 ) 

446 

447 

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 ) 

460 

461 

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 ) 

474 

475 

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

480 

481 

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 ) 

492 

493 

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 ) 

504 

505 

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 ) 

515 

516 

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 ) 

527 

528 

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 ) 

544 

545 

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 ) 

557 

558 

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 ) 

569 

570 

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 ) 

581 

582 

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 ) 

596 

597 

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 ) 

611 

612 

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 ) 

626 

627 

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 ) 

641 

642 

643def _render_modnote__create(loc_context: LocalizationContext) -> PushNotificationContent: 

644 return _get_content(NotificationTopicAction.modnote__create, loc_context) 

645 

646 

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 ) 

654 

655 

656def _render_password__change(loc_context: LocalizationContext) -> PushNotificationContent: 

657 return _get_content(NotificationTopicAction.password__change, loc_context, action_url=urls.account_settings_link()) 

658 

659 

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 ) 

668 

669 

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 ) 

676 

677 

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 ) 

687 

688 

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 ) 

698 

699 

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 ) 

709 

710 

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 ) 

717 

718 

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" 

730 

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 ) 

737 

738 

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 ) 

750 

751 

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 ) 

779 

780 

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) 

786 

787 

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) 

792 

793 

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 ) 

809 

810 

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) 

816 

817 

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) 

822 

823 

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

838 

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 ) 

847 

848 

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 ) 

855 

856 

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

870 

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 ) 

877 

878 

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) 

883 

884 

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 )