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

298 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 11:50 +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: 52 ↛ 53line 52 didn't jump to line 53 because the pattern on line 52 never matched

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 # All strings are dynamic, no need to use _get_content 

273 return PushNotificationContent( 

274 title=data.author.name, 

275 ios_title=data.author.name, 

276 body=data.text, 

277 icon_url=_avatar_url_or_default(data.author), 

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

279 ) 

280 

281 

282def _render_chat__missed_messages( 

283 data: notification_data_pb2.ChatMissedMessages, loc_context: LocalizationContext 

284) -> PushNotificationContent: 

285 # Each message is from a different chat, so this counts conversations. 

286 missed_count: int = len(data.messages) 

287 

288 # Newer version of protos include a per-chat unseen message count (1 or more) 

289 if data.messages and data.messages[0].unseen_count: 

290 missed_count = sum(message.unseen_count for message in data.messages) 

291 

292 return _get_content( 

293 NotificationTopicAction.chat__missed_messages, 

294 loc_context, 

295 substitutions={"count": missed_count}, 

296 action_url=urls.messages_link(), 

297 ) 

298 

299 

300def _render_donation__received( 

301 data: notification_data_pb2.DonationReceived, loc_context: LocalizationContext 

302) -> PushNotificationContent: 

303 return _get_content( 

304 NotificationTopicAction.donation__received, 

305 loc_context, 

306 # Other currencies are not yet supported 

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

308 action_url=data.receipt_url, 

309 ) 

310 

311 

312def _render_discussion__create( 

313 data: notification_data_pb2.DiscussionCreate, loc_context: LocalizationContext 

314) -> PushNotificationContent: 

315 return _get_content( 

316 NotificationTopicAction.discussion__create, 

317 loc_context, 

318 substitutions={ 

319 "title": data.discussion.title, 

320 "user": data.author.name, 

321 "group_or_community": data.discussion.owner_title, 

322 }, 

323 icon_user=data.author, 

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

325 ) 

326 

327 

328def _render_discussion__comment( 

329 data: notification_data_pb2.DiscussionComment, loc_context: LocalizationContext 

330) -> PushNotificationContent: 

331 return _get_content( 

332 NotificationTopicAction.discussion__comment, 

333 loc_context, 

334 ios_title=data.author.name, 

335 ios_subtitle=data.discussion.title, 

336 body=data.reply.content, 

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

338 icon_user=data.author, 

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

340 ) 

341 

342 

343def _render_email_address__change( 

344 data: notification_data_pb2.EmailAddressChange, loc_context: LocalizationContext 

345) -> PushNotificationContent: 

346 return _get_content( 

347 NotificationTopicAction.email_address__change, 

348 loc_context, 

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

350 action_url=urls.account_settings_link(), 

351 ) 

352 

353 

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

355 return _get_content( 

356 NotificationTopicAction.email_address__verify, 

357 loc_context, 

358 action_url=urls.account_settings_link(), 

359 ) 

360 

361 

362def _render_event__create_any( 

363 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

364) -> PushNotificationContent: 

365 return _get_content( 

366 NotificationTopicAction.event__create_any, 

367 loc_context, 

368 substitutions={ 

369 "title": data.event.title, 

370 "user": data.inviting_user.name, 

371 "date_and_time": _format_event_start_datetime(data.event.start_time, data.event.timezone, loc_context), 

372 }, 

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

374 ) 

375 

376 

377def _render_event__create_approved( 

378 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

379) -> PushNotificationContent: 

380 return _get_content( 

381 NotificationTopicAction.event__create_approved, 

382 loc_context, 

383 substitutions={ 

384 "title": data.event.title, 

385 "user": data.inviting_user.name, 

386 "date_and_time": _format_event_start_datetime(data.event.start_time, data.event.timezone, loc_context), 

387 }, 

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

389 ) 

390 

391 

392def _render_event__update( 

393 data: notification_data_pb2.EventUpdate, loc_context: LocalizationContext 

394) -> PushNotificationContent: 

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

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

397 return _get_content( 

398 NotificationTopicAction.event__update, 

399 loc_context, 

400 substitutions={ 

401 "title": data.event.title, 

402 "user": data.updating_user.name, 

403 }, 

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

405 ) 

406 

407 

408def _render_event__invite_organizer( 

409 data: notification_data_pb2.EventInviteOrganizer, loc_context: LocalizationContext 

410) -> PushNotificationContent: 

411 return _get_content( 

412 NotificationTopicAction.event__invite_organizer, 

413 loc_context, 

414 substitutions={ 

415 "title": data.event.title, 

416 "user": data.inviting_user.name, 

417 }, 

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

419 ) 

