Coverage for app/flights/routes.py: 100%

908 statements  

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

1import contextlib 

2import decimal 

3import json as _json 

4import os 

5import uuid 

6from datetime import ( 

7 UTC, 

8) 

9from datetime import ( 

10 date as _date, 

11) 

12from datetime import ( 

13 datetime as _datetime, 

14) 

15from datetime import ( 

16 time as _time, 

17) 

18from datetime import ( 

19 timedelta as _timedelta, 

20) 

21from typing import Any 

22 

23from extensions import _rate_limiting_disabled # pyright: ignore[reportMissingImports] 

24from extensions import limiter as _limiter 

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

26 Blueprint, 

27 abort, 

28 current_app, 

29 flash, 

30 jsonify, 

31 redirect, 

32 render_template, 

33 request, 

34 send_from_directory, 

35 session, 

36 url_for, 

37) 

38from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports] 

39from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

40from models import ( 

41 Aircraft, 

42 AppSetting, 

43 Component, 

44 CrewRole, 

45 Document, 

46 Flight, 

47 GpsTrack, 

48 Reservation, 

49 ReservationStatus, 

50 Role, 

51 TenantUser, 

52 User, 

53 db, 

54) # pyright: ignore[reportMissingImports] 

55from pilots.personal_minimums import ( # pyright: ignore[reportMissingImports] 

56 get_active_revision, 

57 recency_breaches, 

58) 

59from sqlalchemy import func, or_ # pyright: ignore[reportMissingImports] 

60from utils import ( 

61 accessible_aircraft, 

62 activity, 

63 login_required, 

64 require_pilot_access, 

65 require_role, 

66 user_can_access_aircraft, 

67) # pyright: ignore[reportMissingImports] 

68from werkzeug.utils import secure_filename 

69 

70from flights.form_parsing import ( # pyright: ignore[reportMissingImports] 

71 apply_flight_fields, 

72 flight_is_lenient, 

73 parse_flight_fields, 

74) 

75 

76flights_bp = Blueprint("flights", __name__) 

77 

78_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"} 

79_ALLOWED_GPS_EXTS = {".gpx", ".kml", ".csv"} 

80_FUEL_UNITS = ["L", "gal"] 

81_NATURE_SUGGESTIONS = [ 

82 "Local flight", 

83 "Navigation", 

84 "Cross-country", 

85 "Training", 

86 "IFR practice", 

87 "Night flight", 

88 "Touch-and-go", 

89 "Ferry flight", 

90 "Air test", 

91 "Sightseeing", 

92] 

93 

94_HOUR_MILESTONES = [100, 500, 1000, 2000, 5000] 

95 

96 

97def _openaip_key() -> str | None: 

98 s = db.session.get(AppSetting, "openaip_api_key") 

99 return s.value if s and s.value else None 

100 

101 

102def _tenant_id() -> int: 

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

104 if not tu: 

105 abort(403) 

106 return int(tu.tenant_id) 

107 

108 

109def _check_flight_hour_milestone(fe: Flight) -> None: 

110 """Set a one-shot session flag when total fleet hours cross a milestone.""" 

111 this_flight = float(fe.flight_time or 0) 

112 if this_flight <= 0: 

113 return 

114 tid = _tenant_id() 

115 aircraft_ids = [a.id for a in accessible_aircraft(tid).all()] 

116 new_total = float( 

117 db.session.query(func.sum(Flight.flight_time)) 

118 .filter(Flight.aircraft_id.in_(aircraft_ids)) 

119 .scalar() 

120 or 0 

121 ) 

122 old_total = new_total - this_flight 

123 for milestone in _HOUR_MILESTONES: 

124 if old_total < milestone <= new_total: 

125 session["milestone_hours"] = milestone 

126 flash( 

127 _( 

128 "🎉 You just crossed %(hours)s flight hours!", 

129 hours=milestone, 

130 ), 

131 "info", 

132 ) 

133 break 

134 

135 

136def _get_aircraft_or_404(aircraft_id: int) -> Aircraft: 

137 ac = db.session.get(Aircraft, aircraft_id) 

138 if ( 

139 not ac 

140 or ac.tenant_id != _tenant_id() 

141 or not user_can_access_aircraft(aircraft_id) 

142 ): 

143 abort(404) 

144 return ac 

145 

146 

147def _get_flight_or_404(flight_id: int) -> Flight: 

148 """Fetch a Flight row, authorizing by tenant (managed-aircraft rows) or 

149 by crew identity (standalone rows, aircraft_id NULL — no tenant to check, 

150 so only the pic/second-crew occupant may access it).""" 

151 fe = db.session.get(Flight, flight_id) 

152 if not fe: 

153 abort(404) 

154 if fe.aircraft_id is not None: 

155 ac = db.session.get(Aircraft, fe.aircraft_id) 

156 if not ac or ac.tenant_id != _tenant_id(): 

157 abort(404) 

158 else: 

159 uid = session.get("user_id") 

160 if fe.pic_user_id != uid and fe.second_crew_user_id != uid: 

161 abort(404) 

162 return fe 

163 

164 

165def _save_upload(file: Any, flight_id: int, label: str) -> str | None: 

166 # secure_filename() raises TypeError on None — file.filename is None 

167 # (not just "") when a multipart part omits the filename= attribute 

168 # entirely. The only current caller already guards this, but this 

169 # function shouldn't rely on that (found auditing every secure_filename 

170 # call site for the fuzzing backlog's "extension-allowlist logic" 

171 # entry). 

172 ext = os.path.splitext(secure_filename(file.filename or ""))[1].lower() 

173 if ext not in _ALLOWED_PHOTO_EXTS: 

174 return None 

175 stored = f"flight_{flight_id}_{label}_{uuid.uuid4().hex[:8]}{ext}" 

176 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

177 os.makedirs(folder, exist_ok=True) 

178 file.save(os.path.join(folder, stored)) 

179 return stored 

180 

181 

182def _delete_upload(filename: str | None) -> None: 

183 if not filename: 

184 return 

185 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

186 try: 

187 os.remove(os.path.join(folder, filename)) 

188 except OSError: 

189 current_app.logger.debug( 

190 "Could not delete upload %s (already absent?)", filename 

191 ) 

192 

193 

194def _nature_suggestions(aircraft_id: int) -> list[str]: 

195 used = [ 

196 row[0] 

197 for row in db.session.query(Flight.nature_of_flight) 

198 .filter_by(aircraft_id=aircraft_id) 

199 .filter(Flight.nature_of_flight.isnot(None)) 

200 .distinct() 

201 .all() 

202 ] 

203 return _NATURE_SUGGESTIONS + [n for n in used if n not in _NATURE_SUGGESTIONS] 

204 

205 

206def _parse_gps_upload(file: Any) -> dict[str, Any] | None: 

207 """Parse a single GPS file. Returns autofill dict or None.""" 

208 try: 

209 from aircraft.gps_import import ( # pyright: ignore[reportMissingImports] 

210 detect_segments, 

211 merge_and_sort, 

212 parse_gps_file, 

213 ) 

214 except ImportError: 

215 return None 

216 filename = secure_filename(file.filename or "") 

217 ext = os.path.splitext(filename)[1].lower() 

218 if ext not in _ALLOWED_GPS_EXTS: 

219 return None 

220 data = file.read() 

221 try: 

222 parsed = parse_gps_file(data, filename) 

223 all_points = merge_and_sort([parsed]) 

224 segments = detect_segments(all_points) 

225 except Exception: # noqa: BLE001 -- untrusted uploaded GPS file, many possible parse failures 

226 return None 

227 if not segments: 

228 return None 

229 seg = segments[0] 

230 return { 

231 "filename": filename, 

232 "device_id": parsed.device_id, 

233 "block_off_utc": seg.block_off_utc, 

234 "block_on_utc": seg.block_on_utc, 

235 "date": seg.block_off_utc.date(), 

236 "departure_icao": seg.departure_icao or seg.hint_departure_icao or "", 

237 "arrival_icao": seg.arrival_icao or seg.hint_arrival_icao or "", 

238 "departure_time": seg.block_off_utc.time(), 

239 "arrival_time": seg.block_on_utc.time(), 

240 "flight_time_h": round(seg.flight_time_raw_h, 1), 

241 "geojson": seg.track_geojson, 

242 "landing_count": seg.landing_count, 

243 } 

244 

245 

246def _find_duplicate_flight( 

247 aircraft_id: int | None, 

248 pilot_user_id: int, 

249 date: _date, 

250 dep_icao: str, 

251 arr_icao: str, 

252 block_off: _datetime | None, 

253 block_on: _datetime | None, 

254 exclude_flight_id: int | None = None, 

255) -> dict[str, Any] | None: 

256 """Return info about a matching Flight row, or None. 

257 

258 Unified model note: a pilot's own standalone entry and an aircraft-log 

259 entry are the same table now, so there's only one id space to exclude — 

260 the old separate ``exclude_pilot_entry_id`` parameter is gone, since 

261 "my own linked entry for this flight" and "this flight" are the same 

262 row and the same id. 

263 """ 

264 if aircraft_id and block_off and block_on: 

265 q = Flight.query.filter( 

266 Flight.aircraft_id == aircraft_id, 

267 Flight.block_off_utc.isnot(None), 

268 Flight.block_on_utc.isnot(None), 

269 Flight.block_off_utc < block_on, 

270 Flight.block_on_utc > block_off, 

271 ) 

272 if exclude_flight_id: 

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

274 existing = q.first() 

275 if existing: 

276 return {"type": "flight", "entry": existing} 

277 

278 if aircraft_id and not block_off: 

279 q2 = Flight.query.filter_by( 

280 aircraft_id=aircraft_id, 

281 date=date, 

282 departure_icao=dep_icao, 

283 arrival_icao=arr_icao, 

284 ) 

285 if exclude_flight_id: 

286 q2 = q2.filter(Flight.id != exclude_flight_id) 

287 existing2 = q2.first() 

288 if existing2: 

289 return {"type": "flight", "entry": existing2} 

290 

291 q3 = Flight.query.filter( 

292 or_( 

293 Flight.pic_user_id == pilot_user_id, 

294 Flight.second_crew_user_id == pilot_user_id, 

295 ), 

296 Flight.date == date, 

297 Flight.departure_icao == dep_icao, 

298 Flight.arrival_icao == arr_icao, 

299 ) 

300 if exclude_flight_id: 

301 q3 = q3.filter(Flight.id != exclude_flight_id) 

302 existing3 = q3.first() 

303 if existing3: 

304 return {"type": "pilot", "entry": existing3} 

305 

306 return None 

307 

308 

