Coverage for app/init.py: 100%

881 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1import os 

2import secrets 

3import sqlite3 

4from datetime import UTC, timedelta 

5from functools import cache 

6from typing import Any 

7from urllib.parse import urlparse 

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# EE-09: aviation history days — (month, day, msgid). Add new entries here. 

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

78 (3, 2, "First flight of Concorde — André Turcat at the controls, Toulouse (1969)"), 

79 ( 

80 5, 

81 21, 

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

83 ), 

84 ( 

85 7, 

86 25, 

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

88 ), 

89 ( 

90 11, 

91 21, 

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

93 ), 

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

95] 

96 

97 

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

99 for m, d, msgid in _AVIATION_DAYS: 

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

101 return msgid 

102 return None 

103 

104 

105@cache 

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

107 latest = 0 

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

109 for name in files: 

110 try: 

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

112 except OSError: # file removed while walking 

113 continue 

114 latest = max(latest, mtime) 

115 return str(latest) 

116 

117 

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

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

120 

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

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

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

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

125 if version and version != "development": 

126 return version 

127 return _static_folder_mtime_token(static_folder) 

128 

129 

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

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

132 if isinstance(dbapi_connection, sqlite3.Connection): 

133 cur = dbapi_connection.cursor() 

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

135 cur.close() 

136 

137 

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

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

140 import subprocess # nosec B404 

141 import tempfile 

142 

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

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

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

146 

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

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

149 db.session.remove() 

150 

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

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

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

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

155 conn.execute( 

156 text( 

157 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity" 

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

159 ) 

160 ) 

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

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

163 conn.commit() 

164 

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

166 # block psql's DDL statements with AccessShareLock. 

167 db.engine.dispose() 

168 

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

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

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

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

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

174 # made before that change. 

175 import re 

176 

177 sql_bytes = re.sub( 

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

179 b"", 

180 sql_bytes, 

181 flags=re.MULTILINE | re.IGNORECASE, 

182 ) 

183 

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

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

186 # print progress to the terminal in real time. 

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

188 tmp.write(sql_bytes) 

189 tmp_path = tmp.name 

190 

191 try: 

192 result = subprocess.run( # nosec B603 

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

194 timeout=600, 

195 check=False, # returncode checked explicitly below 

196 ) 

197 except subprocess.TimeoutExpired: 

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

199 finally: 

200 os.unlink(tmp_path) 

201 

202 if result.returncode != 0: 

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

204 

205 

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

207 import logging 

208 import os 

209 import random 

210 import time 

211 from datetime import datetime, timedelta 

212 

213 from airworthiness_sync import ( 

214 sync_all_nodes, # pyright: ignore[reportMissingImports] 

215 ) 

216 

217 _log = logging.getLogger(__name__) 

218 

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

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

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

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

223 if env_hour is not None: 

224 try: 

225 sync_hour = int(env_hour) % 24 

226 except ValueError: 

227 sync_hour = random.randint(1, 5) 

228 else: 

229 sync_hour = random.randint(1, 5) 

230 sync_minute = random.randint(0, 59) 

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

232 

233 while True: 

234 now = datetime.now(UTC) 

235 next_run = now.replace( 

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

237 ) 

238 if next_run <= now: 

239 next_run += timedelta(days=1) 

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

241 sync_all_nodes(app) 

242 

243 

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

245 import threading 

246 

247 t = threading.Thread( 

248 target=_easa_sync_loop, 

249 args=(app,), 

250 daemon=True, 

251 name="easa-sync", 

252 ) 

253 t.start() 

254 

255 

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

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

258 

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

