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

767 statements  

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

1import contextlib 

2import json 

3import logging 

4import os 

5import uuid 

6from typing import Any 

7from datetime import ( 

8 date as _date, 

9 datetime as _datetime, 

10 time as _time, 

11 timedelta as _td, 

12 timezone as _tz, 

13) 

14 

15from sqlalchemy import func # pyright: ignore[reportMissingImports] 

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

17 Blueprint, 

18 abort, 

19 current_app, 

20 flash, 

21 redirect, 

22 render_template, 

23 request, 

24 session, 

25 url_for, 

26) 

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

28 

29from flask_babel import gettext as _, ngettext # pyright: ignore[reportMissingImports] 

30from werkzeug.utils import secure_filename # pyright: ignore[reportMissingImports] 

31 

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

33 Aircraft, 

34 Document, 

35 FlightCrew, 

36 FlightEntry, 

37 GpsTrack, 

38 LogbookImportBatch, 

39 LogbookImportMapping, 

40 PilotLogbookEntry, 

41 PilotProfile, 

42 TenantUser, 

43 db, 

44) 

45from utils import login_required, require_pilot_access # pyright: ignore[reportMissingImports] 

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

47 TARGET_FIELDS, 

48 _norm, 

49 execute_import, 

50 parse_duration_value, 

51 parse_file, 

52 preview_rows, 

53 propose_mapping, 

54 type_hints, 

55) 

56 

57log = logging.getLogger(__name__) 

58 

59pilots_bp = Blueprint("pilots", __name__) 

60 

61 

62def _current_user_id() -> int: 

63 return int(session["user_id"]) 

64 

65 

66def _openaip_key() -> str | None: 

67 from models import AppSetting # pyright: ignore[reportMissingImports] 

68 

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

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

71 

72 

73def _get_or_create_profile(user_id: int) -> PilotProfile: 

74 profile: PilotProfile | None = PilotProfile.query.filter_by(user_id=user_id).first() 

75 if not profile: 

76 profile = PilotProfile(user_id=user_id) 

77 db.session.add(profile) 

78 db.session.flush() 

79 return profile 

80 

81 

82def _check_logbook_milestone(entry: PilotLogbookEntry, uid: int) -> None: 

83 """Set one-shot session flags when a logbook milestone is crossed.""" 

84 total = PilotLogbookEntry.query.filter_by(pilot_user_id=uid).count() 

85 if total == 100: 

86 session["logbook_milestone"] = "100flights" 

87 flash(_("🎉 100th logbook entry — congratulations!"), "success") 

88 return 

89 

90 night = float(entry.night_time or 0) 

91 if night > 0: 

92 prev_night = ( 

93 db.session.query(func.count(PilotLogbookEntry.id)) 

94 .filter( 

95 PilotLogbookEntry.pilot_user_id == uid, 

96 PilotLogbookEntry.id != entry.id, 

97 PilotLogbookEntry.night_time > 0, 

98 ) 

99 .scalar() 

100 or 0 

101 ) 

102 if prev_night == 0: 

103 session["logbook_milestone"] = "first_night" 

104 flash(_("🌙 First night flight logged — well done!"), "success") 

105 return 

106 

107 dep = (entry.departure_place or "").strip().upper() 

108 arr = (entry.arrival_place or "").strip().upper() 

109 if dep and arr and dep != arr: 

110 prev_xc = ( 

111 db.session.query(func.count(PilotLogbookEntry.id)) 

112 .filter( 

113 PilotLogbookEntry.pilot_user_id == uid, 

114 PilotLogbookEntry.id != entry.id, 

115 PilotLogbookEntry.departure_place.isnot(None), 

116 PilotLogbookEntry.arrival_place.isnot(None), 

117 PilotLogbookEntry.departure_place != PilotLogbookEntry.arrival_place, 

118 ) 

119 .scalar() 

120 or 0 

121 ) 

122 if prev_xc == 0: 

123 session["logbook_milestone"] = "first_xc" 

124 flash( 

125 _("✈️ First cross-country flight logged — congratulations!"), "success" 

126 ) 

127 

128 

129def _parse_time(val: str, field: str) -> tuple[_time | None, str | None]: 

130 val = val.strip() 

131 if not val: 

132 return None, None 

133 try: 

134 h, m = val.split(":") 

135 t = _time(int(h), int(m)) 

136 return t, None 

137 except (ValueError, AttributeError): 

138 return None, _("%(field)s: enter a valid HH:MM time.", field=field) 

139 

140 

141def _parse_decimal(val: str, field: str) -> tuple[float | None, str | None]: 

142 val = val.strip() 

143 if not val: 

144 return None, None 

145 try: 

146 n = float(val) 

147 if n < 0: 

148 return None, _("%(field)s: must be non-negative.", field=field) 

149 return n, None 

150 except ValueError: 

151 return None, _("%(field)s: must be a number.", field=field) 

152 

153 

154def _parse_int(val: str, field: str) -> tuple[int | None, str | None]: 

155 val = val.strip() 

156 if not val: 

157 return None, None 

158 try: 

159 n = int(val) 

160 if n < 0: 

161 return None, _("%(field)s: must be non-negative.", field=field) 

162 return n, None 

163 except ValueError: 

164 return None, _("%(field)s: must be a whole number.", field=field) 

165 

166 

167def _parse_date(val: str, field: str) -> tuple[_date | None, str | None]: 

168 val = val.strip() 

169 if not val: 

170 return None, None 

171 try: 

172 return _date.fromisoformat(val), None 

173 except ValueError: 

174 return None, _("%(field)s: enter a valid date (YYYY-MM-DD).", field=field) 

175 

176 

177# ── Profile ─────────────────────────────────────────────────────────────────── 

178 

179 

180@pilots_bp.route("/pilot/profile", methods=["GET", "POST"]) 

181@login_required 

182@require_pilot_access 

183def profile() -> ResponseReturnValue: 

184 uid = _current_user_id() 

185 p = _get_or_create_profile(uid) 

186 

187 if request.method == "POST": 

188 errors = [] 

189 

190 p.license_number = request.form.get("license_number", "").strip() or None 

191 

192 medical_str = request.form.get("medical_expiry", "") 

193 medical, err = _parse_date(medical_str, "Medical expiry") 

194 if err: 

195 errors.append(err) 

196 else: 

197 p.medical_expiry = medical 

198 

199 sep_str = request.form.get("sep_expiry", "") 

200 sep, err = _parse_date(sep_str, "SEP expiry") 

201 if err: 

202 errors.append(err) 

203 else: 

204 p.sep_expiry = sep 

205 

206 solo_str = request.form.get("first_solo_date", "") 

207 solo, err = _parse_date(solo_str, _("First solo date")) 

208 if err: 

209 errors.append(err) 

210 else: 

211 p.first_solo_date = solo 

212 

213 ppl_str = request.form.get("ppl_issue_date", "") 

214 ppl, err = _parse_date(ppl_str, _("PPL issue date")) 

215 if err: 

216 errors.append(err) 

217 else: 

218 p.ppl_issue_date = ppl 

219 

220 if errors: 

221 for e in errors: 

222 flash(e, "danger") 

223 return ( 

224 render_template( 

225 "pilots/profile.html", profile=p, pilot_docs=[], currency=None 

226 ), 

227 422, 

228 ) 

229 

230 db.session.commit() 

231 flash(_("Profile saved."), "success") 

232 return redirect(url_for("pilots.profile")) 

233 

234 from pilots.currency import currency_summary as _currency_summary # pyright: ignore[reportMissingImports] 

