Coverage for app/services/notification_service.py: 100%

383 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1""" 

2Notification service — three-level preference lookup, email dispatch, daily checks. 

3 

4Three-level lookup (highest wins): 

5 1. NotificationPreference — per-user per-tenant override 

6 2. TenantNotificationDefault — per-tenant override of system defaults 

7 3. NotificationType.SYSTEM_DEFAULTS — coded constants, no DB row 

8 

9All functions that touch the DB must be called within an app context. 

10""" 

11 

12import logging 

13from datetime import date 

14from typing import Any 

15 

16log = logging.getLogger(__name__) 

17 

18_REPO_URL = "https://github.com/e2jk/OpenHangar" 

19 

20 

21# ── Preference lookup ────────────────────────────────────────────────────────── 

22 

23 

24def get_effective_preference( 

25 user_id: int, tenant_id: int, notification_type: str 

26) -> dict[str, Any]: 

27 """Return {"enabled": bool, "threshold_days": int|None} for this user/tenant/type.""" 

28 from models import ( # pyright: ignore[reportMissingImports] 

29 NotificationPreference as NP, 

30 ) 

31 from models import ( 

32 NotificationType, 

33 TenantNotificationDefault, 

34 db, 

35 ) 

36 

37 user_pref = ( 

38 db.session.query(NP) 

39 .filter_by( 

40 user_id=user_id, tenant_id=tenant_id, notification_type=notification_type 

41 ) 

42 .first() 

43 ) 

44 if user_pref is not None: 

45 return { 

46 "enabled": user_pref.enabled, 

47 "threshold_days": user_pref.threshold_days, 

48 } 

49 

50 tenant_def = ( 

51 db.session.query(TenantNotificationDefault) 

52 .filter_by(tenant_id=tenant_id, notification_type=notification_type) 

53 .first() 

54 ) 

55 if tenant_def is not None: 

56 return { 

57 "enabled": tenant_def.enabled, 

58 "threshold_days": tenant_def.threshold_days, 

59 } 

60 

61 return dict( 

62 NotificationType.SYSTEM_DEFAULTS.get( 

63 notification_type, {"enabled": False, "threshold_days": None} 

64 ) 

65 ) 

66 

67 

68# ── Recipient resolution ─────────────────────────────────────────────────────── 

69 

70 

71def _user_caps(role: Any, user: Any) -> set[str]: 

72 """Compute capability set for a user from their role + capability flags.""" 

73 from models import Role # pyright: ignore[reportMissingImports] 

74 

75 caps: set[str] = set() 

76 if role in (Role.ADMIN, Role.OWNER): 

77 caps |= {"is_owner", "is_pilot", "is_maint"} 

78 if role in (Role.PILOT, Role.STUDENT) or getattr(user, "is_pilot", False): 

79 caps.add("is_pilot") 

80 if role == Role.MAINTENANCE or getattr(user, "is_maintenance", False): 

81 caps.add("is_maint") 

82 if role == Role.INSTRUCTOR: 

83 caps |= {"is_pilot", "is_maint"} 

84 return caps 

85 

86 

87def _find_recipients( 

88 notification_type: str, tenant_id: int, target_user_ids: list[int] | None = None 

89) -> list[Any]: 

90 """Return list of User objects that should receive this notification type.""" 

91 from models import ( # pyright: ignore[reportMissingImports] 

92 NotificationType, 

93 TenantUser, 

94 User, 

95 db, 

96 ) 

97 

98 required = set(NotificationType.REQUIRED_CAPS.get(notification_type, [])) 

99 

100 query = ( 

101 db.session.query(User, TenantUser) 

102 .join(TenantUser, TenantUser.user_id == User.id) 

103 .filter(TenantUser.tenant_id == tenant_id, User.is_active.is_(True)) 

104 ) 

105 if target_user_ids is not None: 

106 query = query.filter(User.id.in_(target_user_ids)) 

107 

108 recipients = [] 

109 for user, tu in query.all(): 

110 caps = _user_caps(tu.role, user) 

111 if caps & required: 

112 recipients.append(user) 

113 return recipients 

114 

115 

116# ── Branding ────────────────────────────────────────────────────────────────── 

117 

118 

119def _tenant_display_name(profile: Any) -> str: 

120 if profile is None: 

121 return "OpenHangar" 

122 return ( 

123 profile.club_name 

124 or profile.school_name 

125 or profile.organisation_name 

126 or "OpenHangar" 

127 ) 

128 

129 

130def _build_subject(base: str, profile: Any) -> str: 

131 prefix = getattr(profile, "email_subject_prefix", None) if profile else None 

132 return f"[{prefix}] {base}" if prefix else base 

133 

134 

135# ── Template rendering ───────────────────────────────────────────────────────── 

136 

137 

138def _render_email( 

139 template_name: str, locale: str = "en", **ctx: Any 

140) -> tuple[str, str]: 

141 """Return (text_body, html_body) for a notification email.""" 

142 import os 

143 

144 from flask import render_template # pyright: ignore[reportMissingImports] 

145 from flask_babel import force_locale # pyright: ignore[reportMissingImports] 

146 

147 ctx.setdefault("repo_url", _REPO_URL) 

148 ctx.setdefault( 

149 "instance_url", os.environ.get("OPENHANGAR_INSTANCE_URL", "").strip() or None 

150 ) 

151 with force_locale(locale): 

152 body_html = render_template(f"email/notif/{template_name}", **ctx) 

153 html = render_template("email/base_email.html", body=body_html, **ctx) 

154 return ctx.get("text_body", ""), html 

155 

156 

157def _text_for(notification_type: str, context: dict[str, Any]) -> str: 

158 """Build a plain-text fallback body.""" 

159 title = context.get("notification_title", notification_type) 

