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

1154 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 15:29 +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 flask_babel import ngettext # pyright: ignore[reportMissingImports] 

41from models import ( 

42 Aircraft, 

43 AppSetting, 

44 Component, 

45 CorrectionStatus, 

46 CrewInviteKind, 

47 CrewInviteStatus, 

48 CrewRole, 

49 CrewSlot, 

50 Document, 

51 Flight, 

52 FlightCorrectionSuggestion, 

53 FlightCrewInvite, 

54 GpsTrack, 

55 Reservation, 

56 ReservationStatus, 

57 Role, 

58 TenantUser, 

59 User, 

60 db, 

61) # pyright: ignore[reportMissingImports] 

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

63 get_active_revision, 

64 recency_breaches, 

65) 

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

67from utils import ( 

68 accessible_aircraft, 

69 activity, 

70 current_user_role, 

71 login_required, 

72 require_pilot_access, 

73 require_role, 

74 tenant_pilots, 

75 user_can_access_aircraft, 

76) # pyright: ignore[reportMissingImports] 

77from werkzeug.utils import secure_filename 

78 

79from flights.crew_invites import ( # pyright: ignore[reportMissingImports] 

80 accept_invite, 

81 claim_option, 

82 create_claim, 

83 decline_invite, 

84 notify_claim_answered, 

85 notify_claim_created, 

86 notify_invite_answered, 

87 notify_invites, 

88 own_slot, 

89 pending_invites_for_flight, 

90 requested_invites, 

91 sync_crew_invites, 

92) 

93from flights.crew_removal import ( # pyright: ignore[reportMissingImports] 

94 flash_kept_in_pilot_logbooks, 

95 remove_from_aircraft_log, 

96 unlink_user, 

97) 

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

99 apply_flight_fields, 

100 flight_is_lenient, 

101 parse_flight_fields, 

102) 

103from flights.shared_flight import ( # pyright: ignore[reportMissingImports] 

104 FIELD_SPECS, 

105 apply_personal_fields, 

106 apply_suggestion, 

107 build_suggestion, 

108 can_edit_shared, 

109 current_values, 

110 field_label, 

111 notify_shared_changes, 

112 notify_suggestion_created, 

113 notify_suggestion_rejected, 

114 pending_suggestions_by, 

115 personal_function_hours, 

116 protect_other_pilots, 

117 reject_suggestion, 

118 restore_other_pilots, 

119 shared_editor_ids, 

120 shared_snapshot, 

121 suggestable_fields, 

122) 

123 

124flights_bp = Blueprint("flights", __name__) 

125 

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

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

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

129_NATURE_SUGGESTIONS = [ 

130 "Local flight", 

131 "Navigation", 

132 "Cross-country", 

133 "Training", 

134 "IFR practice", 

135 "Night flight", 

136 "Touch-and-go", 

137 "Ferry flight", 

138 "Air test", 

139 "Sightseeing", 

140] 

141 

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

143 

144# Aircraft logbook pagination (list_flights) -- same values/convention as 

145# the pilot logbook (pilots/routes.py _VALID_PER_PAGE/_DEFAULT_PER_PAGE). 

146_FLIGHTS_VALID_PER_PAGE = (10, 20, 50, 100) 

147_FLIGHTS_DEFAULT_PER_PAGE = 20 

148 

149 

150def _skipped_rows_summary(result: Any) -> list[str]: 

151 """The sentences of a logbook import summary (airframe or pilot) about 

152 rows left out — only those with something to report, each pluralised 

153 on its own count.""" 

154 parts = [] 

155 if result.subtotals: 

156 parts.append( 

157 ngettext( 

158 "One subtotal row skipped.", 

159 "%(n)d subtotal rows skipped.", 

160 result.subtotals, 

161 n=result.subtotals, 

162 ) 

163 ) 

164 if result.skipped: 

165 parts.append( 

166 ngettext( 

167 "One row could not be parsed.", 

168 "%(n)d rows could not be parsed.", 

169 len(result.skipped), 

170 n=len(result.skipped), 

171 ) 

172 ) 

173 return parts 

174 

175 

176def _openaip_key() -> str | None: 

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

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

179 

180 

181def _tenant_id() -> int: 

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

183 if not tu: 

184 abort(403) 

185 return int(tu.tenant_id) 

186 

187 

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

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

190 this_flight = float(fe.flight_time or 0) 

191 if this_flight <= 0: 

192 return 

193 tid = _tenant_id() 

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

195 new_total = float( 

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

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

198 .scalar() 

199 or 0 

200 ) 

201 old_total = new_total - this_flight 

202 for milestone in _HOUR_MILESTONES: 

203 if old_total < milestone <= new_total: 

204 session["milestone_hours"] = milestone 

205 flash( 

206 _( 

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

208 hours=milestone, 

209 ), 

210 "info", 

211 ) 

212 break 

213 

214 

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

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

217 if ( 

218 not ac 

219 or ac.tenant_id != _tenant_id() 

220 or not user_can_access_aircraft(aircraft_id) 

221 ): 

222 abort(404) 

223 return ac 

224 

225 

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

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

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

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

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

231 if not fe: 

232 abort(404) 

233 if fe.aircraft_id is not None: 

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

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

236 abort(404) 

237 else: 

238 uid = session.get("user_id") 

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

240 abort(404) 

241 return fe 

242 

243 

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

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

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

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

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

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

250 # entry). 

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

252 if ext not in _ALLOWED_PHOTO_EXTS: 

253 return None 

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

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

256 os.makedirs(folder, exist_ok=True) 

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

258 return stored 

259 

260 

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

262 if not filename: 

263 return 

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

265 try: 

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

267 except OSError: 

268 current_app.logger.debug( 

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

270 ) 

271 

272 

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

274 used = [ 

275 row[0] 

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

277 .filter_by(aircraft_id=aircraft_id) 

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

279 .distinct() 

280 .all() 

281 ] 

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

283 

284 

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

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

287 try: 

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

289 detect_segments, 

290 merge_and_sort, 

291 parse_gps_file, 

292 ) 

293 except ImportError: 

294 return None 

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

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

297 if ext not in _ALLOWED_GPS_EXTS: 

298 return None 

299 data = file.read() 

300 try: 

301 parsed = parse_gps_file(data, filename) 

302 all_points = merge_and_sort([parsed]) 

303 segments = detect_segments(all_points) 

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

305 return None 

306 if not segments: 

307 return None 

308 seg = segments[0] 

309 return { 

310 "filename": filename, 

311 "device_id": parsed.device_id, 

312 "block_off_utc": seg.block_off_utc, 

313 "block_on_utc": seg.block_on_utc, 

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

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

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

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

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

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

320 "geojson": seg.track_geojson, 

321 "landing_count": seg.landing_count, 

322 } 

323 

324 

