Coverage for app / backend / src / couchers / notifications / render_push.py: 86%

296 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1""" 

2Renders a Notification model into a localized push notification. 

3""" 

4 

5import logging 

6from datetime import date 

7from functools import lru_cache 

8from pathlib import Path 

9from typing import Any, assert_never 

10 

11from couchers import urls 

12from couchers.i18n import LocalizationContext 

13from couchers.i18n.i18next import I18Next, LocalizationError 

14from couchers.i18n.locales import load_locales 

15from couchers.i18n.localize import format_phone_number 

16from couchers.models import Notification, NotificationTopicAction 

17from couchers.notifications.push import PushNotificationContent 

18from couchers.proto import api_pb2, notification_data_pb2 

19 

20logger = logging.getLogger(__name__) 

21 

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

23 

24 

25def render_push_notification(notification: Notification, loc_context: LocalizationContext) -> PushNotificationContent: 

26 data: Any = notification.topic_action.data_type.FromString(notification.data) # type: ignore[attr-defined] 

27 

28 match notification.topic_action: 

29 # Using a match statement enable mypy's exhaustiveness checking. 

30 # Every case is has its own function so that they can declare different types for "data", 

31 # as mypy wouldn't allow that in a single function. 

32 # Keep topics sorted (actions can follow logical ordering) 

33 case NotificationTopicAction.account_deletion__start: 

34 return _render_account_deletion__start(data, loc_context) 

35 case NotificationTopicAction.account_deletion__complete: 

36 return _render_account_deletion__complete(data, loc_context) 

37 case NotificationTopicAction.account_deletion__recovered: 

38 return _render_account_deletion__recovered(loc_context) 

39 case NotificationTopicAction.activeness__probe: 

40 return _render_activeness__probe(data, loc_context) 

41 case NotificationTopicAction.api_key__create: 

42 return _render_api_key__create(data, loc_context) 

43 case NotificationTopicAction.badge__add: 

44 return _render_badge__add(data, loc_context) 

45 case NotificationTopicAction.badge__remove: 

46 return _render_badge__remove(data, loc_context) 

47 case NotificationTopicAction.birthdate__change: 

48 return _render_birthdate__change(data, loc_context) 

49 case NotificationTopicAction.chat__message: 

50 return _render_chat__message(data, loc_context) 

51 case NotificationTopicAction.chat__missed_messages: 51 ↛ 52line 51 didn't jump to line 52 because the pattern on line 51 never matched

52 return _render_chat__missed_messages(data, loc_context) 

53 case NotificationTopicAction.donation__received: 

54 return _render_donation__received(data, loc_context) 

55 case NotificationTopicAction.discussion__create: 

56 return _render_discussion__create(data, loc_context) 

57 case NotificationTopicAction.discussion__comment: 

58 return _render_discussion__comment(data, loc_context) 

59 case NotificationTopicAction.email_address__change: 

60 return _render_email_address__change(data, loc_context) 

61 case NotificationTopicAction.email_address__verify: 

62 return _render_email_address__verify(loc_context) 

63 case NotificationTopicAction.event__create_any: 63 ↛ 64line 63 didn't jump to line 64 because the pattern on line 63 never matched

64 return _render_event__create_any(data, loc_context) 

65 case NotificationTopicAction.event__create_approved: 

66 return _render_event__create_approved(data, loc_context) 

67 case NotificationTopicAction.event__update: 67 ↛ 68line 67 didn't jump to line 68 because the pattern on line 67 never matched

68 return _render_event__update(data, loc_context) 

69 case NotificationTopicAction.event__invite_organizer: 69 ↛ 70line 69 didn't jump to line 70 because the pattern on line 69 never matched

70 return _render_event__invite_organizer(data, loc_context) 

71 case NotificationTopicAction.event__comment: 

72 return _render_event__comment(data, loc_context) 

73 case NotificationTopicAction.event__reminder: 

74 return _render_event__reminder(data, loc_context) 

75 case NotificationTopicAction.event__cancel: 75 ↛ 76line 75 didn't jump to line 76 because the pattern on line 75 never matched

76 return _render_event__cancel(data, loc_context) 

77 case NotificationTopicAction.event__delete: 77 ↛ 78line 77 didn't jump to line 78 because the pattern on line 77 never matched

78 return _render_event__delete(data, loc_context) 

79 case NotificationTopicAction.friend_request__create: 

