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

297 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +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 google.protobuf.timestamp_pb2 import Timestamp 

10 

11from couchers import urls 

12from couchers.i18n import LocalizationContext 

13from couchers.i18n.i18next import LocalizationError 

14from couchers.i18n.localize import format_phone_number 

15from couchers.models import Notification, NotificationTopicAction 

16from couchers.notifications.locales import get_notifs_i18next 

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: 

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: 

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: 

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}.push", 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=ios_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 full_key = f"{string_group.topic}.{string_group.action}.push.{key}" 

196 else: 

197 full_key = f"{string_group}.{key}" 

198 return get_notifs_i18next().localize(full_key, loc_context.locale, substitutions) 

199 

200 

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

202 return user.avatar_thumbnail_url or urls.icon_url() 

203 

204 

205def _render_account_deletion__start( 

206 data: notification_data_pb2.AccountDeletionStart, loc_context: LocalizationContext 

207) -> PushNotificationContent: 

208 return _get_content(NotificationTopicAction.account_deletion__start, loc_context) 

209 

210 

211def _render_account_deletion__complete( 

212 data: notification_data_pb2.AccountDeletionComplete, loc_context: LocalizationContext 

213) -> PushNotificationContent: 

214 return _get_content( 

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

216 ) 

217 

218 

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

220 return _get_content(NotificationTopicAction.account_deletion__recovered, loc_context) 

221 

222 

223def _render_activeness__probe( 

224 data: notification_data_pb2.ActivenessProbe, loc_context: LocalizationContext 

225) -> PushNotificationContent: 

226 return _get_content(NotificationTopicAction.activeness__probe, loc_context) 

227 

228 

229def _render_api_key__create( 

230 data: notification_data_pb2.ApiKeyCreate, loc_context: LocalizationContext 

231) -> PushNotificationContent: 

232 return _get_content(NotificationTopicAction.api_key__create, loc_context) 

233 

234 

235def _render_badge__add( 

236 data: notification_data_pb2.BadgeAdd, loc_context: LocalizationContext 

237) -> PushNotificationContent: 

238 return _get_content( 

239 NotificationTopicAction.badge__add, 

240 loc_context, 

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

242 action_url=urls.profile_link(), 

243 ) 

244 

245 

246def _render_badge__remove( 

247 data: notification_data_pb2.BadgeRemove, loc_context: LocalizationContext 

248) -> PushNotificationContent: 

249 return _get_content( 

250 NotificationTopicAction.badge__remove, 

251 loc_context, 

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

253 action_url=urls.profile_link(), 

254 ) 

255 

256 

257def _render_birthdate__change( 

258 data: notification_data_pb2.BirthdateChange, loc_context: LocalizationContext 

259) -> PushNotificationContent: 

260 return _get_content( 

261 NotificationTopicAction.birthdate__change, 

262 loc_context, 

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

264 action_url=urls.account_settings_link(), 

265 ) 

266 

267 

268def _render_chat__message( 

269 data: notification_data_pb2.ChatMessage, loc_context: LocalizationContext 

270) -> PushNotificationContent: 

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

272 return PushNotificationContent( 

273 title=data.author.name, 

274 ios_title=data.author.name, 

275 body=data.text, 

276 icon_url=_avatar_url_or_default(data.author), 

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

278 ) 

279 

280 

281def _render_chat__missed_messages( 

282 data: notification_data_pb2.ChatMissedMessages, loc_context: LocalizationContext 

283) -> PushNotificationContent: 

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

285 missed_count: int = len(data.messages) 

286 

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

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

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

290 

291 return _get_content( 

292 NotificationTopicAction.chat__missed_messages, 

293 loc_context, 

294 substitutions={"count": missed_count}, 

295 action_url=urls.messages_link(), 

296 ) 

297 

298 

299def _render_donation__received( 

300 data: notification_data_pb2.DonationReceived, loc_context: LocalizationContext 

301) -> PushNotificationContent: 

