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

1450 statements  

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

1import json 

2import math 

3import os 

4import uuid as _uuid_mod 

5from datetime import UTC 

6from typing import Any, cast 

7 

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

9 Blueprint, 

10 abort, 

11 current_app, 

12 flash, 

13 redirect, 

14 render_template, 

15 request, 

16 session, 

17 url_for, 

18) 

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

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

21from flask_babel import ngettext 

22from models import ( 

23 FUEL_DENSITY, 

24 GAL_TO_L, 

25 Aircraft, 

26 AircraftFuelTank, 

27 AircraftGpsImportBatch, 

28 AircraftOwner, 

29 AircraftPhoto, 

30 AirworthinessDocumentStatus, 

31 AppSetting, 

32 Component, 

33 ComponentType, 

34 DocType, 

35 Document, 

36 Expense, 

37 ExpenseType, 

38 Flight, 

39 GpsTrack, 

40 MaintenanceTrigger, 

41 OperatingModel, 

42 Refuel, 

43 Reservation, 

44 ReservationStatus, 

45 Role, 

46 Snag, 

47 TenantProfile, 

48 TenantUser, 

49 User, 

50 WeightBalanceConfig, 

51 WeightBalanceEntry, 

52 WeightBalanceStation, 

53 db, 

54) # pyright: ignore[reportMissingImports] 

55from utils import ( 

56 accessible_aircraft, 

57 activity, 

58 compute_aircraft_statuses, 

59 get_aircraft_type_engine_info, 

60 login_required, 

61 require_role, 

62 user_can_access_aircraft, 

63) # pyright: ignore[reportMissingImports] 

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

65 

66from aircraft.co_owner_form_parsing import ( # pyright: ignore[reportMissingImports] 

67 parse_owners_form, 

68 parse_reserve_fields, 

69) 

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

71 detect_segments, 

72 merge_and_sort, 

73 parse_gps_file, 

74 round_flight_time, 

75) 

76 

77aircraft_bp = Blueprint("aircraft", __name__, url_prefix="/aircraft") 

78 

79_OWNER_ROLES = (Role.ADMIN, Role.OWNER) 

80_PILOT_ROLES = (Role.ADMIN, Role.OWNER, Role.PILOT) 

81 

82 

83def _tenant_id() -> int: 

84 """Return the tenant ID for the currently logged-in user.""" 

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

86 if not tu: 

87 abort(403) 

88 return int(tu.tenant_id) 

89 

90 

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

92 """Fetch an aircraft that belongs to the current tenant and is accessible to the user.""" 

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

94 if ( 

95 not ac 

96 or ac.tenant_id != _tenant_id() 

97 or not user_can_access_aircraft(aircraft_id) 

98 ): 

99 abort(404) 

100 return ac 

101 

102 

103def _get_tenant_aircraft_or_404(aircraft_id: int) -> Aircraft: 

104 """Like _get_aircraft_or_404 but without the operational 

105 user_can_access_aircraft gate — for self-service co-owner routes 

106 (my_share/my_share_statement_csv) where the AircraftOwner row checked 

107 right after this call is the real authorization boundary. A co-owner may 

108 have no operational role on the aircraft at all (e.g. a silent investor 

109 with no UserAircraftAccess grant) and must still be able to view their 

110 own share.""" 

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

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

113 abort(404) 

114 return ac 

115 

116 

117def _shared_ownership_enabled(tenant_id: int, aircraft_id: int) -> bool: 

118 """True when the tenant runs the shared_ownership operating model, OR 

119 when this aircraft already has AircraftOwner rows (legacy data after a 

120 model switch — the pages must stay reachable so an admin can view 

121 accounts and clear the owner set; without rows they 404, keeping zero 

122 trace on instances that never used the feature).""" 

123 profile = TenantProfile.query.filter_by(tenant_id=tenant_id).first() 

124 if profile and profile.operating_model == OperatingModel.SHARED_OWNERSHIP: 

125 return True 

126 return bool( 

127 db.session.query( 

128 AircraftOwner.query.filter_by(aircraft_id=aircraft_id).exists() 

129 ).scalar() 

130 ) 

131 

132 

133def _registration_taken( 

134 registration: str, tenant_id: int, exclude_aircraft_id: int | None = None 

135) -> bool: 

136 """True if another aircraft in this tenant already has this registration — 

137 compared the same way AircraftRefConverter (utils.py) sanitizes for its 

138 URL slot ('/' and ' ' -> '-', case-insensitive), so two registrations 

139 that only differ that way don't end up silently sharing a URL.""" 

140 needle = registration.replace("/", "-").replace(" ", "-").upper() 

141 query = Aircraft.query.filter( 

142 Aircraft.tenant_id == tenant_id, 

143 db.func.upper( 

144 db.func.replace(db.func.replace(Aircraft.registration, "/", "-"), " ", "-") 

145 ) 

146 == needle, 

147 ) 

148 if exclude_aircraft_id is not None: 

149 query = query.filter(Aircraft.id != exclude_aircraft_id) 

150 return query.first() is not None 

151 

152 

153def _get_component_or_404(aircraft: Aircraft, component_id: int) -> Component: 

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

155 if not comp or comp.aircraft_id != aircraft.id: 

156 abort(404) 

157 return comp 

158 

159 

160# ── Aircraft list ───────────────────────────────────────────────────────────── 

161 

162 

163@aircraft_bp.route("/") 

164@login_required 

165def list_aircraft() -> ResponseReturnValue: 

166 from models import TenantProfile 

167 

168 if not request.args.get("list"): 

169 tp = TenantProfile.query.filter_by(tenant_id=_tenant_id()).first() 

170 if tp and tp.planned_aircraft_count == 1: 

171 aircraft_list = accessible_aircraft(_tenant_id()).all() 

172 if len(aircraft_list) == 1: 

173 return redirect( 

174 url_for("aircraft.detail", aircraft_id=aircraft_list[0].id) 

175 ) 

176 

177 show_archived = request.args.get("archived") == "1" 

178 all_accessible = accessible_aircraft(_tenant_id(), include_archived=True).all() 

179 archived_count = sum(1 for ac in all_accessible if ac.is_archived) 

180 aircraft = ( 

181 all_accessible 

182 if show_archived 

183 else [ac for ac in all_accessible if not ac.is_archived] 

184 ) 

185 aircraft_ids = [ac.id for ac in aircraft] 

186 hobbs_by_id = Aircraft.engine_hours_by_id(aircraft_ids) 

187 landings_by_id = Aircraft.landings_by_id(aircraft_ids) 

188 flight_hours_by_id = Aircraft.flight_hours_by_id(aircraft_ids) 

189 triggers = ( 

190 ( 

191 MaintenanceTrigger.query.filter( 

192 MaintenanceTrigger.aircraft_id.in_(aircraft_ids) 

193 ).all() 

194 ) 

195 if aircraft_ids 

196 else [] 

197 ) 

198 aircraft_status = compute_aircraft_statuses( 

199 aircraft, triggers, hobbs_by_id, landings_by_id, flight_hours_by_id 

200 ) 

201 wb_configured_ids = {ac.id for ac in aircraft if ac.wb_config is not None} 

202 cover_photos = ( 

203 { 

204 p.aircraft_id: p 

205 for p in AircraftPhoto.query.filter( 

206 AircraftPhoto.aircraft_id.in_(aircraft_ids), 

207 AircraftPhoto.sort_order == 1, 

208 ).all() 

209 } 

210 if aircraft_ids 

211 else {} 

212 ) 

213 return render_template( 

214 "aircraft/list.html", 

215 aircraft=aircraft, 

216 aircraft_status=aircraft_status, 

217 wb_configured_ids=wb_configured_ids, 

218 cover_photos=cover_photos, 

219 show_archived=show_archived, 

220 archived_count=archived_count, 

221 ) 

222 

223 

224# ── Add aircraft ────────────────────────────────────────────────────────────── 

225 

226 

227@aircraft_bp.route("/new", methods=["GET", "POST"]) 

228@login_required 

229@require_role(*_OWNER_ROLES) 

230def new_aircraft() -> ResponseReturnValue: 

231 if request.method == "POST": 

232 return _save_aircraft(None) 

233 return render_template("aircraft/aircraft_form.html", aircraft=None) 

234 

235 

236# ── Aircraft detail ─────────────────────────────────────────────────────────── 

237 

238 

239@aircraft_bp.route("/<aircraft_ref:aircraft_id>") 

240@login_required 

241def detail(aircraft_id: int) -> ResponseReturnValue: 

242 from models import Flight, MaintenanceTrigger 

243 

244 ac = _get_aircraft_or_404(aircraft_id) 

245 components_by_type: dict[Any, list[Any]] = {} 

246 for comp in sorted(ac.components, key=lambda c: (c.type, c.position or "")): 

247 components_by_type.setdefault(comp.type, []).append(comp) 

248 recent_flights = ( 

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

250 .order_by( 

251 Flight.date.desc(), 

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

253 Flight.id.desc(), 

254 ) 

255 .limit(3) 

256 .all() 

257 ) 

258 current_hobbs = ac.total_engine_hours 

259 current_landings = ac.total_landings 

260 current_flight_hours = ac.total_flight_hours 

261 triggers = MaintenanceTrigger.query.filter_by(aircraft_id=ac.id).all() 

262 maintenance_summary = [ 

263 ( 

264 t, 

265 t.status( 

266 current_engine_hours=current_hobbs, 

267 current_landings=current_landings, 

268 current_flight_hours=current_flight_hours, 

269 ), 

270 ) 

271 for t in triggers 

272 ] 

273 recent_expenses = ( 

274 Expense.query.filter_by(aircraft_id=ac.id) 

275 .order_by(Expense.date.desc(), Expense.id.desc()) 

276 .limit(3) 

277 .all() 

278 ) 

279 recent_refuels = ( 

280 Refuel.query.filter_by(aircraft_id=ac.id) 

281 .order_by(Refuel.date.desc(), Refuel.id.desc()) 

282 .limit(3) 

283 .all() 

284 ) 

285 recent_documents = ( 

286 Document.query.filter_by(aircraft_id=ac.id, is_sensitive=False) 

287 .order_by(Document.uploaded_at.desc()) 

288 .limit(3) 

289 .all() 

290 ) 

291 document_count = Document.query.filter_by(aircraft_id=ac.id).count() 

292 from datetime import date as _cert_date 

293 

294 from documents.routes import ( # pyright: ignore[reportMissingImports] 

295 active_document_for, 

296 ) 

297 

298 active_insurance_cert = active_document_for(ac.id, DocType.INSURANCE_CERT) 

299 active_arc_cert = active_document_for(ac.id, DocType.ARC) 

300 upcoming_insurance_cert = ( 

301 Document.query.filter( 

302 Document.aircraft_id == ac.id, 

303 Document.doc_type == DocType.INSURANCE_CERT, 

304 Document.component_id.is_(None), 

305 Document.valid_from > _cert_date.today(), 

306 ) 

307 .order_by(Document.valid_from.asc()) 

308 .first() 

309 ) 

310 upcoming_arc_cert = ( 

311 Document.query.filter( 

312 Document.aircraft_id == ac.id, 

313 Document.doc_type == DocType.ARC, 

314 Document.component_id.is_(None), 

315 Document.valid_from > _cert_date.today(), 

316 ) 

317 .order_by(Document.valid_from.asc()) 

318 .first() 

319 ) 

320 open_snags = ( 

321 Snag.query.filter_by(aircraft_id=ac.id, resolved_at=None) 

322 .order_by(Snag.is_grounding.desc(), Snag.reported_at.desc()) 

323 .all() 

324 ) 

325 wb_cfg = ac.wb_config 

326 last_wb_entry = None 

327 if wb_cfg: 

328 last_wb_entry = ( 

329 WeightBalanceEntry.query.filter_by(config_id=wb_cfg.id) 

330 .order_by(WeightBalanceEntry.date.desc(), WeightBalanceEntry.id.desc()) 

331 .first() 

332 ) 

333 from datetime import datetime 

334 

335 now = datetime.now(UTC) 

336 upcoming_reservations = ( 

337 Reservation.query.filter( 

338 Reservation.aircraft_id == ac.id, 

339 Reservation.status.in_( 

340 [ReservationStatus.CONFIRMED, ReservationStatus.PENDING] 

341 ), 

342 Reservation.end_dt >= now, 

343 ) 

344 .order_by(Reservation.start_dt) 

345 .limit(5) 

346 .all() 

347 ) 

348 suggest_components = session.pop(f"suggest_components_{ac.id}", None) 

349 photos = ( 

350 AircraftPhoto.query.filter_by(aircraft_id=ac.id) 

351 .order_by(AircraftPhoto.sort_order) 

352 .all() 

353 ) 

354 aw_statuses = AirworthinessDocumentStatus.query.filter_by(aircraft_id=ac.id).all() 

355 aw_counts: dict[str, int] = {} 

356 for _st in aw_statuses: 

357 aw_counts[_st.status] = aw_counts.get(_st.status, 0) + 1 

358 aw_counts["total"] = len(aw_statuses) 

359 _gps_entries = ( 

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

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

362 .order_by(Flight.date.asc()) 

363 .all() 

364 ) 

365 track_rows = [ 

366 { 

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

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

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

370 "time_str": f"{e.flight_time} h" if e.flight_time is not None else "", 

371 "view_url": url_for( 

372 "aircraft.flight_detail", 

373 aircraft_id=aircraft_id, 

374 flight_id=e.id, 

375 ), 

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

377 } 

378 for e in _gps_entries 

379 ] 

380 _tile = db.session.get(AppSetting, "openaip_api_key") 

381 openaip_key = _tile.value if _tile and _tile.value else None 

382 from services.component_limits import ( 

383 aircraft_limit_infos, # pyright: ignore[reportMissingImports] 

384 ) 

385 

386 component_limit_by_id = { 

387 info["component"].id: info for info in aircraft_limit_infos(ac) 

388 } 

389 from datetime import date as _today_date 

390 

391 shared_ownership_enabled = _shared_ownership_enabled(ac.tenant_id, ac.id) 

