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

387 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 15:29 +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 documents.routes import ( # pyright: ignore[reportMissingImports] 

565 effective_coverage_until, 

566 ) 

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

568 lazy_gettext as _l, 

569 ) 

570 from flask_babel import ( 

571 lazy_ngettext as _ln, 

572 ) 

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

574 Aircraft, 

575 DocType, 

576 Tenant, 

577 ) 

578 from models import NotificationType as NT 

579 

580 today = date.today() 

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

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

583 if ac.insurance_expiry is None: 

584 continue 

585 # A renewal already on file (e.g. next quarter's cert uploaded a 

586 # month ahead) can push real coverage well past this document's 

587 # own expiry -- warn about the actual end of unbroken coverage, 

588 # not this one document, or a renewal already secured triggers 

589 # a false alarm every time (see the docstring on 

590 # effective_coverage_until for the gap-detection rules). 

591 # Falls back to the cached field itself when there's no backing 

592 # Document to walk (e.g. dev/demo seed data, which sets this 

593 # field directly) -- same behaviour as before this chain check 

594 # existed, for that case. 

595 covered_until = ( 

596 effective_coverage_until(ac.id, DocType.INSURANCE_CERT) 

597 or ac.insurance_expiry 

598 ) 

599 days_left = (covered_until - today).days 

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

601 threshold = ( 

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

603 ) 

604 if 0 <= days_left <= threshold: 

605 _dispatch_in_context( 

606 NT.INSURANCE_EXPIRING, 

607 tenant.id, 

608 { 

609 "subject_key": _ln( 

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

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

612 days_left, 

613 days=days_left, 

614 reg=ac.registration, 

615 ), 

616 "subject_args": {}, 

617 "notification_title_key": _l( 

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

619 ), 

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

621 "notification_message_key": _ln( 

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

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

624 days_left, 

625 reg=ac.registration, 

626 date=covered_until.isoformat(), 

627 days=days_left, 

628 ), 

629 "notification_message_args": {}, 

630 "details": [ 

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

632 (_l("Expires"), covered_until.isoformat()), 

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

634 ], 

635 "expiry_value": covered_until.isoformat(), 

636 }, 

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

638 ) 

639 

640 

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

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

643 effective_coverage_until, 

644 ) 

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

646 lazy_gettext as _l, 

647 ) 

648 from flask_babel import ( 

649 lazy_ngettext as _ln, 

650 ) 

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

652 Aircraft, 

653 DocType, 

654 Tenant, 

655 ) 

656 from models import NotificationType as NT 

657 

658 today = date.today() 

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

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

661 if ac.arc_expiry is None: 

662 continue 

663 # See the matching comment in _check_insurance -- a renewal 

664 # already on file can push real coverage well past this 

665 # document's own expiry, and the fallback covers the same 

666 # no-backing-Document case (e.g. dev/demo seed data). 

667 covered_until = ( 

668 effective_coverage_until(ac.id, DocType.ARC) or ac.arc_expiry 

669 ) 

670 days_left = (covered_until - today).days 

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

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

673 if 0 <= days_left <= threshold: 

674 _dispatch_in_context( 

675 NT.ARC_EXPIRY, 

676 tenant.id, 

677 { 

678 "subject_key": _ln( 

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

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

681 days_left, 

682 days=days_left, 

683 reg=ac.registration, 

684 ), 

685 "subject_args": {}, 

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

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

688 "notification_message_key": _ln( 

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

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

691 days_left, 

692 reg=ac.registration, 

693 date=covered_until.isoformat(), 

694 days=days_left, 

695 ), 

696 "notification_message_args": {}, 

697 "details": [ 

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

699 (_l("Expires"), covered_until.isoformat()), 

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

701 ], 

702 "expiry_value": covered_until.isoformat(), 

703 }, 

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

705 ) 

706 

707 

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

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

710 lazy_gettext as _l, 

711 ) 

712 from flask_babel import ( 

713 lazy_ngettext as _ln, 

714 ) 

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