302 return _get_content( 

303 NotificationTopicAction.donation__received, 

304 loc_context, 

305 # Other currencies are not yet supported 

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

307 action_url=data.receipt_url, 

308 ) 

309 

310 

311def _render_discussion__create( 

312 data: notification_data_pb2.DiscussionCreate, loc_context: LocalizationContext 

313) -> PushNotificationContent: 

314 return _get_content( 

315 NotificationTopicAction.discussion__create, 

316 loc_context, 

317 substitutions={ 

318 "title": data.discussion.title, 

319 "user": data.author.name, 

320 "group_or_community": data.discussion.owner_title, 

321 }, 

322 icon_user=data.author, 

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

324 ) 

325 

326 

327def _render_discussion__comment( 

328 data: notification_data_pb2.DiscussionComment, loc_context: LocalizationContext 

329) -> PushNotificationContent: 

330 return _get_content( 

331 NotificationTopicAction.discussion__comment, 

332 loc_context, 

333 ios_title=data.author.name, 

334 ios_subtitle=data.discussion.title, 

335 body=data.reply.content, 

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

337 icon_user=data.author, 

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

339 ) 

340 

341 

342def _render_email_address__change( 

343 data: notification_data_pb2.EmailAddressChange, loc_context: LocalizationContext 

344) -> PushNotificationContent: 

345 return _get_content( 

346 NotificationTopicAction.email_address__change, 

347 loc_context, 

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

349 action_url=urls.account_settings_link(), 

350 ) 

351 

352 

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

354 return _get_content( 

355 NotificationTopicAction.email_address__verify, 

356 loc_context, 

357 action_url=urls.account_settings_link(), 

358 ) 

359 

360 

361def _render_event__create_any( 

362 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

363) -> PushNotificationContent: 

364 return _get_content( 

365 NotificationTopicAction.event__create_any, 

366 loc_context, 

367 substitutions={ 

368 "title": data.event.title, 

369 "user": data.inviting_user.name, 

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

371 }, 

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

373 ) 

374 

375 

376def _render_event__create_approved( 

377 data: notification_data_pb2.EventCreate, loc_context: LocalizationContext 

378) -> PushNotificationContent: 

379 return _get_content( 

380 NotificationTopicAction.event__create_approved, 

381 loc_context, 

382 substitutions={ 

383 "title": data.event.title, 

384 "user": data.inviting_user.name, 

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

386 }, 

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

388 ) 

389 

390 

391def _render_event__update( 

392 data: notification_data_pb2.EventUpdate, loc_context: LocalizationContext 

393) -> PushNotificationContent: 

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

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

396 return _get_content( 

397 NotificationTopicAction.event__update, 

398 loc_context, 

399 substitutions={ 

400 "title": data.event.title, 

401 "user": data.updating_user.name, 

402 }, 

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

404 ) 

405 

406 

407def _render_event__invite_organizer( 

408 data: notification_data_pb2.EventInviteOrganizer, loc_context: LocalizationContext 

409) -> PushNotificationContent: 

410 return _get_content( 

411 NotificationTopicAction.event__invite_organizer, 

412 loc_context, 

413 substitutions={ 

414 "title": data.event.title, 

415 "user": data.inviting_user.name, 

416 }, 

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

418 ) 

419 

420 

421def _render_event__comment( 

422 data: notification_data_pb2.EventComment, loc_context: LocalizationContext 

423) -> PushNotificationContent: 

424 return _get_content( 

425 NotificationTopicAction.event__comment, 

426 loc_context, 

427 substitutions={ 

428 "title": data.event.title, 

429 "user": data.author.name, 

430 }, 

431 body=data.reply.content, 

432 icon_user=data.author, 

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

434 ) 

435 

436 

437def _render_event__reminder( 

438 data: notification_data_pb2.EventReminder, loc_context: LocalizationContext 

439) -> PushNotificationContent: 

440 return _get_content( 

441 NotificationTopicAction.event__reminder, 

442 loc_context, 

443 substitutions={ 

444 "title": data.event.title, 

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

446 }, 

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

448 ) 

449 

450 

