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
« 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.
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
9All functions that touch the DB must be called within an app context.
10"""
12import logging
13from datetime import date
14from typing import Any
16log = logging.getLogger(__name__)
18_REPO_URL = "https://github.com/e2jk/OpenHangar"
21# ── Preference lookup ──────────────────────────────────────────────────────────
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 )
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 }
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 }
61 return dict(
62 NotificationType.SYSTEM_DEFAULTS.get(
63 notification_type, {"enabled": False, "threshold_days": None}
64 )
65 )
68# ── Recipient resolution ───────────────────────────────────────────────────────
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]
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
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 )
98 required = set(NotificationType.REQUIRED_CAPS.get(notification_type, []))
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))
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
116# ── Branding ──────────────────────────────────────────────────────────────────
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 )
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
135# ── Template rendering ─────────────────────────────────────────────────────────
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
144 from flask import render_template # pyright: ignore[reportMissingImports]
145 from flask_babel import force_locale # pyright: ignore[reportMissingImports]
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
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)
172# ── Send-log dedup + per-instance snooze ────────────────────────────────────────
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]
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 )
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 )
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")
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 )
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()
242 if row is not None and row.snoozed_value == expiry_value:
243 return True, _snooze_url(row.token)
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)
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
272 from flask import current_app # pyright: ignore[reportMissingImports]
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
281# ── Dispatch ──────────────────────────────────────────────────────────────────
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.
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]
313 from services.email_service import ( # pyright: ignore[reportMissingImports]
314 EmailNotConfiguredError,
315 EmailSendError,
316 send_email,
317 )
319 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first()
320 recipients = _find_recipients(notification_type, tenant_id, target_user_ids)
322 for user in recipients:
323 pref = get_effective_preference(user.id, tenant_id, notification_type)
324 if not pref["enabled"]:
325 continue
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}
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
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
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)
407# ── Daily expiry checks ────────────────────────────────────────────────────────
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 )
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()
434def run_daily_checks(app: Any) -> None:
435 """Check all expiry-based notification types across all tenants. Runs in background thread.
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]
443 from services.advisory_lock import (
444 advisory_lock_scope, # pyright: ignore[reportMissingImports]
445 )
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 )
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")
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]
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 )
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
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 )
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
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 )
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
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
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 )
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
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 )
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 )
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 )
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 )
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))
963 if not rows: # has_content guard — nothing soon-expiring, no email
964 continue
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 )
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 )
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 )
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
1073 breaches = recency_breaches(revision, user.id)
1074 if not breaches: # has_content guard
1075 continue
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))
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 )
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 )
1147# ── Welcome email ──────────────────────────────────────────────────────────────
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]
1156 return bool(
1157 db.session.execute(
1158 _text("SELECT pg_try_advisory_xact_lock(7283910456)")
1159 ).scalar()
1160 )
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
1169 from models import ( # pyright: ignore[reportMissingImports]
1170 AppSetting,
1171 User,
1172 db,
1173 )
1175 from services.email_service import ( # pyright: ignore[reportMissingImports]
1176 send_email,
1177 )
1179 if db.session.get(AppSetting, "welcome_email_sent"):
1180 return
1181 if not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip():
1182 return
1184 # Guard against all gunicorn workers racing at startup.
1185 if not _try_welcome_lock(db):
1186 return
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
1194 owner = (
1195 User.query.filter_by(is_instance_admin=True).order_by(User.id).first()
1196 )
1197 if not owner:
1198 return
1200 from flask import render_template # pyright: ignore[reportMissingImports]
1201 from flask_babel import ( # pyright: ignore[reportMissingImports]
1202 force_locale,
1203 gettext,
1204 )
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 )
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 )
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)