392 is_current_user_co_owner = any( 

393 o.user_id == session["user_id"] for o in list(ac.owners) 

394 ) 

395 

396 return render_template( 

397 "aircraft/detail.html", 

398 aircraft=ac, 

399 components_by_type=components_by_type, 

400 component_limit_by_id=component_limit_by_id, 

401 today_date=_today_date.today(), 

402 component_types=ComponentType, 

403 recent_flights=recent_flights, 

404 suggest_components=suggest_components, 

405 maintenance_summary=maintenance_summary, 

406 recent_expenses=recent_expenses, 

407 expense_type_labels=ExpenseType.LABELS, 

408 recent_refuels=recent_refuels, 

409 recent_documents=recent_documents, 

410 document_count=document_count, 

411 active_insurance_cert=active_insurance_cert, 

412 active_arc_cert=active_arc_cert, 

413 upcoming_insurance_cert=upcoming_insurance_cert, 

414 upcoming_arc_cert=upcoming_arc_cert, 

415 open_snags=open_snags, 

416 wb_config=wb_cfg, 

417 last_wb_entry=last_wb_entry, 

418 upcoming_reservations=upcoming_reservations, 

419 ReservationStatus=ReservationStatus, 

420 photos=photos, 

421 aw_counts=aw_counts, 

422 track_rows=track_rows, 

423 openaip_key=openaip_key, 

424 shared_ownership_enabled=shared_ownership_enabled, 

425 owners=ac.owners if shared_ownership_enabled else [], 

426 is_current_user_co_owner=is_current_user_co_owner, 

427 ) 

428 

429 

430# ── Edit aircraft ───────────────────────────────────────────────────────────── 

431 

432 

433@aircraft_bp.route("/<aircraft_ref:aircraft_id>/edit", methods=["GET", "POST"]) 

434@login_required 

435@require_role(*_OWNER_ROLES) 

436def edit_aircraft(aircraft_id: int) -> ResponseReturnValue: 

437 ac = _get_aircraft_or_404(aircraft_id) 

438 if request.method == "POST": 

439 return _save_aircraft(ac) 

440 return render_template("aircraft/aircraft_form.html", aircraft=ac) 

441 

442 

443def _save_aircraft(ac: Aircraft | None) -> ResponseReturnValue: 

444 is_new = ac is None 

445 icao_type = request.form.get("aircraft_type_icao", "").strip().upper() 

446 registration = request.form.get("registration", "").strip().upper() 

447 make = request.form.get("make", "").strip() 

448 model = request.form.get("model", "").strip() 

449 year_raw = request.form.get("year", "").strip() 

450 has_flight_counter = bool(request.form.get("has_flight_counter")) 

451 flight_counter_offset_raw = request.form.get("flight_counter_offset", "0.3").strip() 

452 fuel_flow_raw = request.form.get("fuel_flow", "").strip() 

453 fuel_type = request.form.get("fuel_type", "avgas").strip() 

454 if fuel_type not in ("avgas", "ul91", "mogas", "jet_a1"): 

455 fuel_type = "avgas" 

456 reserve_hourly_rate_raw = request.form.get("reserve_hourly_rate", "").strip() 

457 oil_warning_lph_raw = request.form.get("oil_warning_lph", "").strip() 

458 showcase_blurb = request.form.get("showcase_blurb", "").strip() or None 

459 logbook_time_precision = request.form.get( 

460 "logbook_time_precision", "tenth_hour" 

461 ).strip() 

462 if logbook_time_precision not in ("tenth_hour", "minute"): 

463 logbook_time_precision = "tenth_hour" 

464 

465 errors = [] 

466 if not registration: 

467 errors.append(_("Registration is required.")) 

468 elif _registration_taken( 

469 registration, _tenant_id(), exclude_aircraft_id=ac.id if ac else None 

470 ): 

471 errors.append( 

472 _( 

473 "%(reg)s is already used by another aircraft in this hangar " 

474 "(registrations that only differ by spaces or slashes still " 

475 "count as the same, since they'd otherwise share a URL).", 

476 reg=registration, 

477 ) 

478 ) 

479 if not make: 

480 errors.append(_("Manufacturer is required.")) 

481 if not model: 

482 errors.append(_("Model is required.")) 

483 year = None 

484 if year_raw: 

485 try: 

486 year = int(year_raw) 

487 if not (1900 <= year <= 2100): 

488 raise ValueError 

489 except ValueError: 

490 errors.append(_("Year must be a valid 4-digit year.")) 

491 

492 flight_counter_offset = 0.3 

493 if flight_counter_offset_raw: 

494 try: 

495 flight_counter_offset = float(flight_counter_offset_raw) 

496 if flight_counter_offset < 0: 

497 raise ValueError 

498 except ValueError: 

499 errors.append(_("Flight counter offset must be a non-negative number.")) 

500 

501 fuel_flow = None 

502 if fuel_flow_raw: 

503 try: 

504 fuel_flow = float(fuel_flow_raw) 

505 if fuel_flow < 0: 

506 raise ValueError 

507 except ValueError: 

508 errors.append(_("Fuel consumption must be a non-negative number.")) 

509 

510 reserve_hourly_rate = None 

511 if reserve_hourly_rate_raw: 

512 try: 

513 reserve_hourly_rate = float(reserve_hourly_rate_raw) 

514 if reserve_hourly_rate < 0: 

515 raise ValueError 

516 except ValueError: 

517 errors.append(_("Overhaul reserve accrual must be a non-negative number.")) 

518 

519 oil_warning_lph = None 

520 if oil_warning_lph_raw: 

521 try: 

522 oil_warning_lph = float(oil_warning_lph_raw) 

523 if oil_warning_lph < 0: 

524 raise ValueError 

525 except ValueError: 

526 errors.append( 

527 _("Oil consumption warning threshold must be a non-negative number.") 

528 ) 

529 

530 # Fuel tanks (see AircraftFuelTank docstring). Same "replace all rows" 

531 # pattern as the W&B stations form: blank-name rows are silently 

532 # dropped, a non-positive/invalid capacity is an error. 

533 tank_names = request.form.getlist("tank_name[]") 

534 tank_capacities_raw = request.form.getlist("tank_capacity[]") 

535 tanks_data: list[tuple[str, float]] = [] 

536 for i, tname in enumerate(tank_names): 

537 tname = tname.strip() 

538 if not tname: 

539 continue 

540 try: 

541 tcap = float(tank_capacities_raw[i]) 

542 if tcap <= 0: 

543 raise ValueError 

544 except (ValueError, IndexError): 

545 errors.append(_("Fuel tank capacity must be a positive number.")) 

546 continue 

547 tanks_data.append((tname, tcap)) 

548 

549 if errors: 

550 for msg in errors: 

551 flash(msg, "danger") 

552 return render_template("aircraft/aircraft_form.html", aircraft=ac) 

553 

554 if ac is None: 

555 ac = Aircraft(tenant_id=_tenant_id()) 

556 db.session.add(ac) 

557 

558 ac.registration = registration 

559 ac.make = make 

560 ac.model = model 

561 ac.year = year 

562 ac.has_flight_counter = has_flight_counter 

563 ac.flight_counter_offset = flight_counter_offset 

564 ac.fuel_flow = fuel_flow 

565 ac.fuel_type = fuel_type 

566 ac.logbook_time_precision = logbook_time_precision 

567 ac.reserve_hourly_rate = reserve_hourly_rate 

568 ac.oil_warning_lph = oil_warning_lph 

569 ac.showcase_blurb = showcase_blurb 

570 db.session.commit() 

571 

572 # Replace fuel tanks (same "delete all, recreate" strategy as W&B stations) 

573 for t in list(ac.fuel_tanks): 

574 db.session.delete(t) 

575 for i, (tname, tcap) in enumerate(tanks_data): 

576 db.session.add( 

577 AircraftFuelTank( 

578 aircraft_id=ac.id, name=tname, capacity_liters=tcap, sort_order=i 

579 ) 

580 ) 

581 db.session.commit() 

582 

583 if is_new: 

584 activity("aircraft.created", registration=ac.registration, aircraft_id=ac.id) 

585 else: 

586 activity("aircraft.updated", registration=ac.registration, aircraft_id=ac.id) 

587 

588 if is_new and icao_type: 

589 engine_info = get_aircraft_type_engine_info(icao_type) 

590 if engine_info: 

591 ec, et = engine_info 

592 if et == "Piston" and not ac.components: 

593 session[f"suggest_components_{ac.id}"] = {"engine_count": ec} 

594 

595 flash(_("%(reg)s saved.", reg=ac.registration), "success") 

596 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

597 

598 

599# ── Shared ownership (Phase 39a) ────────────────────────────────────────────── 

600 

601 

602@aircraft_bp.route("/<aircraft_ref:aircraft_id>/owners", methods=["GET", "POST"]) 

603@login_required 

604@require_role(*_OWNER_ROLES) 

605def manage_owners(aircraft_id: int) -> ResponseReturnValue: 

606 ac = _get_aircraft_or_404(aircraft_id) 

607 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

608 abort(404) 

609 

610 tenant_users = ( 

611 User.query.join(TenantUser, TenantUser.user_id == User.id) 

612 .filter(TenantUser.tenant_id == ac.tenant_id, User.is_active.is_(True)) 

613 .order_by(User.name) 

614 .all() 

615 ) 

616 

617 if request.method == "POST": 

618 from datetime import date as _date 

619 

620 rows, billing_start, hourly_rate, errors = parse_owners_form(request.form) 

621 reserve_hourly, reserve_monthly, reserve_errors = parse_reserve_fields( 

622 request.form 

623 ) 

624 errors = errors + reserve_errors 

625 

626 if errors: 

627 for msg in errors: 

628 flash(msg, "danger") 

629 return render_template( 

630 "aircraft/manage_owners.html", 

631 aircraft=ac, 

632 tenant_users=tenant_users, 

633 ) 

634 

635 submitted_user_ids = {r["user_id"] for r in rows} 

636 existing_by_user = {o.user_id: o for o in list(ac.owners)} 

637 is_first_save = not existing_by_user 

638 

639 for uid, existing in existing_by_user.items(): 

640 if uid not in submitted_user_ids: 

641 db.session.delete(existing) 

642 

643 for row in rows: 

644 existing = existing_by_user.get(row["user_id"]) 

645 if existing is not None: 

646 existing.share_pct = row["share_pct"] 

647 existing.buy_in_amount = row["buy_in_amount"] 

648 else: 

649 db.session.add( 

650 AircraftOwner( 

651 aircraft_id=ac.id, 

652 user_id=row["user_id"], 

653 share_pct=row["share_pct"], 

654 buy_in_amount=row["buy_in_amount"], 

655 ) 

656 ) 

657 

658 ac.co_owner_hourly_rate = hourly_rate 

659 ac.reserve_contribution_hourly = reserve_hourly 

660 ac.reserve_contribution_monthly = reserve_monthly 

661 if billing_start is not None: 

662 ac.co_owner_billing_start = billing_start 

663 elif is_first_save and rows and ac.co_owner_billing_start is None: 

664 ac.co_owner_billing_start = _date.today() 

665 db.session.commit() 

666 

667 flash(_("Ownership updated."), "success") 

668 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

669 

670 return render_template( 

671 "aircraft/manage_owners.html", 

672 aircraft=ac, 

673 tenant_users=tenant_users, 

674 ) 

675 

676 

677def _co_owner_billing_period(period_months_raw: str | None) -> tuple[Any, Any]: 

678 from datetime import date as _date 

679 from datetime import timedelta as _timedelta 

680 

681 try: 

682 period_months = int(period_months_raw) if period_months_raw else 12 

683 except ValueError: 

684 period_months = 12 

685 if period_months <= 0: 

686 period_months = 12 

687 end = _date.today() 

688 start = end - _timedelta(days=period_months * 30) 

689 return start, end 

690 

691 

692@aircraft_bp.route("/<aircraft_ref:aircraft_id>/owners/billing") 

693@login_required 

694@require_role(*_OWNER_ROLES) 

695def owners_billing(aircraft_id: int) -> ResponseReturnValue: 

696 from decimal import Decimal 

697 from itertools import groupby 

698 

699 from models import ( 

700 BillingAccountKind, 

701 CoOwnerValuationSnapshot, 

702 LedgerEntryType, 

703 LogbookEntryType, 

704 TenantProfile, 

705 ) 

706 from services.billing import BillingService 

707 from services.co_owner_billing import ( 

708 overdue_since, 

709 reserve_fund_balance, 

710 run_co_owner_billing_pass, 

711 ) 

712 

713 ac = _get_aircraft_or_404(aircraft_id) 

714 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

715 abort(404) 

716 

717 run_co_owner_billing_pass(ac) 

718 db.session.commit() 

719 

720 profile = TenantProfile.query.filter_by(tenant_id=ac.tenant_id).first() 

721 overdue_days = profile.co_owner_overdue_days if profile else 30 

722 

723 start, end = _co_owner_billing_period(request.args.get("period")) 

724 

725 from datetime import date as _date 

726 

727 from models import LedgerEntry 

728 

729 today = _date.today() 

730 owners = list(ac.owners) 

731 rows = [] 

732 for owner in owners: 

733 account = BillingService.get_or_create_account( 

734 ac.tenant_id, owner.user_id, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

735 ) 

736 statement = BillingService.statement(account, start, end) 

737 already_reversed_ids = { 

738 row[0] 

739 for row in db.session.query(LedgerEntry.reverses_id) 

740 .filter( 

741 LedgerEntry.account_id == account.id, 

742 LedgerEntry.reverses_id.isnot(None), 

743 ) 

744 .all() 

745 } 

746 

747 hours = Decimal(0) 

748 fixed_liability = Decimal(0) 

749 operating_liability = Decimal(0) 

750 reserve_liability = Decimal(0) 

751 payments = Decimal(0) 

752 for line in statement.lines: 

753 entry = line.entry 

754 if entry.source_type == "flight_usage": 

755 operating_liability += Decimal(entry.amount) 