451def _render_event__cancel( 

452 data: notification_data_pb2.EventCancel, loc_context: LocalizationContext 

453) -> PushNotificationContent: 

454 return _get_content( 

455 NotificationTopicAction.event__cancel, 

456 loc_context, 

457 substitutions={ 

458 "title": data.event.title, 

459 "user": data.cancelling_user.name, 

460 }, 

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

462 ) 

463 

464 

465def _render_event__delete( 

466 data: notification_data_pb2.EventDelete, loc_context: LocalizationContext 

467) -> PushNotificationContent: 

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

469 

470 

471def _render_friend_request__create( 

472 data: notification_data_pb2.FriendRequestCreate, loc_context: LocalizationContext 

473) -> PushNotificationContent: 

474 return _get_content( 

475 NotificationTopicAction.friend_request__create, 

476 loc_context, 

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

478 icon_user=data.other_user, 

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

480 ) 

481 

482 

483def _render_friend_request__accept( 

484 data: notification_data_pb2.FriendRequestAccept, loc_context: LocalizationContext 

485) -> PushNotificationContent: 

486 return _get_content( 

487 NotificationTopicAction.friend_request__accept, 

488 loc_context, 

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

490 icon_user=data.other_user, 

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

492 ) 

493 

494 

495def _render_gender__change( 

496 data: notification_data_pb2.GenderChange, loc_context: LocalizationContext 

497) -> PushNotificationContent: 

498 return _get_content( 

499 NotificationTopicAction.gender__change, 

500 loc_context, 

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

502 action_url=urls.account_settings_link(), 

503 ) 

504 

505 

506def _render_general__new_blog_post( 

507 data: notification_data_pb2.GeneralNewBlogPost, loc_context: LocalizationContext 

508) -> PushNotificationContent: 

509 return _get_content( 

510 NotificationTopicAction.general__new_blog_post, 

511 loc_context, 

512 body=data.blurb, 

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

514 action_url=data.url, 

515 ) 

516 

517 

518def _render_host_request__create( 

519 data: notification_data_pb2.HostRequestCreate, loc_context: LocalizationContext 

520) -> PushNotificationContent: 

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

522 return _get_content( 

523 NotificationTopicAction.host_request__create, 

524 loc_context, 

525 substitutions={ 

526 "user": data.surfer.name, 

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

528 "count": night_count, 

529 }, 

530 icon_user=data.surfer, 

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

532 ) 

533 

534 

535def _render_host_request__message( 

536 data: notification_data_pb2.HostRequestMessage, loc_context: LocalizationContext 

537) -> PushNotificationContent: 

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

539 return PushNotificationContent( 

540 title=data.user.name, 

541 ios_title=data.user.name, 

542 body=data.text, 

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

544 icon_url=_avatar_url_or_default(data.user), 

545 ) 

546 

547 

548def _render_host_request__missed_messages( 

549 data: notification_data_pb2.HostRequestMissedMessages, loc_context: LocalizationContext 

550) -> PushNotificationContent: 

551 return _get_content( 

552 NotificationTopicAction.host_request__missed_messages, 

553 loc_context, 

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

555 icon_user=data.user, 

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

557 ) 

558 

559 

560def _render_host_request__reminder( 

561 data: notification_data_pb2.HostRequestReminder, loc_context: LocalizationContext 

562) -> PushNotificationContent: 

563 return _get_content( 

564 NotificationTopicAction.host_request__reminder, 

565 loc_context, 

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

567 icon_user=data.surfer, 

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

569 ) 

570 

571 

572def _render_host_request__accept( 

573 data: notification_data_pb2.HostRequestAccept, loc_context: LocalizationContext 

574) -> PushNotificationContent: 

575 return _get_content( 

576 NotificationTopicAction.host_request__accept, 

577 loc_context, 

578 substitutions={ 

579 "user": data.host.name, 

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

581 }, 

582 icon_user=data.host, 

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

584 ) 

585 

586 

587def _render_host_request__reject( 

588 data: notification_data_pb2.HostRequestReject, loc_context: LocalizationContext 

589) -> PushNotificationContent: 