235 

236 pilot_entries = PilotLogbookEntry.query.filter_by(pilot_user_id=uid).all() 

237 currency = _currency_summary(p, pilot_entries) 

238 

239 pilot_docs = ( 

240 Document.query.filter_by(pilot_user_id=uid) 

241 .order_by(Document.uploaded_at.desc()) 

242 .all() 

243 ) 

244 return render_template( 

245 "pilots/profile.html", profile=p, pilot_docs=pilot_docs, currency=currency 

246 ) 

247 

248 

249_VALID_PER_PAGE = (10, 20, 50, 100) 

250_DEFAULT_PER_PAGE = 20 

251 

252 

253# ── GPS tracks map ──────────────────────────────────────────────────────────── 

254 

255 

256@pilots_bp.route("/pilot/tracks") 

257@login_required 

258@require_pilot_access 

259def pilot_tracks() -> ResponseReturnValue: 

260 from flask import url_for as _url_for 

261 

262 uid = _current_user_id() 

263 entries = ( 

264 PilotLogbookEntry.query.filter_by(pilot_user_id=uid) 

265 .filter(PilotLogbookEntry.gps_track_id.isnot(None)) 

266 .order_by(PilotLogbookEntry.date.asc()) 

267 .all() 

268 ) 

269 track_rows = [ 

270 { 

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

272 "dep": e.departure_place or "", 

273 "arr": e.arrival_place or "", 

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

275 if e.total_flight_time is not None 

276 else "", 

277 "view_url": _url_for( 

278 "aircraft.flight_detail", 

279 aircraft_id=e.flight.aircraft_id, 

280 flight_id=e.flight_id, 

281 ) 

282 if e.flight_id and e.flight 

283 else _url_for("pilots.view_entry", entry_id=e.id), 

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

285 } 

286 for e in entries 

287 ] 

288 

289 return render_template( 

290 "pilots/flight_tracks.html", 

291 track_rows=track_rows, 

292 openaip_key=_openaip_key(), 

293 ) 

294 

295 

296@pilots_bp.route("/pilot/tracks/animation.gif") 

297@login_required 

298@require_pilot_access 

299def pilot_tracks_gif() -> ResponseReturnValue: 

300 from utils import generate_tracks_gif, sort_tracks_oldest_first # pyright: ignore[reportMissingImports] 

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

302 

303 uid = _current_user_id() 

304 entries = ( 

305 PilotLogbookEntry.query.filter_by(pilot_user_id=uid) 

306 .filter(PilotLogbookEntry.gps_track_id.isnot(None)) 

307 .all() 

308 ) 

309 track_rows = sort_tracks_oldest_first( 

310 [ 

311 { 

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

313 "dep": e.departure_place or "", 

314 "arr": e.arrival_place or "", 

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

316 } 

317 for e in entries 

318 ] 

319 ) 

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

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

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

323 mul = 2 if hires else 1 

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

325 gif_bytes = generate_tracks_gif( 

326 track_rows, 

327 _openaip_key=_openaip_key(), 

328 canvas_w=canvas_w, 

329 canvas_h=canvas_h, 

330 high_res=hires, 

331 ) 

332 orient_sfx = "-portrait" if portrait else "" 

333 qual_sfx = "-hires" if hires else "" 

334 suffix = orient_sfx + qual_sfx 

335 return Response( 

336 gif_bytes, 

337 mimetype="image/gif", 

338 headers={ 

339 "Content-Disposition": f'attachment; filename="my_tracks{suffix}.gif"' 

340 }, 

341 ) 

342 

343 

344# ── Logbook entry detail (read-only) ───────────────────────────────────────── 

345 

346 

347@pilots_bp.route("/pilot/logbook/<int:entry_id>/view") 

348@login_required 

349@require_pilot_access 

350def view_entry(entry_id: int) -> ResponseReturnValue: 

351 uid = _current_user_id() 

352 entry = db.session.get(PilotLogbookEntry, entry_id) 

353 if not entry or entry.pilot_user_id != uid: 

354 abort(404) 

355 

356 return render_template( 

357 "pilots/entry_detail.html", 

358 entry=entry, 

359 openaip_key=_openaip_key(), 

360 ) 

361 

362 

363# ── Logbook list ────────────────────────────────────────────────────────────── 

364 

365 

366@pilots_bp.route("/pilot/logbook") 

367@login_required 

368@require_pilot_access 

369def logbook() -> ResponseReturnValue: 

370 uid = _current_user_id() 

371 order = request.args.get("order", "desc") 

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

373 pp_raw = request.args.get("per_page", str(_DEFAULT_PER_PAGE)) 

374 show_all = pp_raw == "all" 

375 per_page = ( 

376 None 

377 if show_all 

378 else ( 

379 int(pp_raw) 

380 if pp_raw.isdigit() and int(pp_raw) in _VALID_PER_PAGE 

381 else _DEFAULT_PER_PAGE 

382 ) 

383 ) 

384 

385 q = PilotLogbookEntry.query.filter_by(pilot_user_id=uid) 

386 if order == "asc": 

387 q = q.order_by(PilotLogbookEntry.date.asc(), PilotLogbookEntry.id.asc()) 

388 else: 

389 q = q.order_by(PilotLogbookEntry.date.desc(), PilotLogbookEntry.id.desc()) 

390 

391 if show_all: 

392 entries = q.all() 

393 pagination = None 

394 else: 

395 pagination = q.paginate(page=page, per_page=per_page, error_out=False) 

396 entries = pagination.items 

397 

398 totals = _compute_totals_sql(uid) 

399 logbook_milestone = session.pop("logbook_milestone", None) 

400 

401 return render_template( 

402 "pilots/logbook.html", 

403 entries=entries, 

404 pagination=pagination, 

405 totals=totals, 

406 order=order, 

407 per_page=pp_raw, 

408 valid_per_page=_VALID_PER_PAGE, 

409 logbook_milestone=logbook_milestone, 

410 ) 

411 

412 

413def _compute_totals_sql(pilot_user_id: int) -> dict[str, object]: 

414 """Aggregate totals over ALL entries for the pilot via a single SQL query.""" 

415 row = ( 

416 db.session.query( 

417 func.sum(PilotLogbookEntry.night_time), 

418 func.sum(PilotLogbookEntry.instrument_time), 

419 func.sum(PilotLogbookEntry.landings_day), 

420 func.sum(PilotLogbookEntry.landings_night), 

421 func.sum(PilotLogbookEntry.single_pilot_se), 

422 func.sum(PilotLogbookEntry.single_pilot_me), 

423 func.sum(PilotLogbookEntry.multi_pilot), 

424 func.sum(PilotLogbookEntry.function_pic), 

425 func.sum(PilotLogbookEntry.function_copilot), 

426 func.sum(PilotLogbookEntry.function_dual), 

427 func.sum(PilotLogbookEntry.function_instructor), 

428 ) 

429 .filter(PilotLogbookEntry.pilot_user_id == pilot_user_id) 

430 .one() 

431 ) 

432 

433 sp_se = round(float(row[4] or 0), 1) 

434 sp_me = round(float(row[5] or 0), 1) 

435 multi = round(float(row[6] or 0), 1) 

436 

