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

676 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-28 23:33 +0000

1import contextlib 

2import decimal 

3import json as _json 

4import os 

5import uuid 

6from datetime import date as _date, time as _time, datetime as _datetime 

7 

8from typing import Any 

9 

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

11 Blueprint, 

12 abort, 

13 current_app, 

14 flash, 

15 jsonify, 

16 redirect, 

17 render_template, 

18 request, 

19 send_from_directory, 

20 session, 

21 url_for, 

22) 

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

24from werkzeug.utils import secure_filename 

25 

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

27 

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

29 

30from extensions import _rate_limiting_disabled, limiter as _limiter # pyright: ignore[reportMissingImports] 

31 

32from models import ( 

33 Aircraft, 

34 AppSetting, 

35 Component, 

36 CrewRole, 

37 Document, 

38 FlightCrew, 

39 FlightEntry, 

40 GpsTrack, 

41 PilotLogbookEntry, 

42 TenantUser, 

43 User, 

44 db, 

45) # pyright: ignore[reportMissingImports] 

46from utils import ( 

47 accessible_aircraft, 

48 activity, 

49 login_required, 

50 require_pilot_access, 

51 user_can_access_aircraft, 

52) # pyright: ignore[reportMissingImports] 

53 

54flights_bp = Blueprint("flights", __name__) 

55 

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

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

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

59_NATURE_SUGGESTIONS = [ 

60 "Local flight", 

61 "Navigation", 

62 "Cross-country", 

63 "Training", 

64 "IFR practice", 

65 "Night flight", 

66 "Touch-and-go", 

67 "Ferry flight", 

68 "Air test", 

69 "Sightseeing", 

70] 

71 

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

73 

74 

75def _openaip_key() -> str | None: 

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

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

78 

79 

80def _tenant_id() -> int: 

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

82 if not tu: 

83 abort(403) 

84 return int(tu.tenant_id) 

85 

86 

87def _check_flight_hour_milestone(fe: FlightEntry) -> None: 

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

89 this_flight = float(fe.flight_time or 0) 

90 if this_flight <= 0: 

91 return 

92 tid = _tenant_id() 

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

94 new_total = float( 

95 db.session.query(func.sum(FlightEntry.flight_time)) 

96 .filter(FlightEntry.aircraft_id.in_(aircraft_ids)) 

97 .scalar() 

98 or 0 

99 ) 

100 old_total = new_total - this_flight 

101 for milestone in _HOUR_MILESTONES: 

102 if old_total < milestone <= new_total: 

103 session["milestone_hours"] = milestone 

104 flash( 

105 _( 

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

107 hours=milestone, 

108 ), 

109 "info", 

110 ) 

111 break 

112 

113 

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

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

116 if ( 

117 not ac 

118 or ac.tenant_id != _tenant_id() 

119 or not user_can_access_aircraft(aircraft_id) 

120 ): 

121 abort(404) 

122 return ac 

123 

124 

125def _get_flight_or_404(flight_id: int) -> FlightEntry: 

126 fe = db.session.get(FlightEntry, flight_id) 

127 if not fe: 

128 abort(404) 

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

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

131 abort(404) 

132 return fe 

133 

134 

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

136 ext = os.path.splitext(secure_filename(file.filename))[1].lower() 

137 if ext not in _ALLOWED_PHOTO_EXTS: 

138 return None 

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

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

141 os.makedirs(folder, exist_ok=True) 

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

143 return stored 

144 

145 

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

147 if not filename: 

148 return 

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

150 try: 

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

152 except OSError: 

153 current_app.logger.debug( 

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

155 ) 

156 

157 

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

159 used = [ 

160 row[0] 

161 for row in db.session.query(FlightEntry.nature_of_flight) 

162 .filter_by(aircraft_id=aircraft_id) 

163 .filter(FlightEntry.nature_of_flight.isnot(None)) 

164 .distinct() 

165 .all() 

166 ] 

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

168 

169 

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

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

172 try: 

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

174 detect_segments, 

175 merge_and_sort, 

176 parse_gps_file, 

177 ) 

178 except ImportError: 

179 return None 

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

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

182 if ext not in _ALLOWED_GPS_EXTS: 

183 return None 

184 data = file.read() 

185 try: 

186 parsed = parse_gps_file(data, filename) 

187 all_points = merge_and_sort([parsed]) 

188 segments = detect_segments(all_points) 

189 except Exception: 

190 return None 

191 if not segments: 

192 return None 

193 seg = segments[0] 

194 return { 

195 "filename": filename, 

196 "device_id": parsed.device_id, 

197 "block_off_utc": seg.block_off_utc, 

198 "block_on_utc": seg.block_on_utc, 

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

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

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

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

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

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

205 "geojson": seg.track_geojson, 

206 "landing_count": seg.landing_count, 

207 } 

208 

209 