325def _find_duplicate_flight( 

326 aircraft_id: int | None, 

327 pilot_user_id: int, 

328 date: _date, 

329 dep_icao: str, 

330 arr_icao: str, 

331 block_off: _datetime | None, 

332 block_on: _datetime | None, 

333 exclude_flight_id: int | None = None, 

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

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

336 

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

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

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

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

341 row and the same id. 

342 """ 

343 if aircraft_id and block_off and block_on: 

344 q = Flight.query.filter( 

345 Flight.aircraft_id == aircraft_id, 

346 Flight.block_off_utc.isnot(None), 

347 Flight.block_on_utc.isnot(None), 

348 Flight.block_off_utc < block_on, 

349 Flight.block_on_utc > block_off, 

350 ) 

351 if exclude_flight_id: 

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

353 existing = q.first() 

354 if existing: 

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

356 

357 if aircraft_id and not block_off: 

358 q2 = Flight.query.filter_by( 

359 aircraft_id=aircraft_id, 

360 date=date, 

361 departure_icao=dep_icao, 

362 arrival_icao=arr_icao, 

363 ) 

364 if exclude_flight_id: 

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

366 existing2 = q2.first() 

367 if existing2: 

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

369 

370 q3 = Flight.query.filter( 

371 or_( 

372 Flight.pic_user_id == pilot_user_id, 

373 Flight.second_crew_user_id == pilot_user_id, 

374 ), 

375 Flight.date == date, 

376 Flight.departure_icao == dep_icao, 

377 Flight.arrival_icao == arr_icao, 

378 ) 

379 if exclude_flight_id: 

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

381 existing3 = q3.first() 

382 if existing3: 

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

384 

385 return None 

386 

387 

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

389 last = ( 

390 Flight.query.filter_by(aircraft_id=aircraft_id) 

391 .filter( 

392 db.or_( 

393 Flight.flight_time_counter_end.isnot(None), 

394 Flight.engine_time_counter_end.isnot(None), 

395 ) 

396 ) 

397 .order_by( 

398 Flight.date.desc(), 

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

400 Flight.id.desc(), 

401 ) 

402 .first() 

403 ) 

404 if not last: 

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

406 return { 

407 "flight": float(last.flight_time_counter_end) 

408 if last.flight_time_counter_end is not None 

409 else None, 

410 "engine": float(last.engine_time_counter_end) 

411 if last.engine_time_counter_end is not None 

412 else None, 

413 } 

414 

415 

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

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

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

419_RESERVATION_LINK_BEFORE = _timedelta(hours=2) 

420_RESERVATION_LINK_AFTER = _timedelta(hours=6) 

421 

422 

423def _find_covering_reservation( 

424 aircraft_id: int, pilot_user_id: int, anchor: _datetime 

425) -> Reservation | None: 

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

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

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

429 aircraft_id=aircraft_id, 

430 pilot_user_id=pilot_user_id, 

431 status=ReservationStatus.CONFIRMED, 

432 ).all() 

433 for r in candidates: 

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

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

436 cmp_anchor = ( 

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

438 ) 

439 if ( 

440 r.start_dt - _RESERVATION_LINK_BEFORE 

441 <= cmp_anchor 

442 <= r.end_dt + _RESERVATION_LINK_AFTER 

443 ): 

444 return r 

445 return None 

446 

447 

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

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

450 

451 

452def pilot_time(fe: Flight) -> Any: 

453 """The duration that goes into the pilot logbook's EASA figures. 

454 

455 EASA FCL.050 flight time is block-to-block — the engine-side track 

456 (`engine_time`: engine counters, else departure/arrival clock times) — 

457 not the airborne segment `flight_time` measures for the airframe log. 

458 Filling in an aircraft's flight counters must never shrink the pilot's 

459 own logged time to the airborne figure. `flight_time` is only the 

460 fallback when nothing engine-side is known (e.g. a GPS import, which 

461 stores its block-to-block duration there). 

462 """ 

463 return fe.engine_time if fe.engine_time is not None else fe.flight_time 

464 

465 

466def apply_pilot_identity( 

467 fe: Flight, 

468 ac: Aircraft | None, 

469 uid: int, 

470 pilot_role: str, 

471) -> None: 

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

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

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

475 from the flight's pilot time (see `pilot_time`) and `ac`'s category. 

476 

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

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

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

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

481 the fields here. 

482 

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

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

485 no-fleet-aircraft branch's behaviour. 

486 

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

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

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

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

491 """ 

492 ft_decimal = pilot_time(fe) 

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

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

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

496 

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

498 display_name = _u.display_name if _u else "" 

499 

500 if pilot_role == "pic": 

501 fe.pic_user_id = uid 

502 if not fe.pic_name: 

503 fe.pic_name = display_name 

504 fe.function_pic = ft_decimal 

505 fe.function_dual = None 

506 else: # "dual" 

507 fe.second_crew_user_id = uid 

508 if not fe.second_crew_name: 

509 fe.second_crew_name = display_name 

510 if not fe.second_crew_role: 

511 fe.second_crew_role = CrewRole.STUDENT 

512 fe.function_dual = ft_decimal 

513 fe.function_pic = None 

514 

515 

516# ── Serve uploads ───────────────────────────────────────────────────────────── 

517 

518 

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

520@login_required 

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

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

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

524 if doc is not None: 

525 if doc.aircraft_id is not None: 

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

527 _get_aircraft_or_404( 

528 doc.aircraft_id 

529 ) # aborts 404 if wrong tenant/no access 

530 elif doc.flight_entry_id is not None: 

531 _get_flight_or_404(doc.flight_entry_id) 

532 elif doc.pilot_user_id is not None: 

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

534 abort(404) 

535 else: 

536 abort(404) 

537 else: 

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

539 fe = Flight.query.filter( 

540 or_( 

541 Flight.flight_counter_photo == filename, 

542 Flight.engine_counter_photo == filename, 

543 Flight.fuel_photo == filename, 

544 Flight.flight_counter_photo_preflight == filename, 

545 Flight.engine_counter_photo_preflight == filename, 

546 ) 

547 ).first() 

548 if fe is None: 

549 abort(404) 

550 _get_flight_or_404(fe.id) 

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

552 return send_from_directory(folder, filename) 

553 

554 

555# ── Fleet logbook ───────────────────────────────────────────────────────────── 

556 

557 

558@flights_bp.route("/flights") 

559@login_required 

560def fleet_flights() -> ResponseReturnValue: 

561 tid = _tenant_id() 

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

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

564 flights = ( 

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

566 .order_by( 

567 Flight.date.desc(), 

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

569 Flight.id.desc(), 

570 ) 

571 .all() 

572 ) 

573 return render_template( 

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

575 ) 

576 

577 

578# ── Airframe logbook ────────────────────────────────────────────────────────── 

579 

580 

581def _flights_total_hours(aircraft_id: int) -> float: 

582 """Sum of flight_time (falling back to the flight-counter delta when 

583 flight_time itself is unset) across ALL of this aircraft's flights -- 

584 computed in SQL so the footer total in flights/list.html stays correct 

585 once that list is paginated, instead of only summing the current page.""" 

586 duration = func.coalesce( 

587 Flight.flight_time, 

588 case( 

589 ( 

590 Flight.flight_time_counter_end.isnot(None) 

591 & Flight.flight_time_counter_start.isnot(None), 

592 Flight.flight_time_counter_end - Flight.flight_time_counter_start, 

593 ), 

594 else_=0, 

595 ), 

596 ) 

597 total = ( 

598 db.session.query(func.sum(duration)) 

599 .filter(Flight.aircraft_id == aircraft_id) 

600 .scalar() 

601 ) 

602 return float(total) if total is not None else 0.0 

603 

604 

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

606@login_required 

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

608 ac = _get_aircraft_or_404(aircraft_id) 

609 page = request.args.get("page", 1, type=int) 

610 pp_raw = request.args.get("per_page", str(_FLIGHTS_DEFAULT_PER_PAGE)) 

611 show_all = pp_raw == "all" 

612 per_page = ( 

613 None 

614 if show_all 

615 else ( 

616 int(pp_raw) 

617 if pp_raw.isdigit() and int(pp_raw) in _FLIGHTS_VALID_PER_PAGE 

618 else _FLIGHTS_DEFAULT_PER_PAGE 

619 ) 

620 ) 

621 

622 query = Flight.query.filter_by(aircraft_id=ac.id).order_by( 

623 Flight.date.desc(), 

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

625 Flight.id.desc(), 

626 ) 

627 if show_all: 

628 flights = query.all() 

629 pagination = None 

630 total_count = len(flights) 

631 else: 

632 pagination = query.paginate(page=page, per_page=per_page, error_out=False) 

633 flights = pagination.items 

634 total_count = pagination.total 

635 

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

637 return render_template( 

638 "flights/list.html", 

639 aircraft=ac, 

640 flights=flights, 

641 pagination=pagination, 

642 per_page=pp_raw, 

643 valid_per_page=_FLIGHTS_VALID_PER_PAGE, 

644 total_count=total_count, 

645 total_hours=_flights_total_hours(ac.id), 

646 milestone_hours=milestone_hours, 

647 ) 

648 

649 

650# ── Component logbook ───────────────────────────────────────────────────────── 

651 

652 

653@flights_bp.route( 

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

655) 

656@login_required 

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

658 ac = _get_aircraft_or_404(aircraft_id) 

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

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

661 abort(404) 

662 

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

664 if comp.installed_at: 

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

666 if comp.removed_at: 

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

668 

669 flights_asc = query.order_by( 

670 Flight.date.asc(), 

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

672 Flight.id.asc(), 

673 ).all() 

674 

675 base = float(comp.time_at_install or 0) 

676 cumulative = base 

677 flights_with_hours = [] 

678 for f in flights_asc: 

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

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

681 if f.engine_time is not None: 

682 cumulative += float(f.engine_time) 

683 elif ( 

684 f.engine_time_counter_end is not None 

685 and f.engine_time_counter_start is not None 

686 ): 

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

688 f.engine_time_counter_start 

689 ) 

690 flights_with_hours.append((f, cumulative)) 

691 

692 flights_with_hours.reverse() 

693 

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

695 # a recorded overhaul resets the reference point. 

696 tbo_hours = ( 

697 float(comp.tbo_hours) 

698 if comp.tbo_hours is not None 

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

700 ) 

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

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

703 

704 return render_template( 

705 "flights/logbook_component.html", 

706 aircraft=ac, 

707 component=comp, 

708 flights_with_hours=flights_with_hours, 

709 total_component_hours=cumulative, 

710 since_overhaul=since_overhaul, 

711 tbo_hours=tbo_hours, 

712 tbo_remaining=tbo_remaining, 

713 ) 

714 

715 

716# ── Unified log / edit flight ───────────────────────────────────────────────── 

717 

718 

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

720@login_required 

721@require_pilot_access 

722def log_flight() -> ResponseReturnValue: 

723 tid = _tenant_id() 

724 managed_aircraft = accessible_aircraft(tid).all() 

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

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

727 

728 if request.method == "POST": 

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

730 

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

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

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

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

735 pilot_name_hint = _u.display_name if _u else "" 

736 nature_suggestions = _NATURE_SUGGESTIONS 

737 aircraft: Aircraft | None = None 

738 if preselect_id: 

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

740 if aircraft: 

741 nature_suggestions = _nature_suggestions(aircraft.id) 

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

743 covering_reservation = ( 

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

745 if aircraft 

746 else None 

747 ) 

748 

749 active_minimums = get_active_revision(uid) 

750 minimums_breaches = ( 

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

752 ) 

753 

