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

291 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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 

8 

9from couchers import urls 

10from couchers.i18n import LocalizationContext 

11from couchers.i18n.i18next import LocalizationError 

12from couchers.i18n.localize import format_phone_number 

13from couchers.models import Notification, NotificationTopicAction 

14from couchers.notifications.locales import get_notifs_i18next 

15from couchers.notifications.push import PushNotificationContent 

16from couchers.proto import api_pb2, notification_data_pb2 

17 

18logger = logging.getLogger(__name__) 

19 

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

21 

22 

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

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

25 

26 match notification.topic_action: 

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

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

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

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

31 case NotificationTopicAction.account_deletion__start: 

32 return _render_account_deletion__start(data, loc_context) 

33 case NotificationTopicAction.account_deletion__complete: 

34 return _render_account_deletion__complete(data, loc_context) 

35 case NotificationTopicAction.account_deletion__recovered: 

36 return _render_account_deletion__recovered(loc_context) 

37 case NotificationTopicAction.activeness__probe: 

38 return _render_activeness__probe(data, loc_context) 

39 case NotificationTopicAction.api_key__create: 

40 return _render_api_key__create(data, loc_context) 

41 case NotificationTopicAction.badge__add: 

42 return _render_badge__add(data, loc_context) 

43 case NotificationTopicAction.badge__remove: 

44 return _render_badge__remove(data, loc_context) 

45 case NotificationTopicAction.birthdate__change: 

46 return _render_birthdate__change(data, loc_context) 

47 case NotificationTopicAction.chat__message: 

48 return _render_chat__message(data, loc_context) 

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

50 return _render_chat__missed_messages(data, loc_context) 

51 case NotificationTopicAction.donation__received: 

52 return _render_donation__received(data, loc_context) 

53 case NotificationTopicAction.discussion__create: 

54 return _render_discussion__create(data, loc_context) 

55 case NotificationTopicAction.discussion__comment: 

56 return _render_discussion__comment(data, loc_context) 

57 case NotificationTopicAction.email_address__change: 

58 return _render_email_address__change(data, loc_context) 

59 case NotificationTopicAction.email_address__verify: 

60 return _render_email_address__verify(loc_context) 

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

62 return _render_event__create_any(data, loc_context) 

63 case NotificationTopicAction.event__create_approved: 

64 return _render_event__create_approved(data, loc_context) 

65 case NotificationTopicAction.event__update: 

66 return _render_event__update(data, loc_context) 

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

68 return _render_event__invite_organizer(data, loc_context) 

69 case NotificationTopicAction.event__comment: 

70 return _render_event__comment(data, loc_context) 

71 case NotificationTopicAction.event__reminder: 

72 return _render_event__reminder(data, loc_context) 

73 case NotificationTopicAction.event__cancel: 

74 return _render_event__cancel(data, loc_context) 

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

76 return _render_event__delete(data, loc_context) 

77 case NotificationTopicAction.friend_request__create: 

78 return _render_friend_request__create(data, loc_context) 

79 case NotificationTopicAction.friend_request__accept: 

80 return _render_friend_request__accept(data, loc_context) 

81 case NotificationTopicAction.gender__change: 

82 return _render_gender__change(data, loc_context) 

83 case NotificationTopicAction.general__new_blog_post: 

84 return _render_general__new_blog_post(data, loc_context) 

85 case NotificationTopicAction.host_request__create: 

86 return _render_host_request__create(data, loc_context) 

87 case NotificationTopicAction.host_request__message: 

88 return _render_host_request__message(data, loc_context) 

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

90 return _render_host_request__missed_messages(data, loc_context) 

91 case NotificationTopicAction.host_request__reminder: 

92 return _render_host_request__reminder(data, loc_context) 

93 case NotificationTopicAction.host_request__accept: 

94 return _render_host_request__accept(data, loc_context) 

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

96 return _render_host_request__reject(data, loc_context) 

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

98 return _render_host_request__cancel(data, loc_context) 

99 case NotificationTopicAction.host_request__confirm: 

100 return _render_host_request__confirm(data, loc_context) 

101 case NotificationTopicAction.modnote__create: 

102 return _render_modnote__create(loc_context) 

103 case NotificationTopicAction.onboarding__reminder: 

104 return _render_onboarding__reminder(notification.key, loc_context) 

105 case NotificationTopicAction.password__change: 

