Coverage for app/utils.py: 100%

672 statements  

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

1"""Shared utilities available to all blueprints.""" 

2 

3import csv 

4import functools 

5import io 

6import logging 

7import math 

8import os 

9from collections import defaultdict 

10from collections.abc import Callable 

11from functools import wraps 

12from typing import Any 

13 

14from flask import ( # pyright: ignore[reportMissingImports] 

15 abort, 

16 redirect, 

17 session, 

18 url_for, 

19) 

20from werkzeug.routing import ( # pyright: ignore[reportMissingImports] 

21 BaseConverter, 

22 ValidationError, 

23) 

24 

25_log = logging.getLogger(__name__) 

26 

27 

28def to_libpq_url(database_url: str) -> str: 

29 """Strip a SQLAlchemy dialect+driver suffix (e.g. postgresql+psycopg://) 

30 down to a plain postgresql:// URL, which is what libpq CLI tools 

31 (pg_dump, psql) expect — they don't understand the +driver suffix.""" 

32 scheme, sep, rest = database_url.partition("://") 

33 return f"{scheme.split('+', 1)[0]}{sep}{rest}" if sep else database_url 

34 

35 

36# ── Tracks GIF export ───────────────────────────────────────────────────────── 

37 

38_GIF_W, _GIF_H = 800, 480 

39_GIF_PAD = 30 # pixel padding around the track area 

40_GIF_STEP_MS = 600 # ms per frame in the animated GIF 

41_GIF_HOLD_MS = 3000 # ms for the final "all tracks" frame 

42_TRACK_COLOUR = (180, 30, 200) # current/newest track: vivid purple 

43_HISTORY_COLOUR = ( 

44 130, 

45 20, 

46 150, 

47) # previously drawn tracks: slightly darker purple (GIF uses flat colour; web uses opacity fade) 

48_BG_COLOUR = (248, 248, 252) # near-white background 

49 

50 

51def _mercator_y(lat_deg: float) -> float: 

52 """Web-Mercator y value for a latitude (not clamped).""" 

53 lat = math.radians(max(-85.0, min(85.0, lat_deg))) 

54 return math.log(math.tan(math.pi / 4 + lat / 2)) 

55 

56 

57def _build_gif_projection( 

58 all_coords: list[tuple[float, float]], 

59 canvas_w: int = _GIF_W, 

60 canvas_h: int = _GIF_H, 

61 pad: int = _GIF_PAD, 

62) -> "tuple[Any, Any] | None": 

63 """Return (project_fn, bbox) or None if there are fewer than 2 unique points.""" 

64 if len(all_coords) < 2: 

65 return None 

66 lons = [c[0] for c in all_coords] 

67 lats = [c[1] for c in all_coords] 

68 min_lon, max_lon = min(lons), max(lons) 

69 min_lat, max_lat = min(lats), max(lats) 

70 # Add small margin so tracks don't touch the edge 

71 dlon = max(max_lon - min_lon, 0.1) * 0.1 

72 dlat = max(max_lat - min_lat, 0.1) * 0.1 

73 min_lon -= dlon 

74 max_lon += dlon 

75 min_lat -= dlat 

76 max_lat += dlat 

77 

78 usable_w = canvas_w - 2 * pad 

79 usable_h = canvas_h - 2 * pad 

80 

81 y_min = _mercator_y(min_lat) 

82 y_max = _mercator_y(max_lat) 

83 y_range = y_max - y_min or 1e-9 # Mercator log-tan units (NOT degrees) 

84 x_range = max_lon - min_lon or 1e-9 # degrees 

85 

86 # Web Mercator geographic aspect: scale_y_mercator = scale_x * 180/π 

87 # (1 Mercator-y unit = 111 km at any latitude; 1° lon at centre = cos(φ)*111 km, 

88 # so equal-km scales satisfy scale_y = scale_x * 180/π). 

89 # Pick scale_x so BOTH axes fit: scale_x ≤ w/x_range AND scale_x * 180/π * y_range ≤ h. 

90 scale_x = min( 

91 usable_w / x_range, 

92 usable_h * math.pi / (180.0 * y_range), 

93 ) 

94 scale_y = scale_x * 180.0 / math.pi # pixels per Mercator-y unit 

95 

96 off_x = pad + (usable_w - x_range * scale_x) / 2 

97 off_y = pad + (usable_h - y_range * scale_y) / 2 

98 

99 def project(lon: float, lat: float) -> tuple[int, int]: 

100 px = off_x + (lon - min_lon) * scale_x 

101 py = off_y + (y_max - _mercator_y(lat)) * scale_y 

102 return int(px), int(py) 

103 

104 return project, (min_lon, min_lat, max_lon, max_lat) 

105 

106 

107def _coords_from_geojson(geojson: dict[str, Any] | None) -> list[tuple[float, float]]: 

108 """Extract (lon, lat) pairs from a GeoJSON Feature or FeatureCollection. 

109 

110 Silently skips a malformed coordinate entry (wrong shape, non-numeric, 

111 or non-finite) rather than raising — GpsTrack.geojson is a DB-stored 

112 JSON field with no enforced schema; a corrupted entry should degrade to 

113 "fewer points plotted", not crash track-image/GIF rendering with an 

114 unhandled 500. Non-finite is included because Python's json module 

115 accepts the non-standard "Infinity"/"NaN" tokens on load, and a NaN/inf 

116 "coordinate" isn't a valid lon/lat regardless — downstream Mercator 

117 projection math (_build_gif_projection) isn't finite-safe either. 

118 """ 

119 if not geojson: 

120 return [] 

121 if geojson.get("type") == "Feature": 

122 geom = geojson.get("geometry") or {} 

123 result = [] 

124 for c in geom.get("coordinates", []): 

125 try: 

126 if len(c) < 2: 

127 continue 

128 lon, lat = float(c[0]), float(c[1]) 

129 if math.isfinite(lon) and math.isfinite(lat): 

130 result.append((lon, lat)) 

131 except (TypeError, ValueError): 

132 continue 

133 return result 

134 if geojson.get("type") == "FeatureCollection": 

135 result = [] 

136 for feat in geojson.get("features", []): 

137 result.extend(_coords_from_geojson(feat)) 

138 return result 

139 return [] 

140 

141 

142def _make_tile_background( 

143 project: Callable[[float, float], tuple[int, int]], 

144 min_lon: float, 

145 max_lon: float, 

146 min_lat: float, 

147 max_lat: float, 

148 canvas_w: int, 

149 canvas_h: int, 

150 openaip_key: str | None = None, 

151 tile_cache: dict[Any, bytes] | None = None, 

152 max_tiles: int = 36, 

153) -> Any: 

154 """Fetch OSM raster tiles and composite them into a background PIL Image. 

155 

156 Returns a PIL Image, or None on failure (caller falls back to plain fill). 

157 Tiles are fetched from OpenStreetMap; zoom level is chosen automatically. 

158 Max 36 tiles are fetched to keep export time reasonable. 

159 

160 tile_cache, if provided, is a dict keyed by (z, tx, ty) or ("opi", z, tx, ty) 

161 holding raw PNG bytes so repeated calls across frames skip network fetches. 

162 """ 

163 import urllib.request 

164 

165 from PIL import Image as _Img # pyright: ignore[reportMissingImports] 

166 

167 # Compute pixels-per-degree-lon from the projection function 

168 mid_lat = (min_lat + max_lat) / 2.0 

169 px0x = project(min_lon, mid_lat)[0] 

170 px1x = project(min_lon + 1.0, mid_lat)[0] 

171 scale_x = float(px1x - px0x) # canvas pixels per degree-lon 