80 return _render_friend_request__create(data, loc_context) 

81 case NotificationTopicAction.friend_request__accept: 

82 return _render_friend_request__accept(data, loc_context) 

83 case NotificationTopicAction.gender__change: 

84 return _render_gender__change(data, loc_context) 

85 case NotificationTopicAction.general__new_blog_post: 

86 return _render_general__new_blog_post(data, loc_context) 

87 case NotificationTopicAction.host_request__create: 

88 return _render_host_request__create(data, loc_context) 

89 case NotificationTopicAction.host_request__message: 

90 return _render_host_request__message(data, loc_context) 

91 case NotificationTopicAction.host_request__missed_messages: 91 ↛ 92line 91 didn't jump to line 92 because the pattern on line 91 never matched

92 return _render_host_request__missed_messages(data, loc_context) 

93 case NotificationTopicAction.host_request__reminder: 

94 return _render_host_request__reminder(data, loc_context) 

95 case NotificationTopicAction.host_request__accept: 

96 return _render_host_request__accept(data, loc_context) 

97 case NotificationTopicAction.host_request__reject: 97 ↛ 98line 97 didn't jump to line 98 because the pattern on line 97 never matched

98 return _render_host_request__reject(data, loc_context) 

99 case NotificationTopicAction.host_request__cancel: 99 ↛ 100line 99 didn't jump to line 100 because the pattern on line 99 never matched

100 return _render_host_request__cancel(data, loc_context) 

101 case NotificationTopicAction.host_request__confirm: 

102 return _render_host_request__confirm(data, loc_context) 

103 case NotificationTopicAction.modnote__create: 

104 return _render_modnote__create(loc_context) 

105 case NotificationTopicAction.onboarding__reminder: 

106 return _render_onboarding__reminder(notification.key, loc_context) 

107 case NotificationTopicAction.password__change: 

108 return _render_password__change(loc_context) 

109 case NotificationTopicAction.password_reset__start: 

110 return _render_password_reset__start(data, loc_context) 

111 case NotificationTopicAction.password_reset__complete: 

112 return _render_password_reset__complete(loc_context) 

113 case NotificationTopicAction.phone_number__change: 

114 return _render_phone_number__change(data, loc_context) 

115 case NotificationTopicAction.phone_number__verify: 

116 return _render_phone_number__verify(data, loc_context) 

117 case NotificationTopicAction.postal_verification__postcard_sent: 

118 return _render_postal_verification__postcard_sent(data, loc_context) 

119 case NotificationTopicAction.postal_verification__success: 119 ↛ 120line 119 didn't jump to line 120 because the pattern on line 119 never matched

120 return _render_postal_verification__success(loc_context) 

121 case NotificationTopicAction.postal_verification__failed: 121 ↛ 122line 121 didn't jump to line 122 because the pattern on line 121 never matched

122 return _render_postal_verification__failed(data, loc_context) 

123 case NotificationTopicAction.reference__receive_friend: 

124 return _render_reference__receive_friend(data, loc_context) 

125 case NotificationTopicAction.reference__receive_hosted: 125 ↛ 126line 125 didn't jump to line 126 because the pattern on line 125 never matched

126 return _render_reference__receive_hosted(data, loc_context) 

127 case NotificationTopicAction.reference__receive_surfed: 

128 return _render_reference__receive_surfed(data, loc_context) 

129 case NotificationTopicAction.reference__reminder_hosted: 

130 return _render_reference__reminder_hosted(data, loc_context) 

131 case NotificationTopicAction.reference__reminder_surfed: 

132 return _render_reference__reminder_surfed(data, loc_context) 

133 case NotificationTopicAction.thread__reply: 

134 return _render_thread__reply(data, loc_context) 

135 case NotificationTopicAction.verification__sv_success: 

136 return _render_verification__sv_success(loc_context) 

137 case NotificationTopicAction.verification__sv_fail: 137 ↛ 139line 137 didn't jump to line 139 because the pattern on line 137 always matched

138 return _render_verification__sv_fail(data, loc_context) 

139 case _: 

140 # Enables mypy's exhaustiveness checking for the cases above. 

141 assert_never(notification.topic_action) 

142 

143 

144def render_adhoc_push_notification(name: str, loc_context: LocalizationContext) -> PushNotificationContent: 

145 """Renders a push notification that doesn't have an assigned topic-action.""" 