756 flight = db.session.get(Flight, entry.source_id) 

757 if flight is not None and flight.flight_time is not None: 

758 hours += Decimal(flight.flight_time) 

759 elif entry.source_type == "expense_share": 

760 fixed_liability += Decimal(entry.amount) 

761 elif entry.source_type == "reserve_contribution": 

762 reserve_liability += Decimal(entry.amount) 

763 elif entry.entry_type == LedgerEntryType.PAYMENT: 

764 payments += -Decimal(entry.amount) 

765 

766 lines = [] 

767 for line in reversed(statement.lines): 

768 entry = line.entry 

769 can_reverse = ( 

770 entry.entry_type == LedgerEntryType.PAYMENT 

771 and entry.reverses_id is None 

772 and entry.id not in already_reversed_ids 

773 ) 

774 lines.append({"line": line, "can_reverse": can_reverse}) 

775 

776 since = overdue_since(account) 

777 rows.append( 

778 { 

779 "owner": owner, 

780 "hours": hours, 

781 "fixed_liability": fixed_liability, 

782 "operating_liability": operating_liability, 

783 "reserve_liability": reserve_liability, 

784 "payments": payments, 

785 "capital_balance": -BillingService.balance(account), 

786 "overdue_since": since, 

787 "overdue_flagged": since is not None 

788 and (today - since).days > overdue_days, 

789 "lines": lines, 

790 } 

791 ) 

792 

793 owner_user_ids = {o.user_id for o in owners} 

794 unattributed_flights: list[Any] = [] 

795 if ac.co_owner_billing_start is not None: 

796 unattributed_flights = [ 

797 f 

798 for f in Flight.query.filter( 

799 Flight.aircraft_id == ac.id, 

800 Flight.entry_type == LogbookEntryType.FLIGHT, 

801 Flight.date >= ac.co_owner_billing_start, 

802 Flight.flight_time.isnot(None), 

803 Flight.flight_time > 0, 

804 ).all() 

805 if f.pic_user_id not in owner_user_ids 

806 ] 

807 unattributed_hours = sum( 

808 (Decimal(f.flight_time) for f in unattributed_flights), Decimal(0) 

809 ) 

810 

811 snapshots = ( 

812 CoOwnerValuationSnapshot.query.filter_by(aircraft_id=ac.id) 

813 .order_by( 

814 CoOwnerValuationSnapshot.valuation_date.desc(), 

815 CoOwnerValuationSnapshot.id.desc(), 

816 ) 

817 .all() 

818 ) 

819 snapshot_groups = [ 

820 (valuation_date, list(items)) 

821 for valuation_date, items in groupby(snapshots, key=lambda s: s.valuation_date) 

822 ] 

823 

824 return render_template( 

825 "aircraft/owners_billing.html", 

826 aircraft=ac, 

827 rows=rows, 

828 period=request.args.get("period") or "12", 

829 snapshot_groups=snapshot_groups, 

830 unattributed_flights=unattributed_flights, 

831 unattributed_hours=unattributed_hours, 

832 today=today.isoformat(), 

833 reserve_fund_balance=reserve_fund_balance(ac), 

834 ) 

835 

836 

837def _get_current_owner_or_404(aircraft: Aircraft, user_id: int) -> AircraftOwner: 

838 owner = AircraftOwner.query.filter_by( 

839 aircraft_id=aircraft.id, user_id=user_id 

840 ).first() 

841 if owner is None: 

842 abort(404) 

843 return cast(AircraftOwner, owner) 

844 

845 

846@aircraft_bp.route( 

847 "/<aircraft_ref:aircraft_id>/owners/<int:user_id>/payment", methods=["POST"] 

848) 

849@login_required 

850@require_role(*_OWNER_ROLES) 

851def owner_record_payment(aircraft_id: int, user_id: int) -> ResponseReturnValue: 

852 from datetime import date as _date_cls 

853 

854 from models import BillingAccountKind, LedgerEntryType 

855 from services.billing import BillingService 

856 

857 ac = _get_aircraft_or_404(aircraft_id) 

858 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

859 abort(404) 

860 _get_current_owner_or_404(ac, user_id) 

861 

862 amount_raw = request.form.get("amount", "").strip() 

863 date_raw = request.form.get("date", "").strip() 

864 note = request.form.get("note", "").strip() or None 

865 

866 errors = [] 

867 try: 

868 amount = float(amount_raw) 

869 if amount <= 0: 

870 errors.append(_("Payment amount must be positive.")) 

871 except ValueError: 

872 amount = 0.0 

873 errors.append(_("Invalid payment amount.")) 

874 try: 

875 payment_date = ( 

876 _date_cls.fromisoformat(date_raw) if date_raw else _date_cls.today() 

877 ) 

878 except ValueError: 

879 payment_date = _date_cls.today() 

880 errors.append(_("Invalid payment date.")) 

881 

882 if errors: 

883 for msg in errors: 

884 flash(msg, "danger") 

885 return redirect(url_for("aircraft.owners_billing", aircraft_id=ac.id)) 

886 

887 account = BillingService.get_or_create_account( 

888 ac.tenant_id, user_id, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

889 ) 

890 recorder = db.session.get(User, session["user_id"]) 

891 description = str(_("Payment — %(note)s", note=note)) if note else str(_("Payment")) 

892 BillingService.post( 

893 account, 

894 LedgerEntryType.PAYMENT, 

895 -amount, 

896 description, 

897 payment_date, 

898 source_type="payment", 

899 created_by=recorder, 

900 ) 

901 db.session.commit() 

902 flash(_("Payment recorded."), "success") 

903 return redirect(url_for("aircraft.owners_billing", aircraft_id=ac.id)) 

904 

905 

906@aircraft_bp.route( 

907 "/<aircraft_ref:aircraft_id>/owners/<int:user_id>/entries/<int:entry_id>/reverse", 

908 methods=["POST"], 

909) 

910@login_required 

911@require_role(*_OWNER_ROLES) 

912def owner_reverse_entry( 

913 aircraft_id: int, user_id: int, entry_id: int 

914) -> ResponseReturnValue: 

915 from models import BillingAccountKind, LedgerEntry 

916 from services.billing import BillingService 

917 

918 ac = _get_aircraft_or_404(aircraft_id) 

919 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

920 abort(404) 

921 _get_current_owner_or_404(ac, user_id) 

922 

923 account = BillingService.get_or_create_account( 

924 ac.tenant_id, user_id, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

925 ) 

926 entry = db.session.get(LedgerEntry, entry_id) 

927 if entry is None or entry.account_id != account.id: 

928 abort(404) 

929 

930 recorder = db.session.get(User, session["user_id"]) 

931 try: 

932 BillingService.reverse(entry, recorder, str(_("Payment reversed"))) 

933 db.session.commit() 

934 flash(_("Entry reversed."), "success") 

935 except ValueError as exc: 

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

937 return redirect(url_for("aircraft.owners_billing", aircraft_id=ac.id)) 

938 

939 

940@aircraft_bp.route("/<aircraft_ref:aircraft_id>/owners/valuation", methods=["POST"]) 

941@login_required 

942@require_role(*_OWNER_ROLES) 

943def record_valuation_snapshot(aircraft_id: int) -> ResponseReturnValue: 

944 from datetime import date as _date_cls 

945 

946 from models import BillingAccountKind, CoOwnerValuationSnapshot 

947 from services.billing import BillingService 

948 from services.co_owner_billing import run_co_owner_billing_pass 

949 

950 ac = _get_aircraft_or_404(aircraft_id) 

951 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

952 abort(404) 

953 

954 date_raw = request.form.get("date", "").strip() 

955 note = request.form.get("note", "").strip() or None 

956 try: 

957 valuation_date = ( 

958 _date_cls.fromisoformat(date_raw) if date_raw else _date_cls.today() 

959 ) 

960 except ValueError: 

961 valuation_date = _date_cls.today() 

962 

963 run_co_owner_billing_pass(ac) 

964 db.session.commit() 

965 

966 recorder = db.session.get(User, session["user_id"]) 

967 for owner in list(ac.owners): 

968 account = BillingService.get_or_create_account( 

969 ac.tenant_id, owner.user_id, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

970 ) 

971 balance = BillingService.balance(account, as_of=valuation_date) 

972 db.session.add( 

973 CoOwnerValuationSnapshot( 

974 aircraft_id=ac.id, 

975 user_id=owner.user_id, 

976 valuation_date=valuation_date, 

977 share_pct=owner.share_pct, 

978 capital_balance=-balance, 

979 note=note, 

980 created_by_id=recorder.id if recorder else None, 

981 ) 

982 ) 

983 db.session.commit() 

984 flash(_("Valuation snapshot recorded."), "success") 

985 return redirect(url_for("aircraft.owners_billing", aircraft_id=ac.id)) 

986 

987 

988@aircraft_bp.route("/<aircraft_ref:aircraft_id>/owners/<int:user_id>/account") 

989@login_required 

990@require_role(*_OWNER_ROLES) 

991def owner_account(aircraft_id: int, user_id: int) -> ResponseReturnValue: 

992 from models import BillingAccountKind 

993 from services.billing import BillingService 

994 

995 ac = _get_aircraft_or_404(aircraft_id) 

996 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

997 abort(404) 

998 owner = _get_current_owner_or_404(ac, user_id) 

999 

1000 account = BillingService.get_or_create_account( 

1001 ac.tenant_id, user_id, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

1002 ) 

1003 db.session.commit() 

1004 start, end = _co_owner_billing_period(request.args.get("period")) 

1005 statement = BillingService.statement(account, start, end) 

1006 

1007 return render_template( 

1008 "aircraft/owner_account.html", 

1009 aircraft=ac, 

1010 owner=owner, 

1011 account=account, 

1012 statement=statement, 

1013 balance=BillingService.balance(account), 

1014 is_owner_view=True, 

1015 csv_url=url_for( 

1016 "aircraft.owner_statement_csv", aircraft_id=ac.id, user_id=user_id 

1017 ), 

1018 ) 

1019 

1020 

1021@aircraft_bp.route( 

1022 "/<aircraft_ref:aircraft_id>/owners/<int:user_id>/account/statement.csv" 

1023) 

1024@login_required 

1025@require_role(*_OWNER_ROLES) 

1026def owner_statement_csv(aircraft_id: int, user_id: int) -> ResponseReturnValue: 

1027 from flask import Response 

1028 from models import BillingAccountKind 

1029 from services.billing import BillingService 

1030 

1031 ac = _get_aircraft_or_404(aircraft_id) 

1032 if not _shared_ownership_enabled(ac.tenant_id, ac.id): 

1033 abort(404) 

1034 _get_current_owner_or_404(ac, user_id) 

1035 

1036 account = BillingService.get_or_create_account( 

1037 ac.tenant_id, user_id, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

1038 ) 

1039 db.session.commit() 

1040 start, end = _co_owner_billing_period(request.args.get("period")) 

1041 statement = BillingService.statement(account, start, end) 

1042 exporter = db.session.get(User, session["user_id"]) 

1043 csv_text = BillingService.statement_csv(statement, exported_by=exporter) 

1044 filename = ( 

1045 f"co_owner_statement_{ac.registration}_{user_id}_" 

1046 f"{start.isoformat()}_{end.isoformat()}.csv" 

1047 ) 

1048 return Response( 

1049 csv_text, 

1050 mimetype="text/csv", 

1051 headers={"Content-Disposition": f"attachment; filename={filename}"}, 

1052 ) 

1053 

1054 

1055@aircraft_bp.route("/<aircraft_ref:aircraft_id>/my-share") 

1056@login_required 

1057def my_share(aircraft_id: int) -> ResponseReturnValue: 

1058 from models import BillingAccountKind 

1059 from services.billing import BillingService 

1060 

1061 ac = _get_tenant_aircraft_or_404(aircraft_id) 

1062 uid = session["user_id"] 

1063 owner = AircraftOwner.query.filter_by(aircraft_id=ac.id, user_id=uid).first() 

1064 if owner is None: 

1065 abort(404) 

1066 

1067 account = BillingService.get_or_create_account( 

1068 ac.tenant_id, uid, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

1069 ) 

1070 db.session.commit() 

1071 start, end = _co_owner_billing_period(request.args.get("period")) 

1072 statement = BillingService.statement(account, start, end) 

1073 

1074 return render_template( 

1075 "aircraft/owner_account.html", 

1076 aircraft=ac, 

1077 owner=owner, 

1078 account=account, 

1079 statement=statement, 

1080 balance=BillingService.balance(account), 

1081 is_owner_view=False, 

1082 csv_url=url_for("aircraft.my_share_statement_csv", aircraft_id=ac.id), 

1083 ) 

1084 

1085 

1086@aircraft_bp.route("/<aircraft_ref:aircraft_id>/my-share/statement.csv") 

1087@login_required 

1088def my_share_statement_csv(aircraft_id: int) -> ResponseReturnValue: 

1089 from flask import Response 

1090 from models import BillingAccountKind 

1091 from services.billing import BillingService 

1092 

1093 ac = _get_tenant_aircraft_or_404(aircraft_id) 

1094 uid = session["user_id"] 

1095 owner = AircraftOwner.query.filter_by(aircraft_id=ac.id, user_id=uid).first() 

1096 if owner is None: 

1097 abort(404) 

1098 

1099 account = BillingService.get_or_create_account( 

1100 ac.tenant_id, uid, BillingAccountKind.CO_OWNER, aircraft_id=ac.id 

1101 ) 

1102 db.session.commit() 

1103 start, end = _co_owner_billing_period(request.args.get("period")) 

1104 statement = BillingService.statement(account, start, end) 

1105 exporter = db.session.get(User, uid) 

1106 csv_text = BillingService.statement_csv(statement, exported_by=exporter) 

1107 filename = f"my_share_statement_{ac.registration}_{start.isoformat()}_{end.isoformat()}.csv" 

1108 return Response( 

1109 csv_text, 

1110 mimetype="text/csv", 

1111 headers={"Content-Disposition": f"attachment; filename={filename}"}, 

1112 ) 

1113 

1114 

