Coverage for app/init.py: 100%

929 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 15:29 +0000

1import os 

2import secrets 

3import sqlite3 

4from datetime import UTC, timedelta 

5from functools import cache 

6from typing import Any 

7from urllib.parse import urlparse 

8 

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) 

30 

31 

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 "" 

56 

57 

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 

66 

67 

68SUPPORTED_LOCALES = ["en", "fr", "nl"] 

69 

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} 

75 

76 

77# Marks a string for pybabel extraction without translating it here — the 

78# actual lookup happens later via gettext() at render time, once the real 

79# locale is known. A no-op at runtime, but its literal-string argument is 

80# what lets pybabel discover msgids that are only selected dynamically 

81# (e.g. picked out of _AVIATION_DAYS by date, below) instead of passed 

82# straight to gettext()/ngettext() as a literal. 

83def N_(s: str) -> str: 

84 return s 

85 

86 

87# EE-09: aviation history days — (month, day, msgid). Add new entries here. 

88_AVIATION_DAYS: list[tuple[int, int, str]] = [ 

89 ( 

90 3, 

91 2, 

92 N_("First flight of Concorde — André Turcat at the controls, Toulouse (1969)"), 

93 ), 

94 ( 

95 5, 

96 21, 

97 N_( 

98 "Charles Lindbergh lands at Le Bourget — first solo transatlantic flight (1927)" 

99 ), 

100 ), 

101 ( 

102 7, 

103 25, 

104 N_( 

105 "Louis Blériot crosses the English Channel — first crossing by airplane (1909)" 

106 ), 

107 ), 

108 ( 

109 11, 

110 21, 

111 N_( 

112 "Pilâtre de Rozier & d'Arlandes — first manned free balloon flight, Paris (1783)" 

113 ), 

114 ), 

115 ( 

116 12, 

117 17, 

118 N_("First flight: 17 Dec 1903 — 12 seconds, 37 metres. (Wright Brothers)"), 

119 ), 

120] 

121 

122 

123def _aviation_day_msgid(month: int, day: int) -> str | None: 

124 for m, d, msgid in _AVIATION_DAYS: 

125 if m == month and d == day: 

126 return msgid 

127 return None 

128 

129 

130def _compute_banners(uid: int | None) -> dict[str, Any]: 

131 """EE-09 aviation-day banner + EE-10 personal anniversary banner. 

132 

133 Shared by the inject_globals context processor (baked into server- 

134 rendered HTML) and the /api/banners endpoint (polled client-side by 

135 pwa.js) so a page served stale from the SW's SWR cache, or an open tab 

136 that never re-navigates, still learns about today's banner — see 

137 pwa.js's banner-poll comment for why a baked-in-HTML-only banner isn't 

138 enough. pilot_anniversary_confetti is a genuine once-a-day flag (backed 

139 by a session key), not a per-caller one, so whichever of the two call 

140 sites hits this first on a given day is the one that gets confetti:True 

141 for that request. 

142 """ 

143 from datetime import date as _date 

144 

145 # babel.cfg's extraction keywords list "_"/"ngettext" specifically (not 

146 # arbitrary aliases) -- importing these "as _gt"/"as _ngt" instead meant 

147 # pybabel silently never extracted any of these msgids, so they rendered 

148 # in English regardless of locale. 

149 from flask_babel import gettext as _ 

150 from flask_babel import ngettext 

151 

152 _today = _date.today() 

153 _avi_msgid = _aviation_day_msgid(_today.month, _today.day) 

154 _aviation_banner = _(_avi_msgid) if _avi_msgid else None 

155 

156 _pilot_anniversary: dict[str, Any] | None = None 

157 _pilot_anniversary_confetti = False 

158 if uid: 

159 from models import PilotProfile as _PP 

160 

161 _pp = _PP.query.filter_by(user_id=uid).first() 

162 if _pp: 

163 for _ann_date, _ann_type in ( 

164 (_pp.first_solo_date, "solo"), 

165 (_pp.ppl_issue_date, "ppl"), 

166 ): 

167 if _ann_date and (_ann_date.month, _ann_date.day) == ( 

168 _today.month, 

169 _today.day, 

170 ): 

171 _years = _today.year - _ann_date.year 

172 if _ann_type == "solo": 

173 _msg = ( 

174 ngettext( 

175 "🎉 Today marks one year since your first solo flight!", 

176 "🎉 Today marks %(n)s years since your first solo flight!", 

177 _years, 

178 n=_years, 

179 ) 

180 if _years > 0 

181 else _( 

182 "🎉 Today is the anniversary of your first solo flight!" 

183 ) 

184 ) 

185 else: 

186 _msg = ( 

187 ngettext( 

188 "🎉 Today marks one year since you earned your PPL!", 

189 "🎉 Today marks %(n)s years since you earned your PPL!", 

190 _years, 

191 n=_years, 

192 ) 

193 if _years > 0 

194 else _("🎉 Today is the anniversary of your PPL!") 

195 ) 

196 _pilot_anniversary = { 

197 "type": _ann_type, 

198 "years": _years, 

199 "message": _msg, 

200 } 

201 _sess_key = f"anniversary_confetti_{_today.isoformat()}" 

202 if not session.get(_sess_key): 

203 session[_sess_key] = True 

204 _pilot_anniversary_confetti = True 

205 break 

206 

207 return { 

208 "aviation_day_banner": _aviation_banner, 

209 "pilot_anniversary": _pilot_anniversary, 

210 "pilot_anniversary_confetti": _pilot_anniversary_confetti, 

211 } 

212 

213 

214@cache 

215def _static_folder_mtime_token(static_folder: str) -> str: 

216 latest = 0 

217 for root, _dirs, files in os.walk(static_folder): 

218 for name in files: 

219 try: 

220 mtime = int(os.path.getmtime(os.path.join(root, name))) 

221 except OSError: # file removed while walking 

222 continue 

223 latest = max(latest, mtime) 

224 return str(latest) 

225 

226 

227def _static_cache_version(static_folder: str) -> str: 

228 """Cache-busting token appended to static URLs (?v=…). 

229 

230 The release version when running a published image; otherwise the newest 

231 file mtime under the static folder — stable across gunicorn workers, and 

232 changes whenever an asset changes during development.""" 

233 version = os.environ.get("OPENHANGAR_VERSION", "") 

234 if version and version != "development": 

235 return version 

236 return _static_folder_mtime_token(static_folder) 

237 

238 

239@event.listens_for(Engine, "connect") 

240def _set_sqlite_fk_pragma(dbapi_connection: Any, _record: Any) -> None: 

241 if isinstance(dbapi_connection, sqlite3.Connection): 

242 cur = dbapi_connection.cursor() 

243 cur.execute("PRAGMA foreign_keys=ON") 

244 cur.close() 

245 

246 

247def _drop_and_restore_schema(database_url: str, sql_bytes: bytes) -> None: 

248 """Drop the public schema and restore it from a pg_dump byte-string.""" 

249 import subprocess # nosec B404 

250 import tempfile 

251 

252 from models import db # pyright: ignore[reportMissingImports] 

253 from sqlalchemy import text # pyright: ignore[reportMissingImports] 

254 from utils import to_libpq_url # pyright: ignore[reportMissingImports] 

255 

256 # Close the ORM session while its connection is still alive so Flask's 

257 # teardown has nothing left to rollback after we terminate other backends. 

258 db.session.remove() 

259 

260 with db.engine.connect() as conn: 

261 # Terminate all other connections so DROP SCHEMA can acquire its 

262 # ACCESS EXCLUSIVE lock even if the web server left a connection 

263 # idle-in-transaction (which would block indefinitely otherwise). 

264 conn.execute( 

265 text( 

266 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity" 

267 " WHERE datname = current_database() AND pid != pg_backend_pid()" 

268 ) 

269 ) 

270 conn.execute(text("DROP SCHEMA public CASCADE")) 

271 conn.execute(text("CREATE SCHEMA public")) 

272 conn.commit() 

273 

274 # Dispose the connection pool so no SQLAlchemy connections linger and 

275 # block psql's DDL statements with AccessShareLock. 

276 db.engine.dispose() 

277 

278 # Strip ownership and privilege statements from the dump. pg_dump (without 

279 # --no-owner / --no-acl) records the source role for every object and emits 

280 # GRANT/REVOKE commands, but those role names are environment-specific and 

281 # do not exist on the target server. New backups are produced with 

282 # --no-owner --no-acl, but we strip here too as a safety net for archives 

283 # made before that change. 

284 import re 

285 

286 sql_bytes = re.sub( 

287 rb"^(?:ALTER\s+\S[^\n]*\bOWNER\s+TO\b|GRANT\b|REVOKE\b)[^\n]*;\s*$", 

288 b"", 

289 sql_bytes, 

290 flags=re.MULTILINE | re.IGNORECASE, 

291 ) 

292 

293 # Write the dump to a temp file so psql can read it directly rather than 

294 # via stdin — avoids pipe-buffering hangs on large dumps and lets psql 

295 # print progress to the terminal in real time. 

296 with tempfile.NamedTemporaryFile(suffix=".sql", delete=False) as tmp: 

297 tmp.write(sql_bytes) 

298 tmp_path = tmp.name 

299 

300 try: 

301 result = subprocess.run( # nosec B603 

302 ["psql", "--no-password", "-f", tmp_path, to_libpq_url(database_url)], 

303 timeout=600, 

304 check=False, # returncode checked explicitly below 

305 ) 

306 except subprocess.TimeoutExpired: 

307 raise RuntimeError("psql restore timed out after 10 minutes.") 

308 finally: 

309 os.unlink(tmp_path) 

310 

311 if result.returncode != 0: 

312 raise RuntimeError(f"psql exited with code {result.returncode}") 

313 

314 

315def _easa_sync_loop(app: Flask) -> None: 

316 import logging 

317 import os 

318 import random 

319 import time 

320 from datetime import datetime, timedelta 

321 

322 from airworthiness_sync import ( 

323 sync_all_nodes, # pyright: ignore[reportMissingImports] 

324 ) 

325 

326 _log = logging.getLogger(__name__) 

327 

328 # Determine the daily sync time (UTC). Admin can pin a specific hour via 

329 # OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR (0-23). Default: random hour 

330 # 01-05 UTC so that different instances do not all hit EASA simultaneously. 

331 env_hour = os.environ.get("OPENHANGAR_AIRWORTHINESS_EASA_SYNC_HOUR") 

332 if env_hour is not None: 

333 try: 

334 sync_hour = int(env_hour) % 24 

335 except ValueError: 

336 sync_hour = random.randint(1, 5) 

337 else: 

