Coverage for app/services/notification_service.py: 100%
383 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""
2Notification service — three-level preference lookup, email dispatch, daily checks.
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 flask_babel import ( # pyright: ignore[reportMissingImports]
565 lazy_gettext as _l,
566 )
567 from flask_babel import (
568 lazy_ngettext as _ln,
569 )
570 from models import Aircraft, Tenant # pyright: ignore[reportMissingImports]
571 from models import NotificationType as NT
573 today = date.today()
574 for tenant in Tenant.query.filter_by(is_active=True).all():
575 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all():
576 if ac.insurance_expiry is None:
577 continue
578 days_left = (ac.insurance_expiry - today).days
579 # Use system default threshold; recipient-level override applied in dispatch()
580 threshold = (
581 NT.SYSTEM_DEFAULTS[NT.INSURANCE_EXPIRING]["threshold_days"] or 30
582 )
583 if 0 <= days_left <= threshold:
584 _dispatch_in_context(
585 NT.INSURANCE_EXPIRING,
586 tenant.id,
587 {
588 "subject_key": _ln(
589 "Insurance expiring in one day: %(reg)s",
590 "Insurance expiring in %(days)s days: %(reg)s",
591 days_left,
592 days=days_left,
593 reg=ac.registration,
594 ),
595 "subject_args": {},
596 "notification_title_key": _l(
597 "Insurance expiring soon: %(reg)s"
598 ),
599 "notification_title_args": {"reg": ac.registration},
600 "notification_message_key": _ln(
601 "The insurance for %(reg)s expires on %(date)s (one day remaining).",
602 "The insurance for %(reg)s expires on %(date)s (%(days)s days remaining).",
603 days_left,
604 reg=ac.registration,
605 date=ac.insurance_expiry.isoformat(),
606 days=days_left,
607 ),
608 "notification_message_args": {},
609 "details": [
610 (_l("Aircraft"), ac.registration),
611 (_l("Expires"), ac.insurance_expiry.isoformat()),
612 (_l("Days left"), str(days_left)),
613 ],
614 "expiry_value": ac.insurance_expiry.isoformat(),
615 },
616 subject_ref=f"aircraft:{ac.id}",
617 )
620def _check_arc(app: Any) -> None:
621 from flask_babel import ( # pyright: ignore[reportMissingImports]
622 lazy_gettext as _l,
623 )
624 from flask_babel import (
625 lazy_ngettext as _ln,
626 )
627 from models import Aircraft, Tenant # pyright: ignore[reportMissingImports]
628 from models import NotificationType as NT
630 today = date.today()
631 for tenant in Tenant.query.filter_by(is_active=True).all():
632 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all():
633 if ac.arc_expiry is None:
634 continue
635 days_left = (ac.arc_expiry - today).days
636 # Use system default threshold; recipient-level override applied in dispatch()
637 threshold = NT.SYSTEM_DEFAULTS[NT.ARC_EXPIRY]["threshold_days"] or 60
638 if 0 <= days_left <= threshold:
639 _dispatch_in_context(
640 NT.ARC_EXPIRY,
641 tenant.id,
642 {
643 "subject_key": _ln(
644 "ARC expiring in one day: %(reg)s",
645 "ARC expiring in %(days)s days: %(reg)s",
646 days_left,
647 days=days_left,
648 reg=ac.registration,
649 ),
650 "subject_args": {},
651 "notification_title_key": _l("ARC expiring soon: %(reg)s"),
652 "notification_title_args": {"reg": ac.registration},
653 "notification_message_key": _ln(
654 "The ARC for %(reg)s expires on %(date)s (one day remaining).",
655 "The ARC for %(reg)s expires on %(date)s (%(days)s days remaining).",
656 days_left,
657 reg=ac.registration,
658 date=ac.arc_expiry.isoformat(),
659 days=days_left,
660 ),
661 "notification_message_args": {},
662 "details": [
663 (_l("Aircraft"), ac.registration),
664 (_l("Expires"), ac.arc_expiry.isoformat()),
665 (_l("Days left"), str(days_left)),
666 ],
667 "expiry_value": ac.arc_expiry.isoformat(),
668 },
669 subject_ref=f"aircraft:{ac.id}",
670 )
673def _check_medical_and_sep(app: Any) -> None:
674 from flask_babel import ( # pyright: ignore[reportMissingImports]
675 lazy_gettext as _l,
676 )
677 from flask_babel import (
678 lazy_ngettext as _ln,
679 )
680 from models import NotificationType as NT # pyright: ignore[reportMissingImports]
681 from models import PilotProfile, TenantUser, User, db
683 today = date.today()
684 for profile in PilotProfile.query.all():
685 user = db.session.get(User, profile.user_id)
686 if user is None or not user.is_active:
687 continue
688 tu = TenantUser.query.filter_by(user_id=user.id).first()
689 if tu is None:
690 continue
692 # Two translatable forms per item (not a mechanical .lower() of the
693 # capitalized one) -- lowercasing a translated string isn't a safe
694 # general i18n operation (case rules and even correct wording can
695 # differ by language), so each form is its own msgid.
696 for notif_type, expiry, label, label_lower in [
697 (
698 NT.MEDICAL_EXPIRING,
699 profile.medical_expiry,
700 _l("Medical certificate"),
701 _l("medical certificate"),
702 ),
703 (
704 NT.SEP_RATING_EXPIRING,
705 profile.sep_expiry,
706 # Same msgid as the dashboard's SEP section label
707 # (app/templates/dashboard.html) -- same concept, not a
708 # coincidental duplicate.
709 _l("SEP endorsement"),
710 _l("SEP endorsement"),
711 ),
712 ]:
713 if expiry is None:
714 continue
715 days_left = (expiry - today).days
716 threshold = NT.SYSTEM_DEFAULTS[notif_type]["threshold_days"] or 60
717 if 0 <= days_left <= threshold:
718 _dispatch_in_context(
719 notif_type,
720 tu.tenant_id,
721 {
722 "subject_key": _ln(
723 "%(label)s expiring in one day",
724 "%(label)s expiring in %(days)s days",
725 days_left,
726 label=label,
727 days=days_left,
728 ),
729 "subject_args": {},
730 "notification_title_key": _l("%(label)s expiring soon"),
731 "notification_title_args": {"label": label},
732 "notification_message_key": _ln(
733 "Your %(label_lower)s expires on %(date)s (one day remaining).",
734 "Your %(label_lower)s expires on %(date)s (%(days)s days remaining).",
735 days_left,
736 label_lower=label_lower,
737 date=expiry.isoformat(),
738 days=days_left,
739 ),
740 "notification_message_args": {},
741 "details": [
742 (_l("Expires"), expiry.isoformat()),
743 (_l("Days left"), str(days_left)),
744 ],
745 "expiry_value": expiry.isoformat(),
746 },
747 target_user_ids=[user.id],
748 subject_ref=f"pilot:{profile.id}",
749 )
752def _check_documents(app: Any) -> None:
753 from documents.routes import ( # pyright: ignore[reportMissingImports]
754 _EXPIRY_DRIVING_DOC_TYPES,
755 )
756 from flask_babel import ( # pyright: ignore[reportMissingImports]
757 lazy_gettext as _l,
758 )
759 from flask_babel import (
760 lazy_ngettext as _ln,
761 )
762 from models import ( # pyright: ignore[reportMissingImports]
763 Aircraft,
764 Document,
765 Tenant,
766 )
767 from models import NotificationType as NT
769 today = date.today()
770 threshold = NT.SYSTEM_DEFAULTS[NT.DOCUMENT_EXPIRING]["threshold_days"] or 30
771 for tenant in Tenant.query.filter_by(is_active=True).all():
772 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all():
773 for doc in Document.query.filter_by(aircraft_id=ac.id).all():
774 if doc.valid_until is None:
775 continue
776 # ARC/Insurance documents already drive their own dedicated
777 # check (_check_arc / _check_insurance) off the synced
778 # Aircraft.arc_expiry/insurance_expiry cache fields -- skip
779 # them here to avoid sending two separate emails for the
780 # same underlying deadline.
781 if doc.doc_type in _EXPIRY_DRIVING_DOC_TYPES:
782 continue
783 days_left = (doc.valid_until - today).days
784 if 0 <= days_left <= threshold:
785 title = doc.title or doc.original_filename
786 _dispatch_in_context(
787 NT.DOCUMENT_EXPIRING,
788 tenant.id,
789 {
790 "subject_key": _ln(
791 "Document expiring in one day: %(title)s",
792 "Document expiring in %(days)s days: %(title)s",
793 days_left,
794 days=days_left,
795 title=title,
796 ),
797 "subject_args": {},
798 "notification_title_key": _l(
799 "Document expiring soon: %(title)s"
800 ),
801 "notification_title_args": {"title": title},
802 "notification_message_key": _ln(
803 "'%(title)s' on %(reg)s expires on %(date)s (one day remaining).",
804 "'%(title)s' on %(reg)s expires on %(date)s (%(days)s days remaining).",
805 days_left,
806 title=title,
807 reg=ac.registration,
808 date=doc.valid_until.isoformat(),
809 days=days_left,
810 ),
811 "notification_message_args": {},
812 "details": [
813 (_l("Aircraft"), ac.registration),
814 (_l("Document"), title),
815 (_l("Expires"), doc.valid_until.isoformat()),
816 ],
817 "expiry_value": doc.valid_until.isoformat(),
818 },
819 subject_ref=f"document:{doc.id}",
820 )
823def _check_airworthiness_reviews(app: Any) -> None:
824 from flask_babel import ( # pyright: ignore[reportMissingImports]
825 lazy_gettext as _l,
826 )
827 from flask_babel import (
828 lazy_ngettext as _ln,
829 )
830 from models import ( # pyright: ignore[reportMissingImports]
831 Aircraft,
832 AirworthinessDocumentStatus,
833 Tenant,
834 )
835 from models import (
836 NotificationType as NT,
837 )
839 today = date.today()
840 threshold = NT.SYSTEM_DEFAULTS[NT.AIRWORTHINESS_REVIEW_DUE]["threshold_days"] or 30
841 for tenant in Tenant.query.filter_by(is_active=True).all():
842 for ac in Aircraft.query.filter_by(tenant_id=tenant.id, archived_at=None).all():
843 for status_row in AirworthinessDocumentStatus.query.filter_by(
844 aircraft_id=ac.id
845 ).all():
846 if status_row.next_review_date is None:
847 continue
848 days_left = (status_row.next_review_date - today).days
849 if 0 <= days_left <= threshold:
850 doc = status_row.document
851 ref = doc.reference if doc else _l("unknown")
852 _dispatch_in_context(
853 NT.AIRWORTHINESS_REVIEW_DUE,
854 tenant.id,
855 {
856 "subject_key": _ln(
857 "Airworthiness review due in one day: %(ref)s on %(reg)s",
858 "Airworthiness review due in %(days)s days: %(ref)s on %(reg)s",
859 days_left,
860 days=days_left,
861 ref=ref,
862 reg=ac.registration,
863 ),
864 "subject_args": {},
865 "notification_title_key": _l(
866 "Airworthiness review due: %(ref)s"
867 ),
868 "notification_title_args": {"ref": ref},
869 "notification_message_key": _ln(
870 "Document %(ref)s on %(reg)s requires review by %(date)s (one day).",
871 "Document %(ref)s on %(reg)s requires review by %(date)s (%(days)s days).",
872 days_left,
873 ref=ref,
874 reg=ac.registration,
875 date=status_row.next_review_date.isoformat(),
876 days=days_left,
877 ),
878 "notification_message_args": {},
879 "details": [
880 (_l("Aircraft"), ac.registration),
881 (_l("Document"), ref),
882 (_l("Due"), status_row.next_review_date.isoformat()),
883 ],
884 "expiry_value": status_row.next_review_date.isoformat(),
885 },
886 subject_ref=f"airworthiness_status:{status_row.id}",
887 )
890def _check_renter_authorizations(app: Any) -> None:
891 """One digest notification per tenant listing every renter authorization
892 whose expires_on or medical_valid_until falls within the threshold —
893 not one email per authorization (has_content guard: nothing to report
894 means no dispatch call at all)."""
895 from flask_babel import ( # pyright: ignore[reportMissingImports]
896 lazy_gettext as _l,
897 )
898 from flask_babel import (
899 lazy_ngettext as _ln,
900 )
901 from models import ( # pyright: ignore[reportMissingImports]
902 NotificationType as NT,
903 )
904 from models import (
905 RenterAuthorization,
906 Tenant,
907 )
909 today = date.today()
910 threshold = (
911 NT.SYSTEM_DEFAULTS[NT.RENTER_AUTHORIZATION_EXPIRY]["threshold_days"] or 30
912 )
913 for tenant in Tenant.query.filter_by(is_active=True).all():
914 rows: list[tuple[Any, str, date]] = []
915 for auth in RenterAuthorization.query.filter_by(
916 tenant_id=tenant.id, revoked_at=None
917 ).all():
918 for label, expiry in (
919 ("authorization", auth.expires_on),
920 ("medical", auth.medical_valid_until),
921 ):
922 if expiry is None:
923 continue
924 days_left = (expiry - today).days
925 if 0 <= days_left <= threshold:
926 rows.append((auth, label, expiry))
928 if not rows: # has_content guard — nothing soon-expiring, no email
929 continue
931 # Internal loop markers ("authorization"/"medical") are looked up
932 # against a translated-label map rather than displayed directly --
933 # embedding a lazy string in an f-string would force it to resolve
934 # immediately in whatever locale is active at check-time, not the
935 # eventual recipient's, so the translated value is built with _l()
936 # instead and stays lazy until Jinja renders it per-recipient.
937 # "medical certificate" here is deliberately the same msgid as the
938 # pilot's-own-medical label in _check_medical_and_sep (label_lower)
939 # -- same real-world concept (medical certificate validity), same
940 # translation, not a coincidence to be flagged.
941 _label_text = {
942 "authorization": _l("authorization"),
943 "medical": _l("medical certificate"),
944 }
945 details = []
946 for auth, label, expiry in rows:
947 renter_name = (
948 auth.renter_user.display_name if auth.renter_user else _l("unknown")
949 )
950 details.append(
951 (
952 renter_name,
953 _l(
954 "%(label)s expires %(date)s",
955 label=_label_text[label],
956 date=expiry.isoformat(),
957 ),
958 )
959 )
961 # Two independent countable quantities in one sentence (item count and
962 # day count) — ngettext only picks one plural form per call, so each
963 # is pluralized separately (as its own fully-resolved lazy fragment)
964 # and dropped into an outer, non-plural template.
965 item_count_phrase = _ln(
966 "One renter authorization",
967 "%(n)s renter authorizations",
968 len(rows),
969 n=len(rows),
970 )
971 day_count_phrase = _ln(
972 "one day",
973 "%(threshold)s days",
974 threshold,
975 threshold=threshold,
976 )
977 _dispatch_in_context(
978 NT.RENTER_AUTHORIZATION_EXPIRY,
979 tenant.id,
980 {
981 "subject_key": _ln(
982 "One renter authorization expiring soon",
983 "%(n)s renter authorizations expiring soon",
984 len(rows),
985 n=len(rows),
986 ),
987 "subject_args": {},
988 "notification_title_key": _l("Renter authorizations expiring soon"),
989 "notification_title_args": {},
990 "notification_message_key": _l(
991 "%(items)s or medical validity dates expire within %(days)s."
992 ),
993 "notification_message_args": {
994 "items": item_count_phrase,
995 "days": day_count_phrase,
996 },
997 "details": details,
998 },
999 subject_ref=f"tenant:{tenant.id}",
1000 )
1003def _check_personal_minimums_recency(app: Any) -> None:
1004 """One notification per pilot listing every personal-minimums recency
1005 item they have exceeded (has_content guard: no breaches, no dispatch).
1006 Only pilots with an active revision and at least one tagged, breached
1007 item are considered."""
1008 from flask_babel import ( # pyright: ignore[reportMissingImports]
1009 lazy_gettext as _l,
1010 )
1011 from flask_babel import (
1012 lazy_ngettext as _ln,
1013 )
1014 from models import ( # pyright: ignore[reportMissingImports]
1015 NotificationType as NT,
1016 )
1017 from models import (
1018 PersonalMinimumsRevision,
1019 PersonalMinimumsStatus,
1020 TenantUser,
1021 User,
1022 db,
1023 )
1024 from pilots.personal_minimums import (
1025 recency_breaches, # pyright: ignore[reportMissingImports]
1026 )
1028 for revision in PersonalMinimumsRevision.query.filter_by(
1029 status=PersonalMinimumsStatus.ACTIVE
1030 ).all():
1031 user = db.session.get(User, revision.user_id)
1032 if user is None or not user.is_active:
1033 continue
1034 tu = TenantUser.query.filter_by(user_id=user.id).first()
1035 if tu is None:
1036 continue
1038 breaches = recency_breaches(revision, user.id)
1039 if not breaches: # has_content guard
1040 continue
1042 # Same phrasing (and translations) as the identical breach data
1043 # shown in app/templates/pilots/logbook.html.
1044 details = []
1045 for b in breaches:
1046 if b["days_since"] is None:
1047 days_txt = _l(
1048 "no matching flight on record yet (comfort zone: %(threshold)s days).",
1049 threshold=b["threshold"],
1050 )
1051 else:
1052 days_txt = _l(
1053 "%(days)s days since your last matching flight (comfort zone: %(threshold)s days).",
1054 days=b["days_since"],
1055 threshold=b["threshold"],
1056 )
1057 details.append((b["item"].label, days_txt))
1059 _dispatch_in_context(
1060 NT.PERSONAL_MINIMUMS_RECENCY,
1061 tu.tenant_id,
1062 {
1063 "subject_key": _ln(
1064 "Personal minimums: one recency threshold exceeded",
1065 "Personal minimums: %(n)s recency thresholds exceeded",
1066 len(breaches),
1067 n=len(breaches),
1068 ),
1069 "subject_args": {},
1070 "notification_title_key": _l("Personal minimums recency reminder"),
1071 "notification_title_args": {},
1072 "notification_message_key": _ln(
1073 "You have exceeded one recency threshold in your "
1074 "personal minimums.",
1075 "You have exceeded %(n)s recency thresholds in your "
1076 "personal minimums.",
1077 len(breaches),
1078 n=len(breaches),
1079 ),
1080 "notification_message_args": {},
1081 "details": details,
1082 },
1083 target_user_ids=[user.id],
1084 subject_ref=f"user:{user.id}",
1085 )
1088def _dispatch_in_context(
1089 notification_type: str,
1090 tenant_id: int,
1091 email_context: dict[str, Any],
1092 target_user_ids: list[int] | None = None,
1093 subject_ref: str | None = None,
1094) -> None:
1095 """Call dispatch() safely, logging any errors."""
1096 try:
1097 dispatch(
1098 notification_type,
1099 tenant_id,
1100 email_context,
1101 target_user_ids,
1102 subject_ref=subject_ref,
1103 )
1104 except Exception as exc: # noqa: BLE001 -- caller name says it: dispatch safely, never raise
1105 log.error(
1106 "Error dispatching notification for tenant %d: %s",
1107 tenant_id,
1108 type(exc).__name__,
1109 )
1112# ── Welcome email ──────────────────────────────────────────────────────────────
1115def _try_welcome_lock(db: Any) -> bool:
1116 """Return False if another gunicorn worker already holds the startup lock."""
1117 if db.engine.dialect.name != "postgresql":
1118 return True
1119 from sqlalchemy import text as _text # pyright: ignore[reportMissingImports]
1121 return bool(
1122 db.session.execute(
1123 _text("SELECT pg_try_advisory_xact_lock(7283910456)")
1124 ).scalar()
1125 )
1128def send_welcome_email_if_needed(app: Any) -> None:
1129 """Send one-time welcome email to the instance owner. Called at startup."""
1130 try:
1131 with app.app_context():
1132 import os
1134 from models import ( # pyright: ignore[reportMissingImports]
1135 AppSetting,
1136 User,
1137 db,
1138 )
1140 from services.email_service import ( # pyright: ignore[reportMissingImports]
1141 send_email,
1142 )
1144 if db.session.get(AppSetting, "welcome_email_sent"):
1145 return
1146 if not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip():
1147 return
1149 # Guard against all gunicorn workers racing at startup.
1150 if not _try_welcome_lock(db):
1151 return
1153 # Re-check after acquiring the lock: another worker may have
1154 # finished sending while we were waiting to acquire it.
1155 db.session.expire_all()
1156 if db.session.get(AppSetting, "welcome_email_sent"):
1157 return
1159 owner = (
1160 User.query.filter_by(is_instance_admin=True).order_by(User.id).first()
1161 )
1162 if not owner:
1163 return
1165 from flask import render_template # pyright: ignore[reportMissingImports]
1166 from flask_babel import ( # pyright: ignore[reportMissingImports]
1167 force_locale,
1168 gettext,
1169 )
1171 locale = owner.language or "en"
1172 instance_url = os.environ.get("OPENHANGAR_INSTANCE_URL", "").strip() or None
1173 with force_locale(locale):
1174 subject = gettext("Welcome to your OpenHangar instance")
1175 greeting = gettext("Hello %(name)s,") % {"name": owner.display_name}
1176 body_text = gettext(
1177 "Welcome to OpenHangar! Your instance is set up and email"
1178 " delivery is working.\n\n"
1179 "You can configure notification preferences for all users"
1180 " under Configuration → Email Notifications.\n\n"
1181 "Fly safely!\n\nThe OpenHangar team"
1182 )
1183 text_body = greeting + "\n\n" + body_text
1184 body_html = render_template(
1185 "email/notif/welcome.html",
1186 owner=owner,
1187 repo_url=_REPO_URL,
1188 subject=subject,
1189 instance_url=instance_url,
1190 )
1191 html_body = render_template(
1192 "email/base_email.html",
1193 body=body_html,
1194 subject=subject,
1195 repo_url=_REPO_URL,
1196 instance_url=instance_url,
1197 )
1199 send_email(
1200 to=owner.email,
1201 subject=subject,
1202 text_body=text_body,
1203 html_body=html_body,
1204 locale=owner.language or "en",
1205 )
1207 db.session.add(AppSetting(key="welcome_email_sent", value="true"))
1208 db.session.commit()
1209 log.info("Welcome email sent to %s", owner.email)
1210 except Exception as exc: # noqa: BLE001 -- best-effort, explicitly will not retry
1211 log.error("Failed to send welcome email (will not retry): %s", exc)