754 return render_template( 

755 "flights/flight_form.html", 

756 flight=None, 

757 pilot_entry=None, 

758 aircraft=aircraft, 

759 managed_aircraft=managed_aircraft, 

760 preselect_aircraft_id=preselect_id, 

761 gps_prefill=gps_prefill, 

762 nature_suggestions=nature_suggestions, 

763 pilot_name_hint=pilot_name_hint, 

764 crew_pilots=tenant_pilots(tid), 

765 crew_pending_invites={}, 

766 crew_roles=CrewRole, 

767 fuel_units=_FUEL_UNITS, 

768 duplicate=None, 

769 counter_hint=counter_hint, 

770 openaip_key=_openaip_key(), 

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

772 gps_review_return_aircraft_id=gps_review_return_aircraft_id, 

773 gps_review_return_seg_idx=gps_review_return_seg_idx, 

774 covering_reservation=covering_reservation, 

775 active_minimums=active_minimums, 

776 minimums_breaches=minimums_breaches, 

777 ) 

778 

779 

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

781@login_required 

782@require_pilot_access 

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

784 tid = _tenant_id() 

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

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

787 fe = _get_flight_or_404(flight_id) 

788 

789 # A pilot who confirmed someone else's flight edits only their own part 

790 # of it and suggests corrections to the rest (flights/shared_flight.py). 

791 if not can_edit_shared(fe, uid, current_user_role()): 

792 return redirect(url_for("flights.crew_entry", flight_id=fe.id)) 

793 

794 if request.method == "POST": 

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

796 

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

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

799 # separate PilotLogbookEntry to fetch. 

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

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

802 

803 return render_template( 

804 "flights/flight_form.html", 

805 flight=fe, 

806 pilot_entry=None, 

807 aircraft=aircraft, 

808 managed_aircraft=managed_aircraft, 

809 preselect_aircraft_id=fe.aircraft_id, 

810 gps_prefill=gps_prefill, 

811 nature_suggestions=_nature_suggestions(fe.aircraft_id), 

812 pilot_name_hint=None, 

813 crew_pilots=tenant_pilots(tid), 

814 crew_pending_invites=pending_invites_for_flight(fe), 

815 crew_locked_slots=_crew_locked_slots(fe, uid), 

816 crew_roles=CrewRole, 

817 fuel_units=_FUEL_UNITS, 

818 duplicate=None, 

819 counter_hint=counter_hint, 

820 openaip_key=_openaip_key(), 

821 gps_review_return_aircraft_id=None, 

822 gps_review_return_seg_idx=None, 

823 covering_reservation=None, 

824 active_minimums=None, 

825 minimums_breaches=[], 

826 ) 

827 

828 

829@flights_bp.app_template_global("correction_field_label") 

830def correction_field_label(field: str) -> str: 

831 return field_label(field) 

832 

833 

834def _crew_locked_slots(fe: Flight, uid: int | None) -> dict[str, str]: 

835 """Slots linked to another pilot's account → that pilot's name. The 

836 flight form shows their name/role read-only: only they change them.""" 

837 locked: dict[str, str] = {} 

838 for slot, occupant in ( 

839 (CrewSlot.PIC, fe.pic_user_id), 

840 (CrewSlot.SECOND, fe.second_crew_user_id), 

841 ): 

842 if occupant is not None and occupant != uid: 

843 user = db.session.get(User, occupant) 

844 locked[slot] = user.display_name if user else "—" 

845 return locked 

846 

847 

848# ── My part of a shared flight ─────────────────────────────────────────────── 

849 

850 

851@flights_bp.route("/flights/<int:flight_id>/my-part", methods=["GET", "POST"]) 

852@login_required 

853@require_pilot_access 

854def crew_entry(flight_id: int) -> ResponseReturnValue: 

855 """A linked pilot's own name, role, function time and remark on a flight, 

856 plus (when they don't own its shared fields) suggesting corrections.""" 

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

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

859 slot = fe.slot_for(uid) if fe else None 

860 if fe is None or slot is None: 

861 abort(404) 

862 can_edit = can_edit_shared(fe, uid, current_user_role()) 

863 form_values = current_values(fe) 

864 

865 if request.method == "POST": 

866 action = request.form.get("action") 

867 # flask.abort() always raises — the `else` branch below never falls 

868 # through — but static analysis doesn't know that, so this is 

869 # initialized unconditionally rather than relying on it. 

870 errors: list[str] = [] 

871 if action == "personal": 

872 errors = apply_personal_fields(fe, slot, request.form) 

873 if not errors: 

874 db.session.commit() 

875 flash(_("Your part of this flight was saved."), "success") 

876 return redirect(url_for("flights.crew_entry", flight_id=fe.id)) 

877 elif action == "suggest" and not can_edit: 

878 changes, errors = build_suggestion(fe, request.form) 

879 form_values.update( 

880 {k: request.form.get(k, "") for k in suggestable_fields(fe)} 

881 ) 

882 if not errors and not changes: 

883 errors = [_("Nothing to suggest — the values are unchanged.")] 

884 if not errors: 

885 suggestion = FlightCorrectionSuggestion( 

886 flight_id=fe.id, suggested_by_user_id=uid, changes=changes 

887 ) 

888 db.session.add(suggestion) 

889 db.session.commit() 

890 notify_suggestion_created(suggestion) 

891 flash( 

892 _( 

893 "Correction sent. It is applied once the pilot who logged " 

894 "this flight accepts it." 

895 ), 

896 "success", 

897 ) 

898 return redirect(url_for("flights.crew_entry", flight_id=fe.id)) 

899 else: 

900 abort(400) 

901 for msg in errors: 

902 flash(msg, "danger") 

903 

904 logger_ids = shared_editor_ids(fe) - {uid} 

905 logger = db.session.get(User, min(logger_ids)) if logger_ids else None 

906 return render_template( 

907 "flights/crew_entry.html", 

908 flight=fe, 

909 slot=slot, 

910 can_edit_shared=can_edit, 

911 logger=logger, 

912 function_hours=personal_function_hours(fe, slot), 

913 crew_roles=CrewRole, 

914 suggestable=suggestable_fields(fe), 

915 field_specs=FIELD_SPECS, 

916 form_values=form_values, 

917 my_suggestions=pending_suggestions_by(uid, fe), 

918 field_label=field_label, 

919 ) 

920 

921 

922def _get_reviewable_suggestion_or_404(suggestion_id: int) -> FlightCorrectionSuggestion: 

923 uid = session.get("user_id") 

924 suggestion = db.session.get(FlightCorrectionSuggestion, suggestion_id) 

925 if suggestion is None: 

926 abort(404) 

927 fe = db.session.get(Flight, suggestion.flight_id) 

928 assert fe is not None, "flight_id is a non-null CASCADE foreign key" 

929 if uid == suggestion.suggested_by_user_id or uid not in shared_editor_ids(fe): 

930 abort(404) 

931 return suggestion 

932 

933 

934def _correction_redirect() -> ResponseReturnValue: 

935 """Fixed allow-list, like _crew_invite_redirect.""" 

936 if request.form.get("next") == "dashboard": 

937 return redirect(url_for("index") + "#flight-corrections") 

938 return redirect(url_for("pilots.logbook") + "#flight-corrections") 

939 

940 

941@flights_bp.route("/flights/corrections/<int:suggestion_id>/accept", methods=["POST"]) 

942@login_required 

943@require_pilot_access 

944def accept_correction(suggestion_id: int) -> ResponseReturnValue: 

945 suggestion = _get_reviewable_suggestion_or_404(suggestion_id) 

946 if suggestion.status != CorrectionStatus.PENDING: 

947 flash(_("This correction has already been handled."), "warning") 

948 return _correction_redirect() 

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

950 before = apply_suggestion(suggestion, uid) 

951 db.session.commit() 

952 fe = db.session.get(Flight, suggestion.flight_id) 

953 assert fe is not None, "flight_id is a non-null CASCADE foreign key" 

954 notify_shared_changes(fe, uid, before) 

955 flash(_("Correction applied to the flight."), "success") 

956 return _correction_redirect() 

957 

958 

959@flights_bp.route("/flights/corrections/<int:suggestion_id>/reject", methods=["POST"]) 

960@login_required 

961@require_pilot_access 

962def reject_correction(suggestion_id: int) -> ResponseReturnValue: 

963 suggestion = _get_reviewable_suggestion_or_404(suggestion_id) 

964 if suggestion.status != CorrectionStatus.PENDING: 

965 flash(_("This correction has already been handled."), "warning") 

966 return _correction_redirect() 

967 reject_suggestion(suggestion) 

968 db.session.commit() 

969 notify_suggestion_rejected(suggestion, int(session["user_id"])) 

970 flash(_("Correction rejected."), "info") 

971 return _correction_redirect() 

972 

973 

974def _get_own_pending_invite_or_404(invite_id: int) -> FlightCrewInvite: 

975 invite = db.session.get(FlightCrewInvite, invite_id) 

976 if ( 

977 not invite 

978 or invite.kind != CrewInviteKind.INVITE 

979 or invite.invited_user_id != session.get("user_id") 

980 ): 

981 abort(404) 

982 return invite 

983 

984 

985def _get_reviewable_claim_or_404(claim_id: int) -> FlightCrewInvite: 

986 claim = db.session.get(FlightCrewInvite, claim_id) 

987 if claim is None or claim.kind != CrewInviteKind.CLAIM: 

988 abort(404) 

989 fe = db.session.get(Flight, claim.flight_id) 

990 assert fe is not None, "flight_id is a non-null CASCADE foreign key" 

991 if session.get("user_id") not in shared_editor_ids(fe): 

992 abort(404) 

993 return claim 

994 

995 

996def _claim_redirect() -> ResponseReturnValue: 