338 sync_hour = random.randint(1, 5) 

339 sync_minute = random.randint(0, 59) 

340 _log.info("EASA sync scheduled daily at %02d:%02d UTC", sync_hour, sync_minute) 

341 

342 while True: 

343 now = datetime.now(UTC) 

344 next_run = now.replace( 

345 hour=sync_hour, minute=sync_minute, second=0, microsecond=0 

346 ) 

347 if next_run <= now: 

348 next_run += timedelta(days=1) 

349 time.sleep((next_run - datetime.now(UTC)).total_seconds()) 

350 sync_all_nodes(app) 

351 

352 

353def _start_easa_sync_scheduler(app: Flask) -> None: 

354 import threading 

355 

356 t = threading.Thread( 

357 target=_easa_sync_loop, 

358 args=(app,), 

359 daemon=True, 

360 name="easa-sync", 

361 ) 

362 t.start() 

363 

364 

365def _parse_notification_time() -> tuple[int, int]: 

366 """Return (hour, minute) from OPENHANGAR_NOTIFICATION_TIME (HH:MM, default 07:00). 

367 

368 Raises ValueError with a human-readable message if the value is set but invalid. 

369 """ 

370 raw = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "07:00") 

371 err = f"OPENHANGAR_NOTIFICATION_TIME={raw!r} is invalid — expected HH:MM (e.g. '07:00')" 

372 parts = raw.split(":") 

373 if len(parts) != 2: 

374 raise ValueError(err) 

375 try: 

376 hour, minute = int(parts[0]), int(parts[1]) 

377 except ValueError: 

378 raise ValueError(err) 

379 if not (0 <= hour <= 23 and 0 <= minute <= 59): 

380 raise ValueError(err) 

381 return hour, minute 

382 

383 

384def _notification_daily_loop(app: Flask, run_hour: int, run_minute: int) -> None: 

385 import logging 

386 import time 

387 from datetime import datetime, timedelta 

388 

389 _log = logging.getLogger(__name__) 

390 _log.info( 

391 "Notification daily check scheduled at %02d:%02d UTC", run_hour, run_minute 

392 ) 

393 

394 while True: 

395 now = datetime.now(UTC) 

396 next_run = now.replace( 

397 hour=run_hour, minute=run_minute, second=0, microsecond=0 

398 ) 

399 if next_run <= now: 

400 next_run += timedelta(days=1) 

401 time.sleep((next_run - datetime.now(UTC)).total_seconds()) 

402 try: 

403 from services.notification_service import ( 

404 run_daily_checks, # pyright: ignore[reportMissingImports] 

405 ) 

406 

407 run_daily_checks(app) 

408 except Exception: # noqa: BLE001 -- background job, must not crash the loop, retries tomorrow 

409 _log.exception("Notification daily check failed; will retry tomorrow") 

410 

411 

412def _start_notification_scheduler(app: Flask) -> None: 

413 import threading 

414 

415 run_hour, run_minute = _parse_notification_time() 

416 t = threading.Thread( 

417 target=_notification_daily_loop, 

418 args=(app, run_hour, run_minute), 

419 daemon=True, 

420 name="notification-daily", 

421 ) 

422 t.start() 

423 

424 

425def create_app() -> Flask: 

426 from security_alerts import ( 

427 attach_to_logger, # pyright: ignore[reportMissingImports] 

428 ) 

429 

430 attach_to_logger() 

431 

432 app = Flask(__name__) 

433 app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) # type: ignore[method-assign] 

434 

435 # Propagate OPENHANGAR_ENV → FLASK_ENV so Flask's own internals keep working. 

436 os.environ["FLASK_ENV"] = os.environ.get("OPENHANGAR_ENV", "production") 

437 

438 app.config["SQLALCHEMY_DATABASE_URI"] = _normalize_database_url( 

439 _env_or_file("DATABASE_URL") or "sqlite:///:memory:" 

440 ) 

441 secret_key = _env_or_file("SECRET_KEY") 

442 if not secret_key: 

443 raise RuntimeError("OPENHANGAR_SECRET_KEY environment variable must be set") 

444 if "change" in secret_key.lower(): 

445 raise RuntimeError( 

446 "OPENHANGAR_SECRET_KEY appears to be a placeholder value. " 

447 "Generate a real key with: openssl rand -hex 32" 

448 ) 

449 app.config["SECRET_KEY"] = secret_key 

450 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 

451 app.config["UPLOAD_FOLDER"] = os.environ.get( 

452 "OPENHANGAR_UPLOAD_FOLDER", "/data/uploads" 

453 ) 

454 app.config["BACKUP_FOLDER"] = os.environ.get( 

455 "OPENHANGAR_BACKUP_FOLDER", "/data/backups" 

456 ) 

457 app.config["MAX_CONTENT_LENGTH"] = ( 

458 50 * 1024 * 1024 

459 ) # overridden by _validate_config 

460 app.config["SESSION_COOKIE_SECURE"] = True 

461 app.config["SESSION_COOKIE_HTTPONLY"] = True 

462 app.config["SESSION_COOKIE_SAMESITE"] = "Lax" 

463 _session_days = int(os.environ.get("OPENHANGAR_SESSION_LIFETIME_DAYS", "30")) 

464 app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=_session_days) 

465 

466 flask_env = os.environ.get("OPENHANGAR_ENV", "production") 

467 

468 if flask_env in ("development", "test"): 

469 app.config["TEMPLATES_AUTO_RELOAD"] = True 

470 

471 from models import db 

472 

473 db.init_app(app) 

474 Migrate(app, db) 

475 

476 def _get_locale() -> str | None: 

477 if not has_request_context(): 

478 return "en" 

479 if session.get("user_id"): 

480 # Demo sessions: visitor's session language takes precedence over the 

481 # demo user's stored default so Accept-Language / manual switcher work. 

482 if ( 

483 session.get("demo_slot_id") 

484 and session.get("language") in SUPPORTED_LOCALES 

485 ): 

486 return str(session["language"]) 

487 from models import User 

488 

489 user = db.session.get(User, session["user_id"]) 

490 if user and user.language in SUPPORTED_LOCALES: 

491 return str(user.language) 

492 if session.get("language") in SUPPORTED_LOCALES: 

493 return str(session["language"]) 

494 return str(request.accept_languages.best_match(SUPPORTED_LOCALES, default="en")) 

495 

496 Babel(app, locale_selector=_get_locale) 

497 # No independent expiry: some nav pages are cached client-side (sw.js 

498 # SWR_ROUTES) for offline/instant-navigation support, so a token embedded 

499 # in a stale-but-served page must stay valid until the session itself 

500 # expires, not Flask-WTF's 1-hour default. 

501 app.config["WTF_CSRF_TIME_LIMIT"] = None 

502 CSRFProtect(app) 

503 

504 from extensions import cache as _cache # pyright: ignore[reportMissingImports] 

505 from extensions import limiter as _limiter # pyright: ignore[reportMissingImports] 

506 

507 app.config["CACHE_TYPE"] = "SimpleCache" 

508 app.config["CACHE_DEFAULT_TIMEOUT"] = 300 

509 _cache.init_app(app) 

510 app.config["RATELIMIT_ENABLED"] = os.environ.get( 

511 "OPENHANGAR_RATELIMIT_ENABLED", "true" 

512 ).lower() in ("1", "true", "yes") 

513 _limiter.init_app(app) 

514 

515 static_version = _static_cache_version(app.static_folder or "static") 

516 

517 @app.url_defaults 

518 def _static_cache_bust(endpoint: str, values: dict[str, Any]) -> None: 

519 if endpoint == "static": 

520 values.setdefault("v", static_version) 

521 

522 @app.before_request 

523 def _generate_csp_nonce() -> None: 

524 g.csp_nonce = secrets.token_urlsafe(16) 

525 

526 def _csp_nonce() -> str: 

527 return getattr(g, "csp_nonce", "") 

528 

529 app.jinja_env.globals["csp_nonce"] = _csp_nonce 

530 

531 @app.after_request 

532 def _security_headers(response: Any) -> Any: 

533 nonce = getattr(g, "csp_nonce", "") 

534 response.headers["Content-Security-Policy"] = ( 

535 f"default-src 'self'; " 

536 f"script-src 'nonce-{nonce}'; " 

537 f"worker-src 'self' blob:; " 

538 f"style-src-elem 'self'; " 

539 f"style-src-attr 'none'; " 

540 f"font-src 'self'; " 

541 f"img-src 'self' data: blob: tile.openstreetmap.org *.basemaps.cartocdn.com api.tiles.openaip.net; " 

542 f"connect-src 'self'; " 

543 f"object-src 'none'; " 

544 f"base-uri 'self'; " 

545 f"form-action 'self'; " 

546 # 'self' (not 'none'): the app frames its own same-origin content 

547 # on purpose — the document/photo preview modal (doc_viewer.js) 

548 # embeds /uploads/<file> in an <iframe>. A blanket 'none' also 

549 # applies to that response and silently breaks that iframe (no 

550 # console-visible failure beyond a CSP violation) rather than 

551 # merely stopping a third-party site from framing OpenHangar, 

552 # which is the actual threat 'self' still fully blocks. 

553 f"frame-ancestors 'self';" 

554 ) 

555 response.headers["X-Frame-Options"] = "SAMEORIGIN" 

556 response.headers["X-Content-Type-Options"] = "nosniff" 

557 response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" 

558 response.headers["Permissions-Policy"] = ( 

559 "camera=(), microphone=(), geolocation=(), payment=()" 

560 ) 

561 response.headers["Cross-Origin-Opener-Policy"] = "same-origin" 

562 response.headers["Cross-Origin-Resource-Policy"] = "same-origin" 

563 if request.endpoint == "static" and response.status_code in (200, 304): 

564 # Static URLs carry the ?v= cache-buster, so long-lived caching is 

565 # safe; uploads/documents go through their own routes and keep the 

566 # authenticated no-store below. 

567 response.headers["Cache-Control"] = "public, max-age=31536000, immutable" 

568 elif session.get("user_id"): 

569 existing_cc = response.headers.get("Cache-Control", "") 

570 if "public" not in existing_cc and "immutable" not in existing_cc: 

571 response.headers["Cache-Control"] = "no-store, private" 

572 return response 

573 

574 from flask_babel import format_date, format_datetime, format_decimal 

575 

576 app.jinja_env.globals.update( 

577 format_date=format_date, 

578 format_datetime=format_datetime, 

579 format_decimal=format_decimal, 

580 ) 

581 

582 if app.config.get("TESTING") or os.environ.get("OPENHANGAR_ENV") == "development": 

583 from jinja2 import StrictUndefined 

