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

1202 statements  

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

1import contextlib 

2import json 

3import logging 

4import math 

5import os 

6import uuid 

7from datetime import ( 

8 UTC, 

9) 

10from datetime import ( 

11 date as _date, 

12) 

13from datetime import ( 

14 datetime as _datetime, 

15) 

16from datetime import ( 

17 timedelta as _td, 

18) 

19from typing import Any 

20 

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

22 Blueprint, 

23 abort, 

24 current_app, 

25 flash, 

26 redirect, 

27 render_template, 

28 request, 

29 session, 

30 url_for, 

31) 

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

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

34from flask_babel import ngettext 

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

36 Aircraft, 

37 Document, 

38 Flight, 

39 FstdType, 

40 GpsTrack, 

41 LogbookEntryType, 

42 LogbookImportBatch, 

43 LogbookImportMapping, 

44 PersonalMinimumsItem, 

45 PersonalMinimumsRevision, 

46 PersonalMinimumsSection, 

47 PersonalMinimumsStatus, 

48 PersonalMinimumsTag, 

49 PilotProfile, 

50 Reservation, 

51 ReservationStatus, 

52 TenantUser, 

53 db, 

54) 

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

56from utils import ( # pyright: ignore[reportMissingImports] 

57 login_required, 

58 require_pilot_access, 

59 user_can_access_aircraft, 

60) 

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

62 

63from pilots.form_parsing import ( # pyright: ignore[reportMissingImports] 

64 _parse_date, 

65 apply_pilot_fields, 

66 parse_pilot_fields, 

67) 

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

69 TARGET_FIELDS, 

70 ConflictRow, 

71 _assign_pilot_identity, 

72 _norm, 

73 execute_import, 

74 find_conflicting_rows, 

75 link_entries_to_aircraft, 

76 parse_duration_value, 

77 parse_file, 

78 preview_rows, 

79 propose_mapping, 

80 type_hints, 

81) 

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

83 STARTERS, 

84 get_active_revision, 

85 recency_breaches, 

86) 

87 

88log = logging.getLogger(__name__) 

89 

90pilots_bp = Blueprint("pilots", __name__) 

91 

92 

93def _current_user_id() -> int: 

94 return int(session["user_id"]) 

95 

96 

97def _openaip_key() -> str | None: 

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

99 

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

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

102 

103 

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

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

106 if not profile: 

107 profile = PilotProfile(user_id=user_id) 

108 db.session.add(profile) 

109 db.session.flush() 

110 return profile 

111 

112 

113def _my_entries_query(uid: int): # type: ignore[no-untyped-def] 

114 """Base query for `Flight` rows this pilot occupies (either identity 

115 slot) — the "my logbook" query, used throughout this module.""" 

116 from sqlalchemy import or_ # pyright: ignore[reportMissingImports] 

117 

118 return Flight.query.filter( 

119 or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid) 

120 ) 

121 

122 

123def _check_logbook_milestone(entry: Flight, uid: int) -> None: 

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

125 from sqlalchemy import or_ # pyright: ignore[reportMissingImports] 

126 

127 mine = or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid) 

128 total = Flight.query.filter(mine).count() 

129 if total == 100: 

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

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

132 return 

133 

134 night = float(entry.night_time or 0) 

135 if night > 0: 

136 prev_night = ( 

137 db.session.query(func.count(Flight.id)) 

138 .filter(mine, Flight.id != entry.id, Flight.night_time > 0) 

139 .scalar() 

140 or 0 

141 ) 

142 if prev_night == 0: 

143 session["logbook_milestone"] = "first_night" 

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

145 return 

146 

147 dep = (entry.departure_icao or "").strip().upper() 

148 arr = (entry.arrival_icao or "").strip().upper() 

149 if dep and arr and dep != arr: 

150 prev_xc = ( 

151 db.session.query(func.count(Flight.id)) 

152 .filter( 

153 mine, 

154 Flight.id != entry.id, 

155 Flight.departure_icao.isnot(None), 

156 Flight.arrival_icao.isnot(None), 

157 Flight.departure_icao != Flight.arrival_icao, 

158 ) 

159 .scalar() 

160 or 0 

161 ) 

162 if prev_xc == 0: 

163 session["logbook_milestone"] = "first_xc" 

164 flash( 

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

166 ) 

167 

168 

169# ── Profile ─────────────────────────────────────────────────────────────────── 

170 

171 

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

173@login_required 

174@require_pilot_access 

175def profile() -> ResponseReturnValue: 

176 uid = _current_user_id() 

177 p = _get_or_create_profile(uid) 

178 

179 if request.method == "POST": 

180 errors = [] 

181 

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

183 

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

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

186 if err: 

187 errors.append(err) 

188 else: 

189 p.medical_expiry = medical 

190 

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

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

193 if err: 

194 errors.append(err) 

195 else: 

196 p.sep_expiry = sep 

197 

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

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

200 if err: 

201 errors.append(err) 

202 else: 

203 p.first_solo_date = solo 

204 

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

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

207 if err: 

208 errors.append(err) 

209 else: 

210 p.ppl_issue_date = ppl 

211 

212 if errors: 

213 for e in errors: 

214 flash(e, "danger") 

215 return ( 

216 render_template( 

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

218 ), 

219 422, 

220 ) 

221 

222 db.session.commit() 

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

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

225 

226 from pilots.currency import ( 

227 currency_summary as _currency_summary, # pyright: ignore[reportMissingImports] 

228 ) 

229 

230 pilot_entries = _my_entries_query(uid).all() 

231 currency = _currency_summary(p, pilot_entries) 

232 

233 pilot_docs = ( 

234 Document.query.filter_by(pilot_user_id=uid) 

235 .order_by(Document.uploaded_at.desc()) 

236 .all() 

237 ) 

238 return render_template( 

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

240 ) 

241 

242 

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

244_DEFAULT_PER_PAGE = 20 

245 

246 

247# ── Personal minimums ───────────────────────────────────────────────────────── 

248 

249 

250def _active_minimums_revision(uid: int) -> PersonalMinimumsRevision | None: 

251 return get_active_revision(uid) 

252 

253 

254def _draft_minimums_revision(uid: int) -> PersonalMinimumsRevision | None: 

255 revision: PersonalMinimumsRevision | None = ( 

256 PersonalMinimumsRevision.query.filter_by( 

257 user_id=uid, status=PersonalMinimumsStatus.DRAFT 

258 ).first() 

259 ) 

260 return revision 

261 

262 

263def _get_own_revision_or_404(revision_id: int, uid: int) -> PersonalMinimumsRevision: 

264 revision = db.session.get(PersonalMinimumsRevision, revision_id) 

265 if revision is None or revision.user_id != uid: 

266 abort(404) 

267 return revision 

268 

269 

270def _get_own_draft_section_or_404(section_id: int, uid: int) -> PersonalMinimumsSection: 

271 section = db.session.get(PersonalMinimumsSection, section_id) 

272 if ( 

273 section is None 

274 or section.revision.user_id != uid 

275 or section.revision.status != PersonalMinimumsStatus.DRAFT 

276 ): 

277 abort(404) 

278 return section 

279 

280 

281def _get_own_draft_item_or_404(item_id: int, uid: int) -> PersonalMinimumsItem: 

282 item = db.session.get(PersonalMinimumsItem, item_id) 

283 if ( 

284 item is None 

285 or item.section.revision.user_id != uid 

286 or item.section.revision.status != PersonalMinimumsStatus.DRAFT 

287 ): 

288 abort(404) 

289 return item 

290 

291 

292def _flew_or_reserved_today(uid: int) -> bool: 

293 today = _date.today() 

294 if _my_entries_query(uid).filter(Flight.date == today).first(): 

295 return True 

296 reservation = Reservation.query.filter( 

297 Reservation.pilot_user_id == uid, 

298 Reservation.status.in_( 

299 [ReservationStatus.PENDING, ReservationStatus.CONFIRMED] 

300 ), 

301 db.func.date(Reservation.start_dt) <= today, 

302 db.func.date(Reservation.end_dt) >= today, 

303 ).first() 

304 return reservation is not None 

305 

306 

307@pilots_bp.route("/pilot/minimums") 

308@login_required 

309@require_pilot_access 

310def minimums_view() -> ResponseReturnValue: 

311 uid = _current_user_id() 

312 active = _active_minimums_revision(uid) 

313 if active is not None: 

314 return render_template( 

315 "pilots/minimums_view.html", revision=active, is_own_draft=False 

316 ) 

317 if _draft_minimums_revision(uid) is not None: 

318 return redirect(url_for("pilots.minimums_edit")) 

319 return render_template("pilots/minimums_start.html") 

320 

321 

322@pilots_bp.route("/pilot/minimums/create", methods=["POST"]) 

323@login_required 

324@require_pilot_access 

325def minimums_create() -> ResponseReturnValue: 

326 uid = _current_user_id() 

327 if _draft_minimums_revision(uid) is not None: 

328 flash(_("You already have a draft in progress."), "warning") 

329 return redirect(url_for("pilots.minimums_edit")) 

330 starter = request.form.get("starter", "blank") 

331 if starter not in ("blank", "light", "full"): 

332 starter = "blank" 

333 next_number = ( 

334 db.session.query(db.func.max(PersonalMinimumsRevision.revision_number)) 

335 .filter_by(user_id=uid) 

336 .scalar() 

337 or 0 

338 ) + 1 

339 revision = PersonalMinimumsRevision( 

340 user_id=uid, 

341 revision_number=next_number, 

342 status=PersonalMinimumsStatus.DRAFT, 

343 ) 

344 db.session.add(revision) 

345 db.session.flush() 

346 if starter in STARTERS: 

347 for s_order, (title, items) in enumerate(STARTERS[starter]): 