997 """Fixed allow-list, like _crew_invite_redirect.""" 

998 if request.form.get("next") == "dashboard": 

999 return redirect(url_for("index") + "#crew-claims") 

1000 return redirect(url_for("pilots.logbook") + "#crew-claims") 

1001 

1002 

1003@flights_bp.route("/flights/crew-claims/<int:claim_id>/approve", methods=["POST"]) 

1004@login_required 

1005@require_pilot_access 

1006def approve_crew_claim(claim_id: int) -> ResponseReturnValue: 

1007 claim = _get_reviewable_claim_or_404(claim_id) 

1008 if claim.status != CrewInviteStatus.PENDING: 

1009 flash(_("This request has already been handled."), "warning") 

1010 return _claim_redirect() 

1011 claimant = db.session.get(User, claim.invited_user_id) 

1012 assert claimant is not None, "invited_user_id is a non-null CASCADE foreign key" 

1013 accepted = accept_invite(claim, claimant) 

1014 db.session.commit() 

1015 if accepted: 

1016 notify_claim_answered(claim, accepted=True, actor_id=int(session["user_id"])) 

1017 flash( 

1018 _("%(name)s was added to the flight.", name=claimant.display_name), 

1019 "success", 

1020 ) 

1021 else: 

1022 flash( 

1023 _("That crew position has already been filled — nothing to approve."), 

1024 "warning", 

1025 ) 

1026 return _claim_redirect() 

1027 

1028 

1029@flights_bp.route("/flights/crew-claims/<int:claim_id>/decline", methods=["POST"]) 

1030@login_required 

1031@require_pilot_access 

1032def decline_crew_claim(claim_id: int) -> ResponseReturnValue: 

1033 claim = _get_reviewable_claim_or_404(claim_id) 

1034 if claim.status != CrewInviteStatus.PENDING: 

1035 flash(_("This request has already been handled."), "warning") 

1036 return _claim_redirect() 

1037 decline_invite(claim) 

1038 db.session.commit() 

1039 notify_claim_answered(claim, accepted=False, actor_id=int(session["user_id"])) 

1040 flash(_("Request declined."), "info") 

1041 return _claim_redirect() 

1042 

1043 

1044def _crew_invite_redirect() -> ResponseReturnValue: 

1045 """Back to where the Confirm/Decline button was clicked — a fixed 

1046 allow-list, so the `next` field can never become an open redirect.""" 

1047 if request.form.get("next") == "dashboard": 

1048 return redirect(url_for("index") + "#crew-invites") 

1049 return redirect(url_for("pilots.logbook") + "#crew-invites") 

1050 

1051 

1052@flights_bp.route("/flights/crew-invites/<int:invite_id>/accept", methods=["POST"]) 

1053@login_required 

1054@require_pilot_access 

1055def accept_crew_invite(invite_id: int) -> ResponseReturnValue: 

1056 invite = _get_own_pending_invite_or_404(invite_id) 

1057 if invite.status != CrewInviteStatus.PENDING: 

1058 flash(_("This flight invitation is no longer open."), "warning") 

1059 return _crew_invite_redirect() 

1060 user = db.session.get(User, invite.invited_user_id) 

1061 assert user is not None, "invited_user_id is a non-null CASCADE foreign key" 

1062 accepted = accept_invite(invite, user) 

1063 db.session.commit() 

1064 if accepted: 

1065 notify_invite_answered(invite, accepted=True) 

1066 flash(_("Flight confirmed — it's now in your pilot logbook."), "success") 

1067 else: 

1068 flash( 

1069 _("This flight's crew slot has already been filled — nothing to confirm."), 

1070 "warning", 

1071 ) 

1072 return _crew_invite_redirect() 

1073 

1074 

1075@flights_bp.route("/flights/crew-invites/<int:invite_id>/decline", methods=["POST"]) 

1076@login_required 

1077@require_pilot_access 

1078def decline_crew_invite(invite_id: int) -> ResponseReturnValue: 

1079 invite = _get_own_pending_invite_or_404(invite_id) 

1080 if invite.status != CrewInviteStatus.PENDING: 

1081 flash(_("This flight invitation is no longer open."), "warning") 

1082 return _crew_invite_redirect() 

1083 decline_invite(invite) 

1084 db.session.commit() 

1085 notify_invite_answered(invite, accepted=False) 

1086 flash(_("Flight invitation declined."), "info") 

1087 return _crew_invite_redirect() 

1088 

1089 

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

1091@login_required 

1092@require_pilot_access 

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

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

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

1096 from utils import ( 

1097 generate_single_track_image, # pyright: ignore[reportMissingImports] 

1098 ) 

1099 

1100 fe = _get_flight_or_404(flight_id) 

1101 track = fe.gps_track 

1102 if not track or not track.geojson: 

1103 abort(404) 

1104 

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

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

1107 is_default = not hires and not portrait 

1108 

1109 if is_default and track.cached_png: 

1110 png_bytes = bytes(track.cached_png) 

1111 else: 

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

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

1114 mul = 2 if hires else 1 

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

1116 

1117 png_bytes = generate_single_track_image( 

1118 track.geojson, 

1119 date=str(fe.date), 

1120 dep=fe.departure_icao or "", 

1121 arr=fe.arrival_icao or "", 

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

1123 canvas_w=canvas_w, 

1124 canvas_h=canvas_h, 

1125 high_res=hires, 

1126 ) 

1127 if is_default: 

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

1129 db.session.commit() 

1130 orient_sfx = "-portrait" if portrait else "" 

1131 qual_sfx = "-hires" if hires else "" 

1132 suffix = orient_sfx + qual_sfx 

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

1134 return Response( 

1135 png_bytes, 

1136 mimetype="image/png", 

1137 headers={ 

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

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

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

1141 }, 

1142 ) 

1143 

1144 

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

1146@login_required 

1147@require_pilot_access 

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

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

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

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

1152 

1153 fe = _get_flight_or_404(flight_id) 

1154 track = fe.gps_track 

1155 if not track or not track.geojson: 

1156 abort(404) 

1157 

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

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

1160 is_default = not hires and not portrait 

1161 

1162 if is_default and track.cached_gif: 

1163 gif_bytes = bytes(track.cached_gif) 

1164 else: 

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

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

1167 mul = 2 if hires else 1 

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

1169 

1170 gif_bytes = generate_single_track_gif( 

1171 track.geojson, 

1172 date=str(fe.date), 

1173 dep=fe.departure_icao or "", 

1174 arr=fe.arrival_icao or "", 

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

1176 canvas_w=canvas_w, 

1177 canvas_h=canvas_h, 

1178 high_res=hires, 

1179 ) 

1180 if is_default: 

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

1182 db.session.commit() 

1183 orient_sfx = "-portrait" if portrait else "" 

1184 qual_sfx = "-hires" if hires else "" 

1185 suffix = orient_sfx + qual_sfx 

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

1187 return Response( 

1188 gif_bytes, 

1189 mimetype="image/gif", 

1190 headers={ 

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

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

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

1194 }, 

1195 ) 

1196 

1197 

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

1199@login_required 

1200@require_pilot_access 

1201def registration_lookup() -> ResponseReturnValue: 

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

1203 

1204 Sources (in priority order): 

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

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

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

1208 

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

