Coverage for app/init.py: 100%
929 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
1import os
2import secrets
3import sqlite3
4from datetime import UTC, timedelta
5from functools import cache
6from typing import Any
7from urllib.parse import urlparse
9import click # pyright: ignore[reportMissingImports]
10from flask import (
11 Flask,
12 Response,
13 g,
14 has_request_context,
15 render_template,
16 request,
17 send_from_directory,
18 session,
19) # pyright: ignore[reportMissingImports]
20from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
21from flask_babel import Babel # pyright: ignore[reportMissingImports]
22from flask_babel import get_locale as _babel_get_locale
23from flask_migrate import Migrate
24from flask_wtf.csrf import CSRFProtect # pyright: ignore[reportMissingImports]
25from sqlalchemy import event # pyright: ignore[reportMissingImports]
26from sqlalchemy.engine import Engine # pyright: ignore[reportMissingImports]
27from werkzeug.middleware.proxy_fix import (
28 ProxyFix, # pyright: ignore[reportMissingImports]
29)
32def _env_or_file(name: str) -> str:
33 """Read OPENHANGAR_<name> from the environment, or from the file named
34 by OPENHANGAR_<name>_FILE if that's set instead (the Docker/Compose
35 "secrets from files" pattern) — keeps the secret's value out of `docker
36 inspect` and /proc/<pid>/environ. Plain env vars keep working
37 unchanged; this is opt-in. Raises RuntimeError if both are set, or if
38 the file is set but unreadable."""
39 env_name = f"OPENHANGAR_{name}"
40 file_env_name = f"{env_name}_FILE"
41 env_val = os.environ.get(env_name)
42 file_path = os.environ.get(file_env_name)
43 if env_val and file_path:
44 raise RuntimeError(
45 f"Both {env_name} and {file_env_name} are set. Set only one."
46 )
47 if file_path:
48 try:
49 with open(file_path) as fh:
50 return fh.read().strip()
51 except OSError as exc:
52 raise RuntimeError(
53 f"{file_env_name} is set to {file_path!r} but the file could not be read: {exc}"
54 ) from exc
55 return env_val or ""
58def _normalize_database_url(db_url: str) -> str:
59 """Rewrite a plain postgresql:// (or explicit +psycopg2) URL to the
60 psycopg 3 dialect, so existing deployments' OPENHANGAR_DATABASE_URL
61 keeps working unchanged after the psycopg2-binary -> psycopg migration."""
62 for prefix in ("postgresql://", "postgresql+psycopg2://"):
63 if db_url.startswith(prefix):
64 return "postgresql+psycopg://" + db_url[len(prefix) :]
65 return db_url
68SUPPORTED_LOCALES = ["en", "fr", "nl"]
70LOCALE_META = {
71 "en": {"flag": "🇬🇧", "abbr": "EN", "native": "English", "english": "English"},
72 "fr": {"flag": "🇫🇷", "abbr": "FR", "native": "Français", "english": "French"},
73 "nl": {"flag": "🇳🇱", "abbr": "NL", "native": "Nederlands", "english": "Dutch"},
74}
77# Marks a string for pybabel extraction without translating it here — the
78# actual lookup happens later via gettext() at render time, once the real
79# locale is known. A no-op at runtime, but its literal-string argument is
80# what lets pybabel discover msgids that are only selected dynamically
81# (e.g. picked out of _AVIATION_DAYS by date, below) instead of passed
82# straight to gettext()/ngettext() as a literal.
83def N_(s: str) -> str:
84 return s
87# EE-09: aviation history days — (month, day, msgid). Add new entries here.
88_AVIATION_DAYS: list[tuple[int, int, str]] = [
89 (
90 3,
91 2,
92 N_("First flight of Concorde — André Turcat at the controls, Toulouse (1969)"),
93 ),
94 (
95 5,
96 21,
97 N_(
98 "Charles Lindbergh lands at Le Bourget — first solo transatlantic flight (1927)"
99 ),
100 ),
101 (
102 7,
103 25,
104 N_(
105 "Louis Blériot crosses the English Channel — first crossing by airplane (1909)"
106 ),
107 ),
108 (
109 11,
110 21,
111 N_(
112 "Pilâtre de Rozier & d'Arlandes — first manned free balloon flight, Paris (1783)"
113 ),
114 ),
115 (
116 12,
117 17,
118 N_("First flight: 17 Dec 1903 — 12 seconds, 37 metres. (Wright Brothers)"),
119 ),
120]
123def _aviation_day_msgid(month: int, day: int) -> str | None:
124 for m, d, msgid in _AVIATION_DAYS:
125 if m == month and d == day:
126 return msgid
127 return None
130def _compute_banners(uid: int | None) -> dict[str, Any]:
131 """EE-09 aviation-day banner + EE-10 personal anniversary banner.
133 Shared by the inject_globals context processor (baked into server-
134 rendered HTML) and the /api/banners endpoint (polled client-side by
135 pwa.js) so a page served stale from the SW's SWR cache, or an open tab
136 that never re-navigates, still learns about today's banner — see
137 pwa.js's banner-poll comment for why a baked-in-HTML-only banner isn't
138 enough. pilot_anniversary_confetti is a genuine once-a-day flag (backed
139 by a session key), not a per-caller one, so whichever of the two call
140 sites hits this first on a given day is the one that gets confetti:True
141 for that request.
142 """
143 from datetime import date as _date
145 # babel.cfg's extraction keywords list "_"/"ngettext" specifically (not
146 # arbitrary aliases) -- importing these "as _gt"/"as _ngt" instead meant
147 # pybabel silently never extracted any of these msgids, so they rendered
148 # in English regardless of locale.
149 from flask_babel import gettext as _
150 from flask_babel import ngettext
152 _today = _date.today()
153 _avi_msgid = _aviation_day_msgid(_today.month, _today.day)
154 _aviation_banner = _(_avi_msgid) if _avi_msgid else None
156 _pilot_anniversary: dict[str, Any] | None = None
157 _pilot_anniversary_confetti = False
158 if uid:
159 from models import PilotProfile as _PP
161 _pp = _PP.query.filter_by(user_id=uid).first()
162 if _pp:
163 for _ann_date, _ann_type in (
164 (_pp.first_solo_date, "solo"),
165 (_pp.ppl_issue_date, "ppl"),
166 ):
167 if _ann_date and (_ann_date.month, _ann_date.day) == (
168 _today.month,
169 _today.day,
170 ):
171 _years = _today.year - _ann_date.year
172 if _ann_type == "solo":
173 _msg = (
174 ngettext(
175 "🎉 Today marks one year since your first solo flight!",
176 "🎉 Today marks %(n)s years since your first solo flight!",
177 _years,
178 n=_years,
179 )
180 if _years > 0
181 else _(
182 "🎉 Today is the anniversary of your first solo flight!"
183 )
184 )
185 else:
186 _msg = (
187 ngettext(
188 "🎉 Today marks one year since you earned your PPL!",
189 "🎉 Today marks %(n)s years since you earned your PPL!",
190 _years,
191 n=_years,
192 )
193 if _years > 0
194 else _("🎉 Today is the anniversary of your PPL!")
195 )
196 _pilot_anniversary = {
197 "type": _ann_type,
198 "years": _years,
199 "message": _msg,
200 }
201 _sess_key = f"anniversary_confetti_{_today.isoformat()}"
202 if not session.get(_sess_key):
203 session[_sess_key] = True
204 _pilot_anniversary_confetti = True
205 break
207 return {
208 "aviation_day_banner": _aviation_banner,
209 "pilot_anniversary": _pilot_anniversary,
210 "pilot_anniversary_confetti": _pilot_anniversary_confetti,
211 }
214@cache
215def _static_folder_mtime_token(static_folder: str) -> str:
216 latest = 0
217 for root, _dirs, files in os.walk(static_folder):
218 for name in files:
219 try:
220 mtime = int(os.path.getmtime(os.path.join(root, name)))
221 except OSError: # file removed while walking
222 continue
223 latest = max(latest, mtime)
224 return str(latest)
227def _static_cache_version(static_folder: str) -> str:
228 """Cache-busting token appended to static URLs (?v=…).
230 The release version when running a published image; otherwise the newest
231 file mtime under the static folder — stable across gunicorn workers, and
232 changes whenever an asset changes during development."""
233 version = os.environ.get("OPENHANGAR_VERSION", "")
234 if version and version != "development":
235 return version
236 return _static_folder_mtime_token(static_folder)
239@event.listens_for(Engine, "connect")
240def _set_sqlite_fk_pragma(dbapi_connection: Any, _record: Any) -> None:
241 if isinstance(dbapi_connection, sqlite3.Connection):
242 cur = dbapi_connection.cursor()
243 cur.execute("PRAGMA foreign_keys=ON")
244 cur.close()
247def _drop_and_restore_schema(database_url: str, sql_bytes: bytes) -> None:
248 """Drop the public schema and restore it from a pg_dump byte-string."""
249 import subprocess # nosec B404
250 import tempfile
252 from models import db # pyright: ignore[reportMissingImports]
253 from sqlalchemy import text # pyright: ignore[reportMissingImports]
254 from utils import to_libpq_url # pyright: ignore[reportMissingImports]
256 # Close the ORM session while its connection is still alive so Flask's
257 # teardown has nothing left to rollback after we terminate other backends.
258 db.session.remove()
260 with db.engine.connect() as conn:
261 # Terminate all other connections so DROP SCHEMA can acquire its
262 # ACCESS EXCLUSIVE lock even if the web server left a connection
263 # idle-in-transaction (which would block indefinitely otherwise).
264 conn.execute(
265 text(
266 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity"
267 " WHERE datname = current_database() AND pid != pg_backend_pid()"
268 )
269 )
270 conn.execute(text("DROP SCHEMA public CASCADE"))
271 conn.execute(text("CREATE SCHEMA public"))
272 conn.commit()
274 # Dispose the connection pool so no SQLAlchemy connections linger and
275 # block psql's DDL statements with AccessShareLock.
276 db.engine.dispose()
278 # Strip ownership and privilege statements from the dump. pg_dump (without
279 # --no-owner / --no-acl) records the source role for every object and emits
280 # GRANT/REVOKE commands, but those role names are environment-specific and
281 # do not exist on the target server. New backups are produced with
282 # --no-owner --no-acl, but we strip here too as a safety net for archives
283 # made before that change.
284 import re
286 sql_bytes = re.sub(
287 rb"^(?:ALTER\s+\S[^\n]*\bOWNER\s+TO\b|GRANT\b|REVOKE\b)[^\n]*;\s*$",
288 b"",
289 sql_bytes,
290 flags=re.MULTILINE | re.IGNORECASE,
291 )
293 # Write the dump to a temp file so psql can read it directly rather than
294 # via stdin — avoids pipe-buffering hangs on large dumps and lets psql
295 # print progress to the terminal in real time.
296 with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
297 tmp.write(sql_bytes)
298 tmp_path = tmp.name
300 try:
301 result = subprocess.run( # nosec B603
302 ["psql", "--no-password", "-f", tmp_path, to_libpq_url(database_url)],
303 timeout=600,
304 check=False, # returncode checked explicitly below
305 )
306 except subprocess.TimeoutExpired:
307 raise RuntimeError("psql restore timed out after 10 minutes.")
308 finally:
309 os.unlink(tmp_path)
311 if result.returncode != 0:
312 raise RuntimeError(f"psql exited with code {result.returncode}")
315def _easa_sync_loop(app: Flask) -> None:
316 import logging
317 import os
318 import random
319 import time
320 from datetime import datetime, timedelta
322 from airworthiness_sync import (
323 sync_all_nodes, # pyright: ignore[reportMissingImports]
324 )
326 _log = logging.getLogger(__name__)
328 # Determine the daily sync time (UTC). Admin can pin a specific hour via
329 # OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR (0-23). Default: random hour
330 # 01-05 UTC so that different instances do not all hit EASA simultaneously.
331 env_hour = os.environ.get("OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR")
332 if env_hour is not None:
333 try:
334 sync_hour = int(env_hour) % 24
335 except ValueError:
336 sync_hour = random.randint(1, 5)
337 else:
338 sync_hour = random.randint(1, 5)
339 sync_minute = random.randint(0, 59)
340 _log.info("EASA sync scheduled daily at %02d:%02d UTC", sync_hour, sync_minute)
342 while True:
343 now = datetime.now(UTC)
344 next_run = now.replace(
345 hour=sync_hour, minute=sync_minute, second=0, microsecond=0
346 )
347 if next_run <= now:
348 next_run += timedelta(days=1)
349 time.sleep((next_run - datetime.now(UTC)).total_seconds())
350 sync_all_nodes(app)
353def _start_easa_sync_scheduler(app: Flask) -> None:
354 import threading
356 t = threading.Thread(
357 target=_easa_sync_loop,
358 args=(app,),
359 daemon=True,
360 name="easa-sync",
361 )
362 t.start()
365def _parse_notification_time() -> tuple[int, int]:
366 """Return (hour, minute) from OPENHANGAR_NOTIFICATION_TIME (HH:MM, default 07:00).
368 Raises ValueError with a human-readable message if the value is set but invalid.
369 """
370 raw = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "07:00")
371 err = f"OPENHANGAR_NOTIFICATION_TIME={raw!r} is invalid — expected HH:MM (e.g. '07:00')"
372 parts = raw.split(":")
373 if len(parts) != 2:
374 raise ValueError(err)
375 try:
376 hour, minute = int(parts[0]), int(parts[1])
377 except ValueError:
378 raise ValueError(err)
379 if not (0 <= hour <= 23 and 0 <= minute <= 59):
380 raise ValueError(err)
381 return hour, minute
384def _notification_daily_loop(app: Flask, run_hour: int, run_minute: int) -> None:
385 import logging
386 import time
387 from datetime import datetime, timedelta
389 _log = logging.getLogger(__name__)
390 _log.info(
391 "Notification daily check scheduled at %02d:%02d UTC", run_hour, run_minute
392 )
394 while True:
395 now = datetime.now(UTC)
396 next_run = now.replace(
397 hour=run_hour, minute=run_minute, second=0, microsecond=0
398 )
399 if next_run <= now:
400 next_run += timedelta(days=1)
401 time.sleep((next_run - datetime.now(UTC)).total_seconds())
402 try:
403 from services.notification_service import (
404 run_daily_checks, # pyright: ignore[reportMissingImports]
405 )
407 run_daily_checks(app)
408 except Exception: # noqa: BLE001 -- background job, must not crash the loop, retries tomorrow
409 _log.exception("Notification daily check failed; will retry tomorrow")
412def _start_notification_scheduler(app: Flask) -> None:
413 import threading
415 run_hour, run_minute = _parse_notification_time()
416 t = threading.Thread(
417 target=_notification_daily_loop,
418 args=(app, run_hour, run_minute),
419 daemon=True,
420 name="notification-daily",
421 )
422 t.start()
425def create_app() -> Flask:
426 from security_alerts import (
427 attach_to_logger, # pyright: ignore[reportMissingImports]
428 )
430 attach_to_logger()
432 app = Flask(__name__)
433 app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign]
435 # Propagate OPENHANGAR_ENV → FLASK_ENV so Flask's own internals keep working.
436 os.environ["FLASK_ENV"] = os.environ.get("OPENHANGAR_ENV", "production")
438 app.config["SQLALCHEMY_DATABASE_URI"] = _normalize_database_url(
439 _env_or_file("DATABASE_URL") or "sqlite:///:memory:"
440 )
441 secret_key = _env_or_file("SECRET_KEY")
442 if not secret_key:
443 raise RuntimeError("OPENHANGAR_SECRET_KEY environment variable must be set")
444 if "change" in secret_key.lower():
445 raise RuntimeError(
446 "OPENHANGAR_SECRET_KEY appears to be a placeholder value. "
447 "Generate a real key with: openssl rand -hex 32"
448 )
449 app.config["SECRET_KEY"] = secret_key
450 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
451 app.config["UPLOAD_FOLDER"] = os.environ.get(
452 "OPENHANGAR_UPLOAD_FOLDER", "/data/uploads"
453 )
454 app.config["BACKUP_FOLDER"] = os.environ.get(
455 "OPENHANGAR_BACKUP_FOLDER", "/data/backups"
456 )
457 app.config["MAX_CONTENT_LENGTH"] = (
458 50 * 1024 * 1024
459 ) # overridden by _validate_config
460 app.config["SESSION_COOKIE_SECURE"] = True
461 app.config["SESSION_COOKIE_HTTPONLY"] = True
462 app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
463 _session_days = int(os.environ.get("OPENHANGAR_SESSION_LIFETIME_DAYS", "30"))
464 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=_session_days)
466 flask_env = os.environ.get("OPENHANGAR_ENV", "production")
468 if flask_env in ("development", "test"):
469 app.config["TEMPLATES_AUTO_RELOAD"] = True
471 from models import db
473 db.init_app(app)
474 Migrate(app, db)
476 def _get_locale() -> str | None:
477 if not has_request_context():
478 return "en"
479 if session.get("user_id"):
480 # Demo sessions: visitor's session language takes precedence over the
481 # demo user's stored default so Accept-Language / manual switcher work.
482 if (
483 session.get("demo_slot_id")
484 and session.get("language") in SUPPORTED_LOCALES
485 ):
486 return str(session["language"])
487 from models import User
489 user = db.session.get(User, session["user_id"])
490 if user and user.language in SUPPORTED_LOCALES:
491 return str(user.language)
492 if session.get("language") in SUPPORTED_LOCALES:
493 return str(session["language"])
494 return str(request.accept_languages.best_match(SUPPORTED_LOCALES, default="en"))
496 Babel(app, locale_selector=_get_locale)
497 # No independent expiry: some nav pages are cached client-side (sw.js
498 # SWR_ROUTES) for offline/instant-navigation support, so a token embedded
499 # in a stale-but-served page must stay valid until the session itself
500 # expires, not Flask-WTF's 1-hour default.
501 app.config["WTF_CSRF_TIME_LIMIT"] = None
502 CSRFProtect(app)
504 from extensions import cache as _cache # pyright: ignore[reportMissingImports]
505 from extensions import limiter as _limiter # pyright: ignore[reportMissingImports]
507 app.config["CACHE_TYPE"] = "SimpleCache"
508 app.config["CACHE_DEFAULT_TIMEOUT"] = 300
509 _cache.init_app(app)
510 app.config["RATELIMIT_ENABLED"] = os.environ.get(
511 "OPENHANGAR_RATELIMIT_ENABLED", "true"
512 ).lower() in ("1", "true", "yes")
513 _limiter.init_app(app)
515 static_version = _static_cache_version(app.static_folder or "static")
517 @app.url_defaults
518 def _static_cache_bust(endpoint: str, values: dict[str, Any]) -> None:
519 if endpoint == "static":
520 values.setdefault("v", static_version)
522 @app.before_request
523 def _generate_csp_nonce() -> None:
524 g.csp_nonce = secrets.token_urlsafe(16)
526 def _csp_nonce() -> str:
527 return getattr(g, "csp_nonce", "")
529 app.jinja_env.globals["csp_nonce"] = _csp_nonce
531 @app.after_request
532 def _security_headers(response: Any) -> Any:
533 nonce = getattr(g, "csp_nonce", "")
534 response.headers["Content-Security-Policy"] = (
535 f"default-src 'self'; "
536 f"script-src 'nonce-{nonce}'; "
537 f"worker-src 'self' blob:; "
538 f"style-src-elem 'self'; "
539 f"style-src-attr 'none'; "
540 f"font-src 'self'; "
541 f"img-src 'self' data: blob: tile.openstreetmap.org *.basemaps.cartocdn.com api.tiles.openaip.net; "
542 f"connect-src 'self'; "
543 f"object-src 'none'; "
544 f"base-uri 'self'; "
545 f"form-action 'self'; "
546 # 'self' (not 'none'): the app frames its own same-origin content
547 # on purpose — the document/photo preview modal (doc_viewer.js)
548 # embeds /uploads/<file> in an <iframe>. A blanket 'none' also
549 # applies to that response and silently breaks that iframe (no
550 # console-visible failure beyond a CSP violation) rather than
551 # merely stopping a third-party site from framing OpenHangar,
552 # which is the actual threat 'self' still fully blocks.
553 f"frame-ancestors 'self';"
554 )
555 response.headers["X-Frame-Options"] = "SAMEORIGIN"
556 response.headers["X-Content-Type-Options"] = "nosniff"
557 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
558 response.headers["Permissions-Policy"] = (
559 "camera=(), microphone=(), geolocation=(), payment=()"
560 )
561 response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
562 response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
563 if request.endpoint == "static" and response.status_code in (200, 304):
564 # Static URLs carry the ?v= cache-buster, so long-lived caching is
565 # safe; uploads/documents go through their own routes and keep the
566 # authenticated no-store below.
567 response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
568 elif session.get("user_id"):
569 existing_cc = response.headers.get("Cache-Control", "")
570 if "public" not in existing_cc and "immutable" not in existing_cc:
571 response.headers["Cache-Control"] = "no-store, private"
572 return response
574 from flask_babel import format_date, format_datetime, format_decimal
576 app.jinja_env.globals.update(
577 format_date=format_date,
578 format_datetime=format_datetime,
579 format_decimal=format_decimal,
580 )
582 if app.config.get("TESTING") or os.environ.get("OPENHANGAR_ENV") == "development":
583 from jinja2 import StrictUndefined
585 app.jinja_env.undefined = StrictUndefined
587 from utils import (
588 _load_aircraft_type_variants,
589 _load_airport_names,
590 )
592 @app.template_filter("airport_name")
593 def _airport_name_filter(code: str | None) -> str:
594 if not code:
595 return ""
596 return _load_airport_names().get(code.upper(), "")
598 @app.route("/manifest.json")
599 def pwa_manifest() -> ResponseReturnValue:
600 from flask import jsonify as _jsonify
602 return _jsonify(
603 {
604 "name": "OpenHangar",
605 "short_name": "OpenHangar",
606 "description": "Open-source aircraft operations and pilot logbook",
607 "start_url": "/",
608 "display": "standalone",
609 "theme_color": "#14233A",
610 "background_color": "#14233A",
611 "icons": [
612 {
613 "src": "/static/icons/icon.svg",
614 "sizes": "any",
615 "type": "image/svg+xml",
616 },
617 {
618 "src": "/static/icons/icon-maskable.svg",
619 "sizes": "any",
620 "type": "image/svg+xml",
621 "purpose": "maskable",
622 },
623 ],
624 "share_target": {
625 "action": "/pwa/shared",
626 "method": "POST",
627 "enctype": "multipart/form-data",
628 "params": {
629 "title": "title",
630 "text": "text",
631 "url": "url",
632 "files": [
633 {
634 "name": "files",
635 "accept": ["application/pdf", "image/*"],
636 }
637 ],
638 },
639 },
640 "shortcuts": [
641 {
642 "name": "Log a Flight",
643 "short_name": "Log Flight",
644 "url": "/flights/new",
645 "icons": [
646 {
647 "src": "/static/icons/shortcut-log-flight.svg",
648 "sizes": "any",
649 "type": "image/svg+xml",
650 }
651 ],
652 },
653 {
654 "name": "My Aircraft",
655 "short_name": "Aircraft",
656 "url": "/aircraft",
657 "icons": [
658 {
659 "src": "/static/icons/shortcut-aircraft.svg",
660 "sizes": "any",
661 "type": "image/svg+xml",
662 }
663 ],
664 },
665 {
666 "name": "Documents",
667 "short_name": "Documents",
668 "url": "/documents",
669 "icons": [
670 {
671 "src": "/static/icons/shortcut-documents.svg",
672 "sizes": "any",
673 "type": "image/svg+xml",
674 }
675 ],
676 },
677 ],
678 }
679 )
681 @app.route("/sw.js")
682 def service_worker() -> ResponseReturnValue:
683 sw_path = os.path.join(app.static_folder or "static", "js", "sw.js")
684 with open(sw_path, encoding="utf-8") as fh:
685 content = fh.read()
686 version = os.environ.get("OPENHANGAR_VERSION", "")
687 cache_name = (
688 f"openhangar-{version}"
689 if version and version != "development"
690 else f"openhangar-{secrets.token_hex(8)}"
691 )
692 content = content.replace("__SW_CACHE_VERSION__", cache_name)
693 response = Response(content, mimetype="application/javascript")
694 response.headers["Service-Worker-Allowed"] = "/"
695 return response
697 @app.route("/api/check-flight-duplicate")
698 def api_check_flight_duplicate() -> ResponseReturnValue:
699 from flask import jsonify as _jsonify
701 if not session.get("user_id"):
702 return _jsonify({"error": "unauthorized"}), 401
703 date_str = request.args.get("date", "")
704 aircraft_id_str = request.args.get("aircraft_id", "")
705 dep = request.args.get("departure_icao", "")
706 arr = request.args.get("arrival_icao", "")
707 exclude_flight_id_str = request.args.get("exclude_flight_id", "")
708 exclude_flight_id = (
709 int(exclude_flight_id_str) if exclude_flight_id_str.isdigit() else None
710 )
711 if not (date_str and dep and arr):
712 return _jsonify({"duplicate": False})
713 from models import Aircraft, Flight, TenantUser
714 from sqlalchemy import or_ as _or # pyright: ignore[reportMissingImports]
716 uid = int(session["user_id"])
717 try:
718 from datetime import date as _date
720 flight_date = _date.fromisoformat(date_str)
721 except ValueError:
722 return _jsonify({"duplicate": False})
724 tu = TenantUser.query.filter_by(user_id=uid).first()
726 if tu and aircraft_id_str and aircraft_id_str.isdigit():
727 ac_id = int(aircraft_id_str)
728 # Scope by tenant: only match flights on an aircraft the caller's
729 # tenant owns, otherwise this leaks a cross-tenant existence oracle.
730 owned = Aircraft.query.filter_by(id=ac_id, tenant_id=tu.tenant_id).first()
731 if owned:
732 q = Flight.query.filter_by(
733 aircraft_id=ac_id,
734 date=flight_date,
735 departure_icao=dep,
736 arrival_icao=arr,
737 )
738 if exclude_flight_id:
739 q = q.filter(Flight.id != exclude_flight_id)
740 if q.first():
741 return _jsonify({"duplicate": True})
743 if tu:
744 q_pilot = Flight.query.filter(
745 _or(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid),
746 Flight.date == flight_date,
747 Flight.departure_icao == dep,
748 Flight.arrival_icao == arr,
749 )
750 if exclude_flight_id:
751 q_pilot = q_pilot.filter(Flight.id != exclude_flight_id)
752 if q_pilot.first():
753 return _jsonify({"duplicate": True})
755 return _jsonify({"duplicate": False})
757 @app.route("/api/banners")
758 def api_banners() -> ResponseReturnValue:
759 """Polled client-side by pwa.js — see _compute_banners' docstring
760 for why a page can't rely on server-rendered HTML alone for this."""
761 from flask import jsonify as _jsonify
763 if not session.get("user_id"):
764 return _jsonify({"error": "unauthorized"}), 401
765 return _jsonify(_compute_banners(int(session["user_id"])))
767 @app.route("/airport-search")
768 def airport_search() -> ResponseReturnValue:
769 if not session.get("user_id"):
770 return {"results": []}
771 q = request.args.get("q", "").strip()
772 if len(q) < 2:
773 return {"results": []}
774 q_code = q.upper()
775 q_low = q.lower()
776 names = _load_airport_names()
777 code_hits: list[dict[str, str]] = []
778 name_hits: list[dict[str, str]] = []
779 for code, name in names.items():
780 if code.startswith(q_code):
781 code_hits.append({"code": code, "name": name})
782 elif q_low in name.lower():
783 name_hits.append({"code": code, "name": name})
784 return {"results": (code_hits + name_hits)[:10]}
786 @app.route("/aircraft-type-search")
787 def aircraft_type_search() -> ResponseReturnValue:
788 if not session.get("user_id"):
789 return {"results": []}
790 q = request.args.get("q", "").strip()
791 if len(q) < 2:
792 return {"results": []}
793 q_up = q.upper()
794 words = q.lower().split()
795 variants = _load_aircraft_type_variants()
796 code_hits: list[dict[str, str]] = []
797 name_hits: list[dict[str, str]] = []
798 for des, full_name, mfr, mdl in variants:
799 name_low = full_name.lower()
800 entry = {"code": des, "name": full_name, "manufacturer": mfr, "model": mdl}
801 if des.startswith(q_up):
802 code_hits.append(entry)
803 elif all(w in name_low for w in words):
804 name_hits.append(entry)
805 return {"results": code_hits + name_hits}
807 from utils import AircraftRefConverter
809 app.url_map.converters["aircraft_ref"] = AircraftRefConverter
811 from auth.routes import auth_bp
813 app.register_blueprint(auth_bp)
815 from aircraft.routes import aircraft_bp
817 app.register_blueprint(aircraft_bp)
819 from flights.routes import flights_bp
821 app.register_blueprint(flights_bp)
823 from maintenance.routes import maintenance_bp
825 app.register_blueprint(maintenance_bp)
827 from expenses.routes import expenses_bp
829 app.register_blueprint(expenses_bp)
831 from documents.routes import documents_bp
833 app.register_blueprint(documents_bp)
835 from config.routes import config_bp
837 app.register_blueprint(config_bp)
839 from share.routes import share_bp
841 app.register_blueprint(share_bp)
843 from snags.routes import snags_bp
845 app.register_blueprint(snags_bp)
847 from notifications.routes import notifications_bp
849 app.register_blueprint(notifications_bp)
851 from refuels.routes import refuels_bp
853 app.register_blueprint(refuels_bp)
855 from reports.routes import reports_bp
857 app.register_blueprint(reports_bp)
859 from pilots.routes import pilots_bp
861 app.register_blueprint(pilots_bp)
863 from users.routes import users_bp
865 app.register_blueprint(users_bp)
867 from reservations.routes import reservations_bp
869 app.register_blueprint(reservations_bp)
871 from squawk.routes import squawk_bp
873 app.register_blueprint(squawk_bp)
875 from hangar.routes import hangar_bp
877 app.register_blueprint(hangar_bp)
879 from airworthiness.routes import airworthiness_bp
881 app.register_blueprint(airworthiness_bp)
883 from pwa.routes import pwa_bp
885 app.register_blueprint(pwa_bp)
887 from offline.routes import offline_bp
889 app.register_blueprint(offline_bp)
891 if flask_env == "demo":
892 from demo.routes import demo_bp
894 app.register_blueprint(demo_bp)
896 def _current_theme(
897 user_obj: Any, in_request: bool, sess: Any, is_demo: bool
898 ) -> str:
899 if in_request and user_obj and not is_demo and not sess.get("demo_slot_id"):
900 t = getattr(user_obj, "theme", None)
901 if t in ("light", "dark"):
902 return str(t)
903 if in_request:
904 t = sess.get("theme")
905 if t in ("light", "dark", "system"):
906 return str(t)
907 return "system"
909 @app.context_processor
910 def inject_globals() -> dict[str, Any]:
911 from models import DemoSlot, Role, TenantProfile, TenantUser, User
912 from utils import (
913 check_legacy_logbook_data,
914 check_update_available,
915 current_user_role,
916 )
918 is_demo = flask_env == "demo"
919 demo_next_wipe_utc = (
920 os.environ.get("OPENHANGAR_DEMO_NEXT_WIPE_UTC") if is_demo else None
921 )
922 repo_url = os.environ.get(
923 "OPENHANGAR_REPO_URL", "https://github.com/e2jk/OpenHangar"
924 )
925 _in_request = has_request_context()
926 demo_display_id = None
927 if is_demo and _in_request:
928 slot_id = session.get("demo_slot_id")
929 if slot_id:
930 slot = db.session.get(DemoSlot, slot_id)
931 if slot:
932 demo_display_id = slot.display_id
933 role = current_user_role() if _in_request else None
934 # Phase 23: is_pilot/is_maint also enabled by per-user capability flags
935 uid = session.get("user_id") if _in_request else None
936 _user_flags = db.session.get(User, uid) if uid else None
937 _flag_pilot = bool(_user_flags and _user_flags.is_pilot)
938 _flag_maint = bool(_user_flags and _user_flags.is_maintenance)
940 # Phase 26: adaptive UI based on TenantProfile
941 _tenant_profile = None
942 _has_renter_account = False
943 if uid:
944 tu = TenantUser.query.filter_by(user_id=uid).first()
945 if tu:
946 _tenant_profile = TenantProfile.query.filter_by(
947 tenant_id=tu.tenant_id
948 ).first()
949 if _tenant_profile and _tenant_profile.allows_rental:
950 # Phase 37e: nav entry only when there's something to see.
951 from models import BillingAccount, BillingAccountKind, LedgerEntry
953 _renter_account = BillingAccount.query.filter_by(
954 tenant_id=tu.tenant_id,
955 user_id=uid,
956 kind=BillingAccountKind.RENTER,
957 ).first()
958 _has_renter_account = bool(
959 _renter_account
960 and LedgerEntry.query.filter_by(
961 account_id=_renter_account.id
962 ).first()
963 is not None
964 )
965 _pac = (
966 _tenant_profile.planned_aircraft_count
967 if _tenant_profile and _tenant_profile.planned_aircraft_count is not None
968 else None
969 )
970 # logbook_only: planned_aircraft_count == 0 → hide all aircraft UI
971 _logbook_only = _pac == 0
972 # single_aircraft_mode: planned_aircraft_count == 1 → hide fleet-level widgets
973 _single_aircraft_mode = _pac == 1
975 # EE-09/EE-10: aviation history day + personal anniversary banners
976 _banners = _compute_banners(uid)
977 from datetime import date as _date
979 _is_owner = role in (Role.ADMIN, Role.OWNER)
980 _nav_update_available = (
981 check_update_available()
982 if _is_owner and _in_request and flask_env == "production"
983 else False
984 )
985 _legacy_logbook_data_present = (
986 check_legacy_logbook_data() if _is_owner and _in_request else False
987 )
988 if uid:
989 from flights.crew_invites import (
990 pending_claims_to_review,
991 pending_invite_count,
992 )
993 from flights.shared_flight import pending_suggestions_to_review
995 _nav_crew_invite_count = (
996 pending_invite_count(uid)
997 + len(pending_claims_to_review(uid))
998 + len(pending_suggestions_to_review(uid))
999 )
1000 else:
1001 _nav_crew_invite_count = 0
1003 return {
1004 "logged_in": bool(uid),
1005 "has_users": User.query.count() > 0,
1006 "flask_env": flask_env,
1007 "is_demo": is_demo,
1008 "demo_next_wipe_utc": demo_next_wipe_utc,
1009 "demo_display_id": demo_display_id,
1010 "repo_url": repo_url,
1011 "current_locale": str(_babel_get_locale()),
1012 "supported_locales": SUPPORTED_LOCALES,
1013 "locale_meta": LOCALE_META,
1014 "current_role": role,
1015 "is_owner": _is_owner,
1016 "is_pilot": role in (Role.ADMIN, Role.OWNER, Role.PILOT, Role.INSTRUCTOR)
1017 or _flag_pilot,
1018 "is_maint": role
1019 in (Role.ADMIN, Role.OWNER, Role.MAINTENANCE, Role.INSTRUCTOR)
1020 or _flag_maint,
1021 "is_crew": role not in (None, Role.VIEWER),
1022 "nav_user_label": (_user_flags.name or _user_flags.email)
1023 if _user_flags
1024 else None,
1025 "tenant_profile": _tenant_profile,
1026 "allows_rental": bool(_tenant_profile and _tenant_profile.allows_rental),
1027 "has_renter_account": _has_renter_account,
1028 "logbook_only": _logbook_only,
1029 "single_aircraft_mode": _single_aircraft_mode,
1030 "aircraft_count_goal": _pac,
1031 "aviation_day_banner": _banners["aviation_day_banner"],
1032 "pilot_anniversary": _banners["pilot_anniversary"],
1033 "pilot_anniversary_confetti": _banners["pilot_anniversary_confetti"],
1034 "today": _date.today(),
1035 "current_theme": _current_theme(_user_flags, _in_request, session, is_demo),
1036 "nav_update_available": _nav_update_available,
1037 "nav_crew_invite_count": _nav_crew_invite_count,
1038 "legacy_logbook_data_present": _legacy_logbook_data_present,
1039 "oh_debug": app.debug
1040 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower()
1041 not in ("1", "true", "yes"),
1042 }
1044 @app.errorhandler(403)
1045 def forbidden(e: Exception) -> ResponseReturnValue:
1046 return render_template("errors/403.html"), 403
1048 @app.errorhandler(404)
1049 def not_found(e: Exception) -> ResponseReturnValue:
1050 return render_template("errors/404.html"), 404
1052 @app.errorhandler(413)
1053 def payload_too_large(e: Exception) -> ResponseReturnValue:
1054 # Typically a multi-file upload (e.g. a season of GPS data logs)
1055 # over MAX_CONTENT_LENGTH in total, each file being within its own
1056 # per-file limit — send the user back to the form with a reason
1057 # instead of a bare error page.
1058 from flask import flash, redirect, url_for
1059 from flask_babel import gettext as _
1061 max_mb = round((app.config.get("MAX_CONTENT_LENGTH") or 0) / (1024 * 1024))
1062 flash(
1063 _(
1064 "This upload is too large (maximum %(mb)s MB in total). "
1065 "Please upload the files in smaller batches.",
1066 mb=max_mb,
1067 ),
1068 "danger",
1069 )
1070 # Back to the submitting page — on this host only, and as a relative
1071 # path (same guard as the language switcher's ?next=).
1072 ref = urlparse(request.referrer or "")
1073 back = ""
1074 if ref.netloc == request.host:
1075 back = ref.path + (f"?{ref.query}" if ref.query else "")
1076 back = back.replace("\\", "") # browsers treat \ as /; strip before parsing
1077 parsed_back = urlparse(back)
1078 if (
1079 not back
1080 or parsed_back.netloc
1081 or parsed_back.scheme
1082 or not back.startswith("/")
1083 or back.startswith("//")
1084 ):
1085 back = url_for("index")
1086 return redirect(back)
1088 @app.errorhandler(500)
1089 def internal_error(e: Exception) -> ResponseReturnValue:
1090 import traceback as _tb
1092 show_debug = flask_env in ("development", "test")
1093 exc_type = type(e).__name__
1094 exc_value = str(e)
1095 tb = _tb.format_exc() if show_debug else None
1096 return (
1097 render_template(
1098 "errors/500.html",
1099 show_debug=show_debug,
1100 env=flask_env,
1101 exc_type=exc_type,
1102 exc_value=exc_value,
1103 tb=tb,
1104 ),
1105 500,
1106 )
1108 @app.route("/")
1109 def index() -> ResponseReturnValue:
1110 from models import TenantUser, User
1112 # Demo mode: unauthenticated visitors always see the landing page
1113 if flask_env == "demo" and not session.get("user_id"):
1114 return render_template("landing.html")
1116 if User.query.count() == 0:
1117 return render_template("landing.html")
1118 if session.get("user_id"):
1119 from datetime import date as _date
1121 from models import (
1122 Aircraft,
1123 AircraftPhoto,
1124 Flight,
1125 MaintenanceTrigger,
1126 Snag,
1127 )
1128 from utils import accessible_aircraft, compute_aircraft_statuses
1130 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
1131 aircraft = accessible_aircraft(tu.tenant_id).all() if tu else []
1132 aircraft_ids = [ac.id for ac in aircraft]
1133 hobbs_by_aircraft = Aircraft.engine_hours_by_id(aircraft_ids)
1134 landings_by_aircraft = Aircraft.landings_by_id(aircraft_ids)
1135 flight_hours_by_aircraft = Aircraft.flight_hours_by_id(aircraft_ids)
1136 cover_photos = (
1137 {
1138 p.aircraft_id: p
1139 for p in AircraftPhoto.query.filter(
1140 AircraftPhoto.aircraft_id.in_(aircraft_ids),
1141 AircraftPhoto.sort_order == 1,
1142 ).all()
1143 }
1144 if aircraft_ids
1145 else {}
1146 )
1148 recent_flights = (
1149 (
1150 Flight.query.filter(Flight.aircraft_id.in_(aircraft_ids))
1151 .order_by(Flight.date.desc(), Flight.id.desc())
1152 .limit(5)
1153 .all()
1154 )
1155 if aircraft_ids
1156 else []
1157 )
1159 today = _date.today()
1160 month_start = today.replace(day=1)
1161 month_flights = (
1162 (
1163 Flight.query.filter(
1164 Flight.aircraft_id.in_(aircraft_ids),
1165 Flight.date >= month_start,
1166 ).all()
1167 )
1168 if aircraft_ids
1169 else []
1170 )
1171 hours_this_month = sum(
1172 float(f.flight_time)
1173 if f.flight_time is not None
1174 else float(f.flight_time_counter_end)
1175 - float(f.flight_time_counter_start)
1176 for f in month_flights
1177 if f.flight_time is not None
1178 or (
1179 f.flight_time_counter_end is not None
1180 and f.flight_time_counter_start is not None
1181 )
1182 )
1183 flights_this_month = len(month_flights)
1185 triggers = (
1186 (
1187 MaintenanceTrigger.query.filter(
1188 MaintenanceTrigger.aircraft_id.in_(aircraft_ids)
1189 ).all()
1190 )
1191 if aircraft_ids
1192 else []
1193 )
1195 aircraft_status = compute_aircraft_statuses(
1196 aircraft,
1197 triggers,
1198 hobbs_by_aircraft,
1199 landings_by_aircraft,
1200 flight_hours_by_aircraft,
1201 )
1203 urgent_maintenance = []
1204 maintenance_alerts = 0
1205 ac_by_id = {ac.id: ac for ac in aircraft}
1206 for t in triggers:
1207 s = t.status(
1208 current_engine_hours=hobbs_by_aircraft.get(t.aircraft_id),
1209 current_landings=landings_by_aircraft.get(t.aircraft_id),
1210 current_flight_hours=flight_hours_by_aircraft.get(t.aircraft_id),
1211 )
1212 if s in ("overdue", "due_soon"):
1213 maintenance_alerts += 1
1214 urgent_maintenance.append((t, s, ac_by_id[t.aircraft_id]))
1215 urgent_maintenance.sort(key=lambda x: 0 if x[1] == "overdue" else 1)
1216 urgent_maintenance = urgent_maintenance[:5]
1218 # Collect grounding snags across the fleet (sorted: grounding first, then by date)
1219 open_grounding = (
1220 (
1221 Snag.query.filter(
1222 Snag.aircraft_id.in_(aircraft_ids),
1223 Snag.is_grounding.is_(True),
1224 Snag.resolved_at.is_(None),
1225 )
1226 .order_by(Snag.reported_at.desc())
1227 .all()
1228 )
1229 if aircraft_ids
1230 else []
1231 )
1232 grounding_snags = [(s, ac_by_id[s.aircraft_id]) for s in open_grounding]
1233 open_other_snags = (
1234 Snag.query.filter(
1235 Snag.aircraft_id.in_(aircraft_ids),
1236 Snag.is_grounding.is_(False),
1237 Snag.resolved_at.is_(None),
1238 ).count()
1239 if aircraft_ids
1240 else 0
1241 )
1243 from models import PilotProfile
1244 from pilots.currency import currency_summary as _currency_summary
1245 from sqlalchemy import (
1246 or_ as _or_dash, # pyright: ignore[reportMissingImports]
1247 )
1249 pilot_profile = PilotProfile.query.filter_by(
1250 user_id=session["user_id"]
1251 ).first()
1252 _mine_dash = _or_dash(
1253 Flight.pic_user_id == session["user_id"],
1254 Flight.second_crew_user_id == session["user_id"],
1255 )
1256 pilot_entries = (
1257 Flight.query.filter(_mine_dash).all() if pilot_profile else []
1258 )
1259 pilot_currency = _currency_summary(pilot_profile, pilot_entries, today)
1261 recent_pilot_entries = (
1262 sorted(pilot_entries, key=lambda e: (e.date, e.id), reverse=True)[:5]
1263 if not aircraft_ids
1264 else []
1265 )
1267 from flask import url_for as _url_for_dash
1269 track_entries = (
1270 Flight.query.filter(_mine_dash)
1271 .filter(Flight.gps_track_id.isnot(None))
1272 .order_by(Flight.date.asc())
1273 .all()
1274 if pilot_profile
1275 else []
1276 )
1277 dash_track_rows = [
1278 {
1279 "date": str(e.date),
1280 "dep": e.departure_icao or "",
1281 "arr": e.arrival_icao or "",
1282 "time_str": f"{e.total_flight_time} h"
1283 if e.total_flight_time is not None
1284 else "",
1285 "view_url": _url_for_dash(
1286 "aircraft.flight_detail",
1287 aircraft_id=e.aircraft_id,
1288 flight_id=e.id,
1289 )
1290 if e.aircraft_id
1291 else _url_for_dash("pilots.view_entry", entry_id=e.id),
1292 "geojson": e.gps_track.geojson if e.gps_track else None,
1293 }
1294 for e in track_entries
1295 ]
1296 from models import AppSetting as _AppSetting
1298 _openaip_s = db.session.get(_AppSetting, "openaip_api_key")
1299 openaip_key = _openaip_s.value if _openaip_s and _openaip_s.value else None
1301 # ── Reservation stat card + pending approval queue ────────────────
1302 import calendar as _cal
1303 from collections import defaultdict
1304 from datetime import datetime as _dt
1305 from datetime import timedelta
1307 from models import Reservation, ReservationStatus, Role
1308 from utils import current_user_role
1310 _role = current_user_role()
1311 today_utc = _dt.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
1312 pending_reservations = (
1313 (
1314 Reservation.query.filter(
1315 Reservation.aircraft_id.in_(aircraft_ids),
1316 Reservation.status == ReservationStatus.PENDING,
1317 Reservation.start_dt >= today_utc,
1318 )
1319 .order_by(Reservation.start_dt)
1320 .all()
1321 )
1322 if aircraft_ids and _role in (Role.ADMIN, Role.OWNER)
1323 else []
1324 )
1325 res_7d = (
1326 Reservation.query.filter(
1327 Reservation.aircraft_id.in_(aircraft_ids),
1328 Reservation.start_dt >= today_utc,
1329 Reservation.start_dt < today_utc + timedelta(days=7),
1330 ).count()
1331 if aircraft_ids
1332 else 0
1333 )
1334 res_30d = (
1335 Reservation.query.filter(
1336 Reservation.aircraft_id.in_(aircraft_ids),
1337 Reservation.start_dt >= today_utc,
1338 Reservation.start_dt < today_utc + timedelta(days=30),
1339 ).count()
1340 if aircraft_ids
1341 else 0
1342 )
1344 # ── Fleet calendar widget ─────────────────────────────────────────
1345 try:
1346 cal_year = int(request.args.get("cal_year", today.year))
1347 cal_month = int(request.args.get("cal_month", today.month))
1348 except ValueError:
1349 cal_year, cal_month = today.year, today.month
1350 if cal_month < 1:
1351 cal_year -= 1
1352 cal_month = 12
1353 if cal_month > 12:
1354 cal_year += 1
1355 cal_month = 1
1357 cal_month_start = _dt(cal_year, cal_month, 1, tzinfo=UTC)
1358 cal_last_day = _cal.monthrange(cal_year, cal_month)[1]
1359 cal_month_end = _dt(
1360 cal_year, cal_month, cal_last_day, 23, 59, 59, tzinfo=UTC
1361 )
1363 cal_reservations = (
1364 Reservation.query.filter(
1365 Reservation.aircraft_id.in_(aircraft_ids),
1366 Reservation.status != ReservationStatus.CANCELLED,
1367 Reservation.start_dt <= cal_month_end,
1368 Reservation.end_dt >= cal_month_start,
1369 )
1370 .order_by(Reservation.start_dt)
1371 .all()
1372 if aircraft_ids
1373 else []
1374 )
1376 cal_flights = (
1377 Flight.query.filter(
1378 Flight.aircraft_id.in_(aircraft_ids),
1379 Flight.date >= cal_month_start.date(),
1380 Flight.date <= cal_month_end.date(),
1381 )
1382 .order_by(Flight.date)
1383 .all()
1384 if aircraft_ids
1385 else []
1386 )
1388 cal_day_events: dict[Any, Any] = defaultdict(
1389 lambda: {"reservations": [], "flights": []}
1390 )
1391 for r in cal_reservations:
1392 cur = r.start_dt.date()
1393 end = r.end_dt.date()
1394 while cur <= end:
1395 if cur.month == cal_month and cur.year == cal_year:
1396 cal_day_events[cur]["reservations"].append(r)
1397 cur += timedelta(days=1)
1398 for f in cal_flights:
1399 cal_day_events[f.date]["flights"].append(f)
1401 cal_weeks = _cal.Calendar(firstweekday=0).monthdatescalendar(
1402 cal_year, cal_month
1403 )
1404 cal_prev_month = cal_month - 1 or 12
1405 cal_prev_year = cal_year - 1 if cal_month == 1 else cal_year
1406 cal_next_month = cal_month % 12 + 1
1407 cal_next_year = cal_year + 1 if cal_month == 12 else cal_year
1408 cal_month_name = _dt(cal_year, cal_month, 1).strftime("%B %Y")
1410 from flights.crew_invites import (
1411 pending_claims_to_review,
1412 pending_invites_for_user,
1413 )
1414 from flights.shared_flight import pending_suggestions_to_review
1415 from models import CrewRole
1417 return render_template(
1418 "dashboard.html",
1419 crew_invites=pending_invites_for_user(session["user_id"]),
1420 crew_claims=pending_claims_to_review(session["user_id"]),
1421 flight_corrections=pending_suggestions_to_review(session["user_id"]),
1422 crew_roles=CrewRole,
1423 aircraft=aircraft,
1424 cover_photos=cover_photos,
1425 pending_reservations=pending_reservations,
1426 recent_flights=recent_flights,
1427 recent_pilot_entries=recent_pilot_entries,
1428 dash_track_rows=dash_track_rows,
1429 openaip_key=openaip_key,
1430 hours_this_month=hours_this_month,
1431 flights_this_month=flights_this_month,
1432 maintenance_alerts=maintenance_alerts,
1433 urgent_maintenance=urgent_maintenance,
1434 grounding_snags=grounding_snags,
1435 open_other_snags=open_other_snags,
1436 aircraft_status=aircraft_status,
1437 triggers=triggers,
1438 pilot_currency=pilot_currency,
1439 today=today,
1440 res_7d=res_7d,
1441 res_30d=res_30d,
1442 cal_weeks=cal_weeks,
1443 cal_day_events=cal_day_events,
1444 cal_month_name=cal_month_name,
1445 cal_year=cal_year,
1446 cal_month=cal_month,
1447 cal_prev_year=cal_prev_year,
1448 cal_prev_month=cal_prev_month,
1449 cal_next_year=cal_next_year,
1450 cal_next_month=cal_next_month,
1451 ReservationStatus=ReservationStatus,
1452 )
1453 return render_template("welcome.html")
1455 @app.route("/not-yet-implemented")
1456 def not_yet_implemented() -> ResponseReturnValue:
1457 feature = request.args.get("feature", "This feature")
1458 return render_template("not_yet_implemented.html", feature=feature), 501
1460 @app.route("/set-language/<lang>")
1461 def set_language(lang: str) -> ResponseReturnValue:
1462 from flask import abort, redirect
1464 if lang not in SUPPORTED_LOCALES:
1465 abort(400)
1466 if session.get("user_id") and not session.get("demo_slot_id"):
1467 from models import User
1469 user = db.session.get(User, session["user_id"])
1470 if user:
1471 user.language = lang
1472 db.session.commit()
1473 else:
1474 session["language"] = (
1475 lang # stale user_id (e.g. setup wizard) — fall back to session
1476 )
1477 else:
1478 session["language"] = lang
1479 next_url = request.args.get("next", "").strip()
1480 next_url = next_url.replace(
1481 "\\", ""
1482 ) # browsers treat \ as /; strip before parsing
1483 parsed_next = urlparse(next_url)
1484 if (
1485 not next_url
1486 or parsed_next.netloc
1487 or parsed_next.scheme
1488 or not next_url.startswith("/")
1489 or next_url.startswith("//")
1490 ):
1491 next_url = "/"
1492 return redirect(next_url)
1494 @app.route("/set-theme/<theme>")
1495 def set_theme(theme: str) -> ResponseReturnValue:
1496 from flask import abort, redirect
1498 if theme not in ("light", "dark", "system"):
1499 abort(400)
1500 if session.get("user_id") and not session.get("demo_slot_id"):
1501 from models import User
1503 user = db.session.get(User, session["user_id"])
1504 if user:
1505 user.theme = None if theme == "system" else theme
1506 db.session.commit()
1507 else:
1508 session["theme"] = theme
1509 else:
1510 session["theme"] = theme
1511 next_url = request.args.get("next", "").strip()
1512 next_url = next_url.replace("\\", "")
1513 parsed_next = urlparse(next_url)
1514 if (
1515 not next_url
1516 or parsed_next.netloc
1517 or parsed_next.scheme
1518 or not next_url.startswith("/")
1519 or next_url.startswith("//")
1520 ):
1521 next_url = "/"
1522 return redirect(next_url)
1524 @app.route("/robots.txt")
1525 def robots_txt() -> ResponseReturnValue:
1526 return send_from_directory(
1527 app.static_folder or "static", "robots.txt", mimetype="text/plain"
1528 )
1530 @app.route("/favicon.ico")
1531 def favicon() -> ResponseReturnValue:
1532 return send_from_directory(
1533 app.static_folder or "static", "favicon.svg", mimetype="image/svg+xml"
1534 )
1536 @app.route("/health")
1537 def health() -> ResponseReturnValue:
1538 # Liveness probe: proves the worker is up and routing. Deliberately does
1539 # NOT touch the database — a liveness check must not fail (and trigger a
1540 # restart) just because a dependency is down. See /health/ready below.
1541 return {"status": "ok"}, 200
1543 @app.route("/health/ready")
1544 def health_ready() -> ResponseReturnValue:
1545 # Readiness probe: confirms the database is reachable. Reserved for the
1546 # in-container Docker healthcheck (curl localhost:5000); public callers
1547 # arrive via Traefik with a non-loopback remote_addr (ProxyFix x_for=1),
1548 # so they get a 404 and the endpoint stays hidden and unabusable. The
1549 # check itself is a single cheap "SELECT 1".
1550 from flask import abort as _abort
1551 from sqlalchemy import text as _text
1552 from sqlalchemy.exc import SQLAlchemyError
1554 if request.remote_addr not in ("127.0.0.1", "::1"):
1555 _abort(404)
1556 try:
1557 db.session.execute(_text("SELECT 1"))
1558 except SQLAlchemyError:
1559 db.session.rollback()
1560 return {"status": "degraded", "database": "down"}, 503
1561 return {"status": "ready"}, 200
1563 @app.cli.command("check-empty-db")
1564 def check_empty_db_command() -> None:
1565 """Exit 0 if the database has no user data, 1 if it does (restore safety check)."""
1566 import sys
1568 from models import User # pyright: ignore[reportMissingImports]
1569 from sqlalchemy.exc import (
1570 ProgrammingError, # pyright: ignore[reportMissingImports]
1571 )
1573 try:
1574 count = User.query.count()
1575 except ProgrammingError:
1576 # Schema not initialised (table missing) — treat as empty.
1577 print("Database is empty.")
1578 return
1579 if count == 0:
1580 print("Database is empty.")
1581 else:
1582 print(f"Database has {count} user(s) — not empty.", file=sys.stderr)
1583 sys.exit(1)
1585 @app.cli.command("restore-backup")
1586 @click.argument("archive_path")
1587 def restore_backup_command(archive_path: str) -> None:
1588 """Restore a backup archive into the current empty database."""
1589 import io
1590 import sys
1591 import zipfile
1593 from flask import current_app # pyright: ignore[reportMissingImports]
1594 from models import User # pyright: ignore[reportMissingImports]
1595 from services.backup_format import ( # pyright: ignore[reportMissingImports]
1596 BackupArchiveError,
1597 parse_backup_archive,
1598 )
1600 # ── safety: refuse if DB already has data ─────────────────────────────
1601 if User.query.count() > 0:
1602 print(
1603 "ERROR: Database is not empty. Restore refused to prevent data loss.",
1604 file=sys.stderr,
1605 )
1606 sys.exit(1)
1608 # ── decrypt + extract ─────────────────────────────────────────────────
1609 with open(archive_path, "rb") as fh:
1610 payload = fh.read()
1612 # Use only OPENHANGAR_RESTORE_ENCRYPTION_KEY for decryption — never fall
1613 # back to OPENHANGAR_BACKUP_ENCRYPTION_KEY, which may be set to a
1614 # different key (e.g. the dev backup key) and would silently fail with a
1615 # wrong-key decryption error instead of prompting for the correct key.
1616 encryption_key_raw = os.environ.get("OPENHANGAR_RESTORE_ENCRYPTION_KEY", "")
1617 if encryption_key_raw:
1618 from config.routes import (
1619 _derive_key, # pyright: ignore[reportMissingImports]
1620 )
1621 from cryptography.hazmat.primitives.ciphers.aead import (
1622 AESGCM, # pyright: ignore[reportMissingImports]
1623 )
1625 key = _derive_key(encryption_key_raw)
1626 nonce, ct = payload[:12], payload[12:]
1627 try:
1628 zip_bytes = AESGCM(key).decrypt(nonce, ct, None)
1629 except Exception as exc: # noqa: BLE001 -- wrong key surfaces as various crypto/zip errors
1630 print(f"ERROR: Decryption failed — wrong key? ({exc})", file=sys.stderr)
1631 sys.exit(1)
1632 else:
1633 if str(archive_path).endswith(".enc"):
1634 print(
1635 "ERROR: Archive is encrypted (.enc) but no decryption key is available.\n"
1636 " Set OPENHANGAR_RESTORE_ENCRYPTION_KEY (recommended for cross-\n"
1637 " environment restores) or use the restore script's --key-file\n"
1638 " option or interactive prompt.",
1639 file=sys.stderr,
1640 )
1641 sys.exit(1)
1642 zip_bytes = payload
1644 try:
1645 metadata, sql_bytes, upload_entries = parse_backup_archive(zip_bytes)
1646 except BackupArchiveError as exc:
1647 print(f"ERROR: {exc}", file=sys.stderr)
1648 sys.exit(1)
1650 # ── version check ─────────────────────────────────────────────────────
1651 backup_alembic = metadata.get("alembic_head") or "unknown"
1652 backup_version = metadata.get("app_version", "unknown")
1653 current_version = os.environ.get("OPENHANGAR_VERSION", "development")
1654 print(f"Backup: version={backup_version} alembic={backup_alembic}")
1655 print(f"Current: version={current_version}")
1657 if backup_alembic != "unknown":
1658 try:
1659 from alembic.script import (
1660 ScriptDirectory, # pyright: ignore[reportMissingImports]
1661 )
1662 from flask_migrate import (
1663 Migrate as _Migrate, # pyright: ignore[reportMissingImports]
1664 )
1666 _m = _Migrate(current_app, db)
1667 scripts = ScriptDirectory.from_config(_m.get_config())
1668 known = {s.revision for s in scripts.walk_revisions()}
1669 if backup_alembic not in known:
1670 print(
1671 f"ERROR: Backup Alembic revision '{backup_alembic}' is not in "
1672 "this container's migration chain. Restore a container version "
1673 "that knows this migration.",
1674 file=sys.stderr,
1675 )
1676 sys.exit(1)
1677 except Exception as exc: # noqa: BLE001 -- best-effort compatibility check, continue restore either way
1678 print(f"WARNING: Could not verify Alembic compatibility: {exc}")
1680 # ── drop schema + restore SQL dump ────────────────────────────────────
1681 database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
1682 if not database_url.startswith("postgresql"):
1683 print(
1684 f"ERROR: Only PostgreSQL is supported for restore (got: {database_url!r})",
1685 file=sys.stderr,
1686 )
1687 sys.exit(1)
1689 print(
1690 "Dropping existing schema and restoring from backup (this may take a minute)..."
1691 )
1692 try:
1693 _drop_and_restore_schema(database_url, sql_bytes)
1694 except RuntimeError as exc:
1695 print(f"ERROR: psql restore failed:\n{exc}", file=sys.stderr)
1696 sys.exit(1)
1698 # ── restore uploaded files ────────────────────────────────────────────
1699 # The DB has just been replaced, so any files already on disk are now
1700 # orphaned. Before clearing, snapshot them into a dated zip in the
1701 # backup folder so nothing is silently destroyed.
1702 import shutil as _shutil
1704 upload_folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1705 backup_folder = current_app.config.get("BACKUP_FOLDER", "/data/backups")
1706 os.makedirs(upload_folder, exist_ok=True)
1707 os.makedirs(backup_folder, exist_ok=True)
1709 _existing = [
1710 (dp, f) for dp, _dirs, files in os.walk(upload_folder) for f in files
1711 ]
1712 if _existing:
1713 from datetime import datetime
1715 _snap_ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
1716 _enc_key_raw = _env_or_file("BACKUP_ENCRYPTION_KEY")
1717 _snap_ext = ".zip.enc" if _enc_key_raw else ".zip"
1718 _snap_name = f"uploads_pre_restore_{_snap_ts}{_snap_ext}"
1719 _snap_path = os.path.join(backup_folder, _snap_name)
1721 _snap_buf = io.BytesIO()
1722 with zipfile.ZipFile(_snap_buf, "w", zipfile.ZIP_DEFLATED) as _zf:
1723 for _dp, _fn in _existing:
1724 _fp = os.path.join(_dp, _fn)
1725 _rel = os.path.relpath(_fp, upload_folder)
1726 _zf.write(_fp, arcname=_rel)
1727 _snap_bytes = _snap_buf.getvalue()
1729 if _enc_key_raw:
1730 from config.routes import ( # pyright: ignore[reportMissingImports]
1731 _derive_key,
1732 _encrypt_bytes,
1733 )
1735 _snap_bytes = _encrypt_bytes(_snap_bytes, _derive_key(_enc_key_raw))
1737 with open(_snap_path, "wb") as _fh:
1738 _fh.write(_snap_bytes)
1740 print(
1741 f"WARNING: {len(_existing)} pre-existing file(s) found in the upload folder.\n"
1742 f" They have been snapshotted to:\n"
1743 f" {_snap_path}\n"
1744 f" {'(encrypted with OPENHANGAR_BACKUP_ENCRYPTION_KEY) ' if _enc_key_raw else ''}"
1745 f"The upload folder will now be cleared."
1746 )
1748 for _item in os.scandir(upload_folder):
1749 if _item.is_dir():
1750 _shutil.rmtree(_item.path, ignore_errors=True)
1751 else:
1752 os.unlink(_item.path)
1754 if upload_entries:
1755 upload_root = os.path.realpath(upload_folder)
1756 with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
1757 for entry in upload_entries:
1758 # entry is e.g. "uploads/tenant/OO-REG/photos/01-abc.jpg"
1759 rel = entry[len("uploads/") :]
1760 if not rel:
1761 continue # skip the bare "uploads/" directory entry
1762 dest = os.path.join(upload_folder, rel)
1763 dest_real = os.path.realpath(dest)
1764 if dest_real != upload_root and not dest_real.startswith(
1765 upload_root + os.sep
1766 ):
1767 print(
1768 f"ERROR: Archive entry {entry!r} resolves outside the "
1769 "upload folder — refusing to restore (corrupted or "
1770 "tampered archive).",
1771 file=sys.stderr,
1772 )
1773 sys.exit(1)
1774 os.makedirs(os.path.dirname(dest), exist_ok=True)
1775 with open(dest, "wb") as fh:
1776 fh.write(zf.read(entry))
1777 print(f"Restored {len(upload_entries)} uploaded file(s).")
1778 else:
1779 print("Backup contains no uploaded files; upload folder cleared.")
1781 print("Restore complete.")
1783 @app.cli.command("backup-now")
1784 def backup_now_command() -> None:
1785 import sys
1787 from config.routes import run_backup
1789 try:
1790 record = run_backup()
1791 print(
1792 f"Backup OK: {record.filename} ({record.size_bytes} bytes, sha256={record.sha256})"
1793 )
1794 except RuntimeError as exc:
1795 print(f"Backup FAILED: {exc}")
1796 sys.exit(1)
1798 # Flask CLI command used by demo/refresh.sh to drop and recreate the schema.
1799 # Only works in demo mode — production uses Alembic migrations.
1800 @app.cli.command("reset-db")
1801 def reset_db_command() -> None:
1802 if flask_env != "demo":
1803 print("reset-db is only available in demo mode. Aborting.")
1804 return
1805 from models import reset_schema
1807 reset_schema(db)
1808 print("Database schema reset.")
1810 # Flask CLI command used by demo/refresh.sh to wipe and reseed demo slots
1811 @app.cli.command("seed-demo")
1812 def seed_demo_command() -> None:
1813 from demo_seed import seed as demo_seed
1815 demo_seed()
1816 print("Demo slots reseeded.")
1818 # Re-apply env-var settings wiped by reset-db
1819 openaip_key = _env_or_file("OPENAIP_API_KEY").strip()
1820 if openaip_key:
1821 from models import AppSetting
1823 setting = db.session.get(AppSetting, "openaip_api_key")
1824 if setting is None:
1825 db.session.add(AppSetting(key="openaip_api_key", value=openaip_key))
1826 else:
1827 setting.value = openaip_key
1828 db.session.commit()
1829 print("Environment settings applied.")
1831 # Only run against a real PostgreSQL database (sqlite = dev/test), and only
1832 # when called from a long-running server process — not from init scripts such
1833 # as docker-init-db.py that run migrations before the schema exists.
1834 if "sqlite" not in app.config.get(
1835 "SQLALCHEMY_DATABASE_URI", ""
1836 ) and not os.environ.get("OPENHANGAR_SKIP_BACKGROUND_THREADS"):
1837 # Only a production deployment is ever actually upgraded in place, so
1838 # only there does checking for updates mean anything. Demo wipes and
1839 # recreates its schema every few hours (see demo/refresh.sh) --
1840 # checking there just adds a recurring app_settings race against
1841 # reset-db's DROP/CREATE SCHEMA window -- and development/test runs
1842 # are never the thing being "upgraded".
1843 if flask_env == "production":
1844 from services.version_service import (
1845 start_version_check_thread, # pyright: ignore[reportMissingImports]
1846 )
1848 start_version_check_thread(app)
1849 from sync_watcher import (
1850 start_sync_watcher, # pyright: ignore[reportMissingImports]
1851 )
1853 start_sync_watcher(app)
1854 if os.environ.get("OPENHANGAR_ENV", "production") == "production":
1855 _start_easa_sync_scheduler(app)
1856 _start_notification_scheduler(app)
1857 from services.backup_scheduler import (
1858 start_backup_scheduler, # pyright: ignore[reportMissingImports]
1859 )
1861 start_backup_scheduler(app)
1862 import threading
1864 from services.notification_service import (
1865 send_welcome_email_if_needed, # pyright: ignore[reportMissingImports]
1866 )
1868 threading.Thread(
1869 target=send_welcome_email_if_needed,
1870 args=(app,),
1871 daemon=True,
1872 name="welcome-email",
1873 ).start()
1875 if (
1876 os.environ.get("WERKZEUG_RUN_MAIN") == "true"
1877 and os.environ.get("OPENHANGAR_ENV", "production") == "development"
1878 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower() in ("1", "true", "yes")
1879 ):
1880 print("OPENHANGAR_SW_ENABLED: service worker active in debug mode", flush=True)
1882 _validate_config(app)
1883 return app
1886def _validate_config(app: Flask) -> None:
1887 """Collect and report all configuration problems at once rather than one at a time."""
1888 errors: list[str] = []
1890 # OPENHANGAR_SECRET_KEY: minimum length (existence and placeholder already checked above)
1891 secret = app.config.get("SECRET_KEY", "")
1892 if secret and len(secret) < 32:
1893 errors.append(
1894 f"OPENHANGAR_SECRET_KEY is too short ({len(secret)} chars, minimum 32). "
1895 "Generate one with: openssl rand -hex 32"
1896 )
1898 # OPENHANGAR_ENV: must be one of the known values when set
1899 _raw_env = os.environ.get("OPENHANGAR_ENV", "")
1900 if _raw_env and _raw_env not in ("production", "development", "test", "demo"):
1901 errors.append(
1902 f"OPENHANGAR_ENV must be one of: production, development, test, demo "
1903 f"(got {_raw_env!r})"
1904 )
1906 # OPENHANGAR_MAX_UPLOAD_BYTES: must be a plain positive integer when set
1907 _raw_max = os.environ.get("OPENHANGAR_MAX_UPLOAD_BYTES", "")
1908 _validated_max: int | None = None
1909 if _raw_max:
1910 try:
1911 _parsed = int(_raw_max)
1912 if _parsed <= 0:
1913 errors.append("OPENHANGAR_MAX_UPLOAD_BYTES must be a positive integer")
1914 else:
1915 _validated_max = _parsed
1916 except ValueError:
1917 errors.append(
1918 f"OPENHANGAR_MAX_UPLOAD_BYTES must be a plain integer (bytes), got {_raw_max!r}. "
1919 "Example: 52428800 for 50 MB."
1920 )
1922 # OPENHANGAR_SYNC_SCAN_INTERVAL: must be a positive integer when set
1923 _raw_interval = os.environ.get("OPENHANGAR_SYNC_SCAN_INTERVAL", "")
1924 if _raw_interval:
1925 try:
1926 _parsed_interval = int(_raw_interval)
1927 if _parsed_interval <= 0:
1928 errors.append(
1929 "OPENHANGAR_SYNC_SCAN_INTERVAL must be a positive integer (seconds)"
1930 )
1931 except ValueError:
1932 errors.append(
1933 f"OPENHANGAR_SYNC_SCAN_INTERVAL must be a plain integer (seconds), got {_raw_interval!r}. "
1934 "Example: 60"
1935 )
1937 # OPENHANGAR_DATABASE_URL: production deployments must use PostgreSQL
1938 db_url = app.config.get("SQLALCHEMY_DATABASE_URI", "")
1939 flask_env = os.environ.get("OPENHANGAR_ENV", "production")
1940 if (
1941 "sqlite" not in db_url
1942 and flask_env not in ("development", "test")
1943 and not db_url.startswith(("postgresql://", "postgresql+psycopg://"))
1944 ):
1945 scheme = db_url.split("://")[0] if "://" in db_url else db_url[:20]
1946 errors.append(
1947 f"OPENHANGAR_DATABASE_URL scheme {scheme!r} is not supported in production. "
1948 "Use 'postgresql://'."
1949 )
1951 # OPENHANGAR_BACKUP_ENCRYPTION_KEY / OPENHANGAR_RESTORE_ENCRYPTION_KEY:
1952 # whitespace-only values are likely a misconfiguration.
1953 enc_key = _env_or_file("BACKUP_ENCRYPTION_KEY")
1954 if enc_key and not enc_key.strip():
1955 errors.append(
1956 "OPENHANGAR_BACKUP_ENCRYPTION_KEY is set but contains only whitespace. "
1957 "Either provide a real key or leave the variable unset."
1958 )
1959 restore_enc_key = os.environ.get("OPENHANGAR_RESTORE_ENCRYPTION_KEY", "")
1960 if restore_enc_key and not restore_enc_key.strip():
1961 errors.append(
1962 "OPENHANGAR_RESTORE_ENCRYPTION_KEY is set but contains only whitespace. "
1963 "Either provide a real key or leave the variable unset."
1964 )
1966 # OPENHANGAR_SMTP_PORT: must be an integer in valid port range when set
1967 _raw_smtp_port = os.environ.get("OPENHANGAR_SMTP_PORT", "")
1968 if _raw_smtp_port:
1969 try:
1970 _smtp_port_val = int(_raw_smtp_port)
1971 if not (1 <= _smtp_port_val <= 65535):
1972 errors.append(
1973 f"OPENHANGAR_SMTP_PORT must be between 1 and 65535, got {_raw_smtp_port!r}"
1974 )
1975 except ValueError:
1976 errors.append(
1977 f"OPENHANGAR_SMTP_PORT must be an integer, got {_raw_smtp_port!r}"
1978 )
1980 # OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES: must be a positive integer when set
1981 _raw_busy = os.environ.get("OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES", "")
1982 if _raw_busy:
1983 try:
1984 if int(_raw_busy) <= 0:
1985 errors.append(
1986 "OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a positive integer"
1987 )
1988 except ValueError:
1989 errors.append(
1990 f"OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a plain integer (minutes), "
1991 f"got {_raw_busy!r}"
1992 )
1994 # OPENHANGAR_NOTIFICATION_TIME: optional, but must be valid HH:MM when set
1995 _raw_notif_time = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "")
1996 if _raw_notif_time:
1997 try:
1998 _parse_notification_time()
1999 except ValueError as exc:
2000 errors.append(str(exc))
2002 # OPENHANGAR_BACKUP_* scheduling/retention vars: optional, validated when set
2003 from services.backup_scheduler import ( # pyright: ignore[reportMissingImports]
2004 parse_auto_upgrade_enabled,
2005 parse_backup_keep,
2006 parse_backup_keep_days,
2007 parse_backup_keep_months,
2008 parse_backup_keep_weeks,
2009 parse_backup_retention,
2010 parse_backup_time,
2011 )
2013 for _backup_parser in (
2014 parse_backup_time,
2015 parse_backup_keep,
2016 parse_backup_retention,
2017 parse_backup_keep_days,
2018 parse_backup_keep_weeks,
2019 parse_backup_keep_months,
2020 parse_auto_upgrade_enabled,
2021 ):
2022 try:
2023 _backup_parser()
2024 except ValueError as exc:
2025 errors.append(str(exc))
2027 # OPENHANGAR_AUTO_UPGRADE: requires scheduled backups AND the upgrade dir
2028 # to be configured — an automatic upgrade must never run without a
2029 # scheduled backup to fall back on.
2030 try:
2031 if parse_auto_upgrade_enabled():
2032 if not os.environ.get("OPENHANGAR_BACKUP_TIME", "").strip():
2033 errors.append(
2034 "OPENHANGAR_AUTO_UPGRADE is enabled but OPENHANGAR_BACKUP_TIME "
2035 "is not set — automatic upgrades run right after the scheduled "
2036 "backup, so a backup schedule must be configured first."
2037 )
2038 if not os.environ.get("OPENHANGAR_UPGRADE_DIR", "").strip():
2039 errors.append(
2040 "OPENHANGAR_AUTO_UPGRADE is enabled but OPENHANGAR_UPGRADE_DIR "
2041 "is not set — automatic upgrades need the upgrade directory "
2042 "configured (see docs/self-hosting.md#one-click-upgrades-optional)."
2043 )
2044 except ValueError:
2045 pass # already reported by the loop above
2047 # OPENHANGAR_ALERT_NTFY_TOPIC_URL: must be an http(s) URL when set
2048 _ntfy_url = os.environ.get("OPENHANGAR_ALERT_NTFY_TOPIC_URL", "").strip()
2049 if _ntfy_url and not _ntfy_url.startswith(("http://", "https://")):
2050 errors.append(
2051 f"OPENHANGAR_ALERT_NTFY_TOPIC_URL must start with http:// or https://, "
2052 f"got {_ntfy_url!r}"
2053 )
2055 # OPENHANGAR_ALERT_EMAIL_TO: must look like an email address when set,
2056 # and SMTP must be configured for delivery to be possible
2057 _alert_email = os.environ.get("OPENHANGAR_ALERT_EMAIL_TO", "").strip()
2058 if _alert_email:
2059 if "@" not in _alert_email:
2060 errors.append(
2061 f"OPENHANGAR_ALERT_EMAIL_TO must be a valid email address, "
2062 f"got {_alert_email!r}"
2063 )
2064 elif not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip():
2065 errors.append(
2066 "OPENHANGAR_ALERT_EMAIL_TO is set but OPENHANGAR_SMTP_HOST is not configured — "
2067 "alert emails cannot be delivered"
2068 )
2070 # OPENHANGAR_ALERT_WEBHOOK_URL: must be an http(s) URL when set
2071 _webhook_url = os.environ.get("OPENHANGAR_ALERT_WEBHOOK_URL", "").strip()
2072 if _webhook_url and not _webhook_url.startswith(("http://", "https://")):
2073 errors.append(
2074 f"OPENHANGAR_ALERT_WEBHOOK_URL must start with http:// or https://, "
2075 f"got {_webhook_url!r}"
2076 )
2078 # OPENHANGAR_EMAIL_SUBJECT_PREFIX: true/false-like, or a short custom prefix
2079 _raw_subject_prefix = os.environ.get("OPENHANGAR_EMAIL_SUBJECT_PREFIX", "")
2080 if _raw_subject_prefix and not _raw_subject_prefix.strip():
2081 errors.append(
2082 "OPENHANGAR_EMAIL_SUBJECT_PREFIX is set but contains only whitespace. "
2083 "Use true/false, or a custom prefix, or leave the variable unset."
2084 )
2085 elif (
2086 _raw_subject_prefix.strip()
2087 and _raw_subject_prefix.strip().lower()
2088 not in ("true", "false", "1", "0", "yes", "no")
2089 and len(_raw_subject_prefix.strip()) > 16
2090 ):
2091 errors.append(
2092 f"OPENHANGAR_EMAIL_SUBJECT_PREFIX must be true/false (or 1/0, yes/no) or a "
2093 f"short custom prefix (max 16 characters), got {_raw_subject_prefix!r}"
2094 )
2096 if errors:
2097 bullet_list = "\n".join(f" • {e}" for e in errors)
2098 raise RuntimeError(
2099 f"Configuration errors — fix before starting:\n{bullet_list}"
2100 )
2102 if _validated_max is not None:
2103 app.config["MAX_CONTENT_LENGTH"] = _validated_max
2106if __name__ == "__main__": # pragma: no cover
2107 _debug = os.environ.get("OPENHANGAR_ENV") == "development"
2108 create_app().run(host="0.0.0.0", port=5000, debug=_debug) # nosec B104