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
« 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.
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 NotificationType,
31 TenantNotificationDefault,
32 db,
33 )
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 }
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 }
59 return dict(
60 NotificationType.SYSTEM_DEFAULTS.get(
61 notification_type, {"enabled": False, "threshold_days": None}
62 )
63 )
66# ── Recipient resolution ───────────────────────────────────────────────────────
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]
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
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 )
96 required = set(NotificationType.REQUIRED_CAPS.get(notification_type, []))
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))
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
114# ── Branding ──────────────────────────────────────────────────────────────────
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 )
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
133# ── Template rendering ─────────────────────────────────────────────────────────
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]
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
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)
167# ── Dispatch ──────────────────────────────────────────────────────────────────
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.
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 )
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)
193 recipients = _find_recipients(notification_type, tenant_id, target_user_ids)
195 for user in recipients:
196 pref = get_effective_preference(user.id, tenant_id, notification_type)
197 if not pref["enabled"]:
198 continue
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
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)
226# ── Daily expiry checks ────────────────────────────────────────────────────────
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")
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]
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 )
282def _check_insurance(app: Any) -> None:
283 from models import Aircraft, NotificationType as NT, Tenant # pyright: ignore[reportMissingImports]
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 )
312def _check_medical_and_sep(app: Any) -> None:
313 from models import NotificationType as NT, PilotProfile, TenantUser, User, db # pyright: ignore[reportMissingImports]
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
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 )
349def _check_documents(app: Any) -> None:
350 from models import Aircraft, Document, NotificationType as NT, Tenant # pyright: ignore[reportMissingImports]
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 )
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 )
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 )
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 )
432# ── Welcome email ──────────────────────────────────────────────────────────────
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]
441 return bool(
442 db.session.execute(
443 _text("SELECT pg_try_advisory_xact_lock(7283910456)")
444 ).scalar()
445 )
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 )
458 if db.session.get(AppSetting, "welcome_email_sent"):
459 return
460 if not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip():
461 return
463 # Guard against all gunicorn workers racing at startup.
464 if not _try_welcome_lock(db):
465 return
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
473 owner = (
474 User.query.filter_by(is_instance_admin=True).order_by(User.id).first()
475 )
476 if not owner:
477 return
479 from flask import render_template # pyright: ignore[reportMissingImports]
480 from flask_babel import force_locale, gettext # pyright: ignore[reportMissingImports]
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 )
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 )
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)