309def _get_counter_hint(aircraft_id: int) -> dict[str, float | None]: 

310 last = ( 

311 Flight.query.filter_by(aircraft_id=aircraft_id) 

312 .filter( 

313 db.or_( 

314 Flight.flight_time_counter_end.isnot(None), 

315 Flight.engine_time_counter_end.isnot(None), 

316 ) 

317 ) 

318 .order_by( 

319 Flight.date.desc(), 

320 Flight.departure_time.desc().nullslast(), 

321 Flight.id.desc(), 

322 ) 

323 .first() 

324 ) 

325 if not last: 

326 return {"flight": None, "engine": None} 

327 return { 

328 "flight": float(last.flight_time_counter_end) 

329 if last.flight_time_counter_end is not None 

330 else None, 

331 "engine": float(last.engine_time_counter_end) 

332 if last.engine_time_counter_end is not None 

333 else None, 

334 } 

335 

336 

337# Phase 37d: how far outside a reservation's booked window a flight may 

338# still fall and be auto-linked to it — absorbs early departures / late 

339# returns. A constant, not a per-tenant setting, per the spec. 

340_RESERVATION_LINK_BEFORE = _timedelta(hours=2) 

341_RESERVATION_LINK_AFTER = _timedelta(hours=6) 

342 

343 

344def _find_covering_reservation( 

345 aircraft_id: int, pilot_user_id: int, anchor: _datetime 

346) -> Reservation | None: 

347 """A CONFIRMED reservation for this pilot on this aircraft whose booked 

348 window (± tolerance) contains *anchor* — never linked across pilots.""" 

349 candidates: list[Reservation] = Reservation.query.filter_by( 

350 aircraft_id=aircraft_id, 

351 pilot_user_id=pilot_user_id, 

352 status=ReservationStatus.CONFIRMED, 

353 ).all() 

354 for r in candidates: 

355 # SQLite returns naive datetimes even for DateTime(timezone=True) 

356 # columns; PostgreSQL returns timezone-aware. Normalize the compare. 

357 cmp_anchor = ( 

358 anchor.replace(tzinfo=None) if r.start_dt.tzinfo is None else anchor 

359 ) 

360 if ( 

361 r.start_dt - _RESERVATION_LINK_BEFORE 

362 <= cmp_anchor 

363 <= r.end_dt + _RESERVATION_LINK_AFTER 

364 ): 

365 return r 

366 return None 

367 

368 

369def _ac_category(ac: Aircraft) -> str: 

370 return getattr(ac, "category", "SEP") or "SEP" 

371 

372 

373def apply_pilot_identity( 

374 fe: Flight, 

375 ac: Aircraft | None, 

376 uid: int, 

377 pilot_role: str, 

378) -> None: 

379 """Resolve the current user's own EASA figures onto whichever crew slot 

380 matches their `pilot_role` ("pic" -> `pic_user_id`, "dual" -> 

381 `second_crew_user_id`), and (re)compute the single_pilot_se/me split 

382 from `fe.flight_time` and `ac`'s category. 

383 

384 Call only when `pilot_role` is "pic" or "dual". The caller is 

385 responsible for assigning `fe`'s other shared EASA fields (night_time, 

386 instrument_time, landings_day/night, multi_pilot) from the submitted 

387 form values first — those aren't tied to identity resolution, unlike 

388 the fields here. 

389 

390 `ac` is None for the "other aircraft" case (no fleet aircraft to derive 

391 a category from) — treated as single-engine, matching the old inline 

392 no-fleet-aircraft branch's behaviour. 

393 

394 Two pilots logging the same real flight must never end up with 

395 different figures for it — that was a bug in the pre-refactor 

396 two-table design, not a feature; this unified row makes it structurally 

397 impossible, since there's only one set of EASA figures per flight. 

398 """ 

399 ft_decimal = fe.flight_time 

400 cat = _ac_category(ac) if ac is not None else "SEP" 

401 fe.single_pilot_se = ft_decimal if cat in ("SEP", "SET", "") else None 

402 fe.single_pilot_me = ft_decimal if cat in ("MEP", "MET") else None 

403 

404 _u = db.session.get(User, uid) 

405 display_name = _u.display_name if _u else "" 

406 

407 if pilot_role == "pic": 

408 fe.pic_user_id = uid 

409 if not fe.pic_name: 

410 fe.pic_name = display_name 

411 fe.function_pic = ft_decimal 

412 fe.function_dual = None 

413 else: # "dual" 

414 fe.second_crew_user_id = uid 

415 if not fe.second_crew_name: 

416 fe.second_crew_name = display_name 

417 if not fe.second_crew_role: 

418 fe.second_crew_role = CrewRole.STUDENT 

419 fe.function_dual = ft_decimal 

420 fe.function_pic = None 

421 

422 

423# ── Serve uploads ───────────────────────────────────────────────────────────── 

424 

425 

426@flights_bp.route("/uploads/<path:filename>") 

427@login_required 

428def serve_upload(filename: str) -> ResponseReturnValue: 

429 # Verify the requesting user may see this file before serving it. 

430 doc = Document.query.filter_by(filename=filename).first() 

431 if doc is not None: 

432 if doc.aircraft_id is not None: 

433 # Covers aircraft docs and component docs (which always carry aircraft_id too). 

434 _get_aircraft_or_404( 

435 doc.aircraft_id 

436 ) # aborts 404 if wrong tenant/no access 

437 elif doc.flight_entry_id is not None: 

438 _get_flight_or_404(doc.flight_entry_id) 

439 elif doc.pilot_user_id is not None: 

440 if doc.pilot_user_id != session["user_id"]: 

441 abort(404) 

442 else: 

443 abort(404) 

444 else: 

445 # Counter and fuel photos are stored directly on Flight (not via Document). 

446 fe = Flight.query.filter( 

447 or_( 

448 Flight.flight_counter_photo == filename, 

449 Flight.engine_counter_photo == filename, 

450 Flight.fuel_photo == filename, 

451 ) 

452 ).first() 

453 if fe is None: 

454 abort(404) 

455 _get_flight_or_404(fe.id) 

456 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

457 return send_from_directory(folder, filename) 

458 

459 

460# ── Fleet logbook ───────────────────────────────────────────────────────────── 

461 

462 

463@flights_bp.route("/flights") 

464@login_required 

465def fleet_flights() -> ResponseReturnValue: 

466 tid = _tenant_id() 

467 aircraft_list = accessible_aircraft(tid, include_archived=True).all() 

468 aircraft_map = {ac.id: ac for ac in aircraft_list} 

469 flights = ( 

470 Flight.query.filter(Flight.aircraft_id.in_([ac.id for ac in aircraft_list])) 

471 .order_by( 

472 Flight.date.desc(), 

473 Flight.departure_time.desc().nullslast(), 

474 Flight.id.desc(), 

475 ) 

476 .all() 

477 ) 

478 return render_template( 

479 "flights/fleet.html", flights=flights, aircraft_map=aircraft_map 

480 ) 

481 

482 

483# ── Airframe logbook ────────────────────────────────────────────────────────── 

484 

485 

486@flights_bp.route("/aircraft/<aircraft_ref:aircraft_id>/flights") 

487@login_required 

488def list_flights(aircraft_id: int) -> ResponseReturnValue: 

489 ac = _get_aircraft_or_404(aircraft_id) 

490 flights = ( 

491 Flight.query.filter_by(aircraft_id=ac.id) 

492 .order_by( 

493 Flight.date.desc(), 

494 Flight.departure_time.desc().nullslast(), 

495 Flight.id.desc(), 

496 ) 

497 .all() 

498 ) 

499 milestone_hours = session.pop("milestone_hours", None) 

500 return render_template( 

501 "flights/list.html", 

502 aircraft=ac, 

503 flights=flights, 

504 milestone_hours=milestone_hours, 

505 ) 

506 

507 

508# ── Component logbook ───────────────────────────────────────────────────────── 

509 

510 

511@flights_bp.route( 

512 "/aircraft/<aircraft_ref:aircraft_id>/components/<int:component_id>/logbook" 

513) 

514@login_required 

515def component_logbook(aircraft_id: int, component_id: int) -> ResponseReturnValue: 

516 ac = _get_aircraft_or_404(aircraft_id) 

517 comp = db.session.get(Component, component_id) 

518 if not comp or comp.aircraft_id != ac.id: 

519 abort(404) 

520 

521 query = Flight.query.filter_by(aircraft_id=ac.id) 

522 if comp.installed_at: 

523 query = query.filter(Flight.date >= comp.installed_at) 

524 if comp.removed_at: 

525 query = query.filter(Flight.date <= comp.removed_at) 

526 

527 flights_asc = query.order_by( 

528 Flight.date.asc(), 

529 Flight.departure_time.asc().nullslast(), 

530 Flight.id.asc(), 

531 ).all() 

532 

533 base = float(comp.time_at_install or 0) 

534 cumulative = base 

535 flights_with_hours = [] 

536 for f in flights_asc: 

537 # Engine/propeller TBO tracking is engine-hours based — mirrors 

538 # services/component_limits.py::component_hours(). 

539 if f.engine_time is not None: 

540 cumulative += float(f.engine_time) 

541 elif ( 

542 f.engine_time_counter_end is not None 

543 and f.engine_time_counter_start is not None 

544 ): 

545 cumulative += float(f.engine_time_counter_end) - float( 

546 f.engine_time_counter_start 

547 ) 

548 flights_with_hours.append((f, cumulative)) 

549 

550 flights_with_hours.reverse() 

551 

552 # TBO from the dedicated column (legacy data may still carry it in extras); 

553 # a recorded overhaul resets the reference point. 

554 tbo_hours = ( 

555 float(comp.tbo_hours) 

556 if comp.tbo_hours is not None 

557 else (comp.extras or {}).get("tbo_hours") 

558 ) 

559 since_overhaul = cumulative - float(comp.overhauled_at_hours or 0) 

560 tbo_remaining = (tbo_hours - since_overhaul) if tbo_hours else None 

561 

562 return render_template( 

563 "flights/logbook_component.html", 

564 aircraft=ac, 

565 component=comp, 

566 flights_with_hours=flights_with_hours, 

567 total_component_hours=cumulative, 

568 since_overhaul=since_overhaul, 

569 tbo_hours=tbo_hours, 

570 tbo_remaining=tbo_remaining, 

571 ) 

572 

573 

574# ── Unified log / edit flight ───────────────────────────────────────────────── 

575 

576 

577@flights_bp.route("/flights/new", methods=["GET", "POST"]) 

578@login_required 

579@require_pilot_access 

580def log_flight() -> ResponseReturnValue: 