1115# ── Delete aircraft ─────────────────────────────────────────────────────────── 

1116 

1117 

1118@aircraft_bp.route("/<aircraft_ref:aircraft_id>/archive", methods=["POST"]) 

1119@login_required 

1120@require_role(*_OWNER_ROLES) 

1121def archive_aircraft(aircraft_id: int) -> ResponseReturnValue: 

1122 from datetime import datetime 

1123 

1124 ac = _get_aircraft_or_404(aircraft_id) 

1125 if not ac.is_archived: 

1126 ac.archived_at = datetime.now(UTC) 

1127 db.session.commit() 

1128 activity("aircraft.archived", registration=ac.registration, aircraft_id=ac.id) 

1129 flash( 

1130 _( 

1131 "%(reg)s has been archived — its history stays available, but it no " 

1132 "longer appears in the active fleet.", 

1133 reg=ac.registration, 

1134 ), 

1135 "success", 

1136 ) 

1137 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

1138 

1139 

1140@aircraft_bp.route("/<aircraft_ref:aircraft_id>/unarchive", methods=["POST"]) 

1141@login_required 

1142@require_role(*_OWNER_ROLES) 

1143def unarchive_aircraft(aircraft_id: int) -> ResponseReturnValue: 

1144 ac = _get_aircraft_or_404(aircraft_id) 

1145 if ac.is_archived: 

1146 ac.archived_at = None 

1147 db.session.commit() 

1148 activity("aircraft.unarchived", registration=ac.registration, aircraft_id=ac.id) 

1149 flash(_("%(reg)s is back in the active fleet.", reg=ac.registration), "success") 

1150 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

1151 

1152 

1153@aircraft_bp.route("/<aircraft_ref:aircraft_id>/delete", methods=["POST"]) 

1154@login_required 

1155@require_role(*_OWNER_ROLES) 

1156def delete_aircraft(aircraft_id: int) -> ResponseReturnValue: 

1157 ac = _get_aircraft_or_404(aircraft_id) 

1158 reg = ac.registration 

1159 activity("aircraft.deleted", registration=reg, aircraft_id=aircraft_id) 

1160 db.session.delete(ac) 

1161 db.session.commit() 

1162 flash(_("%(reg)s and all its components have been deleted.", reg=reg), "success") 

1163 return redirect(url_for("aircraft.list_aircraft")) 

1164 

1165 

1166# ── Quick-add components from ICAO type suggestion ──────────────────────────── 

1167 

1168 

1169@aircraft_bp.route("/<aircraft_ref:aircraft_id>/quick-add-components", methods=["POST"]) 

1170@login_required 

1171@require_role(*_OWNER_ROLES) 

1172def quick_add_components(aircraft_id: int) -> ResponseReturnValue: 

1173 ac = _get_aircraft_or_404(aircraft_id) 

1174 try: 

1175 engine_count = max(1, min(int(request.form.get("engine_count", "1")), 4)) 

1176 except ValueError: 

1177 engine_count = 1 

1178 for i in range(engine_count): 

1179 position = str(i + 1) if engine_count > 1 else None 

1180 db.session.add( 

1181 Component( 

1182 aircraft_id=ac.id, 

1183 type=ComponentType.ENGINE, 

1184 position=position, 

1185 make="", 

1186 model="", 

1187 ) 

1188 ) 

1189 db.session.add( 

1190 Component( 

1191 aircraft_id=ac.id, 

1192 type=ComponentType.PROPELLER, 

1193 position=position, 

1194 make="", 

1195 model="", 

1196 ) 

1197 ) 

1198 db.session.commit() 

1199 activity( 

1200 "component.quick_added", 

1201 aircraft_id=aircraft_id, 

1202 engine_count=engine_count, 

1203 ) 

1204 flash( 

1205 ngettext( 

1206 "Engine and propeller added — fill in the details when ready.", 

1207 "%(n)s engines and propellers added — fill in the details when ready.", 

1208 engine_count, 

1209 n=engine_count, 

1210 ), 

1211 "success", 

1212 ) 

1213 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

1214 

1215 

1216# ── Add component ───────────────────────────────────────────────────────────── 

1217 

1218 

1219@aircraft_bp.route( 

1220 "/<aircraft_ref:aircraft_id>/components/new", methods=["GET", "POST"] 

1221) 

1222@login_required 

1223@require_role(*_OWNER_ROLES) 

1224def new_component(aircraft_id: int) -> ResponseReturnValue: 

1225 ac = _get_aircraft_or_404(aircraft_id) 

1226 if request.method == "POST": 

1227 return _save_component(ac, None) 

1228 return render_template( 

1229 "aircraft/component_form.html", 

1230 aircraft=ac, 

1231 component=None, 

1232 component_types=ComponentType, 

1233 ) 

1234 

1235 

1236# ── Edit component ──────────────────────────────────────────────────────────── 

1237 

1238 

1239@aircraft_bp.route( 

1240 "/<aircraft_ref:aircraft_id>/components/<int:component_id>/edit", 

1241 methods=["GET", "POST"], 

1242) 

1243@login_required 

1244@require_role(*_OWNER_ROLES) 

1245def edit_component(aircraft_id: int, component_id: int) -> ResponseReturnValue: 

1246 ac = _get_aircraft_or_404(aircraft_id) 

1247 comp = _get_component_or_404(ac, component_id) 

1248 if request.method == "POST": 

1249 return _save_component(ac, comp) 

1250 return render_template( 

1251 "aircraft/component_form.html", 

1252 aircraft=ac, 

1253 component=comp, 

1254 component_types=ComponentType, 

1255 ) 

1256 

1257 

1258def _save_component(ac: Aircraft, comp: Component | None) -> ResponseReturnValue: 

1259 from datetime import date as _date 

1260 

1261 type_ = request.form.get("type", "").strip() 

1262 position = request.form.get("position", "").strip() or None 

1263 make = request.form.get("make", "").strip() 

1264 model = request.form.get("model", "").strip() 

1265 serial = request.form.get("serial_number", "").strip() or None 

1266 time_raw = request.form.get("time_at_install", "").strip() 

1267 installed_raw = request.form.get("installed_at", "").strip() 

1268 removed_raw = request.form.get("removed_at", "").strip() 

1269 tbo_raw = request.form.get("tbo_hours", "").strip() 

1270 life_limit_raw = request.form.get("life_limit_date", "").strip() 

1271 overhauled_at_raw = request.form.get("overhauled_at_hours", "").strip() 

1272 overhauled_on_raw = request.form.get("overhauled_on", "").strip() 

1273 

1274 errors = [] 

1275 if not type_: 

1276 errors.append(_("Component type is required.")) 

1277 if not make: 

1278 errors.append(_("Manufacturer is required.")) 

1279 if not model: 

1280 errors.append(_("Model is required.")) 

1281 

1282 time_at_install = None 

1283 if time_raw: 

1284 try: 

1285 time_at_install = float(time_raw) 

1286 if time_at_install < 0: 

1287 raise ValueError 

1288 except ValueError: 

1289 errors.append(_("Time at install must be a positive number.")) 

1290 

1291 tbo_hours = None 

1292 if tbo_raw: 

1293 try: 

1294 tbo_hours = float(tbo_raw) 

1295 if tbo_hours <= 0: 

1296 raise ValueError 

1297 except ValueError: 

1298 errors.append(_("TBO must be a positive number of hours.")) 

1299 

1300 overhauled_at_hours = None 

1301 if overhauled_at_raw: 

1302 try: 

1303 overhauled_at_hours = float(overhauled_at_raw) 

1304 if overhauled_at_hours < 0: 

1305 raise ValueError 

1306 except ValueError: 

1307 errors.append(_("Last overhaul hours must be a non-negative number.")) 

1308 

1309 def _parse_date(raw: str, label: str) -> Any: 

1310 if not raw: 

1311 return None 

1312 try: 

1313 return _date.fromisoformat(raw) 

1314 except ValueError: 

1315 errors.append( 

1316 _("%(label)s must be a valid date (YYYY-MM-DD).", label=label) 

1317 ) 

1318 return None 

1319 

1320 installed_at = _parse_date(installed_raw, "Install date") 

1321 removed_at = _parse_date(removed_raw, "Removal date") 

1322 life_limit_date = _parse_date(life_limit_raw, "Calendar life limit") 

1323 overhauled_on = _parse_date(overhauled_on_raw, "Last overhaul date") 

1324 

1325 if errors: 

1326 for msg in errors: 

1327 flash(msg, "danger") 

1328 return render_template( 

1329 "aircraft/component_form.html", 

1330 aircraft=ac, 

1331 component=comp, 

1332 component_types=ComponentType, 

1333 ) 

1334 

1335 _comp_is_new = comp is None 

1336 if comp is None: 

1337 comp = Component(aircraft_id=ac.id) 

1338 db.session.add(comp) 

1339 

1340 comp.type = type_ 

1341 comp.position = position 

1342 comp.make = make 

1343 comp.model = model 

1344 comp.serial_number = serial 

1345 comp.time_at_install = time_at_install 

1346 comp.installed_at = installed_at 

1347 comp.removed_at = removed_at 

1348 comp.tbo_hours = tbo_hours 

1349 comp.life_limit_date = life_limit_date 

1350 comp.overhauled_at_hours = overhauled_at_hours 

1351 comp.overhauled_on = overhauled_on 

1352 db.session.commit() 

1353 

1354 if _comp_is_new: 

1355 activity( 

1356 "component.added", type=comp.type, component_id=comp.id, aircraft_id=ac.id 

1357 ) 

1358 else: 

1359 activity( 

1360 "component.updated", type=comp.type, component_id=comp.id, aircraft_id=ac.id 

1361 ) 

1362 

1363 flash(_("%(make)s %(model)s saved.", make=comp.make, model=comp.model), "success") 

1364 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

1365 

1366 

1367# ── Delete component ────────────────────────────────────────────────────────── 

1368 

1369 

1370@aircraft_bp.route( 

1371 "/<aircraft_ref:aircraft_id>/components/<int:component_id>/delete", methods=["POST"] 

1372) 

1373@login_required 

1374@require_role(*_OWNER_ROLES) 

1375def delete_component(aircraft_id: int, component_id: int) -> ResponseReturnValue: 

1376 ac = _get_aircraft_or_404(aircraft_id) 

1377 comp = _get_component_or_404(ac, component_id) 

1378 label = f"{comp.make} {comp.model}" 

1379 activity( 

1380 "component.deleted", 

1381 type=comp.type, 

1382 component_id=component_id, 

1383 aircraft_id=aircraft_id, 

1384 ) 

1385 db.session.delete(comp) 

1386 db.session.commit() 

1387 flash(_("%(label)s removed.", label=label), "success") 

1388 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

1389 

1390 

1391# ── Mass & Balance: helpers ─────────────────────────────────────────────────── 

1392 

1393 

1394def _point_in_polygon(cg: float, weight: float, points: Any) -> bool: 

1395 """Ray-casting point-in-polygon test. points: list of [arm, weight] pairs. 

1396 

1397 Returns False (outside) for malformed point data rather than raising — 

1398 envelope_points is a DB-stored JSON field with no enforced schema, so a 

1399 corrupted or malformed entry should degrade to "envelope check 

1400 unavailable" (the conservative, fail-safe answer for a W&B calculation), 

1401 not crash the entry page with an unhandled 500. Non-finite coordinates 

1402 (e.g. "Infinity"/"NaN" — accepted by float() but not a valid arm/weight 

1403 value) are rejected the same way. 

1404 """ 

1405 try: 

1406 coords = [(float(p[0]), float(p[1])) for p in points] 

1407 except (IndexError, ValueError, TypeError): 

1408 return False 

1409 if any(not (math.isfinite(x) and math.isfinite(y)) for x, y in coords): 

1410 return False 

1411 n = len(coords) 

1412 inside = False 

1413 j = n - 1 

1414 for i in range(n): 

1415 xi, yi = coords[i] 

1416 xj, yj = coords[j] 

1417 if ((yi > weight) != (yj > weight)) and ( 

1418 cg < (xj - xi) * (weight - yi) / (yj - yi) + xi 

1419 ): 

1420 inside = not inside 

1421 j = i 

1422 return inside 

1423 

1424 

1425# ── Mass & Balance: config ──────────────────────────────────────────────────── 

1426 

1427 

1428@aircraft_bp.route("/<aircraft_ref:aircraft_id>/wb/config", methods=["GET", "POST"]) 

1429@login_required 

1430@require_role(*_OWNER_ROLES) 

1431def wb_config(aircraft_id: int) -> ResponseReturnValue: 

1432 ac = _get_aircraft_or_404(aircraft_id) 

1433 cfg: WeightBalanceConfig | None = ac.wb_config # type: ignore[assignment] 

1434 

1435 if request.method == "POST": 

1436 errors = [] 

1437 

1438 def _f(name: str) -> float | None: 

1439 try: 

1440 v = float(request.form.get(name, "").strip()) 

1441 if v < 0: 

1442 raise ValueError 

1443 return v 

1444 except ValueError: 

1445 errors.append(_("%(field)s must be a positive number.", field=name)) 

1446 return None 

1447 

1448 empty_weight = _f("empty_weight") 

1449 empty_cg_arm = _f("empty_cg_arm") 

1450 max_takeoff_weight = _f("max_takeoff_weight") 

1451 forward_cg_limit = _f("forward_cg_limit") 

1452 aft_cg_limit = _f("aft_cg_limit") 

1453 datum_note = request.form.get("datum_note", "").strip() or None 

1454 

1455 fuel_unit = request.form.get("fuel_unit", "L").strip() 

1456 if fuel_unit not in ("L", "gal"): 

1457 fuel_unit = "L" 

1458 

1459 # Stations: label[], arm[], station_limit[] (capacity for fuel, max_weight for non-fuel), is_fuel[] 

1460 labels = request.form.getlist("station_label[]") 

1461 arms = request.form.getlist("station_arm[]") 

1462 limits = request.form.getlist("station_limit[]") 

1463 is_fuels = request.form.getlist( 

1464 "station_is_fuel[]" 

1465 ) # index values of checked boxes 