716 from models import PilotProfile, TenantUser, User, db 

717 

718 today = date.today() 

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

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

721 if user is None or not user.is_active: 

722 continue 

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

724 if tu is None: 

725 continue 

726 

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

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

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

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

731 for notif_type, expiry, label, label_lower in [ 

732 ( 

733 NT.MEDICAL_EXPIRING, 

734 profile.medical_expiry, 

735 _l("Medical certificate"), 

736 _l("medical certificate"), 

737 ), 

738 ( 

739 NT.SEP_RATING_EXPIRING, 

740 profile.sep_expiry, 

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

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

743 # coincidental duplicate. 

744 _l("SEP endorsement"), 

745 _l("SEP endorsement"), 

746 ), 

747 ]: 

748 if expiry is None: 

749 continue 

750 days_left = (expiry - today).days 

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

752 if 0 <= days_left <= threshold: 

753 _dispatch_in_context( 

754 notif_type, 

755 tu.tenant_id, 

756 { 

757 "subject_key": _ln( 

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

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

760 days_left, 

761 label=label, 

762 days=days_left, 

763 ), 

764 "subject_args": {}, 

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

766 "notification_title_args": {"label": label}, 

767 "notification_message_key": _ln( 

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

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

770 days_left, 

771 label_lower=label_lower, 

772 date=expiry.isoformat(), 

773 days=days_left, 

774 ), 

775 "notification_message_args": {}, 

776 "details": [ 

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

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

779 ], 

780 "expiry_value": expiry.isoformat(), 

781 }, 

782 target_user_ids=[user.id], 

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

784 ) 

785 

786 

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

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

789 _EXPIRY_DRIVING_DOC_TYPES, 

790 ) 

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

792 lazy_gettext as _l, 

793 ) 

794 from flask_babel import ( 

795 lazy_ngettext as _ln, 

796 ) 

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

798 Aircraft, 

799 Document, 

800 Tenant, 

801 ) 

802 from models import NotificationType as NT 

803 

804 today = date.today() 

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

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

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

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

809 if doc.valid_until is None: 

810 continue 

811 # ARC/Insurance documents already drive their own dedicated 

812 # check (_check_arc / _check_insurance) off the synced 

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

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

815 # same underlying deadline. 

816 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES: 

817 continue 

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

819 if 0 <= days_left <= threshold: 

820 title = doc.title or doc.original_filename 

821 _dispatch_in_context( 

822 NT.DOCUMENT_EXPIRING, 

823 tenant.id, 

824 { 

825 "subject_key": _ln( 

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

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

828 days_left, 

829 days=days_left, 

830 title=title, 

831 ), 

832 "subject_args": {}, 

833 "notification_title_key": _l( 

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

835 ), 

836 "notification_title_args": {"title": title}, 

837 "notification_message_key": _ln( 

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

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

840 days_left, 

841 title=title, 

842 reg=ac.registration, 

843 date=doc.valid_until.isoformat(), 

844 days=days_left, 

845 ), 

846 "notification_message_args": {}, 

847 "details": [ 

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

849 (_l("Document"), title), 

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

851 ], 

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

853 }, 

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

855 ) 

856 

857 

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

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

860 lazy_gettext as _l, 

861 ) 

862 from flask_babel import ( 

863 lazy_ngettext as _ln, 

864 ) 

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

866 Aircraft, 

867 AirworthinessDocumentStatus, 

868 Tenant, 

869 ) 

870 from models import ( 

871 NotificationType as NT, 

872 ) 

873 

874 today = date.today() 

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

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

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

878 for status_row in AirworthinessDocumentStatus.query.filter_by( 

879 aircraft_id=ac.id 

880 ).all(): 

881 if status_row.next_review_date is None: 

882 continue 

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

884 if 0 <= days_left <= threshold: 

885 doc = status_row.document 

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