160 message = context.get("notification_message", "") 

161 lines = [title, "", message] 

162 if context.get("details"): 

163 for label, val in context["details"]: 

164 lines.append(f"{label}: {val}") 

165 if context.get("cta_url"): 

166 lines += ["", context["cta_url"]] 

167 if context.get("snooze_url"): 

168 lines += ["", context["snooze_url"]] 

169 return "\n".join(lines) 

170 

171 

172# ── Send-log dedup + per-instance snooze ──────────────────────────────────────── 

173 

174 

175def _already_sent_today( 

176 user_id: int, tenant_id: int, notification_type: str, subject_ref: str 

177) -> bool: 

178 from models import NotificationSendLog # pyright: ignore[reportMissingImports] 

179 

180 return ( 

181 NotificationSendLog.query.filter_by( 

182 user_id=user_id, 

183 tenant_id=tenant_id, 

184 notification_type=notification_type, 

185 subject_ref=subject_ref, 

186 sent_date=date.today(), 

187 ).first() 

188 is not None 

189 ) 

190 

191 

192def _record_sent( 

193 user_id: int, tenant_id: int, notification_type: str, subject_ref: str 

194) -> None: 

195 from models import ( # pyright: ignore[reportMissingImports] 

196 NotificationSendLog, 

197 db, 

198 ) 

199 

200 try: 

201 db.session.add( 

202 NotificationSendLog( 

203 user_id=user_id, 

204 tenant_id=tenant_id, 

205 notification_type=notification_type, 

206 subject_ref=subject_ref, 

207 sent_date=date.today(), 

208 ) 

209 ) 

210 db.session.commit() 

211 except Exception: 

212 db.session.rollback() 

213 log.exception("Failed to record notification send log") 

214 

215 

216def _resolve_snooze( 

217 user_id: int, 

218 tenant_id: int, 

219 notification_type: str, 

220 subject_ref: str, 

221 expiry_value: str, 

222 label: str, 

223) -> tuple[bool, str]: 

224 """Get-or-create the NotificationSnooze row for this (user, instance), 

225 refresh it with the live deadline value, and return 

226 (suppressed, snooze_url). suppressed is True only when the user has 

227 already confirmed a snooze for exactly this deadline value -- if the 

228 deadline has since moved (e.g. a renewed document was uploaded), any 

229 prior confirmation is stale and is cleared here instead.""" 

230 from models import ( # pyright: ignore[reportMissingImports] 

231 NotificationSnooze, 

232 db, 

233 ) 

234 

235 row = NotificationSnooze.query.filter_by( 

236 user_id=user_id, 

237 tenant_id=tenant_id, 

238 notification_type=notification_type, 

239 subject_ref=subject_ref, 

240 ).first() 

241 

242 if row is not None and row.snoozed_value == expiry_value: 

243 return True, _snooze_url(row.token) 

244 

245 if row is None: 

246 row = NotificationSnooze( 

247 user_id=user_id, 

248 tenant_id=tenant_id, 

249 notification_type=notification_type, 

250 subject_ref=subject_ref, 

251 label=label, 

252 current_value=expiry_value, 

253 ) 

254 db.session.add(row) 

255 else: 

256 row.snoozed_value = None 

257 row.snoozed_at = None 

258 row.current_value = expiry_value 

259 row.label = label 

260 db.session.commit() 

261 return False, _snooze_url(row.token) 

262 

263 

264def _snooze_url(token: str) -> str: 

265 """Build an absolute snooze link without relying on url_for()'s 

266 request-context binding -- run_daily_checks() runs in a background 

267 thread with only an app context, no active request, so url_for()'s 

268 usual host-from-request lookup isn't available. Mirrors the 

269 OPENHANGAR_INSTANCE_URL pattern already used for `instance_url` above.""" 

270 import os 

271 

272 from flask import current_app # pyright: ignore[reportMissingImports] 

273 

274 path = current_app.url_map.bind("localhost").build( 

275 "notifications.snooze", {"token": token} 

276 ) 

277 instance_url = os.environ.get("OPENHANGAR_INSTANCE_URL", "").strip() 

278 return f"{instance_url.rstrip('/')}{path}" if instance_url else path 

279 

280 

281# ── Dispatch ────────────────────────────────────────────────────────────────── 

282 

283 

284def dispatch( 

285 notification_type: str, 

286 tenant_id: int, 

287 email_context: dict[str, Any], 

288 target_user_ids: list[int] | None = None, 

289 subject_ref: str | None = None, 

290) -> None: 

291 """ 

292 Find all eligible recipients and send notification emails. 

293 

294 Must be called within an app context. 

295 target_user_ids: if set, only notify these users (used for pilot-self events). 

296 subject_ref: stable id of the specific thing this notification is about 

297 (e.g. "aircraft:12"), passed by the daily checks in run_daily_checks(). 

298 When set, enables two things: (1) a per-user-per-day send log so a 

299 second run on the same day (e.g. after a restart) does not resend, and 

300 (2) when email_context also carries "expiry_value" (an ISO date string 

301 snapshot of the live deadline), a one-click snooze link that suppresses 

302 future emails for this exact deadline value until it changes. Callers 

303 that don't pass subject_ref (event-driven notifications like a snag 

304 being reported) keep today's always-send behaviour -- they're one-shot 

305 events, not daily re-evaluations. 

306 """ 

307 from flask_babel import ( # pyright: ignore[reportMissingImports] 

308 force_locale, 

309 gettext, 

310 ) 

311 from models import TenantProfile # pyright: ignore[reportMissingImports] 

312 

313 from services.email_service import ( # pyright: ignore[reportMissingImports] 

314 EmailNotConfiguredError, 

315 EmailSendError, 

316 send_email, 

317 ) 

318 

319 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first() 