1466 

1467 if not labels or all(lbl.strip() == "" for lbl in labels): 

1468 errors.append(_("At least one loading station is required.")) 

1469 

1470 if errors: 

1471 for msg in errors: 

1472 flash(msg, "danger") 

1473 return render_template("aircraft/wb_config.html", aircraft=ac, config=cfg) 

1474 

1475 if cfg is None: 

1476 cfg = WeightBalanceConfig(aircraft_id=ac.id) 

1477 db.session.add(cfg) 

1478 

1479 # Optional envelope polygon: env_arm[], env_weight[] 

1480 env_arms = request.form.getlist("env_arm[]") 

1481 env_weights = request.form.getlist("env_weight[]") 

1482 envelope_points = [] 

1483 for arm_s, w_s in zip(env_arms, env_weights): 

1484 try: 

1485 a = float(arm_s.strip()) 

1486 w = float(w_s.strip()) 

1487 if a >= 0 and w >= 0: 

1488 envelope_points.append([round(a, 4), round(w, 2)]) 

1489 except (ValueError, AttributeError): 

1490 continue 

1491 

1492 cfg.empty_weight = empty_weight 

1493 cfg.empty_cg_arm = empty_cg_arm 

1494 cfg.max_takeoff_weight = max_takeoff_weight 

1495 cfg.forward_cg_limit = forward_cg_limit 

1496 cfg.aft_cg_limit = aft_cg_limit 

1497 cfg.fuel_unit = fuel_unit 

1498 cfg.datum_note = datum_note 

1499 cfg.envelope_points = envelope_points if len(envelope_points) >= 3 else None 

1500 

1501 # Replace stations 

1502 for s in list(cfg.stations): 

1503 db.session.delete(s) 

1504 db.session.flush() 

1505 

1506 for i, label in enumerate(labels): 

1507 label = label.strip() 

1508 if not label: 

1509 continue 

1510 try: 

1511 arm = float(arms[i]) 

1512 except (ValueError, IndexError): 

1513 continue 

1514 limit_val = None 

1515 try: 

1516 lim_raw = limits[i].strip() 

1517 if lim_raw: 

1518 limit_val = float(lim_raw) 

1519 except (ValueError, IndexError): 

1520 limit_val = None 

1521 is_fuel = str(i) in is_fuels 

1522 db.session.add( 

1523 WeightBalanceStation( 

1524 config_id=cfg.id, 

1525 label=label, 

1526 arm=arm, 

1527 max_weight=None if is_fuel else limit_val, 

1528 capacity=limit_val if is_fuel else None, 

1529 is_fuel=is_fuel, 

1530 position=i, 

1531 ) 

1532 ) 

1533 

1534 db.session.commit() 

1535 flash(_("W&B configuration saved."), "success") 

1536 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

1537 

1538 return render_template("aircraft/wb_config.html", aircraft=ac, config=cfg) 

1539 

1540 

1541# ── Mass & Balance: entry list ──────────────────────────────────────────────── 

1542 

1543 

1544@aircraft_bp.route("/<aircraft_ref:aircraft_id>/wb/") 

1545@login_required 

1546def wb_list(aircraft_id: int) -> ResponseReturnValue: 

1547 ac = _get_aircraft_or_404(aircraft_id) 

1548 if not ac.wb_config: 

1549 flash(_("Configure W&B envelope first."), "warning") 

1550 return redirect(url_for("aircraft.wb_config", aircraft_id=ac.id)) 

1551 entries = ( 

1552 WeightBalanceEntry.query.filter_by(config_id=ac.wb_config.id) 

1553 .order_by(WeightBalanceEntry.date.desc(), WeightBalanceEntry.id.desc()) 

1554 .all() 

1555 ) 

1556 return render_template( 

1557 "aircraft/wb_list.html", aircraft=ac, config=ac.wb_config, entries=entries 

1558 ) 

1559 

1560 

1561# ── Mass & Balance: new / edit entry ───────────────────────────────────────── 

1562 

1563 

1564@aircraft_bp.route("/<aircraft_ref:aircraft_id>/wb/new", methods=["GET", "POST"]) 

1565@aircraft_bp.route( 

1566 "/<aircraft_ref:aircraft_id>/wb/<int:entry_id>/edit", methods=["GET", "POST"] 

1567) 

1568@login_required 

1569@require_role(*_PILOT_ROLES) 

1570def wb_entry(aircraft_id: int, entry_id: int | None = None) -> ResponseReturnValue: 

1571 ac = _get_aircraft_or_404(aircraft_id) 

1572 if not ac.wb_config: 

1573 flash(_("Configure W&B envelope first."), "warning") 

1574 return redirect(url_for("aircraft.wb_config", aircraft_id=ac.id)) 

1575 cfg = ac.wb_config 

1576 

1577 entry = None 

1578 if entry_id is not None: 

1579 entry = db.session.get(WeightBalanceEntry, entry_id) 

1580 if not entry or entry.config_id != cfg.id: 

1581 abort(404) 

1582 

1583 if request.method == "POST": 

1584 from datetime import date as _date 

1585 

1586 errors = [] 

1587 date_raw = request.form.get("date", "").strip() 

1588 label = request.form.get("label", "").strip() or None 

1589 try: 

1590 entry_date = _date.fromisoformat(date_raw) 

1591 except ValueError: 

1592 errors.append(_("A valid date is required.")) 

1593 entry_date = None 

1594 

1595 # Per-station values: fuel stations store volume (L/gal), non-fuel store kg 

1596 station_weights = {} 

1597 for st in cfg.stations: 

1598 if st.is_fuel: 

1599 raw = request.form.get(f"volume_{st.id}", "").strip() 

1600 try: 

1601 vol = float(raw) if raw else 0.0 

1602 if vol < 0: 

1603 raise ValueError 

1604 if st.capacity is not None and vol > float(st.capacity): 

1605 errors.append( 

1606 _( 

1607 "Volume for %(station)s exceeds tank capacity.", 

1608 station=st.label, 

1609 ) 

1610 ) 

1611 station_weights[str(st.id)] = vol 

1612 except ValueError: 

1613 errors.append( 

1614 _( 

1615 "Volume for %(station)s must be a non-negative number.", 

1616 station=st.label, 

1617 ) 

1618 ) 

1619 else: 

1620 raw = request.form.get(f"weight_{st.id}", "").strip() 

1621 try: 

1622 w = float(raw) if raw else 0.0 

1623 if w < 0: 

1624 raise ValueError 

1625 station_weights[str(st.id)] = w 

1626 except ValueError: 

1627 errors.append( 

1628 _( 

1629 "Weight for %(station)s must be a non-negative number.", 

1630 station=st.label, 

1631 ) 

1632 ) 

1633 

1634 # CG computation — fuel stations: convert volume → kg 

1635 empty_w = float(cfg.empty_weight) 

1636 empty_arm = float(cfg.empty_cg_arm) 

1637 total_moment = empty_w * empty_arm 

1638 total_weight = empty_w 

1639 fuel_density = FUEL_DENSITY.get(ac.fuel_type, 0.72) 

1640 gal_factor = GAL_TO_L if cfg.fuel_unit == "gal" else 1.0 

1641 for st in cfg.stations: 

1642 val = station_weights.get(str(st.id), 0.0) 

1643 w_kg = val * fuel_density * gal_factor if st.is_fuel else val 

1644 total_weight += w_kg 

1645 total_moment += w_kg * float(st.arm) 

1646 loaded_cg = total_moment / total_weight if total_weight else 0.0 

1647 

1648 if cfg.envelope_points and len(cfg.envelope_points) >= 3: 

1649 in_env = _point_in_polygon(loaded_cg, total_weight, cfg.envelope_points) 

1650 else: 

1651 mtow = float(cfg.max_takeoff_weight) 

1652 fwd = float(cfg.forward_cg_limit) 

1653 aft = float(cfg.aft_cg_limit) 

1654 in_env = total_weight <= mtow and fwd <= loaded_cg <= aft 

1655 

1656 if errors: 

1657 for msg in errors: 

1658 flash(msg, "danger") 

1659 return render_template( 

1660 "aircraft/wb_entry.html", 

1661 aircraft=ac, 

1662 config=cfg, 

1663 entry=entry, 

1664 fuel_density=FUEL_DENSITY, 

1665 ) 

1666 

1667 if entry is None: 

1668 entry = WeightBalanceEntry(config_id=cfg.id) 

1669 db.session.add(entry) 

1670 

1671 entry.date = entry_date 

1672 entry.label = label 

1673 entry.total_weight = round(total_weight, 2) 

1674 entry.loaded_cg = round(loaded_cg, 2) 

1675 entry.is_in_envelope = in_env 

1676 entry.station_weights = station_weights 

1677 db.session.commit() 

1678 flash(_("W&B calculation saved."), "success") 

1679 return redirect(url_for("aircraft.wb_list", aircraft_id=ac.id)) 

1680 

1681 return render_template( 

1682 "aircraft/wb_entry.html", 

1683 aircraft=ac, 

1684 config=cfg, 

1685 entry=entry, 

1686 fuel_density=FUEL_DENSITY, 

1687 ) 

1688 

1689 

1690# ── Mass & Balance: delete entry ────────────────────────────────────────────── 

1691 

1692 

1693@aircraft_bp.route( 

1694 "/<aircraft_ref:aircraft_id>/wb/<int:entry_id>/delete", methods=["POST"] 

1695) 

1696@login_required 

1697@require_role(*_PILOT_ROLES) 

1698def wb_entry_delete(aircraft_id: int, entry_id: int) -> ResponseReturnValue: 

1699 ac = _get_aircraft_or_404(aircraft_id) 

1700 if not ac.wb_config: 

1701 abort(404) 

1702 entry = db.session.get(WeightBalanceEntry, entry_id) 

1703 if not entry or entry.config_id != ac.wb_config.id: 

1704 abort(404) 

1705 db.session.delete(entry) 

1706 db.session.commit() 

1707 flash(_("W&B calculation deleted."), "success") 

1708 return redirect(url_for("aircraft.wb_list", aircraft_id=ac.id)) 

1709 

1710 

1711# ── Phase 30: GPS Log Import ────────────────────────────────────────────────── 

1712 

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

1714_GPS_MAX_BYTES = 20 * 1024 * 1024 # 20 MB per file 

1715 

1716 

1717def _gps_tmp_dir() -> str: 

1718 """Return (and create if needed) the tmp directory for GPS uploads.""" 

1719 upload_folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads") 

1720 d = os.path.join(upload_folder, "gps_import_tmp") 

1721 os.makedirs(d, exist_ok=True) 

1722 return d 

1723 

1724 

1725def _segment_to_dict(seg: Any, idx: int) -> dict[str, Any]: 

1726 """Serialise a FlightSegment for template rendering (includes track_geojson).""" 

1727 return { 

1728 "idx": idx, 

1729 "block_off_utc": seg.block_off_utc.isoformat(), 

1730 "block_on_utc": seg.block_on_utc.isoformat(), 

1731 "takeoff_utc": seg.takeoff_utc.isoformat() if seg.takeoff_utc else None, 

1732 "landing_utc": seg.landing_utc.isoformat() if seg.landing_utc else None, 

1733 "departure_icao": seg.departure_icao or "", 

1734 "arrival_icao": seg.arrival_icao or "", 

1735 "flight_time_raw_h": seg.flight_time_raw_h, 

1736 "flight_time_rounded_h": seg.flight_time_rounded_h, 

1737 "landing_count": seg.landing_count, 

1738 "is_ground_only": seg.is_ground_only, 

1739 "track_geojson": seg.track_geojson, 

1740 } 

1741 

1742 

1743def _linked_pilot_entries(flight_id: int, exclude_user_id: int) -> list[dict[str, Any]]: 

1744 """Return metadata about the *other* crew member (if any) on this Flight 

1745 row, for the GPS-track-replacement notice shown before an upload 

1746 overwrites the shared airframe/GPS data. 

1747 

1748 Unified model note: there's only one gps_track_id now, shared by both 

1749 crew slots — the old per-pilot "preserve their own track if they already 

1750 have one" policy doesn't apply any more (there's nothing to preserve 

1751 separately); this is now purely informational — surfacing that another 

1752 named pilot is also linked to this flight and will see the replaced 

1753 track too. 

1754 """ 

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

1756 assert fe is not None, "caller passes a Flight id it just queried" 

1757 other_user_id = None 

1758 if fe.pic_user_id and fe.pic_user_id != exclude_user_id: 

1759 other_user_id = fe.pic_user_id 

1760 elif fe.second_crew_user_id and fe.second_crew_user_id != exclude_user_id: 

1761 other_user_id = fe.second_crew_user_id 

1762 if other_user_id is None: 

1763 return [] 

1764 user = db.session.get(User, other_user_id) 

1765 return [ 

1766 { 

1767 "user_id": other_user_id, 

1768 "display_name": user.display_name if user else f"user #{other_user_id}", 

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

1770 } 

1771 ] 

1772 

1773 

1774def _gps_candidate_str(fe: Any) -> str: 

1775 """One-line summary of an existing Flight row for a GPS-import match 

1776 picker — includes whatever the row already has recorded (times, 

1777 duration) so a human can tell it apart from a similar-looking flight 

1778 without needing to open it first.""" 

1779 s = f"#{fe.id}{fe.date} {fe.departure_icao}{fe.arrival_icao}" 

1780 if fe.departure_time and fe.arrival_time: 

1781 s += ( 

1782 f" ({fe.departure_time.strftime('%H:%M')}" 

1783 f"{fe.arrival_time.strftime('%H:%M')})" 

1784 ) 

1785 if fe.total_flight_time is not None: 

1786 s += f", {fe.total_flight_time:.1f} h" 

1787 return s 

1788 

1789 

1790def _gps_candidate_dict(fe: Any) -> dict[str, Any]: 

1791 """Serialise one match candidate for the review template/session.""" 

1792 return { 

1793 "id": fe.id, 

1794 "str": _gps_candidate_str(fe), 

1795 "aircraft_id": fe.aircraft_id, 

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

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

1798 } 