348 section = PersonalMinimumsSection( 

349 revision_id=revision.id, title=str(title), sort_order=s_order 

350 ) 

351 db.session.add(section) 

352 db.session.flush() 

353 for i_order, (label, tag) in enumerate(items): 

354 db.session.add( 

355 PersonalMinimumsItem( 

356 section_id=section.id, 

357 label=str(label), 

358 value="", 

359 semantic_tag=tag, 

360 sort_order=i_order, 

361 ) 

362 ) 

363 db.session.commit() 

364 flash(_("Draft created."), "success") 

365 return redirect(url_for("pilots.minimums_edit")) 

366 

367 

368@pilots_bp.route("/pilot/minimums/history") 

369@login_required 

370@require_pilot_access 

371def minimums_history() -> ResponseReturnValue: 

372 uid = _current_user_id() 

373 revisions = ( 

374 PersonalMinimumsRevision.query.filter_by(user_id=uid) 

375 .order_by(PersonalMinimumsRevision.revision_number.desc()) 

376 .all() 

377 ) 

378 return render_template("pilots/minimums_history.html", revisions=revisions) 

379 

380 

381@pilots_bp.route("/pilot/minimums/revision/<int:revision_id>") 

382@login_required 

383@require_pilot_access 

384def minimums_revision_detail(revision_id: int) -> ResponseReturnValue: 

385 uid = _current_user_id() 

386 revision = _get_own_revision_or_404(revision_id, uid) 

387 return render_template( 

388 "pilots/minimums_view.html", 

389 revision=revision, 

390 is_own_draft=(revision.status == PersonalMinimumsStatus.DRAFT), 

391 ) 

392 

393 

394@pilots_bp.route("/pilot/minimums/revise", methods=["POST"]) 

395@login_required 

396@require_pilot_access 

397def minimums_revise() -> ResponseReturnValue: 

398 uid = _current_user_id() 

399 active = _active_minimums_revision(uid) 

400 if active is None: 

401 flash(_("No active revision to revise yet."), "danger") 

402 return redirect(url_for("pilots.minimums_view")) 

403 if _draft_minimums_revision(uid) is not None: 

404 flash(_("You already have a draft in progress."), "warning") 

405 return redirect(url_for("pilots.minimums_edit")) 

406 next_number = ( 

407 db.session.query(db.func.max(PersonalMinimumsRevision.revision_number)) 

408 .filter_by(user_id=uid) 

409 .scalar() 

410 or 0 

411 ) + 1 

412 draft = PersonalMinimumsRevision( 

413 user_id=uid, 

414 revision_number=next_number, 

415 status=PersonalMinimumsStatus.DRAFT, 

416 ) 

417 db.session.add(draft) 

418 db.session.flush() 

419 for section in active.sections: # type: ignore[attr-defined] 

420 new_section = PersonalMinimumsSection( 

421 revision_id=draft.id, title=section.title, sort_order=section.sort_order 

422 ) 

423 db.session.add(new_section) 

424 db.session.flush() 

425 for item in section.items: 

426 db.session.add( 

427 PersonalMinimumsItem( 

428 section_id=new_section.id, 

429 label=item.label, 

430 value=item.value, 

431 semantic_tag=item.semantic_tag, 

432 numeric_value=item.numeric_value, 

433 sort_order=item.sort_order, 

434 ) 

435 ) 

436 db.session.commit() 

437 flash(_("Draft created from your active revision."), "success") 

438 return redirect(url_for("pilots.minimums_edit")) 

439 

440 

441@pilots_bp.route("/pilot/minimums/edit") 

442@login_required 

443@require_pilot_access 

444def minimums_edit() -> ResponseReturnValue: 

445 uid = _current_user_id() 

446 draft = _draft_minimums_revision(uid) 

447 if draft is None: 

448 return redirect(url_for("pilots.minimums_view")) 

449 return render_template( 

450 "pilots/minimums_edit.html", revision=draft, tags=PersonalMinimumsTag 

451 ) 

452 

453 

454def _validate_tag_and_numeric( 

455 tag_raw: str, numeric_raw: str 

456) -> tuple[str | None, float | None, str | None]: 

457 """Return (tag_or_None, numeric_value_or_None, error_or_None).""" 

458 tag = tag_raw or None 

459 if tag and tag not in PersonalMinimumsTag.ALL: 

460 return None, None, str(_("Unrecognized tag.")) 

461 if tag: 

462 try: 

463 numeric_value = float(numeric_raw) 

464 if not math.isfinite(numeric_value): 

465 raise ValueError 

466 except ValueError: 

467 return None, None, str(_("This tag requires a numeric value.")) 

468 return tag, numeric_value, None 

469 return None, None, None 

470 

471 

472@pilots_bp.route("/pilot/minimums/section/add", methods=["POST"]) 

473@login_required 

474@require_pilot_access 

475def minimums_section_add() -> ResponseReturnValue: 

476 uid = _current_user_id() 

477 draft = _draft_minimums_revision(uid) 

478 if draft is None: 

479 abort(404) 

480 title = request.form.get("title", "").strip() 

481 if not title: 

482 flash(_("Section title is required."), "danger") 

483 return redirect(url_for("pilots.minimums_edit")) 

484 max_order = ( 

485 db.session.query(db.func.max(PersonalMinimumsSection.sort_order)) 

486 .filter_by(revision_id=draft.id) 

487 .scalar() 

488 ) 

489 next_order = 0 if max_order is None else max_order + 1 

490 db.session.add( 

491 PersonalMinimumsSection( 

492 revision_id=draft.id, title=title, sort_order=next_order 

493 ) 

494 ) 

495 db.session.commit() 

496 flash(_("Section added."), "success") 

497 return redirect(url_for("pilots.minimums_edit")) 

498 

499 

500@pilots_bp.route("/pilot/minimums/section/<int:section_id>/edit", methods=["POST"]) 

501@login_required 

502@require_pilot_access 

503def minimums_section_edit(section_id: int) -> ResponseReturnValue: 

504 uid = _current_user_id() 

505 section = _get_own_draft_section_or_404(section_id, uid) 

506 title = request.form.get("title", "").strip() 

507 if not title: 

508 flash(_("Section title is required."), "danger") 

509 return redirect(url_for("pilots.minimums_edit")) 

510 section.title = title 

511 db.session.commit() 

512 flash(_("Section updated."), "success") 

513 return redirect(url_for("pilots.minimums_edit")) 

514 

515 

516@pilots_bp.route("/pilot/minimums/section/<int:section_id>/delete", methods=["POST"]) 

517@login_required 

518@require_pilot_access 

519def minimums_section_delete(section_id: int) -> ResponseReturnValue: 

520 uid = _current_user_id() 

521 section = _get_own_draft_section_or_404(section_id, uid) 

522 db.session.delete(section) 

523 db.session.commit() 

524 flash(_("Section removed."), "success") 

525 return redirect(url_for("pilots.minimums_edit")) 

526 

527 

528def _move_sort_order( 

529 model: Any, obj: Any, scope_field: str, scope_value: int, direction: str 

530) -> None: 

531 """Swap obj.sort_order with its neighbour within the given scope.""" 

532 siblings = ( 

533 model.query.filter_by(**{scope_field: scope_value}) 

534 .order_by(model.sort_order) 

535 .all() 

536 ) 

537 idx = next(i for i, s in enumerate(siblings) if s.id == obj.id) 

538 neighbour_idx = idx - 1 if direction == "up" else idx + 1 

539 if neighbour_idx < 0 or neighbour_idx >= len(siblings): 

540 return 

541 neighbour = siblings[neighbour_idx] 

542 obj.sort_order, neighbour.sort_order = neighbour.sort_order, obj.sort_order 

543 

544 

545@pilots_bp.route("/pilot/minimums/section/<int:section_id>/move-up", methods=["POST"]) 

546@login_required 

547@require_pilot_access 

548def minimums_section_move_up(section_id: int) -> ResponseReturnValue: 

549 uid = _current_user_id() 

550 section = _get_own_draft_section_or_404(section_id, uid) 

551 _move_sort_order( 

552 PersonalMinimumsSection, section, "revision_id", section.revision_id, "up" 

553 ) 

554 db.session.commit() 

555 return redirect(url_for("pilots.minimums_edit")) 

556 

557 

558@pilots_bp.route("/pilot/minimums/section/<int:section_id>/move-down", methods=["POST"]) 

559@login_required 

560@require_pilot_access 

561def minimums_section_move_down(section_id: int) -> ResponseReturnValue: 

562 uid = _current_user_id() 

563 section = _get_own_draft_section_or_404(section_id, uid) 

564 _move_sort_order( 

565 PersonalMinimumsSection, section, "revision_id", section.revision_id, "down" 

566 ) 

567 db.session.commit() 

568 return redirect(url_for("pilots.minimums_edit")) 

569 

570 

571@pilots_bp.route("/pilot/minimums/item/add", methods=["POST"]) 

572@login_required 

573@require_pilot_access 

574def minimums_item_add() -> ResponseReturnValue: 

575 uid = _current_user_id() 

576 section_id = request.form.get("section_id", type=int) 

577 section = _get_own_draft_section_or_404(section_id, uid) if section_id else None 

578 if section is None: 

579 abort(404) 

580 label = request.form.get("label", "").strip() 

581 value = request.form.get("value", "").strip() 

582 tag_raw = request.form.get("tag", "").strip() 

583 numeric_raw = request.form.get("numeric_value", "").strip() 

584 if not label: 

585 flash(_("Item label is required."), "danger") 

586 return redirect(url_for("pilots.minimums_edit")) 

587 tag, numeric_value, error = _validate_tag_and_numeric(tag_raw, numeric_raw) 

588 if error: 

589 flash(error, "danger") 