584 

585 app.jinja_env.undefined = StrictUndefined 

586 

587 from utils import ( 

588 _load_aircraft_type_variants, 

589 _load_airport_names, 

590 ) 

591 

592 @app.template_filter("airport_name") 

593 def _airport_name_filter(code: str | None) -> str: 

594 if not code: 

595 return "" 

596 return _load_airport_names().get(code.upper(), "") 

597 

598 @app.route("/manifest.json") 

599 def pwa_manifest() -> ResponseReturnValue: 

600 from flask import jsonify as _jsonify 

601 

602 return _jsonify( 

603 { 

604 "name": "OpenHangar", 

605 "short_name": "OpenHangar", 

606 "description": "Open-source aircraft operations and pilot logbook", 

607 "start_url": "/", 

608 "display": "standalone", 

609 "theme_color": "#14233A", 

610 "background_color": "#14233A", 

611 "icons": [ 

612 { 

613 "src": "/static/icons/icon.svg", 

614 "sizes": "any", 

615 "type": "image/svg+xml", 

616 }, 

617 { 

618 "src": "/static/icons/icon-maskable.svg", 

619 "sizes": "any", 

620 "type": "image/svg+xml", 

621 "purpose": "maskable", 

622 }, 

623 ], 

624 "share_target": { 

625 "action": "/pwa/shared", 

626 "method": "POST", 

627 "enctype": "multipart/form-data", 

628 "params": { 

629 "title": "title", 

630 "text": "text", 

631 "url": "url", 

632 "files": [ 

633 { 

634 "name": "files", 

635 "accept": ["application/pdf", "image/*"], 

636 } 

637 ], 

638 }, 

639 }, 

640 "shortcuts": [ 

641 { 

642 "name": "Log a Flight", 

643 "short_name": "Log Flight", 

644 "url": "/flights/new", 

645 "icons": [ 

646 { 

647 "src": "/static/icons/shortcut-log-flight.svg", 

648 "sizes": "any", 

649 "type": "image/svg+xml", 

650 } 

651 ], 

652 }, 

653 { 

654 "name": "My Aircraft", 

655 "short_name": "Aircraft", 

656 "url": "/aircraft", 

657 "icons": [ 

658 { 

659 "src": "/static/icons/shortcut-aircraft.svg", 

660 "sizes": "any", 

661 "type": "image/svg+xml", 

662 } 

663 ], 

664 }, 

665 { 

666 "name": "Documents", 

667 "short_name": "Documents", 

668 "url": "/documents", 

669 "icons": [ 

670 { 

671 "src": "/static/icons/shortcut-documents.svg", 

672 "sizes": "any", 

673 "type": "image/svg+xml", 

674 } 

675 ], 

676 }, 

677 ], 

678 } 

679 ) 

680 

681 @app.route("/sw.js") 

682 def service_worker() -> ResponseReturnValue: 

683 sw_path = os.path.join(app.static_folder or "static", "js", "sw.js") 

684 with open(sw_path, encoding="utf-8") as fh: 

685 content = fh.read() 

686 version = os.environ.get("OPENHANGAR_VERSION", "") 

687 cache_name = ( 

688 f"openhangar-{version}" 

689 if version and version != "development" 

690 else f"openhangar-{secrets.token_hex(8)}" 

691 ) 

692 content = content.replace("__SW_CACHE_VERSION__", cache_name) 

693 response = Response(content, mimetype="application/javascript") 

694 response.headers["Service-Worker-Allowed"] = "/" 

695 return response 

696 

697 @app.route("/api/check-flight-duplicate") 

698 def api_check_flight_duplicate() -> ResponseReturnValue: 

699 from flask import jsonify as _jsonify 

700 

701 if not session.get("user_id"): 

702 return _jsonify({"error": "unauthorized"}), 401 

703 date_str = request.args.get("date", "") 

704 aircraft_id_str = request.args.get("aircraft_id", "") 

705 dep = request.args.get("departure_icao", "") 

706 arr = request.args.get("arrival_icao", "") 

707 exclude_flight_id_str = request.args.get("exclude_flight_id", "") 

708 exclude_flight_id = ( 

709 int(exclude_flight_id_str) if exclude_flight_id_str.isdigit() else None 

710 ) 

711 if not (date_str and dep and arr): 

712 return _jsonify({"duplicate": False}) 

713 from models import Aircraft, Flight, TenantUser 

714 from sqlalchemy import or_ as _or # pyright: ignore[reportMissingImports] 

715 

716 uid = int(session["user_id"]) 

717 try: 

718 from datetime import date as _date 

719 

720 flight_date = _date.fromisoformat(date_str) 

721 except ValueError: 

722 return _jsonify({"duplicate": False}) 

723 

724 tu = TenantUser.query.filter_by(user_id=uid).first() 

725 

726 if tu and aircraft_id_str and aircraft_id_str.isdigit(): 

727 ac_id = int(aircraft_id_str) 

728 # Scope by tenant: only match flights on an aircraft the caller's 

729 # tenant owns, otherwise this leaks a cross-tenant existence oracle. 

730 owned = Aircraft.query.filter_by(id=ac_id, tenant_id=tu.tenant_id).first() 

731 if owned: 

732 q = Flight.query.filter_by( 

733 aircraft_id=ac_id, 

734 date=flight_date, 

735 departure_icao=dep, 

736 arrival_icao=arr, 

737 ) 

738 if exclude_flight_id: 

739 q = q.filter(Flight.id != exclude_flight_id) 

740 if q.first(): 

741 return _jsonify({"duplicate": True}) 

742 

743 if tu: 

744 q_pilot = Flight.query.filter( 

745 _or(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid), 

746 Flight.date == flight_date, 

747 Flight.departure_icao == dep, 

748 Flight.arrival_icao == arr, 

749 ) 

750 if exclude_flight_id: 

751 q_pilot = q_pilot.filter(Flight.id != exclude_flight_id) 

752 if q_pilot.first(): 

753 return _jsonify({"duplicate": True}) 

754 

755 return _jsonify({"duplicate": False}) 

756 

757 @app.route("/api/banners") 

758 def api_banners() -> ResponseReturnValue: 

759 """Polled client-side by pwa.js — see _compute_banners' docstring 

760 for why a page can't rely on server-rendered HTML alone for this.""" 

761 from flask import jsonify as _jsonify 

762 

763 if not session.get("user_id"): 

764 return _jsonify({"error": "unauthorized"}), 401 

765 return _jsonify(_compute_banners(int(session["user_id"]))) 

766 

767 @app.route("/airport-search") 

768 def airport_search() -> ResponseReturnValue: 

769 if not session.get("user_id"): 

770 return {"results": []} 

771 q = request.args.get("q", "").strip() 

772 if len(q) < 2: 

773 return {"results": []} 

774 q_code = q.upper() 

775 q_low = q.lower() 

776 names = _load_airport_names() 

777 code_hits: list[dict[str, str]] = [] 

778 name_hits: list[dict[str, str]] = [] 

779 for code, name in names.items(): 

780 if code.startswith(q_code): 

781 code_hits.append({"code": code, "name": name}) 

782 elif q_low in name.lower(): 

783 name_hits.append({"code": code, "name": name}) 

784 return {"results": (code_hits + name_hits)[:10]} 

785 

786 @app.route("/aircraft-type-search") 

787 def aircraft_type_search() -> ResponseReturnValue: 

788 if not session.get("user_id"): 

789 return {"results": []} 

790 q = request.args.get("q", "").strip() 

791 if len(q) < 2: 

792 return {"results": []} 

793 q_up = q.upper() 

794 words = q.lower().split() 

795 variants = _load_aircraft_type_variants() 

796 code_hits: list[dict[str, str]] = [] 

797 name_hits: list[dict[str, str]] = [] 

798 for des, full_name, mfr, mdl in variants: 

799 name_low = full_name.lower() 

800 entry = {"code": des, "name": full_name, "manufacturer": mfr, "model": mdl} 

801 if des.startswith(q_up): 

802 code_hits.append(entry) 

803 elif all(w in name_low for w in words): 

804 name_hits.append(entry) 

805 return {"results": code_hits + name_hits} 

806 

807 from utils import AircraftRefConverter 

808 

809 app.url_map.converters["aircraft_ref"] = AircraftRefConverter 

810 

811 from auth.routes import auth_bp 

812 

813 app.register_blueprint(auth_bp) 

814 

815 from aircraft.routes import aircraft_bp 

816 

817 app.register_blueprint(aircraft_bp) 

818 

819 from flights.routes import flights_bp 

820 

821 app.register_blueprint(flights_bp) 

822 

823 from maintenance.routes import maintenance_bp 

824 

825 app.register_blueprint(maintenance_bp) 

826 

827 from expenses.routes import expenses_bp 

828 

829 app.register_blueprint(expenses_bp) 

830 

831 from documents.routes import documents_bp 

832 

833 app.register_blueprint(documents_bp) 

834 

835 from config.routes import config_bp 

836 

837 app.register_blueprint(config_bp) 

838 

839 from share.routes import share_bp 

840 

841 app.register_blueprint(share_bp) 

842 

843 from snags.routes import snags_bp 

844 

845 app.register_blueprint(snags_bp) 

846 

847 from notifications.routes import notifications_bp 

848 

849 app.register_blueprint(notifications_bp) 

850 

851 from refuels.routes import refuels_bp 

852 

853 app.register_blueprint(refuels_bp) 

854 

855 from reports.routes import reports_bp 

856 

857 app.register_blueprint(reports_bp) 

858 

859 from pilots.routes import pilots_bp 

860 

861 app.register_blueprint(pilots_bp) 

862 

863 from users.routes import users_bp 

864 

865 app.register_blueprint(users_bp) 

866 

867 from reservations.routes import reservations_bp 

868 

869 app.register_blueprint(reservations_bp) 

870 

871 from squawk.routes import squawk_bp 

872 

873 app.register_blueprint(squawk_bp) 

874 

875 from hangar.routes import hangar_bp 

876 

877 app.register_blueprint(hangar_bp) 

878 

879 from airworthiness.routes import airworthiness_bp 

880 

881 app.register_blueprint(airworthiness_bp) 

882 

883 from pwa.routes import pwa_bp 

884 

885 app.register_blueprint(pwa_bp) 

886 

887 from offline.routes import offline_bp 

888 

889 app.register_blueprint(offline_bp) 

890 

891 if flask_env == "demo": 

892 from demo.routes import demo_bp 

893 

894 app.register_blueprint(demo_bp) 

895 

896 def _current_theme( 

897 user_obj: Any, in_request: bool, sess: Any, is_demo: bool 

898 ) -> str: 