1799 

1800 

1801def _load_segment_geojson(seg: dict[str, Any]) -> Any: 

1802 """Read the GeoJSON dict back from the tmp file written by _segment_for_session.""" 

1803 path = seg.get("geojson_path") 

1804 if not path or not os.path.exists(path): 

1805 return None 

1806 with open(path, encoding="utf-8") as fh: 

1807 return json.load(fh) 

1808 

1809 

1810def _segment_for_session(seg_dict: dict[str, Any], tmp_dir: str) -> dict[str, Any]: 

1811 """Return a copy of seg_dict safe for cookie-session storage. 

1812 

1813 track_geojson can be hundreds of KB — too large for Flask's 4 KB cookie 

1814 limit. We spill it to a tmp file and store the path instead. 

1815 """ 

1816 s = {k: v for k, v in seg_dict.items() if k != "track_geojson"} 

1817 geojson = seg_dict.get("track_geojson") 

1818 if geojson is not None: 

1819 fname = f"seg_{seg_dict['idx']}_{_uuid_mod.uuid4().hex}.geojson" 

1820 path = os.path.join(tmp_dir, fname) 

1821 with open(path, "w", encoding="utf-8") as fh: 

1822 json.dump(geojson, fh) 

1823 s["geojson_path"] = path 

1824 return s 

1825 

1826 

1827@aircraft_bp.route("/<aircraft_ref:aircraft_id>/gps-import", methods=["GET", "POST"]) 

1828@login_required 

1829@require_role(*_PILOT_ROLES) 

1830def gps_import_upload(aircraft_id: int) -> ResponseReturnValue: 

1831 ac = _get_aircraft_or_404(aircraft_id) 

1832 

1833 if request.method == "GET": 

1834 return render_template("aircraft/gps_import_upload.html", aircraft=ac) 

1835 

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

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

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

1839 return render_template("aircraft/gps_import_upload.html", aircraft=ac) 

1840 

1841 tmp_dir = _gps_tmp_dir() 

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

1843 errors: list[str] = [] 

1844 skipped_empty = 0 

1845 formats: list[str] = [] 

1846 

1847 for f in files: 

1848 if not f.filename: 

1849 continue 

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

1851 if ext not in _GPS_ALLOWED_EXTS: 

1852 errors.append( 

1853 _( 

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

1855 fn=f.filename, 

1856 ) 

1857 ) 

1858 continue 

1859 

1860 data = f.read(_GPS_MAX_BYTES + 1) 

1861 if len(data) > _GPS_MAX_BYTES: 

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

1863 continue 

1864 

1865 try: 

1866 parsed = parse_gps_file(data, f.filename) 

1867 except ValueError as exc: 

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

1869 continue 

1870 

1871 if parsed.classification == "empty": 

1872 skipped_empty += 1 

1873 continue 

1874 

1875 # Save raw bytes to tmp 

1876 uid = _uuid_mod.uuid4().hex 

1877 safe_name = f"{uid}_{secure_filename(f.filename)}" 

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

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

1880 fh.write(data) 

1881 

1882 parsed_meta.append( 

1883 { 

1884 "tmp_path": tmp_path, 

1885 "original_filename": f.filename, 

1886 "format": parsed.format, 

1887 "classification": parsed.classification, 

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

1889 "hint_dep": parsed.hint_departure_icao, 

1890 "hint_arr": parsed.hint_arrival_icao, 

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

1892 } 

1893 ) 

1894 formats.append(parsed.format) 

1895 

1896 if errors: 

1897 for e in errors: 

1898 flash(e, "danger") 

1899 if skipped_empty: 

1900 flash( 

1901 ngettext( 

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

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

1904 skipped_empty, 

1905 n=skipped_empty, 

1906 ), 

1907 "info", 

1908 ) 

1909 

1910 if not parsed_meta: 

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

1912 return render_template("aircraft/gps_import_upload.html", aircraft=ac) 

1913 

1914 other_aircraft = request.form.get("other_aircraft") == "1" 

1915 other_ac_make_model = request.form.get("other_ac_make_model", "").strip() 

1916 other_ac_reg = request.form.get("other_ac_reg", "").strip().upper() 

1917 

1918 session["gps_import"] = { 

1919 "user_id": session["user_id"], 

1920 "aircraft_id": aircraft_id, 

1921 "files": parsed_meta, 

1922 "skipped_empty": skipped_empty, 

1923 "other_aircraft": other_aircraft, 

1924 "other_ac_make_model": other_ac_make_model, 

1925 "other_ac_reg": other_ac_reg, 

1926 } 

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

1928 

1929 

1930@aircraft_bp.route("/<aircraft_ref:aircraft_id>/gps-import/review", methods=["GET"]) 

1931@login_required 

1932@require_role(*_PILOT_ROLES) 

1933def gps_import_review(aircraft_id: int) -> ResponseReturnValue: 

1934 ac = _get_aircraft_or_404(aircraft_id) 

1935 state = session.get("gps_import") 

1936 if not state or state.get("aircraft_id") != aircraft_id: 

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

1938 return redirect(url_for("aircraft.gps_import_upload", aircraft_id=aircraft_id)) 

1939 

1940 file_metas = state["files"] 

1941 

1942 # Re-parse each tmp file and build combined trackpoint list 

1943 from aircraft.gps_import import ( 

1944 ParsedGpsFile, # pyright: ignore[reportMissingImports] 

1945 ) 

1946 

1947 all_parsed: list[ParsedGpsFile] = [] 

1948 for meta in file_metas: 

1949 try: 

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

1951 data = fh.read() 

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

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

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

1955 all_parsed.append(parsed) 

1956 except (OSError, ValueError): 

1957 flash( 

1958 _( 

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

1960 fn=meta["original_filename"], 

1961 ), 

1962 "warning", 

1963 ) 

1964 return redirect( 

1965 url_for("aircraft.gps_import_upload", aircraft_id=aircraft_id) 

1966 ) 

1967 

1968 merged = merge_and_sort(all_parsed) 

1969 

1970 # Collect ICAO hints from all files 

1971 hint_dep = next( 

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

1973 ) 

1974 hint_arr = next( 

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

1976 ) 

1977 

1978 segments = detect_segments( 

1979 merged, 

1980 aircraft_precision=ac.logbook_time_precision, 

1981 hint_dep=hint_dep, 

1982 hint_arr=hint_arr, 

1983 ) 

1984 

1985 # Build full dicts (with track_geojson) for template rendering. 

1986 # Spill GeoJSON to tmp files for the session — track_geojson can be 

1987 # hundreds of KB and silently overflows Flask's 4 KB cookie session. 

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

1989 

1990 # Duplicate detection: find existing Flight records that overlap each segment. 

1991 from datetime import datetime as _dt 

1992 from datetime import timedelta as _td 

1993 from functools import partial 

1994 

1995 from flights.airframe_import import _CANDIDATE_MIN_SCORE 

1996 

1997 from aircraft.gps_import import _score_gps_candidate, score_gps_candidates 

1998 

1999 _BLOCK_TOLERANCE = _td(minutes=15) 

2000 

2001 for seg in full_segs: 

2002 block_off = _dt.fromisoformat(seg["block_off_utc"]) 

2003 block_on = _dt.fromisoformat(seg["block_on_utc"]) 

2004 matched = Flight.query.filter( 

2005 Flight.aircraft_id == aircraft_id, 

2006 Flight.block_off_utc.isnot(None), 

2007 Flight.block_on_utc.isnot(None), 

2008 Flight.block_off_utc < block_on + _BLOCK_TOLERANCE, 

2009 Flight.block_on_utc > block_off - _BLOCK_TOLERANCE, 

2010 ).first() 

2011 seg["matched_ambiguous"] = False 

2012 seg["matched_candidates"] = [] 

2013 if matched: 

2014 seg["matched_flight_id"] = matched.id 

2015 seg["matched_flight_str"] = ( 

2016 f"#{matched.id}{matched.date} " 

2017 f"{matched.departure_icao}{matched.arrival_icao}" 

2018 ) 

2019 seg["matched_has_existing_track"] = matched.gps_track_id is not None 

2020 seg["linked_pilot_entries"] = _linked_pilot_entries( 

2021 matched.id, int(session["user_id"]) 

2022 ) 

2023 continue 

2024 

2025 # No exact GPS-block overlap — fall back to a fuzzy date/route/ 

2026 # duration/landings match against this aircraft's flights that 

2027 # don't yet have real GPS block data (typically logged manually 

2028 # or imported from a CSV, airframe or pilot logbook — see 

2029 # docs/backlog.md's GPS-vs-CSV reconciliation note). _score_gps_candidate 

2030 # reuses the airframe-import review's non-time signals, with its own 

2031 # wider time-of-day matching (see its docstring). 

2032 # takeoff_time/landing_time here just labels *which* GPS-observed 

2033 # instant this is (block-off vs block-on) — _score_gps_candidate 

2034 # itself compares each against whichever of the existing row's 

2035 # departure_time/takeoff_time (or landing_time/arrival_time) is set. 

2036 fields = { 

2037 "departure_icao": seg["departure_icao"], 

2038 "arrival_icao": seg["arrival_icao"], 

2039 "takeoff_time": block_off.time(), 

2040 "landing_time": block_on.time(), 

2041 "flight_time": seg["flight_time_rounded_h"], 

2042 "landing_count": seg["landing_count"], 

2043 } 

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

2045 # already has its own track is a different real flight (e.g. two 

2046 # short hops the same day on the same route), not the one this 

2047 # exact-match check above already ruled out. 

2048 same_day = Flight.query.filter( 

2049 Flight.aircraft_id == aircraft_id, 

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

2051 Flight.block_off_utc.is_(None), 

2052 ).all() 

2053 candidates = score_gps_candidates( 

2054 fields, 

2055 same_day, 

2056 partial(_score_gps_candidate, offset_hours=float(ac.flight_counter_offset)), 

2057 _CANDIDATE_MIN_SCORE, 

2058 ) 

2059 if candidates: 

2060 seg["matched_flight_id"] = candidates[0].id 

2061 seg["matched_flight_str"] = None 

2062 seg["matched_has_existing_track"] = False 

2063 seg["linked_pilot_entries"] = [] 

2064 seg["matched_ambiguous"] = True 

2065 seg["matched_candidates"] = [_gps_candidate_dict(c) for c in candidates] 

2066 else: 

2067 seg["matched_flight_id"] = None 

2068 seg["matched_flight_str"] = None 

2069 seg["matched_has_existing_track"] = False 

2070 seg["linked_pilot_entries"] = [] 

2071 

2072 tmp_dir = _gps_tmp_dir() 

2073 session["gps_import"]["segments"] = [ 

2074 _segment_for_session(s, tmp_dir) for s in full_segs 

2075 ] 

2076 session.modified = True 

2077 

2078 # Get OpenAIP API key for map tiles 

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

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

2081 

2082 return render_template( 

2083 "aircraft/gps_import_review.html", 

2084 aircraft=ac, 

2085 segments=full_segs, 

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

2087 openaip_key=openaip_key, 

2088 other_aircraft=state.get("other_aircraft", False), 

2089 other_ac_make_model=state.get("other_ac_make_model", ""), 

2090 other_ac_reg=state.get("other_ac_reg", ""), 

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

2092 ) 

2093 

2094 

2095def _gps_import_create_segment( 

2096 ac: Any, 

2097 aircraft_id: int, 

2098 seg: dict[str, Any], 

2099 seg_idx: int, 

2100 pilot_role: str, 

2101 dep_icao: str, 

2102 arr_icao: str, 

2103 nature: str | None, 

2104 remarks: str | None, 

2105 batch: Any, 

2106 file_metas: list[dict[str, Any]], 

2107 linked_ids: list[int], 

2108 pilot_display_name: str, 

2109 other_aircraft: bool, 

2110 other_ac_make_model: str, 

2111 other_ac_reg: str, 

2112 batch_device_id: str | None, 

2113) -> tuple[Any, list[int]]: 

2114 """Create Flight + optionally PilotLogbookEntry for one GPS segment. 

2115 

2116 Returns (entry_or_None, updated_linked_ids). 

2117 """ 

2118 import decimal as _dec 

2119 from datetime import datetime as _dt 

2120 

2121 create_pilot_entries = pilot_role in ("pic", "dual") 

2122 

2123 block_off = _dt.fromisoformat(seg["block_off_utc"]) 

2124 block_on = _dt.fromisoformat(seg["block_on_utc"]) 

2125 dep_time = block_off.time().replace(tzinfo=None) 

2126 arr_time = block_on.time().replace(tzinfo=None) 

2127 flight_time_h = round_flight_time( 

2128 seg["flight_time_raw_h"], ac.logbook_time_precision 

2129 ) 

2130 

2131 entry = None 

2132 gps_track: GpsTrack | None = None 

2133 

2134 if not other_aircraft: 

2135 matched_id = seg.get("matched_flight_id") 

2136 if matched_id: 

2137 existing = db.session.get(Flight, matched_id) 

2138 if existing and existing.aircraft_id == aircraft_id: 

2139 old_track_id = existing.gps_track_id 

2140 existing.block_off_utc = block_off 

2141 existing.block_on_utc = block_on 

2142 gps_track = GpsTrack( 

2143 source_filename=file_metas[0]["original_filename"] 

2144 if len(file_metas) == 1 

2145 else None, 

2146 device_id=batch_device_id, 

2147 block_off_utc=block_off, 

2148 block_on_utc=block_on, 

2149 departure_icao=dep_icao, 

2150 arrival_icao=arr_icao, 

2151 geojson=_load_segment_geojson(seg), 

2152 ) 

2153 db.session.add(gps_track) 

2154 db.session.flush() 

2155 existing.gps_track_id = gps_track.id 

2156 if old_track_id and old_track_id != gps_track.id: 

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

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

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

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

2161 if old_track is not None: 

2162 db.session.delete(old_track) 

2163 linked_ids.append(existing.id) 

2164 # Unified model: this Flight row already covers both crew 