210def _find_duplicate_flight( 

211 aircraft_id: int | None, 

212 pilot_user_id: int, 

213 date: _date, 

214 dep_icao: str, 

215 arr_icao: str, 

216 block_off: _datetime | None, 

217 block_on: _datetime | None, 

218 exclude_flight_id: int | None = None, 

219 exclude_pilot_entry_id: int | None = None, 

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

221 """Return info about a matching FlightEntry or PilotLogbookEntry, or None.""" 

222 if aircraft_id and block_off and block_on: 

223 q = FlightEntry.query.filter( 

224 FlightEntry.aircraft_id == aircraft_id, 

225 FlightEntry.block_off_utc.isnot(None), 

226 FlightEntry.block_on_utc.isnot(None), 

227 FlightEntry.block_off_utc < block_on, 

228 FlightEntry.block_on_utc > block_off, 

229 ) 

230 if exclude_flight_id: 

231 q = q.filter(FlightEntry.id != exclude_flight_id) 

232 existing = q.first() 

233 if existing: 

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

235 

236 if aircraft_id and not block_off: 

237 q2 = FlightEntry.query.filter_by( 

238 aircraft_id=aircraft_id, 

239 date=date, 

240 departure_icao=dep_icao, 

241 arrival_icao=arr_icao, 

242 ) 

243 if exclude_flight_id: 

244 q2 = q2.filter(FlightEntry.id != exclude_flight_id) 

245 existing2 = q2.first() 

246 if existing2: 

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

248 

249 q3 = PilotLogbookEntry.query.filter_by( 

250 pilot_user_id=pilot_user_id, 

251 date=date, 

252 departure_place=dep_icao, 

253 arrival_place=arr_icao, 

254 ) 

255 if exclude_pilot_entry_id: 

256 q3 = q3.filter(PilotLogbookEntry.id != exclude_pilot_entry_id) 

257 existing3 = q3.first() 

258 if existing3: 

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

260 

261 return None 

262 

263 

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

265 last = ( 

266 FlightEntry.query.filter_by(aircraft_id=aircraft_id) 

267 .filter( 

268 db.or_( 

269 FlightEntry.flight_time_counter_end.isnot(None), 

270 FlightEntry.engine_time_counter_end.isnot(None), 

271 ) 

272 ) 

273 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc()) 

274 .first() 

275 ) 

276 if not last: 

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

278 return { 

279 "flight": float(last.flight_time_counter_end) 

280 if last.flight_time_counter_end is not None 

281 else None, 

282 "engine": float(last.engine_time_counter_end) 

283 if last.engine_time_counter_end is not None 

284 else None, 

285 } 

286 

287 

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

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

290 

291 

292# ── Serve uploads ───────────────────────────────────────────────────────────── 

293 

294 

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

296@login_required 

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

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

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

300 if doc is not None: 

301 if doc.aircraft_id is not None: 

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

303 _get_aircraft_or_404( 

304 doc.aircraft_id 

305 ) # aborts 404 if wrong tenant/no access 

306 elif doc.flight_entry_id is not None: 

307 _get_flight_or_404(doc.flight_entry_id) 

308 elif doc.pilot_user_id is not None: 

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

310 abort(404) 

311 else: 

312 abort(404) 

313 else: 

314 # Counter and fuel photos are stored directly on FlightEntry (not via Document). 

315 fe = FlightEntry.query.filter( 

316 or_( 

317 FlightEntry.flight_counter_photo == filename, 

318 FlightEntry.engine_counter_photo == filename, 

319 FlightEntry.fuel_photo == filename, 

320 ) 

321 ).first() 

322 if fe is None: 

323 abort(404) 

324 _get_flight_or_404(fe.id) 

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

326 return send_from_directory(folder, filename) 

327 

328 

329# ── Fleet logbook ───────────────────────────────────────────────────────────── 

330 

331 

332@flights_bp.route("/flights") 

333@login_required 

334def fleet_flights() -> ResponseReturnValue: 

335 tid = _tenant_id() 

336 aircraft_list = accessible_aircraft(tid).all() 

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

338 flights = ( 

339 FlightEntry.query.filter( 

340 FlightEntry.aircraft_id.in_([ac.id for ac in aircraft_list]) 

341 ) 

342 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc()) 

343 .all() 

344 ) 

345 return render_template( 

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

347 ) 

348 

349 

350# ── Airframe logbook ────────────────────────────────────────────────────────── 

351 

352 

353@flights_bp.route("/aircraft/<int:aircraft_id>/flights") 

354@login_required 

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

356 ac = _get_aircraft_or_404(aircraft_id) 

357 flights = ( 

358 FlightEntry.query.filter_by(aircraft_id=ac.id) 

359 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc()) 

360 .all() 

361 ) 

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

363 return render_template( 

364 "flights/list.html", 

365 aircraft=ac, 

366 flights=flights, 

367 milestone_hours=milestone_hours, 

368 ) 

369 

370 

371# ── Component logbook ───────────────────────────────────────────────────────── 

372 

373 

374@flights_bp.route("/aircraft/<int:aircraft_id>/components/<int:component_id>/logbook") 

375@login_required 

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

377 ac = _get_aircraft_or_404(aircraft_id) 

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

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

380 abort(404) 

381 

382 query = FlightEntry.query.filter_by(aircraft_id=ac.id) 

383 if comp.installed_at: 

384 query = query.filter(FlightEntry.date >= comp.installed_at) 

385 if comp.removed_at: 

386 query = query.filter(FlightEntry.date <= comp.removed_at) 

387 

388 flights_asc = query.order_by(FlightEntry.date.asc(), FlightEntry.id.asc()).all() 

389 

390 base = float(comp.time_at_install or 0) 

391 cumulative = base 

392 flights_with_hours = [] 

393 for f in flights_asc: 

394 if ( 

395 f.flight_time_counter_end is not None 

396 and f.flight_time_counter_start is not None 

397 ): 

398 cumulative += float(f.flight_time_counter_end) - float( 

399 f.flight_time_counter_start 

400 ) 