106 return _render_password__change(loc_context) 

107 case NotificationTopicAction.password_reset__start: 

108 return _render_password_reset__start(data, loc_context) 

109 case NotificationTopicAction.password_reset__complete: 

110 return _render_password_reset__complete(loc_context) 

111 case NotificationTopicAction.phone_number__change: 

112 return _render_phone_number__change(data, loc_context) 

113 case NotificationTopicAction.phone_number__verify: 

114 return _render_phone_number__verify(data, loc_context) 

115 case NotificationTopicAction.postal_verification__postcard_sent: 

116 return _render_postal_verification__postcard_sent(data, loc_context) 

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

118 return _render_postal_verification__success(loc_context) 

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

120 return _render_postal_verification__failed(data, loc_context) 

121 case NotificationTopicAction.reference__receive_friend: 

122 return _render_reference__receive_friend(data, loc_context) 

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

124 return _render_reference__receive_hosted(data, loc_context) 

125 case NotificationTopicAction.reference__receive_surfed: 

126 return _render_reference__receive_surfed(data, loc_context) 

127 case NotificationTopicAction.reference__reminder_hosted: 

128 return _render_reference__reminder_hosted(data, loc_context) 

129 case NotificationTopicAction.reference__reminder_surfed: 

130 return _render_reference__reminder_surfed(data, loc_context) 

131 case NotificationTopicAction.thread__reply: 

132 return _render_thread__reply(data, loc_context) 

133 case NotificationTopicAction.verification__sv_success: 

134 return _render_verification__sv_success(loc_context) 

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

136 return _render_verification__sv_fail(data, loc_context) 

137 case _: 

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

139 assert_never(notification.topic_action) 

140 

141 

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

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

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

145 

146 

147def _get_content( 

148 string_group: NotificationTopicAction | str, 

149 loc_context: LocalizationContext, 

150 title: str | None = None, 

151 ios_title: str | None = None, 

152 ios_subtitle: str | None = None, 

153 body: str | None = None, 

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

155 icon_user: api_pb2.User | None = None, 

156 action_url: str | None = None, 

157) -> PushNotificationContent: 

158 """ 

159 Fills a PushNotificationContent by looking up localized 

160 string based on the topic_action key, unless other strings 

161 are provided by the caller. 

162 

163 Localized strings have the provided substitutions applied. 

164 """ 

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

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

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

168 if ios_title is None: 

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

170 if ios_subtitle is None: 

171 try: 

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

173 except LocalizationError: 

174 # Not all notifications have subtitles 

175 pass 

176 if body is None: 

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

178 

179 icon_url = _avatar_url_or_default(icon_user) if icon_user else None 

180 

181 return PushNotificationContent( 

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

183 ) 

184 

185 

186def _get_string( 

187 string_group: NotificationTopicAction | str, 

188 key: str, 

189 loc_context: LocalizationContext, 

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

191) -> str: 

192 if isinstance(string_group, NotificationTopicAction): 

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

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

195 return get_notifs_i18next().localize(key, loc_context.locale, substitutions) 

196 

197 

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

199 return user.avatar_thumbnail_url or urls.icon_url() 

200 

201 

202def _render_account_deletion__start( 

203 data: notification_data_pb2.AccountDeletionStart, loc_context: LocalizationContext 

204) -> PushNotificationContent: 

205 return _get_content(NotificationTopicAction.account_deletion__start, loc_context) 

206 

207 

208def _render_account_deletion__complete( 

209 data: notification_data_pb2.AccountDeletionComplete, loc_context: LocalizationContext 

210) -> PushNotificationContent: 

211 return _get_content( 

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

213 ) 

214 

215 

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

217 return _get_content(NotificationTopicAction.account_deletion__recovered, loc_context) 

218 

219 

220def _render_activeness__probe( 

221 data: notification_data_pb2.ActivenessProbe, loc_context: LocalizationContext 

222) -> PushNotificationContent: 

223 return _get_content(NotificationTopicAction.activeness__probe, loc_context) 

224 

225 

226def _render_api_key__create( 

227 data: notification_data_pb2.ApiKeyCreate, loc_context: LocalizationContext 

228) -> PushNotificationContent: 

229 return _get_content(NotificationTopicAction.api_key__create, loc_context) 

230 

231 