899 if in_request and user_obj and not is_demo and not sess.get("demo_slot_id"): 

900 t = getattr(user_obj, "theme", None) 

901 if t in ("light", "dark"): 

902 return str(t) 

903 if in_request: 

904 t = sess.get("theme") 

905 if t in ("light", "dark", "system"): 

906 return str(t) 

907 return "system" 

908 

909 @app.context_processor 

910 def inject_globals() -> dict[str, Any]: 

911 from models import DemoSlot, Role, TenantProfile, TenantUser, User 

912 from utils import ( 

913 check_legacy_logbook_data, 

914 check_update_available, 

915 current_user_role, 

916 ) 

917 

918 is_demo = flask_env == "demo" 

919 demo_next_wipe_utc = ( 

920 os.environ.get("OPENHANGAR_DEMO_NEXT_WIPE_UTC") if is_demo else None 

921 ) 

922 repo_url = os.environ.get( 

923 "OPENHANGAR_REPO_URL", "https://github.com/e2jk/OpenHangar" 

924 ) 

925 _in_request = has_request_context() 

926 demo_display_id = None 

927 if is_demo and _in_request: 

928 slot_id = session.get("demo_slot_id") 

929 if slot_id: 

930 slot = db.session.get(DemoSlot, slot_id) 

931 if slot: 

932 demo_display_id = slot.display_id 

933 role = current_user_role() if _in_request else None 

934 # Phase 23: is_pilot/is_maint also enabled by per-user capability flags 

935 uid = session.get("user_id") if _in_request else None 

936 _user_flags = db.session.get(User, uid) if uid else None 

937 _flag_pilot = bool(_user_flags and _user_flags.is_pilot) 

938 _flag_maint = bool(_user_flags and _user_flags.is_maintenance) 

939 

940 # Phase 26: adaptive UI based on TenantProfile 

941 _tenant_profile = None 

942 _has_renter_account = False 

943 if uid: 

944 tu = TenantUser.query.filter_by(user_id=uid).first() 

945 if tu: 

946 _tenant_profile = TenantProfile.query.filter_by( 

947 tenant_id=tu.tenant_id 

948 ).first() 

949 if _tenant_profile and _tenant_profile.allows_rental: 

950 # Phase 37e: nav entry only when there's something to see. 

951 from models import BillingAccount, BillingAccountKind, LedgerEntry 

952 

953 _renter_account = BillingAccount.query.filter_by( 

954 tenant_id=tu.tenant_id, 

955 user_id=uid, 

956 kind=BillingAccountKind.RENTER, 

957 ).first() 

958 _has_renter_account = bool( 

959 _renter_account 

960 and LedgerEntry.query.filter_by( 

961 account_id=_renter_account.id 

962 ).first() 

963 is not None 

964 ) 

965 _pac = ( 

966 _tenant_profile.planned_aircraft_count 

967 if _tenant_profile and _tenant_profile.planned_aircraft_count is not None 

968 else None 

969 ) 

970 # logbook_only: planned_aircraft_count == 0 → hide all aircraft UI 

971 _logbook_only = _pac == 0 

972 # single_aircraft_mode: planned_aircraft_count == 1 → hide fleet-level widgets 

973 _single_aircraft_mode = _pac == 1 

974 

975 # EE-09/EE-10: aviation history day + personal anniversary banners 

976 _banners = _compute_banners(uid) 

977 from datetime import date as _date 

978 

979 _is_owner = role in (Role.ADMIN, Role.OWNER) 

980 _nav_update_available = ( 

981 check_update_available() 

982 if _is_owner and _in_request and flask_env == "production" 

983 else False 

984 ) 

985 _legacy_logbook_data_present = ( 

986 check_legacy_logbook_data() if _is_owner and _in_request else False 

987 ) 

988 if uid: 

989 from flights.crew_invites import ( 

990 pending_claims_to_review, 

991 pending_invite_count, 

992 ) 

993 from flights.shared_flight import pending_suggestions_to_review 

994 

995 _nav_crew_invite_count = ( 

996 pending_invite_count(uid) 

997 + len(pending_claims_to_review(uid)) 

998 + len(pending_suggestions_to_review(uid)) 

999 ) 

1000 else: 

1001 _nav_crew_invite_count = 0 

1002 

1003 return { 

1004 "logged_in": bool(uid), 

1005 "has_users": User.query.count() > 0, 

1006 "flask_env": flask_env, 

1007 "is_demo": is_demo, 

1008 "demo_next_wipe_utc": demo_next_wipe_utc, 

1009 "demo_display_id": demo_display_id, 

1010 "repo_url": repo_url, 

1011 "current_locale": str(_babel_get_locale()), 

1012 "supported_locales": SUPPORTED_LOCALES, 

1013 "locale_meta": LOCALE_META, 

1014 "current_role": role, 

1015 "is_owner": _is_owner, 

1016 "is_pilot": role in (Role.ADMIN, Role.OWNER, Role.PILOT, Role.INSTRUCTOR) 

1017 or _flag_pilot, 

1018 "is_maint": role 

1019 in (Role.ADMIN, Role.OWNER, Role.MAINTENANCE, Role.INSTRUCTOR) 

1020 or _flag_maint, 

1021 "is_crew": role not in (None, Role.VIEWER), 

1022 "nav_user_label": (_user_flags.name or _user_flags.email) 

1023 if _user_flags 

1024 else None, 

1025 "tenant_profile": _tenant_profile, 

1026 "allows_rental": bool(_tenant_profile and _tenant_profile.allows_rental), 

1027 "has_renter_account": _has_renter_account, 

1028 "logbook_only": _logbook_only, 

1029 "single_aircraft_mode": _single_aircraft_mode, 

1030 "aircraft_count_goal": _pac, 

1031 "aviation_day_banner": _banners["aviation_day_banner"], 

1032 "pilot_anniversary": _banners["pilot_anniversary"], 

1033 "pilot_anniversary_confetti": _banners["pilot_anniversary_confetti"], 

1034 "today": _date.today(), 

1035 "current_theme": _current_theme(_user_flags, _in_request, session, is_demo), 

1036 "nav_update_available": _nav_update_available, 

1037 "nav_crew_invite_count": _nav_crew_invite_count, 

1038 "legacy_logbook_data_present": _legacy_logbook_data_present, 

1039 "oh_debug": app.debug 

1040 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower() 

1041 not in ("1", "true", "yes"), 

1042 } 

1043 

1044 @app.errorhandler(403) 

1045 def forbidden(e: Exception) -> ResponseReturnValue: 

1046 return render_template("errors/403.html"), 403 

1047 

1048 @app.errorhandler(404) 

1049 def not_found(e: Exception) -> ResponseReturnValue: 

1050 return render_template("errors/404.html"), 404 

1051 

1052 @app.errorhandler(413) 

1053 def payload_too_large(e: Exception) -> ResponseReturnValue: 

1054 # Typically a multi-file upload (e.g. a season of GPS data logs) 

1055 # over MAX_CONTENT_LENGTH in total, each file being within its own 

1056 # per-file limit — send the user back to the form with a reason 

1057 # instead of a bare error page. 

1058 from flask import flash, redirect, url_for 

1059 from flask_babel import gettext as _ 

1060 

1061 max_mb = round((app.config.get("MAX_CONTENT_LENGTH") or 0) / (1024 * 1024)) 

1062 flash( 

1063 _( 

1064 "This upload is too large (maximum %(mb)s MB in total). " 

1065 "Please upload the files in smaller batches.", 

1066 mb=max_mb, 

1067 ), 

1068 "danger", 

1069 ) 

1070 # Back to the submitting page — on this host only, and as a relative 

1071 # path (same guard as the language switcher's ?next=). 

1072 ref = urlparse(request.referrer or "") 

1073 back = "" 

1074 if ref.netloc == request.host: 

1075 back = ref.path + (f"?{ref.query}" if ref.query else "") 

1076 back = back.replace("\\", "") # browsers treat \ as /; strip before parsing 

1077 parsed_back = urlparse(back) 

1078 if ( 

1079 not back 

1080 or parsed_back.netloc 

1081 or parsed_back.scheme 

1082 or not back.startswith("/") 

1083 or back.startswith("//") 

1084 ): 

1085 back = url_for("index") 

1086 return redirect(back) 

1087 

1088 @app.errorhandler(500) 

1089 def internal_error(e: Exception) -> ResponseReturnValue: 

1090 import traceback as _tb 

1091 

1092 show_debug = flask_env in ("development", "test") 

1093 exc_type = type(e).__name__ 

1094 exc_value = str(e) 

1095 tb = _tb.format_exc() if show_debug else None 

1096 return ( 

1097 render_template( 

1098 "errors/500.html", 

1099 show_debug=show_debug, 

1100 env=flask_env, 

1101 exc_type=exc_type, 

1102 exc_value=exc_value, 

1103 tb=tb, 

1104 ), 

1105 500, 

1106 ) 

1107 

1108 @app.route("/") 

1109 def index() -> ResponseReturnValue: 

1110 from models import TenantUser, User 

1111 

1112 # Demo mode: unauthenticated visitors always see the landing page 

1113 if flask_env == "demo" and not session.get("user_id"): 

1114 return render_template("landing.html") 

1115 

1116 if User.query.count() == 0: 

1117 return render_template("landing.html") 

1118 if session.get("user_id"): 

1119 from datetime import date as _date 

1120 

1121 from models import ( 

1122 Aircraft, 

1123 AircraftPhoto, 

1124 Flight, 

1125 MaintenanceTrigger, 

1126 Snag, 

1127 ) 

1128 from utils import accessible_aircraft, compute_aircraft_statuses 

1129 

1130 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first() 

1131 aircraft = accessible_aircraft(tu.tenant_id).all() if tu else [] 

1132 aircraft_ids = [ac.id for ac in aircraft] 

1133 hobbs_by_aircraft = Aircraft.engine_hours_by_id(aircraft_ids) 

1134 landings_by_aircraft = Aircraft.landings_by_id(aircraft_ids) 

1135 flight_hours_by_aircraft = Aircraft.flight_hours_by_id(aircraft_ids) 

1136 cover_photos = ( 

1137 { 

1138 p.aircraft_id: p 

1139 for p in AircraftPhoto.query.filter( 

1140 AircraftPhoto.aircraft_id.in_(aircraft_ids), 

1141 AircraftPhoto.sort_order == 1, 

1142 ).all() 

1143 } 

1144 if aircraft_ids 

1145 else {} 

1146 ) 

1147 