401 flights_with_hours.append((f, cumulative)) 

402 

403 flights_with_hours.reverse() 

404 

405 tbo_hours = (comp.extras or {}).get("tbo_hours") 

406 tbo_remaining = (tbo_hours - cumulative) if tbo_hours else None 

407 

408 return render_template( 

409 "flights/logbook_component.html", 

410 aircraft=ac, 

411 component=comp, 

412 flights_with_hours=flights_with_hours, 

413 total_component_hours=cumulative, 

414 tbo_hours=tbo_hours, 

415 tbo_remaining=tbo_remaining, 

416 ) 

417 

418 

419# ── Unified log / edit flight ───────────────────────────────────────────────── 

420 

421 

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

423@login_required 

424@require_pilot_access 

425def log_flight() -> ResponseReturnValue: 

426 tid = _tenant_id() 

427 managed_aircraft = accessible_aircraft(tid).all() 

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

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

430 

431 if request.method == "POST": 

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

433 

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

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

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

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

438 pilot_name_hint = _u.display_name if _u else "" 

439 nature_suggestions = _NATURE_SUGGESTIONS 

440 aircraft: Aircraft | None = None 

441 if preselect_id: 

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

443 if aircraft: 

444 nature_suggestions = _nature_suggestions(aircraft.id) 

445 counter_hint = _get_counter_hint(preselect_id) if preselect_id else None 

446 

447 return render_template( 

448 "flights/flight_form.html", 

449 flight=None, 

450 pilot_entry=None, 

451 aircraft=aircraft, 

452 managed_aircraft=managed_aircraft, 

453 preselect_aircraft_id=preselect_id, 

454 gps_prefill=gps_prefill, 

455 nature_suggestions=nature_suggestions, 

456 pilot_name_hint=pilot_name_hint, 

457 crew_roles=CrewRole, 

458 fuel_units=_FUEL_UNITS, 

459 duplicate=None, 

460 counter_hint=counter_hint, 

461 openaip_key=_openaip_key(), 

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

463 gps_review_return_aircraft_id=gps_review_return_aircraft_id, 

464 gps_review_return_seg_idx=gps_review_return_seg_idx, 

465 ) 

466 

467 

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

469@login_required 

470@require_pilot_access 

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

472 tid = _tenant_id() 

473 managed_aircraft = accessible_aircraft(tid).all() 

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

475 fe = _get_flight_or_404(flight_id) 

476 

477 if request.method == "POST": 

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

479 

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

481 pilot_entry = PilotLogbookEntry.query.filter_by( 

482 flight_id=fe.id, pilot_user_id=uid 

483 ).first() 

484 aircraft = db.session.get(Aircraft, fe.aircraft_id) 

485 counter_hint = _get_counter_hint(fe.aircraft_id) 

486 

487 return render_template( 

488 "flights/flight_form.html", 

489 flight=fe, 

490 pilot_entry=pilot_entry, 

491 aircraft=aircraft, 

492 managed_aircraft=managed_aircraft, 

493 preselect_aircraft_id=fe.aircraft_id, 

494 gps_prefill=gps_prefill, 

495 nature_suggestions=_nature_suggestions(fe.aircraft_id), 

496 pilot_name_hint=None, 

497 crew_roles=CrewRole, 

498 fuel_units=_FUEL_UNITS, 

499 duplicate=None, 

500 counter_hint=counter_hint, 

501 openaip_key=_openaip_key(), 

502 gps_review_return_aircraft_id=None, 

503 gps_review_return_seg_idx=None, 

504 ) 

505 

506 

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

508@login_required 

509@require_pilot_access 

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

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

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

513 from utils import generate_single_track_image # pyright: ignore[reportMissingImports] 

514 

515 fe = _get_flight_or_404(flight_id) 

516 if not fe.gps_track or not fe.gps_track.geojson: 

517 abort(404) 

518 

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

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

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

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

523 mul = 2 if hires else 1 

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

525 

526 png_bytes = generate_single_track_image( 

527 fe.gps_track.geojson, 

528 date=str(fe.date), 

529 dep=fe.departure_icao or "", 

530 arr=fe.arrival_icao or "", 

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

532 canvas_w=canvas_w, 

533 canvas_h=canvas_h, 

534 high_res=hires, 

535 ) 

536 orient_sfx = "-portrait" if portrait else "" 

537 qual_sfx = "-hires" if hires else "" 

538 suffix = orient_sfx + qual_sfx 

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

540 return Response( 

541 png_bytes, 

542 mimetype="image/png", 

543 headers={ 

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

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

546 "ETag": f'"{fe.gps_track.id}"', 

547 }, 

548 ) 

549 

550 

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

552@login_required 

553@require_pilot_access 

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

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

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

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

558 

559 fe = _get_flight_or_404(flight_id) 

560 if not fe.gps_track or not fe.gps_track.geojson: 

561 abort(404) 

562 

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

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

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

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

567 mul = 2 if hires else 1 

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

569 

570 gif_bytes = generate_single_track_gif( 

571 fe.gps_track.geojson, 

572 date=str(fe.date), 

573 dep=fe.departure_icao or "", 

574 arr=fe.arrival_icao or "", 

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

576 canvas_w=canvas_w, 

577 canvas_h=canvas_h, 

578 high_res=hires, 

579 ) 

580 orient_sfx = "-portrait" if portrait else "" 

581 qual_sfx = "-hires" if hires else "" 

582 suffix = orient_sfx + qual_sfx 

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