581 tid = _tenant_id() 

582 managed_aircraft = accessible_aircraft(tid).all() 

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

584 preselect_id = request.args.get("aircraft_id", type=int) 

585 

586 if request.method == "POST": 

587 return _handle_log_flight_post(managed_aircraft, uid, fe=None) 

588 

589 gps_prefill = session.pop("gps_prefill", None) 

590 gps_review_return_aircraft_id = request.args.get("gps_review_return", type=int) 

591 gps_review_return_seg_idx = request.args.get("gps_seg", type=int) 

592 _u = db.session.get(User, uid) 

593 pilot_name_hint = _u.display_name if _u else "" 

594 nature_suggestions = _NATURE_SUGGESTIONS 

595 aircraft: Aircraft | None = None 

596 if preselect_id: 

597 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None) 

598 if aircraft: 

599 nature_suggestions = _nature_suggestions(aircraft.id) 

600 counter_hint = _get_counter_hint(aircraft.id) if aircraft else None 

601 covering_reservation = ( 

602 _find_covering_reservation(aircraft.id, uid, _datetime.now(UTC)) 

603 if aircraft 

604 else None 

605 ) 

606 

607 active_minimums = get_active_revision(uid) 

608 minimums_breaches = ( 

609 recency_breaches(active_minimums, uid) if active_minimums else [] 

610 ) 

611 

612 return render_template( 

613 "flights/flight_form.html", 

614 flight=None, 

615 pilot_entry=None, 

616 aircraft=aircraft, 

617 managed_aircraft=managed_aircraft, 

618 preselect_aircraft_id=preselect_id, 

619 gps_prefill=gps_prefill, 

620 nature_suggestions=nature_suggestions, 

621 pilot_name_hint=pilot_name_hint, 

622 crew_roles=CrewRole, 

623 fuel_units=_FUEL_UNITS, 

624 duplicate=None, 

625 counter_hint=counter_hint, 

626 openaip_key=_openaip_key(), 

627 today_date=_date.today().isoformat(), 

628 gps_review_return_aircraft_id=gps_review_return_aircraft_id, 

629 gps_review_return_seg_idx=gps_review_return_seg_idx, 

630 covering_reservation=covering_reservation, 

631 active_minimums=active_minimums, 

632 minimums_breaches=minimums_breaches, 

633 ) 

634 

635 

636@flights_bp.route("/flights/<int:flight_id>/edit", methods=["GET", "POST"]) 

637@login_required 

638@require_pilot_access 

639def edit_flight(flight_id: int) -> ResponseReturnValue: 

640 tid = _tenant_id() 

641 managed_aircraft = accessible_aircraft(tid, include_archived=True).all() 

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

643 fe = _get_flight_or_404(flight_id) 

644 

645 if request.method == "POST": 

646 return _handle_log_flight_post(managed_aircraft, uid, fe=fe) 

647 

648 gps_prefill = session.pop("gps_prefill", None) 

649 # Unified model: every pilot-log field now lives on `fe` itself — no 

650 # separate PilotLogbookEntry to fetch. 

651 aircraft = db.session.get(Aircraft, fe.aircraft_id) if fe.aircraft_id else None 

652 counter_hint = _get_counter_hint(fe.aircraft_id) if fe.aircraft_id else None 

653 

654 return render_template( 

655 "flights/flight_form.html", 

656 flight=fe, 

657 pilot_entry=None, 

658 aircraft=aircraft, 

659 managed_aircraft=managed_aircraft, 

660 preselect_aircraft_id=fe.aircraft_id, 

661 gps_prefill=gps_prefill, 

662 nature_suggestions=_nature_suggestions(fe.aircraft_id), 

663 pilot_name_hint=None, 

664 crew_roles=CrewRole, 

665 fuel_units=_FUEL_UNITS, 

666 duplicate=None, 

667 counter_hint=counter_hint, 

668 openaip_key=_openaip_key(), 

669 gps_review_return_aircraft_id=None, 

670 gps_review_return_seg_idx=None, 

671 covering_reservation=None, 

672 active_minimums=None, 

673 minimums_breaches=[], 

674 ) 

675 

676 

677@flights_bp.route("/flights/<int:flight_id>/track/image.png") 

678@login_required 

679@require_pilot_access 

680def flight_track_image(flight_id: int) -> ResponseReturnValue: 

681 """Return a static PNG of the flight's GPS track.""" 

682 from flask import Response # pyright: ignore[reportMissingImports] 

683 from utils import ( 

684 generate_single_track_image, # pyright: ignore[reportMissingImports] 

685 ) 

686 

687 fe = _get_flight_or_404(flight_id) 

688 track = fe.gps_track 

689 if not track or not track.geojson: 

690 abort(404) 

691 

692 hires = request.args.get("quality") == "hires" 

693 portrait = request.args.get("orientation") == "portrait" 

694 is_default = not hires and not portrait 

695 

696 if is_default and track.cached_png: 

697 png_bytes = bytes(track.cached_png) 

698 else: 

699 tile_s = db.session.get(AppSetting, "openaip_api_key") 

700 base_w, base_h = (480, 800) if portrait else (800, 480) 

701 mul = 2 if hires else 1 

702 canvas_w, canvas_h = base_w * mul, base_h * mul 

703 

704 png_bytes = generate_single_track_image( 

705 track.geojson, 

706 date=str(fe.date), 

707 dep=fe.departure_icao or "", 

708 arr=fe.arrival_icao or "", 

709 _openaip_key=tile_s.value if tile_s and tile_s.value else None, 

710 canvas_w=canvas_w, 

711 canvas_h=canvas_h, 

712 high_res=hires, 

713 ) 

714 if is_default: 

715 track.cached_png = png_bytes # type: ignore[attr-defined] 

716 db.session.commit() 

717 orient_sfx = "-portrait" if portrait else "" 

718 qual_sfx = "-hires" if hires else "" 

719 suffix = orient_sfx + qual_sfx 

720 filename = f"flight_{flight_id}_track{suffix}.png" 

721 return Response( 

722 png_bytes, 

723 mimetype="image/png", 

724 headers={ 

725 "Content-Disposition": f'attachment; filename="{filename}"', 

726 "Cache-Control": "public, max-age=31536000, immutable", 

727 "ETag": f'"{track.id}"', 

728 }, 

729 ) 

730 

731 

732@flights_bp.route("/flights/<int:flight_id>/track/animation.gif") 

733@login_required 

734@require_pilot_access 

735def flight_track_gif(flight_id: int) -> ResponseReturnValue: 

736 """Return an animated GIF of the flight's GPS track drawn progressively.""" 

737 from flask import Response # pyright: ignore[reportMissingImports] 

738 from utils import generate_single_track_gif # pyright: ignore[reportMissingImports] 

739 

740 fe = _get_flight_or_404(flight_id) 

741 track = fe.gps_track 

742 if not track or not track.geojson: 

743 abort(404) 

744 

745 hires = request.args.get("quality") == "hires" 

746 portrait = request.args.get("orientation") == "portrait" 

747 is_default = not hires and not portrait 

748 

749 if is_default and track.cached_gif: 

750 gif_bytes = bytes(track.cached_gif) 

751 else: 

752 tile_s = db.session.get(AppSetting, "openaip_api_key") 

753 base_w, base_h = (480, 800) if portrait else (800, 480) 

754 mul = 2 if hires else 1 

755 canvas_w, canvas_h = base_w * mul, base_h * mul 

756 

757 gif_bytes = generate_single_track_gif( 

758 track.geojson, 

759 date=str(fe.date), 

760 dep=fe.departure_icao or "", 

761 arr=fe.arrival_icao or "", 

762 _openaip_key=tile_s.value if tile_s and tile_s.value else None, 

763 canvas_w=canvas_w, 

764 canvas_h=canvas_h, 

765 high_res=hires, 

766 ) 

767 if is_default: 

768 track.cached_gif = gif_bytes # type: ignore[attr-defined] 

769 db.session.commit() 

770 orient_sfx = "-portrait" if portrait else "" 

771 qual_sfx = "-hires" if hires else "" 

772 suffix = orient_sfx + qual_sfx 

773 filename = f"flight_{flight_id}_track{suffix}.gif" 

774 return Response( 

775 gif_bytes, 

776 mimetype="image/gif", 

777 headers={ 

778 "Content-Disposition": f'attachment; filename="{filename}"', 

779 "Cache-Control": "public, max-age=31536000, immutable", 

780 "ETag": f'"{track.id}"', 

781 }, 

782 ) 

783 

784 

785@flights_bp.route("/flights/registration-lookup") 

786@login_required 

787@require_pilot_access 

788def registration_lookup() -> ResponseReturnValue: 

789 """AJAX endpoint: return aircraft type for a previously logged registration. 

790 

791 Sources (in priority order): 

792 1. Current user's own logbook entries (most recent first). 

793 2. Any user in the same tenant (shared pool within the organisation). 

794 Sources 3 (cross-tenant) and 4 (external registry) are intentionally omitted. 

795 

796 Matching is normalised: case-insensitive, ignoring dashes and spaces. 

797 """ 

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

799 if not q: 

800 return jsonify({"result": None}) 

801 

802 def _norm(s: str) -> str: 

803 return s.upper().replace("-", "").replace(" ", "") 

804 

805 q_norm = _norm(q) 

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

807 tid = _tenant_id() 

808 

809 # Only "other aircraft" (unmanaged) rows carry a free-text registration — 

810 # a managed aircraft's type is already known via its own Aircraft record. 

811 # Source 1: current user's own history (either crew slot) 

812 user_entries = ( 

813 Flight.query.filter(Flight.aircraft_id.is_(None)) 

814 .filter(or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid)) 

815 .filter(Flight.other_aircraft_registration.isnot(None)) 

816 .order_by( 

817 Flight.date.desc(), 

818 Flight.departure_time.desc().nullslast(), 

819 Flight.id.desc(), 

820 ) 

821 .all() 

822 ) 

823 for e in user_entries: 

824 if ( 

825 _norm(e.other_aircraft_registration or "") == q_norm 

826 and e.other_aircraft_type 

827 ): 

828 return jsonify( 

829 { 

830 "result": { 

831 "aircraft_type": e.other_aircraft_type, 

832 "aircraft_type_icao": e.other_aircraft_type_icao or "", 

833 } 

834 } 

835 ) 

836 

837 # Source 2: any user in the same tenant 

838 from models import TenantUser as _TU # pyright: ignore[reportMissingImports] 

839 

840 tenant_user_ids = db.session.query(_TU.user_id).filter(_TU.tenant_id == tid) 