1148 recent_flights = ( 

1149 ( 

1150 Flight.query.filter(Flight.aircraft_id.in_(aircraft_ids)) 

1151 .order_by(Flight.date.desc(), Flight.id.desc()) 

1152 .limit(5) 

1153 .all() 

1154 ) 

1155 if aircraft_ids 

1156 else [] 

1157 ) 

1158 

1159 today = _date.today() 

1160 month_start = today.replace(day=1) 

1161 month_flights = ( 

1162 ( 

1163 Flight.query.filter( 

1164 Flight.aircraft_id.in_(aircraft_ids), 

1165 Flight.date >= month_start, 

1166 ).all() 

1167 ) 

1168 if aircraft_ids 

1169 else [] 

1170 ) 

1171 hours_this_month = sum( 

1172 float(f.flight_time) 

1173 if f.flight_time is not None 

1174 else float(f.flight_time_counter_end) 

1175 - float(f.flight_time_counter_start) 

1176 for f in month_flights 

1177 if f.flight_time is not None 

1178 or ( 

1179 f.flight_time_counter_end is not None 

1180 and f.flight_time_counter_start is not None 

1181 ) 

1182 ) 

1183 flights_this_month = len(month_flights) 

1184 

1185 triggers = ( 

1186 ( 

1187 MaintenanceTrigger.query.filter( 

1188 MaintenanceTrigger.aircraft_id.in_(aircraft_ids) 

1189 ).all() 

1190 ) 

1191 if aircraft_ids 

1192 else [] 

1193 ) 

1194 

1195 aircraft_status = compute_aircraft_statuses( 

1196 aircraft, 

1197 triggers, 

1198 hobbs_by_aircraft, 

1199 landings_by_aircraft, 

1200 flight_hours_by_aircraft, 

1201 ) 

1202 

1203 urgent_maintenance = [] 

1204 maintenance_alerts = 0 

1205 ac_by_id = {ac.id: ac for ac in aircraft} 

1206 for t in triggers: 

1207 s = t.status( 

1208 current_engine_hours=hobbs_by_aircraft.get(t.aircraft_id), 

1209 current_landings=landings_by_aircraft.get(t.aircraft_id), 

1210 current_flight_hours=flight_hours_by_aircraft.get(t.aircraft_id), 

1211 ) 

1212 if s in ("overdue", "due_soon"): 

1213 maintenance_alerts += 1 

1214 urgent_maintenance.append((t, s, ac_by_id[t.aircraft_id])) 

1215 urgent_maintenance.sort(key=lambda x: 0 if x[1] == "overdue" else 1) 

1216 urgent_maintenance = urgent_maintenance[:5] 

1217 

1218 # Collect grounding snags across the fleet (sorted: grounding first, then by date) 

1219 open_grounding = ( 

1220 ( 

1221 Snag.query.filter( 

1222 Snag.aircraft_id.in_(aircraft_ids), 

1223 Snag.is_grounding.is_(True), 

1224 Snag.resolved_at.is_(None), 

1225 ) 

1226 .order_by(Snag.reported_at.desc()) 

1227 .all() 

1228 ) 

1229 if aircraft_ids 

1230 else [] 

1231 ) 

1232 grounding_snags = [(s, ac_by_id[s.aircraft_id]) for s in open_grounding] 

1233 open_other_snags = ( 

1234 Snag.query.filter( 

1235 Snag.aircraft_id.in_(aircraft_ids), 

1236 Snag.is_grounding.is_(False), 

1237 Snag.resolved_at.is_(None), 

1238 ).count() 

1239 if aircraft_ids 

1240 else 0 

1241 ) 

1242 

1243 from models import PilotProfile 

1244 from pilots.currency import currency_summary as _currency_summary 

1245 from sqlalchemy import ( 

1246 or_ as _or_dash, # pyright: ignore[reportMissingImports] 

1247 ) 

1248 

1249 pilot_profile = PilotProfile.query.filter_by( 

1250 user_id=session["user_id"] 

1251 ).first() 

1252 _mine_dash = _or_dash( 

1253 Flight.pic_user_id == session["user_id"], 

1254 Flight.second_crew_user_id == session["user_id"], 

1255 ) 

1256 pilot_entries = ( 

1257 Flight.query.filter(_mine_dash).all() if pilot_profile else [] 

1258 ) 

1259 pilot_currency = _currency_summary(pilot_profile, pilot_entries, today) 

1260 

1261 recent_pilot_entries = ( 

1262 sorted(pilot_entries, key=lambda e: (e.date, e.id), reverse=True)[:5] 

1263 if not aircraft_ids 

1264 else [] 

1265 ) 

1266 

1267 from flask import url_for as _url_for_dash 

1268 

1269 track_entries = ( 

1270 Flight.query.filter(_mine_dash) 

1271 .filter(Flight.gps_track_id.isnot(None)) 

1272 .order_by(Flight.date.asc()) 

1273 .all() 

1274 if pilot_profile 

1275 else [] 

1276 ) 

1277 dash_track_rows = [ 

1278 { 

1279 "date": str(e.date), 

1280 "dep": e.departure_icao or "", 

1281 "arr": e.arrival_icao or "", 

1282 "time_str": f"{e.total_flight_time} h" 

1283 if e.total_flight_time is not None 

1284 else "", 

1285 "view_url": _url_for_dash( 

1286 "aircraft.flight_detail", 

1287 aircraft_id=e.aircraft_id, 

1288 flight_id=e.id, 

1289 ) 

1290 if e.aircraft_id 

1291 else _url_for_dash("pilots.view_entry", entry_id=e.id), 

1292 "geojson": e.gps_track.geojson if e.gps_track else None, 

1293 } 

1294 for e in track_entries 

1295 ] 

1296 from models import AppSetting as _AppSetting 

1297 

1298 _openaip_s = db.session.get(_AppSetting, "openaip_api_key") 

1299 openaip_key = _openaip_s.value if _openaip_s and _openaip_s.value else None 

1300 

1301 # ── Reservation stat card + pending approval queue ──────────────── 

1302 import calendar as _cal 

1303 from collections import defaultdict 

1304 from datetime import datetime as _dt 

1305 from datetime import timedelta 

1306 

1307 from models import Reservation, ReservationStatus, Role 

1308 from utils import current_user_role 

1309 

1310 _role = current_user_role() 

1311 today_utc = _dt.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) 

1312 pending_reservations = ( 

1313 ( 

1314 Reservation.query.filter( 

1315 Reservation.aircraft_id.in_(aircraft_ids), 

1316 Reservation.status == ReservationStatus.PENDING, 

1317 Reservation.start_dt >= today_utc, 

1318 ) 

1319 .order_by(Reservation.start_dt) 

1320 .all() 

1321 ) 

1322 if aircraft_ids and _role in (Role.ADMIN, Role.OWNER) 

1323 else [] 

1324 ) 

1325 res_7d = ( 

1326 Reservation.query.filter( 

1327 Reservation.aircraft_id.in_(aircraft_ids), 

1328 Reservation.start_dt >= today_utc, 

1329 Reservation.start_dt < today_utc + timedelta(days=7), 

1330 ).count() 

1331 if aircraft_ids 

1332 else 0 

1333 ) 

1334 res_30d = ( 

1335 Reservation.query.filter( 

1336 Reservation.aircraft_id.in_(aircraft_ids), 

1337 Reservation.start_dt >= today_utc, 

1338 Reservation.start_dt < today_utc + timedelta(days=30), 

1339 ).count() 

1340 if aircraft_ids 

1341 else 0 

1342 ) 

1343 

1344 # ── Fleet calendar widget ───────────────────────────────────────── 

1345 try: 

1346 cal_year = int(request.args.get("cal_year", today.year)) 

1347 cal_month = int(request.args.get("cal_month", today.month)) 

1348 except ValueError: 

1349 cal_year, cal_month = today.year, today.month 

1350 if cal_month < 1: 

1351 cal_year -= 1 

1352 cal_month = 12 

1353 if cal_month > 12: 

1354 cal_year += 1 

1355 cal_month = 1 

1356 

1357 cal_month_start = _dt(cal_year, cal_month, 1, tzinfo=UTC) 

1358 cal_last_day = _cal.monthrange(cal_year, cal_month)[1] 

1359 cal_month_end = _dt( 

1360 cal_year, cal_month, cal_last_day, 23, 59, 59, tzinfo=UTC 

1361 ) 

1362 

1363 cal_reservations = ( 

1364 Reservation.query.filter( 

1365 Reservation.aircraft_id.in_(aircraft_ids), 

1366 Reservation.status != ReservationStatus.CANCELLED, 

1367 Reservation.start_dt <= cal_month_end, 

1368 Reservation.end_dt >= cal_month_start, 

1369 ) 

1370 .order_by(Reservation.start_dt) 

1371 .all() 

1372 if aircraft_ids 

1373 else [] 

1374 ) 

1375 

1376 cal_flights = ( 

1377 Flight.query.filter( 

1378 Flight.aircraft_id.in_(aircraft_ids), 

1379 Flight.date >= cal_month_start.date(), 

1380 Flight.date <= cal_month_end.date(), 

1381 ) 

1382 .order_by(Flight.date) 

1383 .all() 

1384 if aircraft_ids 

1385 else [] 

1386 ) 

1387 

1388 cal_day_events: dict[Any, Any] = defaultdict( 

1389 lambda: {"reservations": [], "flights": []} 

1390 ) 

1391 for r in cal_reservations: 

1392 cur = r.start_dt.date() 

1393 end = r.end_dt.date() 

1394 while cur <= end: 

1395 if cur.month == cal_month and cur.year == cal_year: 

1396 cal_day_events[cur]["reservations"].append(r) 

1397 cur += timedelta(days=1) 

1398 for f in cal_flights: 

1399 cal_day_events[f.date]["flights"].append(f) 

1400 

1401 cal_weeks = _cal.Calendar(firstweekday=0).monthdatescalendar( 

1402 cal_year, cal_month 

1403 ) 

1404 cal_prev_month = cal_month - 1 or 12 

1405 cal_prev_year = cal_year - 1 if cal_month == 1 else cal_year 

1406 cal_next_month = cal_month % 12 + 1 

1407 cal_next_year = cal_year + 1 if cal_month == 12 else cal_year 

1408 cal_month_name = _dt(cal_year, cal_month, 1).strftime("%B %Y") 

1409 

1410 from flights.crew_invites import ( 

1411 pending_claims_to_review, 

1412 pending_invites_for_user, 

1413 ) 

1414 from flights.shared_flight import pending_suggestions_to_review 

1415 from models import CrewRole 

1416 