2165 # slots, so setting gps_track_id above already applies to 

2166 # whichever other pilot occupies the other slot too — no 

2167 # separate per-pilot row left to update. 

2168 db.session.flush() 

2169 entry = existing 

2170 else: 

2171 matched_id = None 

2172 

2173 if not matched_id: 

2174 gps_track = GpsTrack( 

2175 source_filename=file_metas[0]["original_filename"] 

2176 if len(file_metas) == 1 

2177 else None, 

2178 block_off_utc=block_off, 

2179 block_on_utc=block_on, 

2180 departure_icao=dep_icao, 

2181 arrival_icao=arr_icao, 

2182 geojson=_load_segment_geojson(seg), 

2183 ) 

2184 db.session.add(gps_track) 

2185 db.session.flush() 

2186 entry = Flight( 

2187 aircraft_id=aircraft_id, 

2188 date=block_off.date(), 

2189 departure_icao=dep_icao, 

2190 arrival_icao=arr_icao, 

2191 departure_time=dep_time, 

2192 arrival_time=arr_time, 

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

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

2195 nature_of_flight=nature, 

2196 source="gps_import", 

2197 gps_import_batch_id=batch.id, 

2198 block_off_utc=block_off, 

2199 block_on_utc=block_on, 

2200 gps_track_id=gps_track.id, 

2201 ) 

2202 db.session.add(entry) 

2203 db.session.flush() 

2204 

2205 if create_pilot_entries: 

2206 from flights.routes import apply_pilot_identity 

2207 

2208 if entry is None: 

2209 # Other/external aircraft — standalone row (no airframe side). 

2210 if gps_track is None: 

2211 gps_track = GpsTrack( 

2212 source_filename=file_metas[0]["original_filename"] 

2213 if len(file_metas) == 1 

2214 else None, 

2215 device_id=batch_device_id, 

2216 block_off_utc=block_off, 

2217 block_on_utc=block_on, 

2218 departure_icao=dep_icao, 

2219 arrival_icao=arr_icao, 

2220 geojson=_load_segment_geojson(seg), 

2221 ) 

2222 db.session.add(gps_track) 

2223 db.session.flush() 

2224 entry = Flight( 

2225 date=block_off.date(), 

2226 other_aircraft_type=other_ac_make_model or None, 

2227 other_aircraft_registration=other_ac_reg or None, 

2228 departure_icao=dep_icao, 

2229 departure_time=dep_time, 

2230 arrival_icao=arr_icao, 

2231 arrival_time=arr_time, 

2232 source="gps_import", 

2233 gps_import_batch_id=batch.id, 

2234 gps_track_id=gps_track.id, 

2235 ) 

2236 db.session.add(entry) 

2237 db.session.flush() 

2238 

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

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

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

2242 # entry made. 

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

2244 if remarks: 

2245 entry.notes = remarks 

2246 entry_ac: Aircraft | None = None if other_aircraft else ac 

2247 apply_pilot_identity(entry, entry_ac, int(session["user_id"]), pilot_role) 

2248 

2249 return entry, linked_ids 

2250 

2251 

2252def _gps_cleanup(state: dict[str, Any]) -> None: 

2253 """Delete tmp GPS and GeoJSON files from a gps_import session state.""" 

2254 for meta in state.get("files", []): 

2255 try: 

2256 os.unlink(meta["tmp_path"]) 

2257 except OSError as exc: 

2258 current_app.logger.debug("cleanup GPS tmp file: %s", exc) 

2259 for seg in state.get("segments", []): 

2260 gj_path = seg.get("geojson_path") 

2261 if gj_path: 

2262 try: 

2263 os.unlink(gj_path) 

2264 except OSError as exc: 

2265 current_app.logger.debug("cleanup GPS geojson tmp: %s", exc) 

2266 

2267 

2268@aircraft_bp.route( 

2269 "/<aircraft_ref:aircraft_id>/gps-import/confirm-one", methods=["POST"] 

2270) 

2271@login_required 

2272@require_role(*_PILOT_ROLES) 

2273def gps_import_confirm_one(aircraft_id: int) -> ResponseReturnValue: 

2274 """Confirm a single GPS segment as-is and redirect back to the review page.""" 

2275 ac = _get_aircraft_or_404(aircraft_id) 

2276 state = session.get("gps_import") 

2277 if not state or state.get("aircraft_id") != aircraft_id: 

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

2279 return redirect(url_for("aircraft.gps_import_upload", aircraft_id=aircraft_id)) 

2280 

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

2282 if not segments_data: 

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

2284 return redirect(url_for("aircraft.gps_import_upload", aircraft_id=aircraft_id)) 

2285 

2286 try: 

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

2288 except (ValueError, TypeError): 

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

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

2291 

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

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

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

2295 

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

2297 if str(seg_idx) in confirmed: 

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

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

2300 

2301 pilot_role = request.form.get("pilot_role", "none") 

2302 

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

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

2305 state["confirmed_segments"] = confirmed 

2306 session["gps_import"] = state 

2307 session.modified = True 

2308 

2309 all_handled = len(confirmed) == len(segments_data) 

2310 if all_handled: 

2311 _gps_cleanup(state) 

2312 session.pop("gps_import", None) 

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

2314 skipped_count = len(segments_data) - imported 

2315 if imported > 0: 

2316 flash( 

2317 ngettext( 

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

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

2320 imported, 

2321 n=imported, 

2322 ), 

2323 "success", 

2324 ) 

2325 flash( 

2326 ngettext( 

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

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

2329 skipped_count, 

2330 n=skipped_count, 

2331 ), 

2332 "info", 

2333 ) 

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

2335 pilot_role = "none" 

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

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

2338 return redirect(url_for("flights.list_flights", aircraft_id=aircraft_id)) 

2339 

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

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

2342 

2343 seg = segments_data[seg_idx] 

2344 

2345 if seg.get("matched_ambiguous"): 

2346 # A fuzzy (non-exact) match picker was rendered — respect the 

2347 # human's explicit choice instead of always taking the top-scored 

2348 # candidate, including "none of these" (submitted as ""). 

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

2350 seg["matched_flight_id"] = int(picked) if picked.strip().isdigit() else None 

2351 

2352 other_aircraft: bool = state.get("other_aircraft", False) 

2353 other_ac_make_model: str = state.get("other_ac_make_model", "") 

2354 other_ac_reg: str = state.get("other_ac_reg", "") 

2355 

2356 if other_aircraft and pilot_role == "none": 

2357 pilot_role = "pic" 

2358 

2359 _pilot_user = db.session.get(User, int(session["user_id"])) 

2360 pilot_display_name = _pilot_user.display_name if _pilot_user else "" 

2361 file_metas = state["files"] 

2362 

2363 dep_icao = ( 

2364 (request.form.get("dep_icao") or seg["departure_icao"] or "") 

2365 .strip() 

2366 .upper()[:4] 

2367 ) 

2368 arr_icao = ( 

2369 (request.form.get("arr_icao") or seg["arrival_icao"] or "").strip().upper()[:4] 

2370 ) 

2371 if not dep_icao: 

2372 dep_icao = "????" 

2373 if not arr_icao: 

2374 arr_icao = "????" 

2375 

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

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

2378 

2379 batch_device_id: str | None = next( 

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

2381 ) 

2382 

2383 # Get or create the shared batch record for this session 

2384 batch_id = state.get("batch_id") 

2385 batch: AircraftGpsImportBatch | None = ( 

2386 db.session.get(AircraftGpsImportBatch, batch_id) if batch_id else None 

2387 ) 

2388 if batch is None: 

2389 formats = {m["format"] for m in file_metas} 

2390 format_label = formats.pop() if len(formats) == 1 else "mixed" 

2391 batch = AircraftGpsImportBatch( 

2392 aircraft_id=aircraft_id, 

2393 pilot_user_id=int(session["user_id"]) if pilot_role != "none" else None, 

2394 source_filenames=[m["original_filename"] for m in file_metas], 

2395 format_detected=format_label, 

2396 segments_found=len(segments_data), 

2397 linked_flight_entry_ids=[], 

2398 pilot_role=pilot_role, 

2399 other_aircraft_make_model=other_ac_make_model or None, 

2400 other_aircraft_registration=other_ac_reg or None, 

2401 ) 

2402 db.session.add(batch) 

2403 db.session.flush() 

2404 state["batch_id"] = batch.id 

2405 

2406 linked_ids: list[int] = list(batch.linked_flight_entry_ids or []) 

2407 

2408 entry, linked_ids = _gps_import_create_segment( 

2409 ac=ac, 

2410 aircraft_id=aircraft_id, 

2411 seg=seg, 

2412 seg_idx=seg_idx, 

2413 pilot_role=pilot_role, 

2414 dep_icao=dep_icao, 

2415 arr_icao=arr_icao, 

2416 nature=nature, 

2417 remarks=remarks, 

2418 batch=batch, 

2419 file_metas=file_metas, 

2420 linked_ids=linked_ids, 

2421 pilot_display_name=pilot_display_name, 

2422 other_aircraft=other_aircraft, 

2423 other_ac_make_model=other_ac_make_model, 

2424 other_ac_reg=other_ac_reg, 

2425 batch_device_id=batch_device_id, 

2426 ) 

2427 

2428 batch.linked_flight_entry_ids = linked_ids 

2429 batch.segments_imported = (batch.segments_imported or 0) + 1 

2430 db.session.commit() 

2431 

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

2433 state["confirmed_segments"] = confirmed 

2434 session["gps_import"] = state 

2435 session.modified = True 

2436 

2437 all_confirmed = len(confirmed) == len(segments_data) 

2438 

2439 if all_confirmed: 

2440 _gps_cleanup(state) 

2441 session.pop("gps_import", None) 

2442 total = len(confirmed) 

2443 flash( 

2444 ngettext( 

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

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

2447 total, 

2448 n=total, 

2449 ), 

2450 "success", 

2451 ) 

2452 if pilot_role in ("pic", "dual"): 

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

2454 return redirect(url_for("flights.list_flights", aircraft_id=aircraft_id)) 

2455 

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

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

2458 

2459 

2460@aircraft_bp.route( 

2461 "/<aircraft_ref:aircraft_id>/gps-import/prefill-segment/<int:seg_idx>", 

2462 methods=["GET"], 

2463) 

2464@login_required 

2465@require_role(*_PILOT_ROLES) 

2466def gps_import_prefill_segment(aircraft_id: int, seg_idx: int) -> ResponseReturnValue: 

2467 """Store a batch segment as gps_prefill then redirect to /flights/new.""" 

2468 import json as _json 

2469 from datetime import datetime as _dt 

2470 

2471 _get_aircraft_or_404(aircraft_id) 

2472 state = session.get("gps_import") 

2473 if not state or state.get("aircraft_id") != aircraft_id: 

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

2475 return redirect(url_for("aircraft.gps_import_upload", aircraft_id=aircraft_id)) 

2476 

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

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

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

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

2481 

2482 seg = segments_data[seg_idx] 

2483 block_off = _dt.fromisoformat(seg["block_off_utc"]) 

2484 block_on = _dt.fromisoformat(seg["block_on_utc"]) 

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

2486 single_filename = file_metas[0]["original_filename"] if len(file_metas) == 1 else "" 

2487 geojson_data = _load_segment_geojson(seg) 

2488 geojson_str = _json.dumps(geojson_data) if geojson_data else "" 

2489 

2490 session["gps_prefill"] = { 

2491 "filename": single_filename, 

2492 "date": block_off.date().isoformat(), 

2493 "departure_icao": seg.get("departure_icao") or "", 

2494 "arrival_icao": seg.get("arrival_icao") or "", 

2495 "departure_time": block_off.strftime("%H:%M"), 

2496 "arrival_time": block_on.strftime("%H:%M"), 

2497 "flight_time_h": str(seg.get("flight_time_rounded_h") or 0), 

2498 "block_off_utc": block_off.isoformat(), 

2499 "block_on_utc": block_on.isoformat(), 

2500 "geojson": geojson_str, 

2501 "landing_count": seg.get("landing_count") or 0, 

2502 } 

2503 session.modified = True 

2504 

2505 return redirect( 

2506 url_for( 

2507 "flights.log_flight", 

2508 aircraft_id=aircraft_id, 

2509 gps_review_return=aircraft_id, 

2510 gps_seg=seg_idx, 

2511 ) 

2512 ) 

2513 

2514 

2515@aircraft_bp.route("/<aircraft_ref:aircraft_id>/gps-import/history", methods=["GET"]) 

2516@login_required 

2517@require_role(*_PILOT_ROLES) 

2518def gps_import_history(aircraft_id: int) -> ResponseReturnValue: 

2519 ac = _get_aircraft_or_404(aircraft_id) 

2520 batches = ( 

2521 AircraftGpsImportBatch.query.filter_by(aircraft_id=aircraft_id) 

2522 .order_by(AircraftGpsImportBatch.imported_at.desc()) 

2523 .all() 

2524 ) 

2525 return render_template( 

2526 "aircraft/gps_import_history.html", aircraft=ac, batches=batches 

2527 ) 

2528 

2529 

2530@aircraft_bp.route( 

2531 "/<aircraft_ref:aircraft_id>/gps-import/<int:batch_id>/rollback", methods=["POST"] 

2532) 

2533@login_required 

2534@require_role(*_OWNER_ROLES) 

2535def gps_import_rollback(aircraft_id: int, batch_id: int) -> ResponseReturnValue: 

2536 _get_aircraft_or_404(aircraft_id) 

2537 batch = db.session.get(AircraftGpsImportBatch, batch_id) 

2538 if not batch or batch.aircraft_id != aircraft_id: 

2539 abort(404) 

2540 

2541 # Flights created by this batch — delete them entirely. Unified model: 

2542 # this already covers what used to be a separate PilotLogbookEntry 

2543 # deletion pass (gps_batch_id and gps_import_batch_id were merged into 

2544 # one column on the unified row). 

2545 Flight.query.filter_by(gps_import_batch_id=batch.id).delete( 

2546 synchronize_session="fetch" 

2547 ) 