584 return Response( 

585 gif_bytes, 

586 mimetype="image/gif", 

587 headers={ 

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

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

590 "ETag": f'"{fe.gps_track.id}"', 

591 }, 

592 ) 

593 

594 

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

596@login_required 

597@require_pilot_access 

598def registration_lookup() -> ResponseReturnValue: 

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

600 

601 Sources (in priority order): 

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

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

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

605 

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

607 """ 

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

609 if not q: 

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

611 

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

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

614 

615 q_norm = _norm(q) 

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

617 tid = _tenant_id() 

618 

619 # Source 1: current user's own history 

620 user_entries = ( 

621 PilotLogbookEntry.query.filter_by(pilot_user_id=uid) 

622 .filter(PilotLogbookEntry.aircraft_registration.isnot(None)) 

623 .order_by(PilotLogbookEntry.date.desc(), PilotLogbookEntry.id.desc()) 

624 .all() 

625 ) 

626 for e in user_entries: 

627 if _norm(e.aircraft_registration or "") == q_norm and e.aircraft_type: 

628 return jsonify( 

629 { 

630 "result": { 

631 "aircraft_type": e.aircraft_type, 

632 "aircraft_type_icao": e.aircraft_type_icao or "", 

633 } 

634 } 

635 ) 

636 

637 # Source 2: any user in the same tenant 

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

639 

640 tenant_entries = ( 

641 PilotLogbookEntry.query.join( 

642 _TU, _TU.user_id == PilotLogbookEntry.pilot_user_id 

643 ) 

644 .filter(_TU.tenant_id == tid) 

645 .filter(PilotLogbookEntry.aircraft_registration.isnot(None)) 

646 .filter(PilotLogbookEntry.aircraft_type.isnot(None)) 

647 .order_by(PilotLogbookEntry.date.desc(), PilotLogbookEntry.id.desc()) 

648 .all() 

649 ) 

650 for e in tenant_entries: 

651 if _norm(e.aircraft_registration or "") == q_norm: 

652 return jsonify( 

653 { 

654 "result": { 

655 "aircraft_type": e.aircraft_type, 

656 "aircraft_type_icao": e.aircraft_type_icao or "", 

657 } 

658 } 

659 ) 

660 

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

662 

663 

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

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

666@login_required 

667@require_pilot_access 

668def parse_gps_api() -> ResponseReturnValue: 

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

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

671 if not gps_file or not gps_file.filename: 

672 return jsonify( 

673 { 

674 "success": False, 

675 "error": str( 

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

677 ), 

678 } 

679 ) 

680 gps_data = _parse_gps_upload(gps_file) 

681 if not gps_data: 

682 return jsonify( 

683 { 

684 "success": False, 

685 "error": str( 

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

687 ), 

688 } 

689 ) 

690 return jsonify( 

691 { 

692 "success": True, 

693 "message": str( 

694 _( 

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

696 filename=gps_data["filename"], 

697 ) 

698 ), 

699 "data": { 

700 "filename": gps_data["filename"], 

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

702 "departure_icao": gps_data["departure_icao"], 

703 "arrival_icao": gps_data["arrival_icao"], 

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

705 if gps_data["departure_time"] 

706 else "", 

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

708 if gps_data["arrival_time"] 

709 else "", 

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

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

712 if gps_data["block_off_utc"] 

713 else "", 

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

715 if gps_data["block_on_utc"] 

716 else "", 

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

718 if gps_data["geojson"] 

719 else "", 

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

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

722 }, 

723 "duplicate": _check_gps_duplicate(gps_data), 

724 "suggested_aircraft_id": _suggested_aircraft_for_device( 

725 gps_data["device_id"] 

726 ), 

727 } 

728 ) 

729 

730 

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

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

733 if not device_id: 

734 return None 

735 row = ( 

736 db.session.query(FlightEntry.aircraft_id) 

737 .join(GpsTrack, FlightEntry.gps_track_id == GpsTrack.id) 

738 .filter(GpsTrack.device_id == device_id) 

739 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc()) 

740 .first() 

741 ) 

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

743 

744 

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

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

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

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

749 dup = _find_duplicate_flight( 

750 aircraft_id=aircraft_id, 

751 pilot_user_id=uid, 

752 date=gps_data["date"], 

753 dep_icao=gps_data["departure_icao"], 

754 arr_icao=gps_data["arrival_icao"], 

755 block_off=gps_data["block_off_utc"], 

756 block_on=gps_data["block_on_utc"], 

757 ) 

758 if not dup: 

759 return None 

760 entry = dup["entry"] 

761 return { 

762 "type": dup["type"], 

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

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

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

766 "entry_id": entry.id, 

767 } 

768 

769 

770def _handle_log_flight_post( 

771 managed_aircraft: list[Aircraft], 

772 uid: int, 

773 fe: FlightEntry | None, 

774) -> ResponseReturnValue: 

775 f = request.form 

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

777 

778 # ── GPS parse step ───────────────────────────────────────────────────────── 

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

780 gps_data = _parse_gps_upload(gps_file) 

781 if gps_data: 

782 session["gps_prefill"] = { 

783 "filename": gps_data["filename"], 

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

785 "departure_icao": gps_data["departure_icao"], 

786 "arrival_icao": gps_data["arrival_icao"], 

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

788 if gps_data["departure_time"] 

789 else "", 

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

791 if gps_data["arrival_time"] 

792 else "", 

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

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

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

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

797 if gps_data["geojson"] 

798 else "", 

799 "landing_count": gps_data["landing_count"], 

800 } 

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

802 else: 

803 flash( 

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

805 ) 

806 if fe: 

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

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

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

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

811 

812 # ── Determine aircraft ───────────────────────────────────────────────────── 

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

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

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

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

817 if aircraft_id_raw is None and fe is not None: 

818 aircraft_id_raw = fe.aircraft_id 

819 ac: Aircraft | None = None 

820 if not other_aircraft and aircraft_id_raw: 

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

822 

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

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

825 

826 # ── Parse common fields ──────────────────────────────────────────────────── 

827 date_raw = f.get("date", "").strip() 

828 dep = (f.get("departure_icao") or "").strip().upper()[:4] 

829 arr = (f.get("arrival_icao") or "").strip().upper()[:4] 

830 departure_time_raw = f.get("departure_time", "").strip() 

831 arrival_time_raw = f.get("arrival_time", "").strip() 

832 flight_time_raw = f.get("flight_time", "").strip() 

833 nature_of_flight = f.get("nature_of_flight", "").strip() or None 

834 notes = f.get("notes", "").strip() or None 

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

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

837 pilot_role = "none" 

838 

839 # Aircraft-log fields 

840 flight_time_counter_start_raw = f.get("flight_time_counter_start", "").strip() 

841 flight_time_counter_end_raw = f.get("flight_time_counter_end", "").strip() 

842 engine_time_counter_start_raw = f.get("engine_time_counter_start", "").strip() 

843 engine_time_counter_end_raw = f.get("engine_time_counter_end", "").strip() 

844 passenger_count_raw = f.get("passenger_count", "").strip() 

845 fuel_event_raw = f.get("fuel_event", "none").strip() 

846 fuel_added_qty_raw = f.get("fuel_added_qty", "").strip() 

847 fuel_added_unit = f.get("fuel_added_unit", "L").strip() 

848 fuel_remaining_qty_raw = f.get("fuel_remaining_qty", "").strip() 

849 crew_name_0 = f.get("crew_name_0", "").strip() 

850 crew_role_0 = f.get("crew_role_0", CrewRole.PIC).strip() 

851 crew_name_1 = f.get("crew_name_1", "").strip() 

852 crew_role_1 = f.get("crew_role_1", CrewRole.COPILOT).strip() 

853 

854 # Pilot-log fields 

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

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

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

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

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

860 pic_name = f.get("pic_name", "").strip() or None 

861 

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

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

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

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

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

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

868 

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

870 

871 errors = [] 

872 

873 flight_date: _date | None = None 

874 if not date_raw: 

875 errors.append(_("Date is required.")) 

876 else: 

877 try: 

878 flight_date = _date.fromisoformat(date_raw) 

879 except ValueError: 

880 errors.append(_("Date must be a valid date (YYYY-MM-DD).")) 

881 

882 if not dep: 

883 errors.append(_("Departure airfield is required.")) 

884 if not arr: 

885 errors.append(_("Arrival airfield is required.")) 

886 

887 if not fe and not ac and not other_aircraft: 

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

889 

890 if ac and not crew_name_0: 

891 errors.append(_("Pilot (crew 1) name is required.")) 

892 

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

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

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

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

897 if other_aircraft and not other_ac_make_model: 

898 errors.append( 

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

900 ) 

901 if other_aircraft and not other_ac_reg: 

902 errors.append( 

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

904 ) 

905 

906 departure_time: _time | None = None 

907 arrival_time: _time | None = None 

908 if departure_time_raw: 

909 try: 

910 departure_time = _time.fromisoformat(departure_time_raw) 

911 except ValueError: 

912 errors.append(_("Departure time must be a valid UTC time (HH:MM).")) 

913 if arrival_time_raw: 

914 try: 

915 arrival_time = _time.fromisoformat(arrival_time_raw) 

916 except ValueError: 

917 errors.append(_("Arrival time must be a valid UTC time (HH:MM).")) 

918 

919 flight_time_counter_start = flight_time_counter_end = None 

920 engine_time_counter_start = engine_time_counter_end = None 

921 if ac: 

922 for raw, dest in [ 

923 (flight_time_counter_start_raw, "fc_start"), 

924 (flight_time_counter_end_raw, "fc_end"), 

925 (engine_time_counter_start_raw, "ec_start"), 

926 (engine_time_counter_end_raw, "ec_end"), 

927 ]: 

928 if raw: 

929 try: 

930 val = float(raw) 

931 if val < 0: 

932 raise ValueError 

933 if dest == "fc_start": 

934 flight_time_counter_start = val 

935 elif dest == "fc_end": 

936 flight_time_counter_end = val 

937 elif dest == "ec_start": 

938 engine_time_counter_start = val 

939 else: 

940 engine_time_counter_end = val 

941 except (ValueError, TypeError): 

942 errors.append(_("Counter value must be a positive number.")) 

943 

944 if ( 

945 flight_time_counter_start is not None 

946 and flight_time_counter_end is not None 

947 and flight_time_counter_end <= flight_time_counter_start 

948 ): 

949 errors.append( 

950 _("Flight counter end must be greater than flight counter start.") 

951 ) 

952 if ( 

953 engine_time_counter_start is not None 

954 and engine_time_counter_end is not None 

955 and engine_time_counter_end <= engine_time_counter_start 

956 ): 

957 errors.append( 

958 _("Engine counter end must be greater than engine counter start.") 

959 ) 

960 

961 flight_time: float | None = None 

962 if flight_time_raw: 

963 try: 

964 flight_time = round(float(flight_time_raw), 1) 

965 if flight_time < 0: 

966 raise ValueError 

967 except (ValueError, TypeError): 

968 errors.append(_("Flight time must be a non-negative number.")) 

969 elif ( 

970 ac 

971 and flight_time_counter_start is not None 

972 and flight_time_counter_end is not None 

973 ): 

974 flight_time = round(flight_time_counter_end - flight_time_counter_start, 1) 

975 elif ( 

976 ac 

977 and not getattr(ac, "has_flight_counter", True) 

978 and engine_time_counter_start is not None 

979 and engine_time_counter_end is not None 

980 ): 

981 raw_diff = (engine_time_counter_end - engine_time_counter_start) - float( 

982 getattr(ac, "flight_counter_offset", 0) or 0 

983 ) 

984 flight_time = round(max(0.0, raw_diff), 1) 

985 

986 passenger_count: int | None = None 

987 if passenger_count_raw: 

988 try: 

989 passenger_count = int(passenger_count_raw) 

990 if passenger_count < 0: 

991 raise ValueError 

992 except (ValueError, TypeError): 

993 errors.append(_("Passenger count must be a non-negative integer.")) 

994 

995 fuel_event = fuel_event_raw if fuel_event_raw in ("before", "after") else None 

996 fuel_added_qty: float | None = None 

997 if fuel_event and fuel_added_qty_raw: 

998 try: 

999 fuel_added_qty = float(fuel_added_qty_raw) 

1000 if fuel_added_qty < 0: 

1001 raise ValueError 

1002 except (ValueError, TypeError): 

1003 errors.append(_("Fuel quantity added must be a non-negative number.")) 

1004 

1005 fuel_remaining_qty: float | None = None 

1006 if fuel_remaining_qty_raw: 

1007 try: 

1008 fuel_remaining_qty = float(fuel_remaining_qty_raw) 

1009 if fuel_remaining_qty < 0: 

1010 raise ValueError 

1011 except (ValueError, TypeError): 

1012 errors.append(_("Fuel remaining must be a non-negative number.")) 

1013 

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

1015 if not raw: 

1016 return None 

1017 try: 

1018 v = decimal.Decimal(raw) 

1019 return v if v >= 0 else None 

1020 except Exception: 

1021 return None 

1022 

1023 night_time = _parse_dec(night_time_raw) 

1024 instrument_time = _parse_dec(instrument_time_raw) 

1025 multi_pilot = _parse_dec(multi_pilot_raw) 

1026 landings_day: int | None = ( 

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

1028 ) 

1029 landings_night: int | None = ( 

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

1031 ) 

1032 

1033 gps_block_off: _datetime | None = None 

1034 gps_block_on: _datetime | None = None 

1035 if gps_block_off_raw: 

1036 with contextlib.suppress( 

1037 ValueError 

1038 ): # malformed hidden field — treat as absent 

1039 gps_block_off = _datetime.fromisoformat(gps_block_off_raw) 

1040 if gps_block_on_raw: 

1041 with contextlib.suppress( 

1042 ValueError 

1043 ): # malformed hidden field — treat as absent 

1044 gps_block_on = _datetime.fromisoformat(gps_block_on_raw) 

1045 

1046 gps_geojson: Any = None 

1047 if gps_geojson_raw: 

1048 with contextlib.suppress( 

1049 Exception 

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

1051 gps_geojson = _json.loads(gps_geojson_raw) 

1052 

1053 if errors: 

1054 for msg in errors: 

1055 flash(msg, "danger") 

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

1057 

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

1059 if not duplicate_action and flight_date and dep and arr: 

1060 dup = _find_duplicate_flight( 

1061 aircraft_id=ac.id if ac else None, 

1062 pilot_user_id=uid, 

1063 date=flight_date, 

1064 dep_icao=dep, 

1065 arr_icao=arr, 

1066 block_off=gps_block_off, 

1067 block_on=gps_block_on, 

1068 exclude_flight_id=fe.id if fe else None, 

1069 ) 

1070 if dup: 

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

1072 

1073 # ── GPS-attach-only path ─────────────────────────────────────────────────── 

1074 if duplicate_action == "link_gps" and flight_date: 

1075 dup = _find_duplicate_flight( 

1076 aircraft_id=ac.id if ac else None, 

1077 pilot_user_id=uid, 

1078 date=flight_date, 

1079 dep_icao=dep, 

1080 arr_icao=arr, 

1081 block_off=gps_block_off, 

1082 block_on=gps_block_on, 

1083 exclude_flight_id=fe.id if fe else None, 

1084 ) 

1085 if dup and (gps_geojson or gps_filename): 

1086 link_track = GpsTrack( 

1087 source_filename=gps_filename, 

1088 device_id=gps_device_id, 

1089 block_off_utc=gps_block_off, 

1090 block_on_utc=gps_block_on, 

1091 departure_icao=dep, 

1092 arrival_icao=arr, 

1093 geojson=gps_geojson, 

1094 ) 

1095 db.session.add(link_track) 

1096 db.session.flush() 

1097 entry = dup["entry"] 

1098 if isinstance(entry, FlightEntry): 

1099 entry.gps_track_id = link_track.id 

1100 plink = PilotLogbookEntry.query.filter_by( 

1101 flight_id=entry.id, pilot_user_id=uid 

1102 ).first() 

1103 if plink: 

1104 plink.gps_track_id = link_track.id 

1105 else: 

1106 entry.gps_track_id = link_track.id 

1107 db.session.commit() 

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

1109 else: 

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

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

1112 

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

1114 ft_decimal = decimal.Decimal(str(flight_time)) if flight_time is not None else None 

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

1116 

1117 gps_track: GpsTrack | None = None 

1118 if gps_geojson or gps_filename: 

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

1120 if existing_track_id: 

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

1122 if gps_track: 

1123 if gps_geojson: 

1124 gps_track.geojson = gps_geojson 

1125 if gps_filename: 

1126 gps_track.source_filename = gps_filename 

1127 if gps_block_off: 

1128 gps_track.block_off_utc = gps_block_off 

1129 if gps_block_on: 

1130 gps_track.block_on_utc = gps_block_on 

1131 if gps_track and gps_device_id: 

1132 gps_track.device_id = gps_device_id 

1133 if not gps_track: 

1134 gps_track = GpsTrack( 

1135 source_filename=gps_filename, 

1136 device_id=gps_device_id, 

1137 block_off_utc=gps_block_off, 

1138 block_on_utc=gps_block_on, 

1139 departure_icao=dep, 

1140 arrival_icao=arr, 

1141 geojson=gps_geojson, 

1142 ) 

1143 db.session.add(gps_track) 

1144 db.session.flush() 

1145 

1146 # ── Pilot log aircraft fields ────────────────────────────────────────────── 

1147 plog_ac_type: str | None 

1148 if ac: 

1149 plog_ac_type = f"{ac.make} {ac.model}".strip() 

1150 plog_ac_type_icao = getattr(ac, "aircraft_type_icao", None) 

1151 plog_ac_reg = ac.registration 

1152 cat = _ac_category(ac) 

1153 plog_sp_se = ft_decimal if cat in ("SEP", "SET", "") else None 

1154 plog_sp_me = ft_decimal if cat in ("MEP", "MET") else None 

1155 else: 

1156 plog_ac_type = other_ac_make_model or None 

1157 plog_ac_type_icao = f.get("aircraft_type_icao", "").strip() or None 

1158 plog_ac_reg = other_ac_reg or None 

1159 plog_sp_se = ft_decimal 

1160 plog_sp_me = None 

1161 

1162 # ── Aircraft log entry ───────────────────────────────────────────────────── 

1163 _fe_is_new = fe is None 

1164 if ac: 

1165 if fe is None: 

1166 fe = FlightEntry(aircraft_id=ac.id) 

1167 db.session.add(fe) 

1168 

1169 fe.date = flight_date 

1170 fe.departure_icao = dep 

1171 fe.arrival_icao = arr 

1172 fe.departure_time = departure_time 

1173 fe.arrival_time = arrival_time 

1174 fe.flight_time = ft_decimal 

1175 fe.nature_of_flight = nature_of_flight 

1176 fe.passenger_count = passenger_count 

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

1178 fe.landing_count = (landings_day or 0) + (landings_night or 0) 

1179 fe.flight_time_counter_start = flight_time_counter_start 

1180 fe.flight_time_counter_end = flight_time_counter_end 

1181 fe.notes = notes 

1182 fe.engine_time_counter_start = engine_time_counter_start 

1183 fe.engine_time_counter_end = engine_time_counter_end 

1184 fe.fuel_event = fuel_event 

1185 fe.fuel_added_qty = fuel_added_qty 

1186 fe.fuel_added_unit = fuel_added_unit if fuel_added_qty is not None else None 

1187 fe.fuel_remaining_qty = fuel_remaining_qty 

1188 if gps_track: 

1189 fe.gps_track_id = gps_track.id 

1190 if gps_block_off: 

1191 fe.block_off_utc = gps_block_off 

1192 if gps_block_on: 

1193 fe.block_on_utc = gps_block_on 

1194 

1195 db.session.flush() 

1196 

1197 FlightCrew.query.filter_by(flight_id=fe.id).delete() 

1198 if crew_name_0: 

1199 db.session.add( 

1200 FlightCrew( 

1201 flight_id=fe.id, 

1202 name=crew_name_0, 

1203 role=crew_role_0 if crew_role_0 in CrewRole.ALL else CrewRole.PIC, 

1204 sort_order=0, 

1205 ) 

1206 ) 

1207 if crew_name_1: 

1208 db.session.add( 

1209 FlightCrew( 

1210 flight_id=fe.id, 

1211 name=crew_name_1, 

1212 role=crew_role_1 

1213 if crew_role_1 in CrewRole.ALL 

1214 else CrewRole.COPILOT, 

1215 sort_order=1, 

1216 ) 

1217 ) 

1218 

1219 for photo_field, label, attr in [ 

1220 ("flight_counter_photo", "flight", "flight_counter_photo"), 

1221 ("engine_counter_photo", "engine", "engine_counter_photo"), 

1222 ("fuel_photo", "fuel", "fuel_photo"), 

1223 ]: 

1224 photo_file = request.files.get(photo_field) 

1225 if photo_file and photo_file.filename: 

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

1227 if stored: 

1228 _delete_upload(getattr(fe, attr)) 

1229 setattr(fe, attr, stored) 

1230 

1231 # ── Pilot log entry ──────────────────────────────────────────────────────── 

1232 if create_pilot: 

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

1234 effective_pic_name = ( 

1235 pic_name 

1236 or (crew_name_0 if pilot_role == "pic" else None) 

1237 or (_u.display_name if _u else "") 

1238 ) 

1239 existing_pe: PilotLogbookEntry | None = None 

1240 if fe and fe.id: 

1241 existing_pe = PilotLogbookEntry.query.filter_by( 

1242 flight_id=fe.id, pilot_user_id=uid 

1243 ).first() 

1244 

1245 pe = existing_pe or PilotLogbookEntry(pilot_user_id=uid) 

1246 if not existing_pe: 

1247 db.session.add(pe) 

1248 

1249 pe.flight_id = fe.id if fe else None 

1250 pe.date = flight_date 

1251 pe.aircraft_type = plog_ac_type 

1252 pe.aircraft_type_icao = plog_ac_type_icao 

1253 pe.aircraft_registration = plog_ac_reg 

1254 pe.departure_place = dep 

1255 pe.departure_time = departure_time 

1256 pe.arrival_place = arr 

1257 pe.arrival_time = arrival_time 

1258 pe.pic_name = effective_pic_name 

1259 pe.night_time = night_time 

1260 pe.instrument_time = instrument_time 

1261 pe.landings_day = landings_day if landings_day is not None else 0 

1262 pe.landings_night = landings_night 

1263 pe.single_pilot_se = plog_sp_se 

1264 pe.single_pilot_me = plog_sp_me 

1265 pe.multi_pilot = multi_pilot 

1266 pe.function_pic = ft_decimal if pilot_role == "pic" else None 

1267 pe.function_dual = ft_decimal if pilot_role == "dual" else None 

1268 pe.remarks = notes 

1269 if gps_track: 

1270 pe.gps_track_id = gps_track.id 

1271 

1272 elif fe and fe.id: 

1273 detach_action = f.get("detach_pilot_log", "").strip() 

1274 if detach_action in ("detach", "delete"): 

1275 existing_pe2 = PilotLogbookEntry.query.filter_by( 

1276 flight_id=fe.id, pilot_user_id=uid 

1277 ).first() 

1278 if existing_pe2: 

1279 if detach_action == "delete": 

1280 db.session.delete(existing_pe2) 

1281 else: 

1282 existing_pe2.flight_id = None 

1283 

1284 db.session.commit() 

1285 

1286 if fe and ac: 

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

1288 activity( 

1289 event_name, 

1290 flight_id=fe.id, 

1291 aircraft_id=ac.id, 

1292 dep=dep, 

1293 arr=arr, 

1294 date=str(flight_date), 

1295 ) 

1296 _check_flight_hour_milestone(fe) 

1297 

1298 if ac and fe: 

1299 flash( 

1300 _( 

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

1302 dep=dep, 

1303 arr=arr, 

1304 date=flight_date, 

1305 ), 

1306 "success", 

1307 ) 

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

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

1310 if return_ac_id is not None: 

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

1312 if ( 

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

1314 and return_seg_idx is not None 

1315 ): 

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

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

1318 gps_state["confirmed_segments"] = confirmed 

1319 session["gps_import"] = gps_state 

1320 session.modified = True 

1321 return redirect( 

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

1323 ) 

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

1325 

1326 flash( 

1327 _( 

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

1329 dep=dep, 

1330 arr=arr, 

1331 date=flight_date, 

1332 ), 

1333 "success", 

1334 ) 

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

1336 

1337 

1338def _render_form( 

1339 managed_aircraft: list[Aircraft], 

1340 flight: FlightEntry | None, 

1341 pilot_entry: PilotLogbookEntry | None, 

1342 preselect_id: int | None, 

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

1344) -> ResponseReturnValue: 

1345 nature_suggestions = _NATURE_SUGGESTIONS 

1346 aircraft: Aircraft | None = None 

1347 if preselect_id: 

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

1349 if aircraft: 

1350 nature_suggestions = _nature_suggestions(aircraft.id) 

1351 counter_hint = _get_counter_hint(preselect_id) if preselect_id else None 

1352 return render_template( 

1353 "flights/flight_form.html", 

1354 flight=flight, 

1355 pilot_entry=pilot_entry, 

1356 aircraft=aircraft, 

1357 managed_aircraft=managed_aircraft, 

1358 preselect_aircraft_id=preselect_id, 

1359 gps_prefill=None, 

1360 nature_suggestions=nature_suggestions, 

1361 pilot_name_hint=None, 

1362 crew_roles=CrewRole, 

1363 fuel_units=_FUEL_UNITS, 

1364 duplicate=duplicate, 

1365 counter_hint=counter_hint, 

1366 openaip_key=_openaip_key(), 

1367 gps_review_return_aircraft_id=None, 

1368 gps_review_return_seg_idx=None, 

1369 ) 

1370 

1371 

1372# ── Delete flight ───────────────────────────────────────────────────────────── 

1373 

1374 

1375@flights_bp.route( 

1376 "/aircraft/<int:aircraft_id>/flights/<int:flight_id>/delete", methods=["POST"] 

1377) 

1378@login_required 

1379@require_pilot_access 

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

1381 ac = _get_aircraft_or_404(aircraft_id) 

1382 fe = db.session.get(FlightEntry, flight_id) 

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

1384 abort(404) 

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

1386 activity( 

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

1388 ) 

1389 _delete_upload(fe.flight_counter_photo) 

1390 _delete_upload(fe.engine_counter_photo) 

1391 db.session.delete(fe) 

1392 db.session.commit() 

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

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