146 return _get_content(string_group=f"adhoc__{name}", loc_context=loc_context) 

147 

148 

149def _get_content( 

150 string_group: NotificationTopicAction | str, 

151 loc_context: LocalizationContext, 

152 title: str | None = None, 

153 ios_title: str | None = None, 

154 ios_subtitle: str | None = None, 

155 body: str | None = None, 

156 substitutions: dict[str, str | int] | None = None, 

157 icon_user: api_pb2.User | None = None, 

158 action_url: str | None = None, 

159) -> PushNotificationContent: 

160 """ 

161 Fills a PushNotificationContent by looking up localized 

162 string based on the topic_action key, unless other strings 

163 are provided by the caller. 

164 

165 Localized strings have the provided substitutions applied. 

166 """ 

167 # Look up the localized string for any string that was not provided 

168 if title is None: 168 ↛ 170line 168 didn't jump to line 170 because the condition on line 168 was always true

169 title = _get_string(string_group, "title", loc_context, substitutions) 

170 if ios_title is None: 

171 ios_title = _get_string(string_group, "ios_title", loc_context, substitutions) 

172 if ios_subtitle is None: 

173 try: 

174 ios_subtitle = _get_string(string_group, "ios_subtitle", loc_context, substitutions) 

175 except LocalizationError: 

176 # Not all notifications have subtitles 

177 pass 

178 if body is None: 

179 body = _get_string(string_group, "body", loc_context, substitutions) 

180 

181 icon_url = _avatar_url_or_default(icon_user) if icon_user else None 

182 

183 return PushNotificationContent( 

184 title=title, ios_title=title, ios_subtitle=ios_subtitle, body=body, icon_url=icon_url, action_url=action_url 

185 ) 

186 

187 

188def _get_string( 

189 string_group: NotificationTopicAction | str, 

190 key: str, 

191 loc_context: LocalizationContext, 

192 substitutions: dict[str, str | int] | None = None, 

193) -> str: 

194 if isinstance(string_group, NotificationTopicAction): 

195 string_group = string_group.display.replace(":", "__") 

196 key = f"push.{string_group}.{key}" 

197 return _get_notifs_i18next().localize(key, loc_context.locale, substitutions) 

198 

199 

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

201 return user.avatar_thumbnail_url or urls.icon_url() 

202 

203 

204def _render_account_deletion__start( 

205 data: notification_data_pb2.AccountDeletionStart, loc_context: LocalizationContext 

206) -> PushNotificationContent: 

207 return _get_content(NotificationTopicAction.account_deletion__start, loc_context) 

208 

209 

210def _render_account_deletion__complete( 

211 data: notification_data_pb2.AccountDeletionComplete, loc_context: LocalizationContext 

212) -> PushNotificationContent: 

213 return _get_content( 

214 NotificationTopicAction.account_deletion__complete, loc_context, substitutions={"count": data.undelete_days} 

215 ) 

216 

217 

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

219 return _get_content(NotificationTopicAction.account_deletion__recovered, loc_context) 

220 

221 

222def _render_activeness__probe( 

223 data: notification_data_pb2.ActivenessProbe, loc_context: LocalizationContext 

224) -> PushNotificationContent: 

225 return _get_content(NotificationTopicAction.activeness__probe, loc_context) 

226 

227 

228def _render_api_key__create( 

229 data: notification_data_pb2.ApiKeyCreate, loc_context: LocalizationContext 

230) -> PushNotificationContent: 

231 return _get_content(NotificationTopicAction.api_key__create, loc_context) 

232 

233 

234def _render_badge__add( 

235 data: notification_data_pb2.BadgeAdd, loc_context: LocalizationContext 

236) -> PushNotificationContent: 

237 return _get_content( 

238 NotificationTopicAction.badge__add, 

239 loc_context, 

240 substitutions={"badge_name": data.badge_name}, 

241 action_url=urls.profile_link(), 

242 ) 

243 

244 

245def _render_badge__remove( 

246 data: notification_data_pb2.BadgeRemove, loc_context: LocalizationContext 

247) -> PushNotificationContent: 

248 return _get_content( 

249 NotificationTopicAction.badge__remove, 

250 loc_context, 

251 substitutions={"badge_name": data.badge_name}, 

252 action_url=urls.profile_link(), 

253 ) 

254 

255 