320 recipients = _find_recipients(notification_type, tenant_id, target_user_ids) 

321 

322 for user in recipients: 

323 pref = get_effective_preference(user.id, tenant_id, notification_type) 

324 if not pref["enabled"]: 

325 continue 

326 

327 locale = user.language or "en" 

328 with force_locale(locale): 

329 if "subject_key" in email_context: 

330 base_subject = str(email_context["subject_key"]) % email_context.get( 

331 "subject_args", {} 

332 ) 

333 else: 

334 base_subject = email_context.get("subject", notification_type) 

335 subject = _build_subject(base_subject, profile) 

336 if "notification_title_key" in email_context: 

337 notif_title = str( 

338 email_context["notification_title_key"] 

339 ) % email_context.get("notification_title_args", {}) 

340 else: 

341 notif_title = email_context.get("notification_title", notification_type) 

342 if "notification_message_key" in email_context: 

343 notif_message = str( 

344 email_context["notification_message_key"] 

345 ) % email_context.get("notification_message_args", {}) 

346 else: 

347 notif_message = email_context.get("notification_message", "") 

348 greeting = gettext("Hello %(name)s,") % {"name": user.display_name} 

349 

350 snooze_url = None 

351 if subject_ref is not None: 

352 expiry_value = email_context.get("expiry_value") 

353 if expiry_value is not None: 

354 suppressed, snooze_url = _resolve_snooze( 

355 user.id, 

356 tenant_id, 

357 notification_type, 

358 subject_ref, 

359 expiry_value, 

360 notif_title, 

361 ) 

362 if suppressed: 

363 continue 

364 if _already_sent_today(user.id, tenant_id, notification_type, subject_ref): 

365 continue 

366 

367 ctx = dict(email_context) 

368 ctx.setdefault("threshold_days", pref["threshold_days"]) 

369 # generic.html treats these as optional ({% if %} guards), but under 

370 # Jinja's StrictUndefined (active whenever TESTING or a development 

371 # environment is detected at create_app() time) an absent key raises 

372 # instead of evaluating falsy -- most _check_* callers never set 

373 # them, so without a default here every such notification email 

374 # silently fails to send in a strict-undefined environment. 

375 ctx.setdefault("cta_url", None) 

376 ctx.setdefault("cta_label", None) 

377 ctx.setdefault("details", None) 

378 ctx["snooze_url"] = snooze_url 

379 ctx["subject"] = subject 

380 ctx["notification_title"] = notif_title 

381 ctx["notification_message"] = notif_message 

382 ctx["recipient_name"] = user.display_name 

383 ctx["greeting"] = greeting 

384 

385 text_body = _text_for(notification_type, ctx) 

386 try: 

387 _text, html_body = _render_email( 

388 "generic.html", locale=locale, text_body=text_body, **ctx 

389 ) 

390 send_email( 

391 to=user.email, 

392 subject=subject, 

393 text_body=text_body, 

394 html_body=html_body, 

395 locale=locale, 

396 ) 

397 if subject_ref is not None: 

398 _record_sent(user.id, tenant_id, notification_type, subject_ref) 

399 except EmailNotConfiguredError: 

400 return # SMTP not configured — stop trying all recipients 

401 except EmailSendError as exc: 

402 log.warning("Notification email to %s failed: %s", user.email, exc) 

403 except Exception: 

404 log.exception("Unexpected error sending notification to %s", user.email) 

405 

406 

407# ── Daily expiry checks ──────────────────────────────────────────────────────── 

408 

409 

410def _recompute_all_expiry_fields() -> None: 

411 """Roll Aircraft.insurance_expiry/arc_expiry over to a future-dated 

412 document once its valid_from arrives, even on days nobody touches that 

413 aircraft's documents -- upload/edit/delete (documents/routes.py) are 

414 the only other trigger for _recompute_expiry_field. Runs before the 

415 checks below so a same-day rollover is reflected in that day's 

416 notification emails too, not just the next one.""" 

417 from documents.routes import ( # pyright: ignore[reportMissingImports] 

418 _recompute_expiry_field, 

419 ) 

420 from models import ( # pyright: ignore[reportMissingImports] 

421 Aircraft, 

422 DocType, 

423 Tenant, 

424 db, 

425 ) 

426 

427 for tenant in Tenant.query.filter_by(is_active=True).all(): 

428 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all(): 

429 _recompute_expiry_field(ac, DocType.INSURANCE_CERT) 

430 _recompute_expiry_field(ac, DocType.ARC) 

431 db.session.commit() 

432 

433 

434def run_daily_checks(app: Any) -> None: 

435 """Check all expiry-based notification types across all tenants. Runs in background thread. 

436 

437 Guarded by an advisory lock (see services.advisory_lock) so that only one 

438 gunicorn worker runs the checks per scheduled tick — without it, each of 

439 the four production workers would send its own copy of every alert email. 

440 """ 

441 from models import db # pyright: ignore[reportMissingImports] 

442 

443 from services.advisory_lock import ( 

444 advisory_lock_scope, # pyright: ignore[reportMissingImports] 

445 ) 

446 

447 with app.app_context(): 

448 try: 

449 with advisory_lock_scope(db, 7283910457) as acquired: 

450 if not acquired: 

451 log.info( 

452 "Daily notification checks: another worker holds the lock — skipping" 

453 ) 

454 return 

455 from services.co_owner_billing import ( # pyright: ignore[reportMissingImports] 

456 run_co_owner_billing_pass_all, 

457 ) 

458 from services.recurring_expense_service import ( # pyright: ignore[reportMissingImports] 

459 materialize_recurring_expenses, 

460 ) 

461 

462 _recompute_all_expiry_fields() 

463 materialize_recurring_expenses() 

464 run_co_owner_billing_pass_all() 