590 return _get_content( 

591 NotificationTopicAction.host_request__reject, 

592 loc_context, 

593 substitutions={ 

594 "user": data.host.name, 

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

596 }, 

597 icon_user=data.host, 

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

599 ) 

600 

601 

602def _render_host_request__cancel( 

603 data: notification_data_pb2.HostRequestCancel, loc_context: LocalizationContext 

604) -> PushNotificationContent: 

605 return _get_content( 

606 NotificationTopicAction.host_request__cancel, 

607 loc_context, 

608 substitutions={ 

609 "user": data.surfer.name, 

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

611 }, 

612 icon_user=data.surfer, 

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

614 ) 

615 

616 

617def _render_host_request__confirm( 

618 data: notification_data_pb2.HostRequestConfirm, loc_context: LocalizationContext 

619) -> PushNotificationContent: 

620 return _get_content( 

621 NotificationTopicAction.host_request__confirm, 

622 loc_context, 

623 substitutions={ 

624 "user": data.surfer.name, 

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

626 }, 

627 icon_user=data.surfer, 

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

629 ) 

630 

631 

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

633 return _get_content(NotificationTopicAction.modnote__create, loc_context) 

634 

635 

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

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

638 return _get_content( 

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

640 loc_context, 

641 action_url=urls.edit_profile_link(), 

642 ) 

643 

644 

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

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

647 

648 

649def _render_password_reset__start( 

650 data: notification_data_pb2.PasswordResetStart, loc_context: LocalizationContext 

651) -> PushNotificationContent: 

652 return _get_content( 

653 NotificationTopicAction.password_reset__start, 

654 loc_context, 

655 action_url=urls.account_settings_link(), 

656 ) 

657 

658 

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

660 return _get_content( 

661 NotificationTopicAction.password_reset__complete, 

662 loc_context, 

663 action_url=urls.account_settings_link(), 

664 ) 

665 

666 

667def _render_phone_number__change( 

668 data: notification_data_pb2.PhoneNumberChange, loc_context: LocalizationContext 

669) -> PushNotificationContent: 

670 return _get_content( 

671 NotificationTopicAction.phone_number__change, 

672 loc_context, 

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

674 action_url=urls.account_settings_link(), 

675 ) 

676 

677 

678def _render_phone_number__verify( 

679 data: notification_data_pb2.PhoneNumberVerify, loc_context: LocalizationContext 

680) -> PushNotificationContent: 

681 return _get_content( 

682 NotificationTopicAction.phone_number__verify, 

683 loc_context, 

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

685 action_url=urls.account_settings_link(), 

686 ) 

687 

688 

689def _render_postal_verification__postcard_sent( 

690 data: notification_data_pb2.PostalVerificationPostcardSent, loc_context: LocalizationContext 

691) -> PushNotificationContent: 

692 return _get_content( 

693 NotificationTopicAction.postal_verification__postcard_sent, 

694 loc_context, 

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

696 action_url=urls.account_settings_link(), 

697 ) 

698 

699 

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

701 return _get_content( 

702 NotificationTopicAction.postal_verification__success, 

703 loc_context, 

704 action_url=urls.account_settings_link(), 

705 ) 

706 

707 

708def _render_postal_verification__failed( 

709 data: notification_data_pb2.PostalVerificationFailed, loc_context: LocalizationContext 

710) -> PushNotificationContent: 

711 body_key: str 

712 match data.reason: 

713 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_CODE_EXPIRED: 

714 body_key = "body_code_expired" 

715 case notification_data_pb2.POSTAL_VERIFICATION_FAIL_REASON_TOO_MANY_ATTEMPTS: 

716 body_key = "body_too_many_attempts" 

717 case _: 

718 body_key = "body_generic" 

719 

720 return _get_content( 

721 NotificationTopicAction.postal_verification__failed, 

722 loc_context, 

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

724 action_url=urls.account_settings_link(), 

725 ) 

726 

727 