256def _render_birthdate__change( 

257 data: notification_data_pb2.BirthdateChange, loc_context: LocalizationContext 

258) -> PushNotificationContent: 

259 return _get_content( 

260 NotificationTopicAction.birthdate__change, 

261 loc_context, 

262 substitutions={"birthdate": loc_context.localize_date_from_iso(data.birthdate)}, 

263 action_url=urls.account_settings_link(), 

264 ) 

265 

266 

267def _render_chat__message( 

268 data: notification_data_pb2.ChatMessage, loc_context: LocalizationContext 

269) -> PushNotificationContent: 

270 # All strings are dynamic, no need to use _get_content 

271 return PushNotificationContent( 

272 title=data.author.name, 

273 ios_title=data.author.name, 

274 body=data.text, 

275 icon_url=_avatar_url_or_default(data.author), 

276 action_url=urls.chat_link(chat_id=data.group_chat_id), 

277 ) 

278 

279 

280def _render_chat__missed_messages( 

281 data: notification_data_pb2.ChatMissedMessages, loc_context: LocalizationContext 

282) -> PushNotificationContent: 

283 return _get_content( 

284 NotificationTopicAction.chat__missed_messages, 

285 loc_context, 

286 substitutions={"count": len(data.messages)}, 

287 action_url=urls.messages_link(), 

288 ) 

289 

290 

291def _render_donation__received( 

292 data: notification_data_pb2.DonationReceived, loc_context: LocalizationContext 

293) -> PushNotificationContent: 

294 return _get_content( 

295 NotificationTopicAction.donation__received, 

296 loc_context, 

297 # Other currencies are not yet supported 

298 substitutions={"amount_with_currency": f"${data.amount}"}, 

299 action_url=data.receipt_url, 

300 ) 

301 

302 

303def _render_discussion__create( 

304 data: notification_data_pb2.DiscussionCreate, loc_context: LocalizationContext 

305) -> PushNotificationContent: 

306 return _get_content( 

307 NotificationTopicAction.discussion__create, 

308 loc_context, 

309 substitutions={ 

310 "title": data.discussion.title, 

311 "user": data.author.name, 

312 "group_or_community": data.discussion.owner_title, 

313 }, 

314 icon_user=data.author, 

315 action_url=urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug), 

316 ) 

317 

318 

319def _render_discussion__comment( 

320 data: notification_data_pb2.DiscussionComment, loc_context: LocalizationContext 

321) -> PushNotificationContent: 

322 return _get_content( 

323 NotificationTopicAction.discussion__comment, 

324 loc_context, 

325 ios_title=data.author.name, 

326 ios_subtitle=data.discussion.title, 

327 body=data.reply.content, 

328 substitutions={"user": data.author.name, "title": data.discussion.title}, 

329 icon_user=data.author, 

330 action_url=urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug), 

331 ) 

332 

333 

334def _render_email_address__change( 

335 data: notification_data_pb2.EmailAddressChange, loc_context: LocalizationContext 

336) -> PushNotificationContent: 

337 return _get_content( 

338 NotificationTopicAction.email_address__change, 

339 loc_context, 

340 substitutions={"email": data.new_email}, 

341 action_url=urls.account_settings_link(), 

342 ) 

343 

344 

345def _render_email_address__verify(loc_context: LocalizationContext) -> PushNotificationContent: 

346 return _get_content( 

347 NotificationTopicAction.email_address__verify, 

348 loc_context, 

349 action_url=urls.account_settings_link(), 

350 ) 

351 

352 

353def _render_event__create_any( 

354 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

355) -> PushNotificationContent: 

356 return _get_content( 

357 NotificationTopicAction.event__create_any, 

358 loc_context, 

359 substitutions={ 

360 "title": data.event.title, 

361 "user": data.inviting_user.name, 

362 "date_and_time": loc_context.localize_datetime(data.event.start_time), 

363 }, 

364 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

365 ) 

366 

367 

368def _render_event__create_approved( 

369 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

370) -> PushNotificationContent: 

371 return _get_content( 

372 NotificationTopicAction.event__create_approved, 

373 loc_context, 

374 substitutions={ 

375 "title": data.event.title, 

376 "user": data.inviting_user.name, 

377 "date_and_time": loc_context.localize_datetime(data.event.start_time), 

378 }, 

379 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

380 ) 

381 

382 