841 tenant_entries = ( 

842 Flight.query.filter(Flight.aircraft_id.is_(None)) 

843 .filter( 

844 or_( 

845 Flight.pic_user_id.in_(tenant_user_ids), 

846 Flight.second_crew_user_id.in_(tenant_user_ids), 

847 ) 

848 ) 

849 .filter(Flight.other_aircraft_registration.isnot(None)) 

850 .filter(Flight.other_aircraft_type.isnot(None)) 

851 .order_by( 

852 Flight.date.desc(), 

853 Flight.departure_time.desc().nullslast(), 

854 Flight.id.desc(), 

855 ) 

856 .all() 

857 ) 

858 for e in tenant_entries: 

859 if _norm(e.other_aircraft_registration or "") == q_norm: 

860 return jsonify( 

861 { 

862 "result": { 

863 "aircraft_type": e.other_aircraft_type, 

864 "aircraft_type_icao": e.other_aircraft_type_icao or "", 

865 } 

866 } 

867 ) 

868 

869 return jsonify({"result": None}) 

870 

871 

872@flights_bp.route("/flights/parse-gps", methods=["POST"]) 

873@_limiter.limit("30 per minute", exempt_when=_rate_limiting_disabled) 

874@login_required 

875@require_pilot_access 

876def parse_gps_api() -> ResponseReturnValue: 

877 """AJAX endpoint: parse a GPS upload, check for duplicates, return JSON.""" 

878 gps_file = request.files.get("gps_file") 

879 if not gps_file or not gps_file.filename: 

880 return jsonify( 

881 { 

882 "success": False, 

883 "error": str( 

884 _("Could not parse GPS file. Fill in the fields manually.") 

885 ), 

886 } 

887 ) 

888 gps_data = _parse_gps_upload(gps_file) 

889 if not gps_data: 

890 return jsonify( 

891 { 

892 "success": False, 

893 "error": str( 

894 _("Could not parse GPS file. Fill in the fields manually.") 

895 ), 

896 } 

897 ) 

898 return jsonify( 

899 { 

900 "success": True, 

901 "message": str( 

902 _( 

903 "GPS file parsed: %(filename)s — fields pre-filled below. Review and save.", 

904 filename=gps_data["filename"], 

905 ) 

906 ), 

907 "data": { 

908 "filename": gps_data["filename"], 

909 "date": gps_data["date"].isoformat(), 

910 "departure_icao": gps_data["departure_icao"], 

911 "arrival_icao": gps_data["arrival_icao"], 

912 "departure_time": gps_data["departure_time"].strftime("%H:%M") 

913 if gps_data["departure_time"] 

914 else "", 

915 "arrival_time": gps_data["arrival_time"].strftime("%H:%M") 

916 if gps_data["arrival_time"] 

917 else "", 

918 "flight_time_h": str(gps_data["flight_time_h"]), 

919 "block_off_utc": gps_data["block_off_utc"].isoformat() 

920 if gps_data["block_off_utc"] 

921 else "", 

922 "block_on_utc": gps_data["block_on_utc"].isoformat() 

923 if gps_data["block_on_utc"] 

924 else "", 

925 "geojson": _json.dumps(gps_data["geojson"]) 

926 if gps_data["geojson"] 

927 else "", 

928 "landing_count": gps_data["landing_count"] or 0, 

929 "device_id": gps_data["device_id"] or "", 

930 }, 

931 "duplicate": _check_gps_duplicate(gps_data), 

932 "suggested_aircraft_id": _suggested_aircraft_for_device( 

933 gps_data["device_id"] 

934 ), 

935 } 

936 ) 

937 

938 

939def _suggested_aircraft_for_device(device_id: str | None) -> int | None: 

940 """Return the aircraft_id most recently used with this device_id, or None.""" 

941 if not device_id: 

942 return None 

943 row = ( 

944 db.session.query(Flight.aircraft_id) 

945 .join(GpsTrack, Flight.gps_track_id == GpsTrack.id) 

946 .filter(GpsTrack.device_id == device_id) 

947 .order_by( 

948 Flight.date.desc(), 

949 Flight.departure_time.desc().nullslast(), 

950 Flight.id.desc(), 

951 ) 

952 .first() 

953 ) 

954 return int(row[0]) if row else None 

955 

956 

957def _check_gps_duplicate(gps_data: dict[str, Any]) -> dict[str, Any] | None: 

958 """Return a duplicate summary dict if a matching entry exists, else None.""" 

959 uid = int(session.get("user_id", 0)) 

960 aircraft_id = request.form.get("aircraft_id", type=int) 

961 if aircraft_id is not None: 

962 ac = db.session.get(Aircraft, aircraft_id) 

963 if not ac or ac.tenant_id != _tenant_id(): 

964 aircraft_id = None 

965 dup = _find_duplicate_flight( 

966 aircraft_id=aircraft_id, 

967 pilot_user_id=uid, 

968 date=gps_data["date"], 

969 dep_icao=gps_data["departure_icao"], 

970 arr_icao=gps_data["arrival_icao"], 

971 block_off=gps_data["block_off_utc"], 

972 block_on=gps_data["block_on_utc"], 

973 ) 

974 if not dup: 

975 return None 

976 entry = dup["entry"] 

977 return { 

978 "type": dup["type"], 

979 "date": str(gps_data["date"]), 

980 "dep": gps_data["departure_icao"], 

981 "arr": gps_data["arrival_icao"], 

982 "entry_id": entry.id, 

983 } 

984 

985 

986def _handle_log_flight_post( 

987 managed_aircraft: list[Aircraft], 

988 uid: int, 

989 fe: Flight | None, 

990) -> ResponseReturnValue: 

991 f = request.form 

992 gps_file = request.files.get("gps_file") 

993 

994 # ── GPS parse step ───────────────────────────────────────────────────────── 

995 if request.form.get("action") == "parse_gps" and gps_file and gps_file.filename: 

996 gps_data = _parse_gps_upload(gps_file) 

997 if gps_data: 

998 session["gps_prefill"] = { 

999 "filename": gps_data["filename"], 

1000 "date": gps_data["date"].isoformat(), 

1001 "departure_icao": gps_data["departure_icao"], 

1002 "arrival_icao": gps_data["arrival_icao"], 

1003 "departure_time": gps_data["departure_time"].strftime("%H:%M") 

1004 if gps_data["departure_time"] 

1005 else "", 

1006 "arrival_time": gps_data["arrival_time"].strftime("%H:%M") 

1007 if gps_data["arrival_time"] 

1008 else "", 

1009 "flight_time_h": str(gps_data["flight_time_h"]), 

1010 "block_off_utc": gps_data["block_off_utc"].isoformat(), 

1011 "block_on_utc": gps_data["block_on_utc"].isoformat(), 

1012 "geojson": _json.dumps(gps_data["geojson"]) 

1013 if gps_data["geojson"] 

1014 else "", 

1015 "landing_count": gps_data["landing_count"], 

1016 } 

1017 flash(_("GPS file parsed — fields pre-filled. Review and save."), "info") 

1018 else: 

1019 flash( 

1020 _("Could not parse GPS file. Fill in the fields manually."), "warning" 

1021 ) 

1022 if fe: 

1023 return redirect(url_for("flights.edit_flight", flight_id=fe.id)) 

1024 aircraft_id = f.get("aircraft_id", type=int) 

1025 qs: dict[str, Any] = {"aircraft_id": aircraft_id} if aircraft_id else {} 

1026 return redirect(url_for("flights.log_flight", **qs)) 

1027 

1028 # ── Determine aircraft ───────────────────────────────────────────────────── 

1029 other_aircraft = f.get("other_aircraft") == "1" 

1030 aircraft_id_raw = f.get("aircraft_id", type=int) 

1031 # When editing an existing flight, fall back to the flight's own aircraft_id 

1032 # so the `if ac:` block is entered even when aircraft_id is absent from the form. 

1033 if aircraft_id_raw is None and fe is not None: 

1034 aircraft_id_raw = fe.aircraft_id 

1035 ac: Aircraft | None = None 

1036 if not other_aircraft and aircraft_id_raw: 

1037 ac = next((a for a in managed_aircraft if a.id == aircraft_id_raw), None) 

1038 

1039 other_ac_make_model = f.get("other_ac_make_model", "").strip() 

1040 other_ac_reg = f.get("other_ac_reg", "").strip().upper() 

1041 

1042 # ── Parse common fields ──────────────────────────────────────────────────── 

1043 pilot_role = f.get("pilot_role", "none").strip() 

1044 if pilot_role not in ("pic", "dual", "none"): 

1045 pilot_role = "none" 

1046 

1047 # Pilot-log fields 

1048 night_time_raw = f.get("night_time", "").strip() 

1049 instrument_time_raw = f.get("instrument_time", "").strip() 

1050 landings_day_raw = f.get("landings_day", "").strip() 

1051 landings_night_raw = f.get("landings_night", "").strip() 

1052 multi_pilot_raw = f.get("multi_pilot", "").strip() 

1053 

1054 def _parse_dec(raw: str) -> decimal.Decimal | None: 

1055 if not raw: 

1056 return None 

1057 try: 

1058 v = decimal.Decimal(raw) 

1059 return v if v >= 0 else None 

1060 except decimal.InvalidOperation: 

1061 return None 

1062 

1063 night_time = _parse_dec(night_time_raw) 

1064 instrument_time = _parse_dec(instrument_time_raw) 

1065 multi_pilot = _parse_dec(multi_pilot_raw) 

1066 landings_day: int | None = ( 

1067 int(landings_day_raw) if landings_day_raw.isdigit() else None 

1068 ) 

1069 landings_night: int | None = ( 

1070 int(landings_night_raw) if landings_night_raw.isdigit() else None 

1071 ) 

1072 

1073 # GPS hidden fields (carried from parse step or re-render) 

1074 gps_filename = f.get("gps_filename", "").strip() or None 

1075 gps_device_id = f.get("gps_device_id", "").strip() or None 

1076 gps_block_off_raw = f.get("gps_block_off_utc", "").strip() 

1077 gps_block_on_raw = f.get("gps_block_on_utc", "").strip() 

1078 gps_geojson_raw = f.get("gps_geojson", "").strip() 

1079 

1080 duplicate_action = f.get("duplicate_action", "").strip() 

1081 

1082 errors = [] 

1083 

1084 if not fe and not ac and not other_aircraft: 

1085 errors.append(_("Please select an aircraft.")) 

1086 

1087 if other_aircraft and pilot_role not in ("pic", "dual"): 

1088 errors.append(_("Pilot role is required for other aircraft flights.")) 

