Coverage for app/init.py: 100%
750 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1import os
2import secrets
3import sqlite3
4from datetime import timedelta
6import click # pyright: ignore[reportMissingImports]
7from typing import Any
8from urllib.parse import urlparse
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, get_locale as _babel_get_locale # pyright: ignore[reportMissingImports]
22from flask_migrate import Migrate
23from flask_wtf.csrf import CSRFProtect # pyright: ignore[reportMissingImports]
24from werkzeug.middleware.proxy_fix import ProxyFix # pyright: ignore[reportMissingImports]
25from sqlalchemy import event # pyright: ignore[reportMissingImports]
26from sqlalchemy.engine import Engine # pyright: ignore[reportMissingImports]
28SUPPORTED_LOCALES = ["en", "fr", "nl"]
30LOCALE_META = {
31 "en": {"flag": "🇬🇧", "abbr": "EN", "native": "English", "english": "English"},
32 "fr": {"flag": "🇫🇷", "abbr": "FR", "native": "Français", "english": "French"},
33 "nl": {"flag": "🇳🇱", "abbr": "NL", "native": "Nederlands", "english": "Dutch"},
34}
36# EE-09: aviation history days — (month, day, msgid). Add new entries here.
37_AVIATION_DAYS: list[tuple[int, int, str]] = [
38 (3, 2, "First flight of Concorde — André Turcat at the controls, Toulouse (1969)"),
39 (
40 5,
41 21,
42 "Charles Lindbergh lands at Le Bourget — first solo transatlantic flight (1927)",
43 ),
44 (
45 7,
46 25,
47 "Louis Blériot crosses the English Channel — first crossing by airplane (1909)",
48 ),
49 (
50 11,
51 21,
52 "Pilâtre de Rozier & d'Arlandes — first manned free balloon flight, Paris (1783)",
53 ),
54 (12, 17, "First flight: 17 Dec 1903 — 12 seconds, 37 metres. (Wright Brothers)"),
55]
58def _aviation_day_msgid(month: int, day: int) -> str | None:
59 for m, d, msgid in _AVIATION_DAYS:
60 if m == month and d == day:
61 return msgid
62 return None
65@event.listens_for(Engine, "connect")
66def _set_sqlite_fk_pragma(dbapi_connection: Any, _record: Any) -> None:
67 if isinstance(dbapi_connection, sqlite3.Connection):
68 cur = dbapi_connection.cursor()
69 cur.execute("PRAGMA foreign_keys=ON")
70 cur.close()
73def _drop_and_restore_schema(database_url: str, sql_bytes: bytes) -> None:
74 """Drop the public schema and restore it from a pg_dump byte-string."""
75 import subprocess # noqa: PLC0415 # nosec B404
76 import tempfile
78 from sqlalchemy import text # pyright: ignore[reportMissingImports]
80 from models import db # pyright: ignore[reportMissingImports]
82 # Close the ORM session while its connection is still alive so Flask's
83 # teardown has nothing left to rollback after we terminate other backends.
84 db.session.remove()
86 with db.engine.connect() as conn:
87 # Terminate all other connections so DROP SCHEMA can acquire its
88 # ACCESS EXCLUSIVE lock even if the web server left a connection
89 # idle-in-transaction (which would block indefinitely otherwise).
90 conn.execute(
91 text(
92 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity"
93 " WHERE datname = current_database() AND pid != pg_backend_pid()"
94 )
95 )
96 conn.execute(text("DROP SCHEMA public CASCADE"))
97 conn.execute(text("CREATE SCHEMA public"))
98 conn.commit()
100 # Dispose the connection pool so no SQLAlchemy connections linger and
101 # block psql's DDL statements with AccessShareLock.
102 db.engine.dispose()
104 # Write the dump to a temp file so psql can read it directly rather than
105 # via stdin — avoids pipe-buffering hangs on large dumps and lets psql
106 # print progress to the terminal in real time.
107 with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp:
108 tmp.write(sql_bytes)
109 tmp_path = tmp.name
111 try:
112 result = subprocess.run( # nosec B603
113 ["psql", "--no-password", "-f", tmp_path, database_url],
114 timeout=600,
115 )
116 except subprocess.TimeoutExpired:
117 raise RuntimeError("psql restore timed out after 10 minutes.")
118 finally:
119 os.unlink(tmp_path)
121 if result.returncode != 0:
122 raise RuntimeError(f"psql exited with code {result.returncode}")
125def _easa_sync_loop(app: Flask) -> None:
126 import logging
127 import os
128 import random
129 import time
130 from datetime import datetime, timedelta, timezone
132 from airworthiness_sync import sync_all_nodes # pyright: ignore[reportMissingImports]
134 _log = logging.getLogger(__name__)
136 # Determine the daily sync time (UTC). Admin can pin a specific hour via
137 # OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR (0-23). Default: random hour
138 # 01-05 UTC so that different instances do not all hit EASA simultaneously.
139 env_hour = os.environ.get("OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR")
140 if env_hour is not None:
141 try:
142 sync_hour = int(env_hour) % 24
143 except ValueError:
144 sync_hour = random.randint(1, 5)
145 else:
146 sync_hour = random.randint(1, 5)
147 sync_minute = random.randint(0, 59)
148 _log.info("EASA sync scheduled daily at %02d:%02d UTC", sync_hour, sync_minute)
150 while True:
151 now = datetime.now(timezone.utc)
152 next_run = now.replace(
153 hour=sync_hour, minute=sync_minute, second=0, microsecond=0
154 )
155 if next_run <= now:
156 next_run += timedelta(days=1)
157 time.sleep((next_run - datetime.now(timezone.utc)).total_seconds())
158 sync_all_nodes(app)
161def _start_easa_sync_scheduler(app: Flask) -> None:
162 import threading
164 t = threading.Thread(
165 target=_easa_sync_loop,
166 args=(app,),
167 daemon=True,
168 name="easa-sync",
169 )
170 t.start()
173def _parse_notification_time() -> tuple[int, int]:
174 """Return (hour, minute) from OPENHANGAR_NOTIFICATION_TIME (HH:MM, default 07:00).
176 Raises ValueError with a human-readable message if the value is set but invalid.
177 """
178 raw = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "07:00")
179 err = f"OPENHANGAR_NOTIFICATION_TIME={raw!r} is invalid — expected HH:MM (e.g. '07:00')"
180 parts = raw.split(":")
181 if len(parts) != 2:
182 raise ValueError(err)
183 try:
184 hour, minute = int(parts[0]), int(parts[1])
185 except ValueError:
186 raise ValueError(err)
187 if not (0 <= hour <= 23 and 0 <= minute <= 59):
188 raise ValueError(err)
189 return hour, minute
192def _notification_daily_loop(app: Flask, run_hour: int, run_minute: int) -> None:
193 import logging
194 import time
195 from datetime import datetime, timedelta, timezone
197 _log = logging.getLogger(__name__)
198 _log.info(
199 "Notification daily check scheduled at %02d:%02d UTC", run_hour, run_minute
200 )
202 while True:
203 now = datetime.now(timezone.utc)
204 next_run = now.replace(
205 hour=run_hour, minute=run_minute, second=0, microsecond=0
206 )
207 if next_run <= now:
208 next_run += timedelta(days=1)
209 time.sleep((next_run - datetime.now(timezone.utc)).total_seconds())
210 try:
211 from services.notification_service import run_daily_checks # pyright: ignore[reportMissingImports]
213 run_daily_checks(app)
214 except Exception:
215 _log.exception("Notification daily check failed; will retry tomorrow")
218def _start_notification_scheduler(app: Flask) -> None:
219 import threading
221 run_hour, run_minute = _parse_notification_time()
222 t = threading.Thread(
223 target=_notification_daily_loop,
224 args=(app, run_hour, run_minute),
225 daemon=True,
226 name="notification-daily",
227 )
228 t.start()
231def create_app() -> Flask:
232 from security_alerts import attach_to_logger # pyright: ignore[reportMissingImports]
234 attach_to_logger()
236 app = Flask(__name__)
237 app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign]
239 # Propagate OPENHANGAR_ENV → FLASK_ENV so Flask's own internals keep working.
240 os.environ["FLASK_ENV"] = os.environ.get("OPENHANGAR_ENV", "production")
242 app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get(
243 "OPENHANGAR_DATABASE_URL", "sqlite:///:memory:"
244 )
245 secret_key = os.environ.get("OPENHANGAR_SECRET_KEY")
246 if not secret_key:
247 raise RuntimeError("OPENHANGAR_SECRET_KEY environment variable must be set")
248 if "change" in secret_key.lower():
249 raise RuntimeError(
250 "OPENHANGAR_SECRET_KEY appears to be a placeholder value. "
251 "Generate a real key with: openssl rand -hex 32"
252 )
253 app.config["SECRET_KEY"] = secret_key
254 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
255 app.config["UPLOAD_FOLDER"] = os.environ.get(
256 "OPENHANGAR_UPLOAD_FOLDER", "/data/uploads"
257 )
258 app.config["BACKUP_FOLDER"] = os.environ.get(
259 "OPENHANGAR_BACKUP_FOLDER", "/data/backups"
260 )
261 app.config["MAX_CONTENT_LENGTH"] = (
262 50 * 1024 * 1024
263 ) # overridden by _validate_config
264 app.config["SESSION_COOKIE_SECURE"] = True
265 app.config["SESSION_COOKIE_HTTPONLY"] = True
266 app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
267 _session_days = int(os.environ.get("OPENHANGAR_SESSION_LIFETIME_DAYS", "30"))
268 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=_session_days)
270 flask_env = os.environ.get("OPENHANGAR_ENV", "production")
272 if flask_env in ("development", "test"):
273 app.config["TEMPLATES_AUTO_RELOAD"] = True
275 from models import db
277 db.init_app(app)
278 Migrate(app, db)
280 def _get_locale() -> str | None:
281 if not has_request_context():
282 return "en"
283 if session.get("user_id"):
284 # Demo sessions: visitor's session language takes precedence over the
285 # demo user's stored default so Accept-Language / manual switcher work.
286 if (
287 session.get("demo_slot_id")
288 and session.get("language") in SUPPORTED_LOCALES
289 ):
290 return str(session["language"])
291 from models import User
293 user = db.session.get(User, session["user_id"])
294 if user and user.language in SUPPORTED_LOCALES:
295 return str(user.language)
296 if session.get("language") in SUPPORTED_LOCALES:
297 return str(session["language"])
298 return str(request.accept_languages.best_match(SUPPORTED_LOCALES, default="en"))
300 Babel(app, locale_selector=_get_locale)
301 CSRFProtect(app)
303 from extensions import cache as _cache # pyright: ignore[reportMissingImports]
304 from extensions import limiter as _limiter # pyright: ignore[reportMissingImports]
306 app.config["CACHE_TYPE"] = "SimpleCache"
307 app.config["CACHE_DEFAULT_TIMEOUT"] = 300
308 _cache.init_app(app)
309 _limiter.init_app(app)
311 @app.before_request
312 def _generate_csp_nonce() -> None:
313 g.csp_nonce = secrets.token_urlsafe(16)
315 def _csp_nonce() -> str:
316 return getattr(g, "csp_nonce", "")
318 app.jinja_env.globals["csp_nonce"] = _csp_nonce
320 @app.after_request
321 def _security_headers(response: Any) -> Any:
322 nonce = getattr(g, "csp_nonce", "")
323 response.headers["Content-Security-Policy"] = (
324 f"default-src 'self'; "
325 f"script-src 'nonce-{nonce}'; "
326 f"worker-src 'self' blob:; "
327 f"style-src-elem 'self'; "
328 f"style-src-attr 'none'; "
329 f"font-src 'self'; "
330 f"img-src 'self' data: blob: tile.openstreetmap.org *.basemaps.cartocdn.com api.tiles.openaip.net; "
331 f"connect-src 'self'; "
332 f"object-src 'none'; "
333 f"base-uri 'self'; "
334 f"form-action 'self'; "
335 f"frame-ancestors 'none';"
336 )
337 response.headers["X-Frame-Options"] = "DENY"
338 response.headers["X-Content-Type-Options"] = "nosniff"
339 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
340 response.headers["Permissions-Policy"] = (
341 "camera=(), microphone=(), geolocation=(), payment=()"
342 )
343 response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
344 response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
345 if session.get("user_id"):
346 existing_cc = response.headers.get("Cache-Control", "")
347 if "public" not in existing_cc and "immutable" not in existing_cc:
348 response.headers["Cache-Control"] = "no-store, private"
349 return response
351 from flask_babel import format_date, format_datetime, format_decimal
353 app.jinja_env.globals.update(
354 format_date=format_date,
355 format_datetime=format_datetime,
356 format_decimal=format_decimal,
357 )
359 if app.config.get("TESTING") or os.environ.get("OPENHANGAR_ENV") == "development":
360 from jinja2 import StrictUndefined
362 app.jinja_env.undefined = StrictUndefined
364 from utils import (
365 _load_aircraft_type_variants,
366 _load_airport_names,
367 )
369 @app.template_filter("airport_name")
370 def _airport_name_filter(code: str | None) -> str:
371 if not code:
372 return ""
373 return _load_airport_names().get(code.upper(), "")
375 @app.route("/manifest.json")
376 def pwa_manifest() -> ResponseReturnValue:
377 from flask import jsonify as _jsonify
379 return _jsonify(
380 {
381 "name": "OpenHangar",
382 "short_name": "OpenHangar",
383 "description": "Open-source aircraft operations and pilot logbook",
384 "start_url": "/",
385 "display": "standalone",
386 "theme_color": "#1a3a5c",
387 "background_color": "#1a3a5c",
388 "icons": [
389 {
390 "src": "/static/icons/icon.svg",
391 "sizes": "any",
392 "type": "image/svg+xml",
393 },
394 {
395 "src": "/static/icons/icon-maskable.svg",
396 "sizes": "any",
397 "type": "image/svg+xml",
398 "purpose": "maskable",
399 },
400 ],
401 "share_target": {
402 "action": "/pwa/shared",
403 "method": "POST",
404 "enctype": "multipart/form-data",
405 "params": {
406 "title": "title",
407 "text": "text",
408 "url": "url",
409 "files": [
410 {
411 "name": "files",
412 "accept": ["application/pdf", "image/*"],
413 }
414 ],
415 },
416 },
417 "shortcuts": [
418 {
419 "name": "Log a Flight",
420 "short_name": "Log Flight",
421 "url": "/flights/new",
422 "icons": [
423 {
424 "src": "/static/icons/shortcut-log-flight.svg",
425 "sizes": "any",
426 "type": "image/svg+xml",
427 }
428 ],
429 },
430 {
431 "name": "My Aircraft",
432 "short_name": "Aircraft",
433 "url": "/aircraft",
434 "icons": [
435 {
436 "src": "/static/icons/shortcut-aircraft.svg",
437 "sizes": "any",
438 "type": "image/svg+xml",
439 }
440 ],
441 },
442 {
443 "name": "Documents",
444 "short_name": "Documents",
445 "url": "/documents",
446 "icons": [
447 {
448 "src": "/static/icons/shortcut-documents.svg",
449 "sizes": "any",
450 "type": "image/svg+xml",
451 }
452 ],
453 },
454 ],
455 }
456 )
458 @app.route("/sw.js")
459 def service_worker() -> ResponseReturnValue:
460 sw_path = os.path.join(app.static_folder or "static", "js", "sw.js")
461 with open(sw_path, encoding="utf-8") as fh:
462 content = fh.read()
463 version = os.environ.get("OPENHANGAR_VERSION", "")
464 cache_name = (
465 f"openhangar-{version}"
466 if version and version != "development"
467 else f"openhangar-{secrets.token_hex(8)}"
468 )
469 content = content.replace("__SW_CACHE_VERSION__", cache_name)
470 response = Response(content, mimetype="application/javascript")
471 response.headers["Service-Worker-Allowed"] = "/"
472 return response
474 @app.route("/api/check-flight-duplicate")
475 def api_check_flight_duplicate() -> ResponseReturnValue:
476 from flask import jsonify as _jsonify
478 if not session.get("user_id"):
479 return _jsonify({"error": "unauthorized"}), 401
480 date_str = request.args.get("date", "")
481 aircraft_id_str = request.args.get("aircraft_id", "")
482 dep = request.args.get("departure_icao", "")
483 arr = request.args.get("arrival_icao", "")
484 if not (date_str and dep and arr):
485 return _jsonify({"duplicate": False})
486 from models import Aircraft, FlightEntry, PilotLogbookEntry, TenantUser
488 uid = int(session["user_id"])
489 try:
490 from datetime import date as _date
492 flight_date = _date.fromisoformat(date_str)
493 except ValueError:
494 return _jsonify({"duplicate": False})
496 tu = TenantUser.query.filter_by(user_id=uid).first()
498 if tu and aircraft_id_str and aircraft_id_str.isdigit():
499 ac_id = int(aircraft_id_str)
500 # Scope by tenant: only match flights on an aircraft the caller's
501 # tenant owns, otherwise this leaks a cross-tenant existence oracle.
502 owned = Aircraft.query.filter_by(id=ac_id, tenant_id=tu.tenant_id).first()
503 if owned:
504 dup = FlightEntry.query.filter_by(
505 aircraft_id=ac_id,
506 date=flight_date,
507 departure_icao=dep,
508 arrival_icao=arr,
509 ).first()
510 if dup:
511 return _jsonify({"duplicate": True})
513 if tu:
514 dup_pilot = PilotLogbookEntry.query.filter_by(
515 pilot_user_id=uid,
516 date=flight_date,
517 departure_place=dep,
518 arrival_place=arr,
519 ).first()
520 if dup_pilot:
521 return _jsonify({"duplicate": True})
523 return _jsonify({"duplicate": False})
525 @app.route("/airport-search")
526 def airport_search() -> ResponseReturnValue:
527 if not session.get("user_id"):
528 return {"results": []}
529 q = request.args.get("q", "").strip()
530 if len(q) < 2:
531 return {"results": []}
532 q_code = q.upper()
533 q_low = q.lower()
534 names = _load_airport_names()
535 code_hits: list[dict[str, str]] = []
536 name_hits: list[dict[str, str]] = []
537 for code, name in names.items():
538 if code.startswith(q_code):
539 code_hits.append({"code": code, "name": name})
540 elif q_low in name.lower():
541 name_hits.append({"code": code, "name": name})
542 return {"results": (code_hits + name_hits)[:10]}
544 @app.route("/aircraft-type-search")
545 def aircraft_type_search() -> ResponseReturnValue:
546 if not session.get("user_id"):
547 return {"results": []}
548 q = request.args.get("q", "").strip()
549 if len(q) < 2:
550 return {"results": []}
551 q_up = q.upper()
552 words = q.lower().split()
553 variants = _load_aircraft_type_variants()
554 code_hits: list[dict[str, str]] = []
555 name_hits: list[dict[str, str]] = []
556 for des, full_name, mfr, mdl in variants:
557 name_low = full_name.lower()
558 entry = {"code": des, "name": full_name, "manufacturer": mfr, "model": mdl}
559 if des.startswith(q_up):
560 code_hits.append(entry)
561 elif all(w in name_low for w in words):
562 name_hits.append(entry)
563 return {"results": code_hits + name_hits}
565 from auth.routes import auth_bp
567 app.register_blueprint(auth_bp)
569 from aircraft.routes import aircraft_bp
571 app.register_blueprint(aircraft_bp)
573 from flights.routes import flights_bp
575 app.register_blueprint(flights_bp)
577 from maintenance.routes import maintenance_bp
579 app.register_blueprint(maintenance_bp)
581 from expenses.routes import expenses_bp
583 app.register_blueprint(expenses_bp)
585 from documents.routes import documents_bp
587 app.register_blueprint(documents_bp)
589 from config.routes import config_bp
591 app.register_blueprint(config_bp)
593 from share.routes import share_bp
595 app.register_blueprint(share_bp)
597 from snags.routes import snags_bp
599 app.register_blueprint(snags_bp)
601 from pilots.routes import pilots_bp
603 app.register_blueprint(pilots_bp)
605 from users.routes import users_bp
607 app.register_blueprint(users_bp)
609 from reservations.routes import reservations_bp
611 app.register_blueprint(reservations_bp)
613 from squawk.routes import squawk_bp
615 app.register_blueprint(squawk_bp)
617 from hangar.routes import hangar_bp
619 app.register_blueprint(hangar_bp)
621 from airworthiness.routes import airworthiness_bp
623 app.register_blueprint(airworthiness_bp)
625 from pwa.routes import pwa_bp
627 app.register_blueprint(pwa_bp)
629 if flask_env == "demo":
630 from demo.routes import demo_bp
632 app.register_blueprint(demo_bp)
634 def _current_theme(
635 user_obj: Any, in_request: bool, sess: Any, is_demo: bool
636 ) -> str:
637 if in_request and user_obj and not is_demo and not sess.get("demo_slot_id"):
638 t = getattr(user_obj, "theme", None)
639 if t in ("light", "dark"):
640 return str(t)
641 if in_request:
642 t = sess.get("theme")
643 if t in ("light", "dark", "system"):
644 return str(t)
645 return "system"
647 @app.context_processor
648 def inject_globals() -> dict[str, Any]:
649 from models import DemoSlot, Role, TenantProfile, TenantUser, User
650 from utils import current_user_role
652 is_demo = flask_env == "demo"
653 demo_next_wipe_utc = (
654 os.environ.get("OPENHANGAR_DEMO_NEXT_WIPE_UTC") if is_demo else None
655 )
656 demo_site_url = os.environ.get("OPENHANGAR_DEMO_SITE_URL")
657 repo_url = os.environ.get(
658 "OPENHANGAR_REPO_URL", "https://github.com/e2jk/OpenHangar"
659 )
660 _in_request = has_request_context()
661 demo_display_id = None
662 if is_demo and _in_request:
663 slot_id = session.get("demo_slot_id")
664 if slot_id:
665 slot = db.session.get(DemoSlot, slot_id)
666 if slot:
667 demo_display_id = slot.display_id
668 role = current_user_role() if _in_request else None
669 # Phase 23: is_pilot/is_maint also enabled by per-user capability flags
670 uid = session.get("user_id") if _in_request else None
671 _user_flags = db.session.get(User, uid) if uid else None
672 _flag_pilot = bool(_user_flags and _user_flags.is_pilot)
673 _flag_maint = bool(_user_flags and _user_flags.is_maintenance)
675 # Phase 26: adaptive UI based on TenantProfile
676 _tenant_profile = None
677 if uid:
678 tu = TenantUser.query.filter_by(user_id=uid).first()
679 if tu:
680 _tenant_profile = TenantProfile.query.filter_by(
681 tenant_id=tu.tenant_id
682 ).first()
683 _pac = (
684 _tenant_profile.planned_aircraft_count
685 if _tenant_profile and _tenant_profile.planned_aircraft_count is not None
686 else None
687 )
688 # logbook_only: planned_aircraft_count == 0 → hide all aircraft UI
689 _logbook_only = _pac == 0
690 # single_aircraft_mode: planned_aircraft_count == 1 → hide fleet-level widgets
691 _single_aircraft_mode = _pac == 1
693 # EE-09: aviation history day banner
694 from datetime import date as _date # noqa: PLC0415
695 from flask_babel import gettext as _gt, ngettext as _ngt # noqa: PLC0415
697 _today = _date.today()
698 _avi_msgid = _aviation_day_msgid(_today.month, _today.day)
699 _aviation_banner = _gt(_avi_msgid) if _avi_msgid else None
701 # EE-10: personal anniversary banner (first solo / PPL)
702 _pilot_anniversary: dict[str, Any] | None = None
703 _pilot_anniversary_confetti = False
704 if uid:
705 from models import PilotProfile as _PP # noqa: PLC0415
707 _pp = _PP.query.filter_by(user_id=uid).first()
708 if _pp:
709 for _ann_date, _ann_type in (
710 (_pp.first_solo_date, "solo"),
711 (_pp.ppl_issue_date, "ppl"),
712 ):
713 if _ann_date and (_ann_date.month, _ann_date.day) == (
714 _today.month,
715 _today.day,
716 ):
717 _years = _today.year - _ann_date.year
718 if _ann_type == "solo":
719 _msg = (
720 _ngt(
721 "🎉 Today marks %(n)s year since your first solo flight!",
722 "🎉 Today marks %(n)s years since your first solo flight!",
723 _years,
724 n=_years,
725 )
726 if _years > 0
727 else _gt(
728 "🎉 Today is the anniversary of your first solo flight!"
729 )
730 )
731 else:
732 _msg = (
733 _ngt(
734 "🎉 Today marks %(n)s year since you earned your PPL!",
735 "🎉 Today marks %(n)s years since you earned your PPL!",
736 _years,
737 n=_years,
738 )
739 if _years > 0
740 else _gt("🎉 Today is the anniversary of your PPL!")
741 )
742 _pilot_anniversary = {
743 "type": _ann_type,
744 "years": _years,
745 "message": _msg,
746 }
747 _sess_key = f"anniversary_confetti_{_today.isoformat()}"
748 if not session.get(_sess_key):
749 session[_sess_key] = True
750 _pilot_anniversary_confetti = True
751 break
753 return {
754 "logged_in": bool(uid),
755 "has_users": User.query.count() > 0,
756 "flask_env": flask_env,
757 "is_demo": is_demo,
758 "demo_next_wipe_utc": demo_next_wipe_utc,
759 "demo_display_id": demo_display_id,
760 "demo_site_url": demo_site_url,
761 "repo_url": repo_url,
762 "current_locale": str(_babel_get_locale()),
763 "supported_locales": SUPPORTED_LOCALES,
764 "locale_meta": LOCALE_META,
765 "current_role": role,
766 "is_owner": role in (Role.ADMIN, Role.OWNER),
767 "is_pilot": role in (Role.ADMIN, Role.OWNER, Role.PILOT, Role.INSTRUCTOR)
768 or _flag_pilot,
769 "is_maint": role
770 in (Role.ADMIN, Role.OWNER, Role.MAINTENANCE, Role.INSTRUCTOR)
771 or _flag_maint,
772 "is_crew": role not in (None, Role.VIEWER),
773 "nav_user_label": (_user_flags.name or _user_flags.email)
774 if _user_flags
775 else None,
776 "tenant_profile": _tenant_profile,
777 "allows_rental": bool(_tenant_profile and _tenant_profile.allows_rental),
778 "logbook_only": _logbook_only,
779 "single_aircraft_mode": _single_aircraft_mode,
780 "aircraft_count_goal": _pac,
781 "aviation_day_banner": _aviation_banner,
782 "pilot_anniversary": _pilot_anniversary,
783 "pilot_anniversary_confetti": _pilot_anniversary_confetti,
784 "today": _date.today(),
785 "current_theme": _current_theme(_user_flags, _in_request, session, is_demo),
786 "oh_debug": app.debug
787 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower()
788 not in ("1", "true", "yes"),
789 }
791 @app.errorhandler(403)
792 def forbidden(e: Exception) -> ResponseReturnValue:
793 return render_template("errors/403.html"), 403
795 @app.errorhandler(404)
796 def not_found(e: Exception) -> ResponseReturnValue:
797 return render_template("errors/404.html"), 404
799 @app.errorhandler(500)
800 def internal_error(e: Exception) -> ResponseReturnValue:
801 import traceback as _tb
803 show_debug = flask_env in ("development", "test")
804 exc_type = type(e).__name__
805 exc_value = str(e)
806 tb = _tb.format_exc() if show_debug else None
807 return (
808 render_template(
809 "errors/500.html",
810 show_debug=show_debug,
811 env=flask_env,
812 exc_type=exc_type,
813 exc_value=exc_value,
814 tb=tb,
815 ),
816 500,
817 )
819 @app.route("/")
820 def index() -> ResponseReturnValue:
821 from models import TenantUser, User
823 # Demo mode: unauthenticated visitors always see the landing page
824 if flask_env == "demo" and not session.get("user_id"):
825 return render_template("landing.html")
827 if User.query.count() == 0:
828 return render_template("landing.html")
829 if session.get("user_id"):
830 from datetime import date as _date
831 from models import FlightEntry, MaintenanceTrigger, Snag
832 from utils import accessible_aircraft, compute_aircraft_statuses
834 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
835 aircraft = accessible_aircraft(tu.tenant_id).all() if tu else []
836 aircraft_ids = [ac.id for ac in aircraft]
837 hobbs_by_aircraft = {ac.id: ac.total_engine_hours for ac in aircraft}
839 recent_flights = (
840 (
841 FlightEntry.query.filter(FlightEntry.aircraft_id.in_(aircraft_ids))
842 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc())
843 .limit(5)
844 .all()
845 )
846 if aircraft_ids
847 else []
848 )
850 today = _date.today()
851 month_start = today.replace(day=1)
852 month_flights = (
853 (
854 FlightEntry.query.filter(
855 FlightEntry.aircraft_id.in_(aircraft_ids),
856 FlightEntry.date >= month_start,
857 ).all()
858 )
859 if aircraft_ids
860 else []
861 )
862 hours_this_month = sum(
863 float(f.flight_time)
864 if f.flight_time is not None
865 else float(f.flight_time_counter_end)
866 - float(f.flight_time_counter_start)
867 for f in month_flights
868 if f.flight_time is not None
869 or (
870 f.flight_time_counter_end is not None
871 and f.flight_time_counter_start is not None
872 )
873 )
874 flights_this_month = len(month_flights)
876 triggers = (
877 (
878 MaintenanceTrigger.query.filter(
879 MaintenanceTrigger.aircraft_id.in_(aircraft_ids)
880 ).all()
881 )
882 if aircraft_ids
883 else []
884 )
886 aircraft_status = compute_aircraft_statuses(
887 aircraft, triggers, hobbs_by_aircraft
888 )
890 urgent_maintenance = []
891 maintenance_alerts = 0
892 ac_by_id = {ac.id: ac for ac in aircraft}
893 for t in triggers:
894 s = t.status(hobbs_by_aircraft.get(t.aircraft_id))
895 if s in ("overdue", "due_soon"):
896 maintenance_alerts += 1
897 urgent_maintenance.append((t, s, ac_by_id[t.aircraft_id]))
898 urgent_maintenance.sort(key=lambda x: 0 if x[1] == "overdue" else 1)
899 urgent_maintenance = urgent_maintenance[:5]
901 # Collect grounding snags across the fleet (sorted: grounding first, then by date)
902 open_grounding = (
903 (
904 Snag.query.filter(
905 Snag.aircraft_id.in_(aircraft_ids),
906 Snag.is_grounding.is_(True),
907 Snag.resolved_at.is_(None),
908 )
909 .order_by(Snag.reported_at.desc())
910 .all()
911 )
912 if aircraft_ids
913 else []
914 )
915 grounding_snags = [(s, ac_by_id[s.aircraft_id]) for s in open_grounding]
917 from models import PilotLogbookEntry, PilotProfile
918 from pilots.currency import currency_summary as _currency_summary
920 pilot_profile = PilotProfile.query.filter_by(
921 user_id=session["user_id"]
922 ).first()
923 pilot_entries = (
924 PilotLogbookEntry.query.filter_by(
925 pilot_user_id=session["user_id"]
926 ).all()
927 if pilot_profile
928 else []
929 )
930 pilot_currency = _currency_summary(pilot_profile, pilot_entries, today)
932 recent_pilot_entries = (
933 sorted(pilot_entries, key=lambda e: (e.date, e.id), reverse=True)[:5]
934 if not aircraft_ids
935 else []
936 )
938 from flask import url_for as _url_for_dash
940 track_entries = (
941 PilotLogbookEntry.query.filter_by(pilot_user_id=session["user_id"])
942 .filter(PilotLogbookEntry.gps_track_id.isnot(None))
943 .order_by(PilotLogbookEntry.date.asc())
944 .all()
945 if pilot_profile
946 else []
947 )
948 dash_track_rows = [
949 {
950 "date": str(e.date),
951 "dep": e.departure_place or "",
952 "arr": e.arrival_place or "",
953 "time_str": f"{e.total_flight_time} h"
954 if e.total_flight_time is not None
955 else "",
956 "view_url": _url_for_dash(
957 "aircraft.flight_detail",
958 aircraft_id=e.flight.aircraft_id,
959 flight_id=e.flight_id,
960 )
961 if e.flight_id and e.flight
962 else _url_for_dash("pilots.view_entry", entry_id=e.id),
963 "geojson": e.gps_track.geojson if e.gps_track else None,
964 }
965 for e in track_entries
966 ]
967 from models import AppSetting as _AppSetting
969 _openaip_s = db.session.get(_AppSetting, "openaip_api_key")
970 openaip_key = _openaip_s.value if _openaip_s and _openaip_s.value else None
972 # ── Reservation stat card + pending approval queue ────────────────
973 import calendar as _cal
974 from collections import defaultdict
975 from datetime import datetime as _dt, timedelta, timezone as _tz
976 from models import Reservation, ReservationStatus
978 from models import Role
979 from utils import current_user_role
981 _role = current_user_role()
982 today_utc = _dt.now(_tz.utc).replace(
983 hour=0, minute=0, second=0, microsecond=0
984 )
985 pending_reservations = (
986 (
987 Reservation.query.filter(
988 Reservation.aircraft_id.in_(aircraft_ids),
989 Reservation.status == ReservationStatus.PENDING,
990 Reservation.start_dt >= today_utc,
991 )
992 .order_by(Reservation.start_dt)
993 .all()
994 )
995 if aircraft_ids and _role in (Role.ADMIN, Role.OWNER)
996 else []
997 )
998 res_7d = (
999 Reservation.query.filter(
1000 Reservation.aircraft_id.in_(aircraft_ids),
1001 Reservation.start_dt >= today_utc,
1002 Reservation.start_dt < today_utc + timedelta(days=7),
1003 ).count()
1004 if aircraft_ids
1005 else 0
1006 )
1007 res_30d = (
1008 Reservation.query.filter(
1009 Reservation.aircraft_id.in_(aircraft_ids),
1010 Reservation.start_dt >= today_utc,
1011 Reservation.start_dt < today_utc + timedelta(days=30),
1012 ).count()
1013 if aircraft_ids
1014 else 0
1015 )
1017 # ── Fleet calendar widget ─────────────────────────────────────────
1018 try:
1019 cal_year = int(request.args.get("cal_year", today.year))
1020 cal_month = int(request.args.get("cal_month", today.month))
1021 except ValueError:
1022 cal_year, cal_month = today.year, today.month
1023 if cal_month < 1:
1024 cal_year -= 1
1025 cal_month = 12
1026 if cal_month > 12:
1027 cal_year += 1
1028 cal_month = 1
1030 cal_month_start = _dt(cal_year, cal_month, 1, tzinfo=_tz.utc)
1031 cal_last_day = _cal.monthrange(cal_year, cal_month)[1]
1032 cal_month_end = _dt(
1033 cal_year, cal_month, cal_last_day, 23, 59, 59, tzinfo=_tz.utc
1034 )
1036 cal_reservations = (
1037 Reservation.query.filter(
1038 Reservation.aircraft_id.in_(aircraft_ids),
1039 Reservation.status != ReservationStatus.CANCELLED,
1040 Reservation.start_dt <= cal_month_end,
1041 Reservation.end_dt >= cal_month_start,
1042 )
1043 .order_by(Reservation.start_dt)
1044 .all()
1045 if aircraft_ids
1046 else []
1047 )
1049 cal_flights = (
1050 FlightEntry.query.filter(
1051 FlightEntry.aircraft_id.in_(aircraft_ids),
1052 FlightEntry.date >= cal_month_start.date(),
1053 FlightEntry.date <= cal_month_end.date(),
1054 )
1055 .order_by(FlightEntry.date)
1056 .all()
1057 if aircraft_ids
1058 else []
1059 )
1061 cal_day_events: dict[Any, Any] = defaultdict(
1062 lambda: {"reservations": [], "flights": []}
1063 )
1064 for r in cal_reservations:
1065 cur = r.start_dt.date()
1066 end = r.end_dt.date()
1067 while cur <= end:
1068 if cur.month == cal_month and cur.year == cal_year:
1069 cal_day_events[cur]["reservations"].append(r)
1070 cur += timedelta(days=1)
1071 for f in cal_flights:
1072 cal_day_events[f.date]["flights"].append(f)
1074 cal_weeks = _cal.Calendar(firstweekday=0).monthdatescalendar(
1075 cal_year, cal_month
1076 )
1077 cal_prev_month = cal_month - 1 or 12
1078 cal_prev_year = cal_year - 1 if cal_month == 1 else cal_year
1079 cal_next_month = cal_month % 12 + 1
1080 cal_next_year = cal_year + 1 if cal_month == 12 else cal_year
1081 cal_month_name = _dt(cal_year, cal_month, 1).strftime("%B %Y")
1083 return render_template(
1084 "dashboard.html",
1085 aircraft=aircraft,
1086 pending_reservations=pending_reservations,
1087 recent_flights=recent_flights,
1088 recent_pilot_entries=recent_pilot_entries,
1089 dash_track_rows=dash_track_rows,
1090 openaip_key=openaip_key,
1091 hours_this_month=hours_this_month,
1092 flights_this_month=flights_this_month,
1093 maintenance_alerts=maintenance_alerts,
1094 urgent_maintenance=urgent_maintenance,
1095 grounding_snags=grounding_snags,
1096 aircraft_status=aircraft_status,
1097 triggers=triggers,
1098 pilot_currency=pilot_currency,
1099 today=today,
1100 res_7d=res_7d,
1101 res_30d=res_30d,
1102 cal_weeks=cal_weeks,
1103 cal_day_events=cal_day_events,
1104 cal_month_name=cal_month_name,
1105 cal_year=cal_year,
1106 cal_month=cal_month,
1107 cal_prev_year=cal_prev_year,
1108 cal_prev_month=cal_prev_month,
1109 cal_next_year=cal_next_year,
1110 cal_next_month=cal_next_month,
1111 ReservationStatus=ReservationStatus,
1112 )
1113 return render_template("welcome.html")
1115 @app.route("/not-yet-implemented")
1116 def not_yet_implemented() -> ResponseReturnValue:
1117 feature = request.args.get("feature", "This feature")
1118 return render_template("not_yet_implemented.html", feature=feature), 501
1120 @app.route("/set-language/<lang>")
1121 def set_language(lang: str) -> ResponseReturnValue:
1122 from flask import abort, redirect
1124 if lang not in SUPPORTED_LOCALES:
1125 abort(400)
1126 if session.get("user_id") and not session.get("demo_slot_id"):
1127 from models import User
1129 user = db.session.get(User, session["user_id"])
1130 if user:
1131 user.language = lang
1132 db.session.commit()
1133 else:
1134 session["language"] = (
1135 lang # stale user_id (e.g. setup wizard) — fall back to session
1136 )
1137 else:
1138 session["language"] = lang
1139 next_url = request.args.get("next", "").strip()
1140 next_url = next_url.replace(
1141 "\\", ""
1142 ) # browsers treat \ as /; strip before parsing
1143 parsed_next = urlparse(next_url)
1144 if (
1145 not next_url
1146 or parsed_next.netloc
1147 or parsed_next.scheme
1148 or not next_url.startswith("/")
1149 or next_url.startswith("//")
1150 ):
1151 next_url = "/"
1152 return redirect(next_url)
1154 @app.route("/set-theme/<theme>")
1155 def set_theme(theme: str) -> ResponseReturnValue:
1156 from flask import abort, redirect
1158 if theme not in ("light", "dark", "system"):
1159 abort(400)
1160 if session.get("user_id") and not session.get("demo_slot_id"):
1161 from models import User
1163 user = db.session.get(User, session["user_id"])
1164 if user:
1165 user.theme = None if theme == "system" else theme
1166 db.session.commit()
1167 else:
1168 session["theme"] = theme
1169 else:
1170 session["theme"] = theme
1171 next_url = request.args.get("next", "").strip()
1172 next_url = next_url.replace("\\", "")
1173 parsed_next = urlparse(next_url)
1174 if (
1175 not next_url
1176 or parsed_next.netloc
1177 or parsed_next.scheme
1178 or not next_url.startswith("/")
1179 or next_url.startswith("//")
1180 ):
1181 next_url = "/"
1182 return redirect(next_url)
1184 @app.route("/robots.txt")
1185 def robots_txt() -> ResponseReturnValue:
1186 return send_from_directory(
1187 app.static_folder or "static", "robots.txt", mimetype="text/plain"
1188 )
1190 @app.route("/favicon.ico")
1191 def favicon() -> ResponseReturnValue:
1192 return send_from_directory(
1193 app.static_folder or "static", "favicon.svg", mimetype="image/svg+xml"
1194 )
1196 @app.route("/health")
1197 def health() -> ResponseReturnValue:
1198 # Liveness probe: proves the worker is up and routing. Deliberately does
1199 # NOT touch the database — a liveness check must not fail (and trigger a
1200 # restart) just because a dependency is down. See /health/ready below.
1201 return {"status": "ok"}, 200
1203 @app.route("/health/ready")
1204 def health_ready() -> ResponseReturnValue:
1205 # Readiness probe: confirms the database is reachable. Reserved for the
1206 # in-container Docker healthcheck (curl localhost:5000); public callers
1207 # arrive via Traefik with a non-loopback remote_addr (ProxyFix x_for=1),
1208 # so they get a 404 and the endpoint stays hidden and unabusable. The
1209 # check itself is a single cheap "SELECT 1".
1210 from flask import abort as _abort
1211 from sqlalchemy import text as _text
1212 from sqlalchemy.exc import SQLAlchemyError
1214 if request.remote_addr not in ("127.0.0.1", "::1"):
1215 _abort(404)
1216 try:
1217 db.session.execute(_text("SELECT 1"))
1218 except SQLAlchemyError:
1219 db.session.rollback()
1220 return {"status": "degraded", "database": "down"}, 503
1221 return {"status": "ready"}, 200
1223 @app.cli.command("check-empty-db")
1224 def check_empty_db_command() -> None:
1225 """Exit 0 if the database has no user data, 1 if it does (restore safety check)."""
1226 import sys
1228 from sqlalchemy.exc import ProgrammingError # pyright: ignore[reportMissingImports]
1230 from models import User # pyright: ignore[reportMissingImports]
1232 try:
1233 count = User.query.count()
1234 except ProgrammingError:
1235 # Schema not initialised (table missing) — treat as empty.
1236 print("Database is empty.")
1237 return
1238 if count == 0:
1239 print("Database is empty.")
1240 else:
1241 print(f"Database has {count} user(s) — not empty.", file=sys.stderr)
1242 sys.exit(1)
1244 @app.cli.command("restore-backup")
1245 @click.argument("archive_path")
1246 def restore_backup_command(archive_path: str) -> None:
1247 """Restore a backup archive into the current empty database."""
1248 import io
1249 import json
1250 import sys
1251 import zipfile
1253 from flask import current_app # pyright: ignore[reportMissingImports]
1255 from models import User # pyright: ignore[reportMissingImports]
1257 # ── safety: refuse if DB already has data ─────────────────────────────
1258 if User.query.count() > 0:
1259 print(
1260 "ERROR: Database is not empty. Restore refused to prevent data loss.",
1261 file=sys.stderr,
1262 )
1263 sys.exit(1)
1265 # ── decrypt + extract ─────────────────────────────────────────────────
1266 with open(archive_path, "rb") as fh:
1267 payload = fh.read()
1269 encryption_key_raw = os.environ.get("OPENHANGAR_BACKUP_ENCRYPTION_KEY", "")
1270 if encryption_key_raw:
1271 from config.routes import _derive_key # pyright: ignore[reportMissingImports]
1272 from cryptography.hazmat.primitives.ciphers.aead import AESGCM # pyright: ignore[reportMissingImports]
1274 key = _derive_key(encryption_key_raw)
1275 nonce, ct = payload[:12], payload[12:]
1276 try:
1277 zip_bytes = AESGCM(key).decrypt(nonce, ct, None)
1278 except Exception as exc:
1279 print(f"ERROR: Decryption failed — wrong key? ({exc})", file=sys.stderr)
1280 sys.exit(1)
1281 else:
1282 zip_bytes = payload
1284 with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
1285 names = zf.namelist()
1286 metadata: dict[str, str] = (
1287 json.loads(zf.read("metadata.json")) if "metadata.json" in names else {}
1288 )
1289 sql_bytes = zf.read("openhangar.sql")
1290 upload_entries = [n for n in names if n.startswith("uploads/")]
1292 # ── version check ─────────────────────────────────────────────────────
1293 backup_alembic = metadata.get("alembic_head") or "unknown"
1294 backup_version = metadata.get("app_version", "unknown")
1295 current_version = os.environ.get("OPENHANGAR_VERSION", "development")
1296 print(f"Backup: version={backup_version} alembic={backup_alembic}")
1297 print(f"Current: version={current_version}")
1299 if backup_alembic != "unknown":
1300 try:
1301 from alembic.script import ScriptDirectory # pyright: ignore[reportMissingImports]
1302 from flask_migrate import Migrate as _Migrate # pyright: ignore[reportMissingImports]
1304 _m = _Migrate(current_app, db)
1305 scripts = ScriptDirectory.from_config(_m.get_config())
1306 known = {s.revision for s in scripts.walk_revisions()}
1307 if backup_alembic not in known:
1308 print(
1309 f"ERROR: Backup Alembic revision '{backup_alembic}' is not in "
1310 "this container's migration chain. Restore a container version "
1311 "that knows this migration.",
1312 file=sys.stderr,
1313 )
1314 sys.exit(1)
1315 except Exception as exc:
1316 print(f"WARNING: Could not verify Alembic compatibility: {exc}")
1318 # ── drop schema + restore SQL dump ────────────────────────────────────
1319 database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "")
1320 if not database_url.startswith("postgresql"):
1321 print(
1322 f"ERROR: Only PostgreSQL is supported for restore (got: {database_url!r})",
1323 file=sys.stderr,
1324 )
1325 sys.exit(1)
1327 print(
1328 "Dropping existing schema and restoring from backup (this may take a minute)..."
1329 )
1330 try:
1331 _drop_and_restore_schema(database_url, sql_bytes)
1332 except RuntimeError as exc:
1333 print(f"ERROR: psql restore failed:\n{exc}", file=sys.stderr)
1334 sys.exit(1)
1336 # ── restore uploaded files ────────────────────────────────────────────
1337 if upload_entries:
1338 upload_folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1339 os.makedirs(upload_folder, exist_ok=True)
1340 with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
1341 for entry in upload_entries:
1342 fname = os.path.basename(entry)
1343 if fname:
1344 with open(os.path.join(upload_folder, fname), "wb") as fh:
1345 fh.write(zf.read(entry))
1346 print(f"Restored {len(upload_entries)} uploaded file(s).")
1348 print("Restore complete.")
1350 @app.cli.command("backup-now")
1351 def backup_now_command() -> None:
1352 import sys
1354 from config.routes import run_backup
1356 try:
1357 record = run_backup()
1358 print(
1359 f"Backup OK: {record.filename} ({record.size_bytes} bytes, sha256={record.sha256})"
1360 )
1361 except RuntimeError as exc:
1362 print(f"Backup FAILED: {exc}")
1363 sys.exit(1)
1365 # Flask CLI command used by demo/refresh.sh to drop and recreate the schema.
1366 # Only works in demo mode — production uses Alembic migrations.
1367 @app.cli.command("reset-db")
1368 def reset_db_command() -> None:
1369 if flask_env != "demo":
1370 print("reset-db is only available in demo mode. Aborting.")
1371 return
1372 db.drop_all()
1373 db.create_all()
1374 print("Database schema reset.")
1376 # Flask CLI command used by demo/refresh.sh to wipe and reseed demo slots
1377 @app.cli.command("seed-demo")
1378 def seed_demo_command() -> None:
1379 from demo_seed import seed as demo_seed
1381 demo_seed()
1382 print("Demo slots reseeded.")
1384 # Re-apply env-var settings wiped by reset-db
1385 openaip_key = os.environ.get("OPENHANGAR_OPENAIP_API_KEY", "").strip()
1386 if openaip_key:
1387 from models import AppSetting
1389 setting = db.session.get(AppSetting, "openaip_api_key")
1390 if setting is None:
1391 db.session.add(AppSetting(key="openaip_api_key", value=openaip_key))
1392 else:
1393 setting.value = openaip_key
1394 db.session.commit()
1395 print("Environment settings applied.")
1397 # Only run against a real PostgreSQL database (sqlite = dev/test), and only
1398 # when called from a long-running server process — not from init scripts such
1399 # as docker-init-db.py that run migrations before the schema exists.
1400 if "sqlite" not in app.config.get(
1401 "SQLALCHEMY_DATABASE_URI", ""
1402 ) and not os.environ.get("OPENHANGAR_SKIP_BACKGROUND_THREADS"):
1403 from services.version_service import start_version_check_thread # pyright: ignore[reportMissingImports]
1405 start_version_check_thread(app)
1406 from sync_watcher import start_sync_watcher # pyright: ignore[reportMissingImports]
1408 start_sync_watcher(app)
1409 if os.environ.get("OPENHANGAR_ENV", "production") == "production":
1410 _start_easa_sync_scheduler(app)
1411 _start_notification_scheduler(app)
1412 import threading
1413 from services.notification_service import send_welcome_email_if_needed # pyright: ignore[reportMissingImports]
1415 threading.Thread(
1416 target=send_welcome_email_if_needed,
1417 args=(app,),
1418 daemon=True,
1419 name="welcome-email",
1420 ).start()
1422 if (
1423 os.environ.get("WERKZEUG_RUN_MAIN") == "true"
1424 and os.environ.get("OPENHANGAR_ENV", "production") == "development"
1425 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower() in ("1", "true", "yes")
1426 ):
1427 print("OPENHANGAR_SW_ENABLED: service worker active in debug mode", flush=True)
1429 _validate_config(app)
1430 return app
1433def _validate_config(app: Flask) -> None:
1434 """Collect and report all configuration problems at once rather than one at a time."""
1435 errors: list[str] = []
1437 # OPENHANGAR_SECRET_KEY: minimum length (existence and placeholder already checked above)
1438 secret = app.config.get("SECRET_KEY", "")
1439 if secret and len(secret) < 32:
1440 errors.append(
1441 f"OPENHANGAR_SECRET_KEY is too short ({len(secret)} chars, minimum 32). "
1442 "Generate one with: openssl rand -hex 32"
1443 )
1445 # OPENHANGAR_ENV: must be one of the known values when set
1446 _raw_env = os.environ.get("OPENHANGAR_ENV", "")
1447 if _raw_env and _raw_env not in ("production", "development", "test", "demo"):
1448 errors.append(
1449 f"OPENHANGAR_ENV must be one of: production, development, test, demo "
1450 f"(got {_raw_env!r})"
1451 )
1453 # OPENHANGAR_MAX_UPLOAD_BYTES: must be a plain positive integer when set
1454 _raw_max = os.environ.get("OPENHANGAR_MAX_UPLOAD_BYTES", "")
1455 _validated_max: int | None = None
1456 if _raw_max:
1457 try:
1458 _parsed = int(_raw_max)
1459 if _parsed <= 0:
1460 errors.append("OPENHANGAR_MAX_UPLOAD_BYTES must be a positive integer")
1461 else:
1462 _validated_max = _parsed
1463 except ValueError:
1464 errors.append(
1465 f"OPENHANGAR_MAX_UPLOAD_BYTES must be a plain integer (bytes), got {_raw_max!r}. "
1466 "Example: 52428800 for 50 MB."
1467 )
1469 # OPENHANGAR_SYNC_SCAN_INTERVAL: must be a positive integer when set
1470 _raw_interval = os.environ.get("OPENHANGAR_SYNC_SCAN_INTERVAL", "")
1471 if _raw_interval:
1472 try:
1473 _parsed_interval = int(_raw_interval)
1474 if _parsed_interval <= 0:
1475 errors.append(
1476 "OPENHANGAR_SYNC_SCAN_INTERVAL must be a positive integer (seconds)"
1477 )
1478 except ValueError:
1479 errors.append(
1480 f"OPENHANGAR_SYNC_SCAN_INTERVAL must be a plain integer (seconds), got {_raw_interval!r}. "
1481 "Example: 60"
1482 )
1484 # OPENHANGAR_DATABASE_URL: production deployments must use PostgreSQL
1485 db_url = app.config.get("SQLALCHEMY_DATABASE_URI", "")
1486 flask_env = os.environ.get("OPENHANGAR_ENV", "production")
1487 if "sqlite" not in db_url and flask_env not in ("development", "test"):
1488 if not db_url.startswith(("postgresql://", "postgresql+psycopg2://")):
1489 scheme = db_url.split("://")[0] if "://" in db_url else db_url[:20]
1490 errors.append(
1491 f"OPENHANGAR_DATABASE_URL scheme {scheme!r} is not supported in production. "
1492 "Use 'postgresql://' or 'postgresql+psycopg2://'."
1493 )
1495 # OPENHANGAR_BACKUP_ENCRYPTION_KEY: whitespace-only value is likely a misconfiguration
1496 enc_key = os.environ.get("OPENHANGAR_BACKUP_ENCRYPTION_KEY", "")
1497 if enc_key and not enc_key.strip():
1498 errors.append(
1499 "OPENHANGAR_BACKUP_ENCRYPTION_KEY is set but contains only whitespace. "
1500 "Either provide a real key or leave the variable unset."
1501 )
1503 # OPENHANGAR_SMTP_PORT: must be an integer in valid port range when set
1504 _raw_smtp_port = os.environ.get("OPENHANGAR_SMTP_PORT", "")
1505 if _raw_smtp_port:
1506 try:
1507 _smtp_port_val = int(_raw_smtp_port)
1508 if not (1 <= _smtp_port_val <= 65535):
1509 errors.append(
1510 f"OPENHANGAR_SMTP_PORT must be between 1 and 65535, got {_raw_smtp_port!r}"
1511 )
1512 except ValueError:
1513 errors.append(
1514 f"OPENHANGAR_SMTP_PORT must be an integer, got {_raw_smtp_port!r}"
1515 )
1517 # OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES: must be a positive integer when set
1518 _raw_busy = os.environ.get("OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES", "")
1519 if _raw_busy:
1520 try:
1521 if int(_raw_busy) <= 0:
1522 errors.append(
1523 "OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a positive integer"
1524 )
1525 except ValueError:
1526 errors.append(
1527 f"OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a plain integer (minutes), "
1528 f"got {_raw_busy!r}"
1529 )
1531 # OPENHANGAR_NOTIFICATION_TIME: optional, but must be valid HH:MM when set
1532 _raw_notif_time = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "")
1533 if _raw_notif_time:
1534 try:
1535 _parse_notification_time()
1536 except ValueError as exc:
1537 errors.append(str(exc))
1539 # OPENHANGAR_ALERT_NTFY_TOPIC_URL: must be an http(s) URL when set
1540 _ntfy_url = os.environ.get("OPENHANGAR_ALERT_NTFY_TOPIC_URL", "").strip()
1541 if _ntfy_url and not _ntfy_url.startswith(("http://", "https://")):
1542 errors.append(
1543 f"OPENHANGAR_ALERT_NTFY_TOPIC_URL must start with http:// or https://, "
1544 f"got {_ntfy_url!r}"
1545 )
1547 # OPENHANGAR_ALERT_EMAIL_TO: must look like an email address when set,
1548 # and SMTP must be configured for delivery to be possible
1549 _alert_email = os.environ.get("OPENHANGAR_ALERT_EMAIL_TO", "").strip()
1550 if _alert_email:
1551 if "@" not in _alert_email:
1552 errors.append(
1553 f"OPENHANGAR_ALERT_EMAIL_TO must be a valid email address, "
1554 f"got {_alert_email!r}"
1555 )
1556 elif not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip():
1557 errors.append(
1558 "OPENHANGAR_ALERT_EMAIL_TO is set but OPENHANGAR_SMTP_HOST is not configured — "
1559 "alert emails cannot be delivered"
1560 )
1562 # OPENHANGAR_ALERT_WEBHOOK_URL: must be an http(s) URL when set
1563 _webhook_url = os.environ.get("OPENHANGAR_ALERT_WEBHOOK_URL", "").strip()
1564 if _webhook_url and not _webhook_url.startswith(("http://", "https://")):
1565 errors.append(
1566 f"OPENHANGAR_ALERT_WEBHOOK_URL must start with http:// or https://, "
1567 f"got {_webhook_url!r}"
1568 )
1570 if errors:
1571 bullet_list = "\n".join(f" • {e}" for e in errors)
1572 raise RuntimeError(
1573 f"Configuration errors — fix before starting:\n{bullet_list}"
1574 )
1576 if _validated_max is not None:
1577 app.config["MAX_CONTENT_LENGTH"] = _validated_max
1580if __name__ == "__main__": # pragma: no cover
1581 _debug = os.environ.get("OPENHANGAR_ENV") == "development"
1582 create_app().run(host="0.0.0.0", port=5000, debug=_debug) # nosec B104