172 if scale_x <= 0: 

173 return None 

174 

175 # Choose zoom so each tile is ~256 canvas pixels wide (clamped 2–14) 

176 z = round(math.log2(max(scale_x * 360.0 / 256.0, 1.0))) 

177 z = max(2, min(z, 14)) 

178 n = 2**z 

179 

180 def _lon_to_tx(lon: float) -> int: 

181 return int(int((lon + 180.0) / 360.0 * n) % n) 

182 

183 def _lat_to_ty(lat: float) -> int: 

184 lat_r = math.radians(max(-85.0, min(85.0, lat))) 

185 return int( 

186 (1.0 - math.log(math.tan(lat_r) + 1.0 / math.cos(lat_r)) / math.pi) 

187 / 2.0 

188 * n 

189 ) 

190 

191 def _tile_nw_lonlat(tx: int, ty: int) -> tuple[float, float]: 

192 lon = tx * 360.0 / n - 180.0 

193 lat = math.degrees(math.atan(math.sinh(math.pi * (1.0 - 2.0 * ty / n)))) 

194 return lon, lat 

195 

196 tx_min = _lon_to_tx(min_lon) 

197 tx_max = _lon_to_tx(max_lon) 

198 ty_min = _lat_to_ty(max_lat) 

199 ty_max = _lat_to_ty(min_lat) 

200 

201 # Guard against tile count explosion 

202 if (tx_max - tx_min + 1) * (ty_max - ty_min + 1) > max_tiles: 

203 return None 

204 

205 bg = _Img.new("RGB", (canvas_w, canvas_h), _BG_COLOUR) 

206 ua = "OpenHangar flight-logbook GIF export (https://github.com/e2jk/OpenHangar)" 

207 

208 for tx in range(tx_min, tx_max + 1): 

209 for ty in range(ty_min, ty_max + 1): 

210 try: 

211 tx_w = tx % n 

212 # Compute pixel bounds for this tile by projecting its NW corner 

213 # and the NW corner of the tile to its SE — then size the resize 

214 # to exactly span that gap plus 1 px overlap, eliminating seams 

215 # caused by integer rounding of adjacent corner coordinates. 

216 lon_nw, lat_nw = _tile_nw_lonlat(tx, ty) 

217 lon_se, lat_se = _tile_nw_lonlat(tx + 1, ty + 1) 

218 px, py = project(lon_nw, lat_nw) 

219 px_se, py_se = project(lon_se, lat_se) 

220 tile_w = max(1, px_se - px + 1) 

221 tile_h = max(1, py_se - py + 1) 

222 

223 # Base map: CARTO light (same as web animation) 

224 base_key = (z, tx_w, ty) 

225 if tile_cache is not None and base_key in tile_cache: 

226 raw = tile_cache[base_key] 

227 else: 

228 base_url = ( 

229 f"https://a.basemaps.cartocdn.com/light_all/{z}/{tx_w}/{ty}.png" 

230 ) 

231 req = urllib.request.Request(base_url, headers={"User-Agent": ua}) 

232 with urllib.request.urlopen(req, timeout=5) as resp: # nosec B310 # base_url has a hardcoded https:// prefix 

233 raw = resp.read() 

234 if tile_cache is not None: 

235 tile_cache[base_key] = raw 

236 tile = _Img.open(io.BytesIO(raw)).convert("RGBA") 

237 tile = tile.resize((tile_w, tile_h), _Img.Resampling.LANCZOS) 

238 bg.paste(tile.convert("RGB"), (px, py)) 

239 # Aviation overlay: OpenAIP (only at zoom ≤ 14, requires key) 

240 if openaip_key and z <= 14: 

241 opi_key = ("opi", z, tx_w, ty) 

242 if tile_cache is not None and opi_key in tile_cache: 

243 opi_raw = tile_cache[opi_key] 

244 else: 

245 opi_url = f"https://api.tiles.openaip.net/api/data/openaip/{z}/{tx_w}/{ty}.png?apiKey={openaip_key}" 

246 opi_req = urllib.request.Request( 

247 opi_url, headers={"User-Agent": ua} 

248 ) 

249 with urllib.request.urlopen(opi_req, timeout=5) as opi_resp: # nosec B310 # opi_url has a hardcoded https:// prefix 

250 opi_raw = opi_resp.read() 

251 if tile_cache is not None: 

252 tile_cache[opi_key] = opi_raw 

253 opi_tile = _Img.open(io.BytesIO(opi_raw)).convert("RGBA") 

254 opi_tile = opi_tile.resize( 

255 (tile_w, tile_h), _Img.Resampling.LANCZOS 

256 ) 

257 bg.paste(opi_tile.convert("RGB"), (px, py), opi_tile) 

258 except Exception as exc: # noqa: BLE001 -- one bad tile must not fail the whole image 

259 _log.debug("OPI tile unavailable, leaving area as background: %s", exc) 

260 

261 return bg 

262 

263 

264def _canvas_geo_bounds( 

265 project_fn: Callable[[float, float], tuple[int, int]], 

266 canvas_w: int, 

267 canvas_h: int, 

268 min_lon: float, 

269 max_lon: float, 

270 min_lat: float, 

271 max_lat: float, 

272) -> tuple[float, float, float, float]: 

273 """Return (lon_min, lat_min, lon_max, lat_max) spanning the full canvas. 

274 

275 Expands the geographic bbox beyond the track bounding box so the full 

276 canvas area is covered by map tiles, eliminating plain-background padding. 

277 Returns the original bbox if the inverse projection cannot be computed. 

278 """ 

279 mid_lat = (min_lat + max_lat) / 2.0 

280 # Longitude is linear with pixel x in Mercator 

281 px0, _ = project_fn(min_lon, mid_lat) 

282 px1, _ = project_fn(min_lon + 1.0, mid_lat) 

283 scale_x = float(px1 - px0) 

284 if scale_x <= 0: 

285 return min_lon, min_lat, max_lon, max_lat 

286 c_lon_min = min_lon - float(px0) / scale_x 

287 c_lon_max = min_lon + float(canvas_w - px0) / scale_x 

288 

289 # Latitude: derive Mercator scale from two known projection points 

290 _, py_bot = project_fn(min_lon, min_lat) # larger py (bottom of canvas area) 

291 _, py_top = project_fn(min_lon, max_lat) # smaller py (top of canvas area) 

292 merc_bot = _mercator_y(min_lat) 

293 merc_top = _mercator_y(max_lat) 

294 merc_range = merc_top - merc_bot 

295 py_range = float(py_bot - py_top) 

296 if merc_range <= 0 or py_range <= 0: 

297 return c_lon_min, min_lat, c_lon_max, max_lat 

298 scale_y = py_range / merc_range # pixels per Mercator-y unit 

299 # Derived: merc_y at canvas row py = merc_top + (py_top - py) / scale_y 

300 merc_canvas_top = merc_top + float(py_top) / scale_y 

301 merc_canvas_bot = merc_top + float(py_top - canvas_h) / scale_y 

302 # Clamp to avoid math.atan overflow at extreme Mercator values 

303 merc_canvas_top = max(-10.0, min(10.0, merc_canvas_top)) 

304 merc_canvas_bot = max(-10.0, min(10.0, merc_canvas_bot)) 

305 c_lat_max = math.degrees(2.0 * math.atan(math.exp(merc_canvas_top)) - math.pi / 2.0) 

306 c_lat_min = math.degrees(2.0 * math.atan(math.exp(merc_canvas_bot)) - math.pi / 2.0) 

307 return c_lon_min, max(-85.0, c_lat_min), c_lon_max, min(85.0, c_lat_max) 

308 