1210 """ 

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

1212 if not q: 

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

1214 

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

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

1217 

1218 q_norm = _norm(q) 

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

1220 tid = _tenant_id() 

1221 

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

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

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

1225 user_entries = ( 

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

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

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

1229 .order_by( 

1230 Flight.date.desc(), 

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

1232 Flight.id.desc(), 

1233 ) 

1234 .all() 

1235 ) 

1236 for e in user_entries: 

1237 if ( 

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

1239 and e.other_aircraft_type 

1240 ): 

1241 return jsonify( 

1242 { 

1243 "result": { 

1244 "aircraft_type": e.other_aircraft_type, 

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

1246 } 

1247 } 

1248 ) 

1249 

1250 # Source 2: any user in the same tenant 

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

1252 

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

1254 tenant_entries = ( 

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

1256 .filter( 

1257 or_( 

1258 Flight.pic_user_id.in_(tenant_user_ids), 

1259 Flight.second_crew_user_id.in_(tenant_user_ids), 

1260 ) 

1261 ) 

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

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

1264 .order_by( 

1265 Flight.date.desc(), 

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

1267 Flight.id.desc(), 

1268 ) 

1269 .all() 

1270 ) 

1271 for e in tenant_entries: 

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

1273 return jsonify( 

1274 { 

1275 "result": { 

1276 "aircraft_type": e.other_aircraft_type, 

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

1278 } 

1279 } 

1280 ) 

1281 

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

1283 

1284 

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

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

1287@login_required 

1288@require_pilot_access 

1289def parse_gps_api() -> ResponseReturnValue: 

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

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

1292 if not gps_file or not gps_file.filename: 

1293 return jsonify( 

1294 { 

1295 "success": False, 

1296 "error": str( 

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

1298 ), 

1299 } 

1300 ) 

1301 gps_data = _parse_gps_upload(gps_file) 

1302 if not gps_data: 

1303 return jsonify( 

1304 { 

1305 "success": False, 

1306 "error": str( 

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

1308 ), 

1309 } 

1310 ) 

1311 return jsonify( 

1312 { 

1313 "success": True, 

1314 "message": str( 

1315 _( 

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

1317 filename=gps_data["filename"], 

1318 ) 

1319 ), 

1320 "data": { 

1321 "filename": gps_data["filename"], 

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

1323 "departure_icao": gps_data["departure_icao"], 

1324 "arrival_icao": gps_data["arrival_icao"], 

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

1326 if gps_data["departure_time"] 

1327 else "", 

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

1329 if gps_data["arrival_time"] 

1330 else "", 

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

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

1333 if gps_data["block_off_utc"] 

1334 else "", 

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

1336 if gps_data["block_on_utc"] 

1337 else "", 

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

1339 if gps_data["geojson"] 

1340 else "", 

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

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

1343 }, 

1344 "duplicate": _check_gps_duplicate(gps_data), 

1345 "suggested_aircraft_id": _suggested_aircraft_for_device( 

1346 gps_data["device_id"] 

1347 ), 

1348 } 

1349 ) 

1350 

1351 

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

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

1354 if not device_id: 

1355 return None 

1356 row = ( 

1357 db.session.query(Flight.aircraft_id) 

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

1359 .filter(GpsTrack.device_id == device_id) 

1360 .order_by( 

1361 Flight.date.desc(), 

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

1363 Flight.id.desc(), 

1364 ) 

1365 .first() 

1366 ) 

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

1368 

1369 

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

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

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

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

1374 if aircraft_id is not None: 

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

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

1377 aircraft_id = None 

1378 dup = _find_duplicate_flight( 

1379 aircraft_id=aircraft_id, 

1380 pilot_user_id=uid, 

1381 date=gps_data["date"], 

1382 dep_icao=gps_data["departure_icao"], 

1383 arr_icao=gps_data["arrival_icao"], 

1384 block_off=gps_data["block_off_utc"], 

1385 block_on=gps_data["block_on_utc"], 

1386 ) 

1387 if not dup: 

1388 return None 

1389 entry = dup["entry"] 

1390 return { 

1391 "type": dup["type"], 

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

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

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

1395 "entry_id": entry.id, 

1396 } 

1397 

1398 

1399def _handle_log_flight_post( 

1400 managed_aircraft: list[Aircraft], 

1401 uid: int, 

1402 fe: Flight | None, 

1403) -> ResponseReturnValue: 

1404 f = request.form 

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

1406 

1407 # ── GPS parse step ───────────────────────────────────────────────────────── 

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

1409 gps_data = _parse_gps_upload(gps_file) 

1410 if gps_data: 

1411 session["gps_prefill"] = { 

1412 "filename": gps_data["filename"], 

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

1414 "departure_icao": gps_data["departure_icao"], 

1415 "arrival_icao": gps_data["arrival_icao"], 

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

1417 if gps_data["departure_time"] 

1418 else "", 

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

1420 if gps_data["arrival_time"] 

1421 else "", 

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

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

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

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

1426 if gps_data["geojson"] 

1427 else "", 

1428 "landing_count": gps_data["landing_count"], 

1429 } 

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

1431 else: 

1432 flash( 

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

1434 ) 

1435 if fe: 

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

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

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

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

1440 

1441 # ── Determine aircraft ───────────────────────────────────────────────────── 

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

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

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

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

1446 if aircraft_id_raw is None and fe is not None: 

1447 aircraft_id_raw = fe.aircraft_id 

1448 ac: Aircraft | None = None 

1449 if not other_aircraft and aircraft_id_raw: 

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

1451 

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

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

1454 

1455 # ── Parse common fields ──────────────────────────────────────────────────── 

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

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

1458 pilot_role = "none" 

1459 

1460 # Pilot-log fields 

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

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

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

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

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

1466 

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

1468 if not raw: 

1469 return None 

1470 try: 

1471 v = decimal.Decimal(raw) 

1472 return v if v >= 0 else None 

1473 except decimal.InvalidOperation: 

1474 return None 

1475 

1476 night_time = _parse_dec(night_time_raw) 

1477 instrument_time = _parse_dec(instrument_time_raw) 

1478 multi_pilot = _parse_dec(multi_pilot_raw) 

1479 landings_day: int | None = ( 

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

1481 ) 

1482 landings_night: int | None = ( 

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

1484 ) 

1485 

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

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

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

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

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

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

1492 

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

1494 

1495 errors = [] 

1496 

1497 if not fe and not ac and not other_aircraft: 

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

1499 

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

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

1502 if ( 

1503 other_aircraft 

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

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

1506 ): 

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

1508 if other_aircraft and not other_ac_make_model: 

1509 errors.append( 

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

1511 ) 

1512 if other_aircraft and not other_ac_reg: 

1513 errors.append( 

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

1515 ) 

1516 # The slot this pilot picks for themselves must not already belong to the 

1517 # other pilot who confirmed this flight. 

1518 requested_slot = own_slot(pilot_role) 

1519 if fe is not None and requested_slot is not None: 

1520 occupant = ( 

1521 fe.pic_user_id if requested_slot == CrewSlot.PIC else fe.second_crew_user_id 

1522 ) 

1523 if occupant is not None and occupant != uid: 

1524 occupant_user = db.session.get(User, occupant) 

1525 errors.append( 

1526 _( 

1527 "That crew position is already confirmed by %(name)s — pick " 

1528 "your own role on this flight.", 

1529 name=occupant_user.display_name if occupant_user else "—", 

1530 ) 

1531 ) 

1532 

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

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

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

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

1537 # parse_flight_fields, which always assigns it unconditionally. 

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

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

1540 else: 

1541 landing_count_for_fe = fe.landing_count if fe else None 

1542 field_map = dict(f) 

1543 field_map["landing_count"] = ( 

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

1545 ) 

1546 values, field_errors = parse_flight_fields( 

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

1548 ) 

1549 errors.extend(field_errors) 

1550 

1551 flight_date = values["date"] 

1552 dep = values["departure_icao"] 

1553 arr = values["arrival_icao"] 

1554 departure_time = values["departure_time"] 

1555 

1556 gps_block_off: _datetime | None = None 

1557 gps_block_on: _datetime | None = None 

1558 if gps_block_off_raw: 

1559 with contextlib.suppress( 

1560 ValueError 

1561 ): # malformed hidden field — treat as absent 

1562 gps_block_off = _datetime.fromisoformat(gps_block_off_raw) 

1563 if gps_block_on_raw: 

1564 with contextlib.suppress( 

1565 ValueError 

1566 ): # malformed hidden field — treat as absent 

1567 gps_block_on = _datetime.fromisoformat(gps_block_on_raw) 

1568 

1569 gps_geojson: Any = None 

1570 if gps_geojson_raw: 

1571 with contextlib.suppress( 

1572 Exception 

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

1574 gps_geojson = _json.loads(gps_geojson_raw) 

1575 

1576 if errors: 

1577 for msg in errors: 

1578 flash(msg, "danger") 

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

1580 

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

1582 if not duplicate_action and flight_date and dep and arr: 

1583 dup = _find_duplicate_flight( 

1584 aircraft_id=ac.id if ac else None, 

1585 pilot_user_id=uid, 

1586 date=flight_date, 

1587 dep_icao=dep, 

1588 arr_icao=arr, 

1589 block_off=gps_block_off, 

1590 block_on=gps_block_on, 

1591 exclude_flight_id=fe.id if fe else None, 

1592 ) 

1593 if dup: 

1594 # Someone else already logged this flight: offer to ask them to 

1595 # add this pilot to it, instead of logging a duplicate. 

1596 dup["claim"] = ( 

1597 claim_option(dup["entry"], uid, pilot_role) if fe is None else None 

1598 ) 

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

1600 

1601 # ── Claim path: ask to be added to the already-logged flight ────────────── 

1602 if duplicate_action == "claim" and fe is None and flight_date: 

1603 dup = _find_duplicate_flight( 

1604 aircraft_id=ac.id if ac else None, 

1605 pilot_user_id=uid, 

1606 date=flight_date, 

1607 dep_icao=dep, 

1608 arr_icao=arr, 

1609 block_off=gps_block_off, 

1610 block_on=gps_block_on, 

1611 ) 

1612 option = claim_option(dup["entry"], uid, pilot_role) if dup else None 

1613 if dup is None or option is None or option["already_requested"]: 

1614 flash( 

1615 _("Could not send the request — this flight can't be claimed."), 

1616 "warning", 

1617 ) 

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

1619 claim = create_claim(dup["entry"], uid, option["slot"], f.get("crew_role_1")) 

1620 db.session.commit() 

1621 notify_claim_created(claim) 

1622 flash( 

1623 _( 

1624 "Request sent to %(names)s. The flight appears in your logbook " 

1625 "once they approve it.", 

1626 names=option["approver_names"], 

1627 ), 

1628 "success", 

1629 ) 

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

1631 

1632 # ── GPS-attach-only path ─────────────────────────────────────────────────── 

1633 if duplicate_action == "link_gps" and flight_date: 

1634 dup = _find_duplicate_flight( 

1635 aircraft_id=ac.id if ac else None, 

1636 pilot_user_id=uid, 

1637 date=flight_date, 

1638 dep_icao=dep, 

1639 arr_icao=arr, 

1640 block_off=gps_block_off, 

1641 block_on=gps_block_on, 

1642 exclude_flight_id=fe.id if fe else None, 

1643 ) 

1644 if dup and (gps_geojson or gps_filename): 

1645 link_track = GpsTrack( 

1646 source_filename=gps_filename, 

1647 device_id=gps_device_id, 

1648 block_off_utc=gps_block_off, 

1649 block_on_utc=gps_block_on, 

1650 departure_icao=dep, 

1651 arrival_icao=arr, 

1652 geojson=gps_geojson, 

1653 ) 

1654 db.session.add(link_track) 

1655 db.session.flush() 

1656 entry = dup["entry"] 

1657 entry.gps_track_id = link_track.id 

1658 db.session.commit() 

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

1660 else: 

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

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

1663 

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

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

1666 

1667 gps_track: GpsTrack | None = None 

1668 if gps_geojson or gps_filename: 

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

1670 if existing_track_id: 

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

1672 if gps_track: 

1673 if gps_geojson: 

1674 gps_track.geojson = gps_geojson 

1675 if gps_filename: 

1676 gps_track.source_filename = gps_filename 

1677 if gps_block_off: 

1678 gps_track.block_off_utc = gps_block_off 

1679 if gps_block_on: 

1680 gps_track.block_on_utc = gps_block_on 

1681 if gps_track and gps_device_id: 

1682 gps_track.device_id = gps_device_id 

1683 if not gps_track: 

1684 gps_track = GpsTrack( 

1685 source_filename=gps_filename, 

1686 device_id=gps_device_id, 

1687 block_off_utc=gps_block_off, 

1688 block_on_utc=gps_block_on, 

1689 departure_icao=dep, 

1690 arrival_icao=arr, 

1691 geojson=gps_geojson, 

1692 ) 

1693 db.session.add(gps_track) 

1694 db.session.flush() 

1695 

1696 # ── Unified flight row ───────────────────────────────────────────────────── 

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

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

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

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

1701 _fe_is_new = fe is None 

1702 shared_before: dict[str, str] | None = None 

1703 if fe is None: 

1704 fe = Flight(aircraft_id=ac.id if ac else None, created_by_user_id=uid) 

1705 db.session.add(fe) 

1706 other_pilots: dict[str, Any] = {"total": None, "slots": {}} 

1707 else: 

1708 # Editing a flight another pilot has in their logbook: never touch 

1709 # their name/role/function hours/remark, and tell them what changed. 

1710 shared_before = shared_snapshot(fe) 

1711 other_pilots = protect_other_pilots(fe, uid) 

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

1713 

1714 if ac: 

1715 fe.other_aircraft_type = None 

1716 fe.other_aircraft_type_icao = None 

1717 fe.other_aircraft_registration = None 

1718 else: 

1719 fe.other_aircraft_type = other_ac_make_model or None 

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

1721 fe.other_aircraft_registration = other_ac_reg or None 

1722 

1723 apply_flight_fields(fe, values) 

1724 

1725 if gps_track: 

1726 fe.gps_track_id = gps_track.id 

1727 if gps_block_off: 

1728 fe.block_off_utc = gps_block_off 

1729 if gps_block_on: 

1730 fe.block_on_utc = gps_block_on 

1731 

1732 if ac: 

1733 if _fe_is_new and flight_date is not None: 

1734 anchor = _datetime.combine( 

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

1736 ) 

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

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

1739 

1740 db.session.flush() 

1741 

1742 for photo_field, label, attr in [ 

1743 ("flight_counter_photo", "flight", "flight_counter_photo"), 

1744 ("engine_counter_photo", "engine", "engine_counter_photo"), 

1745 ("fuel_photo", "fuel", "fuel_photo"), 

1746 ( 

1747 "flight_counter_photo_preflight", 

1748 "flight_preflight", 

1749 "flight_counter_photo_preflight", 

1750 ), 

1751 ( 

1752 "engine_counter_photo_preflight", 

1753 "engine_preflight", 

1754 "engine_counter_photo_preflight", 

1755 ), 

1756 ]: 

1757 photo_file = request.files.get(photo_field) 

1758 if photo_file and photo_file.filename: 

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

1760 if stored: 

1761 _delete_upload(getattr(fe, attr)) 

1762 setattr(fe, attr, stored) 

1763 

1764 # ── Pilot log figures ────────────────────────────────────────────────────── 

1765 if create_pilot: 

1766 fe.night_time = night_time 

1767 fe.instrument_time = instrument_time 

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

1769 fe.landings_night = landings_night 

1770 fe.multi_pilot = multi_pilot 

1771 apply_pilot_identity(fe, ac, uid, pilot_role) 

1772 else: 

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

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

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

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

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

1778 # depend on them. 

1779 unlink_user(fe, uid) 

1780 

1781 restore_other_pilots(fe, other_pilots) 

1782 

1783 # ── Crew invites (a tenant pilot picked in a crew name field) ────────────── 

1784 tid = _tenant_id() 

1785 db.session.flush() 

1786 new_invites = sync_crew_invites(fe, uid, requested_invites(f, uid, tid, pilot_role)) 

1787 

1788 db.session.commit() 

1789 notify_invites(new_invites, tid) 

1790 if shared_before is not None: 

1791 notify_shared_changes(fe, uid, shared_before) 

1792 

1793 if ac: 

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

1795 activity( 

1796 event_name, 

1797 flight_id=fe.id, 

1798 aircraft_id=ac.id, 

1799 dep=dep, 

1800 arr=arr, 

1801 date=str(flight_date), 

1802 ) 

1803 _check_flight_hour_milestone(fe) 

1804 

1805 flash( 

1806 _( 

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

1808 dep=dep, 

1809 arr=arr, 

1810 date=flight_date, 

1811 ), 

1812 "success", 

1813 ) 

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

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

1816 if return_ac_id is not None: 

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

1818 if ( 

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

1820 and return_seg_idx is not None 

1821 ): 

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

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

1824 gps_state["confirmed_segments"] = confirmed 

1825 session["gps_import"] = gps_state 

1826 session.modified = True 

1827 return redirect( 

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

1829 ) 

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

1831 

1832 flash( 

1833 _( 

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

1835 dep=dep, 

1836 arr=arr, 

1837 date=flight_date, 

1838 ), 

1839 "success", 

1840 ) 

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

1842 

1843 

1844def _render_form( 

1845 managed_aircraft: list[Aircraft], 

1846 flight: Flight | None, 

1847 pilot_entry: None, 

1848 preselect_id: int | None, 

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

1850) -> ResponseReturnValue: 

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

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

1853 itself in the unified model.""" 

