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

213 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 23:33 +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 NotificationType, 

31 TenantNotificationDefault, 

32 db, 

33 ) 

34 

35 user_pref = ( 

36 db.session.query(NP) 

37 .filter_by( 

38 user_id=user_id, tenant_id=tenant_id, notification_type=notification_type 

39 ) 

40 .first() 

41 ) 

42 if user_pref is not None: 

43 return { 

44 "enabled": user_pref.enabled, 

45 "threshold_days": user_pref.threshold_days, 

46 } 

47 

48 tenant_def = ( 

49 db.session.query(TenantNotificationDefault) 

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

51 .first() 

52 ) 

53 if tenant_def is not None: 

54 return { 

55 "enabled": tenant_def.enabled, 

56 "threshold_days": tenant_def.threshold_days, 

57 } 

58 

59 return dict( 

60 NotificationType.SYSTEM_DEFAULTS.get( 

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

62 ) 

63 ) 

64 

65 

66# ── Recipient resolution ─────────────────────────────────────────────────────── 

67 

68 

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

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

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

72 

73 caps: set[str] = set() 

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

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

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

77 caps.add("is_pilot") 

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

79 caps.add("is_maint") 

80 if role == Role.INSTRUCTOR: 

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

82 return caps 

83 

84 

85def _find_recipients( 

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

87) -> list[Any]: 

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

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

90 NotificationType, 

91 TenantUser, 

92 User, 

93 db, 

94 ) 

95 

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

97 

98 query = ( 

99 db.session.query(User, TenantUser) 

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

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

102 ) 

103 if target_user_ids is not None: 

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

105 

106 recipients = [] 

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

108 caps = _user_caps(tu.role, user) 

109 if caps & required: 

110 recipients.append(user) 

111 return recipients 

112 

113 

114# ── Branding ────────────────────────────────────────────────────────────────── 

115 

116 

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

118 if profile is None: 

119 return "OpenHangar" 

120 return ( 

121 profile.club_name 

122 or profile.school_name 

123 or profile.organisation_name 

124 or "OpenHangar" 

125 ) 

126 

127 

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

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

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

131 

132 

133# ── Template rendering ───────────────────────────────────────────────────────── 

134 

135 

136def _render_email( 

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

138) -> tuple[str, str]: 

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

140 import os 

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

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

143 

144 ctx.setdefault("repo_url", _REPO_URL) 

145 ctx.setdefault( 

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

147 ) 

148 with force_locale(locale): 

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

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

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

152 

153 

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

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

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

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

158 lines = [title, "", message] 

159 if context.get("details"): 

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

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

162 if context.get("cta_url"): 

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

164 return "\n".join(lines) 

165 

166 

167# ── Dispatch ────────────────────────────────────────────────────────────────── 

168 

169 

170def dispatch( 

171 notification_type: str, 

172 tenant_id: int, 

173 email_context: dict[str, Any], 

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

175) -> None: 

176 """ 

177 Find all eligible recipients and send notification emails. 

178 

179 Must be called within an app context. 

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

181 """ 

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

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

184 EmailNotConfiguredError, 

185 EmailSendError, 

186 send_email, 

187 ) 

188 

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

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

191 subject = _build_subject(base_subject, profile) 

192 

193 recipients = _find_recipients(notification_type, tenant_id, target_user_ids) 

194 

195 for user in recipients: 

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

197 if not pref["enabled"]: 

198 continue 

199 

200 # Merge threshold from preference into context 

201 ctx = dict(email_context) 

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

203 ctx["subject"] = subject 

204 ctx["recipient_name"] = user.display_name 

205 

206 text_body = _text_for(notification_type, ctx) 

207 try: 

208 _text, html_body = _render_email( 

209 "generic.html", locale=user.language or "en", text_body=text_body, **ctx 

210 ) 

211 send_email( 

212 to=user.email, 

213 subject=subject, 

214 text_body=text_body, 

215 html_body=html_body, 

216 locale=user.language or "en", 

217 ) 

218 except EmailNotConfiguredError: 

219 return # SMTP not configured — stop trying all recipients 

220 except EmailSendError as exc: 

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

222 except Exception: 

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

224 

225 

226# ── Daily expiry checks ──────────────────────────────────────────────────────── 

227 

228 

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

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

231 with app.app_context(): 

232 try: 