309 

310def sort_tracks_oldest_first( 

311 rows: list[dict[str, Any]], 

312) -> list[dict[str, Any]]: 

313 """Return rows sorted ascending by their 'date' string key (oldest first). 

314 

315 Both the web animation and the GIF export use this ordering so that 

316 chronological playback and progressive opacity fading are consistent 

317 across rendering environments. Call this once in the route handler and 

318 pass the result to both the template context and generate_tracks_gif(). 

319 """ 

320 return sorted(rows, key=lambda r: r.get("date", "")) 

321 

322 

323def generate_tracks_gif( 

324 track_rows: list[dict[str, Any]], 

325 _font_path: str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 

326 _openaip_key: str | None = None, 

327 canvas_w: int = _GIF_W, 

328 canvas_h: int = _GIF_H, 

329 high_res: bool = False, 

330) -> bytes: 

331 """Render an animated GIF of the flight tracks, oldest-first. 

332 

333 track_rows must already be sorted oldest-first; use sort_tracks_oldest_first(). 

334 Each frame adds one more track (cumulative) and re-fits the map to the 

335 bounding box of tracks seen so far, creating a progressive zoom-out effect 

336 that mirrors the web animation. Previous tracks are drawn in a lighter 

337 colour; the newest one in vivid purple. Returns raw GIF bytes ready to 

338 stream to the browser. 

339 

340 high_res=True doubles line widths, font sizes and padding, uses a 256-colour 

341 palette, and raises the tile-fetch cap to 64 — producing a sharper map with 

342 readable labels at the cost of a larger file. 

343 """ 

344 from PIL import Image, ImageDraw, ImageFont # pyright: ignore[reportMissingImports] 

345 

346 _q_scale = 2 if high_res else 1 

347 _pad = _GIF_PAD * _q_scale 

348 _line_curr = 3 * _q_scale 

349 _line_hist = 2 * _q_scale 

350 _font_sz = 14 * _q_scale 

351 _font_sz_sm = 11 * _q_scale 

352 _txt_margin = 8 * _q_scale 

353 _q_colors = 256 if high_res else 128 

354 _max_tiles = 64 if high_res else 36 

355 # Canvas-extent calls cover the full 1600×960 canvas which can require 

356 # ~70–90 tiles at certain zoom levels (wide flat tracks at z=7), so use a 

357 # higher cap than the track-bbox call. 128 = 2× the high-res track cap. 

358 _max_tiles_canvas = _max_tiles * 2 if high_res else _max_tiles 

359 

360 # Pre-compute per-track coordinates (avoids re-parsing GeoJSON in the inner loop) 

361 per_track_coords = [_coords_from_geojson(r.get("geojson")) for r in track_rows] 

362 all_coords: list[tuple[float, float]] = [c for tc in per_track_coords for c in tc] 

363 

364 if len(all_coords) < 2: 

365 # Fallback: single blank frame 

366 img = Image.new("RGB", (canvas_w, canvas_h), _BG_COLOUR) 

367 buf = io.BytesIO() 

368 img.save(buf, format="GIF") 

369 return buf.getvalue() 

370 

371 font: Any # PIL font type varies across Pillow versions 

372 font_sm: Any 

373 try: 

374 font = ImageFont.truetype(_font_path, _font_sz) 

375 font_sm = ImageFont.truetype(_font_path, _font_sz_sm) 

376 except OSError: 

377 font = font_sm = ImageFont.load_default() 

378 

379 def draw_shadow_text(draw: Any, text: str, font: Any) -> None: 

380 draw.text( 

381 (_txt_margin + 1, _txt_margin + 1), text, fill=(255, 255, 255), font=font 

382 ) 

383 draw.text((_txt_margin, _txt_margin), text, fill=(40, 40, 60), font=font) 

384 

385 def draw_track( 

386 draw: Any, 

387 coords: list[tuple[float, float]], 

388 colour: tuple[int, int, int], 

389 width: int, 

390 project_fn: Callable[[float, float], tuple[int, int]], 

391 ) -> None: 

392 pts = [project_fn(lon, lat) for lon, lat in coords] 

393 if len(pts) >= 2: 

394 draw.line(pts, fill=colour, width=width) 

395 r = width + 2 * _q_scale 

396 sx, sy = pts[0] 

397 draw.ellipse([sx - r, sy - r, sx + r, sy + r], fill=(40, 160, 60)) 

398 ex, ey = pts[-1] 

399 draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(200, 40, 40)) 

400 

401 # Shared tile cache: (z, tx, ty) → raw PNG bytes; ("opi", z, tx, ty) for OpenAIP. 

402 # Avoids re-fetching tiles that appear in multiple frames at the same zoom level. 

403 tile_cache: dict[Any, bytes] = {} 

404 

405 def _frame_bg( 

406 project_fn: Callable[[float, float], tuple[int, int]], 

407 min_lon: float, 

408 max_lon: float, 

409 min_lat: float, 

410 max_lat: float, 

411 ) -> Any: 

412 fetch_lon_min, fetch_lat_min, fetch_lon_max, fetch_lat_max = ( 

413 min_lon, 

414 min_lat, 

415 max_lon, 

416 max_lat, 

417 ) 

418 if high_res: 

419 fetch_lon_min, fetch_lat_min, fetch_lon_max, fetch_lat_max = ( 

420 _canvas_geo_bounds( 

421 project_fn, 

422 canvas_w, 

423 canvas_h, 

424 min_lon, 

425 max_lon, 

426 min_lat, 

427 max_lat, 

428 ) 

429 ) 

430 bg = _make_tile_background( 

431 project_fn, 

432 fetch_lon_min, 

433 fetch_lon_max, 

434 fetch_lat_min, 

435 fetch_lat_max, 

436 canvas_w, 

437 canvas_h, 

438 openaip_key=_openaip_key, 

439 tile_cache=tile_cache, 

440 max_tiles=_max_tiles_canvas, 

441 ) 

442 if bg is None and high_res: 

443 # Canvas extent still exceeded the raised cap; fall back to track bbox 

444 bg = _make_tile_background( 

445 project_fn, 

446 min_lon, 

447 max_lon, 

448 min_lat, 

449 max_lat, 

450 canvas_w, 

451 canvas_h, 

452 openaip_key=_openaip_key, 

453 tile_cache=tile_cache, 

454 max_tiles=_max_tiles, 

455 ) 

456 return ( 

457 bg.copy() 

458 if bg is not None 

459 else Image.new("RGB", (canvas_w, canvas_h), _BG_COLOUR) 

460 ) 

461 

462 frames: list[Any] = [] 

463 durations: list[int] = [] 

464 accumulated_coords: list[tuple[float, float]] = [] 

465 

466 for frame_idx in range(len(track_rows)): 

467 accumulated_coords.extend(per_track_coords[frame_idx]) 

468 proj_result = _build_gif_projection( 

469 accumulated_coords, canvas_w=canvas_w, canvas_h=canvas_h, pad=_pad 

470 ) 

471 if proj_result is None: 

472 continue # not enough coords yet (e.g. leading rows with no geojson) 

473 

474 project_fn, (f_min_lon, f_min_lat, f_max_lon, f_max_lat) = proj_result 

475 img = _frame_bg(project_fn, f_min_lon, f_max_lon, f_min_lat, f_max_lat) 

476 draw = ImageDraw.Draw(img) 

477 

478 for i in range(frame_idx): 

479 draw_track( 

480 draw, per_track_coords[i], _HISTORY_COLOUR, _line_hist, project_fn 

481 ) 

482 draw_track( 

483 draw, per_track_coords[frame_idx], _TRACK_COLOUR, _line_curr, project_fn 

484 ) 