887 _dispatch_in_context( 

888 NT.AIRWORTHINESS_REVIEW_DUE, 

889 tenant.id, 

890 { 

891 "subject_key": _ln( 

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

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

894 days_left, 

895 days=days_left, 

896 ref=ref, 

897 reg=ac.registration, 

898 ), 

899 "subject_args": {}, 

900 "notification_title_key": _l( 

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

902 ), 

903 "notification_title_args": {"ref": ref}, 

904 "notification_message_key": _ln( 

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

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

907 days_left, 

908 ref=ref, 

909 reg=ac.registration, 

910 date=status_row.next_review_date.isoformat(), 

911 days=days_left, 

912 ), 

913 "notification_message_args": {}, 

914 "details": [ 

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

916 (_l("Document"), ref), 

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

918 ], 

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

920 }, 

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

922 ) 

923 

924 

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

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

927 whose expires_on or medical_valid_until falls within the threshold — 

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

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

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

931 lazy_gettext as _l, 

932 ) 

933 from flask_babel import ( 

934 lazy_ngettext as _ln, 

935 ) 

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

937 NotificationType as NT, 

938 ) 

939 from models import ( 

940 RenterAuthorization, 

941 Tenant, 

942 ) 

943 

944 today = date.today() 

945 threshold = ( 

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

947 ) 

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

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

950 for auth in RenterAuthorization.query.filter_by( 

951 tenant_id=tenant.id, revoked_at=None 

952 ).all(): 

953 for label, expiry in ( 

954 ("authorization", auth.expires_on), 

955 ("medical", auth.medical_valid_until), 

956 ): 

957 if expiry is None: 

958 continue 

959 days_left = (expiry - today).days 

960 if 0 <= days_left <= threshold: 

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

962 

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

964 continue 

965 

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

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

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

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

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

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

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

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

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

975 # translation, not a coincidence to be flagged. 

976 _label_text = { 

977 "authorization": _l("authorization"), 

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

979 } 

980 details = [] 

981 for auth, label, expiry in rows: 

982 renter_name = ( 

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

984 ) 

985 details.append( 

986 ( 

987 renter_name, 

988 _l( 

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

990 label=_label_text[label], 

991 date=expiry.isoformat(), 

992 ), 

993 ) 

994 ) 

995 

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

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

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

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

1000 item_count_phrase = _ln( 

1001 "One renter authorization", 

1002 "%(n)s renter authorizations", 

1003 len(rows), 

1004 n=len(rows), 

1005 ) 

1006 day_count_phrase = _ln( 

1007 "one day", 

1008 "%(n)s days", 

1009 threshold, 

1010 n=threshold, 

1011 ) 

1012 _dispatch_in_context( 

1013 NT.RENTER_AUTHORIZATION_EXPIRY, 

1014 tenant.id, 

1015 { 

1016 "subject_key": _ln( 

1017 "One renter authorization expiring soon", 

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

1019 len(rows), 

1020 n=len(rows), 

1021 ), 

1022 "subject_args": {}, 

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

1024 "notification_title_args": {}, 

1025 "notification_message_key": _l( 

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

1027 ), 

1028 "notification_message_args": { 

1029 "items": item_count_phrase, 

1030 "days": day_count_phrase, 

1031 }, 

1032 "details": details, 

1033 }, 

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

1035 ) 

1036 

1037 

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

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

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

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