465 _check_maintenance(app) 

466 _check_insurance(app) 

467 _check_arc(app) 

468 _check_medical_and_sep(app) 

469 _check_documents(app) 

470 _check_airworthiness_reviews(app) 

471 _check_renter_authorizations(app) 

472 _check_personal_minimums_recency(app) 

473 except Exception: 

474 log.exception("Error in daily notification checks") 

475 

476 

477def _check_maintenance(app: Any) -> None: 

478 from flask_babel import lazy_gettext as _l # pyright: ignore[reportMissingImports] 

479 from models import Aircraft, Tenant # pyright: ignore[reportMissingImports] 

480 from models import NotificationType as NT # pyright: ignore[reportMissingImports] 

481 

482 for tenant in Tenant.query.filter_by(is_active=True).all(): 

483 aircraft_list = Aircraft.query.filter_by( 

484 tenant_id=tenant.id, archived_at=None 

485 ).all() 

486 hobbs_by_id = Aircraft.engine_hours_by_id([ac.id for ac in aircraft_list]) 

487 landings_by_id = Aircraft.landings_by_id([ac.id for ac in aircraft_list]) 

488 flight_hours_by_id = Aircraft.flight_hours_by_id( 

489 [ac.id for ac in aircraft_list] 

490 ) 

491 for ac in aircraft_list: 

492 hobbs = hobbs_by_id[ac.id] 

493 landings = landings_by_id[ac.id] 

494 flight_hours = flight_hours_by_id[ac.id] 

495 for trigger in ac.maintenance_triggers: 

496 status = trigger.status( 

497 current_engine_hours=hobbs, 

498 current_landings=landings, 

499 current_flight_hours=flight_hours, 

500 ) 

501 if status == "overdue": 

502 _dispatch_in_context( 

503 NT.MAINTENANCE_OVERDUE, 

504 tenant.id, 

505 { 

506 "subject_key": _l( 

507 "Maintenance overdue: %(name)s on %(reg)s" 

508 ), 

509 "subject_args": { 

510 "name": trigger.name, 

511 "reg": ac.registration, 

512 }, 

513 "notification_title_key": _l( 

514 "Maintenance overdue: %(name)s" 

515 ), 

516 "notification_title_args": {"name": trigger.name}, 

517 "notification_message_key": _l( 

518 "%(name)s on %(reg)s is overdue." 

519 ), 

520 "notification_message_args": { 

521 "name": trigger.name, 

522 "reg": ac.registration, 

523 }, 

524 "details": [ 

525 (_l("Aircraft"), ac.registration), 

526 (_l("Item"), trigger.name), 

527 ], 

528 }, 

529 subject_ref=f"trigger:{trigger.id}", 

530 ) 

531 elif status == "due_soon": 

532 _dispatch_in_context( 

533 NT.MAINTENANCE_DUE_SOON, 

534 tenant.id, 

535 { 

536 "subject_key": _l( 

537 "Maintenance due soon: %(name)s on %(reg)s" 

538 ), 

539 "subject_args": { 

540 "name": trigger.name, 

541 "reg": ac.registration, 

542 }, 

543 "notification_title_key": _l( 

544 "Maintenance due soon: %(name)s" 

545 ), 

546 "notification_title_args": {"name": trigger.name}, 

547 "notification_message_key": _l( 

548 "%(name)s on %(reg)s is coming due." 

549 ), 

550 "notification_message_args": { 

551 "name": trigger.name, 

552 "reg": ac.registration, 

553 }, 

554 "details": [ 

555 (_l("Aircraft"), ac.registration), 

556 (_l("Item"), trigger.name), 

557 ], 

558 }, 

559 subject_ref=f"trigger:{trigger.id}", 

560 ) 

561 

562 

563def _check_insurance(app: Any) -> None: 

564 from flask_babel import ( # pyright: ignore[reportMissingImports] 

565 lazy_gettext as _l, 

566 ) 

567 from flask_babel import ( 

568 lazy_ngettext as _ln, 

569 ) 

570 from models import Aircraft, Tenant # pyright: ignore[reportMissingImports] 

571 from models import NotificationType as NT 

572 

573 today = date.today() 

574 for tenant in Tenant.query.filter_by(is_active=True).all(): 

575 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all(): 

576 if ac.insurance_expiry is None: 

577 continue 

578 days_left = (ac.insurance_expiry - today).days 

579 # Use system default threshold; recipient-level override applied in dispatch() 

580 threshold = ( 

581 NT.SYSTEM_DEFAULTS[NT.INSURANCE_EXPIRING]["threshold_days"] or 30 

582 ) 

583 if 0 <= days_left <= threshold: 

584 _dispatch_in_context( 

585 NT.INSURANCE_EXPIRING, 

586 tenant.id, 

587 { 

588 "subject_key": _ln( 

589 "Insurance expiring in one day: %(reg)s", 

590 "Insurance expiring in %(days)s days: %(reg)s", 

591 days_left, 

592 days=days_left, 

593 reg=ac.registration, 

594 ), 

595 "subject_args": {}, 

596 "notification_title_key": _l( 

597 "Insurance expiring soon: %(reg)s" 

598 ), 

599 "notification_title_args": {"reg": ac.registration}, 

600 "notification_message_key": _ln( 

601 "The insurance for %(reg)s expires on %(date)s (one day remaining).", 

602 "The insurance for %(reg)s expires on %(date)s (%(days)s days remaining).", 

603 days_left, 

604 reg=ac.registration, 

605 date=ac.insurance_expiry.isoformat(), 

606 days=days_left, 

607 ), 

608 "notification_message_args": {}, 

609 "details": [ 

610 (_l("Aircraft"), ac.registration), 

611 (_l("Expires"), ac.insurance_expiry.isoformat()), 

612 (_l("Days left"), str(days_left)), 

613 ], 

614 "expiry_value": ac.insurance_expiry.isoformat(), 

615 }, 

616 subject_ref=f"aircraft:{ac.id}", 

617 ) 