590 return redirect(url_for("pilots.minimums_edit")) 

591 max_order = ( 

592 db.session.query(db.func.max(PersonalMinimumsItem.sort_order)) 

593 .filter_by(section_id=section.id) 

594 .scalar() 

595 ) 

596 next_order = 0 if max_order is None else max_order + 1 

597 db.session.add( 

598 PersonalMinimumsItem( 

599 section_id=section.id, 

600 label=label, 

601 value=value or None, 

602 semantic_tag=tag, 

603 numeric_value=numeric_value, 

604 sort_order=next_order, 

605 ) 

606 ) 

607 db.session.commit() 

608 flash(_("Item added."), "success") 

609 return redirect(url_for("pilots.minimums_edit")) 

610 

611 

612@pilots_bp.route("/pilot/minimums/item/<int:item_id>/edit", methods=["POST"]) 

613@login_required 

614@require_pilot_access 

615def minimums_item_edit(item_id: int) -> ResponseReturnValue: 

616 uid = _current_user_id() 

617 item = _get_own_draft_item_or_404(item_id, uid) 

618 label = request.form.get("label", "").strip() 

619 value = request.form.get("value", "").strip() 

620 tag_raw = request.form.get("tag", "").strip() 

621 numeric_raw = request.form.get("numeric_value", "").strip() 

622 if not label: 

623 flash(_("Item label is required."), "danger") 

624 return redirect(url_for("pilots.minimums_edit")) 

625 tag, numeric_value, error = _validate_tag_and_numeric(tag_raw, numeric_raw) 

626 if error: 

627 flash(error, "danger") 

628 return redirect(url_for("pilots.minimums_edit")) 

629 item.label = label 

630 item.value = value or None 

631 item.semantic_tag = tag 

632 item.numeric_value = numeric_value 

633 db.session.commit() 

634 flash(_("Item updated."), "success") 

635 return redirect(url_for("pilots.minimums_edit")) 

636 

637 

638@pilots_bp.route("/pilot/minimums/item/<int:item_id>/delete", methods=["POST"]) 

639@login_required 

640@require_pilot_access 

641def minimums_item_delete(item_id: int) -> ResponseReturnValue: 

642 uid = _current_user_id() 

643 item = _get_own_draft_item_or_404(item_id, uid) 

644 db.session.delete(item) 

645 db.session.commit() 

646 flash(_("Item removed."), "success") 

647 return redirect(url_for("pilots.minimums_edit")) 

648 

649 

650@pilots_bp.route("/pilot/minimums/item/<int:item_id>/move-up", methods=["POST"]) 

651@login_required 

652@require_pilot_access 

653def minimums_item_move_up(item_id: int) -> ResponseReturnValue: 

654 uid = _current_user_id() 

655 item = _get_own_draft_item_or_404(item_id, uid) 

656 _move_sort_order(PersonalMinimumsItem, item, "section_id", item.section_id, "up") 

657 db.session.commit() 

658 return redirect(url_for("pilots.minimums_edit")) 

659 

660 

661@pilots_bp.route("/pilot/minimums/item/<int:item_id>/move-down", methods=["POST"]) 

662@login_required 

663@require_pilot_access 

664def minimums_item_move_down(item_id: int) -> ResponseReturnValue: 

665 uid = _current_user_id() 

666 item = _get_own_draft_item_or_404(item_id, uid) 

667 _move_sort_order(PersonalMinimumsItem, item, "section_id", item.section_id, "down") 

668 db.session.commit() 

669 return redirect(url_for("pilots.minimums_edit")) 

670 

671 

672@pilots_bp.route("/pilot/minimums/publish", methods=["GET", "POST"]) 

673@login_required 

674@require_pilot_access 

675def minimums_publish() -> ResponseReturnValue: 

676 uid = _current_user_id() 

677 draft = _draft_minimums_revision(uid) 

678 if draft is None: 

679 flash(_("No draft to publish."), "danger") 

680 return redirect(url_for("pilots.minimums_view")) 

681 if not draft.sections: 

682 flash(_("Add at least one section before publishing."), "danger") 

683 return redirect(url_for("pilots.minimums_edit")) 

684 

685 if request.method == "POST": 

686 active = _active_minimums_revision(uid) 

687 if active is not None: 

688 active.status = PersonalMinimumsStatus.SUPERSEDED 

689 totals = _compute_totals_sql(uid) 

690 draft.status = PersonalMinimumsStatus.ACTIVE 

691 draft.published_on = _date.today() 

692 draft.experience_hours = totals["total_flight_time"] 

693 db.session.commit() 

694 flash(_("Personal minimums published."), "success") 

695 return redirect(url_for("pilots.minimums_view")) 

696 

697 return render_template( 

698 "pilots/minimums_publish_confirm.html", 

699 revision=draft, 

700 flew_or_reserved_today=_flew_or_reserved_today(uid), 

701 ) 

702 

703 

704@pilots_bp.route("/pilot/minimums/delete-draft", methods=["POST"]) 

705@login_required 

706@require_pilot_access 

707def minimums_delete_draft() -> ResponseReturnValue: 

708 uid = _current_user_id() 

709 draft = _draft_minimums_revision(uid) 

710 if draft is None: 

711 abort(404) 

712 db.session.delete(draft) 

713 db.session.commit() 

714 flash(_("Draft discarded."), "success") 

715 return redirect(url_for("pilots.minimums_view")) 

716 

717 

718@pilots_bp.route("/pilot/minimums/print") 

719@login_required 

720@require_pilot_access 

721def minimums_print() -> ResponseReturnValue: 

722 uid = _current_user_id() 

723 active = _active_minimums_revision(uid) 

724 if active is None: 

725 flash(_("No active personal minimums to print yet."), "warning") 

726 return redirect(url_for("pilots.minimums_view")) 

727 return render_template("pilots/minimums_print.html", revision=active) 

728 

729 

730# ── GPS tracks map ──────────────────────────────────────────────────────────── 

731 

732 

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

734@login_required 

735@require_pilot_access 

736def pilot_tracks() -> ResponseReturnValue: 

737 from flask import url_for as _url_for 

738 

739 uid = _current_user_id() 

740 entries = ( 

741 _my_entries_query(uid) 

742 .filter(Flight.gps_track_id.isnot(None)) 

743 .order_by(Flight.date.asc()) 

744 .all() 

745 ) 

746 track_rows = [ 

747 { 

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

749 "dep": e.departure_icao or "", 

750 "arr": e.arrival_icao or "", 

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

752 if e.total_flight_time is not None 

753 else "", 

754 "view_url": _url_for( 

755 "aircraft.flight_detail", 

756 aircraft_id=e.aircraft_id, 

757 flight_id=e.id, 

758 ) 

759 if e.aircraft_id 

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

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

762 } 

763 for e in entries 

764 ] 

765 

766 return render_template( 

767 "pilots/flight_tracks.html", 

768 track_rows=track_rows, 

769 openaip_key=_openaip_key(), 

770 ) 

771 

772 

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

774@login_required 

775@require_pilot_access 

776def pilot_tracks_gif() -> ResponseReturnValue: 

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

778 from utils import ( # pyright: ignore[reportMissingImports] 

779 generate_tracks_gif, 

780 sort_tracks_oldest_first, 

781 ) 

782 

783 uid = _current_user_id() 

784 entries = _my_entries_query(uid).filter(Flight.gps_track_id.isnot(None)).all() 

785 track_rows = sort_tracks_oldest_first( 

786 [ 

787 { 

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

789 "dep": e.departure_icao or "", 

790 "arr": e.arrival_icao or "", 

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

792 } 

793 for e in entries 

794 ] 

795 ) 

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

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

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

799 mul = 2 if hires else 1 

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

801 gif_bytes = generate_tracks_gif( 

802 track_rows, 

803 _openaip_key=_openaip_key(), 

804 canvas_w=canvas_w, 

805 canvas_h=canvas_h, 

806 high_res=hires, 

807 ) 

808 orient_sfx = "-portrait" if portrait else "" 

809 qual_sfx = "-hires" if hires else "" 

810 suffix = orient_sfx + qual_sfx 

811 return Response( 

812 gif_bytes, 

813 mimetype="image/gif", 

814 headers={ 

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

816 }, 

817 ) 

818 

819 

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

821 

822 

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

824@login_required 

825@require_pilot_access 

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

827 uid = _current_user_id() 

828 entry = db.session.get(Flight, entry_id) 

829 if not entry or (entry.pic_user_id != uid and entry.second_crew_user_id != uid): 

830 abort(404) 

831 

832 return render_template( 

833 "pilots/entry_detail.html", 

834 entry=entry, 

835 openaip_key=_openaip_key(), 

836 LogbookEntryType=LogbookEntryType, 

837 ) 

838 

839 

840# ── Logbook list ────────────────────────────────────────────────────────────── 

841 

842 

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

844@login_required 

845@require_pilot_access 

846def logbook() -> ResponseReturnValue: 

847 uid = _current_user_id() 

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

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

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

851 show_all = pp_raw == "all" 

852 per_page = ( 

853 None 

854 if show_all 

855 else ( 

856 int(pp_raw) 

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

858 else _DEFAULT_PER_PAGE 

859 ) 

860 ) 

861 

862 q = _my_entries_query(uid) 

863 if order == "asc": 

864 q = q.order_by( 

865 Flight.date.asc(), 

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

867 Flight.id.asc(), 

868 ) 

869 else: 

870 q = q.order_by( 

871 Flight.date.desc(), 

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

873 Flight.id.desc(), 

874 ) 

875 

876 if show_all: 

877 entries = q.all() 

878 pagination = None 

879 else: 

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

881 entries = pagination.items 

882 

883 totals = _compute_totals_sql(uid) 

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