1417 return render_template( 

1418 "dashboard.html", 

1419 crew_invites=pending_invites_for_user(session["user_id"]), 

1420 crew_claims=pending_claims_to_review(session["user_id"]), 

1421 flight_corrections=pending_suggestions_to_review(session["user_id"]), 

1422 crew_roles=CrewRole, 

1423 aircraft=aircraft, 

1424 cover_photos=cover_photos, 

1425 pending_reservations=pending_reservations, 

1426 recent_flights=recent_flights, 

1427 recent_pilot_entries=recent_pilot_entries, 

1428 dash_track_rows=dash_track_rows, 

1429 openaip_key=openaip_key, 

1430 hours_this_month=hours_this_month, 

1431 flights_this_month=flights_this_month, 

1432 maintenance_alerts=maintenance_alerts, 

1433 urgent_maintenance=urgent_maintenance, 

1434 grounding_snags=grounding_snags, 

1435 open_other_snags=open_other_snags, 

1436 aircraft_status=aircraft_status, 

1437 triggers=triggers, 

1438 pilot_currency=pilot_currency, 

1439 today=today, 

1440 res_7d=res_7d, 

1441 res_30d=res_30d, 

1442 cal_weeks=cal_weeks, 

1443 cal_day_events=cal_day_events, 

1444 cal_month_name=cal_month_name, 

1445 cal_year=cal_year, 

1446 cal_month=cal_month, 

1447 cal_prev_year=cal_prev_year, 

1448 cal_prev_month=cal_prev_month, 

1449 cal_next_year=cal_next_year, 

1450 cal_next_month=cal_next_month, 

1451 ReservationStatus=ReservationStatus, 

1452 ) 

1453 return render_template("welcome.html") 

1454 

1455 @app.route("/not-yet-implemented") 

1456 def not_yet_implemented() -> ResponseReturnValue: 

1457 feature = request.args.get("feature", "This feature") 

1458 return render_template("not_yet_implemented.html", feature=feature), 501 

1459 

1460 @app.route("/set-language/<lang>") 

1461 def set_language(lang: str) -> ResponseReturnValue: 

1462 from flask import abort, redirect 

1463 

1464 if lang not in SUPPORTED_LOCALES: 

1465 abort(400) 

1466 if session.get("user_id") and not session.get("demo_slot_id"): 

1467 from models import User 

1468 

1469 user = db.session.get(User, session["user_id"]) 

1470 if user: 

1471 user.language = lang 

1472 db.session.commit() 

1473 else: 

1474 session["language"] = ( 

1475 lang # stale user_id (e.g. setup wizard) — fall back to session 

1476 ) 

1477 else: 

1478 session["language"] = lang 

1479 next_url = request.args.get("next", "").strip() 

1480 next_url = next_url.replace( 

1481 "\\", "" 

1482 ) # browsers treat \ as /; strip before parsing 

1483 parsed_next = urlparse(next_url) 

1484 if ( 

1485 not next_url 

1486 or parsed_next.netloc 

1487 or parsed_next.scheme 

1488 or not next_url.startswith("/") 

1489 or next_url.startswith("//") 

1490 ): 

1491 next_url = "/" 

1492 return redirect(next_url) 

1493 

1494 @app.route("/set-theme/<theme>") 

1495 def set_theme(theme: str) -> ResponseReturnValue: 

1496 from flask import abort, redirect 

1497 

1498 if theme not in ("light", "dark", "system"): 

1499 abort(400) 

1500 if session.get("user_id") and not session.get("demo_slot_id"): 

1501 from models import User 

1502 

1503 user = db.session.get(User, session["user_id"]) 

1504 if user: 

1505 user.theme = None if theme == "system" else theme 

1506 db.session.commit() 

1507 else: 

1508 session["theme"] = theme 

1509 else: 

1510 session["theme"] = theme 

1511 next_url = request.args.get("next", "").strip() 

1512 next_url = next_url.replace("\\", "") 

1513 parsed_next = urlparse(next_url) 

1514 if ( 

1515 not next_url 

1516 or parsed_next.netloc 

1517 or parsed_next.scheme 

1518 or not next_url.startswith("/") 

1519 or next_url.startswith("//") 

1520 ): 

1521 next_url = "/" 

1522 return redirect(next_url) 

1523 

1524 @app.route("/robots.txt") 

1525 def robots_txt() -> ResponseReturnValue: 

1526 return send_from_directory( 

1527 app.static_folder or "static", "robots.txt", mimetype="text/plain" 

1528 ) 

1529 

1530 @app.route("/favicon.ico") 

1531 def favicon() -> ResponseReturnValue: 

1532 return send_from_directory( 

1533 app.static_folder or "static", "favicon.svg", mimetype="image/svg+xml" 

1534 ) 

1535 

1536 @app.route("/health") 

1537 def health() -> ResponseReturnValue: 

1538 # Liveness probe: proves the worker is up and routing. Deliberately does 

1539 # NOT touch the database — a liveness check must not fail (and trigger a 

1540 # restart) just because a dependency is down. See /health/ready below. 

1541 return {"status": "ok"}, 200 

1542 

1543 @app.route("/health/ready") 

1544 def health_ready() -> ResponseReturnValue: 

1545 # Readiness probe: confirms the database is reachable. Reserved for the 

1546 # in-container Docker healthcheck (curl localhost:5000); public callers 

1547 # arrive via Traefik with a non-loopback remote_addr (ProxyFix x_for=1), 

1548 # so they get a 404 and the endpoint stays hidden and unabusable. The 

1549 # check itself is a single cheap "SELECT 1". 

1550 from flask import abort as _abort 

1551 from sqlalchemy import text as _text 

1552 from sqlalchemy.exc import SQLAlchemyError 

1553 

1554 if request.remote_addr not in ("127.0.0.1", "::1"): 

1555 _abort(404) 

1556 try: 

1557 db.session.execute(_text("SELECT 1")) 

1558 except SQLAlchemyError: 

1559 db.session.rollback() 

1560 return {"status": "degraded", "database": "down"}, 503 

1561 return {"status": "ready"}, 200 

1562 

1563 @app.cli.command("check-empty-db") 

1564 def check_empty_db_command() -> None: 

1565 """Exit 0 if the database has no user data, 1 if it does (restore safety check).""" 

1566 import sys 

1567 

1568 from models import User # pyright: ignore[reportMissingImports] 

1569 from sqlalchemy.exc import ( 

1570 ProgrammingError, # pyright: ignore[reportMissingImports] 

1571 ) 

1572 

1573 try: 

1574 count = User.query.count() 

1575 except ProgrammingError: 

1576 # Schema not initialised (table missing) — treat as empty. 

1577 print("Database is empty.") 

1578 return 

1579 if count == 0: 

1580 print("Database is empty.") 

1581 else: 

1582 print(f"Database has {count} user(s) — not empty.", file=sys.stderr) 

1583 sys.exit(1) 

1584 

1585 @app.cli.command("restore-backup") 

1586 @click.argument("archive_path") 

1587 def restore_backup_command(archive_path: str) -> None: 

1588 """Restore a backup archive into the current empty database.""" 

1589 import io 

1590 import sys 

1591 import zipfile 

1592 

1593 from flask import current_app # pyright: ignore[reportMissingImports] 

1594 from models import User # pyright: ignore[reportMissingImports] 

1595 from services.backup_format import ( # pyright: ignore[reportMissingImports] 

1596 BackupArchiveError, 

1597 parse_backup_archive, 

1598 ) 

1599 

1600 # ── safety: refuse if DB already has data ───────────────────────────── 

1601 if User.query.count() > 0: 

1602 print( 

1603 "ERROR: Database is not empty. Restore refused to prevent data loss.", 

1604 file=sys.stderr, 

1605 ) 

1606 sys.exit(1) 

1607 

1608 # ── decrypt + extract ───────────────────────────────────────────────── 

1609 with open(archive_path, "rb") as fh: 

1610 payload = fh.read() 

1611 

1612 # Use only OPENHANGAR_RESTORE_ENCRYPTION_KEY for decryption — never fall 

1613 # back to OPENHANGAR_BACKUP_ENCRYPTION_KEY, which may be set to a 

1614 # different key (e.g. the dev backup key) and would silently fail with a 

1615 # wrong-key decryption error instead of prompting for the correct key. 

1616 encryption_key_raw = os.environ.get("OPENHANGAR_RESTORE_ENCRYPTION_KEY", "") 

1617 if encryption_key_raw: 

1618 from config.routes import ( 

1619 _derive_key, # pyright: ignore[reportMissingImports] 

1620 ) 

1621 from cryptography.hazmat.primitives.ciphers.aead import ( 

1622 AESGCM, # pyright: ignore[reportMissingImports] 

1623 ) 

1624 

1625 key = _derive_key(encryption_key_raw) 

1626 nonce, ct = payload[:12], payload[12:] 

1627 try: 

1628 zip_bytes = AESGCM(key).decrypt(nonce, ct, None) 

1629 except Exception as exc: # noqa: BLE001 -- wrong key surfaces as various crypto/zip errors 

1630 print(f"ERROR: Decryption failed — wrong key? ({exc})", file=sys.stderr) 

1631 sys.exit(1) 

1632 else: 

1633 if str(archive_path).endswith(".enc"): 

1634 print( 

1635 "ERROR: Archive is encrypted (.enc) but no decryption key is available.\n" 

1636 " Set OPENHANGAR_RESTORE_ENCRYPTION_KEY (recommended for cross-\n" 

1637 " environment restores) or use the restore script's --key-file\n" 

1638 " option or interactive prompt.", 

1639 file=sys.stderr, 

1640 ) 

1641 sys.exit(1) 

1642 zip_bytes = payload 

1643 

1644 try: 

1645 metadata, sql_bytes, upload_entries = parse_backup_archive(zip_bytes) 

1646 except BackupArchiveError as exc: 

1647 print(f"ERROR: {exc}", file=sys.stderr) 

1648 sys.exit(1) 

1649 

1650 # ── version check ───────────────────────────────────────────────────── 

1651 backup_alembic = metadata.get("alembic_head") or "unknown" 

1652 backup_version = metadata.get("app_version", "unknown") 

1653 current_version = os.environ.get("OPENHANGAR_VERSION", "development") 

1654 print(f"Backup: version={backup_version} alembic={backup_alembic}") 

1655 print(f"Current: version={current_version}") 

1656 

1657 if backup_alembic != "unknown": 

1658 try: 

1659 from alembic.script import ( 

1660 ScriptDirectory, # pyright: ignore[reportMissingImports] 

1661 ) 

1662 from flask_migrate import ( 

1663 Migrate as _Migrate, # pyright: ignore[reportMissingImports] 

1664 ) 

1665 

1666 _m = _Migrate(current_app, db) 