383def _render_event__update( 

384 data: notification_data_pb2.EventUpdate, loc_context: LocalizationContext 

385) -> PushNotificationContent: 

386 # updated_items can include: title, content, start_time, end_time, location, 

387 # but a list like that is tricky to localize. 

388 return _get_content( 

389 NotificationTopicAction.event__update, 

390 loc_context, 

391 substitutions={ 

392 "title": data.event.title, 

393 "user": data.updating_user.name, 

394 }, 

395 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

396 ) 

397 

398 

399def _render_event__invite_organizer( 

400 data: notification_data_pb2.EventInviteOrganizer, loc_context: LocalizationContext 

401) -> PushNotificationContent: 

402 return _get_content( 

403 NotificationTopicAction.event__invite_organizer, 

404 loc_context, 

405 substitutions={ 

406 "title": data.event.title, 

407 "user": data.inviting_user.name, 

408 }, 

409 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

410 ) 

411 

412 

413def _render_event__comment( 

414 data: notification_data_pb2.EventComment, loc_context: LocalizationContext 

415) -> PushNotificationContent: 

416 return _get_content( 

417 NotificationTopicAction.event__comment, 

418 loc_context, 

419 substitutions={ 

420 "title": data.event.title, 

421 "user": data.author.name, 

422 }, 

423 body=data.reply.content, 

424 icon_user=data.author, 

425 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

426 ) 

427 

428 

429def _render_event__reminder( 

430 data: notification_data_pb2.EventReminder, loc_context: LocalizationContext 

431) -> PushNotificationContent: 

432 return _get_content( 

433 NotificationTopicAction.event__reminder, 

434 loc_context, 

435 substitutions={ 

436 "title": data.event.title, 

437 "date_and_time": loc_context.localize_datetime(data.event.start_time), 

438 }, 

439 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

440 ) 

441 

442 

443def _render_event__cancel( 

444 data: notification_data_pb2.EventCancel, loc_context: LocalizationContext 

445) -> PushNotificationContent: 

446 return _get_content( 

447 NotificationTopicAction.event__cancel, 

448 loc_context, 

449 substitutions={ 

450 "title": data.event.title, 

451 "user": data.cancelling_user.name, 

452 }, 

453 action_url=urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug), 

454 ) 

455 

456 

457def _render_event__delete( 

458 data: notification_data_pb2.EventDelete, loc_context: LocalizationContext 

459) -> PushNotificationContent: 

460 return _get_content(NotificationTopicAction.event__delete, loc_context, substitutions={"title": data.event.title}) 

461 

462 

463def _render_friend_request__create( 

464 data: notification_data_pb2.FriendRequestCreate, loc_context: LocalizationContext 

465) -> PushNotificationContent: 

466 return _get_content( 

467 NotificationTopicAction.friend_request__create, 

468 loc_context, 

469 substitutions={"from_user": data.other_user.name}, 

470 icon_user=data.other_user, 

471 action_url=urls.friend_requests_link(), 

472 ) 

473 

474 

475def _render_friend_request__accept( 

476 data: notification_data_pb2.FriendRequestAccept, loc_context: LocalizationContext 

477) -> PushNotificationContent: 

478 return _get_content( 

479 NotificationTopicAction.friend_request__accept, 

480 loc_context, 

481 substitutions={"friend": data.other_user.name}, 

482 icon_user=data.other_user, 

483 action_url=urls.user_link(username=data.other_user.username), 

484 ) 

485 

486 

487def _render_gender__change( 

488 data: notification_data_pb2.GenderChange, loc_context: LocalizationContext 

489) -> PushNotificationContent: 

490 return _get_content( 

491 NotificationTopicAction.gender__change, 

492 loc_context, 

493 substitutions={"gender": data.gender}, 

494 action_url=urls.account_settings_link(), 

495 ) 

496 

497 

498def _render_general__new_blog_post( 

499 data: notification_data_pb2.GeneralNewBlogPost, loc_context: LocalizationContext 

500) -> PushNotificationContent: 

501 return _get_content( 

502 NotificationTopicAction.general__new_blog_post, 

503 loc_context, 

504 body=data.blurb, 

505 substitutions={"title": data.title}, 

506 action_url=data.url, 

507 ) 

508 

509 

510def _render_host_request__create( 

511 data: notification_data_pb2.HostRequestCreate, loc_context: LocalizationContext 

512) -> PushNotificationContent: 