885 

886 active_minimums = _active_minimums_revision(uid) 

887 minimums_breaches = ( 

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

889 ) 

890 

891 return render_template( 

892 "pilots/logbook.html", 

893 entries=entries, 

894 pagination=pagination, 

895 totals=totals, 

896 order=order, 

897 per_page=pp_raw, 

898 valid_per_page=_VALID_PER_PAGE, 

899 logbook_milestone=logbook_milestone, 

900 LogbookEntryType=LogbookEntryType, 

901 minimums_breaches=minimums_breaches, 

902 ) 

903 

904 

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

906 """Aggregate totals over ALL entries for the pilot via SQL queries. 

907 

908 Shared EASA figures (night/instrument time, landings, single_pilot_se/me, 

909 multi_pilot, fstd_duration) are summed once per row regardless of which 

910 slot this pilot occupies — they describe the flight, not the occupant. 

911 function_pic only ever belongs to the pic_user_id slot's own hours, and 

912 function_copilot/dual/instructor only to the second_crew_user_id slot's 

913 (see Flight's docstring) — each summed with its own slot-scoped filter, 

914 so a flight where this pilot was PIC doesn't accidentally credit them 

915 with the other occupant's dual/instructor time, or vice versa. 

916 """ 

917 from sqlalchemy import or_ # pyright: ignore[reportMissingImports] 

918 

919 mine = or_( 

920 Flight.pic_user_id == pilot_user_id, 

921 Flight.second_crew_user_id == pilot_user_id, 

922 ) 

923 row = ( 

924 db.session.query( 

925 func.sum(Flight.night_time), 

926 func.sum(Flight.instrument_time), 

927 func.sum(Flight.landings_day), 

928 func.sum(Flight.landings_night), 

929 func.sum(Flight.single_pilot_se), 

930 func.sum(Flight.single_pilot_me), 

931 func.sum(Flight.multi_pilot), 

932 func.sum(Flight.fstd_duration), 

933 ) 

934 .filter(mine) 

935 .one() 

936 ) 

937 fn_pic = ( 

938 db.session.query(func.sum(Flight.function_pic)) 

939 .filter(Flight.pic_user_id == pilot_user_id) 

940 .scalar() 

941 ) 

942 fn_second = ( 

943 db.session.query( 

944 func.sum(Flight.function_copilot), 

945 func.sum(Flight.function_dual), 

946 func.sum(Flight.function_instructor), 

947 ) 

948 .filter(Flight.second_crew_user_id == pilot_user_id) 

949 .one() 

950 ) 

951 

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

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

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

955 

956 return { 

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

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

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

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

961 "single_pilot_se": sp_se, 

962 "single_pilot_me": sp_me, 

963 "multi_pilot": multi, 

964 # FSTD/simulator sessions are excluded from flight-time totals — they 

965 # are not flight hours, only single_pilot_se/me and multi_pilot count. 

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

967 "function_pic": round(float(fn_pic or 0), 1), 

968 "function_copilot": round(float(fn_second[0] or 0), 1), 

969 "function_dual": round(float(fn_second[1] or 0), 1), 

970 "function_instructor": round(float(fn_second[2] or 0), 1), 

971 "fstd_duration": round(float(row[7] or 0), 1), 

972 } 

973 

974 

975# ── New entry ──────────────────────────────────────────────────────────────── 

976 

977 

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

979@login_required 

980@require_pilot_access 

981def new_entry() -> ResponseReturnValue: 

982 uid = _current_user_id() 

983 

984 if request.method == "POST": 

985 values, errors = parse_pilot_fields(request.form) 

986 if errors: 

987 for e in errors: 

988 flash(e, "danger") 

989 return render_template( 

990 "pilots/entry_form.html", 

991 entry=None, 

992 form=request.form, 

993 action="new", 

994 openaip_key=_openaip_key(), 

995 LogbookEntryType=LogbookEntryType, 

996 FstdType=FstdType, 

997 ), 422 

998 entry = Flight(pic_user_id=uid) 

999 apply_pilot_fields(entry, values) 

1000 db.session.add(entry) 

1001 db.session.flush() 

1002 _apply_gps_to_pilot_entry(entry) 

1003 db.session.commit() 

1004 _check_logbook_milestone(entry, uid) 

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

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

1007 

1008 return render_template( 

1009 "pilots/entry_form.html", 

1010 entry=None, 

1011 form={}, 

1012 action="new", 

1013 openaip_key=_openaip_key(), 

1014 LogbookEntryType=LogbookEntryType, 

1015 FstdType=FstdType, 

1016 ) 

1017 

1018 

1019# ── Edit entry ──────────────────────────────────────────────────────────────── 

1020 

1021 

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

1023@login_required 

1024@require_pilot_access 

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

1026 uid = _current_user_id() 

1027 entry = db.session.get(Flight, entry_id) 

1028 if not entry or (entry.pic_user_id != uid and entry.second_crew_user_id != uid): 

1029 abort(404) 

1030 

1031 if entry.aircraft_id: 

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

1033 

1034 if request.method == "POST": 

1035 values, errors = parse_pilot_fields(request.form) 

1036 if errors: 

1037 for e in errors: 

1038 flash(e, "danger") 

1039 return render_template( 

1040 "pilots/entry_form.html", 

1041 entry=entry, 

1042 form=request.form, 

1043 action="edit", 

1044 openaip_key=_openaip_key(), 

1045 LogbookEntryType=LogbookEntryType, 

1046 FstdType=FstdType, 

1047 ), 422 

1048 apply_pilot_fields(entry, values) 

1049 _apply_gps_to_pilot_entry(entry) 

1050 db.session.commit() 

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

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

1053 

1054 return render_template( 

1055 "pilots/entry_form.html", 

1056 entry=entry, 

1057 form={}, 

1058 action="edit", 

1059 openaip_key=_openaip_key(), 

1060 LogbookEntryType=LogbookEntryType, 

1061 FstdType=FstdType, 

1062 ) 

1063 

1064 

1065# ── Delete entry ────────────────────────────────────────────────────────────── 

1066 

1067 

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

1069 if not filename: 

1070 return 

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

1072 try: 

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

1074 except OSError: 

1075 current_app.logger.debug( 

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

1077 ) 

1078 

1079 

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

1081@login_required 

1082@require_pilot_access 

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

1084 uid = _current_user_id() 

1085 entry = db.session.get(Flight, entry_id) 

1086 if not entry or (entry.pic_user_id != uid and entry.second_crew_user_id != uid): 

1087 abort(404) 

1088 

1089 # Unified model: there's only one row now, so deleting it removes both 

1090 # the pilot's own record and the airframe log entry at once (no more 

1091 # separate "also delete the linked flight" choice). If it's a managed 

1092 # aircraft's row, still gate on aircraft access — not just crew identity 

1093 # — before deleting shared airframe data. 

1094 if entry.aircraft_id and not user_can_access_aircraft(entry.aircraft_id): 

1095 abort(403) 

1096 

1097 _delete_upload(entry.flight_counter_photo) 

1098 _delete_upload(entry.engine_counter_photo) 

1099 db.session.delete(entry) 

1100 db.session.commit() 

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

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

1103 

1104 

1105# ── GPS track helper ────────────────────────────────────────────────────────── 

1106 

1107 

1108def _apply_gps_to_pilot_entry(entry: Flight) -> None: 

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

1110 f = request.form 

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

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

1113 if not gps_geojson_raw and not gps_filename: 

1114 return 

1115 

1116 geojson = None 

1117 if gps_geojson_raw: 

