Coverage for app/init.py: 100%

750 statements  

« 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 

5 

6import click # pyright: ignore[reportMissingImports] 

7from typing import Any 

8from urllib.parse import urlparse 

9 

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] 

27 

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

29 

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} 

35 

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] 

56 

57 

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 

63 

64 

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() 

71 

72 

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 

77 

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

79 

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

81 

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() 

85 

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() 

99 

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

101 # block psql's DDL statements with AccessShareLock. 

102 db.engine.dispose() 

103 

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 

110 

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) 

120 

121 if result.returncode != 0: 

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

123 

124 

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 

131 

132 from airworthiness_sync import sync_all_nodes # pyright: ignore[reportMissingImports] 

133 

134 _log = logging.getLogger(__name__) 

135 

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) 

149 

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) 

159 

160 

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

162 import threading 

163 

164 t = threading.Thread( 

165 target=_easa_sync_loop, 

166 args=(app,), 

167 daemon=True, 

168 name="easa-sync", 

169 ) 

170 t.start() 

171 

172 

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

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

175 

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 

190 

191 

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 

196 

197 _log = logging.getLogger(__name__) 

198 _log.info( 

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

200 ) 

201 

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] 

212 

213 run_daily_checks(app) 

214 except Exception: 

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

216 

217 

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

219 import threading 

220 

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() 

229 

230 

231def create_app() -> Flask: 

232 from security_alerts import attach_to_logger # pyright: ignore[reportMissingImports] 

233 

234 attach_to_logger() 

235 

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] 

238 

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

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

241 

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) 

269 

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

271 

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

273 app.config["TEMPLATES_AUTO_RELOAD"] = True 

274 

275 from models import db 

276 

277 db.init_app(app) 

278 Migrate(app, db) 

279 

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 

292 

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

299 

300 Babel(app, locale_selector=_get_locale) 

301 CSRFProtect(app) 

302 

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

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

305 

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

307 app.config["CACHE_DEFAULT_TIMEOUT"] = 300 

308 _cache.init_app(app) 

309 _limiter.init_app(app) 

310 

311 @app.before_request 

312 def _generate_csp_nonce() -> None: 

313 g.csp_nonce = secrets.token_urlsafe(16) 

314 

315 def _csp_nonce() -> str: 

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

317 

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

319 

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 

350 

351 from flask_babel import format_date, format_datetime, format_decimal 

352 

353 app.jinja_env.globals.update( 

354 format_date=format_date, 

355 format_datetime=format_datetime, 

356 format_decimal=format_decimal, 

357 ) 

358 

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

360 from jinja2 import StrictUndefined 

361 

362 app.jinja_env.undefined = StrictUndefined 

363 

364 from utils import ( 

365 _load_aircraft_type_variants, 

366 _load_airport_names, 

367 ) 

368 

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(), "") 

374 

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

376 def pwa_manifest() -> ResponseReturnValue: 

377 from flask import jsonify as _jsonify 

378 

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 ) 

457 

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 

473 

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

475 def api_check_flight_duplicate() -> ResponseReturnValue: 

476 from flask import jsonify as _jsonify 

477 

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 

487 

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

489 try: 

490 from datetime import date as _date 

491 

492 flight_date = _date.fromisoformat(date_str) 

493 except ValueError: 

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

495 

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

497 

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}) 

512 

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}) 

522 

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

524 

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]} 

543 

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} 

564 

565 from auth.routes import auth_bp 

566 

567 app.register_blueprint(auth_bp) 

568 

569 from aircraft.routes import aircraft_bp 

570 

571 app.register_blueprint(aircraft_bp) 

572 

573 from flights.routes import flights_bp 

574 

575 app.register_blueprint(flights_bp) 

576 

577 from maintenance.routes import maintenance_bp 

578 

579 app.register_blueprint(maintenance_bp) 

580 

581 from expenses.routes import expenses_bp 

582 

583 app.register_blueprint(expenses_bp) 

584 

585 from documents.routes import documents_bp 

586 

587 app.register_blueprint(documents_bp) 

588 

589 from config.routes import config_bp 

590 

591 app.register_blueprint(config_bp) 

592 

593 from share.routes import share_bp 

594 

595 app.register_blueprint(share_bp) 

596 

597 from snags.routes import snags_bp 

598 

599 app.register_blueprint(snags_bp) 

600 

601 from pilots.routes import pilots_bp 

602 

603 app.register_blueprint(pilots_bp) 

604 