618 

619 

620def _check_arc(app: Any) -> None: 

621 from flask_babel import ( # pyright: ignore[reportMissingImports] 

622 lazy_gettext as _l, 

623 ) 

624 from flask_babel import ( 

625 lazy_ngettext as _ln, 

626 ) 

627 from models import Aircraft, Tenant # pyright: ignore[reportMissingImports] 

628 from models import NotificationType as NT 

629 

630 today = date.today() 

631 for tenant in Tenant.query.filter_by(is_active=True).all(): 

632 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all(): 

633 if ac.arc_expiry is None: 

634 continue 

635 days_left = (ac.arc_expiry - today).days 

636 # Use system default threshold; recipient-level override applied in dispatch() 

637 threshold = NT.SYSTEM_DEFAULTS[NT.ARC_EXPIRY]["threshold_days"] or 60 

638 if 0 <= days_left <= threshold: 

639 _dispatch_in_context( 

640 NT.ARC_EXPIRY, 

641 tenant.id, 

642 { 

643 "subject_key": _ln( 

644 "ARC expiring in one day: %(reg)s", 

645 "ARC expiring in %(days)s days: %(reg)s", 

646 days_left, 

647 days=days_left, 

648 reg=ac.registration, 

649 ), 

650 "subject_args": {}, 

651 "notification_title_key": _l("ARC expiring soon: %(reg)s"), 

652 "notification_title_args": {"reg": ac.registration}, 

653 "notification_message_key": _ln( 

654 "The ARC for %(reg)s expires on %(date)s (one day remaining).", 

655 "The ARC for %(reg)s expires on %(date)s (%(days)s days remaining).", 

656 days_left, 

657 reg=ac.registration, 

658 date=ac.arc_expiry.isoformat(), 

659 days=days_left, 

660 ), 

661 "notification_message_args": {}, 

662 "details": [ 

663 (_l("Aircraft"), ac.registration), 

664 (_l("Expires"), ac.arc_expiry.isoformat()), 

665 (_l("Days left"), str(days_left)), 

666 ], 

667 "expiry_value": ac.arc_expiry.isoformat(), 

668 }, 

669 subject_ref=f"aircraft:{ac.id}", 

670 ) 

671 

672 

673def _check_medical_and_sep(app: Any) -> None: 

674 from flask_babel import ( # pyright: ignore[reportMissingImports] 

675 lazy_gettext as _l, 

676 ) 

677 from flask_babel import ( 

678 lazy_ngettext as _ln, 

679 ) 

680 from models import NotificationType as NT # pyright: ignore[reportMissingImports] 

681 from models import PilotProfile, TenantUser, User, db 

682 

683 today = date.today() 

684 for profile in PilotProfile.query.all(): 

685 user = db.session.get(User, profile.user_id) 

686 if user is None or not user.is_active: 

687 continue 

688 tu = TenantUser.query.filter_by(user_id=user.id).first() 

689 if tu is None: 

690 continue 

691 

692 # Two translatable forms per item (not a mechanical .lower() of the 

693 # capitalized one) -- lowercasing a translated string isn't a safe 

694 # general i18n operation (case rules and even correct wording can 

695 # differ by language), so each form is its own msgid. 

696 for notif_type, expiry, label, label_lower in [ 

697 ( 

698 NT.MEDICAL_EXPIRING, 

699 profile.medical_expiry, 

700 _l("Medical certificate"), 

701 _l("medical certificate"), 

702 ), 

703 ( 

704 NT.SEP_RATING_EXPIRING, 

705 profile.sep_expiry, 

706 # Same msgid as the dashboard's SEP section label 

707 # (app/templates/dashboard.html) -- same concept, not a 

708 # coincidental duplicate. 

709 _l("SEP endorsement"), 

710 _l("SEP endorsement"), 

711 ), 

712 ]: 

713 if expiry is None: 

714 continue 

715 days_left = (expiry - today).days 

716 threshold = NT.SYSTEM_DEFAULTS[notif_type]["threshold_days"] or 60 

717 if 0 <= days_left <= threshold: 

718 _dispatch_in_context( 

719 notif_type, 

720 tu.tenant_id, 

721 { 

722 "subject_key": _ln( 

723 "%(label)s expiring in one day", 

724 "%(label)s expiring in %(days)s days", 

725 days_left, 

726 label=label, 

727 days=days_left, 

728 ), 

729 "subject_args": {}, 

730 "notification_title_key": _l("%(label)s expiring soon"), 

731 "notification_title_args": {"label": label}, 

732 "notification_message_key": _ln( 

733 "Your %(label_lower)s expires on %(date)s (one day remaining).", 

734 "Your %(label_lower)s expires on %(date)s (%(days)s days remaining).", 

735 days_left, 

736 label_lower=label_lower, 

737 date=expiry.isoformat(), 

738 days=days_left, 

739 ), 

740 "notification_message_args": {}, 

741 "details": [ 

742 (_l("Expires"), expiry.isoformat()), 

743 (_l("Days left"), str(days_left)), 

744 ], 

745 "expiry_value": expiry.isoformat(), 

746 }, 

747 target_user_ids=[user.id], 

748 subject_ref=f"pilot:{profile.id}", 

749 ) 

750 

751 

752def _check_documents(app: Any) -> None: 

753 from documents.routes import ( # pyright: ignore[reportMissingImports] 

754 _EXPIRY_DRIVING_DOC_TYPES, 

755 ) 

756 from flask_babel import ( # pyright: ignore[reportMissingImports] 

757 lazy_gettext as _l, 

758 ) 