513 days = (date.fromisoformat(data.host_request.to_date) - date.fromisoformat(data.host_request.from_date)).days + 1 

514 return _get_content( 

515 NotificationTopicAction.host_request__create, 

516 loc_context, 

517 substitutions={ 

518 "user": data.surfer.name, 

519 "start_date": loc_context.localize_date_from_iso(data.host_request.from_date), 

520 "count": days, 

521 }, 

522 icon_user=data.surfer, 

523 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

524 ) 

525 

526 

527def _render_host_request__message( 

528 data: notification_data_pb2.HostRequestMessage, loc_context: LocalizationContext 

529) -> PushNotificationContent: 

530 # All strings are dynamic, no need to use _get_content 

531 return PushNotificationContent( 

532 title=data.user.name, 

533 ios_title=data.user.name, 

534 body=data.text, 

535 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

536 icon_url=_avatar_url_or_default(data.user), 

537 ) 

538 

539 

540def _render_host_request__missed_messages( 

541 data: notification_data_pb2.HostRequestMissedMessages, loc_context: LocalizationContext 

542) -> PushNotificationContent: 

543 return _get_content( 

544 NotificationTopicAction.host_request__missed_messages, 

545 loc_context, 

546 substitutions={"user": data.user.name}, 

547 icon_user=data.user, 

548 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

549 ) 

550 

551 

552def _render_host_request__reminder( 

553 data: notification_data_pb2.HostRequestReminder, loc_context: LocalizationContext 

554) -> PushNotificationContent: 

555 return _get_content( 

556 NotificationTopicAction.host_request__reminder, 

557 loc_context, 

558 substitutions={"user": data.surfer.name}, 

559 icon_user=data.surfer, 

560 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

561 ) 

562 

563 

564def _render_host_request__accept( 

565 data: notification_data_pb2.HostRequestAccept, loc_context: LocalizationContext 

566) -> PushNotificationContent: 

567 return _get_content( 

568 NotificationTopicAction.host_request__accept, 

569 loc_context, 

570 substitutions={ 

571 "user": data.host.name, 

572 "date": loc_context.localize_date_from_iso(data.host_request.from_date), 

573 }, 

574 icon_user=data.host, 

575 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

576 ) 

577 

578 

579def _render_host_request__reject( 

580 data: notification_data_pb2.HostRequestReject, loc_context: LocalizationContext 

581) -> PushNotificationContent: 

582 return _get_content( 

583 NotificationTopicAction.host_request__reject, 

584 loc_context, 

585 substitutions={ 

586 "user": data.host.name, 

587 "date": loc_context.localize_date_from_iso(data.host_request.from_date), 

588 }, 

589 icon_user=data.host, 

590 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

591 ) 

592 

593 

594def _render_host_request__cancel( 

595 data: notification_data_pb2.HostRequestCancel, loc_context: LocalizationContext 

596) -> PushNotificationContent: 

597 return _get_content( 

598 NotificationTopicAction.host_request__cancel, 

599 loc_context, 

600 substitutions={ 

601 "user": data.surfer.name, 

602 "date": loc_context.localize_date_from_iso(data.host_request.from_date), 

603 }, 

604 icon_user=data.surfer, 

605 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

606 ) 

607 

608 

609def _render_host_request__confirm( 

610 data: notification_data_pb2.HostRequestConfirm, loc_context: LocalizationContext 

611) -> PushNotificationContent: 

612 return _get_content( 

613 NotificationTopicAction.host_request__confirm, 

614 loc_context, 

615 substitutions={ 

616 "user": data.surfer.name, 

617 "date": loc_context.localize_date_from_iso(data.host_request.from_date), 

618 }, 

619 icon_user=data.surfer, 

620 action_url=urls.host_request(host_request_id=data.host_request.host_request_id), 

621 ) 

622 

623 

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

625 return _get_content(NotificationTopicAction.modnote__create, loc_context) 

626 

627 

628def _render_onboarding__reminder(key: str, loc_context: LocalizationContext) -> PushNotificationContent: 

629 string_group = NotificationTopicAction.onboarding__reminder.display.replace(":", "__") 

630 string_group += "." 

631 string_group += "first" if key == "1" else "subsequent" 

632 return _get_content( 

633 string_group, 

634 loc_context, 

635 action_url=urls.edit_profile_link(), 

636 ) 

637 

638 

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

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

641 