420 

421 

422def _render_event__comment( 

423 data: notification_data_pb2.EventComment, loc_context: LocalizationContext 

424) -> PushNotificationContent: 

425 return _get_content( 

426 NotificationTopicAction.event__comment, 

427 loc_context, 

428 substitutions={ 

429 "title": data.event.title, 

430 "user": data.author.name, 

431 }, 

432 body=data.reply.content, 

433 icon_user=data.author, 

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

435 ) 

436 

437 

438def _render_event__reminder( 

439 data: notification_data_pb2.EventReminder, loc_context: LocalizationContext 

440) -> PushNotificationContent: 

441 return _get_content( 

442 NotificationTopicAction.event__reminder, 

443 loc_context, 

444 substitutions={ 

445 "title": data.event.title, 

446 "date_and_time": _format_event_start_datetime(data.event.start_time, data.event.timezone, loc_context), 

447 }, 

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

449 ) 

450 

451 

452def _render_event__cancel( 

453 data: notification_data_pb2.EventCancel, loc_context: LocalizationContext 

454) -> PushNotificationContent: 

455 return _get_content( 

456 NotificationTopicAction.event__cancel, 

457 loc_context, 

458 substitutions={ 

459 "title": data.event.title, 

460 "user": data.cancelling_user.name, 

461 }, 

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

463 ) 

464 

465 

466def _render_event__delete( 

467 data: notification_data_pb2.EventDelete, loc_context: LocalizationContext 

468) -> PushNotificationContent: 

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

470 

471 

472def _render_friend_request__create( 

473 data: notification_data_pb2.FriendRequestCreate, loc_context: LocalizationContext 

474) -> PushNotificationContent: 

475 return _get_content( 

476 NotificationTopicAction.friend_request__create, 

477 loc_context, 

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

479 icon_user=data.other_user, 

480 action_url=urls.friend_requests_link(from_user_id=data.other_user.user_id), 

481 ) 

482 

483 

484def _render_friend_request__accept( 

485 data: notification_data_pb2.FriendRequestAccept, loc_context: LocalizationContext 

486) -> PushNotificationContent: 

487 return _get_content( 

488 NotificationTopicAction.friend_request__accept, 

489 loc_context, 

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

491 icon_user=data.other_user, 

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

493 ) 

494 

495 

496def _render_gender__change( 

497 data: notification_data_pb2.GenderChange, loc_context: LocalizationContext 

498) -> PushNotificationContent: 

499 return _get_content( 

500 NotificationTopicAction.gender__change, 

501 loc_context, 

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

503 action_url=urls.account_settings_link(), 

504 ) 

505 

506 

507def _render_general__new_blog_post( 

508 data: notification_data_pb2.GeneralNewBlogPost, loc_context: LocalizationContext 

509) -> PushNotificationContent: 

510 return _get_content( 

511 NotificationTopicAction.general__new_blog_post, 

512 loc_context, 

513 body=data.blurb, 

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

515 action_url=data.url, 

516 ) 

517 

518 

519def _render_host_request__create( 

520 data: notification_data_pb2.HostRequestCreate, loc_context: LocalizationContext 

521) -> PushNotificationContent: 

522 night_count = (date.fromisoformat(data.host_request.to_date) - date.fromisoformat(data.host_request.from_date)).days 

523 return _get_content( 

524 NotificationTopicAction.host_request__create, 

525 loc_context, 

526 substitutions={ 

527 "user": data.surfer.name, 

528 "start_date": _format_host_request_start_date(data.host_request.from_date, loc_context), 

529 "count": night_count, 

530 }, 

531 icon_user=data.surfer, 

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

533 ) 

534 

535 

536def _render_host_request__message( 

537 data: notification_data_pb2.HostRequestMessage, loc_context: LocalizationContext 

538) -> PushNotificationContent: 

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

540 return PushNotificationContent( 

541 title=data.user.name, 

542 ios_title=data.user.name, 

543 body=data.text, 

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

545 icon_url=_avatar_url_or_default(data.user), 

546 ) 

547 

548 

549def _render_host_request__missed_messages( 

550 data: notification_data_pb2.HostRequestMissedMessages, loc_context: LocalizationContext 

551) -> PushNotificationContent: 

552 return _get_content( 

553 NotificationTopicAction.host_request__missed_messages, 

554 loc_context, 

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

556 icon_user=data.user, 

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

558 ) 

559 

560 

561def _render_host_request__reminder( 

562 data: notification_data_pb2.HostRequestReminder, loc_context: LocalizationContext 

563) -> PushNotificationContent: 