232def _render_badge__add( 

233 data: notification_data_pb2.BadgeAdd, loc_context: LocalizationContext 

234) -> PushNotificationContent: 

235 return _get_content( 

236 NotificationTopicAction.badge__add, 

237 loc_context, 

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

239 action_url=urls.profile_link(), 

240 ) 

241 

242 

243def _render_badge__remove( 

244 data: notification_data_pb2.BadgeRemove, loc_context: LocalizationContext 

245) -> PushNotificationContent: 

246 return _get_content( 

247 NotificationTopicAction.badge__remove, 

248 loc_context, 

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

250 action_url=urls.profile_link(), 

251 ) 

252 

253 

254def _render_birthdate__change( 

255 data: notification_data_pb2.BirthdateChange, loc_context: LocalizationContext 

256) -> PushNotificationContent: 

257 return _get_content( 

258 NotificationTopicAction.birthdate__change, 

259 loc_context, 

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

261 action_url=urls.account_settings_link(), 

262 ) 

263 

264 

265def _render_chat__message( 

266 data: notification_data_pb2.ChatMessage, loc_context: LocalizationContext 

267) -> PushNotificationContent: 

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

269 return PushNotificationContent( 

270 title=data.author.name, 

271 ios_title=data.author.name, 

272 body=data.text, 

273 icon_url=_avatar_url_or_default(data.author), 

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

275 ) 

276 

277 

278def _render_chat__missed_messages( 

279 data: notification_data_pb2.ChatMissedMessages, loc_context: LocalizationContext 

280) -> PushNotificationContent: 

281 return _get_content( 

282 NotificationTopicAction.chat__missed_messages, 

283 loc_context, 

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

285 action_url=urls.messages_link(), 

286 ) 

287 

288 

289def _render_donation__received( 

290 data: notification_data_pb2.DonationReceived, loc_context: LocalizationContext 

291) -> PushNotificationContent: 

292 return _get_content( 

293 NotificationTopicAction.donation__received, 

294 loc_context, 

295 # Other currencies are not yet supported 

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

297 action_url=data.receipt_url, 

298 ) 

299 

300 

301def _render_discussion__create( 

302 data: notification_data_pb2.DiscussionCreate, loc_context: LocalizationContext 

303) -> PushNotificationContent: 

304 return _get_content( 

305 NotificationTopicAction.discussion__create, 

306 loc_context, 

307 substitutions={ 

308 "title": data.discussion.title, 

309 "user": data.author.name, 

310 "group_or_community": data.discussion.owner_title, 

311 }, 

312 icon_user=data.author, 

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

314 ) 

315 

316 

317def _render_discussion__comment( 

318 data: notification_data_pb2.DiscussionComment, loc_context: LocalizationContext 

319) -> PushNotificationContent: 

320 return _get_content( 

321 NotificationTopicAction.discussion__comment, 

322 loc_context, 

323 ios_title=data.author.name, 

324 ios_subtitle=data.discussion.title, 

325 body=data.reply.content, 

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

327 icon_user=data.author, 

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

329 ) 

330 

331 

332def _render_email_address__change( 

333 data: notification_data_pb2.EmailAddressChange, loc_context: LocalizationContext 

334) -> PushNotificationContent: 

335 return _get_content( 

336 NotificationTopicAction.email_address__change, 

337 loc_context, 

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

339 action_url=urls.account_settings_link(), 

340 ) 

341 

342 

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

344 return _get_content( 

345 NotificationTopicAction.email_address__verify, 

346 loc_context, 

347 action_url=urls.account_settings_link(), 

348 ) 

349 

350 

351def _render_event__create_any( 

352 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

353) -> PushNotificationContent: 

354 return _get_content( 

355 NotificationTopicAction.event__create_any, 

356 loc_context, 

357 substitutions={ 

358 "title": data.event.title, 

359 "user": data.inviting_user.name, 

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

361 }, 

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

363 ) 

364 

365 

366def _render_event__create_approved( 

367 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

368) -> PushNotificationContent: 

369 return _get_content( 

370 NotificationTopicAction.event__create_approved, 

371 loc_context, 

372 substitutions={ 

373 "title": data.event.title, 

374 "user": data.inviting_user.name, 

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

376 }, 

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

378 ) 

379 

380 

381def _render_event__update( 

382 data: notification_data_pb2.EventUpdate, loc_context: LocalizationContext 

383) -> PushNotificationContent: 

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

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