1089 if ( 

1090 other_aircraft 

1091 and pilot_role in ("pic", "dual") 

1092 and not f.get("crew_name_0", "").strip() 

1093 ): 

1094 errors.append(_("Pilot name is required.")) 

1095 if other_aircraft and not other_ac_make_model: 

1096 errors.append( 

1097 _("Aircraft type (make/model) is required for other aircraft flights.") 

1098 ) 

1099 if other_aircraft and not other_ac_reg: 

1100 errors.append( 

1101 _("Aircraft registration is required for other aircraft flights.") 

1102 ) 

1103 

1104 # The aircraft-log `landing_count` is derived from the pilot-log day/night 

1105 # split (there is no separate `landing_count` form field); when neither is 

1106 # given, an existing value is preserved rather than cleared — mirrored here 

1107 # by injecting the resolved value into the field map handed to 

1108 # parse_flight_fields, which always assigns it unconditionally. 

1109 if landings_day is not None or landings_night is not None: 

1110 landing_count_for_fe: int | None = (landings_day or 0) + (landings_night or 0) 

1111 else: 

1112 landing_count_for_fe = fe.landing_count if fe else None 

1113 field_map = dict(f) 

1114 field_map["landing_count"] = ( 

1115 str(landing_count_for_fe) if landing_count_for_fe is not None else "" 

1116 ) 

1117 values, field_errors = parse_flight_fields( 

1118 field_map, ac, strict=not flight_is_lenient(fe) 

1119 ) 

1120 errors.extend(field_errors) 

1121 

1122 flight_date = values["date"] 

1123 dep = values["departure_icao"] 

1124 arr = values["arrival_icao"] 

1125 departure_time = values["departure_time"] 

1126 

1127 gps_block_off: _datetime | None = None 

1128 gps_block_on: _datetime | None = None 

1129 if gps_block_off_raw: 

1130 with contextlib.suppress( 

1131 ValueError 

1132 ): # malformed hidden field — treat as absent 

1133 gps_block_off = _datetime.fromisoformat(gps_block_off_raw) 

1134 if gps_block_on_raw: 

1135 with contextlib.suppress( 

1136 ValueError 

1137 ): # malformed hidden field — treat as absent 

1138 gps_block_on = _datetime.fromisoformat(gps_block_on_raw) 

1139 

1140 gps_geojson: Any = None 

1141 if gps_geojson_raw: 

1142 with contextlib.suppress( 

1143 Exception 

1144 ): # malformed hidden field — GPS track simply not applied 

1145 gps_geojson = _json.loads(gps_geojson_raw) 

1146 

1147 if errors: 

1148 for msg in errors: 

1149 flash(msg, "danger") 

1150 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, None) 

1151 

1152 # ── Duplicate detection (first pass) ────────────────────────────────────── 

1153 if not duplicate_action and flight_date and dep and arr: 

1154 dup = _find_duplicate_flight( 

1155 aircraft_id=ac.id if ac else None, 

1156 pilot_user_id=uid, 

1157 date=flight_date, 

1158 dep_icao=dep, 

1159 arr_icao=arr, 

1160 block_off=gps_block_off, 

1161 block_on=gps_block_on, 

1162 exclude_flight_id=fe.id if fe else None, 

1163 ) 

1164 if dup: 

1165 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, dup) 

1166 

1167 # ── GPS-attach-only path ─────────────────────────────────────────────────── 

1168 if duplicate_action == "link_gps" and flight_date: 

1169 dup = _find_duplicate_flight( 

1170 aircraft_id=ac.id if ac else None, 

1171 pilot_user_id=uid, 

1172 date=flight_date, 

1173 dep_icao=dep, 

1174 arr_icao=arr, 

1175 block_off=gps_block_off, 

1176 block_on=gps_block_on, 

1177 exclude_flight_id=fe.id if fe else None, 

1178 ) 

1179 if dup and (gps_geojson or gps_filename): 

1180 link_track = GpsTrack( 

1181 source_filename=gps_filename, 

1182 device_id=gps_device_id, 

1183 block_off_utc=gps_block_off, 

1184 block_on_utc=gps_block_on, 

1185 departure_icao=dep, 

1186 arrival_icao=arr, 

1187 geojson=gps_geojson, 

1188 ) 

1189 db.session.add(link_track) 

1190 db.session.flush() 

1191 entry = dup["entry"] 

1192 entry.gps_track_id = link_track.id 

1193 db.session.commit() 

1194 flash(_("GPS track linked to the existing flight entry."), "success") 

1195 else: 

1196 flash(_("Could not link GPS track — no matching entry found."), "warning") 

1197 return redirect(url_for("pilots.logbook")) 

1198 

1199 # ── Build GpsTrack if GPS data is present ───────────────────────────────── 

1200 create_pilot = pilot_role in ("pic", "dual") 

1201 

1202 gps_track: GpsTrack | None = None 

1203 if gps_geojson or gps_filename: 

1204 existing_track_id: int | None = fe.gps_track_id if fe else None 

1205 if existing_track_id: 

1206 gps_track = db.session.get(GpsTrack, existing_track_id) 

1207 if gps_track: 

1208 if gps_geojson: 

1209 gps_track.geojson = gps_geojson 

1210 if gps_filename: 

1211 gps_track.source_filename = gps_filename 

1212 if gps_block_off: 

1213 gps_track.block_off_utc = gps_block_off 

1214 if gps_block_on: 

1215 gps_track.block_on_utc = gps_block_on 

1216 if gps_track and gps_device_id: 

1217 gps_track.device_id = gps_device_id 

1218 if not gps_track: 

1219 gps_track = GpsTrack( 

1220 source_filename=gps_filename, 

1221 device_id=gps_device_id, 

1222 block_off_utc=gps_block_off, 

1223 block_on_utc=gps_block_on, 

1224 departure_icao=dep, 

1225 arrival_icao=arr, 

1226 geojson=gps_geojson, 

1227 ) 

1228 db.session.add(gps_track) 

1229 db.session.flush() 

1230 

1231 # ── Unified flight row ───────────────────────────────────────────────────── 

1232 # A Flight row now always exists, whether or not the aircraft is managed 

1233 # here — the old "other aircraft" path built a standalone PilotLogbookEntry 

1234 # instead of a FlightEntry; that distinction no longer exists in the 

1235 # unified schema, only aircraft_id being NULL vs set. 

1236 _fe_is_new = fe is None 

1237 if fe is None: 

1238 fe = Flight(aircraft_id=ac.id if ac else None) 

1239 db.session.add(fe) 

1240 else: 

1241 fe.aircraft_id = ac.id if ac else None 

1242 

1243 if ac: 

1244 fe.other_aircraft_type = None 

1245 fe.other_aircraft_type_icao = None 

1246 fe.other_aircraft_registration = None 

1247 else: 

1248 fe.other_aircraft_type = other_ac_make_model or None 

1249 fe.other_aircraft_type_icao = f.get("aircraft_type_icao", "").strip() or None 

1250 fe.other_aircraft_registration = other_ac_reg or None 

1251 

1252 apply_flight_fields(fe, values) 

1253 

1254 if gps_track: 

1255 fe.gps_track_id = gps_track.id 

1256 if gps_block_off: 

1257 fe.block_off_utc = gps_block_off 

1258 if gps_block_on: 

1259 fe.block_on_utc = gps_block_on 

1260 

1261 if ac: 

1262 if _fe_is_new and flight_date is not None: 

1263 anchor = _datetime.combine( 

1264 flight_date, departure_time or _time(12, 0), tzinfo=UTC 

1265 ) 

1266 covering = _find_covering_reservation(ac.id, uid, anchor) 

1267 fe.reservation_id = covering.id if covering else None 

1268 

1269 db.session.flush() 

1270 

1271 for photo_field, label, attr in [ 

1272 ("flight_counter_photo", "flight", "flight_counter_photo"), 

1273 ("engine_counter_photo", "engine", "engine_counter_photo"), 

1274 ("fuel_photo", "fuel", "fuel_photo"), 

1275 ]: 

1276 photo_file = request.files.get(photo_field) 

1277 if photo_file and photo_file.filename: 

1278 stored = _save_upload(photo_file, fe.id, label) 

1279 if stored: 

1280 _delete_upload(getattr(fe, attr)) 

1281 setattr(fe, attr, stored) 

1282 

1283 # ── Pilot log figures ────────────────────────────────────────────────────── 

1284 if create_pilot: 

1285 fe.night_time = night_time 

1286 fe.instrument_time = instrument_time 

1287 fe.landings_day = landings_day if landings_day is not None else 0 

1288 fe.landings_night = landings_night 

1289 fe.multi_pilot = multi_pilot 

1290 apply_pilot_identity(fe, ac, uid, pilot_role) 

1291 else: 

1292 # pilot_role == "none": this user isn't tracking a personal logbook 

1293 # entry for this flight. If they previously occupied a slot on this 

1294 # same flight (editing), un-claim just their identity + function 

1295 # figures — the shared EASA figures (night_time etc.) stay, since 

1296 # they describe the flight itself and another crew member may still 

1297 # depend on them. 

1298 if fe.pic_user_id == uid: 

1299 fe.pic_user_id = None 

1300 fe.function_pic = None 

1301 elif fe.second_crew_user_id == uid: 

1302 fe.second_crew_user_id = None 

1303 fe.function_dual = None 

1304 

1305 db.session.commit() 

1306 

1307 if ac: 

1308 event_name = "flight.logged" if _fe_is_new else "flight.updated" 

1309 activity( 

1310 event_name, 

1311 flight_id=fe.id, 

1312 aircraft_id=ac.id, 

1313 dep=dep, 

1314 arr=arr, 

1315 date=str(flight_date), 

1316 ) 

1317 _check_flight_hour_milestone(fe) 

1318 

1319 flash( 

1320 _( 

1321 "Flight %(dep)s→%(arr)s on %(date)s saved.", 

1322 dep=dep, 

1323 arr=arr, 

1324 date=flight_date, 

1325 ), 

1326 "success", 

1327 ) 

1328 return_ac_id = f.get("gps_review_return_aircraft_id", type=int) 

1329 return_seg_idx = f.get("gps_review_return_seg_idx", type=int) 

1330 if return_ac_id is not None: 

1331 gps_state = session.get("gps_import", {}) 

1332 if ( 

1333 gps_state.get("aircraft_id") == return_ac_id 

1334 and return_seg_idx is not None 

1335 ): 

1336 confirmed = gps_state.get("confirmed_segments", {}) 

1337 confirmed[str(return_seg_idx)] = fe.id 

1338 gps_state["confirmed_segments"] = confirmed 

1339 session["gps_import"] = gps_state 

1340 session.modified = True 