728def _render_reference__receive_friend( 

729 data: notification_data_pb2.ReferenceReceiveFriend, loc_context: LocalizationContext 

730) -> PushNotificationContent: 

731 return _get_content( 

732 NotificationTopicAction.reference__receive_friend, 

733 loc_context, 

734 body=data.text, 

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

736 icon_user=data.from_user, 

737 action_url=urls.profile_references_link(), 

738 ) 

739 

740 

741def _render_reference__receive( 

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

743) -> PushNotificationContent: 

744 body: str 

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

746 body = data.text 

747 action_url = urls.profile_references_link() 

748 else: 

749 body = _get_string( 

750 "reference._receive_any.push", 

751 "body_must_write_yours", 

752 loc_context, 

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

754 ) 

755 action_url = urls.leave_reference_link( 

756 reference_type=leave_reference_type, 

757 to_user_id=data.from_user.user_id, 

758 host_request_id=str(data.host_request_id), 

759 ) 

760 return _get_content( 

761 string_group="reference._receive_any.push", 

762 loc_context=loc_context, 

763 body=body, 

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

765 icon_user=data.from_user, 

766 action_url=action_url, 

767 ) 

768 

769 

770def _render_reference__receive_hosted( 

771 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

772) -> PushNotificationContent: 

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

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

775 

776 

777def _render_reference__receive_surfed( 

778 data: notification_data_pb2.ReferenceReceiveHostRequest, loc_context: LocalizationContext 

779) -> PushNotificationContent: 

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

781 

782 

783def _render_reference__reminder( 

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

785) -> PushNotificationContent: 

786 leave_reference_link = urls.leave_reference_link( 

787 reference_type=leave_reference_type, 

788 to_user_id=data.other_user.user_id, 

789 host_request_id=str(data.host_request_id), 

790 ) 

791 return _get_content( 

792 string_group="reference._reminder_any.push", 

793 loc_context=loc_context, 

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

795 icon_user=data.other_user, 

796 action_url=leave_reference_link, 

797 ) 

798 

799 

800def _render_reference__reminder_surfed( 

801 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

802) -> PushNotificationContent: 

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

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

805 

806 

807def _render_reference__reminder_hosted( 

808 data: notification_data_pb2.ReferenceReminder, loc_context: LocalizationContext 

809) -> PushNotificationContent: 

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

811 

812 

813def _render_thread__reply( 

814 data: notification_data_pb2.ThreadReply, loc_context: LocalizationContext 

815) -> PushNotificationContent: 

816 parent_title: str 

817 view_link: str 

818 match data.WhichOneof("reply_parent"): 

819 case "event": 

820 parent_title = data.event.title 

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

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

823 parent_title = data.discussion.title 

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

825 case _: 

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

827 

828 return _get_content( 

829 NotificationTopicAction.thread__reply, 

830 loc_context=loc_context, 

831 body=data.reply.content, 

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

833 icon_user=data.author, 

834 action_url=view_link, 

835 ) 

836 

837 

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

839 return _get_content( 

840 NotificationTopicAction.verification__sv_success, 

841 loc_context, 

842 action_url=urls.account_settings_link(), 

843 ) 

844 

845 

846def _render_verification__sv_fail( 

847 data: notification_data_pb2.VerificationSVFail, loc_context: LocalizationContext 

848) -> PushNotificationContent: 

849 body_key: str 

850 match data.reason: 

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

852 body_key = "body_wrong_birthdate_gender" 

853 case notification_data_pb2.SV_FAIL_REASON_NOT_A_PASSPORT: 

854 body_key = "body_not_a_passport" 

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

856 body_key = "body_duplicate" 

857 case _: 

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

859 

860 return _get_content( 

861 NotificationTopicAction.verification__sv_fail, 

862 loc_context, 

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

864 action_url=urls.account_settings_link(), 

865 ) 

866 

867 

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

869 # Events are typically in the near future future, 

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

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

872 

873 

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

875 # Events are typically in the near future future, 

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

877 return loc_context.localize_datetime(timestamp, with_year=False, with_day_of_week=True)