260 """ 

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

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

263 parts = raw.split(":") 

264 if len(parts) != 2: 

265 raise ValueError(err) 

266 try: 

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

268 except ValueError: 

269 raise ValueError(err) 

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

271 raise ValueError(err) 

272 return hour, minute 

273 

274 

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

276 import logging 

277 import time 

278 from datetime import datetime, timedelta 

279 

280 _log = logging.getLogger(__name__) 

281 _log.info( 

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

283 ) 

284 

285 while True: 

286 now = datetime.now(UTC) 

287 next_run = now.replace( 

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

289 ) 

290 if next_run <= now: 

291 next_run += timedelta(days=1) 

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

293 try: 

294 from services.notification_service import ( 

295 run_daily_checks, # pyright: ignore[reportMissingImports] 

296 ) 

297 

298 run_daily_checks(app) 

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

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

301 

302 

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

304 import threading 

305 

306 run_hour, run_minute = _parse_notification_time() 

307 t = threading.Thread( 

308 target=_notification_daily_loop, 

309 args=(app, run_hour, run_minute), 

310 daemon=True, 

311 name="notification-daily", 

312 ) 

313 t.start() 

314 

315 

316def create_app() -> Flask: 

317 from security_alerts import ( 

318 attach_to_logger, # pyright: ignore[reportMissingImports] 

319 ) 

320 

321 attach_to_logger() 

322 

323 app = Flask(__name__) 

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

325 

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

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

328 

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

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

331 ) 

332 secret_key = _env_or_file("SECRET_KEY") 

333 if not secret_key: 

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

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

336 raise RuntimeError( 

337 "OPENHANGAR_SECRET_KEY appears to be a placeholder value. " 

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

339 ) 

340 app.config["SECRET_KEY"] = secret_key 

341 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 

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

343 "OPENHANGAR_UPLOAD_FOLDER", "/data/uploads" 

344 ) 

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

346 "OPENHANGAR_BACKUP_FOLDER", "/data/backups" 

347 ) 

348 app.config["MAX_CONTENT_LENGTH"] = ( 

349 50 * 1024 * 1024 

350 ) # overridden by _validate_config 

351 app.config["SESSION_COOKIE_SECURE"] = True 

352 app.config["SESSION_COOKIE_HTTPONLY"] = True 

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

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

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

356 

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

358 

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

360 app.config["TEMPLATES_AUTO_RELOAD"] = True 

361 

362 from models import db 

363 

364 db.init_app(app) 

365 Migrate(app, db) 

366 

367 def _get_locale() -> str | None: 

368 if not has_request_context(): 

369 return "en" 

370 if session.get("user_id"): 

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

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

373 if ( 

374 session.get("demo_slot_id") 

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

376 ): 

377 return str(session["language"]) 

378 from models import User 

379 

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

381 if user and user.language in SUPPORTED_LOCALES: 

382 return str(user.language) 

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

384 return str(session["language"]) 

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

386 

387 Babel(app, locale_selector=_get_locale) 

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

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

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

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

392 app.config["WTF_CSRF_TIME_LIMIT"] = None 

393 CSRFProtect(app) 

394 

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

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

397 

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

399 app.config["CACHE_DEFAULT_TIMEOUT"] = 300 

400 _cache.init_app(app) 

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

402 "OPENHANGAR_RATELIMIT_ENABLED", "true" 

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

404 _limiter.init_app(app) 

405 

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

407 

408 @app.url_defaults 

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

410 if endpoint == "static": 

411 values.setdefault("v", static_version) 

412 

413 @app.before_request 

414 def _generate_csp_nonce() -> None: 

415 g.csp_nonce = secrets.token_urlsafe(16) 

416 

417 def _csp_nonce() -> str: 

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

419 

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

421 

422 @app.after_request 

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

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

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

426 f"default-src 'self'; " 

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

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

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

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

431 f"font-src 'self'; " 

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

433 f"connect-src 'self'; " 

434 f"object-src 'none'; " 

435 f"base-uri 'self'; " 

436 f"form-action 'self'; " 

437 f"frame-ancestors 'none';" 

438 ) 

439 response.headers["X-Frame-Options"] = "DENY" 

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

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

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

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

444 ) 

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

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

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

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

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

450 # authenticated no-store below. 

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

452 elif session.get("user_id"): 

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

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

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

456 return response 

457 

458 from flask_babel import format_date, format_datetime, format_decimal 

459 

460 app.jinja_env.globals.update( 

461 format_date=format_date, 

462 format_datetime=format_datetime, 

463 format_decimal=format_decimal, 

464 ) 

465 

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

467 from jinja2 import StrictUndefined 

468 

469 app.jinja_env.undefined = StrictUndefined 

470 

471 from utils import ( 

472 _load_aircraft_type_variants, 

473 _load_airport_names, 

474 ) 

475 

476 @app.template_filter("airport_name") 

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

478 if not code: 

479 return "" 

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

481 

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

483 def pwa_manifest() -> ResponseReturnValue: 

484 from flask import jsonify as _jsonify 

485 

486 return _jsonify( 

487 { 

488 "name": "OpenHangar", 

489 "short_name": "OpenHangar", 

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

491 "start_url": "/", 

492 "display": "standalone", 

493 "theme_color": "#14233A", 

494 "background_color": "#14233A", 

495 "icons": [ 

496 { 

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

498 "sizes": "any", 

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

500 }, 

501 { 

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

503 "sizes": "any", 

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

505 "purpose": "maskable", 

506 }, 

507 ], 

508 "share_target": { 

509 "action": "/pwa/shared", 

510 "method": "POST", 

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

512 "params": { 

513 "title": "title", 

514 "text": "text", 

515 "url": "url", 

516 "files": [ 

517 { 

518 "name": "files", 

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

520 } 

521 ], 

522 }, 

523 }, 

524 "shortcuts": [ 

525 { 

526 "name": "Log a Flight", 

527 "short_name": "Log Flight", 

528 "url": "/flights/new", 

529 "icons": [ 

530 { 

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

532 "sizes": "any", 

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

534 } 

535 ], 

536 }, 

537 { 

538 "name": "My Aircraft", 

539 "short_name": "Aircraft", 

540 "url": "/aircraft", 

541 "icons": [ 

542 { 

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

544 "sizes": "any", 

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

546 } 

547 ], 

548 }, 

549 { 

550 "name": "Documents", 

551 "short_name": "Documents", 

552 "url": "/documents", 

553 "icons": [ 

554 { 

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

556 "sizes": "any", 

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

558 } 

559 ], 

560 }, 

561 ], 

562 } 

563 ) 

564 

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

566 def service_worker() -> ResponseReturnValue: 

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

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

569 content = fh.read() 

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

571 cache_name = ( 

572 f"openhangar-{version}" 

573 if version and version != "development" 

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

575 ) 

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

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

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

579 return response 

580 

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

582 def api_check_flight_duplicate() -> ResponseReturnValue: 

583 from flask import jsonify as _jsonify 

584 

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

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

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

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

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

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

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

592 exclude_flight_id = ( 

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

594 ) 

595 if not (date_str and dep and arr): 

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

597 from models import Aircraft, Flight, TenantUser 

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

599 

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

601 try: 

602 from datetime import date as _date 

603 

604 flight_date = _date.fromisoformat(date_str) 

605 except ValueError: 

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

607 

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

609 

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

611 ac_id = int(aircraft_id_str) 

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

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

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

615 if owned: 

616 q = Flight.query.filter_by( 

617 aircraft_id=ac_id, 

618 date=flight_date, 

619 departure_icao=dep, 

620 arrival_icao=arr, 

621 ) 

622 if exclude_flight_id: 

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

624 if q.first(): 

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

626 

627 if tu: 

628 q_pilot = Flight.query.filter( 

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

630 Flight.date == flight_date, 

631 Flight.departure_icao == dep, 

632 Flight.arrival_icao == arr, 

633 ) 

634 if exclude_flight_id: 

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

636 if q_pilot.first(): 

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

638 

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

640 

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

642 def airport_search() -> ResponseReturnValue: 

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

644 return {"results": []} 

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

646 if len(q) < 2: 

647 return {"results": []} 

648 q_code = q.upper() 

649 q_low = q.lower() 

650 names = _load_airport_names() 

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

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

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

654 if code.startswith(q_code): 

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

656 elif q_low in name.lower(): 

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

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

659 

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

661 def aircraft_type_search() -> ResponseReturnValue: 

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

663 return {"results": []} 

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

665 if len(q) < 2: 

666 return {"results": []} 

667 q_up = q.upper() 

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

669 variants = _load_aircraft_type_variants() 

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

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

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

673 name_low = full_name.lower() 

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

675 if des.startswith(q_up): 

676 code_hits.append(entry) 

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

678 name_hits.append(entry) 

679 return {"results": code_hits + name_hits} 

680 

681 from utils import AircraftRefConverter 

682 

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

684 

685 from auth.routes import auth_bp 

686 

687 app.register_blueprint(auth_bp) 

688 

689 from aircraft.routes import aircraft_bp 

690 

691 app.register_blueprint(aircraft_bp) 

692 

693 from flights.routes import flights_bp 

694 

695 app.register_blueprint(flights_bp) 

696 

697 from maintenance.routes import maintenance_bp 

698 

699 app.register_blueprint(maintenance_bp) 

700 

701 from expenses.routes import expenses_bp 

702 

703 app.register_blueprint(expenses_bp) 

704 

705 from documents.routes import documents_bp 

706 

707 app.register_blueprint(documents_bp) 

708 

709 from config.routes import config_bp 

710 

711 app.register_blueprint(config_bp) 

712 

713 from share.routes import share_bp 

714 

715 app.register_blueprint(share_bp) 

716 

717 from snags.routes import snags_bp 

718 

719 app.register_blueprint(snags_bp) 

720 

721 from notifications.routes import notifications_bp 

722 

723 app.register_blueprint(notifications_bp) 

724 

725 from refuels.routes import refuels_bp 

726 

727 app.register_blueprint(refuels_bp) 

728 

729 from reports.routes import reports_bp 

730 

731 app.register_blueprint(reports_bp) 

732 

733 from pilots.routes import pilots_bp 

734 

735 app.register_blueprint(pilots_bp) 

736 

737 from users.routes import users_bp 

738 

739 app.register_blueprint(users_bp) 

740 

741 from reservations.routes import reservations_bp 

742 

743 app.register_blueprint(reservations_bp) 

744 

745 from squawk.routes import squawk_bp 

746 

747 app.register_blueprint(squawk_bp) 

748 

749 from hangar.routes import hangar_bp 

750 

751 app.register_blueprint(hangar_bp) 

752 

753 from airworthiness.routes import airworthiness_bp 

754 

755 app.register_blueprint(airworthiness_bp) 

756 

757 from pwa.routes import pwa_bp 

758 

759 app.register_blueprint(pwa_bp) 

760 

761 from offline.routes import offline_bp 

762 

763 app.register_blueprint(offline_bp) 

764 

765 if flask_env == "demo": 

766 from demo.routes import demo_bp 

767 

768 app.register_blueprint(demo_bp) 

769 

770 def _current_theme( 

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

772 ) -> str: 

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

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

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

776 return str(t) 

777 if in_request: 

778 t = sess.get("theme") 

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

780 return str(t) 

781 return "system" 

782 

783 @app.context_processor 

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

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

786 from utils import ( 

787 check_legacy_logbook_data, 

788 check_update_available, 

789 current_user_role, 

790 ) 

791 

792 is_demo = flask_env == "demo" 

793 demo_next_wipe_utc = ( 

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

795 ) 

796 repo_url = os.environ.get( 

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

798 ) 

799 _in_request = has_request_context() 

800 demo_display_id = None 

801 if is_demo and _in_request: 

802 slot_id = session.get("demo_slot_id") 

803 if slot_id: 

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

805 if slot: 

806 demo_display_id = slot.display_id 

807 role = current_user_role() if _in_request else None 

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

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

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

811 _flag_pilot = bool(_user_flags and _user_flags.is_pilot) 

812 _flag_maint = bool(_user_flags and _user_flags.is_maintenance) 

813 

814 # Phase 26: adaptive UI based on TenantProfile 

815 _tenant_profile = None 

816 _has_renter_account = False 

817 if uid: 

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

819 if tu: 

820 _tenant_profile = TenantProfile.query.filter_by( 

821 tenant_id=tu.tenant_id 

822 ).first() 

823 if _tenant_profile and _tenant_profile.allows_rental: 

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

825 from models import BillingAccount, BillingAccountKind, LedgerEntry 

826 

827 _renter_account = BillingAccount.query.filter_by( 

828 tenant_id=tu.tenant_id, 

829 user_id=uid, 

830 kind=BillingAccountKind.RENTER, 

831 ).first() 

832 _has_renter_account = bool( 

833 _renter_account 

834 and LedgerEntry.query.filter_by( 

835 account_id=_renter_account.id 

836 ).first() 

837 is not None 

838 ) 

839 _pac = ( 

840 _tenant_profile.planned_aircraft_count 

841 if _tenant_profile and _tenant_profile.planned_aircraft_count is not None 

842 else None 

843 ) 

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

845 _logbook_only = _pac == 0 

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

847 _single_aircraft_mode = _pac == 1 

848 

849 # EE-09: aviation history day banner 

850 from datetime import date as _date 

851 

852 from flask_babel import gettext as _gt 

853 from flask_babel import ngettext as _ngt 

854 

855 _today = _date.today() 

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

857 _aviation_banner = _gt(_avi_msgid) if _avi_msgid else None 

858 

859 # EE-10: personal anniversary banner (first solo / PPL) 

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

861 _pilot_anniversary_confetti = False 

862 if uid: 

863 from models import PilotProfile as _PP 

864 

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

866 if _pp: 

867 for _ann_date, _ann_type in ( 

868 (_pp.first_solo_date, "solo"), 

869 (_pp.ppl_issue_date, "ppl"), 

870 ): 

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

872 _today.month, 

873 _today.day, 

874 ): 

875 _years = _today.year - _ann_date.year 

876 if _ann_type == "solo": 

877 _msg = ( 

878 _ngt( 

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

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

881 _years, 

882 n=_years, 

883 ) 

884 if _years > 0 

885 else _gt( 

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

887 ) 

888 ) 

889 else: 

890 _msg = ( 

891 _ngt( 

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

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

894 _years, 

895 n=_years, 

896 ) 

897 if _years > 0 

898 else _gt("🎉 Today is the anniversary of your PPL!") 

899 ) 

900 _pilot_anniversary = { 

901 "type": _ann_type, 

902 "years": _years, 

903 "message": _msg, 

904 } 

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

906 if not session.get(_sess_key): 

907 session[_sess_key] = True 

908 _pilot_anniversary_confetti = True 

909 break 

910 

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

912 _nav_update_available = ( 

913 check_update_available() 

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

915 else False 

916 ) 

917 _legacy_logbook_data_present = ( 

918 check_legacy_logbook_data() if _is_owner and _in_request else False 

919 ) 

920 

921 return { 

922 "logged_in": bool(uid), 

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

924 "flask_env": flask_env, 

925 "is_demo": is_demo, 

926 "demo_next_wipe_utc": demo_next_wipe_utc, 

927 "demo_display_id": demo_display_id, 

928 "repo_url": repo_url, 

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

930 "supported_locales": SUPPORTED_LOCALES, 

931 "locale_meta": LOCALE_META, 

932 "current_role": role, 

933 "is_owner": _is_owner, 

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

935 or _flag_pilot, 

936 "is_maint": role 

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

938 or _flag_maint, 

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

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

941 if _user_flags 

942 else None, 

943 "tenant_profile": _tenant_profile, 

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

945 "has_renter_account": _has_renter_account, 

946 "logbook_only": _logbook_only, 

947 "single_aircraft_mode": _single_aircraft_mode, 

948 "aircraft_count_goal": _pac, 

949 "aviation_day_banner": _aviation_banner, 

950 "pilot_anniversary": _pilot_anniversary, 

951 "pilot_anniversary_confetti": _pilot_anniversary_confetti, 

952 "today": _date.today(), 

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

954 "nav_update_available": _nav_update_available, 

955 "legacy_logbook_data_present": _legacy_logbook_data_present, 

956 "oh_debug": app.debug 

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

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

959 } 

960 

961 @app.errorhandler(403) 

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

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

964 

965 @app.errorhandler(404) 

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

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

968 

969 @app.errorhandler(500) 

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

971 import traceback as _tb 

972 

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

974 exc_type = type(e).__name__ 

975 exc_value = str(e) 

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

977 return ( 

978 render_template( 

979 "errors/500.html", 

980 show_debug=show_debug, 

981 env=flask_env, 

982 exc_type=exc_type, 

983 exc_value=exc_value, 

984 tb=tb, 

985 ), 

986 500, 

987 ) 

988 

989 @app.route("/") 

990 def index() -> ResponseReturnValue: 

991 from models import TenantUser, User 

992 

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

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

995 return render_template("landing.html") 

996 

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

998 return render_template("landing.html") 

999 if session.get("user_id"): 

1000 from datetime import date as _date 

1001 

1002 from models import ( 

1003 Aircraft, 

1004 AircraftPhoto, 

1005 Flight, 

1006 MaintenanceTrigger, 

1007 Snag, 

1008 ) 

1009 from utils import accessible_aircraft, compute_aircraft_statuses 

1010 

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

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

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

1014 hobbs_by_aircraft = Aircraft.engine_hours_by_id(aircraft_ids) 

1015 landings_by_aircraft = Aircraft.landings_by_id(aircraft_ids) 

1016 flight_hours_by_aircraft = Aircraft.flight_hours_by_id(aircraft_ids) 

1017 cover_photos = ( 

1018 { 

1019 p.aircraft_id: p 

1020 for p in AircraftPhoto.query.filter( 

1021 AircraftPhoto.aircraft_id.in_(aircraft_ids), 

1022 AircraftPhoto.sort_order == 1, 

1023 ).all() 

1024 } 

1025 if aircraft_ids 

1026 else {} 

1027 ) 

1028 

1029 recent_flights = ( 

1030 ( 

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

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

1033 .limit(5) 

1034 .all() 

1035 ) 

1036 if aircraft_ids 

1037 else [] 

1038 ) 

1039 

1040 today = _date.today() 

1041 month_start = today.replace(day=1) 

1042 month_flights = ( 

1043 ( 

1044 Flight.query.filter( 

1045 Flight.aircraft_id.in_(aircraft_ids), 

1046 Flight.date >= month_start, 

1047 ).all() 

1048 ) 

1049 if aircraft_ids 

1050 else [] 

1051 ) 

1052 hours_this_month = sum( 

1053 float(f.flight_time) 

1054 if f.flight_time is not None 

1055 else float(f.flight_time_counter_end) 

1056 - float(f.flight_time_counter_start) 

1057 for f in month_flights 

1058 if f.flight_time is not None 

1059 or ( 

1060 f.flight_time_counter_end is not None 

1061 and f.flight_time_counter_start is not None 

1062 ) 

1063 ) 

1064 flights_this_month = len(month_flights) 

1065 

1066 triggers = ( 

1067 ( 

1068 MaintenanceTrigger.query.filter( 

1069 MaintenanceTrigger.aircraft_id.in_(aircraft_ids) 

1070 ).all() 

1071 ) 

1072 if aircraft_ids 

1073 else [] 

1074 ) 

1075 

1076 aircraft_status = compute_aircraft_statuses( 

1077 aircraft, 

1078 triggers, 

1079 hobbs_by_aircraft, 

1080 landings_by_aircraft, 

1081 flight_hours_by_aircraft, 

1082 ) 

1083 

1084 urgent_maintenance = [] 

1085 maintenance_alerts = 0 

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

1087 for t in triggers: 

1088 s = t.status( 

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

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

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

1092 ) 

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

1094 maintenance_alerts += 1 

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

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

1097 urgent_maintenance = urgent_maintenance[:5] 

1098 

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

1100 open_grounding = ( 

1101 ( 

1102 Snag.query.filter( 

1103 Snag.aircraft_id.in_(aircraft_ids), 

1104 Snag.is_grounding.is_(True), 

1105 Snag.resolved_at.is_(None), 

1106 ) 

1107 .order_by(Snag.reported_at.desc()) 

1108 .all() 

1109 ) 

1110 if aircraft_ids 

1111 else [] 

1112 ) 

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

1114 open_other_snags = ( 

1115 Snag.query.filter( 

1116 Snag.aircraft_id.in_(aircraft_ids), 

1117 Snag.is_grounding.is_(False), 

1118 Snag.resolved_at.is_(None), 

1119 ).count() 

1120 if aircraft_ids 

1121 else 0 

1122 ) 

1123 

1124 from models import PilotProfile 

1125 from pilots.currency import currency_summary as _currency_summary 

1126 from sqlalchemy import ( 

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

1128 ) 

1129 

1130 pilot_profile = PilotProfile.query.filter_by( 

1131 user_id=session["user_id"] 

1132 ).first() 

1133 _mine_dash = _or_dash( 

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

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

1136 ) 

1137 pilot_entries = ( 

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

1139 ) 

1140 pilot_currency = _currency_summary(pilot_profile, pilot_entries, today) 

1141 

1142 recent_pilot_entries = ( 

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

1144 if not aircraft_ids 

1145 else [] 

1146 ) 

1147 

1148 from flask import url_for as _url_for_dash 

1149 

1150 track_entries = ( 

1151 Flight.query.filter(_mine_dash) 

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

1153 .order_by(Flight.date.asc()) 

1154 .all() 

1155 if pilot_profile 

1156 else [] 

1157 ) 

1158 dash_track_rows = [ 

1159 { 

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

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

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

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

1164 if e.total_flight_time is not None 

1165 else "", 

1166 "view_url": _url_for_dash( 

1167 "aircraft.flight_detail", 

1168 aircraft_id=e.aircraft_id, 

1169 flight_id=e.id, 

1170 ) 

1171 if e.aircraft_id 

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

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

1174 } 

1175 for e in track_entries 

1176 ] 

1177 from models import AppSetting as _AppSetting 

1178 

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

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

1181 

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

1183 import calendar as _cal 

1184 from collections import defaultdict 

1185 from datetime import datetime as _dt 

1186 from datetime import timedelta 

1187 

1188 from models import Reservation, ReservationStatus, Role 

1189 from utils import current_user_role 

1190 

1191 _role = current_user_role() 

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

1193 pending_reservations = ( 

1194 ( 

1195 Reservation.query.filter( 

1196 Reservation.aircraft_id.in_(aircraft_ids), 

1197 Reservation.status == ReservationStatus.PENDING, 

1198 Reservation.start_dt >= today_utc, 

1199 ) 

1200 .order_by(Reservation.start_dt) 

1201 .all() 

1202 ) 

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

1204 else [] 

1205 ) 

1206 res_7d = ( 

1207 Reservation.query.filter( 

1208 Reservation.aircraft_id.in_(aircraft_ids), 

1209 Reservation.start_dt >= today_utc, 

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

1211 ).count() 

1212 if aircraft_ids 

1213 else 0 

1214 ) 

1215 res_30d = ( 

1216 Reservation.query.filter( 

1217 Reservation.aircraft_id.in_(aircraft_ids), 

1218 Reservation.start_dt >= today_utc, 

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

1220 ).count() 

1221 if aircraft_ids 

1222 else 0 

1223 ) 

1224 

1225 # ── Fleet calendar widget ───────────────────────────────────────── 

1226 try: 

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

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

1229 except ValueError: 

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

1231 if cal_month < 1: 

1232 cal_year -= 1 

1233 cal_month = 12 

1234 if cal_month > 12: 

1235 cal_year += 1 

1236 cal_month = 1 

1237 

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

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

1240 cal_month_end = _dt( 

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

1242 ) 

1243 

1244 cal_reservations = ( 

1245 Reservation.query.filter( 

1246 Reservation.aircraft_id.in_(aircraft_ids), 

1247 Reservation.status != ReservationStatus.CANCELLED, 

1248 Reservation.start_dt <= cal_month_end, 

1249 Reservation.end_dt >= cal_month_start, 

1250 ) 

1251 .order_by(Reservation.start_dt) 

1252 .all() 

1253 if aircraft_ids 

1254 else [] 

1255 ) 

1256 

1257 cal_flights = ( 

1258 Flight.query.filter( 

1259 Flight.aircraft_id.in_(aircraft_ids), 

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

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

1262 ) 

1263 .order_by(Flight.date) 

1264 .all() 

1265 if aircraft_ids 

1266 else [] 

1267 ) 

1268 

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

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

1271 ) 

1272 for r in cal_reservations: 

1273 cur = r.start_dt.date() 

1274 end = r.end_dt.date() 

1275 while cur <= end: 

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

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

1278 cur += timedelta(days=1) 

1279 for f in cal_flights: 

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

1281 

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

1283 cal_year, cal_month 

1284 ) 

1285 cal_prev_month = cal_month - 1 or 12 

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

1287 cal_next_month = cal_month % 12 + 1 

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

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

1290 

1291 return render_template( 

1292 "dashboard.html", 

1293 aircraft=aircraft, 

1294 cover_photos=cover_photos, 

1295 pending_reservations=pending_reservations, 

1296 recent_flights=recent_flights, 

1297 recent_pilot_entries=recent_pilot_entries, 

1298 dash_track_rows=dash_track_rows, 

1299 openaip_key=openaip_key, 

1300 hours_this_month=hours_this_month, 

1301 flights_this_month=flights_this_month, 

1302 maintenance_alerts=maintenance_alerts, 

1303 urgent_maintenance=urgent_maintenance, 

1304 grounding_snags=grounding_snags, 

1305 open_other_snags=open_other_snags, 

1306 aircraft_status=aircraft_status, 

1307 triggers=triggers, 

1308 pilot_currency=pilot_currency, 

1309 today=today, 

1310 res_7d=res_7d, 

1311 res_30d=res_30d, 

1312 cal_weeks=cal_weeks, 

1313 cal_day_events=cal_day_events, 

1314 cal_month_name=cal_month_name, 

1315 cal_year=cal_year, 

1316 cal_month=cal_month, 

1317 cal_prev_year=cal_prev_year, 

1318 cal_prev_month=cal_prev_month, 

1319 cal_next_year=cal_next_year, 

1320 cal_next_month=cal_next_month, 

1321 ReservationStatus=ReservationStatus, 

1322 ) 

1323 return render_template("welcome.html") 

1324 

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

1326 def not_yet_implemented() -> ResponseReturnValue: 

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

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

1329 

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

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

1332 from flask import abort, redirect 

1333 

1334 if lang not in SUPPORTED_LOCALES: 

1335 abort(400) 

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

1337 from models import User 

1338 

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

1340 if user: 

1341 user.language = lang 

1342 db.session.commit() 

1343 else: 

1344 session["language"] = ( 

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

1346 ) 

1347 else: 

1348 session["language"] = lang 

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

1350 next_url = next_url.replace( 

1351 "\\", "" 

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

1353 parsed_next = urlparse(next_url) 

1354 if ( 

1355 not next_url 

1356 or parsed_next.netloc 

1357 or parsed_next.scheme 

1358 or not next_url.startswith("/") 

1359 or next_url.startswith("//") 

1360 ): 

1361 next_url = "/" 

1362 return redirect(next_url) 

1363 

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

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

1366 from flask import abort, redirect 

1367 

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

1369 abort(400) 

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

1371 from models import User 

1372 

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

1374 if user: 

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

1376 db.session.commit() 

1377 else: 

1378 session["theme"] = theme 

1379 else: 

1380 session["theme"] = theme 

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

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

1383 parsed_next = urlparse(next_url) 

1384 if ( 

1385 not next_url 

1386 or parsed_next.netloc 

1387 or parsed_next.scheme 

1388 or not next_url.startswith("/") 

1389 or next_url.startswith("//") 

1390 ): 

1391 next_url = "/" 

1392 return redirect(next_url) 

1393 

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

1395 def robots_txt() -> ResponseReturnValue: 

1396 return send_from_directory( 

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

1398 ) 

1399 

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

1401 def favicon() -> ResponseReturnValue: 

1402 return send_from_directory( 

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

1404 ) 

1405 

1406 @app.route("/health") 

1407 def health() -> ResponseReturnValue: 

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

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

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

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

1412 

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

1414 def health_ready() -> ResponseReturnValue: 

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

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

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

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

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

1420 from flask import abort as _abort 

1421 from sqlalchemy import text as _text 

1422 from sqlalchemy.exc import SQLAlchemyError 

1423 

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

1425 _abort(404) 

1426 try: 

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

1428 except SQLAlchemyError: 

1429 db.session.rollback() 

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

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

1432 

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

1434 def check_empty_db_command() -> None: 

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

1436 import sys 

1437 

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

1439 from sqlalchemy.exc import ( 

1440 ProgrammingError, # pyright: ignore[reportMissingImports] 

1441 ) 

1442 

1443 try: 

1444 count = User.query.count() 

1445 except ProgrammingError: 

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

1447 print("Database is empty.") 

1448 return 

1449 if count == 0: 

1450 print("Database is empty.") 

1451 else: 

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

1453 sys.exit(1) 

1454 

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

1456 @click.argument("archive_path") 

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

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

1459 import io 

1460 import sys 

1461 import zipfile 

1462 

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

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

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

1466 BackupArchiveError, 

1467 parse_backup_archive, 

1468 ) 

1469 

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

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

1472 print( 

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

1474 file=sys.stderr, 

1475 ) 

1476 sys.exit(1) 

1477 

1478 # ── decrypt + extract ───────────────────────────────────────────────── 

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

1480 payload = fh.read() 

1481 

1482 # Use only OPENHANGAR_RESTORE_ENCRYPTION_KEY for decryption — never fall 

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

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

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

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

1487 if encryption_key_raw: 

1488 from config.routes import ( 

1489 _derive_key, # pyright: ignore[reportMissingImports] 

1490 ) 

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

1492 AESGCM, # pyright: ignore[reportMissingImports] 

1493 ) 

1494 

1495 key = _derive_key(encryption_key_raw) 

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

1497 try: 

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

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

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

1501 sys.exit(1) 

1502 else: 

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

1504 print( 

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

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

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

1508 " option or interactive prompt.", 

1509 file=sys.stderr, 

1510 ) 

1511 sys.exit(1) 

1512 zip_bytes = payload 

1513 

1514 try: 

1515 metadata, sql_bytes, upload_entries = parse_backup_archive(zip_bytes) 

1516 except BackupArchiveError as exc: 

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

1518 sys.exit(1) 

1519 

1520 # ── version check ───────────────────────────────────────────────────── 

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

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

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

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

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

1526 

1527 if backup_alembic != "unknown": 

1528 try: 

1529 from alembic.script import ( 

1530 ScriptDirectory, # pyright: ignore[reportMissingImports] 

1531 ) 

1532 from flask_migrate import ( 

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

1534 ) 

1535 

1536 _m = _Migrate(current_app, db) 

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

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

1539 if backup_alembic not in known: 

1540 print( 

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

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

1543 "that knows this migration.", 

1544 file=sys.stderr, 

1545 ) 

1546 sys.exit(1) 

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

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

1549 

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

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

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

1553 print( 

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

1555 file=sys.stderr, 

1556 ) 

1557 sys.exit(1) 

1558 

1559 print( 

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

1561 ) 

1562 try: 

1563 _drop_and_restore_schema(database_url, sql_bytes) 

1564 except RuntimeError as exc: 

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

1566 sys.exit(1) 

1567 

1568 # ── restore uploaded files ──────────────────────────────────────────── 

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

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

1571 # backup folder so nothing is silently destroyed. 

1572 import shutil as _shutil 

1573 

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

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

1576 os.makedirs(upload_folder, exist_ok=True) 

1577 os.makedirs(backup_folder, exist_ok=True) 

1578 

1579 _existing = [ 

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

1581 ] 

1582 if _existing: 

1583 from datetime import datetime 

1584 

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

1586 _enc_key_raw = _env_or_file("BACKUP_ENCRYPTION_KEY") 

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

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

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

1590 

1591 _snap_buf = io.BytesIO() 

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

1593 for _dp, _fn in _existing: 

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

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

1596 _zf.write(_fp, arcname=_rel) 

1597 _snap_bytes = _snap_buf.getvalue() 

1598 

1599 if _enc_key_raw: 

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

1601 _derive_key, 

1602 _encrypt_bytes, 

1603 ) 

1604 

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

1606 

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

1608 _fh.write(_snap_bytes) 

1609 

1610 print( 

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

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

1613 f" {_snap_path}\n" 

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

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

1616 ) 

1617 

1618 for _item in os.scandir(upload_folder): 

1619 if _item.is_dir(): 

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

1621 else: 

1622 os.unlink(_item.path) 

1623 

1624 if upload_entries: 

1625 upload_root = os.path.realpath(upload_folder) 

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

1627 for entry in upload_entries: 

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

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

1630 if not rel: 

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

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

1633 dest_real = os.path.realpath(dest) 

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

1635 upload_root + os.sep 

1636 ): 

1637 print( 

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

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

1640 "tampered archive).", 

1641 file=sys.stderr, 

1642 ) 

1643 sys.exit(1) 

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

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

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

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

1648 else: 

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

1650 

1651 print("Restore complete.") 

1652 

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

1654 def backup_now_command() -> None: 

1655 import sys 

1656 

1657 from config.routes import run_backup 

1658 

1659 try: 

1660 record = run_backup() 

1661 print( 

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

1663 ) 

1664 except RuntimeError as exc: 

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

1666 sys.exit(1) 

1667 

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

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

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

1671 def reset_db_command() -> None: 

1672 if flask_env != "demo": 

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

1674 return 

1675 from models import reset_schema 

1676 

1677 reset_schema(db) 

1678 print("Database schema reset.") 

1679 

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

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

1682 def seed_demo_command() -> None: 

1683 from demo_seed import seed as demo_seed 

1684 

1685 demo_seed() 

1686 print("Demo slots reseeded.") 

1687 

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

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

1690 if openaip_key: 

1691 from models import AppSetting 

1692 

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

1694 if setting is None: 

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

1696 else: 

1697 setting.value = openaip_key 

1698 db.session.commit() 

1699 print("Environment settings applied.") 

1700 

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

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

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

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

1705 "SQLALCHEMY_DATABASE_URI", "" 

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

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

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

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

1710 # checking there just adds a recurring app_settings race against 

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

1712 # are never the thing being "upgraded". 

1713 if flask_env == "production": 

1714 from services.version_service import ( 

1715 start_version_check_thread, # pyright: ignore[reportMissingImports] 

1716 ) 

1717 

1718 start_version_check_thread(app) 

1719 from sync_watcher import ( 

1720 start_sync_watcher, # pyright: ignore[reportMissingImports] 

1721 ) 

1722 

1723 start_sync_watcher(app) 

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

1725 _start_easa_sync_scheduler(app) 

1726 _start_notification_scheduler(app) 

1727 from services.backup_scheduler import ( 

1728 start_backup_scheduler, # pyright: ignore[reportMissingImports] 

1729 ) 

1730 

1731 start_backup_scheduler(app) 

1732 import threading 

1733 

1734 from services.notification_service import ( 

1735 send_welcome_email_if_needed, # pyright: ignore[reportMissingImports] 

1736 ) 

1737 

1738 threading.Thread( 

1739 target=send_welcome_email_if_needed, 

1740 args=(app,), 

1741 daemon=True, 

1742 name="welcome-email", 

1743 ).start() 

1744 

1745 if ( 

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

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

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

1749 ): 

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

1751 

1752 _validate_config(app) 

1753 return app 

1754 

1755 

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

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

1758 errors: list[str] = [] 

1759 

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

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

1762 if secret and len(secret) < 32: 

1763 errors.append( 

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

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

1766 ) 

1767 

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

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

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

1771 errors.append( 

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

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

1774 ) 

1775 

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

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

1778 _validated_max: int | None = None 

1779 if _raw_max: 

1780 try: 

1781 _parsed = int(_raw_max) 

1782 if _parsed <= 0: 

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

1784 else: 

1785 _validated_max = _parsed 

1786 except ValueError: 

1787 errors.append( 

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

1789 "Example: 52428800 for 50 MB." 

1790 ) 

1791 

1792 # OPENHANGAR_SYNC_SCAN_INTERVAL: must be a positive integer when set 

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

1794 if _raw_interval: 

1795 try: 

1796 _parsed_interval = int(_raw_interval) 

1797 if _parsed_interval <= 0: 

1798 errors.append( 

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

1800 ) 

1801 except ValueError: 

1802 errors.append( 

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

1804 "Example: 60" 

1805 ) 

1806 

1807 # OPENHANGAR_DATABASE_URL: production deployments must use PostgreSQL 

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

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

1810 if ( 

1811 "sqlite" not in db_url 

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

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

1814 ): 

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

1816 errors.append( 

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

1818 "Use 'postgresql://'." 

1819 ) 

1820 

1821 # OPENHANGAR_BACKUP_ENCRYPTION_KEY / OPENHANGAR_RESTORE_ENCRYPTION_KEY: 

1822 # whitespace-only values are likely a misconfiguration. 

1823 enc_key = _env_or_file("BACKUP_ENCRYPTION_KEY") 

1824 if enc_key and not enc_key.strip(): 

1825 errors.append( 

1826 "OPENHANGAR_BACKUP_ENCRYPTION_KEY is set but contains only whitespace. " 

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

1828 ) 

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

1830 if restore_enc_key and not restore_enc_key.strip(): 

1831 errors.append( 

1832 "OPENHANGAR_RESTORE_ENCRYPTION_KEY is set but contains only whitespace. " 

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

1834 ) 

1835 

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

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

1838 if _raw_smtp_port: 

1839 try: 

1840 _smtp_port_val = int(_raw_smtp_port) 

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

1842 errors.append( 

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

1844 ) 

1845 except ValueError: 

1846 errors.append( 

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

1848 ) 

1849 

1850 # OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES: must be a positive integer when set 

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

1852 if _raw_busy: 

1853 try: 

1854 if int(_raw_busy) <= 0: 

1855 errors.append( 

1856 "OPENHANGAR_DEMO_BUSY_WINDOW_MINUTES must be a positive integer" 

1857 ) 

1858 except ValueError: 

1859 errors.append( 

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

1861 f"got {_raw_busy!r}" 

1862 ) 

1863 

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

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

1866 if _raw_notif_time: 

1867 try: 

1868 _parse_notification_time() 

1869 except ValueError as exc: 

1870 errors.append(str(exc)) 

1871 

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

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

1874 parse_backup_keep, 

1875 parse_backup_keep_days, 

1876 parse_backup_keep_months, 

1877 parse_backup_keep_weeks, 

1878 parse_backup_retention, 

1879 parse_backup_time, 

1880 ) 

1881 

1882 for _backup_parser in ( 

1883 parse_backup_time, 

1884 parse_backup_keep, 

1885 parse_backup_retention, 

1886 parse_backup_keep_days, 

1887 parse_backup_keep_weeks, 

1888 parse_backup_keep_months, 

1889 ): 

1890 try: 

1891 _backup_parser() 

1892 except ValueError as exc: 

1893 errors.append(str(exc)) 

1894 

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

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

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

1898 errors.append( 

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

1900 f"got {_ntfy_url!r}" 

1901 ) 

1902 

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

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

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

1906 if _alert_email: 

1907 if "@" not in _alert_email: 

1908 errors.append( 

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

1910 f"got {_alert_email!r}" 

1911 ) 

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

1913 errors.append( 

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

1915 "alert emails cannot be delivered" 

1916 ) 

1917 

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

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

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

1921 errors.append( 

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

1923 f"got {_webhook_url!r}" 

1924 ) 

1925 

1926 if errors: 

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

1928 raise RuntimeError( 

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

1930 ) 

1931 

1932 if _validated_max is not None: 

1933 app.config["MAX_CONTENT_LENGTH"] = _validated_max 

1934 

1935 

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

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

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