1341 return redirect( 

1342 url_for("aircraft.gps_import_review", aircraft_id=return_ac_id) 

1343 ) 

1344 return redirect(url_for("flights.list_flights", aircraft_id=ac.id)) 

1345 

1346 flash( 

1347 _( 

1348 "Flight %(dep)s→%(arr)s on %(date)s saved to your pilot logbook.", 

1349 dep=dep, 

1350 arr=arr, 

1351 date=flight_date, 

1352 ), 

1353 "success", 

1354 ) 

1355 return redirect(url_for("pilots.logbook")) 

1356 

1357 

1358def _render_form( 

1359 managed_aircraft: list[Aircraft], 

1360 flight: Flight | None, 

1361 pilot_entry: None, 

1362 preselect_id: int | None, 

1363 duplicate: dict[str, Any] | None, 

1364) -> ResponseReturnValue: 

1365 """`pilot_entry` is always None now — kept as a parameter only so every 

1366 call site doesn't need touching; every pilot-log field lives on `flight` 

1367 itself in the unified model.""" 

1368 nature_suggestions = _NATURE_SUGGESTIONS 

1369 aircraft: Aircraft | None = None 

1370 if preselect_id: 

1371 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None) 

1372 if aircraft: 

1373 nature_suggestions = _nature_suggestions(aircraft.id) 

1374 counter_hint = _get_counter_hint(aircraft.id) if aircraft else None 

1375 return render_template( 

1376 "flights/flight_form.html", 

1377 flight=flight, 

1378 pilot_entry=pilot_entry, 

1379 aircraft=aircraft, 

1380 managed_aircraft=managed_aircraft, 

1381 preselect_aircraft_id=preselect_id, 

1382 gps_prefill=None, 

1383 nature_suggestions=nature_suggestions, 

1384 pilot_name_hint=None, 

1385 crew_roles=CrewRole, 

1386 fuel_units=_FUEL_UNITS, 

1387 duplicate=duplicate, 

1388 counter_hint=counter_hint, 

1389 openaip_key=_openaip_key(), 

1390 gps_review_return_aircraft_id=None, 

1391 gps_review_return_seg_idx=None, 

1392 covering_reservation=None, 

1393 active_minimums=None, 

1394 minimums_breaches=[], 

1395 ) 

1396 

1397 

1398# ── Delete flight ───────────────────────────────────────────────────────────── 

1399 

1400 

1401@flights_bp.route( 

1402 "/aircraft/<aircraft_ref:aircraft_id>/flights/<int:flight_id>/delete", 

1403 methods=["POST"], 

1404) 

1405@login_required 

1406@require_pilot_access 

1407def delete_flight(aircraft_id: int, flight_id: int) -> ResponseReturnValue: 

1408 ac = _get_aircraft_or_404(aircraft_id) 

1409 fe = db.session.get(Flight, flight_id) 

1410 if not fe or fe.aircraft_id != ac.id: 

1411 abort(404) 

1412 label = f"{fe.departure_icao}{fe.arrival_icao} on {fe.date}" 

1413 activity( 

1414 "flight.deleted", flight_id=flight_id, aircraft_id=aircraft_id, label=label 

1415 ) 

1416 _delete_upload(fe.flight_counter_photo) 

1417 _delete_upload(fe.engine_counter_photo) 

1418 db.session.delete(fe) 

1419 db.session.commit() 

1420 flash(_("Flight %(label)s deleted.", label=label), "success") 

1421 return redirect(url_for("flights.list_flights", aircraft_id=ac.id)) 

1422 

1423 

1424# ── Bulk airframe logbook import (CSV / Excel) ──────────────────────────────── 

1425 

1426_AIRFRAME_IMPORT_SESSION_KEY = "airframe_import" 

1427_AIRFRAME_IMPORT_REVIEW_SESSION_KEY = "airframe_import_review" 

1428_AIRFRAME_IMPORT_EXTS = {".csv", ".xlsx", ".xls"} 

1429_AIRFRAME_IMPORT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB 

1430 

1431 

1432def _airframe_tmp_dir() -> str: 

1433 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

1434 d = os.path.join(folder, "import_tmp") 

1435 os.makedirs(d, exist_ok=True) 

1436 return d 

1437 

1438 

1439def _airframe_cleanup_tmp() -> None: 

1440 """Delete any leftover temp import file, including one left behind by 

1441 an abandoned conflict-review (started a fresh upload instead of 

1442 finishing it).""" 

1443 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY) 

1444 if meta: 

1445 tmp = meta.get("tmp_path") 

1446 if tmp and os.path.isfile(tmp): 

1447 with contextlib.suppress(OSError): 

1448 os.remove(tmp) 

1449 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None) 

1450 

1451 review_state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY) 

1452 if review_state: 

1453 tmp = review_state.get("tmp_path") 

1454 if tmp and os.path.isfile(tmp): 

1455 with contextlib.suppress(OSError): 

1456 os.remove(tmp) 

1457 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1458 

1459 

1460def _render_airframe_map( 

1461 ac: Aircraft, 

1462 parsed: Any, 

1463 mapping: dict[str, str], 

1464 match_type: str, 

1465 filename: str, 

1466 is_historical: bool = False, 

1467) -> str: 

1468 from pilots.logbook_import import ( # pyright: ignore[reportMissingImports] 

1469 _norm, 

1470 preview_rows, 

1471 ) 

1472 

1473 from flights.airframe_import import ( # pyright: ignore[reportMissingImports] 

1474 AIRFRAME_TARGET_FIELDS, 

1475 airframe_type_hints, 

1476 ) 

1477 

1478 return render_template( 

1479 "flights/airframe_import_map.html", 

1480 aircraft=ac, 

1481 norm_cols=parsed.norm_cols, 

1482 raw_cols=parsed.raw_cols, 

1483 base_norm_cols=[_norm(r) for r in parsed.raw_cols], 

1484 mapping=mapping, 

1485 match_type=match_type, 

1486 target_fields=AIRFRAME_TARGET_FIELDS, 

1487 is_historical=is_historical, 

1488 preview=preview_rows(parsed, mapping, n=5), 

1489 filename=filename, 

1490 type_hints=airframe_type_hints(parsed, mapping), 

1491 ) 

1492 

1493 

1494@flights_bp.route( 

1495 "/aircraft/<aircraft_ref:aircraft_id>/flights/import", methods=["GET", "POST"] 

1496) 

1497@login_required 

1498@require_role(Role.ADMIN, Role.OWNER) 

1499def airframe_import_upload(aircraft_id: int) -> ResponseReturnValue: 

1500 from models import ( # pyright: ignore[reportMissingImports] 

1501 AirframeImportBatch, 

1502 AirframeImportMapping, 

1503 ) 

1504 from pilots.logbook_import import ( 

1505 parse_file, # pyright: ignore[reportMissingImports] 

1506 ) 

1507 

1508 from flights.airframe_import import ( 

1509 propose_airframe_mapping, # pyright: ignore[reportMissingImports] 

1510 ) 

1511 

1512 ac = _get_aircraft_or_404(aircraft_id) 

1513 batches = ( 

1514 AirframeImportBatch.query.filter_by(aircraft_id=ac.id) 

1515 .order_by(AirframeImportBatch.imported_at.desc()) 

1516 .all() 

1517 ) 

1518 

1519 if request.method == "GET": 

1520 return render_template( 

1521 "flights/airframe_import_upload.html", aircraft=ac, batches=batches 

1522 ) 

1523 

1524 uploaded = request.files.get("logbook_file") 

1525 if not uploaded or not uploaded.filename: 

1526 flash(_("Please select a file to upload."), "danger") 

1527 return render_template( 

1528 "flights/airframe_import_upload.html", aircraft=ac, batches=batches 

1529 ), 422 

1530 

1531 ext = os.path.splitext(uploaded.filename)[1].lower() 

1532 if ext not in _AIRFRAME_IMPORT_EXTS: 

1533 flash(_("Unsupported format. Please upload a .csv or .xlsx file."), "danger") 

1534 return render_template( 

1535 "flights/airframe_import_upload.html", aircraft=ac, batches=batches 

1536 ), 422 

1537 

1538 data = uploaded.read() 

1539 if len(data) > _AIRFRAME_IMPORT_MAX_BYTES: 

1540 flash(_("File too large (maximum 10 MB)."), "danger") 

1541 return render_template( 

1542 "flights/airframe_import_upload.html", aircraft=ac, batches=batches 

1543 ), 422 

1544 

1545 try: 

1546 parsed = parse_file(data, uploaded.filename) 

1547 except ValueError as exc: 

1548 flash(str(exc), "danger") 

1549 return render_template( 

1550 "flights/airframe_import_upload.html", aircraft=ac, batches=batches 

1551 ), 422 

1552 

1553 _airframe_cleanup_tmp() 

1554 safe_base = secure_filename(uploaded.filename) or "upload" 

1555 tmp_path = os.path.join( 

1556 _airframe_tmp_dir(), f"airframe_{ac.id}_{uuid.uuid4().hex}_{safe_base}" 

1557 ) 

1558 with open(tmp_path, "wb") as fh: 

1559 fh.write(data) 

1560 

1561 session[_AIRFRAME_IMPORT_SESSION_KEY] = { 

1562 "aircraft_id": ac.id, 

1563 "tmp_path": tmp_path, 

1564 "original_filename": uploaded.filename, 

1565 "norm_cols": parsed.norm_cols, 

1566 "fingerprint": parsed.fingerprint, 

1567 } 

1568 

1569 saved = AirframeImportMapping.query.filter_by(tenant_id=ac.tenant_id).all() 

1570 mapping, match_type = propose_airframe_mapping(parsed, saved) 

1571 return _render_airframe_map(ac, parsed, mapping, match_type, uploaded.filename) 

1572 

1573 

1574@flights_bp.route( 

1575 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/execute", methods=["POST"] 

1576) 

1577@login_required 

1578@require_role(Role.ADMIN, Role.OWNER) 

1579def airframe_import_execute(aircraft_id: int) -> ResponseReturnValue: 

1580 from models import ( # pyright: ignore[reportMissingImports] 

1581 AirframeImportBatch, 

1582 AirframeImportMapping, 

1583 ) 

1584 from pilots.logbook_import import ( # pyright: ignore[reportMissingImports] 

1585 parse_duration_value, 

1586 parse_file, 

1587 ) 

1588 

1589 from flights.airframe_import import ( # pyright: ignore[reportMissingImports] 

1590 AIRFRAME_TARGET_FIELDS, 

1591 execute_airframe_import, 

1592 find_conflicting_airframe_rows, 

1593 ) 

1594 