759 from flask_babel import ( 

760 lazy_ngettext as _ln, 

761 ) 

762 from models import ( # pyright: ignore[reportMissingImports] 

763 Aircraft, 

764 Document, 

765 Tenant, 

766 ) 

767 from models import NotificationType as NT 

768 

769 today = date.today() 

770 threshold = NT.SYSTEM_DEFAULTS[NT.DOCUMENT_EXPIRING]["threshold_days"] or 30 

771 for tenant in Tenant.query.filter_by(is_active=True).all(): 

772 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all(): 

773 for doc in Document.query.filter_by(aircraft_id=ac.id).all(): 

774 if doc.valid_until is None: 

775 continue 

776 # ARC/Insurance documents already drive their own dedicated 

777 # check (_check_arc / _check_insurance) off the synced 

778 # Aircraft.arc_expiry/insurance_expiry cache fields -- skip 

779 # them here to avoid sending two separate emails for the 

780 # same underlying deadline. 

781 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES: 

782 continue 

783 days_left = (doc.valid_until - today).days 

784 if 0 <= days_left <= threshold: 

785 title = doc.title or doc.original_filename 

786 _dispatch_in_context( 

787 NT.DOCUMENT_EXPIRING, 

788 tenant.id, 

789 { 

790 "subject_key": _ln( 

791 "Document expiring in one day: %(title)s", 

792 "Document expiring in %(days)s days: %(title)s", 

793 days_left, 

794 days=days_left, 

795 title=title, 

796 ), 

797 "subject_args": {}, 

798 "notification_title_key": _l( 

799 "Document expiring soon: %(title)s" 

800 ), 

801 "notification_title_args": {"title": title}, 

802 "notification_message_key": _ln( 

803 "'%(title)s' on %(reg)s expires on %(date)s (one day remaining).", 

804 "'%(title)s' on %(reg)s expires on %(date)s (%(days)s days remaining).", 

805 days_left, 

806 title=title, 

807 reg=ac.registration, 

808 date=doc.valid_until.isoformat(), 

809 days=days_left, 

810 ), 

811 "notification_message_args": {}, 

812 "details": [ 

813 (_l("Aircraft"), ac.registration), 

814 (_l("Document"), title), 

815 (_l("Expires"), doc.valid_until.isoformat()), 

816 ], 

817 "expiry_value": doc.valid_until.isoformat(), 

818 }, 

819 subject_ref=f"document:{doc.id}", 

820 ) 

821 

822 

823def _check_airworthiness_reviews(app: Any) -> None: 

824 from flask_babel import ( # pyright: ignore[reportMissingImports] 

825 lazy_gettext as _l, 

826 ) 

827 from flask_babel import ( 

828 lazy_ngettext as _ln, 

829 ) 

830 from models import ( # pyright: ignore[reportMissingImports] 

831 Aircraft, 

832 AirworthinessDocumentStatus, 

833 Tenant, 

834 ) 

835 from models import ( 

836 NotificationType as NT, 

837 ) 

838 

839 today = date.today() 

840 threshold = NT.SYSTEM_DEFAULTS[NT.AIRWORTHINESS_REVIEW_DUE]["threshold_days"] or 30 

841 for tenant in Tenant.query.filter_by(is_active=True).all(): 

842 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all(): 

843 for status_row in AirworthinessDocumentStatus.query.filter_by( 

844 aircraft_id=ac.id 

845 ).all(): 

846 if status_row.next_review_date is None: 

847 continue 

848 days_left = (status_row.next_review_date - today).days 

849 if 0 <= days_left <= threshold: 

850 doc = status_row.document 

851 ref = doc.reference if doc else _l("unknown") 

852 _dispatch_in_context( 

853 NT.AIRWORTHINESS_REVIEW_DUE, 

854 tenant.id, 

855 { 

856 "subject_key": _ln( 

857 "Airworthiness review due in one day: %(ref)s on %(reg)s", 

858 "Airworthiness review due in %(days)s days: %(ref)s on %(reg)s", 

859 days_left, 

860 days=days_left, 

861 ref=ref, 

862 reg=ac.registration, 

863 ), 

864 "subject_args": {}, 

865 "notification_title_key": _l( 

866 "Airworthiness review due: %(ref)s" 

867 ), 

868 "notification_title_args": {"ref": ref}, 

869 "notification_message_key": _ln( 

870 "Document %(ref)s on %(reg)s requires review by %(date)s (one day).", 

871 "Document %(ref)s on %(reg)s requires review by %(date)s (%(days)s days).", 

872 days_left, 

873 ref=ref, 

874 reg=ac.registration, 

875 date=status_row.next_review_date.isoformat(), 

876 days=days_left, 

877 ), 

878 "notification_message_args": {}, 

879 "details": [ 

880 (_l("Aircraft"), ac.registration), 

881 (_l("Document"), ref), 

882 (_l("Due"), status_row.next_review_date.isoformat()), 

883 ], 

884 "expiry_value": status_row.next_review_date.isoformat(), 

885 }, 

886 subject_ref=f"airworthiness_status:{status_row.id}", 

887 ) 

888 

889 

890def _check_renter_authorizations(app: Any) -> None: 

891 """One digest notification per tenant listing every renter authorization 

892 whose expires_on or medical_valid_until falls within the threshold — 

893 not one email per authorization (has_content guard: nothing to report 

894 means no dispatch call at all).""" 

895 from flask_babel import ( # pyright: ignore[reportMissingImports] 

896 lazy_gettext as _l, 

897 ) 

898 from flask_babel import ( 

899 lazy_ngettext as _ln, 

900 ) 

901 from models import ( # pyright: ignore[reportMissingImports] 

902 NotificationType as NT, 

903 ) 