1854 nature_suggestions = _NATURE_SUGGESTIONS 

1855 aircraft: Aircraft | None = None 

1856 if preselect_id: 

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

1858 if aircraft: 

1859 nature_suggestions = _nature_suggestions(aircraft.id) 

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

1861 return render_template( 

1862 "flights/flight_form.html", 

1863 flight=flight, 

1864 pilot_entry=pilot_entry, 

1865 aircraft=aircraft, 

1866 managed_aircraft=managed_aircraft, 

1867 preselect_aircraft_id=preselect_id, 

1868 gps_prefill=None, 

1869 nature_suggestions=nature_suggestions, 

1870 pilot_name_hint=None, 

1871 crew_pilots=tenant_pilots(_tenant_id()), 

1872 crew_pending_invites=pending_invites_for_flight(flight) if flight else {}, 

1873 crew_locked_slots=_crew_locked_slots(flight, session.get("user_id")) 

1874 if flight 

1875 else {}, 

1876 crew_roles=CrewRole, 

1877 fuel_units=_FUEL_UNITS, 

1878 duplicate=duplicate, 

1879 counter_hint=counter_hint, 

1880 openaip_key=_openaip_key(), 

1881 gps_review_return_aircraft_id=None, 

1882 gps_review_return_seg_idx=None, 

1883 covering_reservation=None, 

1884 active_minimums=None, 

1885 minimums_breaches=[], 

1886 ) 

1887 

1888 

1889# ── Delete flight ───────────────────────────────────────────────────────────── 

1890 

1891 

1892@flights_bp.route( 

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

1894 methods=["POST"], 

1895) 

1896@login_required 

1897@require_pilot_access 

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

1899 ac = _get_aircraft_or_404(aircraft_id) 

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

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

1902 abort(404) 

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

1904 # Linked to another pilot's logbook: take it off this aircraft's log but 

1905 # keep it for them as an "other aircraft" flight (flights/crew_removal.py). 

1906 if fe.other_linked_user_ids(session.get("user_id")): 

1907 remove_from_aircraft_log(fe, session.get("user_id")) 

1908 db.session.commit() 

1909 activity( 

1910 "flight.detached", flight_id=flight_id, aircraft_id=aircraft_id, label=label 

1911 ) 

1912 flash( 

1913 _( 

1914 "Flight %(label)s removed from the aircraft log. It stays in the " 

1915 "pilot logbook of the crew linked to it.", 

1916 label=label, 

1917 ), 

1918 "success", 

1919 ) 

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

1921 

1922 activity( 

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

1924 ) 

1925 _delete_upload(fe.flight_counter_photo) 

1926 _delete_upload(fe.engine_counter_photo) 

1927 _delete_upload(fe.flight_counter_photo_preflight) 

1928 _delete_upload(fe.engine_counter_photo_preflight) 

1929 db.session.delete(fe) 

1930 db.session.commit() 

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

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

1933 

1934 

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

1936 

1937_AIRFRAME_IMPORT_SESSION_KEY = "airframe_import" 

1938_AIRFRAME_IMPORT_REVIEW_SESSION_KEY = "airframe_import_review" 

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

1940_AIRFRAME_IMPORT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB 

1941 