1118 with contextlib.suppress( 

1119 ValueError 

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

1121 geojson = json.loads(gps_geojson_raw) 

1122 

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

1124 try: 

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

1126 except ValueError: 

1127 return None 

1128 

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

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

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

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

1133 

1134 if entry.gps_track_id: 

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

1136 if gt: 

1137 if geojson is not None: 

1138 gt.geojson = geojson 

1139 if gps_filename: 

1140 gt.source_filename = gps_filename 

1141 if block_off: 

1142 gt.block_off_utc = block_off 

1143 if block_on: 

1144 gt.block_on_utc = block_on 

1145 return 

1146 

1147 gt = GpsTrack( 

1148 source_filename=gps_filename, 

1149 block_off_utc=block_off, 

1150 block_on_utc=block_on, 

1151 departure_icao=dep, 

1152 arrival_icao=arr, 

1153 geojson=geojson, 

1154 ) 

1155 db.session.add(gt) 

1156 db.session.flush() 

1157 entry.gps_track_id = gt.id 

1158 

1159 

1160# ── Logbook Import ──────────────────────────────────────────────────────────── 

1161 

1162_IMPORT_SESSION_KEY = "logbook_import" 

1163_IMPORT_REVIEW_SESSION_KEY = "logbook_import_review" 

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

1165_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB 

1166 

1167 

1168def _import_tmp_dir() -> str: 

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

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

1171 os.makedirs(d, exist_ok=True) 

1172 return d 

1173 

1174 

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

1176 """Delete any leftover temp import file for this user, including one 

1177 left behind by an abandoned conflict-review (started a fresh upload 

1178 instead of finishing it).""" 

1179 meta = session.get(_IMPORT_SESSION_KEY) 

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

1181 tmp = meta.get("tmp_path") 

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

1183 try: 

1184 os.remove(tmp) 

1185 except OSError as exc: 

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

1187 session.pop(_IMPORT_SESSION_KEY, None) 

1188 

1189 review_state = session.get(_IMPORT_REVIEW_SESSION_KEY) 

1190 if review_state and review_state.get("uid") == uid: 

1191 tmp = review_state.get("tmp_path") 

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

1193 try: 

1194 os.remove(tmp) 

1195 except OSError as exc: 

1196 current_app.logger.debug("cleanup tmp review file: %s", exc) 

1197 session.pop(_IMPORT_REVIEW_SESSION_KEY, None) 

1198 

1199 

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

1201@login_required 

1202@require_pilot_access 

1203def import_upload() -> ResponseReturnValue: 

1204 uid = _current_user_id() 

1205 

1206 if request.method == "GET": 

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

1208 

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

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

1211 if not uploaded or not uploaded.filename: 

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

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

1214 

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

1216 if ext not in _ALLOWED_IMPORT_EXTS: 

1217 flash( 

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

1219 "danger", 

1220 ) 

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

1222 

1223 data = uploaded.read() 

1224 if len(data) > _MAX_IMPORT_BYTES: 

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

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

1227 

1228 try: 

1229 parsed = parse_file(data, uploaded.filename) 

1230 except ValueError as exc: 

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

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

1233 

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

1235 _cleanup_previous_tmp(uid) 

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

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

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

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

1240 fh.write(data) 

1241 

1242 session[_IMPORT_SESSION_KEY] = { 

1243 "uid": uid, 

1244 "tmp_path": tmp_path, 

1245 "original_filename": uploaded.filename, 

1246 "norm_cols": parsed.norm_cols, 

1247 "raw_cols": parsed.raw_cols, 

1248 "fingerprint": parsed.fingerprint, 

1249 } 

1250 

1251 # Look up saved mappings for this pilot 

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

1253 proposal = propose_mapping(parsed, saved) 

1254 

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

1256 

1257 return render_template( 

1258 "pilots/import_map.html", 

1259 norm_cols=parsed.norm_cols, 

1260 raw_cols=parsed.raw_cols, 

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

1262 mapping=proposal.mapping, 

1263 match_type=proposal.match_type, 

1264 fuzzy_score=proposal.fuzzy_score, 

1265 target_fields=TARGET_FIELDS, 

1266 preview=preview, 

1267 filename=uploaded.filename, 

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

1269 ) 

1270 

1271 

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

1273@login_required 

1274@require_pilot_access 

1275def import_execute() -> ResponseReturnValue: 

1276 uid = _current_user_id() 

1277 meta = session.get(_IMPORT_SESSION_KEY) 

1278 

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

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

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

1282 

1283 tmp_path: str = meta["tmp_path"] 

1284 original_filename: str = meta["original_filename"] 

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

1286 fingerprint: str = meta["fingerprint"] 

1287 

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

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

1290 session.pop(_IMPORT_SESSION_KEY, None) 

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

1292 

1293 # Reconstruct mapping from form 

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

1295 for col in norm_cols: 

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

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

1298 

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

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

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

1302 # Re-render the mapping page 

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

1304 data = fh.read() 

1305 try: 

1306 parsed = parse_file(data, original_filename) 

1307 except ValueError: 

1308 session.pop(_IMPORT_SESSION_KEY, None) 

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

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

1311 return render_template( 

1312 "pilots/import_map.html", 

1313 norm_cols=parsed.norm_cols, 

1314 raw_cols=parsed.raw_cols, 

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

1316 mapping=mapping, 

1317 match_type="alias", 

1318 fuzzy_score=0.0, 

1319 target_fields=TARGET_FIELDS, 

1320 preview=preview, 

1321 filename=original_filename, 

1322 type_hints=type_hints(parsed, mapping), 

1323 ), 422 

1324 

1325 # Parse opening balance 

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

1327 ob_fields = [ 

1328 "single_pilot_se", 

1329 "single_pilot_me", 

1330 "multi_pilot", 

1331 "night_time", 

1332 "instrument_time", 

1333 "function_pic", 

1334 "function_copilot", 

1335 "function_dual", 

1336 "function_instructor", 

1337 ] 

1338 for f_name in ob_fields: 

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

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

1341 

1342 # Re-parse the temp file 

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

1344 data = fh.read() 

1345 try: 

1346 parsed = parse_file(data, original_filename) 

1347 except ValueError as exc: 

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

1349 session.pop(_IMPORT_SESSION_KEY, None) 

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

1351 

1352 # Create or reuse the mapping record 

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

1354 mapping_record: LogbookImportMapping | None = None 

1355 for m in saved_mappings: 

1356 if m.source_fingerprint == fingerprint: 

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

1358 m.column_mapping = json.dumps(mapping) 

1359 mapping_record = m 

1360 break 

1361 if mapping_record is None: 

1362 mapping_record = LogbookImportMapping( 

1363 pilot_user_id=uid, 

1364 source_fingerprint=fingerprint, 

1365 column_mapping=json.dumps(mapping), 

1366 source_columns=json.dumps(norm_cols), 

1367 created_at=_datetime.now(UTC), 

1368 ) 

1369 db.session.add(mapping_record) 

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

1371 

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

1373 batch = LogbookImportBatch( 

1374 pilot_user_id=uid, 

1375 mapping_id=mapping_record.id, 

1376 source_filename=original_filename, 

1377 imported_at=_datetime.now(UTC), 

1378 ) 

1379 db.session.add(batch) 

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

1381 

1382 resolved_opening_balance = ( 

1383 opening_balance if any(opening_balance.values()) else None 

1384 ) 

1385 

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

1387 # entry (score >= _CANDIDATE_MIN_SCORE) need a human decision, not a 

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

1389 # interactive review step below instead of silently importing or 

1390 # skipping them. 

1391 conflicts = find_conflicting_rows(parsed, mapping, uid) 

1392 

1393 result = execute_import( 

1394 parsed=parsed, 

1395 mapping=mapping, 

1396 pilot_user_id=uid, 

1397 batch_id=batch.id, 

1398 opening_balance=resolved_opening_balance, 

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

1400 ) 

1401 

1402 batch.row_count = result.imported 

1403 batch.subtotal_count = result.subtotals 

1404 batch.skipped_count = len(result.skipped) 

1405 batch.has_opening_balance = result.has_opening_balance 

1406 

1407 db.session.commit() 

1408 

1409 if conflicts: 

1410 # Defer the aircraft-link pass and tmp-file cleanup until every 

1411 # conflict is resolved — _finalize_import_review does both, covering 

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

1413 session[_IMPORT_REVIEW_SESSION_KEY] = { 

1414 "uid": uid, 

1415 "tmp_path": tmp_path, 

1416 "original_filename": original_filename, 

1417 "mapping": mapping, 

1418 "batch_id": batch.id, 

1419 "resolved": {}, 

1420 } 

1421 session.pop(_IMPORT_SESSION_KEY, None) 

1422 session.modified = True 

1423 

1424 flash( 

1425 _( 

1426 "%(imported)d entries imported so far. %(n)d rows look like they " 

1427 "might already be in your logbook with different data — please " 

1428 "review them below.", 

1429 imported=result.imported, 

1430 n=len(conflicts), 

1431 ), 

1432 "info", 

1433 ) 

1434 if result.duplicates: 

1435 detail = "; ".join( 

1436 reason if r == 0 else f"row {r}: {reason}" 

1437 for r, reason in result.duplicates[:5] 

1438 ) 

1439 if len(result.duplicates) > 5: 

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

1441 flash( 

1442 _("Rows already in your logbook, skipped: %(detail)s", detail=detail), 

1443 "info", 

1444 ) 

1445 if result.skipped: 

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

1447 if len(result.skipped) > 5: 

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

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

1450 

1451 return redirect(url_for("pilots.import_review")) 

1452 

1453 # Promote newly imported standalone entries to managed aircraft, where 

1454 # the registration matches one. 

1455 new_entries = ( 

1456 Flight.query.filter( 

1457 Flight.import_batch_id == batch.id, 

1458 Flight.aircraft_id.is_(None), 

1459 Flight.other_aircraft_registration.isnot(None), 

1460 ) 

1461 .order_by(Flight.date.asc(), Flight.id.asc()) 

1462 .all() 

1463 ) 

1464 ac_created = link_entries_to_aircraft(new_entries) 

1465 if ac_created > 0: 

1466 db.session.commit() 

1467 

1468 # Clean up temp file and session 

1469 try: 

1470 os.remove(tmp_path) 

1471 except OSError as exc: 

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

1473 session.pop(_IMPORT_SESSION_KEY, None) 

1474 

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

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

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

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

1479 flash( 

1480 _( 

1481 "All entries in this file were already in your logbook — " 

1482 "nothing new was imported." 

1483 ), 

1484 "success", 

1485 ) 

1486 elif result.duplicates: 

1487 flash( 

1488 _( 

1489 "Import complete: %(imported)d new entries imported, %(duplicates)d " 

1490 "rows were already in your logbook and were skipped, %(subtotals)d " 

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

1492 imported=result.imported, 

1493 duplicates=len(result.duplicates), 

1494 subtotals=result.subtotals, 

1495 skipped=len(result.skipped), 

1496 ), 

1497 "success", 

1498 ) 

1499 else: 

1500 flash( 

1501 _( 

1502 "Import complete: %(imported)d entries imported, %(subtotals)d " 

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

1504 imported=result.imported, 

1505 subtotals=result.subtotals, 

1506 skipped=len(result.skipped), 

1507 ), 

1508 "success", 

1509 ) 

1510 if ac_created > 0: 

1511 flash( 

1512 ngettext( 

1513 "%(n)d aircraft log entry was also created for your managed aircraft.", 

1514 "%(n)d aircraft log entries were also created for your managed aircraft.", 

1515 ac_created, 

1516 n=ac_created, 

1517 ), 

1518 "info", 

1519 ) 

1520 if result.duplicates: 

1521 detail = "; ".join( 

1522 reason if r == 0 else f"row {r}: {reason}" 

1523 for r, reason in result.duplicates[:5] 

1524 ) 

1525 if len(result.duplicates) > 5: 

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

1527 flash( 

1528 _("Rows already in your logbook, skipped: %(detail)s", detail=detail), 

1529 "info", 

1530 ) 

1531 if result.skipped: 

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

1533 if len(result.skipped) > 5: 

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

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

1536 

1537 if result.parse_warnings: 

1538 n = len(result.parse_warnings) 

1539 examples = "; ".join( 

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

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

1542 ) 

1543 if n > 3: 

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

1545 flash( 

1546 ngettext( 

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

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

1549 n, 

1550 n=n, 

1551 examples=examples, 

1552 ), 

1553 "warning", 

1554 ) 

1555 

1556 if result.total_mismatch_warnings: 

1557 n = len(result.total_mismatch_warnings) 

1558 examples = "; ".join( 

1559 _( 

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

1561 row=r, 

1562 src=src, 

1563 comp=comp, 

1564 ) 

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

1566 ) 

1567 if n > 3: 

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

1569 flash( 

1570 ngettext( 

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

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

1573 n, 

1574 n=n, 

1575 examples=examples, 

1576 ), 

1577 "warning", 

1578 ) 

1579 

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

1581 

1582 

1583def _finalize_import_review(state: dict[str, Any]) -> ResponseReturnValue: 

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

1585 aircraft-link pass (covers both the rows auto-imported before the review 

1586 started and any resolved as 'new' during it), tmp-file/session cleanup, 

1587 summary flash, redirect to history — the same shape as the no-conflicts 

1588 tail of import_execute above.""" 

1589 batch_id: int = state["batch_id"] 

1590 tmp_path: str = state["tmp_path"] 

1591 

1592 new_entries = ( 

1593 Flight.query.filter( 

1594 Flight.import_batch_id == batch_id, 

1595 Flight.aircraft_id.is_(None), 

1596 Flight.other_aircraft_registration.isnot(None), 

1597 ) 

1598 .order_by(Flight.date.asc(), Flight.id.asc()) 

1599 .all() 

1600 ) 

1601 ac_created = link_entries_to_aircraft(new_entries) 

1602 if ac_created > 0: 

1603 db.session.commit() 

1604 

1605 try: 

1606 os.remove(tmp_path) 

1607 except OSError as exc: 

1608 current_app.logger.debug("cleanup tmp review file: %s", exc) 

1609 session.pop(_IMPORT_REVIEW_SESSION_KEY, None) 

1610 

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

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

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

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

1615 

1616 flash( 

1617 _( 

1618 "Review complete: %(overwritten)d entries updated, %(new)d imported " 

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

1620 overwritten=overwritten, 

1621 new=added_new, 

1622 kept=kept, 

1623 ), 

1624 "success", 

1625 ) 

1626 if ac_created > 0: 

1627 flash( 

1628 ngettext( 

1629 "%(n)d aircraft log entry was also created for your managed aircraft.", 

1630 "%(n)d aircraft log entries were also created for your managed aircraft.", 

1631 ac_created, 

1632 n=ac_created, 

1633 ), 

1634 "info", 

1635 ) 

1636 

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

1638 

1639 

1640@pilots_bp.route("/pilot/logbook/import/review") 

1641@login_required 

1642@require_pilot_access 

1643def import_review() -> ResponseReturnValue: 

1644 uid = _current_user_id() 

1645 state = session.get(_IMPORT_REVIEW_SESSION_KEY) 

1646 if not state or state.get("uid") != uid: 

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

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

1649 

1650 tmp_path: str = state["tmp_path"] 

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

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

1653 session.pop(_IMPORT_REVIEW_SESSION_KEY, None) 

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

1655 

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

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

1658 

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

1660 data = fh.read() 

1661 try: 

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

1663 except ValueError: 

1664 session.pop(_IMPORT_REVIEW_SESSION_KEY, None) 

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

1666 

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

1668 conflicts = find_conflicting_rows( 

1669 parsed, mapping, uid, exclude_row_nums=exclude_row_nums 

1670 ) 

1671 

1672 if not conflicts: 

1673 return _finalize_import_review(state) 

1674 

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

1676 candidate_entries: dict[int, Flight] = ( 

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

1678 if candidate_ids 

1679 else {} 

1680 ) 

1681 

1682 rows = [ 

1683 { 

1684 "row_num": c.row_num, 

1685 "kwargs": c.kwargs, 

1686 "candidates": [ 

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

1688 for score, cid in c.candidates 

1689 ], 

1690 } 

1691 for c in conflicts 

1692 ] 

1693 

1694 return render_template( 

1695 "pilots/import_review.html", 

1696 rows=rows, 

1697 resolved_count=len(resolved), 

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

1699 ) 

1700 

1701 

1702@pilots_bp.route("/pilot/logbook/import/review/resolve", methods=["POST"]) 

1703@login_required 

1704@require_pilot_access 

1705def import_review_resolve() -> ResponseReturnValue: 

1706 uid = _current_user_id() 

1707 state = session.get(_IMPORT_REVIEW_SESSION_KEY) 

1708 if not state or state.get("uid") != uid: 

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

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

1711 

1712 tmp_path: str = state["tmp_path"] 

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

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

1715 

1716 try: 

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

1718 except (ValueError, TypeError): 

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

1720 return redirect(url_for("pilots.import_review")) 

1721 

1722 if str(row_num) in resolved: 

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

1724 return redirect(url_for("pilots.import_review")) 

1725 

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

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

1728 session.pop(_IMPORT_REVIEW_SESSION_KEY, None) 

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

1730 

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

1732 data = fh.read() 

1733 try: 

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

1735 except ValueError: 

1736 session.pop(_IMPORT_REVIEW_SESSION_KEY, None) 

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

1738 

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

1740 conflicts = find_conflicting_rows( 

1741 parsed, mapping, uid, exclude_row_nums=exclude_row_nums 

1742 ) 

1743 conflict: ConflictRow | None = next( 

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

1745 ) 

1746 if conflict is None: 

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

1748 return redirect(url_for("pilots.import_review")) 

1749 

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

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

1752 

1753 if decision == "keep": 

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

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

1756 try: 

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

1758 except ValueError: 

1759 existing_id = -1 

1760 if existing_id not in candidate_ids: 

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

1762 return redirect(url_for("pilots.import_review")) 

1763 # candidate_ids just came from find_conflicting_rows in this same 

1764 # request, which already scopes candidates to rows linked to this 

1765 # pilot OR unclaimed rows on this pilot's own tenant aircraft — no 

1766 # need to re-check ownership here (and re-checking pic/second_crew 

1767 # linkage would wrongly reject the unclaimed case, which is exactly 

1768 # the row this decision is meant to claim). 

1769 existing = db.session.get(Flight, existing_id) 

1770 if existing is None: 

1771 # candidate_ids just came from a live query in the same 

1772 # request — only a concurrent delete from elsewhere reaches this. 

1773 abort(404) # pragma: no cover 

1774 # Full replace of every field this row provides — id, import_batch_id, 

1775 # aircraft_id and gps_* links are deliberately left untouched, so this 

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

1777 # created by it) and any existing aircraft/GPS linkage survives. 

1778 for field, value in conflict.kwargs.items(): 

1779 setattr(existing, field, value) 

1780 # Crew identity isn't part of conflict.kwargs (the caller's 

1781 # responsibility, per _build_entry_kwargs's docstring) — recompute 

1782 # it the same way a fresh import would, replacing whatever slot 

1783 # *existing* previously had (e.g. an unclaimed airframe-log row 

1784 # being claimed here for the first time). 

1785 ident_kwargs: dict[str, Any] = dict(conflict.kwargs) 

1786 _assign_pilot_identity(ident_kwargs, uid) 

1787 existing.pic_user_id = ident_kwargs.get("pic_user_id") 

1788 existing.second_crew_user_id = ident_kwargs.get("second_crew_user_id") 

1789 existing.second_crew_role = ident_kwargs.get("second_crew_role") 

1790 db.session.commit() 

1791 elif decision == "new": 

1792 batch_id: int = state["batch_id"] 

1793 new_kwargs = dict(conflict.kwargs) 

1794 _assign_pilot_identity(new_kwargs, uid) 

1795 new_kwargs["import_batch_id"] = batch_id 

1796 new_kwargs["source"] = "import" 

1797 db.session.add(Flight(**new_kwargs)) 

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

1799 if batch is not None: 

1800 batch.row_count += 1 

1801 else: 

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

1803 "import_review_resolve: batch %s vanished before resolve", batch_id 

1804 ) 

1805 db.session.commit() 

1806 else: 

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

1808 return redirect(url_for("pilots.import_review")) 

1809 

1810 resolved[str(row_num)] = decision 

1811 state["resolved"] = resolved 

1812 session[_IMPORT_REVIEW_SESSION_KEY] = state 

1813 session.modified = True 

1814 

1815 remaining = find_conflicting_rows( 

1816 parsed, mapping, uid, exclude_row_nums={int(k) for k in resolved} 

1817 ) 

1818 if not remaining: 

1819 return _finalize_import_review(state) 

1820 

1821 return redirect(url_for("pilots.import_review")) 

1822 

1823 

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

1825@login_required 

1826@require_pilot_access 

1827def import_history() -> ResponseReturnValue: 

1828 uid = _current_user_id() 

1829 batches = ( 

1830 LogbookImportBatch.query.filter_by(pilot_user_id=uid) 

1831 .order_by(LogbookImportBatch.imported_at.desc()) 

1832 .all() 

1833 ) 

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

1835 

1836 

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

1838@login_required 

1839@require_pilot_access 

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

1841 uid = _current_user_id() 

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

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

1844 abort(404) 

1845 

1846 # Delete all entries belonging to this batch. Unified-model note: if any 

1847 # of them were later promoted to a managed aircraft (link_entries_to_ 

1848 # aircraft), that's the same row now — rolling back removes the airframe 

1849 # log entry too, not just the pilot's personal copy (a behaviour change 

1850 # from the old two-table design, where the promoted FlightEntry was a 

1851 # separate row left untouched by a pilot-side rollback). 

1852 Flight.query.filter_by(import_batch_id=batch_id).delete() 

1853 db.session.delete(batch) 

1854 db.session.commit() 

1855 

1856 flash( 

1857 ngettext( 

1858 "Import deleted: one entry removed.", 

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

1860 batch.row_count, 

1861 count=batch.row_count, 

1862 ), 

1863 "success", 

1864 ) 

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

1866 

1867 

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

1869 

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

1871_GPS_MAX_BYTES = 20 * 1024 * 1024 

1872_BLOCK_TOLERANCE_PILOT = _td(minutes=15) 

1873 

1874 

1875def _pilot_gps_tmp_dir() -> str: 

1876 from aircraft.routes import _gps_tmp_dir 

1877 

1878 return _gps_tmp_dir() 

1879 

1880 

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

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

1883 return tu.tenant_id if tu else None 

1884 

1885 

1886def _pilot_match_segment( 

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

1888) -> list["Flight"]: 

1889 """Find Flight records the pilot is associated with that overlap block times.""" 

1890 from sqlalchemy import or_ # pyright: ignore[reportMissingImports] 

1891 

1892 tol = _BLOCK_TOLERANCE_PILOT 

1893 

1894 return Flight.query.filter( # type: ignore[no-any-return] 

1895 or_(Flight.pic_user_id == user_id, Flight.second_crew_user_id == user_id), 

1896 Flight.block_off_utc.isnot(None), 

1897 Flight.block_on_utc.isnot(None), 

1898 Flight.block_off_utc < block_on + tol, 

1899 Flight.block_on_utc > block_off - tol, 

1900 ).all() 

1901 

1902 

1903def _pilot_fuzzy_match_segment( 

1904 user_id: int, seg: dict[str, Any], block_off: "_datetime", block_on: "_datetime" 

1905) -> list["Flight"]: 

1906 """Fallback when no exact GPS-block overlap exists: fuzzy-match on 

1907 date/route/duration against the pilot's own flights (any aircraft, or 

1908 standalone) that don't yet have real GPS block data — typically a row 