437 return { 

438 "night_time": round(float(row[0] or 0), 1), 

439 "instrument_time": round(float(row[1] or 0), 1), 

440 "landings_day": int(row[2] or 0), 

441 "landings_night": int(row[3] or 0), 

442 "single_pilot_se": sp_se, 

443 "single_pilot_me": sp_me, 

444 "multi_pilot": multi, 

445 "total_flight_time": round(sp_se + sp_me + multi, 1), 

446 "function_pic": round(float(row[7] or 0), 1), 

447 "function_copilot": round(float(row[8] or 0), 1), 

448 "function_dual": round(float(row[9] or 0), 1), 

449 "function_instructor": round(float(row[10] or 0), 1), 

450 } 

451 

452 

453# ── New entry ──────────────────────────────────────────────────────────────── 

454 

455 

456@pilots_bp.route("/pilot/logbook/new", methods=["GET", "POST"]) 

457@login_required 

458@require_pilot_access 

459def new_entry() -> ResponseReturnValue: 

460 uid = _current_user_id() 

461 

462 if request.method == "POST": 

463 entry, errors = _entry_from_form(uid) 

464 if errors: 

465 for e in errors: 

466 flash(e, "danger") 

467 return render_template( 

468 "pilots/entry_form.html", 

469 entry=None, 

470 form=request.form, 

471 action="new", 

472 openaip_key=_openaip_key(), 

473 ), 422 

474 db.session.add(entry) 

475 db.session.flush() 

476 _apply_gps_to_pilot_entry(entry) 

477 db.session.commit() 

478 _check_logbook_milestone(entry, uid) 

479 flash(_("Logbook entry saved."), "success") 

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

481 

482 return render_template( 

483 "pilots/entry_form.html", 

484 entry=None, 

485 form={}, 

486 action="new", 

487 openaip_key=_openaip_key(), 

488 ) 

489 

490 

491# ── Edit entry ──────────────────────────────────────────────────────────────── 

492 

493 

494@pilots_bp.route("/pilot/logbook/<int:entry_id>/edit", methods=["GET", "POST"]) 

495@login_required 

496@require_pilot_access 

497def edit_entry(entry_id: int) -> ResponseReturnValue: 

498 uid = _current_user_id() 

499 entry = db.session.get(PilotLogbookEntry, entry_id) 

500 if not entry or entry.pilot_user_id != uid: 

501 abort(404) 

502 

503 if entry.flight_id: 

504 return redirect(url_for("flights.edit_flight", flight_id=entry.flight_id)) 

505 

506 if request.method == "POST": 

507 updated, errors = _entry_from_form(uid) 

508 if errors: 

509 for e in errors: 

510 flash(e, "danger") 

511 return render_template( 

512 "pilots/entry_form.html", 

513 entry=entry, 

514 form=request.form, 

515 action="edit", 

516 openaip_key=_openaip_key(), 

517 ), 422 

518 for col in PilotLogbookEntry.__table__.columns: 

519 if col.name not in ("id", "pilot_user_id", "gps_track_id"): 

520 setattr(entry, col.name, getattr(updated, col.name)) 

521 _apply_gps_to_pilot_entry(entry) 

522 db.session.commit() 

523 flash(_("Logbook entry updated."), "success") 

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

525 

526 return render_template( 

527 "pilots/entry_form.html", 

528 entry=entry, 

529 form={}, 

530 action="edit", 

531 openaip_key=_openaip_key(), 

532 ) 

533 

534 

535# ── Delete entry ────────────────────────────────────────────────────────────── 

536 

537 

538@pilots_bp.route("/pilot/logbook/<int:entry_id>/delete", methods=["POST"]) 

539@login_required 

540@require_pilot_access 

541def delete_entry(entry_id: int) -> ResponseReturnValue: 

542 uid = _current_user_id() 

543 entry = db.session.get(PilotLogbookEntry, entry_id) 

544 if not entry or entry.pilot_user_id != uid: 

545 abort(404) 

546 db.session.delete(entry) 

547 db.session.commit() 

548 flash(_("Logbook entry deleted."), "success") 

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

550 

551 

552# ── GPS track helper ────────────────────────────────────────────────────────── 

553 

554 

555def _apply_gps_to_pilot_entry(entry: PilotLogbookEntry) -> None: 

556 """Create or update the GpsTrack linked to a pilot logbook entry from form data.""" 

557 f = request.form 

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

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

560 if not gps_geojson_raw and not gps_filename: 

561 return 

562 

563 geojson = None 

564 if gps_geojson_raw: 