233 _check_maintenance(app) 

234 _check_insurance(app) 

235 _check_medical_and_sep(app) 

236 _check_documents(app) 

237 _check_airworthiness_reviews(app) 

238 except Exception: 

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

240 

241 

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

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

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

245 

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

247 aircraft_list = Aircraft.query.filter_by(tenant_id=tenant.id).all() 

248 for ac in aircraft_list: 

249 hobbs = ac.total_engine_hours 

250 for trigger in ac.maintenance_triggers: 

251 status = trigger.status(hobbs) 

252 if status == "overdue": 

253 _dispatch_in_context( 

254 NT.MAINTENANCE_OVERDUE, 

255 tenant.id, 

256 { 

257 "subject": f"Maintenance overdue: {trigger.name} on {ac.registration}", 

258 "notification_title": f"Maintenance overdue: {trigger.name}", 

259 "notification_message": f"{trigger.name} on {ac.registration} is overdue.", 

260 "details": [ 

261 ("Aircraft", ac.registration), 

262 ("Item", trigger.name), 

263 ], 

264 }, 

265 ) 

266 elif status == "due_soon": 

267 _dispatch_in_context( 

268 NT.MAINTENANCE_DUE_SOON, 

269 tenant.id, 

270 { 

271 "subject": f"Maintenance due soon: {trigger.name} on {ac.registration}", 

272 "notification_title": f"Maintenance due soon: {trigger.name}", 

273 "notification_message": f"{trigger.name} on {ac.registration} is coming due.", 

274 "details": [ 

275 ("Aircraft", ac.registration), 

276 ("Item", trigger.name), 

277 ], 

278 }, 

279 ) 

280 

281 

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

283 from models import Aircraft, NotificationType as NT, Tenant # pyright: ignore[reportMissingImports] 

284 

285 today = date.today() 

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

287 for ac in Aircraft.query.filter_by(tenant_id=tenant.id).all(): 

288 if ac.insurance_expiry is None: 

289 continue 

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

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

292 threshold = ( 

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

294 ) 

295 if 0 <= days_left <= threshold: 

296 _dispatch_in_context( 

297 NT.INSURANCE_EXPIRING, 

298 tenant.id, 

299 { 

300 "subject": f"Insurance expiring in {days_left} day(s): {ac.registration}", 

301 "notification_title": f"Insurance expiring soon: {ac.registration}", 

302 "notification_message": f"The insurance for {ac.registration} expires on {ac.insurance_expiry.isoformat()} ({days_left} day(s) remaining).", 

303 "details": [ 

304 ("Aircraft", ac.registration), 

305 ("Expires", ac.insurance_expiry.isoformat()), 

306 ("Days left", str(days_left)), 

307 ], 

308 }, 

309 ) 

310 

311 

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

313 from models import NotificationType as NT, PilotProfile, TenantUser, User, db # pyright: ignore[reportMissingImports] 

314 

315 today = date.today() 

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

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

318 if user is None or not user.is_active: 

319 continue 

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

321 if tu is None: 

322 continue 

323 

324 for notif_type, expiry, label in [ 

325 (NT.MEDICAL_EXPIRING, profile.medical_expiry, "Medical certificate"), 

326 (NT.SEP_RATING_EXPIRING, profile.sep_expiry, "SEP rating"), 

327 ]: 

328 if expiry is None: 

329 continue 

330 days_left = (expiry - today).days 

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

332 if 0 <= days_left <= threshold: 

333 _dispatch_in_context( 

334 notif_type, 

335 tu.tenant_id, 

336 { 

337 "subject": f"{label} expiring in {days_left} day(s)", 

338 "notification_title": f"{label} expiring soon", 

339 "notification_message": f"Your {label.lower()} expires on {expiry.isoformat()} ({days_left} day(s) remaining).", 

340 "details": [ 

341 ("Expires", expiry.isoformat()), 

342 ("Days left", str(days_left)), 

343 ], 

344 }, 

345 target_user_ids=[user.id], 

346 ) 

347 

348 

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

350 from models import Aircraft, Document, NotificationType as NT, Tenant # pyright: ignore[reportMissingImports] 

351 

352 today = date.today() 

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

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

355 for ac in Aircraft.query.filter_by(tenant_id=tenant.id).all(): 

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

357 if doc.valid_until is None: 