1595 ac = _get_aircraft_or_404(aircraft_id) 

1596 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY) 

1597 if not meta or meta.get("aircraft_id") != ac.id: 

1598 flash(_("Import session expired. Please upload the file again."), "warning") 

1599 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1600 

1601 tmp_path: str = meta["tmp_path"] 

1602 original_filename: str = meta["original_filename"] 

1603 norm_cols: list[str] = meta["norm_cols"] 

1604 fingerprint: str = meta["fingerprint"] 

1605 

1606 if not os.path.isfile(tmp_path): 

1607 flash(_("Temporary file not found. Please upload the file again."), "warning") 

1608 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None) 

1609 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1610 

1611 mapping: dict[str, str] = {} 

1612 for col in norm_cols: 

1613 val = request.form.get(f"mapping_{col}", "ignore").strip() 

1614 mapping[col] = val if val in AIRFRAME_TARGET_FIELDS else "ignore" 

1615 

1616 is_historical = bool(request.form.get("is_historical")) 

1617 

1618 with open(tmp_path, "rb") as fh: 

1619 data = fh.read() 

1620 try: 

1621 parsed = parse_file(data, original_filename) 

1622 except ValueError as exc: 

1623 flash(str(exc), "danger") 

1624 _airframe_cleanup_tmp() 

1625 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1626 

1627 if "date" not in mapping.values(): 

1628 flash(_("You must map at least one column to 'Date'."), "danger") 

1629 return _render_airframe_map( 

1630 ac, 

1631 parsed, 

1632 mapping, 

1633 "alias", 

1634 original_filename, 

1635 is_historical, 

1636 ), 422 

1637 

1638 opening_counters = { 

1639 "flight": parse_duration_value( 

1640 request.form.get("ob_flight_counter", "").strip() 

1641 ) 

1642 if request.form.get("ob_flight_counter", "").strip() 

1643 else None, 

1644 "engine": parse_duration_value( 

1645 request.form.get("ob_engine_counter", "").strip() 

1646 ) 

1647 if request.form.get("ob_engine_counter", "").strip() 

1648 else None, 

1649 } 

1650 

1651 mapping_record = None 

1652 for m in AirframeImportMapping.query.filter_by(tenant_id=ac.tenant_id).all(): 

1653 if m.source_fingerprint == fingerprint: 

1654 m.column_mapping = _json.dumps(mapping) 

1655 mapping_record = m 

1656 break 

1657 if mapping_record is None: 

1658 mapping_record = AirframeImportMapping( 

1659 tenant_id=ac.tenant_id, 

1660 source_fingerprint=fingerprint, 

1661 column_mapping=_json.dumps(mapping), 

1662 source_columns=_json.dumps(norm_cols), 

1663 created_at=_datetime.now(UTC), 

1664 ) 

1665 db.session.add(mapping_record) 

1666 db.session.flush() 

1667 

1668 batch = AirframeImportBatch( 

1669 aircraft_id=ac.id, 

1670 mapping_id=mapping_record.id, 

1671 source_filename=original_filename, 

1672 imported_at=_datetime.now(UTC), 

1673 is_historical=is_historical, 

1674 ) 

1675 db.session.add(batch) 

1676 db.session.flush() 

1677 

1678 resolved_opening_counters = ( 

1679 opening_counters 

1680 if any(v is not None for v in opening_counters.values()) 

1681 else None 

1682 ) 

1683 

1684 # Rows that look like they might be an edited version of an existing 

1685 # flight (score >= _CANDIDATE_MIN_SCORE) need a human decision, not a 

1686 # guess — carve them out of this pass and route them through the 

1687 # interactive review step below instead of silently importing or 

1688 # skipping them. 

1689 conflicts = find_conflicting_airframe_rows(parsed, mapping, ac.id) 

1690 

1691 result = execute_airframe_import( 

1692 parsed=parsed, 

1693 mapping=mapping, 

1694 aircraft=ac, 

1695 batch_id=batch.id, 

1696 opening_counters=resolved_opening_counters, 

1697 skip_row_nums={c.row_num for c in conflicts}, 

1698 ) 

1699 batch.row_count = result.imported 

1700 batch.subtotal_count = result.subtotals 

1701 batch.skipped_count = len(result.skipped) 

1702 batch.warning_count = len(result.continuity_warnings) 

1703 batch.has_opening_counters = result.has_opening_counters 

1704 db.session.commit() 

1705 

1706 if conflicts: 

1707 # Defer activity logging and tmp-file cleanup until every conflict 

1708 # is resolved — _finalize_airframe_import_review does both, covering 

1709 # entries added during review as well as the ones just committed. 

1710 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = { 

1711 "aircraft_id": ac.id, 

1712 "tmp_path": tmp_path, 

1713 "original_filename": original_filename, 

1714 "mapping": mapping, 

1715 "batch_id": batch.id, 

1716 "resolved": {}, 

1717 } 

1718 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None) 

1719 session.modified = True 

1720 

1721 flash( 

1722 _( 

1723 "%(imported)d flights imported so far. %(n)d rows look like " 

1724 "they might already be in this aircraft's log with " 

1725 "different data — please review them below.", 

1726 imported=result.imported, 

1727 n=len(conflicts), 

1728 ), 

1729 "info", 

1730 ) 

1731 if result.duplicates: 

1732 detail = "; ".join( 

1733 f"row {r}: {reason}" for r, reason in result.duplicates[:5] 

1734 ) 

1735 if len(result.duplicates) > 5: 

1736 detail += f" … and {len(result.duplicates) - 5} more" 

1737 flash( 

1738 _( 

1739 "Rows already in this aircraft's log, skipped: %(detail)s", 

1740 detail=detail, 

1741 ), 

1742 "info", 

1743 ) 

1744 if result.skipped: 

1745 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.skipped[:5]) 

1746 if len(result.skipped) > 5: 

1747 detail += f" … and {len(result.skipped) - 5} more" 

1748 flash(_("Skipped rows: %(detail)s", detail=detail), "warning") 

1749 

1750 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id)) 

1751 

1752 activity( 

1753 "flights.airframe_import", 

1754 aircraft_id=ac.id, 

1755 batch_id=batch.id, 

1756 imported=result.imported, 

1757 ) 

1758 _airframe_cleanup_tmp() 

1759 

1760 # Duplicates are a normal, expected outcome — e.g. re-uploading a 

1761 # spreadsheet after appending a few new flights — not an error, so these 

1762 # messages stay in the "success"/"info" register rather than "warning". 

1763 if result.imported == 0 and result.duplicates: 

1764 flash( 

1765 _( 

1766 "All flights in this file were already in this aircraft's " 

1767 "log — nothing new was imported." 

1768 ), 

1769 "success", 

1770 ) 

1771 elif result.duplicates: 

1772 flash( 

1773 _( 

1774 "Import complete: %(imported)d new flights imported, " 

1775 "%(duplicates)d rows were already in this aircraft's log and " 

1776 "were skipped, %(subtotals)d subtotal rows skipped, " 

1777 "%(skipped)d rows could not be parsed.", 

1778 imported=result.imported, 

1779 duplicates=len(result.duplicates), 

1780 subtotals=result.subtotals, 

1781 skipped=len(result.skipped), 

1782 ), 

1783 "success", 

1784 ) 

1785 else: 

1786 flash( 

1787 _( 

1788 "Import complete: %(imported)d flights imported, %(subtotals)d " 

1789 "subtotal rows skipped, %(skipped)d rows could not be parsed.", 

1790 imported=result.imported, 

1791 subtotals=result.subtotals, 

1792 skipped=len(result.skipped), 

1793 ), 

1794 "success", 

1795 ) 

1796 if result.duplicates: 

1797 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.duplicates[:5]) 

1798 if len(result.duplicates) > 5: 

1799 detail += f" … and {len(result.duplicates) - 5} more" 

1800 flash( 

1801 _( 

1802 "Rows already in this aircraft's log, skipped: %(detail)s", 

1803 detail=detail, 

1804 ), 

1805 "info", 

1806 ) 

1807 if result.continuity_warnings: 

1808 detail = "; ".join( 

1809 _( 

1810 "row %(row)d: %(kind)s counter starts at %(got).1f but the previous " 

1811 "entry ended at %(prev).1f", 

1812 row=row, 

1813 kind=kind, 

1814 got=got, 

1815 prev=prev, 

1816 ) 

1817 for row, kind, prev, got in result.continuity_warnings[:5] 

1818 ) 

1819 if len(result.continuity_warnings) > 5: 

1820 detail += _(" … and %(n)d more", n=len(result.continuity_warnings) - 5) 

1821 flash(_("Counter continuity warnings: %(detail)s", detail=detail), "warning") 

1822 if result.skipped: 

1823 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.skipped[:5]) 

1824 if len(result.skipped) > 5: 

1825 detail += f" … and {len(result.skipped) - 5} more" 

1826 flash(_("Skipped rows: %(detail)s", detail=detail), "warning") 

1827 

1828 return redirect(url_for("flights.list_flights", aircraft_id=ac.id)) 

1829 

1830 

1831def _finalize_airframe_import_review( 

1832 ac: Aircraft, state: dict[str, Any] 

1833) -> ResponseReturnValue: 

1834 """Common tail once every conflict row from a review has a decision: 

1835 activity log, tmp-file/session cleanup, summary flash, redirect — the 

1836 same shape as the no-conflicts tail of airframe_import_execute above.""" 

1837 from models import AirframeImportBatch # pyright: ignore[reportMissingImports] 

1838 

1839 batch_id: int = state["batch_id"] 

1840 tmp_path: str = state["tmp_path"] 

1841 batch = db.session.get(AirframeImportBatch, batch_id) 

1842 

1843 activity( 

1844 "flights.airframe_import", 

1845 aircraft_id=ac.id, 

1846 batch_id=batch_id, 

1847 imported=batch.row_count if batch else 0, 

1848 ) 

1849 

1850 with contextlib.suppress(OSError): 

1851 os.remove(tmp_path) 

1852 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1853 

1854 resolved: dict[str, str] = state.get("resolved", {}) 

1855 kept = sum(1 for d in resolved.values() if d == "keep") 

1856 overwritten = sum(1 for d in resolved.values() if d.startswith("overwrite:")) 

1857 added_new = sum(1 for d in resolved.values() if d == "new") 

1858 

1859 flash( 

1860 _( 

1861 "Review complete: %(overwritten)d flights updated, %(new)d " 

1862 "imported as new, %(kept)d left unchanged.", 

1863 overwritten=overwritten, 

1864 new=added_new, 

1865 kept=kept, 

1866 ), 

1867 "success", 

1868 ) 