1042 item are considered.""" 

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

1044 lazy_gettext as _l, 

1045 ) 

1046 from flask_babel import ( 

1047 lazy_ngettext as _ln, 

1048 ) 

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

1050 NotificationType as NT, 

1051 ) 

1052 from models import ( 

1053 PersonalMinimumsRevision, 

1054 PersonalMinimumsStatus, 

1055 TenantUser, 

1056 User, 

1057 db, 

1058 ) 

1059 from pilots.personal_minimums import ( 

1060 recency_breaches, # pyright: ignore[reportMissingImports] 

1061 ) 

1062 

1063 for revision in PersonalMinimumsRevision.query.filter_by( 

1064 status=PersonalMinimumsStatus.ACTIVE 

1065 ).all(): 

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

1067 if user is None or not user.is_active: 

1068 continue 

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

1070 if tu is None: 

1071 continue 

1072 

1073 breaches = recency_breaches(revision, user.id) 

1074 if not breaches: # has_content guard 

1075 continue 

1076 

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

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

1079 details = [] 

1080 for b in breaches: 

1081 if b["days_since"] is None: 

1082 days_txt = _l( 

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

1084 threshold=b["threshold"], 

1085 ) 

1086 else: 

1087 days_txt = _l( 

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

1089 days=b["days_since"], 

1090 threshold=b["threshold"], 

1091 ) 

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

1093 

1094 _dispatch_in_context( 

1095 NT.PERSONAL_MINIMUMS_RECENCY, 

1096 tu.tenant_id, 

1097 { 

1098 "subject_key": _ln( 

1099 "Personal minimums: one recency threshold exceeded", 

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

1101 len(breaches), 

1102 n=len(breaches), 

1103 ), 

1104 "subject_args": {}, 

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

1106 "notification_title_args": {}, 

1107 "notification_message_key": _ln( 

1108 "You have exceeded one recency threshold in your " 

1109 "personal minimums.", 

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

1111 "personal minimums.", 

1112 len(breaches), 

1113 n=len(breaches), 

1114 ), 

1115 "notification_message_args": {}, 

1116 "details": details, 

1117 }, 

1118 target_user_ids=[user.id], 

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

1120 ) 

1121 

1122 

1123def _dispatch_in_context( 

1124 notification_type: str, 

1125 tenant_id: int, 

1126 email_context: dict[str, Any], 

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

1128 subject_ref: str | None = None, 

1129) -> None: 

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

1131 try: 

1132 dispatch( 

1133 notification_type, 

1134 tenant_id, 

1135 email_context, 

1136 target_user_ids, 

1137 subject_ref=subject_ref, 

1138 ) 

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

1140 log.error( 

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

1142 tenant_id, 

1143 type(exc).__name__, 

1144 ) 

1145 

1146 

1147# ── Welcome email ────────────────────────────────────────────────────────────── 

1148 

1149 

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

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

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

1153 return True 

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

1155 

1156 return bool( 

1157 db.session.execute( 

1158 _text("SELECT pg_try_advisory_xact_lock(7283910456)") 

1159 ).scalar() 

1160 ) 

1161 

1162 

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

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

1165 try: 

1166 with app.app_context(): 

1167 import os 

1168 

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

1170 AppSetting, 

1171 User, 

1172 db, 

1173 ) 

1174 

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

1176 send_email, 

1177 ) 

1178 

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

1180 return 

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

1182 return 

1183 

1184 # Guard against all gunicorn workers racing at startup. 

1185 if not _try_welcome_lock(db): 

1186 return 

1187 

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

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

1190 db.session.expire_all() 

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

1192 return 

1193 

1194 owner = ( 

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

1196 ) 

1197 if not owner: 

1198 return 

1199 

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

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

1202 force_locale, 

1203 gettext, 

1204 ) 

1205 

1206 locale = owner.language or "en" 

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

1208 with force_locale(locale): 

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

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

1211 body_text = gettext( 

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

1213 " delivery is working.\n\n" 

1214 "You can configure notification preferences for all users" 

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

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

1217 ) 

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

1219 body_html = render_template( 

1220 "email/notif/welcome.html", 

1221 owner=owner, 

1222 repo_url=_REPO_URL, 

1223 subject=subject, 

1224 instance_url=instance_url, 

1225 ) 

1226 html_body = render_template( 

1227 "email/base_email.html", 

1228 body=body_html, 

1229 subject=subject, 

1230 repo_url=_REPO_URL, 

1231 instance_url=instance_url, 

1232 ) 

1233 

1234 send_email( 

1235 to=owner.email, 

1236 subject=subject, 

1237 text_body=text_body, 

1238 html_body=html_body, 

1239 locale=owner.language or "en", 

1240 ) 

1241 

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

1243 db.session.commit() 

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

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

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