904 from models import ( 

905 RenterAuthorization, 

906 Tenant, 

907 ) 

908 

909 today = date.today() 

910 threshold = ( 

911 NT.SYSTEM_DEFAULTS[NT.RENTER_AUTHORIZATION_EXPIRY]["threshold_days"] or 30 

912 ) 

913 for tenant in Tenant.query.filter_by(is_active=True).all(): 

914 rows: list[tuple[Any, str, date]] = [] 

915 for auth in RenterAuthorization.query.filter_by( 

916 tenant_id=tenant.id, revoked_at=None 

917 ).all(): 

918 for label, expiry in ( 

919 ("authorization", auth.expires_on), 

920 ("medical", auth.medical_valid_until), 

921 ): 

922 if expiry is None: 

923 continue 

924 days_left = (expiry - today).days 

925 if 0 <= days_left <= threshold: 

926 rows.append((auth, label, expiry)) 

927 

928 if not rows: # has_content guard — nothing soon-expiring, no email 

929 continue 

930 

931 # Internal loop markers ("authorization"/"medical") are looked up 

932 # against a translated-label map rather than displayed directly -- 

933 # embedding a lazy string in an f-string would force it to resolve 

934 # immediately in whatever locale is active at check-time, not the 

935 # eventual recipient's, so the translated value is built with _l() 

936 # instead and stays lazy until Jinja renders it per-recipient. 

937 # "medical certificate" here is deliberately the same msgid as the 

938 # pilot's-own-medical label in _check_medical_and_sep (label_lower) 

939 # -- same real-world concept (medical certificate validity), same 

940 # translation, not a coincidence to be flagged. 

941 _label_text = { 

942 "authorization": _l("authorization"), 

943 "medical": _l("medical certificate"), 

944 } 

945 details = [] 

946 for auth, label, expiry in rows: 

947 renter_name = ( 

948 auth.renter_user.display_name if auth.renter_user else _l("unknown") 

949 ) 

950 details.append( 

951 ( 

952 renter_name, 

953 _l( 

954 "%(label)s expires %(date)s", 

955 label=_label_text[label], 

956 date=expiry.isoformat(), 

957 ), 

958 ) 

959 ) 

960 

961 # Two independent countable quantities in one sentence (item count and 

962 # day count) — ngettext only picks one plural form per call, so each 

963 # is pluralized separately (as its own fully-resolved lazy fragment) 

964 # and dropped into an outer, non-plural template. 

965 item_count_phrase = _ln( 

966 "One renter authorization", 

967 "%(n)s renter authorizations", 

968 len(rows), 

969 n=len(rows), 

970 ) 

971 day_count_phrase = _ln( 

972 "one day", 

973 "%(threshold)s days", 

974 threshold, 

975 threshold=threshold, 

976 ) 

977 _dispatch_in_context( 

978 NT.RENTER_AUTHORIZATION_EXPIRY, 

979 tenant.id, 

980 { 

981 "subject_key": _ln( 

982 "One renter authorization expiring soon", 

983 "%(n)s renter authorizations expiring soon", 

984 len(rows), 

985 n=len(rows), 

986 ), 

987 "subject_args": {}, 

988 "notification_title_key": _l("Renter authorizations expiring soon"), 

989 "notification_title_args": {}, 

990 "notification_message_key": _l( 

991 "%(items)s or medical validity dates expire within %(days)s." 

992 ), 

993 "notification_message_args": { 

994 "items": item_count_phrase, 

995 "days": day_count_phrase, 

996 }, 

997 "details": details, 

998 }, 

999 subject_ref=f"tenant:{tenant.id}", 

1000 ) 

1001 

1002 

1003def _check_personal_minimums_recency(app: Any) -> None: 

1004 """One notification per pilot listing every personal-minimums recency 

1005 item they have exceeded (has_content guard: no breaches, no dispatch). 

1006 Only pilots with an active revision and at least one tagged, breached 

1007 item are considered.""" 

1008 from flask_babel import ( # pyright: ignore[reportMissingImports] 

1009 lazy_gettext as _l, 

1010 ) 

1011 from flask_babel import ( 

1012 lazy_ngettext as _ln, 

1013 ) 

1014 from models import ( # pyright: ignore[reportMissingImports] 

1015 NotificationType as NT, 

1016 ) 

1017 from models import ( 

1018 PersonalMinimumsRevision, 

1019 PersonalMinimumsStatus, 

1020 TenantUser, 

1021 User, 

1022 db, 

1023 ) 

1024 from pilots.personal_minimums import ( 

1025 recency_breaches, # pyright: ignore[reportMissingImports] 

1026 ) 

1027 

1028 for revision in PersonalMinimumsRevision.query.filter_by( 

1029 status=PersonalMinimumsStatus.ACTIVE 

1030 ).all(): 

1031 user = db.session.get(User, revision.user_id) 

1032 if user is None or not user.is_active: 

1033 continue 

1034 tu = TenantUser.query.filter_by(user_id=user.id).first() 

1035 if tu is None: 

1036 continue 

1037 

1038 breaches = recency_breaches(revision, user.id) 

1039 if not breaches: # has_content guard 

1040 continue 

1041 

1042 # Same phrasing (and translations) as the identical breach data 

1043 # shown in app/templates/pilots/logbook.html. 

1044 details = [] 

1045 for b in breaches: 

1046 if b["days_since"] is None: 

1047 days_txt = _l( 

1048 "no matching flight on record yet (comfort zone: %(threshold)s days).", 

1049 threshold=b["threshold"], 

1050 ) 

1051 else: 

1052 days_txt = _l( 

1053 "%(days)s days since your last matching flight (comfort zone: %(threshold)s days).", 

1054 days=b["days_since"], 

1055 threshold=b["threshold"], 

1056 ) 