386 return _get_content( 

387 NotificationTopicAction.event__update, 

388 loc_context, 

389 substitutions={ 

390 "title": data.event.title, 

391 "user": data.updating_user.name, 

392 }, 

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

394 ) 

395 

396 

397def _render_event__invite_organizer( 

398 data: notification_data_pb2.EventInviteOrganizer, loc_context: LocalizationContext 

399) -> PushNotificationContent: 

400 return _get_content( 

401 NotificationTopicAction.event__invite_organizer, 

402 loc_context, 

403 substitutions={ 

404 "title": data.event.title, 

405 "user": data.inviting_user.name, 

406 }, 

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

408 ) 

409 

410 

411def _render_event__comment( 

412 data: notification_data_pb2.EventComment, loc_context: LocalizationContext 

413) -> PushNotificationContent: 

414 return _get_content( 

415 NotificationTopicAction.event__comment, 

416 loc_context, 

417 substitutions={ 

418 "title": data.event.title, 

419 "user": data.author.name, 

420 }, 

421 body=data.reply.content, 

422 icon_user=data.author, 

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

424 ) 

425 

426 

427def _render_event__reminder( 

428 data: notification_data_pb2.EventReminder, loc_context: LocalizationContext 

429) -> PushNotificationContent: 

430 return _get_content( 

431 NotificationTopicAction.event__reminder, 

432 loc_context, 

433 substitutions={ 

434 "title": data.event.title, 

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

436 }, 

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

438 ) 

439 

440 

441def _render_event__cancel( 

442 data: notification_data_pb2.EventCancel, loc_context: LocalizationContext 

443) -> PushNotificationContent: 

444 return _get_content( 

445 NotificationTopicAction.event__cancel, 

446 loc_context, 

447 substitutions={ 

448 "title": data.event.title, 

449 "user": data.cancelling_user.name, 

450 }, 

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

452 ) 

453 

454 

455def _render_event__delete( 

456 data: notification_data_pb2.EventDelete, loc_context: LocalizationContext 

457) -> PushNotificationContent: 

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

459 

460 

461def _render_friend_request__create( 

462 data: notification_data_pb2.FriendRequestCreate, loc_context: LocalizationContext 

463) -> PushNotificationContent: 

464 return _get_content( 

465 NotificationTopicAction.friend_request__create, 

466 loc_context, 

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

468 icon_user=data.other_user, 

469 action_url=urls.friend_requests_link(), 

470 ) 

471 

472 

473def _render_friend_request__accept( 

474 data: notification_data_pb2.FriendRequestAccept, loc_context: LocalizationContext 

475) -> PushNotificationContent: 

476 return _get_content( 

477 NotificationTopicAction.friend_request__accept, 

478 loc_context, 

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

480 icon_user=data.other_user, 

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

482 ) 

483 

484 

485def _render_gender__change( 

486 data: notification_data_pb2.GenderChange, loc_context: LocalizationContext 

487) -> PushNotificationContent: 

488 return _get_content( 

489 NotificationTopicAction.gender__change, 

490 loc_context, 

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

492 action_url=urls.account_settings_link(), 

493 ) 

494 

495 

496def _render_general__new_blog_post( 

497 data: notification_data_pb2.GeneralNewBlogPost, loc_context: LocalizationContext 

498) -> PushNotificationContent: 

499 return _get_content( 

500 NotificationTopicAction.general__new_blog_post, 

501 loc_context, 

502 body=data.blurb, 

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

504 action_url=data.url, 

505 ) 

506 

507 

508def _render_host_request__create( 

509 data: notification_data_pb2.HostRequestCreate, loc_context: LocalizationContext 

510) -> PushNotificationContent: 

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

512 return _get_content( 

513 NotificationTopicAction.host_request__create, 

514 loc_context, 

515 substitutions={ 

516 "user": data.surfer.name, 

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

518 "count": days, 

519 }, 

520 icon_user=data.surfer, 

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

522 ) 

523 

524 

525def _render_host_request__message( 

526 data: notification_data_pb2.HostRequestMessage, loc_context: LocalizationContext 

527) -> PushNotificationContent: 

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

529 return PushNotificationContent( 

530 title=data.user.name, 

531 ios_title=data.user.name, 

532 body=data.text, 

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

534 icon_url=_avatar_url_or_default(data.user), 

535 ) 