605 from users.routes import users_bp 

606 

607 app.register_blueprint(users_bp) 

608 

609 from reservations.routes import reservations_bp 

610 

611 app.register_blueprint(reservations_bp) 

612 

613 from squawk.routes import squawk_bp 

614 

615 app.register_blueprint(squawk_bp) 

616 

617 from hangar.routes import hangar_bp 

618 

619 app.register_blueprint(hangar_bp) 

620 

621 from airworthiness.routes import airworthiness_bp 

622 

623 app.register_blueprint(airworthiness_bp) 

624 

625 from pwa.routes import pwa_bp 

626 

627 app.register_blueprint(pwa_bp) 

628 

629 if flask_env == "demo": 

630 from demo.routes import demo_bp 

631 

632 app.register_blueprint(demo_bp) 

633 

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" 

646 

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 

651 

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) 

674 

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 

692 

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 

696 

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 

700 

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 

706 

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 

752 

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 } 

790 

791 @app.errorhandler(403) 

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

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

794 

795 @app.errorhandler(404) 

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

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

798 

799 @app.errorhandler(500) 

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

801 import traceback as _tb 

802 

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 ) 

818 

819 @app.route("/") 

820 def index() -> ResponseReturnValue: 

821 from models import TenantUser, User 

822 

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

826 

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 

833 

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} 

838 

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 ) 

849 

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) 

875 

876 triggers = ( 

877 ( 

878 MaintenanceTrigger.query.filter( 

879 MaintenanceTrigger.aircraft_id.in_(aircraft_ids) 

880 ).all() 

881 ) 

882 if aircraft_ids 

883 else [] 

884 ) 

885 

886 aircraft_status = compute_aircraft_statuses( 

887 aircraft, triggers, hobbs_by_aircraft 

888 ) 

889 

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] 

900 

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] 

916 

917 from models import PilotLogbookEntry, PilotProfile 

918 from pilots.currency import currency_summary as _currency_summary 

919 

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) 

931 

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 ) 

937 

938 from flask import url_for as _url_for_dash 

939 

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 

968 

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 

971 

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 

977 

978 from models import Role 

979 from utils import current_user_role 

980 

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 ) 

1016 

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 

1029 

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 ) 

1035 

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 ) 

1048 

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 ) 

1060 

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) 

1073 

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

1082 

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

1114 

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 

1119 

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

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

1122 from flask import abort, redirect 

1123 

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 

1128 

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) 

1153 

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

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

1156 from flask import abort, redirect 

1157 

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 

1162 

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) 

1183 

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 ) 

1189 

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 ) 

1195 

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 

1202 

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 

1213 

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 

1222 

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 

1227 

1228 from sqlalchemy.exc import ProgrammingError # pyright: ignore[reportMissingImports] 

1229 

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

1231 

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) 

1243 

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 

1252 

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

1254 

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

1256 

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) 

1264 

1265 # ── decrypt + extract ───────────────────────────────────────────────── 

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

1267 payload = fh.read() 

1268 

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] 

1273 

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 

1283 

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/")] 

1291 

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

1298 

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] 

1303 

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

1317 

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) 

1326 

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) 

1335 

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

1347 

1348 print("Restore complete.") 

1349 

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

1351 def backup_now_command() -> None: 

1352 import sys 

1353 

1354 from config.routes import run_backup 

1355 

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) 

1364 

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

1375 

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 

1380 

1381 demo_seed() 

1382 print("Demo slots reseeded.") 

1383 

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 

1388 

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

1396 

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] 

1404 

1405 start_version_check_thread(app) 

1406 from sync_watcher import start_sync_watcher # pyright: ignore[reportMissingImports] 

1407 

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] 

1414 

1415 threading.Thread( 

1416 target=send_welcome_email_if_needed, 

1417 args=(app,), 

1418 daemon=True, 

1419 name="welcome-email", 

1420 ).start() 

1421 

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) 

1428 

1429 _validate_config(app) 

1430 return app 

1431 

1432 

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] = [] 

1436 

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 ) 

1444 

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 ) 

1452 

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 ) 

1468 

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 ) 

1483 

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 ) 

1494 

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 ) 

1502 

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 ) 

1516 

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 ) 

1530 

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

1538 

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 ) 

1546 

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 ) 

1561 

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 ) 

1569 

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 ) 

1575 

1576 if _validated_max is not None: 

1577 app.config["MAX_CONTENT_LENGTH"] = _validated_max 

1578 

1579 

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