1057 details.append((b["item"].label, days_txt)) 

1058 

1059 _dispatch_in_context( 

1060 NT.PERSONAL_MINIMUMS_RECENCY, 

1061 tu.tenant_id, 

1062 { 

1063 "subject_key": _ln( 

1064 "Personal minimums: one recency threshold exceeded", 

1065 "Personal minimums: %(n)s recency thresholds exceeded", 

1066 len(breaches), 

1067 n=len(breaches), 

1068 ), 

1069 "subject_args": {}, 

1070 "notification_title_key": _l("Personal minimums recency reminder"), 

1071 "notification_title_args": {}, 

1072 "notification_message_key": _ln( 

1073 "You have exceeded one recency threshold in your " 

1074 "personal minimums.", 

1075 "You have exceeded %(n)s recency thresholds in your " 

1076 "personal minimums.", 

1077 len(breaches), 

1078 n=len(breaches), 

1079 ), 

1080 "notification_message_args": {}, 

1081 "details": details, 

1082 }, 

1083 target_user_ids=[user.id], 

1084 subject_ref=f"user:{user.id}", 

1085 ) 

1086 

1087 

1088def _dispatch_in_context( 

1089 notification_type: str, 

1090 tenant_id: int, 

1091 email_context: dict[str, Any], 

1092 target_user_ids: list[int] | None = None, 

1093 subject_ref: str | None = None, 

1094) -> None: 

1095 """Call dispatch() safely, logging any errors.""" 

1096 try: 

1097 dispatch( 

1098 notification_type, 

1099 tenant_id, 

1100 email_context, 

1101 target_user_ids, 

1102 subject_ref=subject_ref, 

1103 ) 

1104 except Exception as exc: # noqa: BLE001 -- caller name says it: dispatch safely, never raise 

1105 log.error( 

1106 "Error dispatching notification for tenant %d: %s", 

1107 tenant_id, 

1108 type(exc).__name__, 

1109 ) 

1110 

1111 

1112# ── Welcome email ────────────────────────────────────────────────────────────── 

1113 

1114 

1115def _try_welcome_lock(db: Any) -> bool: 

1116 """Return False if another gunicorn worker already holds the startup lock.""" 

1117 if db.engine.dialect.name != "postgresql": 

1118 return True 

1119 from sqlalchemy import text as _text # pyright: ignore[reportMissingImports] 

1120 

1121 return bool( 

1122 db.session.execute( 

1123 _text("SELECT pg_try_advisory_xact_lock(7283910456)") 

1124 ).scalar() 

1125 ) 

1126 

1127 

1128def send_welcome_email_if_needed(app: Any) -> None: 

1129 """Send one-time welcome email to the instance owner. Called at startup.""" 

1130 try: 

1131 with app.app_context(): 

1132 import os 

1133 

1134 from models import ( # pyright: ignore[reportMissingImports] 

1135 AppSetting, 

1136 User, 

1137 db, 

1138 ) 

1139 

1140 from services.email_service import ( # pyright: ignore[reportMissingImports] 

1141 send_email, 

1142 ) 

1143 

1144 if db.session.get(AppSetting, "welcome_email_sent"): 

1145 return 

1146 if not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip(): 

1147 return 

1148 

1149 # Guard against all gunicorn workers racing at startup. 

1150 if not _try_welcome_lock(db): 

1151 return 

1152 

1153 # Re-check after acquiring the lock: another worker may have 

1154 # finished sending while we were waiting to acquire it. 

1155 db.session.expire_all() 

1156 if db.session.get(AppSetting, "welcome_email_sent"): 

1157 return 

1158 

1159 owner = ( 

1160 User.query.filter_by(is_instance_admin=True).order_by(User.id).first() 

1161 ) 

1162 if not owner: 

1163 return 

1164 

1165 from flask import render_template # pyright: ignore[reportMissingImports] 

1166 from flask_babel import ( # pyright: ignore[reportMissingImports] 

1167 force_locale, 

1168 gettext, 

1169 ) 

1170 

1171 locale = owner.language or "en" 

1172 instance_url = os.environ.get("OPENHANGAR_INSTANCE_URL", "").strip() or None 

1173 with force_locale(locale): 

1174 subject = gettext("Welcome to your OpenHangar instance") 

1175 greeting = gettext("Hello %(name)s,") % {"name": owner.display_name} 

1176 body_text = gettext( 

1177 "Welcome to OpenHangar! Your instance is set up and email" 

1178 " delivery is working.\n\n" 

1179 "You can configure notification preferences for all users" 

1180 " under Configuration → Email Notifications.\n\n" 

1181 "Fly safely!\n\nThe OpenHangar team" 

1182 ) 

1183 text_body = greeting + "\n\n" + body_text 

1184 body_html = render_template( 

1185 "email/notif/welcome.html", 

1186 owner=owner, 

1187 repo_url=_REPO_URL, 

1188 subject=subject, 

1189 instance_url=instance_url, 

1190 ) 

1191 html_body = render_template( 

1192 "email/base_email.html", 

1193 body=body_html, 

1194 subject=subject, 

1195 repo_url=_REPO_URL, 

1196 instance_url=instance_url, 

1197 ) 

1198 

1199 send_email( 

1200 to=owner.email, 

1201 subject=subject, 

1202 text_body=text_body, 

1203 html_body=html_body, 

1204 locale=owner.language or "en", 

1205 ) 

1206 

1207 db.session.add(AppSetting(key="welcome_email_sent", value="true")) 

1208 db.session.commit() 

1209 log.info("Welcome email sent to %s", owner.email) 

1210 except Exception as exc: # noqa: BLE001 -- best-effort, explicitly will not retry 

1211 log.error("Failed to send welcome email (will not retry): %s", exc)