564 return _get_content( 

565 NotificationTopicAction.host_request__reminder, 

566 loc_context, 

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

568 icon_user=data.surfer, 

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

570 ) 

571 

572 

573def _render_host_request__accept( 

574 data: notification_data_pb2.HostRequestAccept, loc_context: LocalizationContext 

575) -> PushNotificationContent: 

576 return _get_content( 

577 NotificationTopicAction.host_request__accept, 

578 loc_context, 

579 substitutions={ 

580 "user": data.host.name, 

581 "date": _format_host_request_start_date(data.host_request.from_date, loc_context), 

582 }, 

583 icon_user=data.host, 

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

585 ) 

586 

587 

588def _render_host_request__reject( 

589 data: notification_data_pb2.HostRequestReject, loc_context: LocalizationContext 

590) -> PushNotificationContent: 

591 return _get_content( 

592 NotificationTopicAction.host_request__reject, 

593 loc_context, 

594 substitutions={ 

595 "user": data.host.name, 

596 "date": _format_host_request_start_date(data.host_request.from_date, loc_context), 

597 }, 

598 icon_user=data.host, 

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

600 ) 

601 

602 

603def _render_host_request__cancel( 

604 data: notification_data_pb2.HostRequestCancel, loc_context: LocalizationContext 

605) -> PushNotificationContent: 

606 return _get_content( 

607 NotificationTopicAction.host_request__cancel, 

608 loc_context, 

609 substitutions={ 

610 "user": data.surfer.name, 

611 "date": _format_host_request_start_date(data.host_request.from_date, loc_context), 

612 }, 

613 icon_user=data.surfer, 

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

615 ) 

616 

617 

618def _render_host_request__confirm( 

619 data: notification_data_pb2.HostRequestConfirm, loc_context: LocalizationContext 

620) -> PushNotificationContent: 

621 return _get_content( 

622 NotificationTopicAction.host_request__confirm, 

623 loc_context, 

624 substitutions={ 

625 "user": data.surfer.name, 

626 "date": _format_host_request_start_date(data.host_request.from_date, loc_context), 

627 }, 

628 icon_user=data.surfer, 

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

630 ) 

631 

632 

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

634 return _get_content(NotificationTopicAction.modnote__create, loc_context) 

635 

636 

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

638 variant = "first" if key == "1" else "subsequent" 

639 return _get_content( 

640 f"onboarding.reminder.push.{variant}", 

641 loc_context, 

642 action_url=urls.edit_profile_link(), 

643 ) 

644 

645 

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

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

648 

649 

650def _render_password_reset__start( 

651 data: notification_data_pb2.PasswordResetStart, loc_context: LocalizationContext 

652) -> PushNotificationContent: 

653 return _get_content( 

654 NotificationTopicAction.password_reset__start, 

655 loc_context, 

656 action_url=urls.account_settings_link(), 

657 ) 

658 

659 

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

661 return _get_content( 

662 NotificationTopicAction.password_reset__complete, 

663 loc_context, 

664 action_url=urls.account_settings_link(), 

665 ) 

666 

667 

668def _render_phone_number__change( 

669 data: notification_data_pb2.PhoneNumberChange, loc_context: LocalizationContext 

670) -> PushNotificationContent: 

671 return _get_content( 

672 NotificationTopicAction.phone_number__change, 

673 loc_context, 

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

675 action_url=urls.account_settings_link(), 

676 ) 

677 

678 

679def _render_phone_number__verify( 

680 data: notification_data_pb2.PhoneNumberVerify, loc_context: LocalizationContext 

681) -> PushNotificationContent: 

682 return _get_content( 

683 NotificationTopicAction.phone_number__verify, 

684 loc_context, 

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

686 action_url=urls.account_settings_link(), 

687 ) 

688 

689 

690def _render_postal_verification__postcard_sent( 

691 data: notification_data_pb2.PostalVerificationPostcardSent, loc_context: LocalizationContext 

692) -> PushNotificationContent: 

693 return _get_content( 

694 NotificationTopicAction.postal_verification__postcard_sent, 

695 loc_context, 

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

697 action_url=urls.account_settings_link(), 

698 ) 

699 

700 

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

702 return _get_content( 

703 NotificationTopicAction.postal_verification__success, 

704 loc_context, 

705 action_url=urls.account_settings_link(), 

706 ) 

707 

708 

709def _render_postal_verification__failed( 

710 data: notification_data_pb2.PostalVerificationFailed, loc_context: LocalizationContext 

711) -> PushNotificationContent: 

712 body_key: str 

713 match data.reason: 

714 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

715 body_key = "body_code_expired" 