565 with contextlib.suppress( 

566 ValueError 

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

568 geojson = json.loads(gps_geojson_raw) 

569 

570 def _parse_dt(raw: str) -> "_datetime | None": 

571 try: 

572 return _datetime.fromisoformat(raw) if raw else None 

573 except ValueError: 

574 return None 

575 

576 block_off = _parse_dt(f.get("gps_block_off_utc", "").strip()) 

577 block_on = _parse_dt(f.get("gps_block_on_utc", "").strip()) 

578 dep = f.get("departure_place", "").strip().upper()[:4] or None 

579 arr = f.get("arrival_place", "").strip().upper()[:4] or None 

580 

581 if entry.gps_track_id: 

582 gt = db.session.get(GpsTrack, entry.gps_track_id) 

583 if gt: 

584 if geojson is not None: 

585 gt.geojson = geojson 

586 if gps_filename: 

587 gt.source_filename = gps_filename 

588 if block_off: 

589 gt.block_off_utc = block_off 

590 if block_on: 

591 gt.block_on_utc = block_on 

592 return 

593 

594 gt = GpsTrack( 

595 source_filename=gps_filename, 

596 block_off_utc=block_off, 

597 block_on_utc=block_on, 

598 departure_icao=dep, 

599 arrival_icao=arr, 

600 geojson=geojson, 

601 ) 

602 db.session.add(gt) 

603 db.session.flush() 

604 entry.gps_track_id = gt.id 

605 

606 

607# ── Form parsing ────────────────────────────────────────────────────────────── 

608 

609 

610def _entry_from_form(pilot_user_id: int) -> tuple[PilotLogbookEntry, list[str]]: 

611 f = request.form 

612 errors = [] 

613 

614 date_str = f.get("date", "") 

615 date_val, err = _parse_date(date_str, "Date") 

616 if err: 

617 errors.append(err) 

618 elif date_val is None: 

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

620 

621 dep_time, err = _parse_time(f.get("departure_time", ""), "Departure time") 

622 if err: 

623 errors.append(err) 

624 arr_time, err = _parse_time(f.get("arrival_time", ""), "Arrival time") 

625 if err: 

626 errors.append(err) 

627 

628 night_time, err = _parse_decimal(f.get("night_time", ""), "Night time") 

629 if err: 

630 errors.append(err) 

631 instrument_time, err = _parse_decimal( 

632 f.get("instrument_time", ""), "Instrument time" 

633 ) 

634 if err: 

635 errors.append(err) 

636 landings_day, err = _parse_int(f.get("landings_day", ""), "Day landings") 

637 if err: 

638 errors.append(err) 

639 landings_night, err = _parse_int(f.get("landings_night", ""), "Night landings") 

640 if err: 

641 errors.append(err) 

642 sp_se, err = _parse_decimal(f.get("single_pilot_se", ""), "S/E time") 

643 if err: 

644 errors.append(err) 

645 sp_me, err = _parse_decimal(f.get("single_pilot_me", ""), "M/E time") 

646 if err: 

647 errors.append(err) 

648 multi_pilot, err = _parse_decimal(f.get("multi_pilot", ""), "Multi-pilot time") 

649 if err: 

650 errors.append(err) 

651 fn_pic, err = _parse_decimal(f.get("function_pic", ""), "PIC function") 

652 if err: 

653 errors.append(err) 

654 fn_co, err = _parse_decimal(f.get("function_copilot", ""), "Co-pilot function") 

655 if err: 

656 errors.append(err) 

657 fn_dual, err = _parse_decimal(f.get("function_dual", ""), "Dual function") 

658 if err: 

659 errors.append(err) 

660 fn_inst, err = _parse_decimal( 

661 f.get("function_instructor", ""), "Instructor function" 

662 ) 

663 if err: 

664 errors.append(err) 

665 

666 entry = PilotLogbookEntry( 

667 pilot_user_id=pilot_user_id, 

668 date=date_val, 

669 aircraft_type=f.get("aircraft_type", "").strip() or None, 

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

671 aircraft_registration=f.get("aircraft_registration", "").strip() or None, 

672 departure_place=f.get("departure_place", "").strip() or None, 

673 departure_time=dep_time, 

674 arrival_place=f.get("arrival_place", "").strip() or None, 

675 arrival_time=arr_time, 

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

677 night_time=night_time, 

678 instrument_time=instrument_time, 

679 landings_day=landings_day, 

680 landings_night=landings_night, 

681 single_pilot_se=sp_se, 

682 single_pilot_me=sp_me, 

683 multi_pilot=multi_pilot, 

684 function_pic=fn_pic, 

685 function_copilot=fn_co, 

686 function_dual=fn_dual, 

687 function_instructor=fn_inst, 

688 remarks=f.get("remarks", "").strip() or None, 

689 ) 

690 return entry, errors 

691 

692 

693# ── Logbook Import ──────────────────────────────────────────────────────────── 

694 

695_IMPORT_SESSION_KEY = "logbook_import" 

696_ALLOWED_IMPORT_EXTS = {".csv", ".xlsx", ".xls"} 

697_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB 

698 

699 

700def _import_tmp_dir() -> str: 

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

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

703 os.makedirs(d, exist_ok=True) 

704 return d 

705 

706 

707def _cleanup_previous_tmp(uid: int) -> None: 

708 """Delete any leftover temp import file for this user.""" 

709 meta = session.get(_IMPORT_SESSION_KEY) 

710 if meta and meta.get("uid") == uid: 

711 tmp = meta.get("tmp_path") 

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

713 try: 

714 os.remove(tmp) 

715 except OSError as exc: 

716 current_app.logger.debug("cleanup tmp import file: %s", exc) 

717 session.pop(_IMPORT_SESSION_KEY, None) 

718 

719 

720@pilots_bp.route("/pilot/logbook/import", methods=["GET", "POST"]) 

721@login_required 

722@require_pilot_access 

723def import_upload() -> ResponseReturnValue: 

724 uid = _current_user_id() 

725 

726 if request.method == "GET": 

727 return render_template("pilots/import_upload.html") 

728 

729 # ── POST: receive file, parse, present mapping page ─────────────────────── 

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

731 if not uploaded or not uploaded.filename: 

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

733 return render_template("pilots/import_upload.html"), 422 

734 

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

736 if ext not in _ALLOWED_IMPORT_EXTS: 

737 flash( 

738 _("Unsupported format. Please upload a .csv or .xlsx file."), 

739 "danger", 

740 ) 

741 return render_template("pilots/import_upload.html"), 422 

742 

743 data = uploaded.read() 

744 if len(data) > _MAX_IMPORT_BYTES: 

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

746 return render_template("pilots/import_upload.html"), 422 

747 

748 try: 

749 parsed = parse_file(data, uploaded.filename) 

750 except ValueError as exc: 

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

752 return render_template("pilots/import_upload.html"), 422 

753 

754 # Save to a temp file so execute step can re-parse without re-upload 

755 _cleanup_previous_tmp(uid) 

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

757 tmp_name = f"import_{uid}_{uuid.uuid4().hex}_{safe_base}" 

758 tmp_path = os.path.join(_import_tmp_dir(), tmp_name) 

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

760 fh.write(data) 

761 

762 session[_IMPORT_SESSION_KEY] = { 

763 "uid": uid, 

764 "tmp_path": tmp_path, 

765 "original_filename": uploaded.filename, 

766 "norm_cols": parsed.norm_cols, 

767 "raw_cols": parsed.raw_cols, 

768 "fingerprint": parsed.fingerprint, 

769 } 

770 

771 # Look up saved mappings for this pilot 

772 saved = LogbookImportMapping.query.filter_by(pilot_user_id=uid).all() 

773 proposal = propose_mapping(parsed, saved) 

774 

775 preview = preview_rows(parsed, proposal.mapping, n=5) 

776 

777 return render_template( 

778 "pilots/import_map.html", 

779 norm_cols=parsed.norm_cols, 

780 raw_cols=parsed.raw_cols, 

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

782 mapping=proposal.mapping, 

783 match_type=proposal.match_type, 

784 fuzzy_score=proposal.fuzzy_score, 

785 target_fields=TARGET_FIELDS, 

786 preview=preview, 

787 filename=uploaded.filename, 

788 type_hints=type_hints(parsed, proposal.mapping), 

789 ) 

790 

791 

792@pilots_bp.route("/pilot/logbook/import/execute", methods=["POST"]) 

793@login_required 

794@require_pilot_access 

795def import_execute() -> ResponseReturnValue: 

796 uid = _current_user_id() 

797 meta = session.get(_IMPORT_SESSION_KEY) 

798 

799 if not meta or meta.get("uid") != uid: 

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

801 return redirect(url_for("pilots.import_upload")) 

802 

803 tmp_path: str = meta["tmp_path"] 

804 original_filename: str = meta["original_filename"] 

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

806 fingerprint: str = meta["fingerprint"] 

807 

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

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

810 session.pop(_IMPORT_SESSION_KEY, None) 

811 return redirect(url_for("pilots.import_upload")) 

812 

813 # Reconstruct mapping from form 

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

815 for col in norm_cols: 

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

817 mapping[col] = val if val in TARGET_FIELDS else "ignore" 

818 

819 # Validate: at least 'date' must be mapped 

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

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

822 # Re-render the mapping page 

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

824 data = fh.read() 

825 try: 

826 parsed = parse_file(data, original_filename) 

827 except ValueError: 

828 session.pop(_IMPORT_SESSION_KEY, None) 

829 return redirect(url_for("pilots.import_upload")) 

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

831 return render_template( 

832 "pilots/import_map.html", 

833 norm_cols=parsed.norm_cols, 

834 raw_cols=parsed.raw_cols, 

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

836 mapping=mapping, 

837 match_type="alias", 

838 fuzzy_score=0.0, 

839 target_fields=TARGET_FIELDS, 

840 preview=preview, 

841 filename=original_filename, 

842 type_hints=type_hints(parsed, mapping), 

843 ), 422 

844 

845 # Parse opening balance 

846 opening_balance: dict[str, float | None] = {} 

847 ob_fields = [ 

848 "single_pilot_se", 

849 "single_pilot_me", 

850 "multi_pilot", 

851 "night_time", 

852 "instrument_time", 

853 "function_pic", 

854 "function_copilot", 

855 "function_dual", 

856 "function_instructor", 

857 ] 

858 for f_name in ob_fields: 

859 raw = request.form.get(f"ob_{f_name}", "").strip() 

860 opening_balance[f_name] = parse_duration_value(raw) if raw else None 

861 

862 # Re-parse the temp file 

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

864 data = fh.read() 

865 try: 

866 parsed = parse_file(data, original_filename) 

867 except ValueError as exc: 

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

869 session.pop(_IMPORT_SESSION_KEY, None) 

870 return redirect(url_for("pilots.import_upload")) 

871 

872 # Create or reuse the mapping record 

873 saved_mappings = LogbookImportMapping.query.filter_by(pilot_user_id=uid).all() 

874 mapping_record: LogbookImportMapping | None = None 

875 for m in saved_mappings: 

876 if m.source_fingerprint == fingerprint: 

877 # Update the saved mapping with the user's potentially-refined choices 

878 m.column_mapping = json.dumps(mapping) 

879 mapping_record = m 

880 break 

881 if mapping_record is None: 

882 mapping_record = LogbookImportMapping( 

883 pilot_user_id=uid, 

884 source_fingerprint=fingerprint, 

885 column_mapping=json.dumps(mapping), 

886 source_columns=json.dumps(norm_cols), 

887 created_at=_datetime.now(_tz.utc), 

888 ) 

889 db.session.add(mapping_record) 

890 db.session.flush() # get mapping_record.id 

891 

892 # Create the batch record (row counts filled in after execute) 

893 batch = LogbookImportBatch( 

894 pilot_user_id=uid, 

895 mapping_id=mapping_record.id, 

896 source_filename=original_filename, 

897 imported_at=_datetime.now(_tz.utc), 

898 ) 

899 db.session.add(batch) 

900 db.session.flush() # get batch.id 

901 

902 result = execute_import( 

903 parsed=parsed, 

904 mapping=mapping, 

905 pilot_user_id=uid, 

906 batch_id=batch.id, 

907 opening_balance=opening_balance if any(opening_balance.values()) else None, 

908 ) 

909 

910 batch.row_count = result.imported 

911 batch.subtotal_count = result.subtotals 

912 batch.skipped_count = len(result.skipped) 

913 batch.has_opening_balance = result.has_opening_balance 

914 

915 db.session.commit() 

916 

917 # Clean up temp file and session 

918 try: 

919 os.remove(tmp_path) 

920 except OSError as exc: 

921 current_app.logger.debug("cleanup tmp import file: %s", exc) 

922 session.pop(_IMPORT_SESSION_KEY, None) 

923 

924 flash( 

925 _( 

926 "Import complete: %(imported)d entries imported, %(subtotals)d subtotal rows " 

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

928 imported=result.imported, 

929 subtotals=result.subtotals, 

930 skipped=len(result.skipped), 

931 ), 

932 "success", 

933 ) 

934 if result.skipped: 

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

936 if len(result.skipped) > 5: 

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

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

939 

940 if result.parse_warnings: 

941 n = len(result.parse_warnings) 

942 examples = "; ".join( 

943 f"row {r}, {target}: {raw}" 

944 for r, _col, target, raw in result.parse_warnings[:3] 

945 ) 

946 if n > 3: 

947 examples += f" … +{n - 3}" 

948 flash( 

949 ngettext( 

950 "One cell value could not be parsed and was imported as blank: %(examples)s", 

951 "%(n)d cell values could not be parsed and were imported as blank: %(examples)s", 

952 n, 

953 n=n, 

954 examples=examples, 

955 ), 

956 "warning", 

957 ) 

958 

959 if result.total_mismatch_warnings: 

960 n = len(result.total_mismatch_warnings) 

961 examples = "; ".join( 

962 _( 

963 "row %(row)d (source %(src).1f h, computed %(comp).1f h)", 

964 row=r, 

965 src=src, 

966 comp=comp, 

967 ) 

968 for r, src, comp in result.total_mismatch_warnings[:3] 

969 ) 

970 if n > 3: 

971 examples += f" … +{n - 3}" 

972 flash( 

973 ngettext( 

974 "One row has a total flight time that doesn't match the sum of its components — please review: %(examples)s", 

975 "%(n)d rows have a total flight time that doesn't match the sum of their components — please review: %(examples)s", 

976 n, 

977 n=n, 

978 examples=examples, 

979 ), 

980 "warning", 

981 ) 

982 

983 return redirect(url_for("pilots.import_history")) 

984 

985 

986@pilots_bp.route("/pilot/logbook/import/history") 

987@login_required 

988@require_pilot_access 

989def import_history() -> ResponseReturnValue: 

990 uid = _current_user_id() 

991 batches = ( 

992 LogbookImportBatch.query.filter_by(pilot_user_id=uid) 

993 .order_by(LogbookImportBatch.imported_at.desc()) 

994 .all() 

995 ) 

996 return render_template("pilots/import_history.html", batches=batches) 

997 

998 

999@pilots_bp.route("/pilot/logbook/import/<int:batch_id>/rollback", methods=["POST"]) 

1000@login_required 

1001@require_pilot_access 

1002def import_rollback(batch_id: int) -> ResponseReturnValue: 

1003 uid = _current_user_id() 

1004 batch = db.session.get(LogbookImportBatch, batch_id) 

1005 if not batch or batch.pilot_user_id != uid: 

1006 abort(404) 

1007 

1008 # Delete all entries belonging to this batch 

1009 PilotLogbookEntry.query.filter_by(import_batch_id=batch_id).delete() 

1010 db.session.delete(batch) 

1011 db.session.commit() 

1012 

1013 flash( 

1014 ngettext( 

1015 "Import deleted: one entry removed.", 

1016 "Import deleted: all %(count)d entries removed.", 

1017 batch.row_count, 

1018 count=batch.row_count, 

1019 ), 

1020 "success", 

1021 ) 

1022 return redirect(url_for("pilots.import_history")) 

1023 

1024 

1025# ── Pilot GPS import (airplane-agnostic batch upload) ──────────────────────── 

1026 

1027_GPS_ALLOWED_EXTS = {".gpx", ".kml", ".csv"} 

1028_GPS_MAX_BYTES = 20 * 1024 * 1024 

1029_BLOCK_TOLERANCE_PILOT = _td(minutes=15) 

1030 

1031 

1032def _pilot_gps_tmp_dir() -> str: 

1033 from aircraft.routes import _gps_tmp_dir # noqa: PLC0415 

1034 

1035 return _gps_tmp_dir() 

1036 

1037 

1038def _pilot_tenant_id(user_id: int) -> int | None: 

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

1040 return tu.tenant_id if tu else None 

1041 

1042 

1043def _pilot_match_segment( 

1044 user_id: int, block_off: "_datetime", block_on: "_datetime" 

1045) -> list["FlightEntry"]: 

1046 """Find FlightEntry records the pilot is associated with that overlap block times.""" 

1047 tol = _BLOCK_TOLERANCE_PILOT 

1048 

1049 crew_flight_ids = db.session.query(FlightCrew.flight_id).filter( 

1050 FlightCrew.user_id == user_id, FlightCrew.flight_id.isnot(None) 

1051 ) 

1052 logbook_flight_ids = db.session.query(PilotLogbookEntry.flight_id).filter( 

1053 PilotLogbookEntry.pilot_user_id == user_id, 

1054 PilotLogbookEntry.flight_id.isnot(None), 

1055 ) 

1056 all_ids = crew_flight_ids.union(logbook_flight_ids).scalar_subquery() 

1057 

1058 return FlightEntry.query.filter( # type: ignore[no-any-return] 

1059 FlightEntry.id.in_(all_ids), 

1060 FlightEntry.block_off_utc.isnot(None), 

1061 FlightEntry.block_on_utc.isnot(None), 

1062 FlightEntry.block_off_utc < block_on + tol, 

1063 FlightEntry.block_on_utc > block_off - tol, 

1064 ).all() 

1065 

1066 

1067def _pilot_seg_match_dict(matches: list[Any]) -> dict[str, Any]: 

1068 """Summarise match results into fields stored on the segment dict.""" 

1069 if not matches: 

1070 return { 

1071 "matched_flight_id": None, 

1072 "matched_flight_str": None, 

1073 "matched_has_existing_track": False, 

1074 "matched_aircraft_id": None, 

1075 "matched_aircraft_reg": None, 

1076 "matched_ambiguous": False, 

1077 "matched_candidates": [], 

1078 } 

1079 candidates = [ 

1080 { 

1081 "id": fe.id, 

1082 "str": f"#{fe.id}{fe.date} {fe.departure_icao}{fe.arrival_icao}", 

1083 "aircraft_id": fe.aircraft_id, 

1084 "aircraft_reg": fe.aircraft.registration if fe.aircraft else "?", 

1085 "has_existing_track": fe.gps_track_id is not None, 

1086 } 

1087 for fe in matches 

1088 ] 

1089 primary = matches[0] 

1090 return { 

1091 "matched_flight_id": primary.id, 

1092 "matched_flight_str": candidates[0]["str"], 

1093 "matched_has_existing_track": primary.gps_track_id is not None, 

1094 "matched_aircraft_id": primary.aircraft_id, 

1095 "matched_aircraft_reg": candidates[0]["aircraft_reg"], 

1096 "matched_ambiguous": len(matches) > 1, 

1097 "matched_candidates": candidates, 

1098 } 

1099 

1100 

1101@pilots_bp.route("/pilot/gps-import", methods=["GET", "POST"]) 

1102@login_required 

1103@require_pilot_access 

1104def pilot_gps_import_upload() -> ResponseReturnValue: 

1105 uid = _current_user_id() 

1106 tenant_id = _pilot_tenant_id(uid) 

1107 tenant_aircraft = ( 

1108 Aircraft.query.filter_by(tenant_id=tenant_id) 

1109 .order_by(Aircraft.registration) 

1110 .all() 

1111 if tenant_id 

1112 else [] 

1113 ) 

1114 

1115 if request.method == "GET": 

1116 return render_template( 

1117 "pilots/gps_import_upload.html", 

1118 tenant_aircraft=tenant_aircraft, 

1119 ) 

1120 

1121 # ── POST: process uploaded files ────────────────────────────────────────── 

1122 mode = request.form.get("mode", "agnostic") # "one_aircraft" | "agnostic" 

1123 files = request.files.getlist("gps_files") 

1124 

1125 if not files or all(f.filename == "" for f in files): 

1126 flash(_("Please select at least one GPS log file."), "warning") 

1127 return render_template( 

1128 "pilots/gps_import_upload.html", tenant_aircraft=tenant_aircraft 

1129 ) 

1130 

1131 from aircraft.gps_import import parse_gps_file # noqa: PLC0415 

1132 

1133 tmp_dir = _pilot_gps_tmp_dir() 

1134 parsed_meta: list[dict[str, Any]] = [] 

1135 errors: list[str] = [] 

1136 skipped_empty = 0 

1137 

1138 for f in files: 

1139 if not f.filename: 

1140 continue # pragma: no cover – Werkzeug never yields empty-filename FileStorage objects 

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

1142 if ext not in _GPS_ALLOWED_EXTS: 

1143 errors.append( 

1144 _( 

1145 "%(fn)s: unsupported file type (use .gpx, .kml, or .csv).", 

1146 fn=f.filename, 

1147 ) 

1148 ) 

1149 continue 

1150 data = f.read(_GPS_MAX_BYTES + 1) 

1151 if len(data) > _GPS_MAX_BYTES: 

1152 errors.append(_("%(fn)s: file too large (20 MB limit).", fn=f.filename)) 

1153 continue 

1154 try: 

1155 parsed = parse_gps_file(data, f.filename) 

1156 except ValueError as exc: 

1157 errors.append(_("%(fn)s: %(err)s", fn=f.filename, err=str(exc))) 

1158 continue 

1159 if parsed.classification == "empty": 

1160 skipped_empty += 1 

1161 continue 

1162 uid_hex = uuid.uuid4().hex 

1163 safe_name = f"{uid_hex}_{secure_filename(f.filename)}" 

1164 tmp_path = os.path.join(tmp_dir, safe_name) 

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

1166 fh.write(data) 

1167 parsed_meta.append( 

1168 { 

1169 "tmp_path": tmp_path, 

1170 "original_filename": f.filename, 

1171 "format": parsed.format, 

1172 "classification": parsed.classification, 

1173 "trkpt_count": len(parsed.trackpoints), 

1174 "hint_dep": parsed.hint_departure_icao, 

1175 "hint_arr": parsed.hint_arrival_icao, 

1176 "device_id": getattr(parsed, "device_id", None), 

1177 } 

1178 ) 

1179 

1180 for e in errors: 

1181 flash(e, "danger") 

1182 if skipped_empty: 

1183 flash( 

1184 ngettext( 

1185 "%(n)s file skipped — no movement detected.", 

1186 "%(n)s files skipped — no movement detected.", 

1187 skipped_empty, 

1188 n=skipped_empty, 

1189 ), 

1190 "info", 

1191 ) 

1192 if not parsed_meta: 

1193 flash(_("No valid GPS files to import."), "warning") 

1194 return render_template( 

1195 "pilots/gps_import_upload.html", tenant_aircraft=tenant_aircraft 

1196 ) 

1197 

1198 if mode == "one_aircraft": 

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

1200 if not aircraft_id: 

1201 flash(_("Please select an aircraft."), "warning") 

1202 return render_template( 

1203 "pilots/gps_import_upload.html", tenant_aircraft=tenant_aircraft 

1204 ) 

1205 session["gps_import"] = { 

1206 "user_id": session["user_id"], 

1207 "aircraft_id": aircraft_id, 

1208 "files": parsed_meta, 

1209 "skipped_empty": skipped_empty, 

1210 "other_aircraft": False, 

1211 "other_ac_make_model": "", 

1212 "other_ac_reg": "", 

1213 } 

1214 session.modified = True 

1215 return redirect(url_for("aircraft.gps_import_review", aircraft_id=aircraft_id)) 

1216 

1217 # agnostic mode 

1218 session["pilot_gps_import"] = { 

1219 "user_id": session["user_id"], 

1220 "files": parsed_meta, 

1221 "skipped_empty": skipped_empty, 

1222 } 

1223 session.modified = True 

1224 return redirect(url_for("pilots.pilot_gps_import_review")) 

1225 

1226 

1227@pilots_bp.route("/pilot/gps-import/review", methods=["GET"]) 

1228@login_required 

1229@require_pilot_access 

1230def pilot_gps_import_review() -> ResponseReturnValue: 

1231 uid = _current_user_id() 

1232 state = session.get("pilot_gps_import") 

1233 if not state: 

1234 flash(_("Session expired — please upload your GPS files again."), "warning") 

1235 return redirect(url_for("pilots.pilot_gps_import_upload")) 

1236 

1237 from aircraft.gps_import import ( # noqa: PLC0415 

1238 detect_segments, 

1239 merge_and_sort, 

1240 parse_gps_file, 

1241 ) 

1242 from aircraft.routes import ( # noqa: PLC0415 

1243 _linked_pilot_entries, 

1244 _segment_for_session, 

1245 _segment_to_dict, 

1246 _gps_tmp_dir, 

1247 ) 

1248 

1249 file_metas = state["files"] 

1250 all_parsed = [] 

1251 for meta in file_metas: 

1252 try: 

1253 with open(meta["tmp_path"], "rb") as fh: 

1254 data = fh.read() 

1255 parsed = parse_gps_file(data, meta["original_filename"]) 

1256 parsed.hint_departure_icao = meta.get("hint_dep") 

1257 parsed.hint_arrival_icao = meta.get("hint_arr") 

1258 all_parsed.append(parsed) 

1259 except (OSError, ValueError): 

1260 flash( 

1261 _( 

1262 "Could not read %(fn)s — please upload again.", 

1263 fn=meta["original_filename"], 

1264 ), 

1265 "warning", 

1266 ) 

1267 return redirect(url_for("pilots.pilot_gps_import_upload")) 

1268 

1269 merged = merge_and_sort(all_parsed) 

1270 hint_dep = next( 

1271 (p.hint_departure_icao for p in all_parsed if p.hint_departure_icao), None 

1272 ) 

1273 hint_arr = next( 

1274 (p.hint_arrival_icao for p in all_parsed if p.hint_arrival_icao), None 

1275 ) 

1276 segments = detect_segments(merged, hint_dep=hint_dep, hint_arr=hint_arr) 

1277 full_segs = [_segment_to_dict(seg, i) for i, seg in enumerate(segments)] 

1278 

1279 for seg in full_segs: 

1280 block_off = _datetime.fromisoformat(seg["block_off_utc"]) 

1281 block_on = _datetime.fromisoformat(seg["block_on_utc"]) 

1282 matches = _pilot_match_segment(uid, block_off, block_on) 

1283 seg.update(_pilot_seg_match_dict(matches)) 

1284 if seg.get("matched_flight_id") and not seg.get("matched_ambiguous"): 

1285 seg["linked_pilot_entries"] = _linked_pilot_entries( 

1286 seg["matched_flight_id"], uid 

1287 ) 

1288 else: 

1289 seg["linked_pilot_entries"] = [] 

1290 

1291 tmp_dir = _gps_tmp_dir() 

1292 state["segments"] = [_segment_for_session(s, tmp_dir) for s in full_segs] 

1293 session["pilot_gps_import"] = state 

1294 session.modified = True 

1295 

1296 tenant_id = _pilot_tenant_id(uid) 

1297 tenant_aircraft = ( 

1298 Aircraft.query.filter_by(tenant_id=tenant_id) 

1299 .order_by(Aircraft.registration) 

1300 .all() 

1301 if tenant_id 

1302 else [] 

1303 ) 

1304 

1305 from models import AppSetting # noqa: PLC0415 # pyright: ignore[reportMissingImports] 

1306 

1307 tile_setting = db.session.get(AppSetting, "openaip_api_key") 

1308 openaip_key = tile_setting.value if tile_setting and tile_setting.value else None 

1309 

1310 return render_template( 

1311 "pilots/gps_import_review.html", 

1312 segments=full_segs, 

1313 skipped_empty=state.get("skipped_empty", 0), 

1314 confirmed_segments=state.get("confirmed_segments", {}), 

1315 tenant_aircraft=tenant_aircraft, 

1316 openaip_key=openaip_key, 

1317 ) 

1318 

1319 

1320@pilots_bp.route("/pilot/gps-import/confirm-one", methods=["POST"]) 

1321@login_required 

1322@require_pilot_access 

1323def pilot_gps_import_confirm_one() -> ResponseReturnValue: 

1324 import decimal as _dec # noqa: PLC0415 

1325 from aircraft.routes import _load_segment_geojson, _gps_cleanup # noqa: PLC0415 

1326 from aircraft.gps_import import round_flight_time # noqa: PLC0415 

1327 

1328 uid = _current_user_id() 

1329 state = session.get("pilot_gps_import") 

1330 if not state: 

1331 flash(_("Session expired — please upload your GPS files again."), "warning") 

1332 return redirect(url_for("pilots.pilot_gps_import_upload")) 

1333 

1334 segments_data: list[dict[str, Any]] = state.get("segments", []) 

1335 if not segments_data: 

1336 flash(_("No segments to import."), "warning") 

1337 return redirect(url_for("pilots.pilot_gps_import_upload")) 

1338 

1339 try: 

1340 seg_idx = int(request.form.get("seg_idx", "")) 

1341 except (ValueError, TypeError): 

1342 flash(_("Invalid segment index."), "danger") 

1343 return redirect(url_for("pilots.pilot_gps_import_review")) 

1344 

1345 if seg_idx < 0 or seg_idx >= len(segments_data): 

1346 flash(_("Invalid segment index."), "danger") 

1347 return redirect(url_for("pilots.pilot_gps_import_review")) 

1348 

1349 confirmed = state.get("confirmed_segments", {}) 

1350 if str(seg_idx) in confirmed: 

1351 flash(_("This segment has already been confirmed."), "info") 

1352 return redirect(url_for("pilots.pilot_gps_import_review")) 

1353 

1354 pilot_role = request.form.get("pilot_role", "pic") 

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

1356 pilot_role = "pic" 

1357 

1358 # ── Skip ───────────────────────────────────────────────────────────────── 

1359 if request.form.get("skip") == "1": 

1360 confirmed[str(seg_idx)] = "skip" 

1361 state["confirmed_segments"] = confirmed 

1362 session["pilot_gps_import"] = state 

1363 session.modified = True 

1364 if len(confirmed) == len(segments_data): 

1365 _gps_cleanup(state) 

1366 session.pop("pilot_gps_import", None) 

1367 imported = sum(1 for v in confirmed.values() if v != "skip") 

1368 skipped_count = len(segments_data) - imported 

1369 if imported > 0: 

1370 flash( 

1371 ngettext( 

1372 "%(n)s flight imported successfully.", 

1373 "%(n)s flights imported successfully.", 

1374 imported, 

1375 n=imported, 

1376 ), 

1377 "success", 

1378 ) 

1379 flash( 

1380 ngettext( 

1381 "%(n)s segment skipped.", 

1382 "%(n)s segments skipped.", 

1383 skipped_count, 

1384 n=skipped_count, 

1385 ), 

1386 "info", 

1387 ) 

1388 if imported > 0 and pilot_role in ("pic", "dual"): 

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

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

1391 flash(_("Segment skipped."), "info") 

1392 return redirect(url_for("pilots.pilot_gps_import_review")) 

1393 

1394 # ── Confirm ─────────────────────────────────────────────────────────────── 

1395 seg = segments_data[seg_idx] 

1396 file_metas = state.get("files", []) 

1397 block_off = _datetime.fromisoformat(seg["block_off_utc"]) 

1398 block_on = _datetime.fromisoformat(seg["block_on_utc"]) 

1399 

1400 dep_icao = ( 

1401 request.form.get("dep_icao") or seg.get("departure_icao") or "" 

1402 ).strip().upper()[:4] or "????" 

1403 arr_icao = ( 

1404 request.form.get("arr_icao") or seg.get("arrival_icao") or "" 

1405 ).strip().upper()[:4] or "????" 

1406 nature = (request.form.get("nature") or "").strip()[:100] or None 

1407 remarks = (request.form.get("remarks") or "").strip() or None 

1408 

1409 geojson = _load_segment_geojson(seg) 

1410 source_filename = ( 

1411 file_metas[0]["original_filename"] if len(file_metas) == 1 else None 

1412 ) 

1413 device_id = next( 

1414 (m.get("device_id") for m in file_metas if m.get("device_id")), None 

1415 ) 

1416 

1417 create_pilot_entry = pilot_role in ("pic", "dual") 

1418 matched_flight_id = seg.get("matched_flight_id") 

1419 entry: FlightEntry | None = None 

1420 gps_track: GpsTrack | None = None 

1421 ac: Aircraft | None = None 

1422 

1423 if matched_flight_id: 

1424 # Link GPS track to the existing matched FlightEntry 

1425 existing = db.session.get(FlightEntry, matched_flight_id) 

1426 if existing: 

1427 gps_track = GpsTrack( 

1428 source_filename=source_filename, 

1429 device_id=device_id, 

1430 block_off_utc=block_off, 

1431 block_on_utc=block_on, 

1432 departure_icao=dep_icao, 

1433 arrival_icao=arr_icao, 

1434 geojson=geojson, 

1435 ) 

1436 db.session.add(gps_track) 

1437 db.session.flush() 

1438 existing.gps_track_id = gps_track.id 

1439 existing.block_off_utc = block_off 

1440 existing.block_on_utc = block_on 

1441 # Link track to other users' pilot logbook entries for this flight 

1442 # (only when they have no existing GPS track — preserve their own data). 

1443 for ple in PilotLogbookEntry.query.filter( 

1444 PilotLogbookEntry.flight_id == existing.id, 

1445 PilotLogbookEntry.pilot_user_id != uid, 

1446 PilotLogbookEntry.gps_track_id.is_(None), 

1447 ).all(): 

1448 ple.gps_track_id = gps_track.id 

1449 db.session.flush() 

1450 entry = existing 

1451 else: 

1452 matched_flight_id = None # stale — fall through to new entry 

1453 

1454 if not matched_flight_id: 

1455 resolution = request.form.get("resolution", "other_aircraft") 

1456 

1457 if resolution == "managed_aircraft": 

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

1459 if aircraft_id: 

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

1461 

1462 if ac: 

1463 # Create a new FlightEntry for the managed aircraft 

1464 gps_track = GpsTrack( 

1465 source_filename=source_filename, 

1466 device_id=device_id, 

1467 block_off_utc=block_off, 

1468 block_on_utc=block_on, 

1469 departure_icao=dep_icao, 

1470 arrival_icao=arr_icao, 

1471 geojson=geojson, 

1472 ) 

1473 db.session.add(gps_track) 

1474 db.session.flush() 

1475 flight_time_h = round_flight_time( 

1476 seg.get("flight_time_raw_h", 0), 

1477 getattr(ac, "logbook_time_precision", "tenth_hour"), 

1478 ) 

1479 entry = FlightEntry( 

1480 aircraft_id=ac.id, 

1481 date=block_off.date(), 

1482 departure_icao=dep_icao, 

1483 arrival_icao=arr_icao, 

1484 departure_time=block_off.time().replace(tzinfo=None), 

1485 arrival_time=block_on.time().replace(tzinfo=None), 

1486 flight_time=_dec.Decimal(str(flight_time_h)), 

1487 landing_count=seg.get("landing_count") or 0, 

1488 nature_of_flight=nature, 

1489 source="gps_import", 

1490 block_off_utc=block_off, 

1491 block_on_utc=block_on, 

1492 gps_track_id=gps_track.id, 

1493 ) 

1494 db.session.add(entry) 

1495 db.session.flush() 

1496 else: 

1497 # Other / external aircraft — pilot-only, no FlightEntry 

1498 if geojson: 

1499 gps_track = GpsTrack( 

1500 source_filename=source_filename, 

1501 device_id=device_id, 

1502 block_off_utc=block_off, 

1503 block_on_utc=block_on, 

1504 departure_icao=dep_icao, 

1505 arrival_icao=arr_icao, 

1506 geojson=geojson, 

1507 ) 

1508 db.session.add(gps_track) 

1509 db.session.flush() 

1510 create_pilot_entry = ( 

1511 True # always create logbook entry for external aircraft 

1512 ) 

1513 

1514 if create_pilot_entry: 

1515 ac_type: str | None = None 

1516 ac_reg: str | None = None 

1517 ac_cat: str = "SEP" 

1518 if ac: 

1519 ac_type = f"{ac.make} {ac.model}".strip() 

1520 ac_reg = ac.registration 

1521 ac_cat = getattr(ac, "category", "SEP") 

1522 elif entry and entry.aircraft: 

1523 _rel_ac = entry.aircraft 

1524 ac_type = f"{_rel_ac.make} {_rel_ac.model}".strip() 

1525 ac_reg = _rel_ac.registration 

1526 ac_cat = getattr(_rel_ac, "category", "SEP") 

1527 else: 

1528 other_reg = (request.form.get("other_reg") or "").strip().upper() 

1529 other_mm = (request.form.get("other_make_model") or "").strip() 

1530 ac_type = other_mm or None 

1531 ac_reg = other_reg or None 

1532 ac_cat = "SEP" 

1533 

1534 flight_time_h = round_flight_time(seg.get("flight_time_raw_h", 0), "tenth_hour") 

1535 single_pilot_se = ( 

1536 _dec.Decimal(str(flight_time_h)) if ac_cat in ("SEP", "SET", "") else None 

1537 ) 

1538 single_pilot_me = ( 

1539 _dec.Decimal(str(flight_time_h)) if ac_cat in ("MEP", "MET") else None 

1540 ) 

1541 

1542 from models import User as _User # noqa: PLC0415 # pyright: ignore[reportMissingImports] 

1543 

1544 _pilot_user = db.session.get(_User, uid) 

1545 pilot_display_name = _pilot_user.display_name if _pilot_user else "" 

1546 

1547 pentry = PilotLogbookEntry( 

1548 pilot_user_id=uid, 

1549 flight_id=entry.id if entry else None, 

1550 date=block_off.date(), 

1551 aircraft_type=ac_type, 

1552 aircraft_registration=ac_reg, 

1553 departure_place=dep_icao, 

1554 departure_time=block_off.time().replace(tzinfo=None), 

1555 arrival_place=arr_icao, 

1556 arrival_time=block_on.time().replace(tzinfo=None), 

1557 pic_name=pilot_display_name, 

1558 single_pilot_se=single_pilot_se, 

1559 single_pilot_me=single_pilot_me, 

1560 function_pic=_dec.Decimal(str(flight_time_h)) 

1561 if pilot_role == "pic" 

1562 else None, 

1563 function_dual=_dec.Decimal(str(flight_time_h)) 

1564 if pilot_role == "dual" 

1565 else None, 

1566 landings_day=seg.get("landing_count") or 0, 

1567 remarks=remarks, 

1568 source="gps_import", 

1569 gps_track_id=gps_track.id if gps_track else None, 

1570 ) 

1571 db.session.add(pentry) 

1572 

1573 db.session.commit() 

1574 

1575 confirmed[str(seg_idx)] = entry.id if entry else 0 

1576 state["confirmed_segments"] = confirmed 

1577 session["pilot_gps_import"] = state 

1578 session.modified = True 

1579 

1580 all_handled = len(confirmed) == len(segments_data) 

1581 if all_handled: 

1582 _gps_cleanup(state) 

1583 session.pop("pilot_gps_import", None) 

1584 total = sum(1 for v in confirmed.values() if v != "skip") 

1585 flash( 

1586 ngettext( 

1587 "%(n)s flight imported successfully.", 

1588 "%(n)s flights imported successfully.", 

1589 total, 

1590 n=total, 

1591 ), 

1592 "success", 

1593 ) 

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

1595 

1596 flash(_("Flight confirmed."), "success") 

1597 return redirect(url_for("pilots.pilot_gps_import_review"))