1667 scripts = ScriptDirectory.from_config(_m.get_config()) 

1668 known = {s.revision for s in scripts.walk_revisions()} 

1669 if backup_alembic not in known: 

1670 print( 

1671 f"ERROR: Backup Alembic revision '{backup_alembic}' is not in " 

1672 "this container's migration chain. Restore a container version " 

1673 "that knows this migration.", 

1674 file=sys.stderr, 

1675 ) 

1676 sys.exit(1) 

1677 except Exception as exc: # noqa: BLE001 -- best-effort compatibility check, continue restore either way 

1678 print(f"WARNING: Could not verify Alembic compatibility: {exc}") 

1679 

1680 # ── drop schema + restore SQL dump ──────────────────────────────────── 

1681 database_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") 

1682 if not database_url.startswith("postgresql"): 

1683 print( 

1684 f"ERROR: Only PostgreSQL is supported for restore (got: {database_url!r})", 

1685 file=sys.stderr, 

1686 ) 

1687 sys.exit(1) 

1688 

1689 print( 

1690 "Dropping existing schema and restoring from backup (this may take a minute)..." 

1691 ) 

1692 try: 

1693 _drop_and_restore_schema(database_url, sql_bytes) 

1694 except RuntimeError as exc: 

1695 print(f"ERROR: psql restore failed:\n{exc}", file=sys.stderr) 

1696 sys.exit(1) 

1697 

1698 # ── restore uploaded files ──────────────────────────────────────────── 

1699 # The DB has just been replaced, so any files already on disk are now 

1700 # orphaned. Before clearing, snapshot them into a dated zip in the 

1701 # backup folder so nothing is silently destroyed. 

1702 import shutil as _shutil 

1703 

1704 upload_folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

1705 backup_folder = current_app.config.get("BACKUP_FOLDER", "/data/backups") 

1706 os.makedirs(upload_folder, exist_ok=True) 

1707 os.makedirs(backup_folder, exist_ok=True) 

1708 

1709 _existing = [ 

1710 (dp, f) for dp, _dirs, files in os.walk(upload_folder) for f in files 

1711 ] 

1712 if _existing: 

1713 from datetime import datetime 

1714 

1715 _snap_ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") 

1716 _enc_key_raw = _env_or_file("BACKUP_ENCRYPTION_KEY") 

1717 _snap_ext = ".zip.enc" if _enc_key_raw else ".zip" 

1718 _snap_name = f"uploads_pre_restore_{_snap_ts}{_snap_ext}" 

1719 _snap_path = os.path.join(backup_folder, _snap_name) 

1720 

1721 _snap_buf = io.BytesIO() 

1722 with zipfile.ZipFile(_snap_buf, "w", zipfile.ZIP_DEFLATED) as _zf: 

1723 for _dp, _fn in _existing: 

1724 _fp = os.path.join(_dp, _fn) 

1725 _rel = os.path.relpath(_fp, upload_folder) 

1726 _zf.write(_fp, arcname=_rel) 

1727 _snap_bytes = _snap_buf.getvalue() 

1728 

1729 if _enc_key_raw: 

1730 from config.routes import ( # pyright: ignore[reportMissingImports] 

1731 _derive_key, 

1732 _encrypt_bytes, 

1733 ) 

1734 

1735 _snap_bytes = _encrypt_bytes(_snap_bytes, _derive_key(_enc_key_raw)) 

1736 

1737 with open(_snap_path, "wb") as _fh: 

1738 _fh.write(_snap_bytes) 

1739 

1740 print( 

1741 f"WARNING: {len(_existing)} pre-existing file(s) found in the upload folder.\n" 

1742 f" They have been snapshotted to:\n" 

1743 f" {_snap_path}\n" 

1744 f" {'(encrypted with OPENHANGAR_BACKUP_ENCRYPTION_KEY) ' if _enc_key_raw else ''}" 

1745 f"The upload folder will now be cleared." 

1746 ) 

1747 

1748 for _item in os.scandir(upload_folder): 

1749 if _item.is_dir(): 

1750 _shutil.rmtree(_item.path, ignore_errors=True) 

1751 else: 

1752 os.unlink(_item.path) 

1753 

1754 if upload_entries: 

1755 upload_root = os.path.realpath(upload_folder) 

1756 with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf: 

1757 for entry in upload_entries: 

1758 # entry is e.g. "uploads/tenant/OO-REG/photos/01-abc.jpg" 

1759 rel = entry[len("uploads/") :] 

1760 if not rel: 

1761 continue # skip the bare "uploads/" directory entry 

1762 dest = os.path.join(upload_folder, rel) 

1763 dest_real = os.path.realpath(dest) 

1764 if dest_real != upload_root and not dest_real.startswith( 

1765 upload_root + os.sep 

1766 ): 

1767 print( 

1768 f"ERROR: Archive entry {entry!r} resolves outside the " 

1769 "upload folder — refusing to restore (corrupted or " 

1770 "tampered archive).", 

1771 file=sys.stderr, 

1772 ) 

1773 sys.exit(1) 

1774 os.makedirs(os.path.dirname(dest), exist_ok=True) 

1775 with open(dest, "wb") as fh: 

1776 fh.write(zf.read(entry)) 

1777 print(f"Restored {len(upload_entries)} uploaded file(s).") 

1778 else: 

1779 print("Backup contains no uploaded files; upload folder cleared.") 

1780 

1781 print("Restore complete.") 

1782 

1783 @app.cli.command("backup-now") 

1784 def backup_now_command() -> None: 

1785 import sys 

1786 

1787 from config.routes import run_backup 

1788 

1789 try: 

1790 record = run_backup() 

1791 print( 

1792 f"Backup OK: {record.filename} ({record.size_bytes} bytes, sha256={record.sha256})" 

1793 ) 

1794 except RuntimeError as exc: 

1795 print(f"Backup FAILED: {exc}") 

1796 sys.exit(1) 

1797 

1798 # Flask CLI command used by demo/refresh.sh to drop and recreate the schema. 

1799 # Only works in demo mode — production uses Alembic migrations. 

1800 @app.cli.command("reset-db") 

1801 def reset_db_command() -> None: 

1802 if flask_env != "demo": 

1803 print("reset-db is only available in demo mode. Aborting.") 

1804 return 

1805 from models import reset_schema 

1806 

1807 reset_schema(db) 

1808 print("Database schema reset.") 

1809 

1810 # Flask CLI command used by demo/refresh.sh to wipe and reseed demo slots 

1811 @app.cli.command("seed-demo") 

1812 def seed_demo_command() -> None: 

1813 from demo_seed import seed as demo_seed 

1814 

1815 demo_seed() 

1816 print("Demo slots reseeded.") 

1817 

1818 # Re-apply env-var settings wiped by reset-db 

1819 openaip_key = _env_or_file("OPENAIP_API_KEY").strip() 

1820 if openaip_key: 

1821 from models import AppSetting 

1822 

1823 setting = db.session.get(AppSetting, "openaip_api_key") 

1824 if setting is None: 

1825 db.session.add(AppSetting(key="openaip_api_key", value=openaip_key)) 

1826 else: 

1827 setting.value = openaip_key 

1828 db.session.commit() 

1829 print("Environment settings applied.") 

1830 

1831 # Only run against a real PostgreSQL database (sqlite = dev/test), and only 

1832 # when called from a long-running server process — not from init scripts such 

1833 # as docker-init-db.py that run migrations before the schema exists. 

1834 if "sqlite" not in app.config.get( 

1835 "SQLALCHEMY_DATABASE_URI", "" 

1836 ) and not os.environ.get("OPENHANGAR_SKIP_BACKGROUND_THREADS"): 

1837 # Only a production deployment is ever actually upgraded in place, so 

1838 # only there does checking for updates mean anything. Demo wipes and 

1839 # recreates its schema every few hours (see demo/refresh.sh) -- 

1840 # checking there just adds a recurring app_settings race against 

1841 # reset-db's DROP/CREATE SCHEMA window -- and development/test runs 

1842 # are never the thing being "upgraded". 

1843 if flask_env == "production": 

1844 from services.version_service import ( 

1845 start_version_check_thread, # pyright: ignore[reportMissingImports] 

1846 ) 

1847 

1848 start_version_check_thread(app) 

1849 from sync_watcher import ( 

1850 start_sync_watcher, # pyright: ignore[reportMissingImports] 

1851 ) 

1852 

1853 start_sync_watcher(app) 

1854 if os.environ.get("OPENHANGAR_ENV", "production") == "production": 

1855 _start_easa_sync_scheduler(app) 

1856 _start_notification_scheduler(app) 

1857 from services.backup_scheduler import ( 

1858 start_backup_scheduler, # pyright: ignore[reportMissingImports] 

1859 ) 

1860 

1861 start_backup_scheduler(app) 

1862 import threading 

1863 

1864 from services.notification_service import ( 

1865 send_welcome_email_if_needed, # pyright: ignore[reportMissingImports] 

1866 ) 

1867 

1868 threading.Thread( 

1869 target=send_welcome_email_if_needed, 

1870 args=(app,), 

1871 daemon=True, 

1872 name="welcome-email", 

1873 ).start() 

1874 

1875 if ( 

1876 os.environ.get("WERKZEUG_RUN_MAIN") == "true" 

1877 and os.environ.get("OPENHANGAR_ENV", "production") == "development" 

1878 and os.environ.get("OPENHANGAR_SW_ENABLED", "").lower() in ("1", "true", "yes") 

1879 ): 

1880 print("OPENHANGAR_SW_ENABLED: service worker active in debug mode", flush=True) 

1881 

1882 _validate_config(app) 

1883 return app 

1884 

1885 

1886def _validate_config(app: Flask) -> None: 

1887 """Collect and report all configuration problems at once rather than one at a time.""" 

1888 errors: list[str] = [] 

1889 

1890 # OPENHANGAR_SECRET_KEY: minimum length (existence and placeholder already checked above) 

1891 secret = app.config.get("SECRET_KEY", "") 

1892 if secret and len(secret) < 32: 

1893 errors.append( 

1894 f"OPENHANGAR_SECRET_KEY is too short ({len(secret)} chars, minimum 32). " 

1895 "Generate one with: openssl rand -hex 32" 

1896 ) 

1897 

1898 # OPENHANGAR_ENV: must be one of the known values when set 

1899 _raw_env = os.environ.get("OPENHANGAR_ENV", "") 

1900 if _raw_env and _raw_env not in ("production", "development", "test", "demo"): 

1901 errors.append( 

1902 f"OPENHANGAR_ENV must be one of: production, development, test, demo " 

1903 f"(got {_raw_env!r})" 

1904 ) 