2548 

2549 # Flights that were pre-existing but got a GPS track linked — unlink only. 

2550 linked_ids = batch.linked_flight_entry_ids or [] 

2551 if linked_ids: 

2552 Flight.query.filter(Flight.id.in_(linked_ids)).update( 

2553 { 

2554 "gps_track_id": None, 

2555 "block_off_utc": None, 

2556 "block_on_utc": None, 

2557 }, 

2558 synchronize_session="fetch", 

2559 ) 

2560 

2561 db.session.delete(batch) 

2562 db.session.commit() 

2563 flash( 

2564 _("GPS import batch rolled back and all linked flight entries removed."), 

2565 "success", 

2566 ) 

2567 return redirect(url_for("aircraft.gps_import_history", aircraft_id=aircraft_id)) 

2568 

2569 

2570@aircraft_bp.route( 

2571 "/<aircraft_ref:aircraft_id>/flights/<int:flight_id>", methods=["GET"] 

2572) 

2573@login_required 

2574@require_role(*_PILOT_ROLES) 

2575def flight_detail(aircraft_id: int, flight_id: int) -> ResponseReturnValue: 

2576 ac = _get_aircraft_or_404(aircraft_id) 

2577 entry = db.session.get(Flight, flight_id) 

2578 if not entry or entry.aircraft_id != aircraft_id: 

2579 abort(404) 

2580 

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

2582 # separate PilotLogbookEntry to fetch. 

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

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

2585 

2586 return render_template( 

2587 "aircraft/flight_detail.html", 

2588 aircraft=ac, 

2589 entry=entry, 

2590 pilot_entry=None, 

2591 openaip_key=openaip_key, 

2592 ) 

2593 

2594 

2595@aircraft_bp.route("/<aircraft_ref:aircraft_id>/tracks", methods=["GET"]) 

2596@login_required 

2597@require_role(*_PILOT_ROLES) 

2598def flight_tracks(aircraft_id: int) -> ResponseReturnValue: 

2599 from flask import url_for as _url_for 

2600 

2601 ac = _get_aircraft_or_404(aircraft_id) 

2602 entries_with_tracks = ( 

2603 Flight.query.filter_by(aircraft_id=aircraft_id) 

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

2605 .order_by(Flight.date.asc()) 

2606 .all() 

2607 ) 

2608 track_rows = [ 

2609 { 

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

2611 "dep": e.departure_icao, 

2612 "arr": e.arrival_icao, 

2613 "time_str": f"{e.flight_time} h" if e.flight_time is not None else "", 

2614 "view_url": _url_for( 

2615 "aircraft.flight_detail", 

2616 aircraft_id=aircraft_id, 

2617 flight_id=e.id, 

2618 ), 

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

2620 } 

2621 for e in entries_with_tracks 

2622 ] 

2623 

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

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

2626 

2627 return render_template( 

2628 "aircraft/flight_tracks.html", 

2629 aircraft=ac, 

2630 track_rows=track_rows, 

2631 openaip_key=openaip_key, 

2632 ) 

2633 

2634 

2635@aircraft_bp.route("/<aircraft_ref:aircraft_id>/tracks/animation.gif") 

2636@login_required 

2637@require_role(*_PILOT_ROLES) 

2638def flight_tracks_gif(aircraft_id: int) -> ResponseReturnValue: 

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

2640 generate_tracks_gif, 

2641 sort_tracks_oldest_first, 

2642 ) 

2643 

2644 ac = _get_aircraft_or_404(aircraft_id) 

2645 entries = ( 

2646 Flight.query.filter_by(aircraft_id=aircraft_id) 

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

2648 .all() 

2649 ) 

2650 track_rows = sort_tracks_oldest_first( 

2651 [ 

2652 { 

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

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

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

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

2657 } 

2658 for e in entries 

2659 ] 

2660 ) 

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

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

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

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

2665 mul = 2 if hires else 1 

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

2667 gif_bytes = generate_tracks_gif( 

2668 track_rows, 

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

2670 canvas_w=canvas_w, 

2671 canvas_h=canvas_h, 

2672 high_res=hires, 

2673 ) 

2674 orient_sfx = "-portrait" if portrait else "" 

2675 qual_sfx = "-hires" if hires else "" 

2676 suffix = orient_sfx + qual_sfx 

2677 filename = f"{ac.registration.lower().replace('-', '')}_tracks{suffix}.gif" 

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

2679 

2680 return Response( 

2681 gif_bytes, 

2682 mimetype="image/gif", 

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

2684 ) 

2685 

2686 

2687# ── Aircraft photos ─────────────────────────────────────────────────────────── 

2688 

2689_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".heic"} 

2690 

2691 

2692def _photo_folder(app: Any, tenant_slug: str, safe_reg: str) -> str: 

2693 folder = app.config.get("UPLOAD_FOLDER", "/data/uploads") 

2694 return os.path.join(folder, tenant_slug, safe_reg, "photos") 

2695 

2696 

2697def _save_photo_file( 

2698 file: Any, 

2699 tenant_slug: str, 

2700 safe_reg: str, 

2701 sort_order: int, 

2702) -> tuple[str, str]: 

2703 """Save photo to canonical path; return (relpath, original_filename).""" 

2704 

2705 original = secure_filename(file.filename or "photo.jpg") 

2706 ext = os.path.splitext(original)[1].lower() or ".jpg" 

2707 short_id = _uuid_mod.uuid4().hex[:6] 

2708 fname = f"{sort_order:02d}-{short_id}{ext}" 

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

2710 dest_dir = os.path.join(folder, tenant_slug, safe_reg, "photos") 

2711 os.makedirs(dest_dir, exist_ok=True) 

2712 file.save(os.path.join(dest_dir, fname)) 

2713 relpath = os.path.join(tenant_slug, safe_reg, "photos", fname).replace("\\", "/") 

2714 return relpath, original 

2715 

2716 

2717def _trash_photo_file(filename: str) -> None: 

2718 """Move photo file to _trash/ (same pattern as document deletion).""" 

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

2720 src = os.path.join(folder, filename) 

2721 if not os.path.exists(src): 

2722 return 

2723 try: 

2724 trash_dir = os.path.join(folder, "_trash") 

2725 os.makedirs(trash_dir, exist_ok=True) 

2726 base = os.path.basename(filename) 

2727 dest = os.path.join(trash_dir, base) 

2728 if os.path.exists(dest): 

2729 stem, ext = os.path.splitext(base) 

2730 dest = os.path.join(trash_dir, f"{stem}_{_uuid_mod.uuid4().hex[:6]}{ext}") 

2731 os.rename(src, dest) 

2732 except OSError: 

2733 current_app.logger.debug("Could not trash photo: %s", filename) 

2734 

2735 

2736def _renumber_photos(photos: list[Any], tenant_slug: str, safe_reg: str) -> None: 

2737 """Assign sort_order 1..N, renaming files on disk to keep the numeric prefix.""" 

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

2739 for new_order, photo in enumerate(photos, start=1): 

2740 if photo.sort_order != new_order: 

2741 old_full = os.path.join(folder, photo.filename) 

2742 old_fname = os.path.basename(photo.filename) 

2743 suffix = old_fname[ 

2744 2: 

2745 ] # strip old "NN" prefix (e.g. "01-abc.jpg" → "-abc.jpg") 

2746 new_fname = f"{new_order:02d}{suffix}" 

2747 new_full = os.path.join(folder, tenant_slug, safe_reg, "photos", new_fname) 

2748 if os.path.exists(old_full): 

2749 try: 

2750 os.rename(old_full, new_full) 

2751 except OSError: 

2752 current_app.logger.debug( 

2753 "Could not renumber photo: %s", photo.filename 

2754 ) 

2755 new_fname = old_fname # keep old name if rename fails 

2756 new_rel = f"{tenant_slug}/{safe_reg}/photos/{new_fname}" 

2757 photo.filename = new_rel 

2758 photo.sort_order = new_order 

2759 

2760 

2761@aircraft_bp.route("/<aircraft_ref:aircraft_id>/photos/upload", methods=["POST"]) 

2762@login_required 

2763@require_role(*_OWNER_ROLES) 

2764def upload_photo(aircraft_id: int) -> ResponseReturnValue: 

2765 from documents.routes import ( # pyright: ignore[reportMissingImports] 

2766 _ensure_tenant_slug, 

2767 _get_tenant, 

2768 ) 

2769 

2770 ac = _get_aircraft_or_404(aircraft_id) 

2771 tenant = _get_tenant() 

2772 

2773 files = request.files.getlist("photos") 

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

2775 flash(_("No files selected."), "warning") 

2776 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

2777 

2778 tenant_slug = _ensure_tenant_slug(tenant) 

2779 safe_reg = ac.registration.replace("/", "-").replace(" ", "-").upper() 

2780 next_order = ( 

2781 db.session.query(db.func.max(AircraftPhoto.sort_order)) 

2782 .filter_by(aircraft_id=ac.id) 

2783 .scalar() 

2784 or 0 

2785 ) + 1 

2786 

2787 uploaded = 0 

2788 for f in files: 

2789 if not f.filename: 

2790 continue 

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

2792 if ext not in _PHOTO_EXTS: 

2793 flash( 

2794 _( 

2795 "%(name)s: unsupported format (use JPEG, PNG, WEBP or HEIC).", 

2796 name=f.filename, 

2797 ), 

2798 "warning", 

2799 ) 

2800 continue 

2801 relpath, original = _save_photo_file(f, tenant_slug, safe_reg, next_order) 

2802 photo = AircraftPhoto( 

2803 aircraft_id=ac.id, 

2804 filename=relpath, 

2805 original_filename=original, 

2806 sort_order=next_order, 

2807 uploaded_by_user_id=session.get("user_id"), 

2808 ) 

2809 db.session.add(photo) 

2810 next_order += 1 

2811 uploaded += 1 

2812 

2813 if uploaded: 

2814 db.session.commit() 

2815 flash( 

2816 ngettext( 

2817 "%(n)s photo uploaded.", "%(n)s photos uploaded.", uploaded, n=uploaded 

2818 ), 

2819 "success", 

2820 ) 

2821 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

2822 

2823 

2824@aircraft_bp.route("/<aircraft_ref:aircraft_id>/photos/<int:photo_id>/img") 

2825@login_required 

2826def serve_photo(aircraft_id: int, photo_id: int) -> ResponseReturnValue: 

2827 from flask import send_from_directory # pyright: ignore[reportMissingImports] 

2828 

2829 ac = _get_aircraft_or_404(aircraft_id) 

2830 photo = db.session.get(AircraftPhoto, photo_id) 

2831 if not photo or photo.aircraft_id != ac.id: 

2832 abort(404) 

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

2834 directory = os.path.join(folder, os.path.dirname(photo.filename)) 

2835 fname = os.path.basename(photo.filename) 

2836 # photo_id -> file is immutable for the row's lifetime (reordering only 

2837 # changes sort_order; a replacement upload gets a new id) — safe to 

2838 # cache aggressively so the dashboard/list prefetch hints actually pay 

2839 # off instead of being revalidated away on the next navigation. 

2840 response = send_from_directory(directory, fname, max_age=31536000) 

2841 response.headers["Cache-Control"] = "private, max-age=31536000, immutable" 

2842 return response 

2843 

2844 

2845@aircraft_bp.route( 

2846 "/<aircraft_ref:aircraft_id>/photos/<int:photo_id>/delete", methods=["POST"] 

2847) 

2848@login_required 

2849@require_role(*_OWNER_ROLES) 

2850def delete_photo(aircraft_id: int, photo_id: int) -> ResponseReturnValue: 

2851 from documents.routes import _get_tenant # pyright: ignore[reportMissingImports] 

2852 

2853 ac = _get_aircraft_or_404(aircraft_id) 

2854 photo = db.session.get(AircraftPhoto, photo_id) 

2855 if not photo or photo.aircraft_id != ac.id: 

2856 abort(404) 

2857 

2858 _trash_photo_file(photo.filename) 

2859 db.session.delete(photo) 

2860 db.session.flush() 

2861 

2862 # Renumber remaining photos 

2863 remaining = ( 

2864 AircraftPhoto.query.filter_by(aircraft_id=ac.id) 

2865 .order_by(AircraftPhoto.sort_order) 

2866 .all() 

2867 ) 

2868 tenant = _get_tenant() 

2869 tenant_slug = tenant.slug or "" 

2870 safe_reg = ac.registration.replace("/", "-").replace(" ", "-").upper() 

2871 _renumber_photos(remaining, tenant_slug, safe_reg) 

2872 db.session.commit() 

2873 flash(_("Photo deleted."), "success") 

2874 return redirect(url_for("aircraft.detail", aircraft_id=ac.id)) 

2875 

2876 

2877@aircraft_bp.route("/<aircraft_ref:aircraft_id>/photos/reorder", methods=["POST"]) 

2878@login_required 

2879@require_role(*_OWNER_ROLES) 

2880def reorder_photos(aircraft_id: int) -> ResponseReturnValue: 

2881 from documents.routes import _get_tenant # pyright: ignore[reportMissingImports] 

2882 

2883 ac = _get_aircraft_or_404(aircraft_id) 

2884 

2885 ordered_ids: list[int] = [] 

2886 try: 

2887 ordered_ids = [int(i) for i in request.form.getlist("photo_order[]")] 

2888 except (ValueError, TypeError): 

2889 abort(400) 

2890 

2891 photos_by_id = { 

2892 p.id: p for p in AircraftPhoto.query.filter_by(aircraft_id=ac.id).all() 

2893 } 

2894 if set(ordered_ids) != set(photos_by_id): 

2895 abort(400) 

2896 

2897 ordered_photos = [photos_by_id[pid] for pid in ordered_ids] 

2898 tenant = _get_tenant() 

2899 tenant_slug = tenant.slug or "" 

2900 safe_reg = ac.registration.replace("/", "-").replace(" ", "-").upper() 

2901 _renumber_photos(ordered_photos, tenant_slug, safe_reg) 

2902 db.session.commit() 

2903 return "", 204