536 

537 

538def _render_host_request__missed_messages( 

539 data: notification_data_pb2.HostRequestMissedMessages, loc_context: LocalizationContext 

540) -> PushNotificationContent: 

541 return _get_content( 

542 NotificationTopicAction.host_request__missed_messages, 

543 loc_context, 

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

545 icon_user=data.user, 

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

547 ) 

548 

549 

550def _render_host_request__reminder( 

551 data: notification_data_pb2.HostRequestReminder, loc_context: LocalizationContext 

552) -> PushNotificationContent: 

553 return _get_content( 

554 NotificationTopicAction.host_request__reminder, 

555 loc_context, 

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

557 icon_user=data.surfer, 

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

559 ) 

560 

561 

562def _render_host_request__accept( 

563 data: notification_data_pb2.HostRequestAccept, loc_context: LocalizationContext 

564) -> PushNotificationContent: 

565 return _get_content( 

566 NotificationTopicAction.host_request__accept, 

567 loc_context, 

568 substitutions={ 

569 "user": data.host.name, 

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

571 }, 

572 icon_user=data.host, 

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

574 ) 

575 

576 

577def _render_host_request__reject( 

578 data: notification_data_pb2.HostRequestReject, loc_context: LocalizationContext 

579) -> PushNotificationContent: 

580 return _get_content( 

581 NotificationTopicAction.host_request__reject, 

582 loc_context, 

583 substitutions={ 

584 "user": data.host.name, 

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

586 }, 

587 icon_user=data.host, 

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

589 ) 

590 

591 

592def _render_host_request__cancel( 

593 data: notification_data_pb2.HostRequestCancel, loc_context: LocalizationContext 

594) -> PushNotificationContent: 

595 return _get_content( 

596 NotificationTopicAction.host_request__cancel, 

597 loc_context, 

598 substitutions={ 

599 "user": data.surfer.name, 

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

601 }, 

602 icon_user=data.surfer, 

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

604 ) 

605 

606 

607def _render_host_request__confirm( 

608 data: notification_data_pb2.HostRequestConfirm, loc_context: LocalizationContext 

609) -> PushNotificationContent: 

610 return _get_content( 

611 NotificationTopicAction.host_request__confirm, 

612 loc_context, 

613 substitutions={ 

614 "user": data.surfer.name, 

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

616 }, 

617 icon_user=data.surfer, 

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

619 ) 

620 

621 

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

623 return _get_content(NotificationTopicAction.modnote__create, loc_context) 

624 

625 

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

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

628 string_group += "." 

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

630 return _get_content( 

631 string_group, 

632 loc_context, 

633 action_url=urls.edit_profile_link(), 

634 ) 

635 

636 

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

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

639 

640 

641def _render_password_reset__start( 

642 data: notification_data_pb2.PasswordResetStart, loc_context: LocalizationContext 

643) -> PushNotificationContent: 

644 return _get_content( 

645 NotificationTopicAction.password_reset__start, 

646 loc_context, 

647 action_url=urls.account_settings_link(), 

648 ) 

649 

650 

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

652 return _get_content( 

653 NotificationTopicAction.password_reset__complete, 

654 loc_context, 

655 action_url=urls.account_settings_link(), 

656 ) 

657 

658 

659def _render_phone_number__change( 

660 data: notification_data_pb2.PhoneNumberChange, loc_context: LocalizationContext 

661) -> PushNotificationContent: 

662 return _get_content( 

663 NotificationTopicAction.phone_number__change, 

664 loc_context, 

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

666 action_url=urls.account_settings_link(), 

667 ) 

668 

669 

670def _render_phone_number__verify( 

671 data: notification_data_pb2.PhoneNumberVerify, loc_context: LocalizationContext 

672) -> PushNotificationContent: 

673 return _get_content( 

674 NotificationTopicAction.phone_number__verify, 

675 loc_context, 

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

677 action_url=urls.account_settings_link(), 

678 ) 

679 

680 

681def _render_postal_verification__postcard_sent( 

682 data: notification_data_pb2.PostalVerificationPostcardSent, loc_context: LocalizationContext 

683) -> PushNotificationContent: 

684 return _get_content( 

685 NotificationTopicAction.postal_verification__postcard_sent, 

686 loc_context, 

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

688 action_url=urls.account_settings_link(), 

689 ) 