1905 

1906 # OPENHANGAR_MAX_UPLOAD_BYTES: must be a plain positive integer when set 

1907 _raw_max = os.environ.get("OPENHANGAR_MAX_UPLOAD_BYTES", "") 

1908 _validated_max: int | None = None 

1909 if _raw_max: 

1910 try: 

1911 _parsed = int(_raw_max) 

1912 if _parsed <= 0: 

1913 errors.append("OPENHANGAR_MAX_UPLOAD_BYTES must be a positive integer") 

1914 else: 

1915 _validated_max = _parsed 

1916 except ValueError: 

1917 errors.append( 

1918 f"OPENHANGAR_MAX_UPLOAD_BYTES must be a plain integer (bytes), got {_raw_max!r}. " 

1919 "Example: 52428800 for 50 MB." 

1920 ) 

1921 

1922 # OPENHANGAR_SYNC_SCAN_INTERVAL: must be a positive integer when set 

1923 _raw_interval = os.environ.get("OPENHANGAR_SYNC_SCAN_INTERVAL", "") 

1924 if _raw_interval: 

1925 try: 

1926 _parsed_interval = int(_raw_interval) 

1927 if _parsed_interval <= 0: 

1928 errors.append( 

1929 "OPENHANGAR_SYNC_SCAN_INTERVAL must be a positive integer (seconds)" 

1930 ) 

1931 except ValueError: 

1932 errors.append( 

1933 f"OPENHANGAR_SYNC_SCAN_INTERVAL must be a plain integer (seconds), got {_raw_interval!r}. " 

1934 "Example: 60" 

1935 ) 

1936 

1937 # OPENHANGAR_DATABASE_URL: production deployments must use PostgreSQL 

1938 db_url = app.config.get("SQLALCHEMY_DATABASE_URI", "") 

1939 flask_env = os.environ.get("OPENHANGAR_ENV", "production") 

1940 if ( 

1941 "sqlite" not in db_url 

1942 and flask_env not in ("development", "test") 

1943 and not db_url.startswith(("postgresql://", "postgresql+psycopg://")) 

1944 ): 

1945 scheme = db_url.split("://")[0] if "://" in db_url else db_url[:20] 

1946 errors.append( 

1947 f"OPENHANGAR_DATABASE_URL scheme {scheme!r} is not supported in production. " 

1948 "Use 'postgresql://'." 

1949 ) 

1950 

1951 # OPENHANGAR_BACKUP_ENCRYPTION_KEY / OPENHANGAR_RESTORE_ENCRYPTION_KEY: 

1952 # whitespace-only values are likely a misconfiguration. 

1953 enc_key = _env_or_file("BACKUP_ENCRYPTION_KEY") 

1954 if enc_key and not enc_key.strip(): 

1955 errors.append( 

1956 "OPENHANGAR_BACKUP_ENCRYPTION_KEY is set but contains only whitespace. " 

1957 "Either provide a real key or leave the variable unset." 

1958 ) 

1959 restore_enc_key = os.environ.get("OPENHANGAR_RESTORE_ENCRYPTION_KEY", "") 

1960 if restore_enc_key and not restore_enc_key.strip(): 

1961 errors.append( 

1962 "OPENHANGAR_RESTORE_ENCRYPTION_KEY is set but contains only whitespace. " 

1963 "Either provide a real key or leave the variable unset." 

1964 ) 

1965 

1966 # OPENHANGAR_SMTP_PORT: must be an integer in valid port range when set 

1967 _raw_smtp_port = os.environ.get("OPENHANGAR_SMTP_PORT", "") 

1968 if _raw_smtp_port: 

1969 try: 

1970 _smtp_port_val = int(_raw_smtp_port) 

1971 if not (1 <= _smtp_port_val <= 65535): 

1972 errors.append( 

1973 f"OPENHANGAR_SMTP_PORT must be between 1 and 65535, got {_raw_smtp_port!r}" 

1974 ) 

1975 except ValueError: 

1976 errors.append( 

1977 f"OPENHANGAR_SMTP_PORT must be an integer, got {_raw_smtp_port!r}" 

1978 ) 

1979 

1980 # OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES: must be a positive integer when set 

1981 _raw_busy = os.environ.get("OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES", "") 

1982 if _raw_busy: 

1983 try: 

1984 if int(_raw_busy) <= 0: 

1985 errors.append( 

1986 "OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a positive integer" 

1987 ) 

1988 except ValueError: 

1989 errors.append( 

1990 f"OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a plain integer (minutes), " 

1991 f"got {_raw_busy!r}" 

1992 ) 

1993 

1994 # OPENHANGAR_NOTIFICATION_TIME: optional, but must be valid HH:MM when set 

1995 _raw_notif_time = os.environ.get("OPENHANGAR_NOTIFICATION_TIME", "") 

1996 if _raw_notif_time: 

1997 try: 

1998 _parse_notification_time() 

1999 except ValueError as exc: 

2000 errors.append(str(exc)) 

2001 

2002 # OPENHANGAR_BACKUP_* scheduling/retention vars: optional, validated when set 

2003 from services.backup_scheduler import ( # pyright: ignore[reportMissingImports] 

2004 parse_auto_upgrade_enabled, 

2005 parse_backup_keep, 

2006 parse_backup_keep_days, 

2007 parse_backup_keep_months, 

2008 parse_backup_keep_weeks, 

2009 parse_backup_retention, 

2010 parse_backup_time, 

2011 ) 

2012 

2013 for _backup_parser in ( 

2014 parse_backup_time, 

2015 parse_backup_keep, 

2016 parse_backup_retention, 

2017 parse_backup_keep_days, 

2018 parse_backup_keep_weeks, 

2019 parse_backup_keep_months, 

2020 parse_auto_upgrade_enabled, 

2021 ): 

2022 try: 

2023 _backup_parser() 

2024 except ValueError as exc: 

2025 errors.append(str(exc)) 

2026 

2027 # OPENHANGAR_AUTO_UPGRADE: requires scheduled backups AND the upgrade dir 

2028 # to be configured — an automatic upgrade must never run without a 

2029 # scheduled backup to fall back on. 

2030 try: 

2031 if parse_auto_upgrade_enabled(): 

2032 if not os.environ.get("OPENHANGAR_BACKUP_TIME", "").strip(): 

2033 errors.append( 

2034 "OPENHANGAR_AUTO_UPGRADE is enabled but OPENHANGAR_BACKUP_TIME " 

2035 "is not set — automatic upgrades run right after the scheduled " 

2036 "backup, so a backup schedule must be configured first." 

2037 ) 

2038 if not os.environ.get("OPENHANGAR_UPGRADE_DIR", "").strip(): 

2039 errors.append( 

2040 "OPENHANGAR_AUTO_UPGRADE is enabled but OPENHANGAR_UPGRADE_DIR " 

2041 "is not set — automatic upgrades need the upgrade directory " 

2042 "configured (see docs/self-hosting.md#one-click-upgrades-optional)." 

2043 ) 

2044 except ValueError: 

2045 pass # already reported by the loop above 

2046 

2047 # OPENHANGAR_ALERT_NTFY_TOPIC_URL: must be an http(s) URL when set 

2048 _ntfy_url = os.environ.get("OPENHANGAR_ALERT_NTFY_TOPIC_URL", "").strip() 

2049 if _ntfy_url and not _ntfy_url.startswith(("http://", "https://")): 

2050 errors.append( 

2051 f"OPENHANGAR_ALERT_NTFY_TOPIC_URL must start with http:// or https://, " 

2052 f"got {_ntfy_url!r}" 

2053 ) 

2054 

2055 # OPENHANGAR_ALERT_EMAIL_TO: must look like an email address when set, 

2056 # and SMTP must be configured for delivery to be possible 

2057 _alert_email = os.environ.get("OPENHANGAR_ALERT_EMAIL_TO", "").strip() 

2058 if _alert_email: 

2059 if "@" not in _alert_email: 

2060 errors.append( 

2061 f"OPENHANGAR_ALERT_EMAIL_TO must be a valid email address, " 

2062 f"got {_alert_email!r}" 

2063 ) 

2064 elif not os.environ.get("OPENHANGAR_SMTP_HOST", "").strip(): 

2065 errors.append( 

2066 "OPENHANGAR_ALERT_EMAIL_TO is set but OPENHANGAR_SMTP_HOST is not configured — " 

2067 "alert emails cannot be delivered" 

2068 ) 

2069 

2070 # OPENHANGAR_ALERT_WEBHOOK_URL: must be an http(s) URL when set 

2071 _webhook_url = os.environ.get("OPENHANGAR_ALERT_WEBHOOK_URL", "").strip() 

2072 if _webhook_url and not _webhook_url.startswith(("http://", "https://")): 

2073 errors.append( 

2074 f"OPENHANGAR_ALERT_WEBHOOK_URL must start with http:// or https://, " 

2075 f"got {_webhook_url!r}" 

2076 ) 

2077 

2078 # OPENHANGAR_EMAIL_SUBJECT_PREFIX: true/false-like, or a short custom prefix 

2079 _raw_subject_prefix = os.environ.get("OPENHANGAR_EMAIL_SUBJECT_PREFIX", "") 

2080 if _raw_subject_prefix and not _raw_subject_prefix.strip(): 

2081 errors.append( 

2082 "OPENHANGAR_EMAIL_SUBJECT_PREFIX is set but contains only whitespace. " 

2083 "Use true/false, or a custom prefix, or leave the variable unset." 

2084 ) 

2085 elif ( 

2086 _raw_subject_prefix.strip() 

2087 and _raw_subject_prefix.strip().lower() 

2088 not in ("true", "false", "1", "0", "yes", "no") 

2089 and len(_raw_subject_prefix.strip()) > 16 

2090 ): 

2091 errors.append( 

2092 f"OPENHANGAR_EMAIL_SUBJECT_PREFIX must be true/false (or 1/0, yes/no) or a " 

2093 f"short custom prefix (max 16 characters), got {_raw_subject_prefix!r}" 

2094 ) 

2095 

2096 if errors: 

2097 bullet_list = "\n".join(f" • {e}" for e in errors) 

2098 raise RuntimeError( 

2099 f"Configuration errors — fix before starting:\n{bullet_list}" 

2100 ) 

2101 

2102 if _validated_max is not None: 

2103 app.config["MAX_CONTENT_LENGTH"] = _validated_max 

2104 

2105 

2106if __name__ == "__main__": # pragma: no cover 

2107 _debug = os.environ.get("OPENHANGAR_ENV") == "development" 

2108 create_app().run(host="0.0.0.0", port=5000, debug=_debug) # nosec B104