642 

643def _render_password_reset__start( 

644 data: notification_data_pb2.PasswordResetStart, loc_context: LocalizationContext 

645) -> PushNotificationContent: 

646 return _get_content( 

647 NotificationTopicAction.password_reset__start, 

648 loc_context, 

649 action_url=urls.account_settings_link(), 

650 ) 

651 

652 

653def _render_password_reset__complete(loc_context: LocalizationContext) -> PushNotificationContent: 

654 return _get_content( 

655 NotificationTopicAction.password_reset__complete, 

656 loc_context, 

657 action_url=urls.account_settings_link(), 

658 ) 

659 

660 

661def _render_phone_number__change( 

662 data: notification_data_pb2.PhoneNumberChange, loc_context: LocalizationContext 

663) -> PushNotificationContent: 

664 return _get_content( 

665 NotificationTopicAction.phone_number__change, 

666 loc_context, 

667 substitutions={"phone_number": format_phone_number(data.phone)}, 

668 action_url=urls.account_settings_link(), 

669 ) 

670 

671 

672def _render_phone_number__verify( 

673 data: notification_data_pb2.PhoneNumberVerify, loc_context: LocalizationContext 

674) -> PushNotificationContent: 

675 return _get_content( 

676 NotificationTopicAction.phone_number__verify, 

677 loc_context, 

678 substitutions={"phone_number": format_phone_number(data.phone)}, 

679 action_url=urls.account_settings_link(), 

680 ) 

681 

682 

683def _render_postal_verification__postcard_sent( 

684 data: notification_data_pb2.PostalVerificationPostcardSent, loc_context: LocalizationContext 

685) -> PushNotificationContent: 

686 return _get_content( 

687 NotificationTopicAction.postal_verification__postcard_sent, 

688 loc_context, 

689 substitutions={"city": data.city, "country": data.country}, 

690 action_url=urls.account_settings_link(), 

691 ) 

692 

693 

694def _render_postal_verification__success(loc_context: LocalizationContext) -> PushNotificationContent: 

695 return _get_content( 

696 NotificationTopicAction.postal_verification__success, 

697 loc_context, 

698 action_url=urls.account_settings_link(), 

699 ) 

700 

701 

702def _render_postal_verification__failed( 

703 data: notification_data_pb2.PostalVerificationFailed, loc_context: LocalizationContext 

704) -> PushNotificationContent: 

705 body_key: str 

706 match data.reason: 

707 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

708 body_key = "body_code_expired" 

709 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

710 body_key = "body_too_many_attempts" 

711 case _: 

712 body_key = "body_generic" 

713 

714 return _get_content( 

715 NotificationTopicAction.postal_verification__failed, 

716 loc_context, 

717 body=_get_string(NotificationTopicAction.postal_verification__failed, body_key, loc_context), 

718 action_url=urls.account_settings_link(), 

719 ) 

720 

721 

722def _render_reference__receive_friend( 

723 data: notification_data_pb2.ReferenceReceiveFriend, loc_context: LocalizationContext 

724) -> PushNotificationContent: 

725 return _get_content( 

726 NotificationTopicAction.reference__receive_friend, 

727 loc_context, 

728 body=data.text, 

729 substitutions={"user": data.from_user.name}, 

730 icon_user=data.from_user, 

731 action_url=urls.profile_references_link(), 

732 ) 

733 

734 

735def _render_reference__receive( 

736 data: notification_data_pb2.ReferenceReceiveHostRequest, leave_reference_type: str, loc_context: LocalizationContext 

737) -> PushNotificationContent: 

738 body: str 

739 if data.text: 739 ↛ 740line 739 didn't jump to line 740 because the condition on line 739 was never true

740 body = data.text 

741 action_url = urls.profile_references_link() 

742 else: 

743 body = _get_string( 

744 "reference__receive", 

745 "body_must_write_yours", 

746 loc_context, 

747 substitutions={"user": data.from_user.name}, 

748 ) 

749 action_url = urls.leave_reference_link( 

750 reference_type=leave_reference_type, 

751 to_user_id=data.from_user.user_id, 

752 host_request_id=str(data.host_request_id), 

753 ) 

754 return _get_content( 

755 string_group="reference__receive", 

756 loc_context=loc_context, 

757 body=body, 

758 substitutions={"user": data.from_user.name}, 

759 icon_user=data.from_user, 

760 action_url=action_url, 

761 ) 