1869 

1870 return redirect(url_for("flights.list_flights", aircraft_id=ac.id)) 

1871 

1872 

1873@flights_bp.route("/aircraft/<aircraft_ref:aircraft_id>/flights/import/review") 

1874@login_required 

1875@require_role(Role.ADMIN, Role.OWNER) 

1876def airframe_import_review(aircraft_id: int) -> ResponseReturnValue: 

1877 from pilots.logbook_import import ( 

1878 parse_file, # pyright: ignore[reportMissingImports] 

1879 ) 

1880 

1881 from flights.airframe_import import ( 

1882 find_conflicting_airframe_rows, # pyright: ignore[reportMissingImports] 

1883 ) 

1884 

1885 ac = _get_aircraft_or_404(aircraft_id) 

1886 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY) 

1887 if not state or state.get("aircraft_id") != ac.id: 

1888 flash(_("Import session expired. Please upload the file again."), "warning") 

1889 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1890 

1891 tmp_path: str = state["tmp_path"] 

1892 if not os.path.isfile(tmp_path): 

1893 flash(_("Temporary file not found. Please upload the file again."), "warning") 

1894 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1895 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1896 

1897 mapping: dict[str, str] = state["mapping"] 

1898 resolved: dict[str, str] = state.get("resolved", {}) 

1899 

1900 with open(tmp_path, "rb") as fh: 

1901 data = fh.read() 

1902 try: 

1903 parsed = parse_file(data, state["original_filename"]) 

1904 except ValueError: 

1905 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1906 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1907 

1908 exclude_row_nums = {int(k) for k in resolved} 

1909 conflicts = find_conflicting_airframe_rows( 

1910 parsed, mapping, ac.id, exclude_row_nums=exclude_row_nums 

1911 ) 

1912 

1913 if not conflicts: 

1914 return _finalize_airframe_import_review(ac, state) 

1915 

1916 candidate_ids = {cid for c in conflicts for _score, cid in c.candidates} 

1917 candidate_entries: dict[int, Flight] = ( 

1918 {e.id: e for e in Flight.query.filter(Flight.id.in_(candidate_ids))} 

1919 if candidate_ids 

1920 else {} 

1921 ) 

1922 

1923 rows = [ 

1924 { 

1925 "row_num": c.row_num, 

1926 "fields": c.fields, 

1927 "crew_name": c.crew_name, 

1928 "candidates": [ 

1929 {"id": cid, "score": score, "entry": candidate_entries.get(cid)} 

1930 for score, cid in c.candidates 

1931 ], 

1932 } 

1933 for c in conflicts 

1934 ] 

1935 

1936 return render_template( 

1937 "flights/airframe_import_review.html", 

1938 aircraft=ac, 

1939 rows=rows, 

1940 resolved_count=len(resolved), 

1941 total_count=len(resolved) + len(conflicts), 

1942 ) 

1943 

1944 

1945@flights_bp.route( 

1946 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/review/resolve", 

1947 methods=["POST"], 

1948) 

1949@login_required 

1950@require_role(Role.ADMIN, Role.OWNER) 

1951def airframe_import_review_resolve(aircraft_id: int) -> ResponseReturnValue: 

1952 from pilots.logbook_import import ( 

1953 parse_file, # pyright: ignore[reportMissingImports] 

1954 ) 

1955 

1956 from flights.airframe_import import ( # pyright: ignore[reportMissingImports] 

1957 AirframeConflictRow, 

1958 _fields_to_flight_entry_kwargs, 

1959 find_conflicting_airframe_rows, 

1960 ) 

1961 

1962 ac = _get_aircraft_or_404(aircraft_id) 

1963 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY) 

1964 if not state or state.get("aircraft_id") != ac.id: 

1965 flash(_("Import session expired. Please upload the file again."), "warning") 

1966 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1967 

1968 tmp_path: str = state["tmp_path"] 

1969 mapping: dict[str, str] = state["mapping"] 

1970 resolved: dict[str, str] = state.get("resolved", {}) 

1971 

1972 try: 

1973 row_num = int(request.form.get("row_num", "")) 

1974 except (ValueError, TypeError): 

1975 flash(_("Invalid row number."), "danger") 

1976 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id)) 

1977 

1978 if str(row_num) in resolved: 

1979 flash(_("This row has already been resolved."), "info") 

1980 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id)) 

1981 

1982 if not os.path.isfile(tmp_path): 

1983 flash(_("Temporary file not found. Please upload the file again."), "warning") 

1984 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1985 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1986 

1987 with open(tmp_path, "rb") as fh: 

1988 data = fh.read() 

1989 try: 

1990 parsed = parse_file(data, state["original_filename"]) 

1991 except ValueError: 

1992 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1993 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

1994 

1995 exclude_row_nums = {int(k) for k in resolved} 

1996 conflicts = find_conflicting_airframe_rows( 

1997 parsed, mapping, ac.id, exclude_row_nums=exclude_row_nums 

1998 ) 

1999 conflict: AirframeConflictRow | None = next( 

2000 (c for c in conflicts if c.row_num == row_num), None 

2001 ) 

2002 if conflict is None: 

2003 flash(_("Invalid row number."), "danger") 

2004 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id)) 

2005 

2006 decision = request.form.get("decision", "") 

2007 candidate_ids = {cid for _score, cid in conflict.candidates} 

2008 

2009 if decision == "keep": 

2010 pass # the freshly-parsed row is discarded; the existing entry is untouched 

2011 elif decision.startswith("overwrite:"): 

2012 try: 

2013 existing_id = int(decision.split(":", 1)[1]) 

2014 except ValueError: 

2015 existing_id = -1 

2016 if existing_id not in candidate_ids: 

2017 flash(_("Invalid selection."), "danger") 

2018 return redirect( 

2019 url_for("flights.airframe_import_review", aircraft_id=ac.id) 

2020 ) 

2021 existing = Flight.query.filter_by(id=existing_id, aircraft_id=ac.id).first() 

2022 if existing is None: 

2023 # candidate_ids just came from a live query for this aircraft in 

2024 # the same request — only a concurrent delete reaches this. 

2025 abort(404) # pragma: no cover 

2026 # Full replace of every field this row provides — id and 

2027 # airframe_import_batch_id are deliberately left untouched, so this 

2028 # entry stays outside the *current* batch's rollback (it wasn't 

2029 # created by it). 

2030 for field_name, value in _fields_to_flight_entry_kwargs( 

2031 conflict.fields 

2032 ).items(): 

2033 setattr(existing, field_name, value) 

2034 if conflict.crew_name and not existing.pic_name: 

2035 existing.pic_name = conflict.crew_name 

2036 db.session.commit() 

2037 elif decision == "new": 

2038 from models import AirframeImportBatch # pyright: ignore[reportMissingImports] 

2039 

2040 batch_id: int = state["batch_id"] 

2041 fe = Flight( 

2042 aircraft_id=ac.id, 

2043 airframe_import_batch_id=batch_id, 

2044 source="import", 

2045 pic_name=conflict.crew_name, 

2046 **_fields_to_flight_entry_kwargs(conflict.fields), 

2047 ) 

2048 db.session.add(fe) 

2049 db.session.flush() 

2050 batch = db.session.get(AirframeImportBatch, batch_id) 

2051 if batch is not None: 

2052 batch.row_count += 1 

2053 else: 

2054 current_app.logger.debug( # pragma: no cover — batch was just created 

2055 "airframe_import_review_resolve: batch %s vanished before resolve", 

2056 batch_id, 

2057 ) 

2058 db.session.commit() 

2059 else: 

2060 flash(_("Invalid decision."), "danger") 

2061 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id)) 

2062 

2063 resolved[str(row_num)] = decision 

2064 state["resolved"] = resolved 

2065 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = state 

2066 session.modified = True 

2067 

2068 remaining = find_conflicting_airframe_rows( 

2069 parsed, mapping, ac.id, exclude_row_nums={int(k) for k in resolved} 

2070 ) 

2071 if not remaining: 

2072 return _finalize_airframe_import_review(ac, state) 

2073 

2074 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id)) 

2075 

2076 

2077@flights_bp.route( 

2078 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/<int:batch_id>/rollback", 

2079 methods=["POST"], 

2080) 

2081@login_required 

2082@require_role(Role.ADMIN, Role.OWNER) 

2083def airframe_import_rollback(aircraft_id: int, batch_id: int) -> ResponseReturnValue: 

2084 from models import AirframeImportBatch # pyright: ignore[reportMissingImports] 

2085 

2086 ac = _get_aircraft_or_404(aircraft_id) 

2087 batch = db.session.get(AirframeImportBatch, batch_id) 

2088 if not batch or batch.aircraft_id != ac.id: 

2089 abort(404) 

2090 

2091 entry_ids = [ 

2092 row.id 

2093 for row in Flight.query.filter_by(airframe_import_batch_id=batch.id) 

2094 .with_entities(Flight.id) 

2095 .all() 

2096 ] 

2097 if entry_ids: 

2098 Flight.query.filter(Flight.id.in_(entry_ids)).delete(synchronize_session=False) 

2099 db.session.delete(batch) 

2100 db.session.commit() 

2101 

2102 flash( 

2103 _( 

2104 "Import deleted: %(n)d flight entries removed.", 

2105 n=len(entry_ids), 

2106 ), 

2107 "success", 

2108 ) 

2109 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id)) 

2110 

2111 

2112@flights_bp.route( 

2113 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/<int:batch_id>/toggle-historical", 

2114 methods=["POST"], 

2115) 

2116@login_required 

2117@require_role(Role.ADMIN, Role.OWNER) 

2118def airframe_import_toggle_historical( 

2119 aircraft_id: int, batch_id: int 

2120) -> ResponseReturnValue: 

2121 from models import AirframeImportBatch # pyright: ignore[reportMissingImports] 

2122 

2123 ac = _get_aircraft_or_404(aircraft_id) 

2124 batch = db.session.get(AirframeImportBatch, batch_id) 

2125 if not batch or batch.aircraft_id != ac.id: 

2126 abort(404) 

2127 

2128 batch.is_historical = not batch.is_historical 

2129 db.session.commit() 

2130 

2131 if batch.is_historical: 

2132 flash( 

2133 _( 

2134 "Import marked as historical: edits to its flights will no " 

2135 "longer be blocked by validation on old, imprecise values." 

2136 ), 

2137 "success", 

2138 ) 

2139 else: 

2140 flash( 

2141 _( 

2142 "Import no longer marked as historical: edits to its " 

2143 "flights are now subject to the normal validation rules." 

2144 ), 

2145 "success", 

2146 ) 

2147 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))