358 continue 

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

360 if 0 <= days_left <= threshold: 

361 title = doc.title or doc.original_filename 

362 _dispatch_in_context( 

363 NT.DOCUMENT_EXPIRING, 

364 tenant.id, 

365 { 

366 "subject": f"Document expiring in {days_left} day(s): {title}", 

367 "notification_title": f"Document expiring soon: {title}", 

368 "notification_message": f"'{title}' on {ac.registration} expires on {doc.valid_until.isoformat()} ({days_left} day(s) remaining).", 

369 "details": [ 

370 ("Aircraft", ac.registration), 

371 ("Document", title), 

372 ("Expires", doc.valid_until.isoformat()), 

373 ], 

374 }, 

375 ) 

376 

377 

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

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

380 Aircraft, 

381 AirworthinessDocumentStatus, 

382 NotificationType as NT, 

383 Tenant, 

384 ) 

385 

386 today = date.today() 

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

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

389 for ac in Aircraft.query.filter_by(tenant_id=tenant.id).all(): 

390 for status_row in AirworthinessDocumentStatus.query.filter_by( 

391 aircraft_id=ac.id 

392 ).all(): 

393 if status_row.next_review_date is None: 

394 continue 

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

396 if 0 <= days_left <= threshold: 

397 doc = status_row.document 

398 ref = doc.reference if doc else "unknown" 

399 _dispatch_in_context( 

400 NT.AIRWORTHINESS_REVIEW_DUE, 

401 tenant.id, 

402 { 

403 "subject": f"Airworthiness review due in {days_left} day(s): {ref} on {ac.registration}", 

404 "notification_title": f"Airworthiness review due: {ref}", 

405 "notification_message": f"Document {ref} on {ac.registration} requires review by {status_row.next_review_date.isoformat()} ({days_left} day(s)).", 

406 "details": [ 

407 ("Aircraft", ac.registration), 

408 ("Document", ref), 

409 ("Due", status_row.next_review_date.isoformat()), 

410 ], 

411 }, 

412 ) 

413 

414 

415def _dispatch_in_context( 

416 notification_type: str, 

417 tenant_id: int, 

418 email_context: dict[str, Any], 

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

420) -> None: 

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

422 try: 

423 dispatch(notification_type, tenant_id, email_context, target_user_ids) 

424 except Exception as exc: 

425 log.error( 

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

427 tenant_id, 

428 type(exc).__name__, 

429 ) 

430 

431 

432# ── Welcome email ────────────────────────────────────────────────────────────── 

433 

434 

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

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

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

438 return True 

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

440 

441 return bool( 

442 db.session.execute( 

443 _text("SELECT pg_try_advisory_xact_lock(7283910456)") 

444 ).scalar() 

445 ) 

446 

447 

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

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

450 try: 

451 with app.app_context(): 

452 import os 

453 from models import AppSetting, User, db # pyright: ignore[reportMissingImports] 

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

455 send_email, 

456 ) 

457 

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

459 return 

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

461 return 

462 

463 # Guard against all gunicorn workers racing at startup. 

464 if not _try_welcome_lock(db): 

465 return 

466 

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

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

469 db.session.expire_all() 

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

471 return 

472 

473 owner = ( 

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

475 ) 

476 if not owner: 

477 return 

478 

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

480 from flask_babel import force_locale, gettext # pyright: ignore[reportMissingImports] 

481 

482 locale = owner.language or "en" 

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

484 with force_locale(locale): 

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

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

487 body_text = gettext( 

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

489 " delivery is working.\n\n" 

490 "You can configure notification preferences for all users" 

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

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

493 ) 

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

495 body_html = render_template( 

496 "email/notif/welcome.html", 

497 owner=owner, 

498 repo_url=_REPO_URL, 

499 subject=subject, 

500 instance_url=instance_url, 

501 ) 

502 html_body = render_template( 

503 "email/base_email.html", 

504 body=body_html, 

505 subject=subject, 

506 repo_url=_REPO_URL, 

507 instance_url=instance_url, 

508 ) 

509 

510 send_email( 

511 to=owner.email, 

512 subject=subject, 

513 text_body=text_body, 

514 html_body=html_body, 

515 locale=owner.language or "en", 

516 ) 

517 

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

519 db.session.commit() 

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

521 except Exception as exc: 

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