485 

486 row = track_rows[frame_idx] 

487 label = f"{row.get('date', '')} {row.get('dep', '')}{row.get('arr', '')}" 

488 draw_shadow_text(draw, label, font) 

489 draw.text( 

490 (_txt_margin, canvas_h - _font_sz_sm - _txt_margin), 

491 f"{frame_idx + 1} / {len(track_rows)}", 

492 fill=(80, 80, 100), 

493 font=font_sm, 

494 ) 

495 

496 frames.append(img) 

497 durations.append(_GIF_STEP_MS) 

498 

499 # Final frame: all tracks at equal weight using the full bounding box, longer hold 

500 if frames: 

501 proj_result_final = _build_gif_projection( 

502 all_coords, canvas_w=canvas_w, canvas_h=canvas_h, pad=_pad 

503 ) 

504 # proj_result_final is guaranteed non-None: all_coords has ≥ 2 points (checked above) 

505 project_final, (f_min_lon, f_min_lat, f_max_lon, f_max_lat) = proj_result_final # type: ignore[misc] 

506 img = _frame_bg(project_final, f_min_lon, f_max_lon, f_min_lat, f_max_lat) 

507 draw = ImageDraw.Draw(img) 

508 for tc in per_track_coords: 

509 draw_track(draw, tc, _TRACK_COLOUR, _line_hist, project_final) 

510 draw_shadow_text(draw, f"All {len(track_rows)} tracks", font) 

511 frames.append(img) 

512 durations.append(_GIF_HOLD_MS) 

513 

514 # Quantise all frames to a shared 128-colour palette built from the last frame 

515 # (widest view, most visually representative). A single global palette means 

516 # identical background areas compress very well with GIF's LZW codec. 

517 palette_src = frames[-1].quantize(colors=_q_colors, dither=Image.Dither.NONE) 

518 quantized: list[Any] = [ 

519 f.quantize(palette=palette_src, dither=Image.Dither.NONE) for f in frames 

520 ] 

521 

522 buf = io.BytesIO() 

523 quantized[0].save( 

524 buf, 

525 format="GIF", 

526 save_all=True, 

527 append_images=quantized[1:], 

528 loop=0, 

529 duration=durations, 

530 optimize=True, 

531 ) 

532 return buf.getvalue() 

533 

534 

535def generate_single_track_image( 

536 geojson: dict[str, Any] | None, 

537 date: str = "", 

538 dep: str = "", 

539 arr: str = "", 

540 _font_path: str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 

541 _openaip_key: str | None = None, 

542 canvas_w: int = _GIF_W, 

543 canvas_h: int = _GIF_H, 

544 high_res: bool = False, 

545) -> bytes: 

546 """Render a single GPS track as a static PNG. 

547 

548 Returns raw PNG bytes. If the track has fewer than 2 points a blank 

549 canvas is returned so the caller can always stream a valid image. 

550 """ 

551 from PIL import Image, ImageDraw, ImageFont # pyright: ignore[reportMissingImports] 

552 

553 all_coords = _coords_from_geojson(geojson) 

554 

555 _q_scale = 2 if high_res else 1 

556 _pad = _GIF_PAD * _q_scale 

557 _line_w = 3 * _q_scale 

558 _font_sz = 14 * _q_scale 

559 _txt_margin = 8 * _q_scale 

560 _max_tiles = 64 if high_res else 36 

561 _max_tiles_canvas = _max_tiles * 2 if high_res else _max_tiles 

562 

563 def _blank_png() -> bytes: 

564 buf = io.BytesIO() 

565 Image.new("RGB", (canvas_w, canvas_h), _BG_COLOUR).save(buf, format="PNG") 

566 return buf.getvalue() 

567 

568 if len(all_coords) < 2: 

569 return _blank_png() 

570 

571 proj_result = _build_gif_projection( 

572 all_coords, canvas_w=canvas_w, canvas_h=canvas_h, pad=_pad 

573 ) 

574 if proj_result is None: 

575 return _blank_png() 

576 

577 project_fn, (f_min_lon, f_min_lat, f_max_lon, f_max_lat) = proj_result 

578 

579 tile_cache: dict[Any, bytes] = {} 

580 fetch_lon_min, fetch_lat_min, fetch_lon_max, fetch_lat_max = ( 

581 f_min_lon, 

582 f_min_lat, 

583 f_max_lon, 

584 f_max_lat, 

585 ) 

586 if high_res: 

587 fetch_lon_min, fetch_lat_min, fetch_lon_max, fetch_lat_max = _canvas_geo_bounds( 

588 project_fn, canvas_w, canvas_h, f_min_lon, f_max_lon, f_min_lat, f_max_lat 

589 ) 

590 bg = _make_tile_background( 

591 project_fn, 

592 fetch_lon_min, 

593 fetch_lon_max, 

594 fetch_lat_min, 

595 fetch_lat_max, 

596 canvas_w, 

597 canvas_h, 

598 openaip_key=_openaip_key, 

599 tile_cache=tile_cache, 

600 max_tiles=_max_tiles_canvas, 

601 ) 

602 if bg is None and high_res: 

603 bg = _make_tile_background( 

604 project_fn, 

605 f_min_lon, 

606 f_max_lon, 

607 f_min_lat, 

608 f_max_lat, 

609 canvas_w, 

610 canvas_h, 

611 openaip_key=_openaip_key, 

612 tile_cache=tile_cache, 

613 max_tiles=_max_tiles, 

614 ) 

615 img = ( 

616 bg.copy() 

617 if bg is not None 

618 else Image.new("RGB", (canvas_w, canvas_h), _BG_COLOUR) 

619 ) 

620 

621 draw = ImageDraw.Draw(img) 

622 pts = [project_fn(lon, lat) for lon, lat in all_coords] 

623 if len(pts) >= 2: 

624 draw.line(pts, fill=_TRACK_COLOUR, width=_line_w) 

625 r = _line_w + 2 * _q_scale 

626 sx, sy = pts[0] 

627 draw.ellipse([sx - r, sy - r, sx + r, sy + r], fill=(40, 160, 60)) 

628 ex, ey = pts[-1] 

629 draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(200, 40, 40)) 

630 

631 label = f"{date} {dep}{arr}".strip(" →").strip() 

632 if label: 

633 try: 

634 font: Any = ImageFont.truetype(_font_path, _font_sz) 

635 except OSError: 

636 font = ImageFont.load_default() 

637 draw.text( 

638 (_txt_margin + 1, _txt_margin + 1), label, fill=(255, 255, 255), font=font 

639 ) 

640 draw.text((_txt_margin, _txt_margin), label, fill=(40, 40, 60), font=font) 

641 

642 buf = io.BytesIO() 

643 img.save(buf, format="PNG") 

644 return buf.getvalue() 

645 

646 

647def generate_single_track_gif( 

648 geojson: dict[str, Any] | None, 

649 date: str = "", 

650 dep: str = "", 

651 arr: str = "", 

652 _font_path: str = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 

653 _openaip_key: str | None = None, 

654 canvas_w: int = _GIF_W, 

655 canvas_h: int = _GIF_H, 

656 high_res: bool = False, 

657) -> bytes: 

658 """Render an animated GIF of a single flight track drawn progressively. 

659 

660 The track's coordinate array is split into N equal chunks (3–10 depending 

661 on track length). Each frame draws all chunks revealed so far, fitting the 

662 map bounding box to the accumulated coords so the view zooms out as the 

663 route grows — mirroring the per-flight behaviour of the web Animate button. 

664 

665 Falls back to a single-frame GIF wrapping the still PNG when the track has 

666 fewer than 20 GPS points (too sparse for meaningful animation). 

667 """ 