690 

691 

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

693 return _get_content( 

694 NotificationTopicAction.postal_verification__success, 

695 loc_context, 

696 action_url=urls.account_settings_link(), 

697 ) 

698 

699 

700def _render_postal_verification__failed( 

701 data: notification_data_pb2.PostalVerificationFailed, loc_context: LocalizationContext 

702) -> PushNotificationContent: 

703 body_key: str 

704 match data.reason: 

705 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

706 body_key = "body_code_expired" 

707 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

708 body_key = "body_too_many_attempts" 

709 case _: 

710 body_key = "body_generic" 

711 

712 return _get_content( 

713 NotificationTopicAction.postal_verification__failed, 

714 loc_context, 

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

716 action_url=urls.account_settings_link(), 

717 ) 

718 

719 

720def _render_reference__receive_friend( 

721 data: notification_data_pb2.ReferenceReceiveFriend, loc_context: LocalizationContext 

722) -> PushNotificationContent: 

723 return _get_content( 

724 NotificationTopicAction.reference__receive_friend, 

725 loc_context, 

726 body=data.text, 

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

728 icon_user=data.from_user, 

729 action_url=urls.profile_references_link(), 

730 ) 

731 

732 

733def _render_reference__receive( 

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

735) -> PushNotificationContent: 

736 body: str 

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

738 body = data.text 

739 action_url = urls.profile_references_link() 

740 else: 

741 body = _get_string( 

742 "reference__receive", 

743 "body_must_write_yours", 

744 loc_context, 

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

746 ) 

747 action_url = urls.leave_reference_link( 

748 reference_type=leave_reference_type, 

749 to_user_id=data.from_user.user_id, 

750 host_request_id=str(data.host_request_id), 

751 ) 

752 return _get_content( 

753 string_group="reference__receive", 

754 loc_context=loc_context, 

755 body=body, 

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

757 icon_user=data.from_user, 

758 action_url=action_url, 

759 ) 

760 

761 

762def _render_reference__receive_hosted( 

763 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

764) -> PushNotificationContent: 

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

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

767 

768 

769def _render_reference__receive_surfed( 

770 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

771) -> PushNotificationContent: 

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

773 

774 

775def _render_reference__reminder( 

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

777) -> PushNotificationContent: 

778 leave_reference_link = urls.leave_reference_link( 

779 reference_type=leave_reference_type, 

780 to_user_id=data.other_user.user_id, 

781 host_request_id=str(data.host_request_id), 

782 ) 

783 return _get_content( 

784 string_group="reference__reminder", 

785 loc_context=loc_context, 

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

787 icon_user=data.other_user, 

788 action_url=leave_reference_link, 

789 ) 

790 

791 

792def _render_reference__reminder_surfed( 

793 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

794) -> PushNotificationContent: 

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

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

797 

798 

799def _render_reference__reminder_hosted( 

800 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

801) -> PushNotificationContent: 

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

803 

804 

805def _render_thread__reply( 

806 data: notification_data_pb2.ThreadReply, loc_context: LocalizationContext 

807) -> PushNotificationContent: 

808 parent_title: str 

809 view_link: str 

810 match data.WhichOneof("reply_parent"): 

811 case "event": 

812 parent_title = data.event.title 

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

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

815 parent_title = data.discussion.title 

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

817 case _: 

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

819 

820 return _get_content( 

821 NotificationTopicAction.thread__reply, 

822 loc_context=loc_context, 

823 body=data.reply.content, 

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

825 icon_user=data.author, 

826 action_url=view_link, 

827 ) 

828 

829 

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

831 return _get_content( 

832 NotificationTopicAction.verification__sv_success, 

833 loc_context, 

834 action_url=urls.account_settings_link(), 

835 ) 

836 

837 

838def _render_verification__sv_fail( 

839 data: notification_data_pb2.VerificationSVFail, loc_context: LocalizationContext 

840) -> PushNotificationContent: 

841 body_key: str 

842 match data.reason: 

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

844 body_key = "body_wrong_birthdate_gender" 

845 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

846 body_key = "body_not_a_passport" 

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

848 body_key = "body_duplicate" 

849 case _: 

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

851 

852 return _get_content( 

853 NotificationTopicAction.verification__sv_fail, 

854 loc_context, 

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

856 action_url=urls.account_settings_link(), 

857 )