Coverage for app/init.py: 100%
881 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
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}
76# EE-09: aviation history days — (month, day, msgid). Add new entries here.
77_AVIATION_DAYS: list[tuple[int, int, str]] = [
78 (3, 2, "First flight of Concorde — André Turcat at the controls, Toulouse (1969)"),
79 (
80 5,
81 21,
82 "Charles Lindbergh lands at Le Bourget — first solo transatlantic flight (1927)",
83 ),
84 (
85 7,
86 25,
87 "Louis Blériot crosses the English Channel — first crossing by airplane (1909)",
88 ),
89 (
90 11,
91 21,
92 "Pilâtre de Rozier & d'Arlandes — first manned free balloon flight, Paris (1783)",
93 ),
94 (12, 17, "First flight: 17 Dec 1903 — 12 seconds, 37 metres. (Wright Brothers)"),
95]
98def _aviation_day_msgid(month: int, day: int) -> str | None:
99 for m, d, msgid in _AVIATION_DAYS:
100 if m == month and d == day:
101 return msgid
102 return None
105@cache
106def _static_folder_mtime_token(static_folder: str) -> str:
107 latest = 0
108 for root, _dirs, files in os.walk(static_folder):
109 for name in files:
110 try:
111 mtime = int(os.path.getmtime(os.path.join(root, name)))
112 except OSError: # file removed while walking
113 continue
114 latest = max(latest, mtime)
115 return str(latest)
118def _static_cache_version(static_folder: str) -> str:
119 """Cache-busting token appended to static URLs (?v=…).
121 The release version when running a published image; otherwise the newest
122 file mtime under the static folder — stable across gunicorn workers, and
123 changes whenever an asset changes during development."""
124 version = os.environ.get("OPENHANGAR_VERSION", "")
125 if version and version != "development":
126 return version
127 return _static_folder_mtime_token(static_folder)
130@event.listens_for(Engine, "connect")
131def _set_sqlite_fk_pragma(dbapi_connection: Any, _record: Any) -> None:
132 if isinstance(dbapi_connection, sqlite3.Connection):
133 cur = dbapi_connection.cursor()
134 cur.execute("PRAGMA foreign_keys=ON")
135 cur.close()
138def _drop_and_restore_schema(database_url: str, sql_bytes: bytes) -> None:
139 """Drop the public schema and restore it from a pg_dump byte-string."""
140 import subprocess # nosec B404
141 import tempfile
143 from models import db # pyright: ignore[reportMissingImports]
144 from sqlalchemy import text # pyright: ignore[reportMissingImports]
145 from utils import to_libpq_url # pyright: ignore[reportMissingImports]
147 # Close the ORM session while its connection is still alive so Flask's
148 # teardown has nothing left to rollback after we terminate other backends.
149 db.session.remove()
151 with db.engine.connect() as conn:
152 # Terminate all other connections so DROP SCHEMA can acquire its
153 # ACCESS EXCLUSIVE lock even if the web server left a connection
154 # idle-in-transaction (which would block indefinitely otherwise).
155 conn.execute(
156 text(
157 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity"
158 " WHERE datname = current_database() AND pid != pg_backend_pid()"
159 )
160 )
161 conn.execute(text("DROP SCHEMA public CASCADE"))
162 conn.execute(text("CREATE SCHEMA public"))
163 conn.commit()
165 # Dispose the connection pool so no SQLAlchemy connections linger and
166 # block psql's DDL statements with AccessShareLock.
167 db.engine.dispose()
169 # Strip ownership and privilege statements from the dump. pg_dump (without
170 # --no-owner / --no-acl) records the source role for every object and emits
171 # GRANT/REVOKE commands, but those role names are environment-specific and
172 # do not exist on the target server. New backups are produced with
173 # --no-owner --no-acl, but we strip here too as a safety net for archives
174 # made before that change.
175 import re
177 sql_bytes = re.sub(
178 rb"^(?:ALTER\s+\S[^\n]*\bOWNER\s+TO\b|GRANT\b|REVOKE\b)[^\n]*;\s*$",
179 b"",
180 sql_bytes,
181 flags=re.MULTILINE | re.IGNORECASE,
182 )
184 # Write the dump to a temp file so psql can read it directly rather than
185 # via stdin — avoids pipe-buffering hangs on large dumps and lets psql
186 # print progress to the terminal in real time.
187 with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
188 tmp.write(sql_bytes)
189 tmp_path = tmp.name
191 try:
192 result = subprocess.run( # nosec B603
193 ["psql", "--no-password", "-f", tmp_path, to_libpq_url(database_url)],
194 timeout=600,
195 check=False, # returncode checked explicitly below
196 )
197 except subprocess.TimeoutExpired:
198 raise RuntimeError("psql restore timed out after 10 minutes.")
199 finally:
200 os.unlink(tmp_path)
202 if result.returncode != 0:
203 raise RuntimeError(f"psql exited with code {result.returncode}")
206def _easa_sync_loop(app: Flask) -> None:
207 import logging
208 import os
209 import random
210 import time
211 from datetime import datetime, timedelta
213 from airworthiness_sync import (
214 sync_all_nodes, # pyright: ignore[reportMissingImports]
215 )
217 _log = logging.getLogger(__name__)
219 # Determine the daily sync time (UTC). Admin can pin a specific hour via
220 # OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR (0-23). Default: random hour
221 # 01-05 UTC so that different instances do not all hit EASA simultaneously.
222 env_hour = os.environ.get("OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR")
223 if env_hour is not None:
224 try:
225 sync_hour = int(env_hour) % 24
226 except ValueError:
227 sync_hour = random.randint(1, 5)
228 else:
229 sync_hour = random.randint(1, 5)
230 sync_minute = random.randint(0, 59)
231 _log.info("EASA sync scheduled daily at %02d:%02d UTC", sync_hour, sync_minute)
233 while True:
234 now = datetime.now(UTC)
235 next_run = now.replace(
236 hour=sync_hour, minute=sync_minute, second=0, microsecond=0
237 )
238 if next_run <= now:
239 next_run += timedelta(days=1)
240 time.sleep((next_run - datetime.now(UTC)).total_seconds())
241 sync_all_nodes(app)
244def _start_easa_sync_scheduler(app: Flask) -> None:
245 import threading
247 t = threading.Thread(
248 target=_easa_sync_loop,
249 args=(app,),
250 daemon=True,
251 name="easa-sync",
252 )
253 t.start()
256def _parse_notification_time() -> tuple[int, int]:
257 """Return (hour, minute) from OPENHANGAR_NOTIFICATION_TIME (HH:MM, default 07:00).
259 Raises ValueError with a human-readable message if the value is set but invalid.
260 """
261 raw = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "07:00")
262 err = f"OPENHANGAR_NOTIFICATION_TIME={raw!r} is invalid — expected HH:MM (e.g. '07:00')"
263 parts = raw.split(":")
264 if len(parts) != 2:
265 raise ValueError(err)
266 try:
267 hour, minute = int(parts[0]), int(parts[1])
268 except ValueError:
269 raise ValueError(err)
270 if not (0 <= hour <= 23 and 0 <= minute <= 59):
271 raise ValueError(err)
272 return hour, minute
275def _notification_daily_loop(app: Flask, run_hour: int, run_minute: int) -> None:
276 import logging
277 import time
278 from datetime import datetime, timedelta
280 _log = logging.getLogger(__name__)
281 _log.info(
282 "Notification daily check scheduled at %02d:%02d UTC", run_hour, run_minute
283 )
285 while True:
286 now = datetime.now(UTC)
287 next_run = now.replace(
288 hour=run_hour, minute=run_minute, second=0, microsecond=0
289 )
290 if next_run <= now:
291 next_run += timedelta(days=1)
292 time.sleep((next_run - datetime.now(UTC)).total_seconds())
293 try:
294 from services.notification_service import (
295 run_daily_checks, # pyright: ignore[reportMissingImports]
296 )
298 run_daily_checks(app)
299 except Exception: # noqa: BLE001 -- background job, must not crash the loop, retries tomorrow
300 _log.exception("Notification daily check failed; will retry tomorrow")
303def _start_notification_scheduler(app: Flask) -> None:
304 import threading
306 run_hour, run_minute = _parse_notification_time()
307 t = threading.Thread(
308 target=_notification_daily_loop,
309 args=(app, run_hour, run_minute),
310 daemon=True,
311 name="notification-daily",
312 )
313 t.start()
316def create_app() -> Flask:
317 from security_alerts import (
318 attach_to_logger, # pyright: ignore[reportMissingImports]
319 )
321 attach_to_logger()
323 app = Flask(__name__)
324 app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign]
326 # Propagate OPENHANGAR_ENV → FLASK_ENV so Flask's own internals keep working.
327 os.environ["FLASK_ENV"] = os.environ.get("OPENHANGAR_ENV", "production")
329 app.config["SQLALCHEMY_DATABASE_URI"] = _normalize_database_url(
330 _env_or_file("DATABASE_URL") or "sqlite:///:memory:"
331 )
332 secret_key = _env_or_file("SECRET_KEY")
333 if not secret_key:
334 raise RuntimeError("OPENHANGAR_SECRET_KEY environment variable must be set")
335 if "change" in secret_key.lower():
336 raise RuntimeError(
337 "OPENHANGAR_SECRET_KEY appears to be a placeholder value. "
338 "Generate a real key with: openssl rand -hex 32"
339 )
340 app.config["SECRET_KEY"] = secret_key
341 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
342 app.config["UPLOAD_FOLDER"] = os.environ.get(
343 "OPENHANGAR_UPLOAD_FOLDER", "/data/uploads"
344 )
345 app.config["BACKUP_FOLDER"] = os.environ.get(
346 "OPENHANGAR_BACKUP_FOLDER", "/data/backups"
347 )
348 app.config["MAX_CONTENT_LENGTH"] = (
349 50 * 1024 * 1024
350 ) # overridden by _validate_config
351 app.config["SESSION_COOKIE_SECURE"] = True
352 app.config["SESSION_COOKIE_HTTPONLY"] = True
353 app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
354 _session_days = int(os.environ.get("OPENHANGAR_SESSION_LIFETIME_DAYS", "30"))
355 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=_session_days)
357 flask_env = os.environ.get("OPENHANGAR_ENV", "production")
359 if flask_env in ("development", "test"):
360 app.config["TEMPLATES_AUTO_RELOAD"] = True
362 from models import db
364 db.init_app(app)
365 Migrate(app, db)
367 def _get_locale() -> str | None:
368 if not has_request_context():
369 return "en"
370 if session.get("user_id"):
371 # Demo sessions: visitor's session language takes precedence over the
372 # demo user's stored default so Accept-Language / manual switcher work.
373 if (
374 session.get("demo_slot_id")
375 and session.get("language") in SUPPORTED_LOCALES
376 ):
377 return str(session["language"])
378 from models import User
380 user = db.session.get(User, session["user_id"])
381 if user and user.language in SUPPORTED_LOCALES:
382 return str(user.language)
383 if session.get("language") in SUPPORTED_LOCALES:
384 return str(session["language"])
385 return str(request.accept_languages.best_match(SUPPORTED_LOCALES, default="en"))
387 Babel(app, locale_selector=_get_locale)
388 # No independent expiry: some nav pages are cached client-side (sw.js
389 # SWR_ROUTES) for offline/instant-navigation support, so a token embedded
390 # in a stale-but-served page must stay valid until the session itself
391 # expires, not Flask-WTF's 1-hour default.
392 app.config["WTF_CSRF_TIME_LIMIT"] = None
393 CSRFProtect(app)
395 from extensions import cache as _cache # pyright: ignore[reportMissingImports]
396 from extensions import limiter as _limiter # pyright: ignore[reportMissingImports]
398 app.config["CACHE_TYPE"] = "SimpleCache"
399 app.config["CACHE_DEFAULT_TIMEOUT"] = 300
400 _cache.init_app(app)
401 app.config["RATELIMIT_ENABLED"] = os.environ.get(
402 "OPENHANGAR_RATELIMIT_ENABLED", "true"
403 ).lower() in ("1", "true", "yes")
404 _limiter.init_app(app)
406 static_version = _static_cache_version(app.static_folder or "static")
408 @app.url_defaults
409 def _static_cache_bust(endpoint: str, values: dict[str, Any]) -> None:
410 if endpoint == "static":
411 values.setdefault("v", static_version)
413 @app.before_request
414 def _generate_csp_nonce() -> None:
415 g.csp_nonce = secrets.token_urlsafe(16)
417 def _csp_nonce() -> str:
418 return getattr(g, "csp_nonce", "")
420 app.jinja_env.globals["csp_nonce"] = _csp_nonce
422 @app.after_request
423 def _security_headers(response: Any) -> Any:
424 nonce = getattr(g, "csp_nonce", "")
425 response.headers["Content-Security-Policy"] = (
426 f"default-src 'self'; "
427 f"script-src 'nonce-{nonce}'; "
428 f"worker-src 'self' blob:; "
429 f"style-src-elem 'self'; "
430 f"style-src-attr 'none'; "
431 f"font-src 'self'; "
432 f"img-src 'self' data: blob: tile.openstreetmap.org *.basemaps.cartocdn.com api.tiles.openaip.net; "
433 f"connect-src 'self'; "
434 f"object-src 'none'; "
435 f"base-uri 'self'; "
436 f"form-action 'self'; "
437 f"frame-ancestors 'none';"
438 )
439 response.headers["X-Frame-Options"] = "DENY"
440 response.headers["X-Content-Type-Options"] = "nosniff"
441 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
442 response.headers["Permissions-Policy"] = (
443 "camera=(), microphone=(), geolocation=(), payment=()"
444 )
445 response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
446 response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
447 if request.endpoint == "static" and response.status_code in (200, 304):
448 # Static URLs carry the ?v= cache-buster, so long-lived caching is
449 # safe; uploads/documents go through their own routes and keep the
450 # authenticated no-store below.
451 response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
452 elif session.get("user_id"):
453 existing_cc = response.headers.get("Cache-Control", "")
454 if "public" not in existing_cc and "immutable" not in existing_cc:
455 response.headers["Cache-Control"] = "no-store, private"
456 return response
458 from flask_babel import format_date, format_datetime, format_decimal
460 app.jinja_env.globals.update(
461 format_date=format_date,
462 format_datetime=format_datetime,
463 format_decimal=format_decimal,
464 )
466 if app.config.get("TESTING") or os.environ.get("OPENHANGAR_ENV") == "development":
467 from jinja2 import StrictUndefined
469 app.jinja_env.undefined = StrictUndefined
471 from utils import (
472 _load_aircraft_type_variants,
473 _load_airport_names,
474 )
476 @app.template_filter("airport_name")
477 def _airport_name_filter(code: str | None) -> str:
478 if not code:
479 return ""
480 return _load_airport_names().get(code.upper(), "")
482 @app.route("/manifest.json")
483 def pwa_manifest() -> ResponseReturnValue:
484 from flask import jsonify as _jsonify
486 return _jsonify(
487 {
488 "name": "OpenHangar",
489 "short_name": "OpenHangar",
490 "description": "Open-source aircraft operations and pilot logbook",
491 "start_url": "/",
492 "display": "standalone",
493 "theme_color": "#14233A",
494 "background_color": "#14233A",
495 "icons": [
496 {
497 "src": "/static/icons/icon.svg",
498 "sizes": "any",
499 "type": "image/svg+xml",
500 },
501 {
502 "src": "/static/icons/icon-maskable.svg",
503 "sizes": "any",
504 "type": "image/svg+xml",
505 "purpose": "maskable",
506 },
507 ],
508 "share_target": {
509 "action": "/pwa/shared",
510 "method": "POST",
511 "enctype": "multipart/form-data",
512 "params": {
513 "title": "title",
514 "text": "text",
515 "url": "url",
516 "files": [
517 {
518 "name": "files",
519 "accept": ["application/pdf", "image/*"],
520 }
521 ],
522 },
523 },
524 "shortcuts": [
525 {
526 "name": "Log a Flight",
527 "short_name": "Log Flight",
528 "url": "/flights/new",
529 "icons": [
530 {
531 "src": "/static/icons/shortcut-log-flight.svg",
532 "sizes": "any",
533 "type": "image/svg+xml",
534 }
535 ],
536 },
537 {
538 "name": "My Aircraft",
539 "short_name": "Aircraft",
540 "url": "/aircraft",
541 "icons": [
542 {
543 "src": "/static/icons/shortcut-aircraft.svg",
544 "sizes": "any",
545 "type": "image/svg+xml",
546 }
547 ],
548 },
549 {
550 "name": "Documents",
551 "short_name": "Documents",
552 "url": "/documents",
553 "icons": [
554 {
555 "src": "/static/icons/shortcut-documents.svg",
556 "sizes": "any",
557 "type": "image/svg+xml",
558 }
559 ],
560 },
561 ],
562 }
563 )
565 @app.route("/sw.js")
566 def service_worker() -> ResponseReturnValue:
567 sw_path = os.path.join(app.static_folder or "static", "js", "sw.js")
568 with open(sw_path, encoding="utf-8") as fh:
569 content = fh.read()
570 version = os.environ.get("OPENHANGAR_VERSION", "")
571 cache_name = (
572 f"openhangar-{version}"
573 if version and version != "development"
574 else f"openhangar-{secrets.token_hex(8)}"
575 )
576 content = content.replace("__SW_CACHE_VERSION__", cache_name)
577 response = Response(content, mimetype="application/javascript")
578 response.headers["Service-Worker-Allowed"] = "/"
579 return response
581 @app.route("/api/check-flight-duplicate")
582 def api_check_flight_duplicate() -> ResponseReturnValue:
583 from flask import jsonify as _jsonify
585 if not session.get("user_id"):
586 return _jsonify({"error": "unauthorized"}), 401
587 date_str = request.args.get("date", "")
588 aircraft_id_str = request.args.get("aircraft_id", "")
589 dep = request.args.get("departure_icao", "")
590 arr = request.args.get("arrival_icao", "")
591 exclude_flight_id_str = request.args.get("exclude_flight_id", "")
592 exclude_flight_id = (
593 int(exclude_flight_id_str) if exclude_flight_id_str.isdigit() else None
594 )
595 if not (date_str and dep and arr):
596 return _jsonify({"duplicate": False})
597 from models import Aircraft, Flight, TenantUser
598 from sqlalchemy import or_ as _or # pyright: ignore[reportMissingImports]
600 uid = int(session["user_id"])
601 try:
602 from datetime import date as _date
604 flight_date = _date.fromisoformat(date_str)
605 except ValueError:
606 return _jsonify({"duplicate": False})
608 tu = TenantUser.query.filter_by(user_id=uid).first()
610 if tu and aircraft_id_str and aircraft_id_str.isdigit():
611 ac_id = int(aircraft_id_str)
612 # Scope by tenant: only match flights on an aircraft the caller's
613 # tenant owns, otherwise this leaks a cross-tenant existence oracle.
614 owned = Aircraft.query.filter_by(id=ac_id, tenant_id=tu.tenant_id).first()
615 if owned:
616 q = Flight.query.filter_by(
617 aircraft_id=ac_id,
618 date=flight_date,
619 departure_icao=dep,
620 arrival_icao=arr,
621 )
622 if exclude_flight_id:
623 q = q.filter(Flight.id != exclude_flight_id)
624 if q.first():
625 return _jsonify({"duplicate": True})
627 if tu:
628 q_pilot = Flight.query.filter(
629 _or(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid),
630 Flight.date == flight_date,
631 Flight.departure_icao == dep,
632 Flight.arrival_icao == arr,
633 )
634 if exclude_flight_id:
635 q_pilot = q_pilot.filter(Flight.id != exclude_flight_id)
636 if q_pilot.first():
637 return _jsonify({"duplicate": True})
639 return _jsonify({"duplicate": False})
641 @app.route("/airport-search")
642 def airport_search() -> ResponseReturnValue:
643 if not session.get("user_id"):
644 return {"results": []}
645 q = request.args.get("q", "").strip()
646 if len(q) < 2:
647 return {"results": []}
648 q_code = q.upper()
649 q_low = q.lower()
650 names = _load_airport_names()
651 code_hits: list[dict[str, str]] = []
652 name_hits: list[dict[str, str]] = []
653 for code, name in names.items():
654 if code.startswith(q_code):
655 code_hits.append({"code": code, "name": name})
656 elif q_low in name.lower():
657 name_hits.append({"code": code, "name": name})
658 return {"results": (code_hits + name_hits)[:10]}
660 @app.route("/aircraft-type-search")
661 def aircraft_type_search() -> ResponseReturnValue:
662 if not session.get("user_id"):
663 return {"results": []}
664 q = request.args.get("q", "").strip()
665 if len(q) < 2:
666 return {"results": []}
667 q_up = q.upper()
668 words = q.lower().split()
669 variants = _load_aircraft_type_variants()
670 code_hits: list[dict[str, str]] = []
671 name_hits: list[dict[str, str]] = []
672 for des, full_name, mfr, mdl in variants:
673 name_low = full_name.lower()
674 entry = {"code": des, "name": full_name, "manufacturer": mfr, "model": mdl}
675 if des.startswith(q_up):
676 code_hits.append(entry)
677 elif all(w in name_low for w in words):
678 name_hits.append(entry)
679 return {"results": code_hits + name_hits}
681 from utils import AircraftRefConverter
683 app.url_map.converters["aircraft_ref"] = AircraftRefConverter
685 from auth.routes import auth_bp
687 app.register_blueprint(auth_bp)
689 from aircraft.routes import aircraft_bp
691 app.register_blueprint(aircraft_bp)
693 from flights.routes import flights_bp
695 app.register_blueprint(flights_bp)
697 from maintenance.routes import maintenance_bp
699 app.register_blueprint(maintenance_bp)
701 from expenses.routes import expenses_bp
703 app.register_blueprint(expenses_bp)
705 from documents.routes import documents_bp
707 app.register_blueprint(documents_bp)
709 from config.routes import config_bp
711 app.register_blueprint(config_bp)
713 from share.routes import share_bp
715 app.register_blueprint(share_bp)
717 from snags.routes import snags_bp
719 app.register_blueprint(snags_bp)
721 from notifications.routes import notifications_bp
723 app.register_blueprint(notifications_bp)
725 from refuels.routes import refuels_bp
727 app.register_blueprint(refuels_bp)
729 from reports.routes import reports_bp
731 app.register_blueprint(reports_bp)
733 from pilots.routes import pilots_bp
735 app.register_blueprint(pilots_bp)
737 from users.routes import users_bp
739 app.register_blueprint(users_bp)
741 from reservations.routes import reservations_bp
743 app.register_blueprint(reservations_bp)
745 from squawk.routes import squawk_bp
747 app.register_blueprint(squawk_bp)
749 from hangar.routes import hangar_bp
751 app.register_blueprint(hangar_bp)
753 from airworthiness.routes import airworthiness_bp
755 app.register_blueprint(airworthiness_bp)
757 from pwa.routes import pwa_bp
759 app.register_blueprint(pwa_bp)
761 from offline.routes import offline_bp
763 app.register_blueprint(offline_bp)
765 if flask_env == "demo":
766 from demo.routes import demo_bp
768 app.register_blueprint(demo_bp)
770 def _current_theme(
771 user_obj: Any, in_request: bool, sess: Any, is_demo: bool
772 ) -> str:
773 if in_request and user_obj and not is_demo and not sess.get("demo_slot_id"):
774 t = getattr(user_obj, "theme", None)
775 if t in ("light", "dark"):
776 return str(t)
777 if in_request:
778 t = sess.get("theme")
779 if t in ("light", "dark", "system"):
780 return str(t)
781 return "system"
783 @app.context_processor
784 def inject_globals() -> dict[str, Any]:
785 from models import DemoSlot, Role, TenantProfile, TenantUser, User
786 from utils import (
787 check_legacy_logbook_data,
788 check_update_available,
789 current_user_role,
790 )
792 is_demo = flask_env == "demo"
793 demo_next_wipe_utc = (
794 os.environ.get("OPENHANGAR_DEMO_NEXT_WIPE_UTC") if is_demo else None
795 )
796 repo_url = os.environ.get(
797 "OPENHANGAR_REPO_URL", "https://github.com/e2jk/OpenHangar"
798 )
799 _in_request = has_request_context()
800 demo_display_id = None
801 if is_demo and _in_request:
802 slot_id = session.get("demo_slot_id")
803 if slot_id:
804 slot = db.session.get(DemoSlot, slot_id)
805 if slot:
806 demo_display_id = slot.display_id
807 role = current_user_role() if _in_request else None
808 # Phase 23: is_pilot/is_maint also enabled by per-user capability flags
809 uid = session.get("user_id") if _in_request else None
810 _user_flags = db.session.get(User, uid) if uid else None
811 _flag_pilot = bool(_user_flags and _user_flags.is_pilot)
812 _flag_maint = bool(_user_flags and _user_flags.is_maintenance)
814 # Phase 26: adaptive UI based on TenantProfile
815 _tenant_profile = None
816 _has_renter_account = False
817 if uid:
818 tu = TenantUser.query.filter_by(user_id=uid).first()
819 if tu:
820 _tenant_profile = TenantProfile.query.filter_by(
821 tenant_id=tu.tenant_id
822 ).first()
823 if _tenant_profile and _tenant_profile.allows_rental:
824 # Phase 37e: nav entry only when there's something to see.
825 from models import BillingAccount, BillingAccountKind, LedgerEntry
827 _renter_account = BillingAccount.query.filter_by(
828 tenant_id=tu.tenant_id,
829 user_id=uid,
830 kind=BillingAccountKind.RENTER,
831 ).first()
832 _has_renter_account = bool(
833 _renter_account
834 and LedgerEntry.query.filter_by(
835 account_id=_renter_account.id
836 ).first()
837 is not None
838 )
839 _pac = (
840 _tenant_profile.planned_aircraft_count
841 if _tenant_profile and _tenant_profile.planned_aircraft_count is not None
842 else None
843 )
844 # logbook_only: planned_aircraft_count == 0 → hide all aircraft UI
845 _logbook_only = _pac == 0
846 # single_aircraft_mode: planned_aircraft_count == 1 → hide fleet-level widgets
847 _single_aircraft_mode = _pac == 1
849 # EE-09: aviation history day banner
850 from datetime import date as _date
852 from flask_babel import gettext as _gt
853 from flask_babel import ngettext as _ngt
855 _today = _date.today()
856 _avi_msgid = _aviation_day_msgid(_today.month, _today.day)
857 _aviation_banner = _gt(_avi_msgid) if _avi_msgid else None
859 # EE-10: personal anniversary banner (first solo / PPL)
860 _pilot_anniversary: dict[str, Any] | None = None
861 _pilot_anniversary_confetti = False
862 if uid:
863 from models import PilotProfile as _PP
865 _pp = _PP.query.filter_by(user_id=uid).first()
866 if _pp:
867 for _ann_date, _ann_type in (
868 (_pp.first_solo_date, "solo"),
869 (_pp.ppl_issue_date, "ppl"),
870 ):
871 if _ann_date and (_ann_date.month, _ann_date.day) == (
872 _today.month,
873 _today.day,
874 ):
875 _years = _today.year - _ann_date.year
876 if _ann_type == "solo":
877 _msg = (
878 _ngt(
879 "🎉 Today marks %(n)s year since your first solo flight!",
880 "🎉 Today marks %(n)s years since your first solo flight!",
881 _years,
882 n=_years,
883 )
884 if _years > 0
885 else _gt(
886 "🎉 Today is the anniversary of your first solo flight!"
887 )
888 )
889 else:
890 _msg = (
891 _ngt(
892 "🎉 Today marks %(n)s year since you earned your PPL!",
893 "🎉 Today marks %(n)s years since you earned your PPL!",
894 _years,
895 n=_years,
896 )
897 if _years > 0
898 else _gt("🎉 Today is the anniversary of your PPL!")
899 )
900 _pilot_anniversary = {
901 "type": _ann_type,
902 "years": _years,
903 "message": _msg,
904 }
905 _sess_key = f"anniversary_confetti_{_today.isoformat()}"
906 if not session.get(_sess_key):
907 session[_sess_key] = True
908 _pilot_anniversary_confetti = True
909 break
911 _is_owner = role in (Role.ADMIN, Role.OWNER)
912 _nav_update_available = (
913 check_update_available()
914 if _is_owner and _in_request and flask_env == "production"
915 else False
916 )
917 _legacy_logbook_data_present = (
918 check_legacy_logbook_data() if _is_owner and _in_request else False
919 )
921 return {
922 "logged_in": bool(uid),
923 "has_users": User.query.count() > 0,
924 "flask_env": flask_env,
925 "is_demo": is_demo,
926 "demo_next_wipe_utc": demo_next_wipe_utc,
927 "demo_display_id": demo_display_id,
928 "repo_url": repo_url,
929 "current_locale": str(_babel_get_locale()),
930 "supported_locales": SUPPORTED_LOCALES,
931 "locale_meta": LOCALE_META,
932 "current_role": role,
933 "is_owner": _is_owner,
934 "is_pilot": role in (Role.ADMIN, Role.OWNER, Role.PILOT, Role.INSTRUCTOR)
935 or _flag_pilot,
936 "is_maint": role
937 in (Role.ADMIN, Role.OWNER, Role.MAINTENANCE, Role.INSTRUCTOR)
938 or _flag_maint,
939 "is_crew": role not in (None, Role.VIEWER),
940 "nav_user_label": (_user_flags.name or _user_flags.email)
941 if _user_flags
942 else None,
943 "tenant_profile": _tenant_profile,
944 "allows_rental": bool(_tenant_profile and _tenant_profile.allows_rental),
945 "has_renter_account": _has_renter_account,
946 "logbook_only": _logbook_only,
947 "single_aircraft_mode": _single_aircraft_mode,
948 "aircraft_count_goal": _pac,
949 "aviation_day_banner": _aviation_banner,
950 "pilot_anniversary": _pilot_anniversary,
951 "pilot_anniversary_confetti": _pilot_anniversary_confetti,
952 "today": _date.today(),
953 "current_theme": _current_theme(_user_flags, _in_request, session, is_demo),
954 "nav_update_available": _nav_update_available,
955 "legacy_logbook_data_present": _legacy_logbook_data_present,
956 "oh_debug": app.debug
957 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower()
958 not in ("1", "true", "yes"),
959 }
961 @app.errorhandler(403)
962 def forbidden(e: Exception) -> ResponseReturnValue:
963 return render_template("errors/403.html"), 403
965 @app.errorhandler(404)
966 def not_found(e: Exception) -> ResponseReturnValue:
967 return render_template("errors/404.html"), 404
969 @app.errorhandler(500)
970 def internal_error(e: Exception) -> ResponseReturnValue:
971 import traceback as _tb
973 show_debug = flask_env in ("development", "test")
974 exc_type = type(e).__name__
975 exc_value = str(e)
976 tb = _tb.format_exc() if show_debug else None
977 return (
978 render_template(
979 "errors/500.html",
980 show_debug=show_debug,
981 env=flask_env,
982 exc_type=exc_type,
983 exc_value=exc_value,
984 tb=tb,
985 ),
986 500,
987 )
989 @app.route("/")
990 def index() -> ResponseReturnValue:
991 from models import TenantUser, User
993 # Demo mode: unauthenticated visitors always see the landing page
994 if flask_env == "demo" and not session.get("user_id"):
995 return render_template("landing.html")
997 if User.query.count() == 0:
998 return render_template("landing.html")
999 if session.get("user_id"):
1000 from datetime import date as _date
1002 from models import (
1003 Aircraft,
1004 AircraftPhoto,
1005 Flight,
1006 MaintenanceTrigger,
1007 Snag,
1008 )
1009 from utils import accessible_aircraft, compute_aircraft_statuses
1011 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
1012 aircraft = accessible_aircraft(tu.tenant_id).all() if tu else []
1013 aircraft_ids = [ac.id for ac in aircraft]
1014 hobbs_by_aircraft = Aircraft.engine_hours_by_id(aircraft_ids)
1015 landings_by_aircraft = Aircraft.landings_by_id(aircraft_ids)
1016 flight_hours_by_aircraft = Aircraft.flight_hours_by_id(aircraft_ids)
1017 cover_photos = (
1018 {
1019 p.aircraft_id: p
1020 for p in AircraftPhoto.query.filter(
1021 AircraftPhoto.aircraft_id.in_(aircraft_ids),
1022 AircraftPhoto.sort_order == 1,
1023 ).all()
1024 }
1025 if aircraft_ids
1026 else {}
1027 )
1029 recent_flights = (
1030 (
1031 Flight.query.filter(Flight.aircraft_id.in_(aircraft_ids))
1032 .order_by(Flight.date.desc(), Flight.id.desc())
1033 .limit(5)
1034 .all()
1035 )
1036 if aircraft_ids
1037 else []
1038 )
1040 today = _date.today()
1041 month_start = today.replace(day=1)
1042 month_flights = (
1043 (
1044 Flight.query.filter(
1045 Flight.aircraft_id.in_(aircraft_ids),
1046 Flight.date >= month_start,
1047 ).all()
1048 )
1049 if aircraft_ids
1050 else []
1051 )
1052 hours_this_month = sum(
1053 float(f.flight_time)
1054 if f.flight_time is not None
1055 else float(f.flight_time_counter_end)
1056 - float(f.flight_time_counter_start)
1057 for f in month_flights
1058 if f.flight_time is not None
1059 or (
1060 f.flight_time_counter_end is not None
1061 and f.flight_time_counter_start is not None
1062 )
1063 )
1064 flights_this_month = len(month_flights)
1066 triggers = (
1067 (
1068 MaintenanceTrigger.query.filter(
1069 MaintenanceTrigger.aircraft_id.in_(aircraft_ids)
1070 ).all()
1071 )
1072 if aircraft_ids
1073 else []
1074 )
1076 aircraft_status = compute_aircraft_statuses(
1077 aircraft,
1078 triggers,
1079 hobbs_by_aircraft,
1080 landings_by_aircraft,
1081 flight_hours_by_aircraft,
1082 )
1084 urgent_maintenance = []
1085 maintenance_alerts = 0
1086 ac_by_id = {ac.id: ac for ac in aircraft}
1087 for t in triggers:
1088 s = t.status(
1089 current_engine_hours=hobbs_by_aircraft.get(t.aircraft_id),
1090 current_landings=landings_by_aircraft.get(t.aircraft_id),
1091 current_flight_hours=flight_hours_by_aircraft.get(t.aircraft_id),
1092 )
1093 if s in ("overdue", "due_soon"):
1094 maintenance_alerts += 1
1095 urgent_maintenance.append((t, s, ac_by_id[t.aircraft_id]))
1096 urgent_maintenance.sort(key=lambda x: 0 if x[1] == "overdue" else 1)
1097 urgent_maintenance = urgent_maintenance[:5]
1099 # Collect grounding snags across the fleet (sorted: grounding first, then by date)
1100 open_grounding = (
1101 (
1102 Snag.query.filter(
1103 Snag.aircraft_id.in_(aircraft_ids),
1104 Snag.is_grounding.is_(True),
1105 Snag.resolved_at.is_(None),
1106 )
1107 .order_by(Snag.reported_at.desc())
1108 .all()
1109 )
1110 if aircraft_ids
1111 else []
1112 )
1113 grounding_snags = [(s, ac_by_id[s.aircraft_id]) for s in open_grounding]
1114 open_other_snags = (
1115 Snag.query.filter(
1116 Snag.aircraft_id.in_(aircraft_ids),
1117 Snag.is_grounding.is_(False),
1118 Snag.resolved_at.is_(None),
1119 ).count()
1120 if aircraft_ids
1121 else 0
1122 )
1124 from models import PilotProfile
1125 from pilots.currency import currency_summary as _currency_summary
1126 from sqlalchemy import (
1127 or_ as _or_dash, # pyright: ignore[reportMissingImports]
1128 )
1130 pilot_profile = PilotProfile.query.filter_by(
1131 user_id=session["user_id"]
1132 ).first()
1133 _mine_dash = _or_dash(
1134 Flight.pic_user_id == session["user_id"],
1135 Flight.second_crew_user_id == session["user_id"],
1136 )
1137 pilot_entries = (
1138 Flight.query.filter(_mine_dash).all() if pilot_profile else []
1139 )
1140 pilot_currency = _currency_summary(pilot_profile, pilot_entries, today)
1142 recent_pilot_entries = (
1143 sorted(pilot_entries, key=lambda e: (e.date, e.id), reverse=True)[:5]
1144 if not aircraft_ids
1145 else []
1146 )
1148 from flask import url_for as _url_for_dash
1150 track_entries = (
1151 Flight.query.filter(_mine_dash)
1152 .filter(Flight.gps_track_id.isnot(None))
1153 .order_by(Flight.date.asc())
1154 .all()
1155 if pilot_profile
1156 else []
1157 )
1158 dash_track_rows = [
1159 {
1160 "date": str(e.date),
1161 "dep": e.departure_icao or "",
1162 "arr": e.arrival_icao or "",
1163 "time_str": f"{e.total_flight_time} h"
1164 if e.total_flight_time is not None
1165 else "",
1166 "view_url": _url_for_dash(
1167 "aircraft.flight_detail",
1168 aircraft_id=e.aircraft_id,
1169 flight_id=e.id,
1170 )
1171 if e.aircraft_id
1172 else _url_for_dash("pilots.view_entry", entry_id=e.id),
1173 "geojson": e.gps_track.geojson if e.gps_track else None,
1174 }
1175 for e in track_entries
1176 ]
1177 from models import AppSetting as _AppSetting
1179 _openaip_s = db.session.get(_AppSetting, "openaip_api_key")
1180 openaip_key = _openaip_s.value if _openaip_s and _openaip_s.value else None
1182 # ── Reservation stat card + pending approval queue ────────────────
1183 import calendar as _cal
1184 from collections import defaultdict
1185 from datetime import datetime as _dt
1186 from datetime import timedelta
1188 from models import Reservation, ReservationStatus, Role
1189 from utils import current_user_role
1191 _role = current_user_role()
1192 today_utc = _dt.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
1193 pending_reservations = (
1194 (
1195 Reservation.query.filter(
1196 Reservation.aircraft_id.in_(aircraft_ids),
1197 Reservation.status == ReservationStatus.PENDING,
1198 Reservation.start_dt >= today_utc,
1199 )
1200 .order_by(Reservation.start_dt)
1201 .all()
1202 )
1203 if aircraft_ids and _role in (Role.ADMIN, Role.OWNER)
1204 else []
1205 )
1206 res_7d = (
1207 Reservation.query.filter(
1208 Reservation.aircraft_id.in_(aircraft_ids),
1209 Reservation.start_dt >= today_utc,
1210 Reservation.start_dt < today_utc + timedelta(days=7),
1211 ).count()
1212 if aircraft_ids
1213 else 0
1214 )
1215 res_30d = (
1216 Reservation.query.filter(
1217 Reservation.aircraft_id.in_(aircraft_ids),
1218 Reservation.start_dt >= today_utc,
1219 Reservation.start_dt < today_utc + timedelta(days=30),
1220 ).count()
1221 if aircraft_ids
1222 else 0
1223 )
1225 # ── Fleet calendar widget ─────────────────────────────────────────
1226 try:
1227 cal_year = int(request.args.get("cal_year", today.year))
1228 cal_month = int(request.args.get("cal_month", today.month))
1229 except ValueError:
1230 cal_year, cal_month = today.year, today.month
1231 if cal_month < 1:
1232 cal_year -= 1
1233 cal_month = 12
1234 if cal_month > 12:
1235 cal_year += 1
1236 cal_month = 1
1238 cal_month_start = _dt(cal_year, cal_month, 1, tzinfo=UTC)
1239 cal_last_day = _cal.monthrange(cal_year, cal_month)[1]
1240 cal_month_end = _dt(
1241 cal_year, cal_month, cal_last_day, 23, 59, 59, tzinfo=UTC
1242 )
1244 cal_reservations = (
1245 Reservation.query.filter(
1246 Reservation.aircraft_id.in_(aircraft_ids),
1247 Reservation.status != ReservationStatus.CANCELLED,
1248 Reservation.start_dt <= cal_month_end,
1249 Reservation.end_dt >= cal_month_start,
1250 )
1251 .order_by(Reservation.start_dt)
1252 .all()
1253 if aircraft_ids
1254 else []
1255 )
1257 cal_flights = (
1258 Flight.query.filter(
1259 Flight.aircraft_id.in_(aircraft_ids),
1260 Flight.date >= cal_month_start.date(),
1261 Flight.date <= cal_month_end.date(),
1262 )
1263 .order_by(Flight.date)
1264 .all()
1265 if aircraft_ids
1266 else []
1267 )
1269 cal_day_events: dict[Any, Any] = defaultdict(
1270 lambda: {"reservations": [], "flights": []}
1271 )
1272 for r in cal_reservations:
1273 cur = r.start_dt.date()
1274 end = r.end_dt.date()
1275 while cur <= end:
1276 if cur.month == cal_month and cur.year == cal_year:
1277 cal_day_events[cur]["reservations"].append(r)
1278 cur += timedelta(days=1)
1279 for f in cal_flights:
1280 cal_day_events[f.date]["flights"].append(f)
1282 cal_weeks = _cal.Calendar(firstweekday=0).monthdatescalendar(
1283 cal_year, cal_month
1284 )
1285 cal_prev_month = cal_month - 1 or 12
1286 cal_prev_year = cal_year - 1 if cal_month == 1 else cal_year
1287 cal_next_month = cal_month % 12 + 1
1288 cal_next_year = cal_year + 1 if cal_month == 12 else cal_year
1289 cal_month_name = _dt(cal_year, cal_month, 1).strftime("%B %Y")
1291 return render_template(
1292 "dashboard.html",
1293 aircraft=aircraft,
1294 cover_photos=cover_photos,
1295 pending_reservations=pending_reservations,
1296 recent_flights=recent_flights,
1297 recent_pilot_entries=recent_pilot_entries,
1298 dash_track_rows=dash_track_rows,
1299 openaip_key=openaip_key,
1300 hours_this_month=hours_this_month,
1301 flights_this_month=flights_this_month,
1302 maintenance_alerts=maintenance_alerts,
1303 urgent_maintenance=urgent_maintenance,
1304 grounding_snags=grounding_snags,
1305 open_other_snags=open_other_snags,
1306 aircraft_status=aircraft_status,
1307 triggers=triggers,
1308 pilot_currency=pilot_currency,
1309 today=today,
1310 res_7d=res_7d,
1311 res_30d=res_30d,
1312 cal_weeks=cal_weeks,
1313 cal_day_events=cal_day_events,
1314 cal_month_name=cal_month_name,
1315 cal_year=cal_year,
1316 cal_month=cal_month,
1317 cal_prev_year=cal_prev_year,
1318 cal_prev_month=cal_prev_month,
1319 cal_next_year=cal_next_year,
1320 cal_next_month=cal_next_month,
1321 ReservationStatus=ReservationStatus,
1322 )
1323 return render_template("welcome.html")
1325 @app.route("/not-yet-implemented")
1326 def not_yet_implemented() -> ResponseReturnValue:
1327 feature = request.args.get("feature", "This feature")
1328 return render_template("not_yet_implemented.html", feature=feature), 501
1330 @app.route("/set-language/<lang>")
1331 def set_language(lang: str) -> ResponseReturnValue:
1332 from flask import abort, redirect
1334 if lang not in SUPPORTED_LOCALES:
1335 abort(400)
1336 if session.get("user_id") and not session.get("demo_slot_id"):
1337 from models import User
1339 user = db.session.get(User, session["user_id"])
1340 if user:
1341 user.language = lang
1342 db.session.commit()
1343 else:
1344 session["language"] = (
1345 lang # stale user_id (e.g. setup wizard) — fall back to session
1346 )
1347 else:
1348 session["language"] = lang
1349 next_url = request.args.get("next", "").strip()
1350 next_url = next_url.replace(
1351 "\\", ""
1352 ) # browsers treat \ as /; strip before parsing
1353 parsed_next = urlparse(next_url)
1354 if (
1355 not next_url
1356 or parsed_next.netloc
1357 or parsed_next.scheme
1358 or not next_url.startswith("/")
1359 or next_url.startswith("//")
1360 ):
1361 next_url = "/"
1362 return redirect(next_url)
1364 @app.route("/set-theme/<theme>")
1365 def set_theme(theme: str) -> ResponseReturnValue:
1366 from flask import abort, redirect
1368 if theme not in ("light", "dark", "system"):
1369 abort(400)
1370 if session.get("user_id") and not session.get("demo_slot_id"):
1371 from models import User
1373 user = db.session.get(User, session["user_id"])
1374 if user:
1375 user.theme = None if theme == "system" else theme
1376 db.session.commit()
1377 else:
1378 session["theme"] = theme
1379 else:
1380 session["theme"] = theme
1381 next_url = request.args.get("next", "").strip()
1382 next_url = next_url.replace("\\", "")
1383 parsed_next = urlparse(next_url)
1384 if (
1385 not next_url
1386 or parsed_next.netloc
1387 or parsed_next.scheme
1388 or not next_url.startswith("/")
1389 or next_url.startswith("//")
1390 ):
1391 next_url = "/"
1392 return redirect(next_url)
1394 @app.route("/robots.txt")
1395 def robots_txt() -> ResponseReturnValue:
1396 return send_from_directory(
1397 app.static_folder or "static", "robots.txt", mimetype="text/plain"
1398 )
1400 @app.route("/favicon.ico")
1401 def favicon() -> ResponseReturnValue:
1402 return send_from_directory(
1403 app.static_folder or "static", "favicon.svg", mimetype="image/svg+xml"
1404 )
1406 @app.route("/health")
1407 def health() -> ResponseReturnValue:
1408 # Liveness probe: proves the worker is up and routing. Deliberately does
1409 # NOT touch the database — a liveness check must not fail (and trigger a
1410 # restart) just because a dependency is down. See /health/ready below.
1411 return {"status": "ok"}, 200
1413 @app.route("/health/ready")
1414 def health_ready() -> ResponseReturnValue:
1415 # Readiness probe: confirms the database is reachable. Reserved for the
1416 # in-container Docker healthcheck (curl localhost:5000); public callers
1417 # arrive via Traefik with a non-loopback remote_addr (ProxyFix x_for=1),
1418 # so they get a 404 and the endpoint stays hidden and unabusable. The
1419 # check itself is a single cheap "SELECT 1".
1420 from flask import abort as _abort
1421 from sqlalchemy import text as _text
1422 from sqlalchemy.exc import SQLAlchemyError
1424 if request.remote_addr not in ("127.0.0.1", "::1"):
1425 _abort(404)
1426 try:
1427 db.session.execute(_text("SELECT 1"))
1428 except SQLAlchemyError:
1429 db.session.rollback()
1430 return {"status": "degraded", "database": "down"}, 503
1431 return {"status": "ready"}, 200
1433 @app.cli.command("check-empty-db")
1434 def check_empty_db_command() -> None:
1435 """Exit 0 if the database has no user data, 1 if it does (restore safety check)."""
1436 import sys
1438 from models import User # pyright: ignore[reportMissingImports]
1439 from sqlalchemy.exc import (
1440 ProgrammingError, # pyright: ignore[reportMissingImports]
1441 )
1443 try:
1444 count = User.query.count()
1445 except ProgrammingError:
1446 # Schema not initialised (table missing) — treat as empty.
1447 print("Database is empty.")
1448 return
1449 if count == 0:
1450 print("Database is empty.")
1451 else:
1452 print(f"Database has {count} user(s) — not empty.", file=sys.stderr)
1453 sys.exit(1)
1455 @app.cli.command("restore-backup")
1456 @click.argument("archive_path")
1457 def restore_backup_command(archive_path: str) -> None:
1458 """Restore a backup archive into the current empty database."""
1459 import io
1460 import sys
1461 import zipfile
1463 from flask import current_app # pyright: ignore[reportMissingImports]
1464 from models import User # pyright: ignore[reportMissingImports]
1465 from services.backup_format import ( # pyright: ignore[reportMissingImports]
1466 BackupArchiveError,
1467 parse_backup_archive,
1468 )
1470 # ── safety: refuse if DB already has data ─────────────────────────────
1471 if User.query.count() > 0:
1472 print(
1473 "ERROR: Database is not empty. Restore refused to prevent data loss.",
1474 file=sys.stderr,
1475 )
1476 sys.exit(1)
1478 # ── decrypt + extract ─────────────────────────────────────────────────
1479 with open(archive_path, "rb") as fh:
1480 payload = fh.read()
1482 # Use only OPENHANGAR_RESTORE_ENCRYPTION_KEY for decryption — never fall
1483 # back to OPENHANGAR_BACKUP_ENCRYPTION_KEY, which may be set to a
1484 # different key (e.g. the dev backup key) and would silently fail with a
1485 # wrong-key decryption error instead of prompting for the correct key.
1486 encryption_key_raw = os.environ.get("OPENHANGAR_RESTORE_ENCRYPTION_KEY", "")
1487 if encryption_key_raw:
1488 from config.routes import (
1489 _derive_key, # pyright: ignore[reportMissingImports]
1490 )
1491 from cryptography.hazmat.primitives.ciphers.aead import (
1492 AESGCM, # pyright: ignore[reportMissingImports]
1493 )
1495 key = _derive_key(encryption_key_raw)
1496 nonce, ct = payload[:12], payload[12:]
1497 try:
1498 zip_bytes = AESGCM(key).decrypt(nonce, ct, None)
1499 except Exception as exc: # noqa: BLE001 -- wrong key surfaces as various crypto/zip errors
1500 print(f"ERROR: Decryption failed — wrong key? ({exc})", file=sys.stderr)
1501 sys.exit(1)
1502 else:
1503 if str(archive_path).endswith(".enc"):
1504 print(
1505 "ERROR: Archive is encrypted (.enc) but no decryption key is available.\n"
1506 " Set OPENHANGAR_RESTORE_ENCRYPTION_KEY (recommended for cross-\n"
1507 " environment restores) or use the restore script's --key-file\n"
1508 " option or interactive prompt.",
1509 file=sys.stderr,
1510 )
1511 sys.exit(1)
1512 zip_bytes = payload
1514 try:
1515 metadata, sql_bytes, upload_entries = parse_backup_archive(zip_bytes)
1516 except BackupArchiveError as exc:
1517 print(f"ERROR: {exc}", file=sys.stderr)
1518 sys.exit(1)
1520 # ── version check ─────────────────────────────────────────────────────
1521 backup_alembic = metadata.get("alembic_head") or "unknown"
1522 backup_version = metadata.get("app_version", "unknown")
1523 current_version = os.environ.get("OPENHANGAR_VERSION", "development")
1524 print(f"Backup: version={backup_version} alembic={backup_alembic}")
1525 print(f"Current: version={current_version}")
1527 if backup_alembic != "unknown":
1528 try:
1529 from alembic.script import (
1530 ScriptDirectory, # pyright: ignore[reportMissingImports]
1531 )
1532 from flask_migrate import (
1533 Migrate as _Migrate, # pyright: ignore[reportMissingImports]
1534 )
1536 _m = _Migrate(current_app, db)
1537 scripts = ScriptDirectory.from_config(_m.get_config())
1538 known = {s.revision for s in scripts.walk_revisions()}
1539 if backup_alembic not in known:
1540 print(
1541 f"ERROR: Backup Alembic revision '{backup_alembic}' is not in "
1542 "this container's migration chain. Restore a container version "
1543 "that knows this migration.",
1544 file=sys.stderr,
1545 )
1546 sys.exit(1)
1547 except Exception as exc: # noqa: BLE001 -- best-effort compatibility check, continue restore either way
1548 print(f"WARNING: Could not verify Alembic compatibility: {exc}")
1550 # ── drop schema + restore SQL dump ────────────────────────────────────
1551 database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
1552 if not database_url.startswith("postgresql"):
1553 print(
1554 f"ERROR: Only PostgreSQL is supported for restore (got: {database_url!r})",
1555 file=sys.stderr,
1556 )
1557 sys.exit(1)
1559 print(
1560 "Dropping existing schema and restoring from backup (this may take a minute)..."
1561 )
1562 try:
1563 _drop_and_restore_schema(database_url, sql_bytes)
1564 except RuntimeError as exc:
1565 print(f"ERROR: psql restore failed:\n{exc}", file=sys.stderr)
1566 sys.exit(1)
1568 # ── restore uploaded files ────────────────────────────────────────────
1569 # The DB has just been replaced, so any files already on disk are now
1570 # orphaned. Before clearing, snapshot them into a dated zip in the
1571 # backup folder so nothing is silently destroyed.
1572 import shutil as _shutil
1574 upload_folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1575 backup_folder = current_app.config.get("BACKUP_FOLDER", "/data/backups")
1576 os.makedirs(upload_folder, exist_ok=True)
1577 os.makedirs(backup_folder, exist_ok=True)
1579 _existing = [
1580 (dp, f) for dp, _dirs, files in os.walk(upload_folder) for f in files
1581 ]
1582 if _existing:
1583 from datetime import datetime
1585 _snap_ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
1586 _enc_key_raw = _env_or_file("BACKUP_ENCRYPTION_KEY")
1587 _snap_ext = ".zip.enc" if _enc_key_raw else ".zip"
1588 _snap_name = f"uploads_pre_restore_{_snap_ts}{_snap_ext}"
1589 _snap_path = os.path.join(backup_folder, _snap_name)
1591 _snap_buf = io.BytesIO()
1592 with zipfile.ZipFile(_snap_buf, "w", zipfile.ZIP_DEFLATED) as _zf:
1593 for _dp, _fn in _existing:
1594 _fp = os.path.join(_dp, _fn)
1595 _rel = os.path.relpath(_fp, upload_folder)
1596 _zf.write(_fp, arcname=_rel)
1597 _snap_bytes = _snap_buf.getvalue()
1599 if _enc_key_raw:
1600 from config.routes import ( # pyright: ignore[reportMissingImports]
1601 _derive_key,
1602 _encrypt_bytes,
1603 )
1605 _snap_bytes = _encrypt_bytes(_snap_bytes, _derive_key(_enc_key_raw))
1607 with open(_snap_path, "wb") as _fh:
1608 _fh.write(_snap_bytes)
1610 print(
1611 f"WARNING: {len(_existing)} pre-existing file(s) found in the upload folder.\n"
1612 f" They have been snapshotted to:\n"
1613 f" {_snap_path}\n"
1614 f" {'(encrypted with OPENHANGAR_BACKUP_ENCRYPTION_KEY) ' if _enc_key_raw else ''}"
1615 f"The upload folder will now be cleared."
1616 )
1618 for _item in os.scandir(upload_folder):
1619 if _item.is_dir():
1620 _shutil.rmtree(_item.path, ignore_errors=True)
1621 else:
1622 os.unlink(_item.path)
1624 if upload_entries:
1625 upload_root = os.path.realpath(upload_folder)
1626 with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
1627 for entry in upload_entries:
1628 # entry is e.g. "uploads/tenant/OO-REG/photos/01-abc.jpg"
1629 rel = entry[len("uploads/") :]
1630 if not rel:
1631 continue # skip the bare "uploads/" directory entry
1632 dest = os.path.join(upload_folder, rel)
1633 dest_real = os.path.realpath(dest)
1634 if dest_real != upload_root and not dest_real.startswith(
1635 upload_root + os.sep
1636 ):
1637 print(
1638 f"ERROR: Archive entry {entry!r} resolves outside the "
1639 "upload folder — refusing to restore (corrupted or "
1640 "tampered archive).",
1641 file=sys.stderr,
1642 )
1643 sys.exit(1)
1644 os.makedirs(os.path.dirname(dest), exist_ok=True)
1645 with open(dest, "wb") as fh:
1646 fh.write(zf.read(entry))
1647 print(f"Restored {len(upload_entries)} uploaded file(s).")
1648 else:
1649 print("Backup contains no uploaded files; upload folder cleared.")
1651 print("Restore complete.")
1653 @app.cli.command("backup-now")
1654 def backup_now_command() -> None:
1655 import sys
1657 from config.routes import run_backup
1659 try:
1660 record = run_backup()
1661 print(
1662 f"Backup OK: {record.filename} ({record.size_bytes} bytes, sha256={record.sha256})"
1663 )
1664 except RuntimeError as exc:
1665 print(f"Backup FAILED: {exc}")
1666 sys.exit(1)
1668 # Flask CLI command used by demo/refresh.sh to drop and recreate the schema.
1669 # Only works in demo mode — production uses Alembic migrations.
1670 @app.cli.command("reset-db")
1671 def reset_db_command() -> None:
1672 if flask_env != "demo":
1673 print("reset-db is only available in demo mode. Aborting.")
1674 return
1675 from models import reset_schema
1677 reset_schema(db)
1678 print("Database schema reset.")
1680 # Flask CLI command used by demo/refresh.sh to wipe and reseed demo slots
1681 @app.cli.command("seed-demo")
1682 def seed_demo_command() -> None:
1683 from demo_seed import seed as demo_seed
1685 demo_seed()
1686 print("Demo slots reseeded.")
1688 # Re-apply env-var settings wiped by reset-db
1689 openaip_key = _env_or_file("OPENAIP_API_KEY").strip()
1690 if openaip_key:
1691 from models import AppSetting
1693 setting = db.session.get(AppSetting, "openaip_api_key")
1694 if setting is None:
1695 db.session.add(AppSetting(key="openaip_api_key", value=openaip_key))
1696 else:
1697 setting.value = openaip_key
1698 db.session.commit()
1699 print("Environment settings applied.")
1701 # Only run against a real PostgreSQL database (sqlite = dev/test), and only
1702 # when called from a long-running server process — not from init scripts such
1703 # as docker-init-db.py that run migrations before the schema exists.
1704 if "sqlite" not in app.config.get(
1705 "SQLALCHEMY_DATABASE_URI", ""
1706 ) and not os.environ.get("OPENHANGAR_SKIP_BACKGROUND_THREADS"):
1707 # Only a production deployment is ever actually upgraded in place, so
1708 # only there does checking for updates mean anything. Demo wipes and
1709 # recreates its schema every few hours (see demo/refresh.sh) --
1710 # checking there just adds a recurring app_settings race against
1711 # reset-db's DROP/CREATE SCHEMA window -- and development/test runs
1712 # are never the thing being "upgraded".
1713 if flask_env == "production":
1714 from services.version_service import (
1715 start_version_check_thread, # pyright: ignore[reportMissingImports]
1716 )
1718 start_version_check_thread(app)
1719 from sync_watcher import (
1720 start_sync_watcher, # pyright: ignore[reportMissingImports]
1721 )
1723 start_sync_watcher(app)
1724 if os.environ.get("OPENHANGAR_ENV", "production") == "production":
1725 _start_easa_sync_scheduler(app)
1726 _start_notification_scheduler(app)
1727 from services.backup_scheduler import (
1728 start_backup_scheduler, # pyright: ignore[reportMissingImports]
1729 )
1731 start_backup_scheduler(app)
1732 import threading
1734 from services.notification_service import (
1735 send_welcome_email_if_needed, # pyright: ignore[reportMissingImports]
1736 )
1738 threading.Thread(
1739 target=send_welcome_email_if_needed,
1740 args=(app,),
1741 daemon=True,
1742 name="welcome-email",
1743 ).start()
1745 if (
1746 os.environ.get("WERKZEUG_RUN_MAIN") == "true"
1747 and os.environ.get("OPENHANGAR_ENV", "production") == "development"
1748 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower() in ("1", "true", "yes")
1749 ):
1750 print("OPENHANGAR_SW_ENABLED: service worker active in debug mode", flush=True)
1752 _validate_config(app)
1753 return app
1756def _validate_config(app: Flask) -> None:
1757 """Collect and report all configuration problems at once rather than one at a time."""
1758 errors: list[str] = []
1760 # OPENHANGAR_SECRET_KEY: minimum length (existence and placeholder already checked above)
1761 secret = app.config.get("SECRET_KEY", "")
1762 if secret and len(secret) < 32:
1763 errors.append(
1764 f"OPENHANGAR_SECRET_KEY is too short ({len(secret)} chars, minimum 32). "
1765 "Generate one with: openssl rand -hex 32"
1766 )
1768 # OPENHANGAR_ENV: must be one of the known values when set
1769 _raw_env = os.environ.get("OPENHANGAR_ENV", "")
1770 if _raw_env and _raw_env not in ("production", "development", "test", "demo"):
1771 errors.append(
1772 f"OPENHANGAR_ENV must be one of: production, development, test, demo "
1773 f"(got {_raw_env!r})"
1774 )
1776 # OPENHANGAR_MAX_UPLOAD_BYTES: must be a plain positive integer when set
1777 _raw_max = os.environ.get("OPENHANGAR_MAX_UPLOAD_BYTES", "")
1778 _validated_max: int | None = None
1779 if _raw_max:
1780 try:
1781 _parsed = int(_raw_max)
1782 if _parsed <= 0:
1783 errors.append("OPENHANGAR_MAX_UPLOAD_BYTES must be a positive integer")
1784 else:
1785 _validated_max = _parsed
1786 except ValueError:
1787 errors.append(
1788 f"OPENHANGAR_MAX_UPLOAD_BYTES must be a plain integer (bytes), got {_raw_max!r}. "
1789 "Example: 52428800 for 50 MB."
1790 )
1792 # OPENHANGAR_SYNC_SCAN_INTERVAL: must be a positive integer when set
1793 _raw_interval = os.environ.get("OPENHANGAR_SYNC_SCAN_INTERVAL", "")
1794 if _raw_interval:
1795 try:
1796 _parsed_interval = int(_raw_interval)
1797 if _parsed_interval <= 0:
1798 errors.append(
1799 "OPENHANGAR_SYNC_SCAN_INTERVAL must be a positive integer (seconds)"
1800 )
1801 except ValueError:
1802 errors.append(
1803 f"OPENHANGAR_SYNC_SCAN_INTERVAL must be a plain integer (seconds), got {_raw_interval!r}. "
1804 "Example: 60"
1805 )
1807 # OPENHANGAR_DATABASE_URL: production deployments must use PostgreSQL
1808 db_url = app.config.get("SQLALCHEMY_DATABASE_URI", "")
1809 flask_env = os.environ.get("OPENHANGAR_ENV", "production")
1810 if (
1811 "sqlite" not in db_url
1812 and flask_env not in ("development", "test")
1813 and not db_url.startswith(("postgresql://", "postgresql+psycopg://"))
1814 ):
1815 scheme = db_url.split("://")[0] if "://" in db_url else db_url[:20]
1816 errors.append(
1817 f"OPENHANGAR_DATABASE_URL scheme {scheme!r} is not supported in production. "
1818 "Use 'postgresql://'."
1819 )
1821 # OPENHANGAR_BACKUP_ENCRYPTION_KEY / OPENHANGAR_RESTORE_ENCRYPTION_KEY:
1822 # whitespace-only values are likely a misconfiguration.
1823 enc_key = _env_or_file("BACKUP_ENCRYPTION_KEY")
1824 if enc_key and not enc_key.strip():
1825 errors.append(
1826 "OPENHANGAR_BACKUP_ENCRYPTION_KEY is set but contains only whitespace. "
1827 "Either provide a real key or leave the variable unset."
1828 )
1829 restore_enc_key = os.environ.get("OPENHANGAR_RESTORE_ENCRYPTION_KEY", "")
1830 if restore_enc_key and not restore_enc_key.strip():
1831 errors.append(
1832 "OPENHANGAR_RESTORE_ENCRYPTION_KEY is set but contains only whitespace. "
1833 "Either provide a real key or leave the variable unset."
1834 )
1836 # OPENHANGAR_SMTP_PORT: must be an integer in valid port range when set
1837 _raw_smtp_port = os.environ.get("OPENHANGAR_SMTP_PORT", "")
1838 if _raw_smtp_port:
1839 try:
1840 _smtp_port_val = int(_raw_smtp_port)
1841 if not (1 <= _smtp_port_val <= 65535):
1842 errors.append(
1843 f"OPENHANGAR_SMTP_PORT must be between 1 and 65535, got {_raw_smtp_port!r}"
1844 )
1845 except ValueError:
1846 errors.append(
1847 f"OPENHANGAR_SMTP_PORT must be an integer, got {_raw_smtp_port!r}"
1848 )
1850 # OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES: must be a positive integer when set
1851 _raw_busy = os.environ.get("OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES", "")
1852 if _raw_busy:
1853 try:
1854 if int(_raw_busy) <= 0:
1855 errors.append(
1856 "OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a positive integer"
1857 )
1858 except ValueError:
1859 errors.append(
1860 f"OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a plain integer (minutes), "
1861 f"got {_raw_busy!r}"
1862 )
1864 # OPENHANGAR_NOTIFICATION_TIME: optional, but must be valid HH:MM when set
1865 _raw_notif_time = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "")
1866 if _raw_notif_time:
1867 try:
1868 _parse_notification_time()
1869 except ValueError as exc:
1870 errors.append(str(exc))
1872 # OPENHANGAR_BACKUP_* scheduling/retention vars: optional, validated when set
1873 from services.backup_scheduler import ( # pyright: ignore[reportMissingImports]
1874 parse_backup_keep,
1875 parse_backup_keep_days,
1876 parse_backup_keep_months,
1877 parse_backup_keep_weeks,
1878 parse_backup_retention,
1879 parse_backup_time,
1880 )
1882 for _backup_parser in (
1883 parse_backup_time,
1884 parse_backup_keep,
1885 parse_backup_retention,
1886 parse_backup_keep_days,
1887 parse_backup_keep_weeks,
1888 parse_backup_keep_months,
1889 ):
1890 try:
1891 _backup_parser()
1892 except ValueError as exc:
1893 errors.append(str(exc))
1895 # OPENHANGAR_ALERT_NTFY_TOPIC_URL: must be an http(s) URL when set
1896 _ntfy_url = os.environ.get("OPENHANGAR_ALERT_NTFY_TOPIC_URL", "").strip()
1897 if _ntfy_url and not _ntfy_url.startswith(("http://", "https://")):
1898 errors.append(
1899 f"OPENHANGAR_ALERT_NTFY_TOPIC_URL must start with http:// or https://, "
1900 f"got {_ntfy_url!r}"
1901 )
1903 # OPENHANGAR_ALERT_EMAIL_TO: must look like an email address when set,
1904 # and SMTP must be configured for delivery to be possible
1905 _alert_email = os.environ.get("OPENHANGAR_ALERT_EMAIL_TO", "").strip()
1906 if _alert_email:
1907 if "@" not in _alert_email:
1908 errors.append(
1909 f"OPENHANGAR_ALERT_EMAIL_TO must be a valid email address, "
1910 f"got {_alert_email!r}"
1911 )
1912 elif not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip():
1913 errors.append(
1914 "OPENHANGAR_ALERT_EMAIL_TO is set but OPENHANGAR_SMTP_HOST is not configured — "
1915 "alert emails cannot be delivered"
1916 )
1918 # OPENHANGAR_ALERT_WEBHOOK_URL: must be an http(s) URL when set
1919 _webhook_url = os.environ.get("OPENHANGAR_ALERT_WEBHOOK_URL", "").strip()
1920 if _webhook_url and not _webhook_url.startswith(("http://", "https://")):
1921 errors.append(
1922 f"OPENHANGAR_ALERT_WEBHOOK_URL must start with http:// or https://, "
1923 f"got {_webhook_url!r}"
1924 )
1926 if errors:
1927 bullet_list = "\n".join(f" • {e}" for e in errors)
1928 raise RuntimeError(
1929 f"Configuration errors — fix before starting:\n{bullet_list}"
1930 )
1932 if _validated_max is not None:
1933 app.config["MAX_CONTENT_LENGTH"] = _validated_max
1936if __name__ == "__main__": # pragma: no cover
1937 _debug = os.environ.get("OPENHANGAR_ENV") == "development"
1938 create_app().run(host="0.0.0.0", port=5000, debug=_debug) # nosec B104