668 from PIL import Image, ImageDraw, ImageFont # pyright: ignore[reportMissingImports] 

669 

670 all_coords = _coords_from_geojson(geojson) 

671 

672 _q_scale = 2 if high_res else 1 

673 _pad = _GIF_PAD * _q_scale 

674 _line_curr = 3 * _q_scale 

675 _line_hist = 2 * _q_scale 

676 _font_sz = 14 * _q_scale 

677 _font_sz_sm = 11 * _q_scale 

678 _txt_margin = 8 * _q_scale 

679 _q_colors = 256 if high_res else 128 

680 _max_tiles = 64 if high_res else 36 

681 _max_tiles_canvas = _max_tiles * 2 if high_res else _max_tiles 

682 

683 def _blank_gif() -> bytes: 

684 buf = io.BytesIO() 

685 Image.new("RGB", (canvas_w, canvas_h), _BG_COLOUR).save(buf, format="GIF") 

686 return buf.getvalue() 

687 

688 if len(all_coords) < 2: 

689 return _blank_gif() 

690 

691 # Sparse-track fallback: wrap the still image in a single-frame GIF 

692 if len(all_coords) < 20: 

693 png = generate_single_track_image( 

694 geojson, 

695 date=date, 

696 dep=dep, 

697 arr=arr, 

698 _font_path=_font_path, 

699 _openaip_key=_openaip_key, 

700 canvas_w=canvas_w, 

701 canvas_h=canvas_h, 

702 high_res=high_res, 

703 ) 

704 img = Image.open(io.BytesIO(png)).convert("RGB") 

705 buf = io.BytesIO() 

706 img.quantize(colors=_q_colors, dither=Image.Dither.NONE).save(buf, format="GIF") 

707 return buf.getvalue() 

708 

709 # Split coords into N equal chunks (3–10) 