762 

763 

764def _render_reference__receive_hosted( 

765 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

766) -> PushNotificationContent: 

767 # Receiving a hosted reminder means I need to leave a surfed reference 

768 return _render_reference__receive(data, leave_reference_type="surfed", loc_context=loc_context) 

769 

770 

771def _render_reference__receive_surfed( 

772 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

773) -> PushNotificationContent: 

774 return _render_reference__receive(data, leave_reference_type="hosted", loc_context=loc_context) 

775 

776 

777def _render_reference__reminder( 

778 data: notification_data_pb2.ReferenceReminder, leave_reference_type: str, loc_context: LocalizationContext 

779) -> PushNotificationContent: 

780 leave_reference_link = urls.leave_reference_link( 

781 reference_type=leave_reference_type, 

782 to_user_id=data.other_user.user_id, 

783 host_request_id=str(data.host_request_id), 

784 ) 

785 return _get_content( 

786 string_group="reference__reminder", 

787 loc_context=loc_context, 

788 substitutions={"count": data.days_left, "user": data.other_user.name}, 

789 icon_user=data.other_user, 

790 action_url=leave_reference_link, 

791 ) 

792 

793 

794def _render_reference__reminder_surfed( 

795 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

796) -> PushNotificationContent: 

797 # Surfed reminder means I need to leave a surfed reference 

798 return _render_reference__reminder(data, leave_reference_type="surfed", loc_context=loc_context) 

799 

800 

801def _render_reference__reminder_hosted( 

802 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

803) -> PushNotificationContent: 

804 return _render_reference__reminder(data, leave_reference_type="hosted", loc_context=loc_context) 

805 

806 

807def _render_thread__reply( 

808 data: notification_data_pb2.ThreadReply, loc_context: LocalizationContext 

809) -> PushNotificationContent: 

810 parent_title: str 

811 view_link: str 

812 match data.WhichOneof("reply_parent"): 

813 case "event": 

814 parent_title = data.event.title 

815 view_link = urls.event_link(occurrence_id=data.event.event_id, slug=data.event.slug) 

816 case "discussion": 816 ↛ 819line 816 didn't jump to line 819 because the pattern on line 816 always matched

817 parent_title = data.discussion.title 

818 view_link = urls.discussion_link(discussion_id=data.discussion.discussion_id, slug=data.discussion.slug) 

819 case _: 

820 raise Exception("Can only do replies to events and discussions") 

821 

822 return _get_content( 

823 NotificationTopicAction.thread__reply, 

824 loc_context=loc_context, 

825 body=data.reply.content, 

826 substitutions={"user": data.author.name, "title": parent_title}, 

827 icon_user=data.author, 

828 action_url=view_link, 

829 ) 

830 

831 

832def _render_verification__sv_success(loc_context: LocalizationContext) -> PushNotificationContent: 

833 return _get_content( 

834 NotificationTopicAction.verification__sv_success, 

835 loc_context, 

836 action_url=urls.account_settings_link(), 

837 ) 

838 

839 

840def _render_verification__sv_fail( 

841 data: notification_data_pb2.VerificationSVFail, loc_context: LocalizationContext 

842) -> PushNotificationContent: 

843 body_key: str 

844 match data.reason: 

845 case notification_data_pb2.SV_FAIL_REASON_WRONG_BIRTHDATE_OR_GENDER: 845 ↛ 846line 845 didn't jump to line 846 because the pattern on line 845 never matched

846 body_key = "body_wrong_birthdate_gender" 

847 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

848 body_key = "body_not_a_passport" 

849 case notification_data_pb2.SV_FAIL_REASON_DUPLICATE: 849 ↛ 851line 849 didn't jump to line 851 because the pattern on line 849 always matched

850 body_key = "body_duplicate" 

851 case _: 

852 raise Exception("Shouldn't get here") 

853 

854 return _get_content( 

855 NotificationTopicAction.verification__sv_fail, 

856 loc_context, 

857 body=_get_string(NotificationTopicAction.verification__sv_fail, body_key, loc_context), 

858 action_url=urls.account_settings_link(), 

859 ) 

860 

861 

862@lru_cache(maxsize=1) 

863def _get_notifs_i18next() -> I18Next: 

864 """Gets the I18Next instance for notifications.""" 

865 return load_locales(Path(__file__).parent / "locales")