716 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

717 body_key = "body_too_many_attempts" 

718 case _: 

719 body_key = "body_generic" 

720 

721 return _get_content( 

722 NotificationTopicAction.postal_verification__failed, 

723 loc_context, 

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

725 action_url=urls.account_settings_link(), 

726 ) 

727 

728 

729def _render_reference__receive_friend( 

730 data: notification_data_pb2.ReferenceReceiveFriend, loc_context: LocalizationContext 

731) -> PushNotificationContent: 

732 return _get_content( 

733 NotificationTopicAction.reference__receive_friend, 

734 loc_context, 

735 body=data.text, 

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

737 icon_user=data.from_user, 

738 action_url=urls.profile_references_link(), 

739 ) 

740 

741 

742def _render_reference__receive( 

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

744) -> PushNotificationContent: 

745 body: str 

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

747 body = data.text 

748 action_url = urls.profile_references_link() 

749 else: 

750 body = _get_string( 

751 "reference._receive_any.push", 

752 "body_must_write_yours", 

753 loc_context, 

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

755 ) 

756 action_url = urls.leave_reference_link( 

757 reference_type=leave_reference_type, 

758 to_user_id=data.from_user.user_id, 

759 host_request_id=str(data.host_request_id), 

760 ) 

761 return _get_content( 

762 string_group="reference._receive_any.push", 

763 loc_context=loc_context, 

764 body=body, 

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

766 icon_user=data.from_user, 

767 action_url=action_url, 

768 ) 

769 

770 

771def _render_reference__receive_hosted( 

772 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

773) -> PushNotificationContent: 

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

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

776 

777 

778def _render_reference__receive_surfed( 

779 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

780) -> PushNotificationContent: 

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

782 

783 

784def _render_reference__reminder( 

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

786) -> PushNotificationContent: 

787 leave_reference_link = urls.leave_reference_link( 

788 reference_type=leave_reference_type, 

789 to_user_id=data.other_user.user_id, 

790 host_request_id=str(data.host_request_id), 

791 ) 

792 return _get_content( 

793 string_group="reference._reminder_any.push", 

794 loc_context=loc_context, 

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

796 icon_user=data.other_user, 

797 action_url=leave_reference_link, 

798 ) 

799 

800 

801def _render_reference__reminder_surfed( 

802 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

803) -> PushNotificationContent: 

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

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

806 

807 

808def _render_reference__reminder_hosted( 

809 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

810) -> PushNotificationContent: 

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

812 

813 

814def _render_thread__reply( 

815 data: notification_data_pb2.ThreadReply, loc_context: LocalizationContext 

816) -> PushNotificationContent: 

817 parent_title: str 

818 view_link: str 

819 match data.WhichOneof("reply_parent"): 

820 case "event": 

821 parent_title = data.event.title 

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

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

824 parent_title = data.discussion.title 

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

826 case _: 

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

828 

829 return _get_content( 

830 NotificationTopicAction.thread__reply, 

831 loc_context=loc_context, 

832 body=data.reply.content, 

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

834 icon_user=data.author, 

835 action_url=view_link, 

836 ) 

837 

838 

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

840 return _get_content( 

841 NotificationTopicAction.verification__sv_success, 

842 loc_context, 

843 action_url=urls.account_settings_link(), 

844 ) 

845 

846 

847def _render_verification__sv_fail( 

848 data: notification_data_pb2.VerificationSVFail, loc_context: LocalizationContext 

849) -> PushNotificationContent: 

850 body_key: str 

851 match data.reason: 

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

853 body_key = "body_wrong_birthdate_gender" 

854 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

855 body_key = "body_not_a_passport" 

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

857 body_key = "body_duplicate" 

858 case _: 

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

860 

861 return _get_content( 

862 NotificationTopicAction.verification__sv_fail, 

863 loc_context, 

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

865 action_url=urls.account_settings_link(), 

866 ) 

867 

868 

869def _format_host_request_start_date(date: str, loc_context: LocalizationContext) -> str: 

870 # Events are typically in the near future future, 

871 # so the year is not useful but the day of week is. 

872 return loc_context.localize_date_from_iso(date, with_year=False, with_day_of_week=True) 

873 

874 

875def _format_event_start_datetime(timestamp: Timestamp, timezone: str, loc_context: LocalizationContext) -> str: 

876 # Events are typically in the near future future, 

877 # so the year is not useful but the day of week is. 

878 # Event start/end times are displayed in their local timezone. 

879 return loc_context.localize_datetime( 

880 timestamp, display_timezone=ZoneInfo(timezone), with_year=False, with_day_of_week=True 

881 )