710 n_chunks = max(3, min(10, len(all_coords) // 10)) 

711 chunk_size = len(all_coords) // n_chunks 

712 chunks: list[list[tuple[float, float]]] = [ 

713 list(all_coords[i * chunk_size : (i + 1) * chunk_size]) 

714 for i in range(n_chunks - 1) 

715 ] 

716 chunks.append( 

717 list(all_coords[(n_chunks - 1) * chunk_size :]) 

718 ) # last catches remainder 

719 

720 try: 

721 font: Any = ImageFont.truetype(_font_path, _font_sz) 

722 font_sm: Any = ImageFont.truetype(_font_path, _font_sz_sm) 

723 except OSError: 

724 font = font_sm = ImageFont.load_default() 

725 

726 def draw_shadow_text(draw: Any, text: str, fnt: Any) -> None: 

727 draw.text( 

728 (_txt_margin + 1, _txt_margin + 1), text, fill=(255, 255, 255), font=fnt 

729 ) 

730 draw.text((_txt_margin, _txt_margin), text, fill=(40, 40, 60), font=fnt) 

731 

732 def draw_track( 

733 draw: Any, 

734 coords: list[tuple[float, float]], 

735 colour: tuple[int, int, int], 

736 width: int, 

737 project_fn: Callable[[float, float], tuple[int, int]], 

738 ) -> None: 

739 pts = [project_fn(lon, lat) for lon, lat in coords] 

740 if len(pts) >= 2: 

741 draw.line(pts, fill=colour, width=width) 

742 r = width + 2 * _q_scale 

743 sx, sy = pts[0] 

744 draw.ellipse([sx - r, sy - r, sx + r, sy + r], fill=(40, 160, 60)) 

745 ex, ey = pts[-1] 

746 draw.ellipse([ex - r, ey - r, ex + r, ey + r], fill=(200, 40, 40)) 

747 

748 tile_cache: dict[Any, bytes] = {} 

749 

750 def _frame_bg( 

751 project_fn: Callable[[float, float], tuple[int, int]], 

752 min_lon: float, 

753 max_lon: float, 

754 min_lat: float, 

755 max_lat: float, 

756 ) -> Any: 

757 fetch_lon_min, fetch_lat_min, fetch_lon_max, fetch_lat_max = ( 

758 min_lon, 

759 min_lat, 

760 max_lon, 

761 max_lat, 

762 ) 

763 if high_res: 

764 fetch_lon_min, fetch_lat_min, fetch_lon_max, fetch_lat_max = ( 

765 _canvas_geo_bounds( 

766 project_fn, canvas_w, canvas_h, min_lon, max_lon, min_lat, max_lat 

767 ) 

768 ) 

769 bg = _make_tile_background( 

770 project_fn, 

771 fetch_lon_min, 

772 fetch_lon_max, 

773 fetch_lat_min, 

774 fetch_lat_max, 

775 canvas_w, 

776 canvas_h, 

777 openaip_key=_openaip_key, 

778 tile_cache=tile_cache, 

779 max_tiles=_max_tiles_canvas, 

780 ) 

781 if bg is None and high_res: 

782 bg = _make_tile_background( 

783 project_fn, 

784 min_lon, 

785 max_lon, 

786 min_lat, 

787 max_lat, 

788 canvas_w, 

789 canvas_h, 

790 openaip_key=_openaip_key, 

791 tile_cache=tile_cache, 

792 max_tiles=_max_tiles, 

793 ) 

794 return ( 

795 bg.copy() 

796 if bg is not None 

797 else Image.new("RGB", (canvas_w, canvas_h), _BG_COLOUR) 

798 ) 

799 

800 label = f"{date} {dep}{arr}".strip(" →").strip() 

801 

802 frames: list[Any] = [] 

803 durations: list[int] = [] 

804 accumulated: list[tuple[float, float]] = [] 

805 

806 for chunk_idx, chunk in enumerate(chunks): 

807 accumulated.extend(chunk) 

808 proj_result = _build_gif_projection( 

809 accumulated, canvas_w=canvas_w, canvas_h=canvas_h, pad=_pad 

810 ) 

811 if proj_result is None: 

812 continue 

813 

814 project_fn, (f_min_lon, f_min_lat, f_max_lon, f_max_lat) = proj_result 

815 img = _frame_bg(project_fn, f_min_lon, f_max_lon, f_min_lat, f_max_lat) 

816 draw = ImageDraw.Draw(img) 

817 

818 for i in range(chunk_idx): 

819 draw_track(draw, chunks[i], _HISTORY_COLOUR, _line_hist, project_fn) 

820 draw_track(draw, chunk, _TRACK_COLOUR, _line_curr, project_fn) 

821 

822 if label: 

823 draw_shadow_text(draw, label, font) 

824 draw.text( 

825 (_txt_margin, canvas_h - _font_sz_sm - _txt_margin), 

826 f"{chunk_idx + 1} / {n_chunks}", 

827 fill=(80, 80, 100), 

828 font=font_sm, 

829 ) 

830 frames.append(img) 

831 durations.append(_GIF_STEP_MS) 

832 

833 if not frames: 

834 return _blank_gif() 

835 

836 # Final hold frame: full track, all chunks at equal weight 

837 proj_result_final = _build_gif_projection( 

838 list(all_coords), canvas_w=canvas_w, canvas_h=canvas_h, pad=_pad 

839 ) 

840 # guaranteed non-None: all_coords has ≥ 20 points 

841 project_final, (f_min_lon, f_min_lat, f_max_lon, f_max_lat) = proj_result_final # type: ignore[misc] 

842 img = _frame_bg(project_final, f_min_lon, f_max_lon, f_min_lat, f_max_lat) 

843 draw = ImageDraw.Draw(img) 

844 for chunk in chunks: 

845 draw_track(draw, chunk, _TRACK_COLOUR, _line_hist, project_final) 

846 if label: 

847 draw_shadow_text(draw, label, font) 

848 frames.append(img) 

849 durations.append(_GIF_HOLD_MS) 

850 

851 palette_src = frames[-1].quantize(colors=_q_colors, dither=Image.Dither.NONE) 

852 quantized: list[Any] = [ 

853 f.quantize(palette=palette_src, dither=Image.Dither.NONE) for f in frames 

854 ] 

855 buf = io.BytesIO() 

856 quantized[0].save( 

857 buf, 

858 format="GIF", 

859 save_all=True, 

860 append_images=quantized[1:], 

861 loop=0, 

862 duration=durations, 

863 optimize=True, 

864 ) 

865 return buf.getvalue() 

866 

867 

868@functools.lru_cache(maxsize=1) 

869def _load_aircraft_types() -> dict[str, tuple[str, str]]: 

870 """Return {type_designator: (manufacturer, model)} from aircraft_types.csv.""" 

871 path = os.path.join(os.path.dirname(__file__), "data", "aircraft_types.csv") 

872 result: dict[str, tuple[str, str]] = {} 

873 try: 

874 with open(path, newline="", encoding="utf-8") as f: 

875 for row in csv.DictReader(f): 

876 des = row.get("type_designator", "").strip().upper() 

877 mfr = row.get("manufacturer", "").strip() 

878 model = row.get("model", "").strip() 

879 if des: 

880 result[des] = (mfr, model) 

881 except OSError as exc: 

882 _log.warning("aircraft_types.csv not found: %s", exc) 

883 return result 

884 

885 

886def _load_aircraft_type_variants() -> list[tuple[str, str, str, str]]: 

887 """Return all (type_designator, full_name, manufacturer, model) tuples — one per CSV row. 

888 

889 Unlike _load_aircraft_types(), duplicate designators are preserved so 

890 the search endpoint can surface every variant (e.g. all PA-28-181 models 

891 that share the P28A ICAO code). manufacturer and model are kept separate 

892 so callers can pre-fill form fields for the exact selected variant rather 

893 than an arbitrary one sharing the same ICAO code. 

894 """ 

895 path = os.path.join(os.path.dirname(__file__), "data", "aircraft_types.csv") 

896 result: list[tuple[str, str, str, str]] = [] 

897 seen: set[tuple[str, str]] = set() 

898 try: 

899 with open(path, newline="", encoding="utf-8") as f: 

900 for row in csv.DictReader(f): 

901 des = row.get("type_designator", "").strip().upper() 

902 mfr = row.get("manufacturer", "").strip() 

903 model = row.get("model", "").strip() 

904 name = f"{mfr} {model}".strip() 

905 if des and (des, name) not in seen: 

906 result.append((des, name, mfr, model)) 

907 seen.add((des, name)) 

908 except OSError as exc: 

909 _log.warning("aircraft_types.csv not found: %s", exc) 

910 return result 

911 

912 

913@functools.lru_cache(maxsize=1) 

914def _load_aircraft_type_engine_data() -> dict[str, tuple[int, str]]: 

915 """Return {type_designator: (engine_count, engine_type)} from aircraft_types.csv. 

916 

917 For designators with multiple rows (variants), uses data from the first row. 

918 """ 

919 path = os.path.join(os.path.dirname(__file__), "data", "aircraft_types.csv") 

920 result: dict[str, tuple[int, str]] = {} 

921 try: 

922 with open(path, newline="", encoding="utf-8") as f: 

923 for row in csv.DictReader(f): 

924 des = row.get("type_designator", "").strip().upper() 

925 if not des or des in result: 

926 continue 

927 try: 

928 ec = int(row.get("engine_count", "1")) 

929 except ValueError: 

930 ec = 1 

931 et = row.get("engine_type", "").strip() 

932 result[des] = (ec, et) 

933 except OSError as exc: 

934 _log.warning("aircraft_types.csv not found: %s", exc) 

935 return result 

936 

937 

938def get_aircraft_type_engine_info(icao_code: str) -> tuple[int, str] | None: 

939 """Return (engine_count, engine_type) for the given ICAO code, or None.""" 

940 return _load_aircraft_type_engine_data().get(icao_code.strip().upper()) 

941 

942 

943@functools.lru_cache(maxsize=1) 

944def _build_model_name_prefix_lookup() -> dict[str, str]: 

945 """Return {compact_first_word_of_model: icao_code} for resolve_aircraft_type_icao fallback. 

946 

947 Enables matching pilot-logbook type strings like "DR401" or "PA28-161 TDI" 

948 against ICAO codes like "DR40" or "P28A" via the CSV model-name column. 

949 """ 

950 path = os.path.join(os.path.dirname(__file__), "data", "aircraft_types.csv") 

951 result: dict[str, str] = {} 

952 try: 

953 with open(path, newline="", encoding="utf-8") as f: 

954 for row in csv.DictReader(f): 

955 des = row.get("type_designator", "").strip().upper() 

956 model = row.get("model", "").strip() 

957 if not des or not model: 

958 continue 

959 first_word = model.split()[0] 

960 key = first_word.replace("-", "").replace(" ", "").upper() 

961 if key and key not in result: 

962 result[key] = des 

963 except OSError as exc: 

964 _log.warning("aircraft_types.csv not found: %s", exc) 

965 return result 

966 

967 

968@functools.lru_cache(maxsize=1) 

969def _sorted_model_prefix_keys() -> list[str]: 

970 """Keys of _build_model_name_prefix_lookup(), longest-first. 

971 

972 Cached separately from the lookup dict itself: found by fuzzing that 

973 resolve_aircraft_type_icao() re-sorted these ~1-2k keys on *every* call 

974 that reaches the fallback branch (i.e. almost every call — an exact ICAO 

975 code is rarely what a user types or a logbook CSV column holds), even 

976 though the lookup dict underneath never changes between calls. 

977 """ 

978 return sorted(_build_model_name_prefix_lookup(), key=len, reverse=True) 

979 

980 

981def resolve_aircraft_type_icao(aircraft_type: str | None) -> str | None: 

982 """Return the matching ICAO type designator for *aircraft_type*, or None.""" 

983 if not aircraft_type: 

984 return None 

985 types = _load_aircraft_types() 

986 norm = aircraft_type.strip().upper() 

987 if norm in types: 

988 return norm 

989 # Try stripping hyphens and spaces (e.g. "PA-28" → "PA28") 

990 compact = norm.replace("-", "").replace(" ", "") 

991 if compact in types: 

992 return compact 

993 # Try matching against the first word of each CSV model name (longest key wins). 

994 # e.g. "DR401" startswith "DR401" (from "DR-401 135CDI") → DR40 

995 # "PA28161TDI" startswith "PA28161" (from "PA-28-161 Cherokee…") → P28A 

996 # Guard: reject if the unmatched tail starts with a digit — that signals a 

997 # different model number (e.g. "C172RG" wrongly matching key "C17"). 

998 prefix_lookup = _build_model_name_prefix_lookup() 

999 for key in _sorted_model_prefix_keys(): 

1000 if len(key) < 4: 

1001 break # keys are sorted longest-first; stop once they're too short 

1002 if compact.startswith(key): 

1003 tail = compact[len(key) :] 

1004 if tail and tail[0].isdigit(): 

1005 continue # "C172RG" matching "C17" — tail "2RG" starts with digit 

1006 return prefix_lookup[key] 

1007 return None 

1008 

1009 

1010@functools.lru_cache(maxsize=1) 

1011def _load_airport_names() -> dict[str, str]: 

1012 """Return {ICAO ident: airport name} for all airports in airports.csv.""" 

1013 path = os.path.join(os.path.dirname(__file__), "data", "airports.csv") 

1014 result: dict[str, str] = {} 

1015 try: 

1016 with open(path, newline="", encoding="utf-8") as f: 

1017 for row in csv.DictReader(f): 

1018 ident = row.get("ident", "").strip() 

1019 name = row.get("name", "").strip() 

1020 if ident and name: 

1021 result[ident] = name 

1022 except OSError as exc: 

1023 _log.warning("airports.csv not found: %s", exc) 

1024 return result 

1025 

1026 

1027_alog = logging.getLogger("openhangar.activity") 

1028 

1029 

1030def _sl(value: object) -> str: 

1031 """Sanitize a value for log output — strips CR/LF to prevent log injection (CWE-117).""" 

1032 return str(value).replace("\r\n", "").replace("\n", "").replace("\r", "") 

1033 

1034 

1035def activity(event: str, **fields: object) -> None: 

1036 """Emit a structured [ACTIVITY] log entry with user_id and ip automatically included.""" 

1037 from flask import request, session 

1038 

1039 uid = session.get("user_id", "") 

1040 ip = request.remote_addr or "" 

1041 parts = [f"[ACTIVITY] {event}", f"user_id={_sl(uid)}", f"ip={_sl(ip)}"] 

1042 parts.extend(f"{k}={_sl(v)}" for k, v in fields.items()) 

1043 _alog.info(" ".join(parts)) 

1044 

1045 

1046class AircraftRefConverter(BaseConverter): 

1047 """URL converter for the ``aircraft_id`` slot: accepts either the numeric 

1048 primary key (unchanged, always works) or the aircraft's registration 

1049 (e.g. ``OO-GRN``), so routes like ``/aircraft/<aircraft_id>/flights`` 

1050 also resolve ``/aircraft/OO-GRN/flights``. 

1051 

1052 Authorization is unaffected: this only resolves the URL segment to a 

1053 primary key (or the reverse, for link generation) — every view still 

1054 does its own tenant-scoped lookup exactly as before, so a registration 

1055 belonging to another tenant 404s the same way a wrong numeric id does. 

1056 

1057 Registrations containing '/' or spaces (rare, but not forbidden by the 

1058 model) are sanitized the same way upload filenames already are 

1059 elsewhere in this blueprint; such an aircraft simply isn't reachable via 

1060 its pretty URL (only via the numeric id, which always works). 

1061 """ 

1062 

1063 regex = r"[^/]+" 

1064 

1065 def to_python(self, value: str) -> int: 

1066 if value.isdigit(): 

1067 return int(value) 

1068 

1069 from models import Aircraft, db 

1070 

1071 needle = value.upper() 

1072 ac = ( 

1073 Aircraft.query.filter( 

1074 db.func.upper( 

1075 db.func.replace( 

1076 db.func.replace(Aircraft.registration, "/", "-"), " ", "-" 

1077 ) 

1078 ) 

1079 == needle 

1080 ) 

1081 .order_by(Aircraft.id) 

1082 .first() 

1083 ) 

1084 if ac is None: 

1085 raise ValidationError() 

1086 return int(ac.id) 

1087 

1088 def to_url(self, value: Any) -> str: 

1089 if isinstance(value, str) and not value.isdigit(): 

1090 return super().to_url(value) 

1091 

1092 from models import Aircraft, db 

1093 

1094 ac = db.session.get(Aircraft, int(value)) 

1095 reg = ac.registration if ac and ac.registration else str(value) 

1096 safe_reg = reg.replace("/", "-").replace(" ", "-") 

1097 return super().to_url(safe_reg) 

1098 

1099 

1100def login_required(f: Callable[..., Any]) -> Callable[..., Any]: 

1101 """Redirect unauthenticated users to the login page.""" 

1102 

1103 @wraps(f) 

1104 def decorated(*args: Any, **kwargs: Any) -> Any: 

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

1106 return redirect(url_for("auth.login")) 

1107 return f(*args, **kwargs) 

1108 

1109 return decorated 

1110 

1111 

1112def require_instance_admin(f: Callable[..., Any]) -> Callable[..., Any]: 

1113 """Abort 403 unless the current user is the instance admin.""" 

1114 

1115 @wraps(f) 

1116 def decorated(*args: Any, **kwargs: Any) -> Any: 

1117 from models import User, db 

1118 

1119 user_id = session.get("user_id") 

1120 if not user_id: 

1121 return redirect(url_for("auth.login")) 

1122 user = db.session.get(User, user_id) 

1123 if not user or not user.is_instance_admin: 

1124 abort(403) 

1125 return f(*args, **kwargs) 

1126 

1127 return decorated 

1128 

1129 

1130def current_user_role() -> str | None: 

1131 """Return the Role of the current user in their tenant, or None.""" 

1132 from models import TenantUser 

1133 

1134 user_id = session.get("user_id") 

1135 if not user_id: 

1136 return None 

1137 tu = TenantUser.query.filter_by(user_id=user_id).first() 

1138 return tu.role if tu else None 

1139 

1140 

1141def check_update_available() -> bool: 

1142 """Return True when a newer release is available than the running instance. 

1143 

1144 Reads the ``update_available`` AppSetting written by the background 

1145 version-check service (computed once per check cycle, not per request). 

1146 Falls back to a live comparison against ``latest_version`` if the flag has 

1147 not been written yet (fresh install before the first background check). 

1148 Returns False on any error. Must be called inside a request (or 

1149 application) context. 

1150 """ 

1151 try: 

1152 from models import AppSetting, db # pyright: ignore[reportMissingImports] 

1153 

1154 flag = db.session.get(AppSetting, "update_available") 

1155 if flag is not None: 

1156 return bool(flag.value == "true") 

1157 

1158 # Fallback for fresh installs: compute from latest_version directly. 

1159 from packaging.version import Version # pyright: ignore[reportMissingImports] 

1160 

1161 current = os.environ.get("OPENHANGAR_VERSION", "development") 

1162 if current == "development": 

1163 return False 

1164 latest_s = db.session.get(AppSetting, "latest_version") 

1165 latest = latest_s.value if latest_s else None 

1166 return bool(latest and Version(latest) > Version(current)) 

1167 except Exception: # noqa: BLE001 -- cosmetic nav-badge hint, degrade to "no update" on any error 

1168 return False 

1169 

1170 

1171def check_legacy_logbook_data() -> bool: 

1172 """Return True when pre-unification logbook/crew data was preserved 

1173 but not migrated during the ``flight_crew``/``pilot_logbook_entries`` 

1174 table removal. 

1175 

1176 Reads the ``legacy_logbook_data_present`` AppSetting written by the 

1177 Alembic migration that dropped those two tables — set only when either 

1178 table still had rows at upgrade time (both tables are dropped outright, 

1179 with no flag set, when they were already empty). Returns False on any 

1180 error. Must be called inside a request (or application) context. 

1181 """ 

1182 try: 

1183 from models import AppSetting, db # pyright: ignore[reportMissingImports] 

1184 

1185 flag = db.session.get(AppSetting, "legacy_logbook_data_present") 

1186 return bool(flag is not None and flag.value == "true") 

1187 except Exception: # noqa: BLE001 -- cosmetic nav-badge hint, degrade to "no legacy data" on any error 

1188 return False 

1189 

1190 

1191def require_role(*roles: str) -> Callable[..., Any]: 

1192 """Decorator: abort 403 if the current user's role is not in *roles*.""" 

1193 

1194 def decorator(f: Callable[..., Any]) -> Callable[..., Any]: 

1195 @wraps(f) 

1196 def decorated(*args: Any, **kwargs: Any) -> Any: 

1197 if current_user_role() not in roles: 

1198 abort(403) 

1199 return f(*args, **kwargs) 

1200 

1201 return decorated 

1202 

1203 return decorator 

1204 

1205 

1206def require_pilot_access(f: Callable[..., Any]) -> Callable[..., Any]: 

1207 """Decorator: abort 403 unless the user has pilot access. 

1208 

1209 Pilot access is granted by ADMIN/OWNER/PILOT/STUDENT/INSTRUCTOR role, 

1210 or by the per-user is_pilot capability flag. 

1211 """ 

1212 

1213 @wraps(f) 

1214 def decorated(*args: Any, **kwargs: Any) -> Any: 

1215 from models import Role, User, db 

1216 

1217 role = current_user_role() 

1218 if role in (Role.ADMIN, Role.OWNER, Role.PILOT, Role.STUDENT, Role.INSTRUCTOR): 

1219 return f(*args, **kwargs) 

1220 uid = session.get("user_id") 

1221 if uid: 

1222 user = db.session.get(User, uid) 

1223 if user and user.is_pilot: 

1224 return f(*args, **kwargs) 

1225 return abort(403) 

1226 

1227 return decorated 

1228 

1229 

1230def require_maint_access(f: Callable[..., Any]) -> Callable[..., Any]: 

1231 """Decorator: abort 403 unless the user has maintenance access. 

1232 

1233 Maintenance access is granted by ADMIN/OWNER/MAINTENANCE/INSTRUCTOR role, 

1234 or by the per-user is_maintenance capability flag. 

1235 """ 

1236 

1237 @wraps(f) 

1238 def decorated(*args: Any, **kwargs: Any) -> Any: 

1239 from models import Role, User, db 

1240 

1241 role = current_user_role() 

1242 if role in (Role.ADMIN, Role.OWNER, Role.MAINTENANCE, Role.INSTRUCTOR): 

1243 return f(*args, **kwargs) 

1244 uid = session.get("user_id") 

1245 if uid: 

1246 user = db.session.get(User, uid) 

1247 if user and user.is_maintenance: 

1248 return f(*args, **kwargs) 

1249 return abort(403) 

1250 

1251 return decorated 

1252 

1253 

1254def user_can_access_aircraft(aircraft_id: int) -> bool: 

1255 """Return True when the current user may access this aircraft. 

1256 

1257 ADMIN and OWNER bypass the check entirely. Other roles need either a 

1258 UserAllAircraftAccess row (all-planes grant) or a per-aircraft 

1259 UserAircraftAccess row. 

1260 """ 

1261 from models import Role, TenantUser, UserAircraftAccess, UserAllAircraftAccess 

1262 

1263 role = current_user_role() 

1264 if role in (Role.ADMIN, Role.OWNER): 

1265 return True 

1266 uid = session.get("user_id") 

1267 if not uid: 

1268 return False 

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

1270 if ( 

1271 tu 

1272 and UserAllAircraftAccess.query.filter_by( 

1273 user_id=uid, tenant_id=tu.tenant_id 

1274 ).first() 

1275 ): 

1276 return True 

1277 return ( 

1278 UserAircraftAccess.query.filter_by(user_id=uid, aircraft_id=aircraft_id).first() 

1279 is not None 

1280 ) 

1281 

1282 

1283def accessible_aircraft(tenant_id: int, include_archived: bool = False) -> Any: 

1284 """Return a query of Aircraft the current user is allowed to see. 

1285 

1286 ADMIN and OWNER see every aircraft in the tenant. A user with a 

1287 UserAllAircraftAccess row for the tenant also sees all aircraft. 

1288 Other roles see only aircraft granted via UserAircraftAccess. 

1289 

1290 Archived aircraft are excluded unless include_archived is True — pass it 

1291 for views that must keep showing an archived aircraft's history. 

1292 """ 

1293 from models import Aircraft, Role, UserAircraftAccess, UserAllAircraftAccess 

1294 

1295 base = Aircraft.query.filter_by(tenant_id=tenant_id).order_by(Aircraft.registration) 

1296 if not include_archived: 

1297 base = base.filter(Aircraft.archived_at.is_(None)) 

1298 role = current_user_role() 

1299 if role in (Role.ADMIN, Role.OWNER): 

1300 return base 

1301 uid = session.get("user_id") 

1302 if not uid: 

1303 from sqlalchemy import false 

1304 

1305 return base.filter(false()) 

1306 if UserAllAircraftAccess.query.filter_by(user_id=uid, tenant_id=tenant_id).first(): 

1307 return base 

1308 ids = [ 

1309 row.aircraft_id 

1310 for row in ( 

1311 UserAircraftAccess.query.filter_by(user_id=uid) 

1312 .with_entities(UserAircraftAccess.aircraft_id) 

1313 .all() 

1314 ) 

1315 ] 

1316 if not ids: 

1317 from sqlalchemy import false 

1318 

1319 return base.filter(false()) 

1320 return base.filter(Aircraft.id.in_(ids)) 

1321 

1322 

1323def compute_aircraft_statuses( 

1324 aircraft_list: Any, 

1325 triggers: Any, 

1326 hobbs_by_id: Any, 

1327 landings_by_id: Any = None, 

1328 flight_hours_by_id: Any = None, 

1329) -> dict[int, str]: 

1330 """Return {aircraft_id: 'grounded'|'overdue'|'due_soon'|'ok'} for every aircraft. 

1331 

1332 Grounded (expired insurance or unresolved grounding snag) takes priority. 

1333 Among maintenance: overdue > due_soon > ok — maintenance triggers, 

1334 component TBO / calendar life limits, and expiring insurance all count. 

1335 

1336 ``landings_by_id``/``flight_hours_by_id`` default to an empty mapping — 

1337 callers that don't (yet) pass them just get 'ok' from any landings- or 

1338 flight-hours-basis trigger, same as passing no ``hobbs_by_id`` entry 

1339 does for engine-hours-basis ones. 

1340 """ 

1341 from services.component_limits import fleet_limit_statuses 

1342 

1343 if landings_by_id is None: 

1344 landings_by_id = {} 

1345 if flight_hours_by_id is None: 

1346 flight_hours_by_id = {} 

1347 

1348 by_aircraft = defaultdict(list) 

1349 for t in triggers: 

1350 by_aircraft[t.aircraft_id].append(t) 

1351 limit_status_by_ac = fleet_limit_statuses(aircraft_list) 

1352 

1353 result = {} 

1354 for ac in aircraft_list: 

1355 if ac.is_grounded: 

1356 result[ac.id] = "grounded" 

1357 continue 

1358 hobbs = hobbs_by_id.get(ac.id) 

1359 landings = landings_by_id.get(ac.id) 

1360 flight_hours = flight_hours_by_id.get(ac.id) 

1361 statuses = [ 

1362 t.status( 

1363 current_engine_hours=hobbs, 

1364 current_landings=landings, 

1365 current_flight_hours=flight_hours, 

1366 ) 

1367 for t in by_aircraft.get(ac.id, []) 

1368 ] 

1369 statuses.append(limit_status_by_ac.get(ac.id, "ok")) 

1370 ins = ac.insurance_status 

1371 if ins == "expiring_soon": 

1372 statuses.append("due_soon") 

1373 if "overdue" in statuses: 

1374 result[ac.id] = "overdue" 

1375 elif "due_soon" in statuses: 

1376 result[ac.id] = "due_soon" 

1377 else: 

1378 result[ac.id] = "ok" 

1379 return result