1909 logged manually or imported from a personal-logbook CSV. Reuses the 

1910 exact same near-match scorer the pilot-logbook-import review already 

1911 uses (see score_gps_candidates's docstring).""" 

1912 from aircraft.gps_import import score_gps_candidates 

1913 from sqlalchemy import or_ # pyright: ignore[reportMissingImports] 

1914 

1915 from pilots.logbook_import import ( 

1916 _CANDIDATE_MIN_SCORE, 

1917 _score_candidate, 

1918 ) 

1919 

1920 kwargs = { 

1921 "departure_icao": seg.get("departure_icao"), 

1922 "arrival_icao": seg.get("arrival_icao"), 

1923 "departure_time": block_off.time(), 

1924 "arrival_time": block_on.time(), 

1925 # _score_candidate sums single_pilot_se/me + multi_pilot for the 

1926 # duration comparison — scoring purposes only, the confirmed 

1927 # entry's real classification is decided later by pilot_role. 

1928 "single_pilot_se": seg.get("flight_time_rounded_h"), 

1929 } 

1930 # Only rows with no real GPS block data yet — a flight that already 

1931 # has its own track is a different real flight, not the one the exact 

1932 # overlap check above already ruled out. 

1933 same_day = Flight.query.filter( 

1934 or_(Flight.pic_user_id == user_id, Flight.second_crew_user_id == user_id), 

1935 Flight.date == block_off.date(), 

1936 Flight.block_off_utc.is_(None), 

1937 ).all() 

1938 return score_gps_candidates( 

1939 kwargs, same_day, _score_candidate, _CANDIDATE_MIN_SCORE 

1940 ) 

1941 

1942 

1943def _pilot_seg_match_dict( 

1944 matches: list[Any], force_ambiguous: bool = False 

1945) -> dict[str, Any]: 

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

1947 

1948 force_ambiguous marks a fuzzy (non-exact) match as always needing an 

1949 explicit human choice, even with just one candidate — unlike an exact 

1950 GPS-block overlap, a fuzzy match is a guess and must not auto-apply. 

1951 """ 

1952 if not matches: 

1953 return { 

1954 "matched_flight_id": None, 

1955 "matched_flight_str": None, 

1956 "matched_has_existing_track": False, 

1957 "matched_aircraft_id": None, 

1958 "matched_aircraft_reg": None, 

1959 "matched_ambiguous": False, 

1960 "matched_candidates": [], 

1961 } 

1962 from aircraft.routes import _gps_candidate_dict 

1963 

1964 candidates = [_gps_candidate_dict(fe) for fe in matches] 

1965 primary = matches[0] 

1966 return { 

1967 "matched_flight_id": primary.id, 

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

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

1970 "matched_aircraft_id": primary.aircraft_id, 

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

1972 "matched_ambiguous": len(matches) > 1 or force_ambiguous, 

1973 "matched_candidates": candidates, 

1974 } 

1975 

1976 

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

1978@login_required 

1979@require_pilot_access 

1980def pilot_gps_import_upload() -> ResponseReturnValue: 

1981 uid = _current_user_id() 

1982 tenant_id = _pilot_tenant_id(uid) 

1983 tenant_aircraft = ( 

1984 Aircraft.query.filter_by(tenant_id=tenant_id) 

1985 .order_by(Aircraft.registration) 

1986 .all() 

1987 if tenant_id 

1988 else [] 

1989 ) 

1990 

1991 if request.method == "GET": 

1992 return render_template( 

1993 "pilots/gps_import_upload.html", 

1994 tenant_aircraft=tenant_aircraft, 

1995 ) 

1996 

1997 # ── POST: process uploaded files ────────────────────────────────────────── 

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

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

2000 

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

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

2003 return render_template( 

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

2005 ) 

2006 

2007 from aircraft.gps_import import parse_gps_file 

2008 

2009 tmp_dir = _pilot_gps_tmp_dir() 

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

2011 errors: list[str] = [] 

2012 skipped_empty = 0 

2013 

2014 for f in files: 

2015 if not f.filename: 

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

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

2018 if ext not in _GPS_ALLOWED_EXTS: 

2019 errors.append( 

2020 _( 

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

2022 fn=f.filename, 

2023 ) 

2024 ) 

2025 continue 

2026 data = f.read(_GPS_MAX_BYTES + 1) 

2027 if len(data) > _GPS_MAX_BYTES: 

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

2029 continue 

2030 try: 

2031 parsed = parse_gps_file(data, f.filename) 

2032 except ValueError as exc: 

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

2034 continue 

2035 if parsed.classification == "empty": 

2036 skipped_empty += 1 

2037 continue 

2038 uid_hex = uuid.uuid4().hex 

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

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

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

2042 fh.write(data) 

2043 parsed_meta.append( 

2044 { 

2045 "tmp_path": tmp_path, 

2046 "original_filename": f.filename, 

2047 "format": parsed.format, 

2048 "classification": parsed.classification, 

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

2050 "hint_dep": parsed.hint_departure_icao, 

2051 "hint_arr": parsed.hint_arrival_icao, 

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

2053 } 

2054 ) 

2055 

2056 for e in errors: 

2057 flash(e, "danger") 

2058 if skipped_empty: 

2059 flash( 

2060 ngettext( 

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

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

2063 skipped_empty, 

2064 n=skipped_empty, 

2065 ), 

2066 "info", 

2067 ) 

2068 if not parsed_meta: 

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

2070 return render_template( 

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

2072 ) 

2073 

2074 if mode == "one_aircraft": 

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

2076 if not aircraft_id: 

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

2078 return render_template( 

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

2080 ) 

2081 session["gps_import"] = { 

2082 "user_id": session["user_id"], 

2083 "aircraft_id": aircraft_id, 

2084 "files": parsed_meta, 

2085 "skipped_empty": skipped_empty, 

2086 "other_aircraft": False, 

2087 "other_ac_make_model": "", 

2088 "other_ac_reg": "", 

2089 } 

2090 session.modified = True 

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

2092 

2093 # agnostic mode 

2094 session["pilot_gps_import"] = { 

2095 "user_id": session["user_id"], 

2096 "files": parsed_meta, 

2097 "skipped_empty": skipped_empty, 

2098 } 

2099 session.modified = True 

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

2101 

2102 

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

2104@login_required 

2105@require_pilot_access 

2106def pilot_gps_import_review() -> ResponseReturnValue: 

2107 uid = _current_user_id() 

2108 state = session.get("pilot_gps_import") 

2109 if not state: 

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

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

2112 

2113 from aircraft.gps_import import ( 

2114 detect_segments, 

2115 merge_and_sort, 

2116 parse_gps_file, 

2117 ) 

2118 from aircraft.routes import ( 

2119 _gps_tmp_dir, 

2120 _linked_pilot_entries, 

2121 _segment_for_session, 

2122 _segment_to_dict, 

2123 ) 

2124 

2125 file_metas = state["files"] 

2126 all_parsed = [] 

2127 for meta in file_metas: 

2128 try: 

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

2130 data = fh.read() 

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

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

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

2134 all_parsed.append(parsed) 

2135 except (OSError, ValueError): 

2136 flash( 

2137 _( 

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

2139 fn=meta["original_filename"], 

2140 ), 

2141 "warning", 

2142 ) 

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

2144 

2145 merged = merge_and_sort(all_parsed) 

2146 hint_dep = next( 

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

2148 ) 

2149 hint_arr = next( 

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

2151 ) 

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

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

2154 

2155 for seg in full_segs: 

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

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

2158 matches = _pilot_match_segment(uid, block_off, block_on) 

2159 is_fuzzy = False 

2160 if not matches: 

2161 matches = _pilot_fuzzy_match_segment(uid, seg, block_off, block_on) 

2162 is_fuzzy = bool(matches) 

2163 seg.update(_pilot_seg_match_dict(matches, force_ambiguous=is_fuzzy)) 

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

2165 seg["linked_pilot_entries"] = _linked_pilot_entries( 

2166 seg["matched_flight_id"], uid 

2167 ) 

2168 else: 

2169 seg["linked_pilot_entries"] = [] 

2170 

2171 tmp_dir = _gps_tmp_dir() 

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

2173 session["pilot_gps_import"] = state 

2174 session.modified = True 

2175 

2176 tenant_id = _pilot_tenant_id(uid) 

2177 tenant_aircraft = ( 

2178 Aircraft.query.filter_by(tenant_id=tenant_id) 

2179 .order_by(Aircraft.registration) 

2180 .all() 

2181 if tenant_id 

2182 else [] 

2183 ) 

2184 

2185 from models import ( 

2186 AppSetting, # pyright: ignore[reportMissingImports] 

2187 ) 

2188 

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

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

2191 

2192 return render_template( 

2193 "pilots/gps_import_review.html", 

2194 segments=full_segs, 

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

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

2197 tenant_aircraft=tenant_aircraft, 

2198 openaip_key=openaip_key, 

2199 ) 

2200 

2201 

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

2203@login_required 

2204@require_pilot_access 

2205def pilot_gps_import_confirm_one() -> ResponseReturnValue: 

2206 import decimal as _dec 

2207 

2208 from aircraft.gps_import import round_flight_time 

2209 from aircraft.routes import _gps_cleanup, _load_segment_geojson 

2210 

2211 uid = _current_user_id() 

2212 state = session.get("pilot_gps_import") 

2213 if not state: 

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

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

2216 

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

2218 if not segments_data: 

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

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

2221 

2222 try: 

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

2224 except (ValueError, TypeError): 

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

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

2227 

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

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

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

2231 

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

2233 if str(seg_idx) in confirmed: 

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

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

2236 

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

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

2239 pilot_role = "pic" 

2240 

2241 # ── Skip ───────────────────────────────────────────────────────────────── 

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

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

2244 state["confirmed_segments"] = confirmed 

2245 session["pilot_gps_import"] = state 

2246 session.modified = True 

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

2248 _gps_cleanup(state) 

2249 session.pop("pilot_gps_import", None) 

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

2251 skipped_count = len(segments_data) - imported 

2252 if imported > 0: 

2253 flash( 

2254 ngettext( 

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

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

2257 imported, 

2258 n=imported, 

2259 ), 

2260 "success", 

2261 ) 

2262 flash( 

2263 ngettext( 

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

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

2266 skipped_count, 

2267 n=skipped_count, 

2268 ), 

2269 "info", 

2270 ) 

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

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

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

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

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

2276 

2277 # ── Confirm ─────────────────────────────────────────────────────────────── 

2278 seg = segments_data[seg_idx] 

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

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

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

2282 

2283 dep_icao = ( 

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

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

2286 arr_icao = ( 

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

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

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

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

2291 

2292 geojson = _load_segment_geojson(seg) 

2293 source_filename = ( 

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

2295 ) 

2296 device_id = next( 

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

2298 ) 

2299 

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

2301 if seg.get("matched_ambiguous"): 

2302 # A candidate picker was rendered — respect the human's explicit 

2303 # choice (including "none of these", submitted as "") instead of 

2304 # always falling back to the top-scored candidate. 

2305 picked = request.form.get("matched_flight_id", "") 

2306 matched_flight_id = int(picked) if picked.strip().isdigit() else None 

2307 else: 

2308 matched_flight_id = seg.get("matched_flight_id") 

2309 entry: Flight | None = None 

2310 gps_track: GpsTrack | None = None 

2311 ac: Aircraft | None = None 

2312 

2313 if matched_flight_id: 

2314 # Link GPS track to the existing matched Flight 

2315 existing = db.session.get(Flight, matched_flight_id) 

2316 if existing: 

2317 old_track_id = existing.gps_track_id 

2318 gps_track = GpsTrack( 

2319 source_filename=source_filename, 

2320 device_id=device_id, 

2321 block_off_utc=block_off, 

2322 block_on_utc=block_on, 

2323 departure_icao=dep_icao, 

2324 arrival_icao=arr_icao, 

2325 geojson=geojson, 

2326 ) 

2327 db.session.add(gps_track) 

2328 db.session.flush() 

2329 existing.gps_track_id = gps_track.id 

2330 existing.block_off_utc = block_off 

2331 existing.block_on_utc = block_on 

2332 if old_track_id and old_track_id != gps_track.id: 

2333 # Re-confirming an already-linked match (the review page 

2334 # warns this replaces the track) — drop the superseded 

2335 # row instead of leaving it as a permanent orphan. 

2336 old_track = db.session.get(GpsTrack, old_track_id) 

2337 if old_track is not None: 

2338 db.session.delete(old_track) 

2339 # Unified model: this Flight row already covers both crew slots 

2340 # (pic_user_id/second_crew_user_id), so setting gps_track_id 

2341 # above already applies to whichever other pilot occupies the 

2342 # other slot too — no separate per-pilot row left to update. 

2343 db.session.flush() 

2344 entry = existing 

2345 else: 

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

2347 

2348 if not matched_flight_id: 

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

2350 

2351 if resolution == "managed_aircraft": 

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

2353 if aircraft_id: 

2354 tenant_id = _pilot_tenant_id(uid) 

2355 ac = ( 

2356 Aircraft.query.filter_by( 

2357 id=aircraft_id, tenant_id=tenant_id 

2358 ).first() 

2359 if tenant_id 

2360 else None 

2361 ) 

2362 

2363 if ac: 

2364 # Create a new Flight for the managed aircraft 

2365 gps_track = GpsTrack( 

2366 source_filename=source_filename, 

2367 device_id=device_id, 

2368 block_off_utc=block_off, 

2369 block_on_utc=block_on, 

2370 departure_icao=dep_icao, 

2371 arrival_icao=arr_icao, 

2372 geojson=geojson, 

2373 ) 

2374 db.session.add(gps_track) 

2375 db.session.flush() 

2376 flight_time_h = round_flight_time( 

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

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

2379 ) 

2380 entry = Flight( 

2381 aircraft_id=ac.id, 

2382 date=block_off.date(), 

2383 departure_icao=dep_icao, 

2384 arrival_icao=arr_icao, 

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

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

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

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

2389 nature_of_flight=nature, 

2390 source="gps_import", 

2391 block_off_utc=block_off, 

2392 block_on_utc=block_on, 

2393 gps_track_id=gps_track.id, 

2394 ) 

2395 db.session.add(entry) 

2396 db.session.flush() 

2397 else: 

2398 # Other / external aircraft — pilot-only, no Flight 

2399 if geojson: 

2400 gps_track = GpsTrack( 

2401 source_filename=source_filename, 

2402 device_id=device_id, 

2403 block_off_utc=block_off, 

2404 block_on_utc=block_on, 

2405 departure_icao=dep_icao, 

2406 arrival_icao=arr_icao, 

2407 geojson=geojson, 

2408 ) 

2409 db.session.add(gps_track) 

2410 db.session.flush() 

2411 create_pilot_entry = ( 

2412 True # always create logbook entry for external aircraft 

2413 ) 

2414 

2415 if create_pilot_entry: 

2416 from flights.routes import apply_pilot_identity 

2417 

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

2419 

2420 if entry is None: 

2421 # Other/external aircraft, no existing match — standalone row. 

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

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

2424 entry = Flight( 

2425 date=block_off.date(), 

2426 other_aircraft_type=other_mm or None, 

2427 other_aircraft_registration=other_reg or None, 

2428 departure_icao=dep_icao, 

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

2430 arrival_icao=arr_icao, 

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

2432 source="gps_import", 

2433 gps_track_id=gps_track.id if gps_track else None, 

2434 ) 

2435 db.session.add(entry) 

2436 db.session.flush() 

2437 

2438 entry.flight_time = _dec.Decimal(str(flight_time_h)) 

2439 # GPS-derived landing count has no day/night split — treat them all 

2440 # as day landings, same simplification the old standalone pilot 

2441 # entry made. 

2442 entry.landings_day = seg.get("landing_count") or 0 

2443 if remarks: 

2444 entry.notes = remarks 

2445 entry_ac: Aircraft | None = ( 

2446 ac if ac is not None else entry.aircraft # type: ignore[assignment] 

2447 ) 

2448 # apply_pilot_identity only knows "pic"/"dual" — an external-aircraft 

2449 # segment always gets a personal entry recorded (create_pilot_entry 

2450 # is forced True above) even when pilot_role is "none"; default that 

2451 # case to "pic" rather than skipping identity resolution entirely. 

2452 apply_pilot_identity( 

2453 entry, 

2454 entry_ac, 

2455 uid, 

2456 pilot_role if pilot_role != "none" else "pic", 

2457 ) 

2458 

2459 db.session.commit() 

2460 

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

2462 state["confirmed_segments"] = confirmed 

2463 session["pilot_gps_import"] = state 

2464 session.modified = True 

2465 

2466 all_handled = len(confirmed) == len(segments_data) 

2467 if all_handled: 

2468 _gps_cleanup(state) 

2469 session.pop("pilot_gps_import", None) 

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

2471 flash( 

2472 ngettext( 

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

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

2475 total, 

2476 n=total, 

2477 ), 

2478 "success", 

2479 ) 

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

2481 

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

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