1942 

1943def _airframe_tmp_dir() -> str: 

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

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

1946 os.makedirs(d, exist_ok=True) 

1947 return d 

1948 

1949 

1950def _airframe_cleanup_tmp() -> None: 

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

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

1953 finishing it).""" 

1954 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY) 

1955 if meta: 

1956 tmp = meta.get("tmp_path") 

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

1958 with contextlib.suppress(OSError): 

1959 os.remove(tmp) 

1960 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None) 

1961 

1962 review_state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY) 

1963 if review_state: 

1964 tmp = review_state.get("tmp_path") 

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

1966 with contextlib.suppress(OSError): 

1967 os.remove(tmp) 

1968 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

1969 

1970 

1971def _render_airframe_map( 

1972 ac: Aircraft, 

1973 parsed: Any, 

1974 mapping: dict[str, str], 

1975 match_type: str, 

1976 filename: str, 

1977 is_historical: bool = False, 

1978) -> str: 

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

1980 _norm, 

1981 preview_rows, 

1982 ) 

1983 

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

1985 AIRFRAME_TARGET_FIELDS, 

1986 airframe_type_hints, 

1987 ) 

1988 

1989 return render_template( 

1990 "flights/airframe_import_map.html", 

1991 aircraft=ac, 

1992 norm_cols=parsed.norm_cols, 

1993 raw_cols=parsed.raw_cols, 

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

1995 mapping=mapping, 

1996 match_type=match_type, 

1997 target_fields=AIRFRAME_TARGET_FIELDS, 

1998 is_historical=is_historical, 

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

2000 filename=filename, 

2001 type_hints=airframe_type_hints(parsed, mapping), 

2002 ) 

2003 

2004 

2005@flights_bp.route( 

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

2007) 

2008@login_required 

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

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

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

2012 AirframeImportBatch, 

2013 AirframeImportMapping, 

2014 ) 

2015 from pilots.logbook_import import ( 

2016 parse_file, # pyright: ignore[reportMissingImports] 

2017 ) 

2018 

2019 from flights.airframe_import import ( 

2020 propose_airframe_mapping, # pyright: ignore[reportMissingImports] 

2021 ) 

2022 

2023 ac = _get_aircraft_or_404(aircraft_id) 

2024 batches = ( 

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

2026 .order_by(AirframeImportBatch.imported_at.desc()) 

2027 .all() 

2028 ) 

2029 

2030 if request.method == "GET": 

2031 return render_template( 

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

2033 ) 

2034 

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

2036 if not uploaded or not uploaded.filename: 

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

2038 return render_template( 

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

2040 ), 422 

2041 

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

2043 if ext not in _AIRFRAME_IMPORT_EXTS: 

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

2045 return render_template( 

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

2047 ), 422 

2048 

2049 data = uploaded.read() 

2050 if len(data) > _AIRFRAME_IMPORT_MAX_BYTES: 

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

2052 return render_template( 

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

2054 ), 422 

2055 

2056 try: 

2057 parsed = parse_file(data, uploaded.filename) 

2058 except ValueError as exc: 

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

2060 return render_template( 

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

2062 ), 422 

2063 

2064 _airframe_cleanup_tmp() 

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

2066 tmp_path = os.path.join( 

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

2068 ) 

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

2070 fh.write(data) 

2071 

2072 session[_AIRFRAME_IMPORT_SESSION_KEY] = { 

2073 "aircraft_id": ac.id, 

2074 "tmp_path": tmp_path, 

2075 "original_filename": uploaded.filename, 

2076 "norm_cols": parsed.norm_cols, 

2077 "fingerprint": parsed.fingerprint, 

2078 } 

2079 

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

2081 mapping, match_type = propose_airframe_mapping(parsed, saved) 

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

2083 

2084 

2085@flights_bp.route( 

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

2087) 

2088@login_required 

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

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

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

2092 AirframeImportBatch, 

2093 AirframeImportMapping, 

2094 ) 

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

2096 parse_duration_value, 

2097 parse_file, 

2098 ) 

2099 

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

2101 AIRFRAME_TARGET_FIELDS, 

2102 execute_airframe_import, 

2103 find_conflicting_airframe_rows, 

2104 merge_airframe_into_existing, 

2105 split_airframe_auto_merges, 

2106 ) 

2107 

2108 ac = _get_aircraft_or_404(aircraft_id) 

2109 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY) 

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

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

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

2113 

2114 tmp_path: str = meta["tmp_path"] 

2115 original_filename: str = meta["original_filename"] 

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

2117 fingerprint: str = meta["fingerprint"] 

2118 

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

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

2121 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None) 

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

2123 

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

2125 for col in norm_cols: 

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

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

2128 

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

2130 

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

2132 data = fh.read() 

2133 try: 

2134 parsed = parse_file(data, original_filename) 

2135 except ValueError as exc: 

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

2137 _airframe_cleanup_tmp() 

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

2139 

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

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

2142 return _render_airframe_map( 

2143 ac, 

2144 parsed, 

2145 mapping, 

2146 "alias", 

2147 original_filename, 

2148 is_historical, 

2149 ), 422 

2150 

2151 opening_counters = { 

2152 "flight": parse_duration_value( 

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

2154 ) 

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

2156 else None, 

2157 "engine": parse_duration_value( 

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

2159 ) 

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

2161 else None, 

2162 } 

2163 

2164 mapping_record = None 

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

2166 if m.source_fingerprint == fingerprint: 

2167 m.column_mapping = _json.dumps(mapping) 

2168 mapping_record = m 

2169 break 

2170 if mapping_record is None: 

2171 mapping_record = AirframeImportMapping( 

2172 tenant_id=ac.tenant_id, 

2173 source_fingerprint=fingerprint, 

2174 column_mapping=_json.dumps(mapping), 

2175 source_columns=_json.dumps(norm_cols), 

2176 created_at=_datetime.now(UTC), 

2177 ) 

2178 db.session.add(mapping_record) 

2179 db.session.flush() 

2180 

2181 batch = AirframeImportBatch( 

2182 aircraft_id=ac.id, 

2183 mapping_id=mapping_record.id, 

2184 source_filename=original_filename, 

2185 imported_at=_datetime.now(UTC), 

2186 is_historical=is_historical, 

2187 ) 

2188 db.session.add(batch) 

2189 db.session.flush() 

2190 

2191 resolved_opening_counters = ( 

2192 opening_counters 

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

2194 else None 

2195 ) 

2196 

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

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

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

2200 # interactive review step below instead of silently importing or 

2201 # skipping them. 

2202 all_conflicts = find_conflicting_airframe_rows(parsed, mapping, ac.id) 

2203 # A row whose only match is the same flight logged from the pilot side 

2204 # (e.g. a personal-logbook import) just completes it — merged straight 

2205 # away, no decision needed. 

2206 auto_merges, conflicts = split_airframe_auto_merges(all_conflicts, ac) 

2207 for auto_conflict, existing_id in auto_merges: 

2208 merge_airframe_into_existing( 

2209 db.session.get(Flight, existing_id), 

2210 auto_conflict.fields, 

2211 auto_conflict.crew_name, 

2212 fill_only=True, 

2213 ) 

2214 

2215 result = execute_airframe_import( 

2216 parsed=parsed, 

2217 mapping=mapping, 

2218 aircraft=ac, 

2219 batch_id=batch.id, 

2220 opening_counters=resolved_opening_counters, 

2221 skip_row_nums={c.row_num for c in all_conflicts}, 

2222 ) 

2223 batch.row_count = result.imported 

2224 batch.subtotal_count = result.subtotals 

2225 batch.skipped_count = len(result.skipped) 

2226 batch.warning_count = len(result.continuity_warnings) 

2227 batch.has_opening_counters = result.has_opening_counters 

2228 db.session.commit() 

2229 

2230 if auto_merges: 

2231 flash( 

2232 ngettext( 

2233 "One row matched a flight already in a pilot logbook and was " 

2234 "merged into it.", 

2235 "%(n)d rows matched a flight already in a pilot logbook and " 

2236 "were merged into it.", 

2237 len(auto_merges), 

2238 n=len(auto_merges), 

2239 ), 

2240 "info", 

2241 ) 

2242 

2243 if conflicts: 

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

2245 # is resolved — _finalize_airframe_import_review does both, covering 

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

2247 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = { 

2248 "aircraft_id": ac.id, 

2249 "tmp_path": tmp_path, 

2250 "original_filename": original_filename, 

2251 "mapping": mapping, 

2252 "batch_id": batch.id, 

2253 "resolved": {}, 

2254 "auto_merged": [c.row_num for c, _eid in auto_merges], 

2255 } 

2256 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None) 

2257 session.modified = True 

2258 

2259 flash( 

2260 _( 

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

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

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

2264 imported=result.imported, 

2265 n=len(conflicts), 

2266 ), 

2267 "info", 

2268 ) 

2269 if result.duplicates: 

2270 detail = "; ".join( 

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

2272 ) 

2273 if len(result.duplicates) > 5: 

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

2275 flash( 

2276 _( 

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

2278 detail=detail, 

2279 ), 

2280 "info", 

2281 ) 

2282 if result.skipped: 

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

2284 if len(result.skipped) > 5: 

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

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

2287 

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

2289 

2290 activity( 

2291 "flights.airframe_import", 

2292 aircraft_id=ac.id, 

2293 batch_id=batch.id, 

2294 imported=result.imported, 

2295 ) 

2296 _airframe_cleanup_tmp() 

2297 

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

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

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

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

2302 flash( 

2303 _( 

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

2305 "log — nothing new was imported." 

2306 ), 

2307 "success", 

2308 ) 

2309 else: 

2310 n_dup = len(result.duplicates) 

2311 if result.duplicates: 

2312 parts = [ 

2313 ngettext( 

2314 "Import complete: one new flight imported.", 

2315 "Import complete: %(n)d new flights imported.", 

2316 result.imported, 

2317 n=result.imported, 

2318 ), 

2319 ngettext( 

2320 "One row was already in this aircraft's log and was skipped.", 

2321 "%(n)d rows were already in this aircraft's log and were skipped.", 

2322 n_dup, 

2323 n=n_dup, 

2324 ), 

2325 ] 

2326 elif result.imported: 

2327 parts = [ 

2328 ngettext( 

2329 "Import complete: one flight imported.", 

2330 "Import complete: %(n)d flights imported.", 

2331 result.imported, 

2332 n=result.imported, 

2333 ) 

2334 ] 

2335 else: 

2336 parts = [_("Import complete: no flights imported.")] 

2337 flash(" ".join(parts + _skipped_rows_summary(result)), "success") 

2338 if result.duplicates: 

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

2340 if len(result.duplicates) > 5: 

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

2342 flash( 

2343 _( 

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

2345 detail=detail, 

2346 ), 

2347 "info", 

2348 ) 

2349 if result.continuity_warnings: 

2350 detail = "; ".join( 

2351 _( 

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

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

2354 row=row, 

2355 kind=kind, 

2356 got=got, 

2357 prev=prev, 

2358 ) 

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

2360 ) 

2361 if len(result.continuity_warnings) > 5: 

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

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

2364 if result.skipped: 

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

2366 if len(result.skipped) > 5: 

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

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

2369 

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

2371 

2372 

2373def _airframe_review_excluded_rows(state: dict[str, Any]) -> set[int]: 

2374 """Rows the review step no longer offers: already resolved, or merged 

2375 automatically when the import ran.""" 

2376 return {int(k) for k in state.get("resolved", {})} | set( 

2377 state.get("auto_merged", []) 

2378 ) 

2379 

2380 

2381def _finalize_airframe_import_review( 

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

2383) -> ResponseReturnValue: 

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

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

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

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

2388 

2389 batch_id: int = state["batch_id"] 

2390 tmp_path: str = state["tmp_path"] 

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

2392 

2393 activity( 

2394 "flights.airframe_import", 

2395 aircraft_id=ac.id, 

2396 batch_id=batch_id, 

2397 imported=batch.row_count if batch else 0, 

2398 ) 

2399 

2400 with contextlib.suppress(OSError): 

2401 os.remove(tmp_path) 

2402 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

2403 

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

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

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

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

2408 

2409 flash( 

2410 _( 

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

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

2413 overwritten=overwritten, 

2414 new=added_new, 

2415 kept=kept, 

2416 ), 

2417 "success", 

2418 ) 

2419 

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

2421 

2422 

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

2424@login_required 

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

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

2427 from pilots.logbook_import import ( 

2428 parse_file, # pyright: ignore[reportMissingImports] 

2429 ) 

2430 

2431 from flights.airframe_import import ( 

2432 find_conflicting_airframe_rows, # pyright: ignore[reportMissingImports] 

2433 ) 

2434 

2435 ac = _get_aircraft_or_404(aircraft_id) 

2436 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY) 

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

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

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

2440 

2441 tmp_path: str = state["tmp_path"] 

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

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

2444 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

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

2446 

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

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

2449 

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

2451 data = fh.read() 

2452 try: 

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

2454 except ValueError: 

2455 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

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

2457 

2458 exclude_row_nums = _airframe_review_excluded_rows(state) 

2459 conflicts = find_conflicting_airframe_rows( 

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

2461 ) 

2462 

2463 if not conflicts: 

2464 return _finalize_airframe_import_review(ac, state) 

2465 

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

2467 candidate_entries: dict[int, Flight] = ( 

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

2469 if candidate_ids 

2470 else {} 

2471 ) 

2472 

2473 rows = [ 

2474 { 

2475 "row_num": c.row_num, 

2476 "fields": c.fields, 

2477 "crew_name": c.crew_name, 

2478 "candidates": [ 

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

2480 for score, cid in c.candidates 

2481 ], 

2482 } 

2483 for c in conflicts 

2484 ] 

2485 

2486 return render_template( 

2487 "flights/airframe_import_review.html", 

2488 aircraft=ac, 

2489 rows=rows, 

2490 resolved_count=len(resolved), 

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

2492 ) 

2493 

2494 

2495@flights_bp.route( 

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

2497 methods=["POST"], 

2498) 

2499@login_required 

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

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

2502 from pilots.logbook_import import ( 

2503 parse_file, # pyright: ignore[reportMissingImports] 

2504 ) 

2505 

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

2507 AirframeConflictRow, 

2508 _fields_to_flight_entry_kwargs, 

2509 find_conflicting_airframe_rows, 

2510 merge_airframe_into_existing, 

2511 ) 

2512 

2513 ac = _get_aircraft_or_404(aircraft_id) 

2514 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY) 

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

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

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

2518 

2519 tmp_path: str = state["tmp_path"] 

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

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

2522 

2523 try: 

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

2525 except (ValueError, TypeError): 

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

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

2528 

2529 if str(row_num) in resolved: 

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

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

2532 

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

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

2535 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

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

2537 

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

2539 data = fh.read() 

2540 try: 

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

2542 except ValueError: 

2543 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None) 

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

2545 

2546 exclude_row_nums = _airframe_review_excluded_rows(state) 

2547 conflicts = find_conflicting_airframe_rows( 

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

2549 ) 

2550 conflict: AirframeConflictRow | None = next( 

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

2552 ) 

2553 if conflict is None: 

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

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

2556 

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

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

2559 

2560 if decision == "keep": 

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

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

2563 try: 

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

2565 except ValueError: 

2566 existing_id = -1 

2567 if existing_id not in candidate_ids: 

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

2569 return redirect( 

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

2571 ) 

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

2573 if existing is None: 

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

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

2576 abort(404) # pragma: no cover 

2577 # Replace with the row's values — see merge_airframe_into_existing 

2578 # for what's deliberately left alone. id and 

2579 # airframe_import_batch_id are never touched, so this entry stays 

2580 # outside the *current* batch's rollback (it wasn't created by it). 

2581 merge_airframe_into_existing( 

2582 existing, conflict.fields, conflict.crew_name, fill_only=False 

2583 ) 

2584 db.session.commit() 

2585 elif decision == "new": 

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

2587 

2588 batch_id: int = state["batch_id"] 

2589 fe = Flight( 

2590 aircraft_id=ac.id, 

2591 airframe_import_batch_id=batch_id, 

2592 source="import", 

2593 pic_name=conflict.crew_name, 

2594 **_fields_to_flight_entry_kwargs(conflict.fields), 

2595 ) 

2596 db.session.add(fe) 

2597 db.session.flush() 

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

2599 if batch is not None: 

2600 batch.row_count += 1 

2601 else: 

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

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

2604 batch_id, 

2605 ) 

2606 db.session.commit() 

2607 else: 

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

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

2610 

2611 resolved[str(row_num)] = decision 

2612 state["resolved"] = resolved 

2613 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = state 

2614 session.modified = True 

2615 

2616 remaining = find_conflicting_airframe_rows( 

2617 parsed, mapping, ac.id, exclude_row_nums=_airframe_review_excluded_rows(state) 

2618 ) 

2619 if not remaining: 

2620 return _finalize_airframe_import_review(ac, state) 

2621 

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

2623 

2624 

2625@flights_bp.route( 

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

2627 methods=["POST"], 

2628) 

2629@login_required 

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

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

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

2633 

2634 ac = _get_aircraft_or_404(aircraft_id) 

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

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

2637 abort(404) 

2638 

2639 actor_id = session.get("user_id") 

2640 entries = Flight.query.filter_by(airframe_import_batch_id=batch.id).all() 

2641 # Rows a pilot has since confirmed in their logbook are detached instead 

2642 # of deleted, so undoing the import never removes pilot hours. 

2643 kept = sum(not remove_from_aircraft_log(fe, actor_id) for fe in entries) 

2644 db.session.delete(batch) 

2645 db.session.commit() 

2646 

2647 flash( 

2648 _( 

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

2650 n=len(entries) - kept, 

2651 ), 

2652 "success", 

2653 ) 

2654 flash_kept_in_pilot_logbooks(kept) 

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

2656 

2657 

2658@flights_bp.route( 

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

2660 methods=["POST"], 

2661) 

2662@login_required 

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

2664def airframe_import_toggle_historical( 

2665 aircraft_id: int, batch_id: int 

2666) -> ResponseReturnValue: 

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

2668 

2669 ac = _get_aircraft_or_404(aircraft_id) 

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

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

2672 abort(404) 

2673 

2674 batch.is_historical = not batch.is_historical 

2675 db.session.commit() 

2676 

2677 if batch.is_historical: 

2678 flash( 

2679 _( 

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

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

2682 ), 

2683 "success", 

2684 ) 

2685 else: 

2686 flash( 

2687 _( 

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

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

2690 ), 

2691 "success", 

2692 ) 

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