Coverage for app/flights/routes.py: 100%
908 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1import contextlib
2import decimal
3import json as _json
4import os
5import uuid
6from datetime import (
7 UTC,
8)
9from datetime import (
10 date as _date,
11)
12from datetime import (
13 datetime as _datetime,
14)
15from datetime import (
16 time as _time,
17)
18from datetime import (
19 timedelta as _timedelta,
20)
21from typing import Any
23from extensions import _rate_limiting_disabled # pyright: ignore[reportMissingImports]
24from extensions import limiter as _limiter
25from flask import ( # pyright: ignore[reportMissingImports]
26 Blueprint,
27 abort,
28 current_app,
29 flash,
30 jsonify,
31 redirect,
32 render_template,
33 request,
34 send_from_directory,
35 session,
36 url_for,
37)
38from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
39from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
40from models import (
41 Aircraft,
42 AppSetting,
43 Component,
44 CrewRole,
45 Document,
46 Flight,
47 GpsTrack,
48 Reservation,
49 ReservationStatus,
50 Role,
51 TenantUser,
52 User,
53 db,
54) # pyright: ignore[reportMissingImports]
55from pilots.personal_minimums import ( # pyright: ignore[reportMissingImports]
56 get_active_revision,
57 recency_breaches,
58)
59from sqlalchemy import func, or_ # pyright: ignore[reportMissingImports]
60from utils import (
61 accessible_aircraft,
62 activity,
63 login_required,
64 require_pilot_access,
65 require_role,
66 user_can_access_aircraft,
67) # pyright: ignore[reportMissingImports]
68from werkzeug.utils import secure_filename
70from flights.form_parsing import ( # pyright: ignore[reportMissingImports]
71 apply_flight_fields,
72 flight_is_lenient,
73 parse_flight_fields,
74)
76flights_bp = Blueprint("flights", __name__)
78_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
79_ALLOWED_GPS_EXTS = {".gpx", ".kml", ".csv"}
80_FUEL_UNITS = ["L", "gal"]
81_NATURE_SUGGESTIONS = [
82 "Local flight",
83 "Navigation",
84 "Cross-country",
85 "Training",
86 "IFR practice",
87 "Night flight",
88 "Touch-and-go",
89 "Ferry flight",
90 "Air test",
91 "Sightseeing",
92]
94_HOUR_MILESTONES = [100, 500, 1000, 2000, 5000]
97def _openaip_key() -> str | None:
98 s = db.session.get(AppSetting, "openaip_api_key")
99 return s.value if s and s.value else None
102def _tenant_id() -> int:
103 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
104 if not tu:
105 abort(403)
106 return int(tu.tenant_id)
109def _check_flight_hour_milestone(fe: Flight) -> None:
110 """Set a one-shot session flag when total fleet hours cross a milestone."""
111 this_flight = float(fe.flight_time or 0)
112 if this_flight <= 0:
113 return
114 tid = _tenant_id()
115 aircraft_ids = [a.id for a in accessible_aircraft(tid).all()]
116 new_total = float(
117 db.session.query(func.sum(Flight.flight_time))
118 .filter(Flight.aircraft_id.in_(aircraft_ids))
119 .scalar()
120 or 0
121 )
122 old_total = new_total - this_flight
123 for milestone in _HOUR_MILESTONES:
124 if old_total < milestone <= new_total:
125 session["milestone_hours"] = milestone
126 flash(
127 _(
128 "🎉 You just crossed %(hours)s flight hours!",
129 hours=milestone,
130 ),
131 "info",
132 )
133 break
136def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
137 ac = db.session.get(Aircraft, aircraft_id)
138 if (
139 not ac
140 or ac.tenant_id != _tenant_id()
141 or not user_can_access_aircraft(aircraft_id)
142 ):
143 abort(404)
144 return ac
147def _get_flight_or_404(flight_id: int) -> Flight:
148 """Fetch a Flight row, authorizing by tenant (managed-aircraft rows) or
149 by crew identity (standalone rows, aircraft_id NULL — no tenant to check,
150 so only the pic/second-crew occupant may access it)."""
151 fe = db.session.get(Flight, flight_id)
152 if not fe:
153 abort(404)
154 if fe.aircraft_id is not None:
155 ac = db.session.get(Aircraft, fe.aircraft_id)
156 if not ac or ac.tenant_id != _tenant_id():
157 abort(404)
158 else:
159 uid = session.get("user_id")
160 if fe.pic_user_id != uid and fe.second_crew_user_id != uid:
161 abort(404)
162 return fe
165def _save_upload(file: Any, flight_id: int, label: str) -> str | None:
166 # secure_filename() raises TypeError on None — file.filename is None
167 # (not just "") when a multipart part omits the filename= attribute
168 # entirely. The only current caller already guards this, but this
169 # function shouldn't rely on that (found auditing every secure_filename
170 # call site for the fuzzing backlog's "extension-allowlist logic"
171 # entry).
172 ext = os.path.splitext(secure_filename(file.filename or ""))[1].lower()
173 if ext not in _ALLOWED_PHOTO_EXTS:
174 return None
175 stored = f"flight_{flight_id}_{label}_{uuid.uuid4().hex[:8]}{ext}"
176 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
177 os.makedirs(folder, exist_ok=True)
178 file.save(os.path.join(folder, stored))
179 return stored
182def _delete_upload(filename: str | None) -> None:
183 if not filename:
184 return
185 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
186 try:
187 os.remove(os.path.join(folder, filename))
188 except OSError:
189 current_app.logger.debug(
190 "Could not delete upload %s (already absent?)", filename
191 )
194def _nature_suggestions(aircraft_id: int) -> list[str]:
195 used = [
196 row[0]
197 for row in db.session.query(Flight.nature_of_flight)
198 .filter_by(aircraft_id=aircraft_id)
199 .filter(Flight.nature_of_flight.isnot(None))
200 .distinct()
201 .all()
202 ]
203 return _NATURE_SUGGESTIONS + [n for n in used if n not in _NATURE_SUGGESTIONS]
206def _parse_gps_upload(file: Any) -> dict[str, Any] | None:
207 """Parse a single GPS file. Returns autofill dict or None."""
208 try:
209 from aircraft.gps_import import ( # pyright: ignore[reportMissingImports]
210 detect_segments,
211 merge_and_sort,
212 parse_gps_file,
213 )
214 except ImportError:
215 return None
216 filename = secure_filename(file.filename or "")
217 ext = os.path.splitext(filename)[1].lower()
218 if ext not in _ALLOWED_GPS_EXTS:
219 return None
220 data = file.read()
221 try:
222 parsed = parse_gps_file(data, filename)
223 all_points = merge_and_sort([parsed])
224 segments = detect_segments(all_points)
225 except Exception: # noqa: BLE001 -- untrusted uploaded GPS file, many possible parse failures
226 return None
227 if not segments:
228 return None
229 seg = segments[0]
230 return {
231 "filename": filename,
232 "device_id": parsed.device_id,
233 "block_off_utc": seg.block_off_utc,
234 "block_on_utc": seg.block_on_utc,
235 "date": seg.block_off_utc.date(),
236 "departure_icao": seg.departure_icao or seg.hint_departure_icao or "",
237 "arrival_icao": seg.arrival_icao or seg.hint_arrival_icao or "",
238 "departure_time": seg.block_off_utc.time(),
239 "arrival_time": seg.block_on_utc.time(),
240 "flight_time_h": round(seg.flight_time_raw_h, 1),
241 "geojson": seg.track_geojson,
242 "landing_count": seg.landing_count,
243 }
246def _find_duplicate_flight(
247 aircraft_id: int | None,
248 pilot_user_id: int,
249 date: _date,
250 dep_icao: str,
251 arr_icao: str,
252 block_off: _datetime | None,
253 block_on: _datetime | None,
254 exclude_flight_id: int | None = None,
255) -> dict[str, Any] | None:
256 """Return info about a matching Flight row, or None.
258 Unified model note: a pilot's own standalone entry and an aircraft-log
259 entry are the same table now, so there's only one id space to exclude —
260 the old separate ``exclude_pilot_entry_id`` parameter is gone, since
261 "my own linked entry for this flight" and "this flight" are the same
262 row and the same id.
263 """
264 if aircraft_id and block_off and block_on:
265 q = Flight.query.filter(
266 Flight.aircraft_id == aircraft_id,
267 Flight.block_off_utc.isnot(None),
268 Flight.block_on_utc.isnot(None),
269 Flight.block_off_utc < block_on,
270 Flight.block_on_utc > block_off,
271 )
272 if exclude_flight_id:
273 q = q.filter(Flight.id != exclude_flight_id)
274 existing = q.first()
275 if existing:
276 return {"type": "flight", "entry": existing}
278 if aircraft_id and not block_off:
279 q2 = Flight.query.filter_by(
280 aircraft_id=aircraft_id,
281 date=date,
282 departure_icao=dep_icao,
283 arrival_icao=arr_icao,
284 )
285 if exclude_flight_id:
286 q2 = q2.filter(Flight.id != exclude_flight_id)
287 existing2 = q2.first()
288 if existing2:
289 return {"type": "flight", "entry": existing2}
291 q3 = Flight.query.filter(
292 or_(
293 Flight.pic_user_id == pilot_user_id,
294 Flight.second_crew_user_id == pilot_user_id,
295 ),
296 Flight.date == date,
297 Flight.departure_icao == dep_icao,
298 Flight.arrival_icao == arr_icao,
299 )
300 if exclude_flight_id:
301 q3 = q3.filter(Flight.id != exclude_flight_id)
302 existing3 = q3.first()
303 if existing3:
304 return {"type": "pilot", "entry": existing3}
306 return None
309def _get_counter_hint(aircraft_id: int) -> dict[str, float | None]:
310 last = (
311 Flight.query.filter_by(aircraft_id=aircraft_id)
312 .filter(
313 db.or_(
314 Flight.flight_time_counter_end.isnot(None),
315 Flight.engine_time_counter_end.isnot(None),
316 )
317 )
318 .order_by(
319 Flight.date.desc(),
320 Flight.departure_time.desc().nullslast(),
321 Flight.id.desc(),
322 )
323 .first()
324 )
325 if not last:
326 return {"flight": None, "engine": None}
327 return {
328 "flight": float(last.flight_time_counter_end)
329 if last.flight_time_counter_end is not None
330 else None,
331 "engine": float(last.engine_time_counter_end)
332 if last.engine_time_counter_end is not None
333 else None,
334 }
337# Phase 37d: how far outside a reservation's booked window a flight may
338# still fall and be auto-linked to it — absorbs early departures / late
339# returns. A constant, not a per-tenant setting, per the spec.
340_RESERVATION_LINK_BEFORE = _timedelta(hours=2)
341_RESERVATION_LINK_AFTER = _timedelta(hours=6)
344def _find_covering_reservation(
345 aircraft_id: int, pilot_user_id: int, anchor: _datetime
346) -> Reservation | None:
347 """A CONFIRMED reservation for this pilot on this aircraft whose booked
348 window (± tolerance) contains *anchor* — never linked across pilots."""
349 candidates: list[Reservation] = Reservation.query.filter_by(
350 aircraft_id=aircraft_id,
351 pilot_user_id=pilot_user_id,
352 status=ReservationStatus.CONFIRMED,
353 ).all()
354 for r in candidates:
355 # SQLite returns naive datetimes even for DateTime(timezone=True)
356 # columns; PostgreSQL returns timezone-aware. Normalize the compare.
357 cmp_anchor = (
358 anchor.replace(tzinfo=None) if r.start_dt.tzinfo is None else anchor
359 )
360 if (
361 r.start_dt - _RESERVATION_LINK_BEFORE
362 <= cmp_anchor
363 <= r.end_dt + _RESERVATION_LINK_AFTER
364 ):
365 return r
366 return None
369def _ac_category(ac: Aircraft) -> str:
370 return getattr(ac, "category", "SEP") or "SEP"
373def apply_pilot_identity(
374 fe: Flight,
375 ac: Aircraft | None,
376 uid: int,
377 pilot_role: str,
378) -> None:
379 """Resolve the current user's own EASA figures onto whichever crew slot
380 matches their `pilot_role` ("pic" -> `pic_user_id`, "dual" ->
381 `second_crew_user_id`), and (re)compute the single_pilot_se/me split
382 from `fe.flight_time` and `ac`'s category.
384 Call only when `pilot_role` is "pic" or "dual". The caller is
385 responsible for assigning `fe`'s other shared EASA fields (night_time,
386 instrument_time, landings_day/night, multi_pilot) from the submitted
387 form values first — those aren't tied to identity resolution, unlike
388 the fields here.
390 `ac` is None for the "other aircraft" case (no fleet aircraft to derive
391 a category from) — treated as single-engine, matching the old inline
392 no-fleet-aircraft branch's behaviour.
394 Two pilots logging the same real flight must never end up with
395 different figures for it — that was a bug in the pre-refactor
396 two-table design, not a feature; this unified row makes it structurally
397 impossible, since there's only one set of EASA figures per flight.
398 """
399 ft_decimal = fe.flight_time
400 cat = _ac_category(ac) if ac is not None else "SEP"
401 fe.single_pilot_se = ft_decimal if cat in ("SEP", "SET", "") else None
402 fe.single_pilot_me = ft_decimal if cat in ("MEP", "MET") else None
404 _u = db.session.get(User, uid)
405 display_name = _u.display_name if _u else ""
407 if pilot_role == "pic":
408 fe.pic_user_id = uid
409 if not fe.pic_name:
410 fe.pic_name = display_name
411 fe.function_pic = ft_decimal
412 fe.function_dual = None
413 else: # "dual"
414 fe.second_crew_user_id = uid
415 if not fe.second_crew_name:
416 fe.second_crew_name = display_name
417 if not fe.second_crew_role:
418 fe.second_crew_role = CrewRole.STUDENT
419 fe.function_dual = ft_decimal
420 fe.function_pic = None
423# ── Serve uploads ─────────────────────────────────────────────────────────────
426@flights_bp.route("/uploads/<path:filename>")
427@login_required
428def serve_upload(filename: str) -> ResponseReturnValue:
429 # Verify the requesting user may see this file before serving it.
430 doc = Document.query.filter_by(filename=filename).first()
431 if doc is not None:
432 if doc.aircraft_id is not None:
433 # Covers aircraft docs and component docs (which always carry aircraft_id too).
434 _get_aircraft_or_404(
435 doc.aircraft_id
436 ) # aborts 404 if wrong tenant/no access
437 elif doc.flight_entry_id is not None:
438 _get_flight_or_404(doc.flight_entry_id)
439 elif doc.pilot_user_id is not None:
440 if doc.pilot_user_id != session["user_id"]:
441 abort(404)
442 else:
443 abort(404)
444 else:
445 # Counter and fuel photos are stored directly on Flight (not via Document).
446 fe = Flight.query.filter(
447 or_(
448 Flight.flight_counter_photo == filename,
449 Flight.engine_counter_photo == filename,
450 Flight.fuel_photo == filename,
451 )
452 ).first()
453 if fe is None:
454 abort(404)
455 _get_flight_or_404(fe.id)
456 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
457 return send_from_directory(folder, filename)
460# ── Fleet logbook ─────────────────────────────────────────────────────────────
463@flights_bp.route("/flights")
464@login_required
465def fleet_flights() -> ResponseReturnValue:
466 tid = _tenant_id()
467 aircraft_list = accessible_aircraft(tid, include_archived=True).all()
468 aircraft_map = {ac.id: ac for ac in aircraft_list}
469 flights = (
470 Flight.query.filter(Flight.aircraft_id.in_([ac.id for ac in aircraft_list]))
471 .order_by(
472 Flight.date.desc(),
473 Flight.departure_time.desc().nullslast(),
474 Flight.id.desc(),
475 )
476 .all()
477 )
478 return render_template(
479 "flights/fleet.html", flights=flights, aircraft_map=aircraft_map
480 )
483# ── Airframe logbook ──────────────────────────────────────────────────────────
486@flights_bp.route("/aircraft/<aircraft_ref:aircraft_id>/flights")
487@login_required
488def list_flights(aircraft_id: int) -> ResponseReturnValue:
489 ac = _get_aircraft_or_404(aircraft_id)
490 flights = (
491 Flight.query.filter_by(aircraft_id=ac.id)
492 .order_by(
493 Flight.date.desc(),
494 Flight.departure_time.desc().nullslast(),
495 Flight.id.desc(),
496 )
497 .all()
498 )
499 milestone_hours = session.pop("milestone_hours", None)
500 return render_template(
501 "flights/list.html",
502 aircraft=ac,
503 flights=flights,
504 milestone_hours=milestone_hours,
505 )
508# ── Component logbook ─────────────────────────────────────────────────────────
511@flights_bp.route(
512 "/aircraft/<aircraft_ref:aircraft_id>/components/<int:component_id>/logbook"
513)
514@login_required
515def component_logbook(aircraft_id: int, component_id: int) -> ResponseReturnValue:
516 ac = _get_aircraft_or_404(aircraft_id)
517 comp = db.session.get(Component, component_id)
518 if not comp or comp.aircraft_id != ac.id:
519 abort(404)
521 query = Flight.query.filter_by(aircraft_id=ac.id)
522 if comp.installed_at:
523 query = query.filter(Flight.date >= comp.installed_at)
524 if comp.removed_at:
525 query = query.filter(Flight.date <= comp.removed_at)
527 flights_asc = query.order_by(
528 Flight.date.asc(),
529 Flight.departure_time.asc().nullslast(),
530 Flight.id.asc(),
531 ).all()
533 base = float(comp.time_at_install or 0)
534 cumulative = base
535 flights_with_hours = []
536 for f in flights_asc:
537 # Engine/propeller TBO tracking is engine-hours based — mirrors
538 # services/component_limits.py::component_hours().
539 if f.engine_time is not None:
540 cumulative += float(f.engine_time)
541 elif (
542 f.engine_time_counter_end is not None
543 and f.engine_time_counter_start is not None
544 ):
545 cumulative += float(f.engine_time_counter_end) - float(
546 f.engine_time_counter_start
547 )
548 flights_with_hours.append((f, cumulative))
550 flights_with_hours.reverse()
552 # TBO from the dedicated column (legacy data may still carry it in extras);
553 # a recorded overhaul resets the reference point.
554 tbo_hours = (
555 float(comp.tbo_hours)
556 if comp.tbo_hours is not None
557 else (comp.extras or {}).get("tbo_hours")
558 )
559 since_overhaul = cumulative - float(comp.overhauled_at_hours or 0)
560 tbo_remaining = (tbo_hours - since_overhaul) if tbo_hours else None
562 return render_template(
563 "flights/logbook_component.html",
564 aircraft=ac,
565 component=comp,
566 flights_with_hours=flights_with_hours,
567 total_component_hours=cumulative,
568 since_overhaul=since_overhaul,
569 tbo_hours=tbo_hours,
570 tbo_remaining=tbo_remaining,
571 )
574# ── Unified log / edit flight ─────────────────────────────────────────────────
577@flights_bp.route("/flights/new", methods=["GET", "POST"])
578@login_required
579@require_pilot_access
580def log_flight() -> ResponseReturnValue:
581 tid = _tenant_id()
582 managed_aircraft = accessible_aircraft(tid).all()
583 uid = int(session["user_id"])
584 preselect_id = request.args.get("aircraft_id", type=int)
586 if request.method == "POST":
587 return _handle_log_flight_post(managed_aircraft, uid, fe=None)
589 gps_prefill = session.pop("gps_prefill", None)
590 gps_review_return_aircraft_id = request.args.get("gps_review_return", type=int)
591 gps_review_return_seg_idx = request.args.get("gps_seg", type=int)
592 _u = db.session.get(User, uid)
593 pilot_name_hint = _u.display_name if _u else ""
594 nature_suggestions = _NATURE_SUGGESTIONS
595 aircraft: Aircraft | None = None
596 if preselect_id:
597 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None)
598 if aircraft:
599 nature_suggestions = _nature_suggestions(aircraft.id)
600 counter_hint = _get_counter_hint(aircraft.id) if aircraft else None
601 covering_reservation = (
602 _find_covering_reservation(aircraft.id, uid, _datetime.now(UTC))
603 if aircraft
604 else None
605 )
607 active_minimums = get_active_revision(uid)
608 minimums_breaches = (
609 recency_breaches(active_minimums, uid) if active_minimums else []
610 )
612 return render_template(
613 "flights/flight_form.html",
614 flight=None,
615 pilot_entry=None,
616 aircraft=aircraft,
617 managed_aircraft=managed_aircraft,
618 preselect_aircraft_id=preselect_id,
619 gps_prefill=gps_prefill,
620 nature_suggestions=nature_suggestions,
621 pilot_name_hint=pilot_name_hint,
622 crew_roles=CrewRole,
623 fuel_units=_FUEL_UNITS,
624 duplicate=None,
625 counter_hint=counter_hint,
626 openaip_key=_openaip_key(),
627 today_date=_date.today().isoformat(),
628 gps_review_return_aircraft_id=gps_review_return_aircraft_id,
629 gps_review_return_seg_idx=gps_review_return_seg_idx,
630 covering_reservation=covering_reservation,
631 active_minimums=active_minimums,
632 minimums_breaches=minimums_breaches,
633 )
636@flights_bp.route("/flights/<int:flight_id>/edit", methods=["GET", "POST"])
637@login_required
638@require_pilot_access
639def edit_flight(flight_id: int) -> ResponseReturnValue:
640 tid = _tenant_id()
641 managed_aircraft = accessible_aircraft(tid, include_archived=True).all()
642 uid = int(session["user_id"])
643 fe = _get_flight_or_404(flight_id)
645 if request.method == "POST":
646 return _handle_log_flight_post(managed_aircraft, uid, fe=fe)
648 gps_prefill = session.pop("gps_prefill", None)
649 # Unified model: every pilot-log field now lives on `fe` itself — no
650 # separate PilotLogbookEntry to fetch.
651 aircraft = db.session.get(Aircraft, fe.aircraft_id) if fe.aircraft_id else None
652 counter_hint = _get_counter_hint(fe.aircraft_id) if fe.aircraft_id else None
654 return render_template(
655 "flights/flight_form.html",
656 flight=fe,
657 pilot_entry=None,
658 aircraft=aircraft,
659 managed_aircraft=managed_aircraft,
660 preselect_aircraft_id=fe.aircraft_id,
661 gps_prefill=gps_prefill,
662 nature_suggestions=_nature_suggestions(fe.aircraft_id),
663 pilot_name_hint=None,
664 crew_roles=CrewRole,
665 fuel_units=_FUEL_UNITS,
666 duplicate=None,
667 counter_hint=counter_hint,
668 openaip_key=_openaip_key(),
669 gps_review_return_aircraft_id=None,
670 gps_review_return_seg_idx=None,
671 covering_reservation=None,
672 active_minimums=None,
673 minimums_breaches=[],
674 )
677@flights_bp.route("/flights/<int:flight_id>/track/image.png")
678@login_required
679@require_pilot_access
680def flight_track_image(flight_id: int) -> ResponseReturnValue:
681 """Return a static PNG of the flight's GPS track."""
682 from flask import Response # pyright: ignore[reportMissingImports]
683 from utils import (
684 generate_single_track_image, # pyright: ignore[reportMissingImports]
685 )
687 fe = _get_flight_or_404(flight_id)
688 track = fe.gps_track
689 if not track or not track.geojson:
690 abort(404)
692 hires = request.args.get("quality") == "hires"
693 portrait = request.args.get("orientation") == "portrait"
694 is_default = not hires and not portrait
696 if is_default and track.cached_png:
697 png_bytes = bytes(track.cached_png)
698 else:
699 tile_s = db.session.get(AppSetting, "openaip_api_key")
700 base_w, base_h = (480, 800) if portrait else (800, 480)
701 mul = 2 if hires else 1
702 canvas_w, canvas_h = base_w * mul, base_h * mul
704 png_bytes = generate_single_track_image(
705 track.geojson,
706 date=str(fe.date),
707 dep=fe.departure_icao or "",
708 arr=fe.arrival_icao or "",
709 _openaip_key=tile_s.value if tile_s and tile_s.value else None,
710 canvas_w=canvas_w,
711 canvas_h=canvas_h,
712 high_res=hires,
713 )
714 if is_default:
715 track.cached_png = png_bytes # type: ignore[attr-defined]
716 db.session.commit()
717 orient_sfx = "-portrait" if portrait else ""
718 qual_sfx = "-hires" if hires else ""
719 suffix = orient_sfx + qual_sfx
720 filename = f"flight_{flight_id}_track{suffix}.png"
721 return Response(
722 png_bytes,
723 mimetype="image/png",
724 headers={
725 "Content-Disposition": f'attachment; filename="{filename}"',
726 "Cache-Control": "public, max-age=31536000, immutable",
727 "ETag": f'"{track.id}"',
728 },
729 )
732@flights_bp.route("/flights/<int:flight_id>/track/animation.gif")
733@login_required
734@require_pilot_access
735def flight_track_gif(flight_id: int) -> ResponseReturnValue:
736 """Return an animated GIF of the flight's GPS track drawn progressively."""
737 from flask import Response # pyright: ignore[reportMissingImports]
738 from utils import generate_single_track_gif # pyright: ignore[reportMissingImports]
740 fe = _get_flight_or_404(flight_id)
741 track = fe.gps_track
742 if not track or not track.geojson:
743 abort(404)
745 hires = request.args.get("quality") == "hires"
746 portrait = request.args.get("orientation") == "portrait"
747 is_default = not hires and not portrait
749 if is_default and track.cached_gif:
750 gif_bytes = bytes(track.cached_gif)
751 else:
752 tile_s = db.session.get(AppSetting, "openaip_api_key")
753 base_w, base_h = (480, 800) if portrait else (800, 480)
754 mul = 2 if hires else 1
755 canvas_w, canvas_h = base_w * mul, base_h * mul
757 gif_bytes = generate_single_track_gif(
758 track.geojson,
759 date=str(fe.date),
760 dep=fe.departure_icao or "",
761 arr=fe.arrival_icao or "",
762 _openaip_key=tile_s.value if tile_s and tile_s.value else None,
763 canvas_w=canvas_w,
764 canvas_h=canvas_h,
765 high_res=hires,
766 )
767 if is_default:
768 track.cached_gif = gif_bytes # type: ignore[attr-defined]
769 db.session.commit()
770 orient_sfx = "-portrait" if portrait else ""
771 qual_sfx = "-hires" if hires else ""
772 suffix = orient_sfx + qual_sfx
773 filename = f"flight_{flight_id}_track{suffix}.gif"
774 return Response(
775 gif_bytes,
776 mimetype="image/gif",
777 headers={
778 "Content-Disposition": f'attachment; filename="{filename}"',
779 "Cache-Control": "public, max-age=31536000, immutable",
780 "ETag": f'"{track.id}"',
781 },
782 )
785@flights_bp.route("/flights/registration-lookup")
786@login_required
787@require_pilot_access
788def registration_lookup() -> ResponseReturnValue:
789 """AJAX endpoint: return aircraft type for a previously logged registration.
791 Sources (in priority order):
792 1. Current user's own logbook entries (most recent first).
793 2. Any user in the same tenant (shared pool within the organisation).
794 Sources 3 (cross-tenant) and 4 (external registry) are intentionally omitted.
796 Matching is normalised: case-insensitive, ignoring dashes and spaces.
797 """
798 q = request.args.get("q", "").strip()
799 if not q:
800 return jsonify({"result": None})
802 def _norm(s: str) -> str:
803 return s.upper().replace("-", "").replace(" ", "")
805 q_norm = _norm(q)
806 uid = int(session["user_id"])
807 tid = _tenant_id()
809 # Only "other aircraft" (unmanaged) rows carry a free-text registration —
810 # a managed aircraft's type is already known via its own Aircraft record.
811 # Source 1: current user's own history (either crew slot)
812 user_entries = (
813 Flight.query.filter(Flight.aircraft_id.is_(None))
814 .filter(or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid))
815 .filter(Flight.other_aircraft_registration.isnot(None))
816 .order_by(
817 Flight.date.desc(),
818 Flight.departure_time.desc().nullslast(),
819 Flight.id.desc(),
820 )
821 .all()
822 )
823 for e in user_entries:
824 if (
825 _norm(e.other_aircraft_registration or "") == q_norm
826 and e.other_aircraft_type
827 ):
828 return jsonify(
829 {
830 "result": {
831 "aircraft_type": e.other_aircraft_type,
832 "aircraft_type_icao": e.other_aircraft_type_icao or "",
833 }
834 }
835 )
837 # Source 2: any user in the same tenant
838 from models import TenantUser as _TU # pyright: ignore[reportMissingImports]
840 tenant_user_ids = db.session.query(_TU.user_id).filter(_TU.tenant_id == tid)
841 tenant_entries = (
842 Flight.query.filter(Flight.aircraft_id.is_(None))
843 .filter(
844 or_(
845 Flight.pic_user_id.in_(tenant_user_ids),
846 Flight.second_crew_user_id.in_(tenant_user_ids),
847 )
848 )
849 .filter(Flight.other_aircraft_registration.isnot(None))
850 .filter(Flight.other_aircraft_type.isnot(None))
851 .order_by(
852 Flight.date.desc(),
853 Flight.departure_time.desc().nullslast(),
854 Flight.id.desc(),
855 )
856 .all()
857 )
858 for e in tenant_entries:
859 if _norm(e.other_aircraft_registration or "") == q_norm:
860 return jsonify(
861 {
862 "result": {
863 "aircraft_type": e.other_aircraft_type,
864 "aircraft_type_icao": e.other_aircraft_type_icao or "",
865 }
866 }
867 )
869 return jsonify({"result": None})
872@flights_bp.route("/flights/parse-gps", methods=["POST"])
873@_limiter.limit("30 per minute", exempt_when=_rate_limiting_disabled)
874@login_required
875@require_pilot_access
876def parse_gps_api() -> ResponseReturnValue:
877 """AJAX endpoint: parse a GPS upload, check for duplicates, return JSON."""
878 gps_file = request.files.get("gps_file")
879 if not gps_file or not gps_file.filename:
880 return jsonify(
881 {
882 "success": False,
883 "error": str(
884 _("Could not parse GPS file. Fill in the fields manually.")
885 ),
886 }
887 )
888 gps_data = _parse_gps_upload(gps_file)
889 if not gps_data:
890 return jsonify(
891 {
892 "success": False,
893 "error": str(
894 _("Could not parse GPS file. Fill in the fields manually.")
895 ),
896 }
897 )
898 return jsonify(
899 {
900 "success": True,
901 "message": str(
902 _(
903 "GPS file parsed: %(filename)s — fields pre-filled below. Review and save.",
904 filename=gps_data["filename"],
905 )
906 ),
907 "data": {
908 "filename": gps_data["filename"],
909 "date": gps_data["date"].isoformat(),
910 "departure_icao": gps_data["departure_icao"],
911 "arrival_icao": gps_data["arrival_icao"],
912 "departure_time": gps_data["departure_time"].strftime("%H:%M")
913 if gps_data["departure_time"]
914 else "",
915 "arrival_time": gps_data["arrival_time"].strftime("%H:%M")
916 if gps_data["arrival_time"]
917 else "",
918 "flight_time_h": str(gps_data["flight_time_h"]),
919 "block_off_utc": gps_data["block_off_utc"].isoformat()
920 if gps_data["block_off_utc"]
921 else "",
922 "block_on_utc": gps_data["block_on_utc"].isoformat()
923 if gps_data["block_on_utc"]
924 else "",
925 "geojson": _json.dumps(gps_data["geojson"])
926 if gps_data["geojson"]
927 else "",
928 "landing_count": gps_data["landing_count"] or 0,
929 "device_id": gps_data["device_id"] or "",
930 },
931 "duplicate": _check_gps_duplicate(gps_data),
932 "suggested_aircraft_id": _suggested_aircraft_for_device(
933 gps_data["device_id"]
934 ),
935 }
936 )
939def _suggested_aircraft_for_device(device_id: str | None) -> int | None:
940 """Return the aircraft_id most recently used with this device_id, or None."""
941 if not device_id:
942 return None
943 row = (
944 db.session.query(Flight.aircraft_id)
945 .join(GpsTrack, Flight.gps_track_id == GpsTrack.id)
946 .filter(GpsTrack.device_id == device_id)
947 .order_by(
948 Flight.date.desc(),
949 Flight.departure_time.desc().nullslast(),
950 Flight.id.desc(),
951 )
952 .first()
953 )
954 return int(row[0]) if row else None
957def _check_gps_duplicate(gps_data: dict[str, Any]) -> dict[str, Any] | None:
958 """Return a duplicate summary dict if a matching entry exists, else None."""
959 uid = int(session.get("user_id", 0))
960 aircraft_id = request.form.get("aircraft_id", type=int)
961 if aircraft_id is not None:
962 ac = db.session.get(Aircraft, aircraft_id)
963 if not ac or ac.tenant_id != _tenant_id():
964 aircraft_id = None
965 dup = _find_duplicate_flight(
966 aircraft_id=aircraft_id,
967 pilot_user_id=uid,
968 date=gps_data["date"],
969 dep_icao=gps_data["departure_icao"],
970 arr_icao=gps_data["arrival_icao"],
971 block_off=gps_data["block_off_utc"],
972 block_on=gps_data["block_on_utc"],
973 )
974 if not dup:
975 return None
976 entry = dup["entry"]
977 return {
978 "type": dup["type"],
979 "date": str(gps_data["date"]),
980 "dep": gps_data["departure_icao"],
981 "arr": gps_data["arrival_icao"],
982 "entry_id": entry.id,
983 }
986def _handle_log_flight_post(
987 managed_aircraft: list[Aircraft],
988 uid: int,
989 fe: Flight | None,
990) -> ResponseReturnValue:
991 f = request.form
992 gps_file = request.files.get("gps_file")
994 # ── GPS parse step ─────────────────────────────────────────────────────────
995 if request.form.get("action") == "parse_gps" and gps_file and gps_file.filename:
996 gps_data = _parse_gps_upload(gps_file)
997 if gps_data:
998 session["gps_prefill"] = {
999 "filename": gps_data["filename"],
1000 "date": gps_data["date"].isoformat(),
1001 "departure_icao": gps_data["departure_icao"],
1002 "arrival_icao": gps_data["arrival_icao"],
1003 "departure_time": gps_data["departure_time"].strftime("%H:%M")
1004 if gps_data["departure_time"]
1005 else "",
1006 "arrival_time": gps_data["arrival_time"].strftime("%H:%M")
1007 if gps_data["arrival_time"]
1008 else "",
1009 "flight_time_h": str(gps_data["flight_time_h"]),
1010 "block_off_utc": gps_data["block_off_utc"].isoformat(),
1011 "block_on_utc": gps_data["block_on_utc"].isoformat(),
1012 "geojson": _json.dumps(gps_data["geojson"])
1013 if gps_data["geojson"]
1014 else "",
1015 "landing_count": gps_data["landing_count"],
1016 }
1017 flash(_("GPS file parsed — fields pre-filled. Review and save."), "info")
1018 else:
1019 flash(
1020 _("Could not parse GPS file. Fill in the fields manually."), "warning"
1021 )
1022 if fe:
1023 return redirect(url_for("flights.edit_flight", flight_id=fe.id))
1024 aircraft_id = f.get("aircraft_id", type=int)
1025 qs: dict[str, Any] = {"aircraft_id": aircraft_id} if aircraft_id else {}
1026 return redirect(url_for("flights.log_flight", **qs))
1028 # ── Determine aircraft ─────────────────────────────────────────────────────
1029 other_aircraft = f.get("other_aircraft") == "1"
1030 aircraft_id_raw = f.get("aircraft_id", type=int)
1031 # When editing an existing flight, fall back to the flight's own aircraft_id
1032 # so the `if ac:` block is entered even when aircraft_id is absent from the form.
1033 if aircraft_id_raw is None and fe is not None:
1034 aircraft_id_raw = fe.aircraft_id
1035 ac: Aircraft | None = None
1036 if not other_aircraft and aircraft_id_raw:
1037 ac = next((a for a in managed_aircraft if a.id == aircraft_id_raw), None)
1039 other_ac_make_model = f.get("other_ac_make_model", "").strip()
1040 other_ac_reg = f.get("other_ac_reg", "").strip().upper()
1042 # ── Parse common fields ────────────────────────────────────────────────────
1043 pilot_role = f.get("pilot_role", "none").strip()
1044 if pilot_role not in ("pic", "dual", "none"):
1045 pilot_role = "none"
1047 # Pilot-log fields
1048 night_time_raw = f.get("night_time", "").strip()
1049 instrument_time_raw = f.get("instrument_time", "").strip()
1050 landings_day_raw = f.get("landings_day", "").strip()
1051 landings_night_raw = f.get("landings_night", "").strip()
1052 multi_pilot_raw = f.get("multi_pilot", "").strip()
1054 def _parse_dec(raw: str) -> decimal.Decimal | None:
1055 if not raw:
1056 return None
1057 try:
1058 v = decimal.Decimal(raw)
1059 return v if v >= 0 else None
1060 except decimal.InvalidOperation:
1061 return None
1063 night_time = _parse_dec(night_time_raw)
1064 instrument_time = _parse_dec(instrument_time_raw)
1065 multi_pilot = _parse_dec(multi_pilot_raw)
1066 landings_day: int | None = (
1067 int(landings_day_raw) if landings_day_raw.isdigit() else None
1068 )
1069 landings_night: int | None = (
1070 int(landings_night_raw) if landings_night_raw.isdigit() else None
1071 )
1073 # GPS hidden fields (carried from parse step or re-render)
1074 gps_filename = f.get("gps_filename", "").strip() or None
1075 gps_device_id = f.get("gps_device_id", "").strip() or None
1076 gps_block_off_raw = f.get("gps_block_off_utc", "").strip()
1077 gps_block_on_raw = f.get("gps_block_on_utc", "").strip()
1078 gps_geojson_raw = f.get("gps_geojson", "").strip()
1080 duplicate_action = f.get("duplicate_action", "").strip()
1082 errors = []
1084 if not fe and not ac and not other_aircraft:
1085 errors.append(_("Please select an aircraft."))
1087 if other_aircraft and pilot_role not in ("pic", "dual"):
1088 errors.append(_("Pilot role is required for other aircraft flights."))
1089 if (
1090 other_aircraft
1091 and pilot_role in ("pic", "dual")
1092 and not f.get("crew_name_0", "").strip()
1093 ):
1094 errors.append(_("Pilot name is required."))
1095 if other_aircraft and not other_ac_make_model:
1096 errors.append(
1097 _("Aircraft type (make/model) is required for other aircraft flights.")
1098 )
1099 if other_aircraft and not other_ac_reg:
1100 errors.append(
1101 _("Aircraft registration is required for other aircraft flights.")
1102 )
1104 # The aircraft-log `landing_count` is derived from the pilot-log day/night
1105 # split (there is no separate `landing_count` form field); when neither is
1106 # given, an existing value is preserved rather than cleared — mirrored here
1107 # by injecting the resolved value into the field map handed to
1108 # parse_flight_fields, which always assigns it unconditionally.
1109 if landings_day is not None or landings_night is not None:
1110 landing_count_for_fe: int | None = (landings_day or 0) + (landings_night or 0)
1111 else:
1112 landing_count_for_fe = fe.landing_count if fe else None
1113 field_map = dict(f)
1114 field_map["landing_count"] = (
1115 str(landing_count_for_fe) if landing_count_for_fe is not None else ""
1116 )
1117 values, field_errors = parse_flight_fields(
1118 field_map, ac, strict=not flight_is_lenient(fe)
1119 )
1120 errors.extend(field_errors)
1122 flight_date = values["date"]
1123 dep = values["departure_icao"]
1124 arr = values["arrival_icao"]
1125 departure_time = values["departure_time"]
1127 gps_block_off: _datetime | None = None
1128 gps_block_on: _datetime | None = None
1129 if gps_block_off_raw:
1130 with contextlib.suppress(
1131 ValueError
1132 ): # malformed hidden field — treat as absent
1133 gps_block_off = _datetime.fromisoformat(gps_block_off_raw)
1134 if gps_block_on_raw:
1135 with contextlib.suppress(
1136 ValueError
1137 ): # malformed hidden field — treat as absent
1138 gps_block_on = _datetime.fromisoformat(gps_block_on_raw)
1140 gps_geojson: Any = None
1141 if gps_geojson_raw:
1142 with contextlib.suppress(
1143 Exception
1144 ): # malformed hidden field — GPS track simply not applied
1145 gps_geojson = _json.loads(gps_geojson_raw)
1147 if errors:
1148 for msg in errors:
1149 flash(msg, "danger")
1150 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, None)
1152 # ── Duplicate detection (first pass) ──────────────────────────────────────
1153 if not duplicate_action and flight_date and dep and arr:
1154 dup = _find_duplicate_flight(
1155 aircraft_id=ac.id if ac else None,
1156 pilot_user_id=uid,
1157 date=flight_date,
1158 dep_icao=dep,
1159 arr_icao=arr,
1160 block_off=gps_block_off,
1161 block_on=gps_block_on,
1162 exclude_flight_id=fe.id if fe else None,
1163 )
1164 if dup:
1165 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, dup)
1167 # ── GPS-attach-only path ───────────────────────────────────────────────────
1168 if duplicate_action == "link_gps" and flight_date:
1169 dup = _find_duplicate_flight(
1170 aircraft_id=ac.id if ac else None,
1171 pilot_user_id=uid,
1172 date=flight_date,
1173 dep_icao=dep,
1174 arr_icao=arr,
1175 block_off=gps_block_off,
1176 block_on=gps_block_on,
1177 exclude_flight_id=fe.id if fe else None,
1178 )
1179 if dup and (gps_geojson or gps_filename):
1180 link_track = GpsTrack(
1181 source_filename=gps_filename,
1182 device_id=gps_device_id,
1183 block_off_utc=gps_block_off,
1184 block_on_utc=gps_block_on,
1185 departure_icao=dep,
1186 arrival_icao=arr,
1187 geojson=gps_geojson,
1188 )
1189 db.session.add(link_track)
1190 db.session.flush()
1191 entry = dup["entry"]
1192 entry.gps_track_id = link_track.id
1193 db.session.commit()
1194 flash(_("GPS track linked to the existing flight entry."), "success")
1195 else:
1196 flash(_("Could not link GPS track — no matching entry found."), "warning")
1197 return redirect(url_for("pilots.logbook"))
1199 # ── Build GpsTrack if GPS data is present ─────────────────────────────────
1200 create_pilot = pilot_role in ("pic", "dual")
1202 gps_track: GpsTrack | None = None
1203 if gps_geojson or gps_filename:
1204 existing_track_id: int | None = fe.gps_track_id if fe else None
1205 if existing_track_id:
1206 gps_track = db.session.get(GpsTrack, existing_track_id)
1207 if gps_track:
1208 if gps_geojson:
1209 gps_track.geojson = gps_geojson
1210 if gps_filename:
1211 gps_track.source_filename = gps_filename
1212 if gps_block_off:
1213 gps_track.block_off_utc = gps_block_off
1214 if gps_block_on:
1215 gps_track.block_on_utc = gps_block_on
1216 if gps_track and gps_device_id:
1217 gps_track.device_id = gps_device_id
1218 if not gps_track:
1219 gps_track = GpsTrack(
1220 source_filename=gps_filename,
1221 device_id=gps_device_id,
1222 block_off_utc=gps_block_off,
1223 block_on_utc=gps_block_on,
1224 departure_icao=dep,
1225 arrival_icao=arr,
1226 geojson=gps_geojson,
1227 )
1228 db.session.add(gps_track)
1229 db.session.flush()
1231 # ── Unified flight row ─────────────────────────────────────────────────────
1232 # A Flight row now always exists, whether or not the aircraft is managed
1233 # here — the old "other aircraft" path built a standalone PilotLogbookEntry
1234 # instead of a FlightEntry; that distinction no longer exists in the
1235 # unified schema, only aircraft_id being NULL vs set.
1236 _fe_is_new = fe is None
1237 if fe is None:
1238 fe = Flight(aircraft_id=ac.id if ac else None)
1239 db.session.add(fe)
1240 else:
1241 fe.aircraft_id = ac.id if ac else None
1243 if ac:
1244 fe.other_aircraft_type = None
1245 fe.other_aircraft_type_icao = None
1246 fe.other_aircraft_registration = None
1247 else:
1248 fe.other_aircraft_type = other_ac_make_model or None
1249 fe.other_aircraft_type_icao = f.get("aircraft_type_icao", "").strip() or None
1250 fe.other_aircraft_registration = other_ac_reg or None
1252 apply_flight_fields(fe, values)
1254 if gps_track:
1255 fe.gps_track_id = gps_track.id
1256 if gps_block_off:
1257 fe.block_off_utc = gps_block_off
1258 if gps_block_on:
1259 fe.block_on_utc = gps_block_on
1261 if ac:
1262 if _fe_is_new and flight_date is not None:
1263 anchor = _datetime.combine(
1264 flight_date, departure_time or _time(12, 0), tzinfo=UTC
1265 )
1266 covering = _find_covering_reservation(ac.id, uid, anchor)
1267 fe.reservation_id = covering.id if covering else None
1269 db.session.flush()
1271 for photo_field, label, attr in [
1272 ("flight_counter_photo", "flight", "flight_counter_photo"),
1273 ("engine_counter_photo", "engine", "engine_counter_photo"),
1274 ("fuel_photo", "fuel", "fuel_photo"),
1275 ]:
1276 photo_file = request.files.get(photo_field)
1277 if photo_file and photo_file.filename:
1278 stored = _save_upload(photo_file, fe.id, label)
1279 if stored:
1280 _delete_upload(getattr(fe, attr))
1281 setattr(fe, attr, stored)
1283 # ── Pilot log figures ──────────────────────────────────────────────────────
1284 if create_pilot:
1285 fe.night_time = night_time
1286 fe.instrument_time = instrument_time
1287 fe.landings_day = landings_day if landings_day is not None else 0
1288 fe.landings_night = landings_night
1289 fe.multi_pilot = multi_pilot
1290 apply_pilot_identity(fe, ac, uid, pilot_role)
1291 else:
1292 # pilot_role == "none": this user isn't tracking a personal logbook
1293 # entry for this flight. If they previously occupied a slot on this
1294 # same flight (editing), un-claim just their identity + function
1295 # figures — the shared EASA figures (night_time etc.) stay, since
1296 # they describe the flight itself and another crew member may still
1297 # depend on them.
1298 if fe.pic_user_id == uid:
1299 fe.pic_user_id = None
1300 fe.function_pic = None
1301 elif fe.second_crew_user_id == uid:
1302 fe.second_crew_user_id = None
1303 fe.function_dual = None
1305 db.session.commit()
1307 if ac:
1308 event_name = "flight.logged" if _fe_is_new else "flight.updated"
1309 activity(
1310 event_name,
1311 flight_id=fe.id,
1312 aircraft_id=ac.id,
1313 dep=dep,
1314 arr=arr,
1315 date=str(flight_date),
1316 )
1317 _check_flight_hour_milestone(fe)
1319 flash(
1320 _(
1321 "Flight %(dep)s→%(arr)s on %(date)s saved.",
1322 dep=dep,
1323 arr=arr,
1324 date=flight_date,
1325 ),
1326 "success",
1327 )
1328 return_ac_id = f.get("gps_review_return_aircraft_id", type=int)
1329 return_seg_idx = f.get("gps_review_return_seg_idx", type=int)
1330 if return_ac_id is not None:
1331 gps_state = session.get("gps_import", {})
1332 if (
1333 gps_state.get("aircraft_id") == return_ac_id
1334 and return_seg_idx is not None
1335 ):
1336 confirmed = gps_state.get("confirmed_segments", {})
1337 confirmed[str(return_seg_idx)] = fe.id
1338 gps_state["confirmed_segments"] = confirmed
1339 session["gps_import"] = gps_state
1340 session.modified = True
1341 return redirect(
1342 url_for("aircraft.gps_import_review", aircraft_id=return_ac_id)
1343 )
1344 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1346 flash(
1347 _(
1348 "Flight %(dep)s→%(arr)s on %(date)s saved to your pilot logbook.",
1349 dep=dep,
1350 arr=arr,
1351 date=flight_date,
1352 ),
1353 "success",
1354 )
1355 return redirect(url_for("pilots.logbook"))
1358def _render_form(
1359 managed_aircraft: list[Aircraft],
1360 flight: Flight | None,
1361 pilot_entry: None,
1362 preselect_id: int | None,
1363 duplicate: dict[str, Any] | None,
1364) -> ResponseReturnValue:
1365 """`pilot_entry` is always None now — kept as a parameter only so every
1366 call site doesn't need touching; every pilot-log field lives on `flight`
1367 itself in the unified model."""
1368 nature_suggestions = _NATURE_SUGGESTIONS
1369 aircraft: Aircraft | None = None
1370 if preselect_id:
1371 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None)
1372 if aircraft:
1373 nature_suggestions = _nature_suggestions(aircraft.id)
1374 counter_hint = _get_counter_hint(aircraft.id) if aircraft else None
1375 return render_template(
1376 "flights/flight_form.html",
1377 flight=flight,
1378 pilot_entry=pilot_entry,
1379 aircraft=aircraft,
1380 managed_aircraft=managed_aircraft,
1381 preselect_aircraft_id=preselect_id,
1382 gps_prefill=None,
1383 nature_suggestions=nature_suggestions,
1384 pilot_name_hint=None,
1385 crew_roles=CrewRole,
1386 fuel_units=_FUEL_UNITS,
1387 duplicate=duplicate,
1388 counter_hint=counter_hint,
1389 openaip_key=_openaip_key(),
1390 gps_review_return_aircraft_id=None,
1391 gps_review_return_seg_idx=None,
1392 covering_reservation=None,
1393 active_minimums=None,
1394 minimums_breaches=[],
1395 )
1398# ── Delete flight ─────────────────────────────────────────────────────────────
1401@flights_bp.route(
1402 "/aircraft/<aircraft_ref:aircraft_id>/flights/<int:flight_id>/delete",
1403 methods=["POST"],
1404)
1405@login_required
1406@require_pilot_access
1407def delete_flight(aircraft_id: int, flight_id: int) -> ResponseReturnValue:
1408 ac = _get_aircraft_or_404(aircraft_id)
1409 fe = db.session.get(Flight, flight_id)
1410 if not fe or fe.aircraft_id != ac.id:
1411 abort(404)
1412 label = f"{fe.departure_icao}→{fe.arrival_icao} on {fe.date}"
1413 activity(
1414 "flight.deleted", flight_id=flight_id, aircraft_id=aircraft_id, label=label
1415 )
1416 _delete_upload(fe.flight_counter_photo)
1417 _delete_upload(fe.engine_counter_photo)
1418 db.session.delete(fe)
1419 db.session.commit()
1420 flash(_("Flight %(label)s deleted.", label=label), "success")
1421 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1424# ── Bulk airframe logbook import (CSV / Excel) ────────────────────────────────
1426_AIRFRAME_IMPORT_SESSION_KEY = "airframe_import"
1427_AIRFRAME_IMPORT_REVIEW_SESSION_KEY = "airframe_import_review"
1428_AIRFRAME_IMPORT_EXTS = {".csv", ".xlsx", ".xls"}
1429_AIRFRAME_IMPORT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
1432def _airframe_tmp_dir() -> str:
1433 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1434 d = os.path.join(folder, "import_tmp")
1435 os.makedirs(d, exist_ok=True)
1436 return d
1439def _airframe_cleanup_tmp() -> None:
1440 """Delete any leftover temp import file, including one left behind by
1441 an abandoned conflict-review (started a fresh upload instead of
1442 finishing it)."""
1443 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY)
1444 if meta:
1445 tmp = meta.get("tmp_path")
1446 if tmp and os.path.isfile(tmp):
1447 with contextlib.suppress(OSError):
1448 os.remove(tmp)
1449 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None)
1451 review_state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY)
1452 if review_state:
1453 tmp = review_state.get("tmp_path")
1454 if tmp and os.path.isfile(tmp):
1455 with contextlib.suppress(OSError):
1456 os.remove(tmp)
1457 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1460def _render_airframe_map(
1461 ac: Aircraft,
1462 parsed: Any,
1463 mapping: dict[str, str],
1464 match_type: str,
1465 filename: str,
1466 is_historical: bool = False,
1467) -> str:
1468 from pilots.logbook_import import ( # pyright: ignore[reportMissingImports]
1469 _norm,
1470 preview_rows,
1471 )
1473 from flights.airframe_import import ( # pyright: ignore[reportMissingImports]
1474 AIRFRAME_TARGET_FIELDS,
1475 airframe_type_hints,
1476 )
1478 return render_template(
1479 "flights/airframe_import_map.html",
1480 aircraft=ac,
1481 norm_cols=parsed.norm_cols,
1482 raw_cols=parsed.raw_cols,
1483 base_norm_cols=[_norm(r) for r in parsed.raw_cols],
1484 mapping=mapping,
1485 match_type=match_type,
1486 target_fields=AIRFRAME_TARGET_FIELDS,
1487 is_historical=is_historical,
1488 preview=preview_rows(parsed, mapping, n=5),
1489 filename=filename,
1490 type_hints=airframe_type_hints(parsed, mapping),
1491 )
1494@flights_bp.route(
1495 "/aircraft/<aircraft_ref:aircraft_id>/flights/import", methods=["GET", "POST"]
1496)
1497@login_required
1498@require_role(Role.ADMIN, Role.OWNER)
1499def airframe_import_upload(aircraft_id: int) -> ResponseReturnValue:
1500 from models import ( # pyright: ignore[reportMissingImports]
1501 AirframeImportBatch,
1502 AirframeImportMapping,
1503 )
1504 from pilots.logbook_import import (
1505 parse_file, # pyright: ignore[reportMissingImports]
1506 )
1508 from flights.airframe_import import (
1509 propose_airframe_mapping, # pyright: ignore[reportMissingImports]
1510 )
1512 ac = _get_aircraft_or_404(aircraft_id)
1513 batches = (
1514 AirframeImportBatch.query.filter_by(aircraft_id=ac.id)
1515 .order_by(AirframeImportBatch.imported_at.desc())
1516 .all()
1517 )
1519 if request.method == "GET":
1520 return render_template(
1521 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
1522 )
1524 uploaded = request.files.get("logbook_file")
1525 if not uploaded or not uploaded.filename:
1526 flash(_("Please select a file to upload."), "danger")
1527 return render_template(
1528 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
1529 ), 422
1531 ext = os.path.splitext(uploaded.filename)[1].lower()
1532 if ext not in _AIRFRAME_IMPORT_EXTS:
1533 flash(_("Unsupported format. Please upload a .csv or .xlsx file."), "danger")
1534 return render_template(
1535 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
1536 ), 422
1538 data = uploaded.read()
1539 if len(data) > _AIRFRAME_IMPORT_MAX_BYTES:
1540 flash(_("File too large (maximum 10 MB)."), "danger")
1541 return render_template(
1542 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
1543 ), 422
1545 try:
1546 parsed = parse_file(data, uploaded.filename)
1547 except ValueError as exc:
1548 flash(str(exc), "danger")
1549 return render_template(
1550 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
1551 ), 422
1553 _airframe_cleanup_tmp()
1554 safe_base = secure_filename(uploaded.filename) or "upload"
1555 tmp_path = os.path.join(
1556 _airframe_tmp_dir(), f"airframe_{ac.id}_{uuid.uuid4().hex}_{safe_base}"
1557 )
1558 with open(tmp_path, "wb") as fh:
1559 fh.write(data)
1561 session[_AIRFRAME_IMPORT_SESSION_KEY] = {
1562 "aircraft_id": ac.id,
1563 "tmp_path": tmp_path,
1564 "original_filename": uploaded.filename,
1565 "norm_cols": parsed.norm_cols,
1566 "fingerprint": parsed.fingerprint,
1567 }
1569 saved = AirframeImportMapping.query.filter_by(tenant_id=ac.tenant_id).all()
1570 mapping, match_type = propose_airframe_mapping(parsed, saved)
1571 return _render_airframe_map(ac, parsed, mapping, match_type, uploaded.filename)
1574@flights_bp.route(
1575 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/execute", methods=["POST"]
1576)
1577@login_required
1578@require_role(Role.ADMIN, Role.OWNER)
1579def airframe_import_execute(aircraft_id: int) -> ResponseReturnValue:
1580 from models import ( # pyright: ignore[reportMissingImports]
1581 AirframeImportBatch,
1582 AirframeImportMapping,
1583 )
1584 from pilots.logbook_import import ( # pyright: ignore[reportMissingImports]
1585 parse_duration_value,
1586 parse_file,
1587 )
1589 from flights.airframe_import import ( # pyright: ignore[reportMissingImports]
1590 AIRFRAME_TARGET_FIELDS,
1591 execute_airframe_import,
1592 find_conflicting_airframe_rows,
1593 )
1595 ac = _get_aircraft_or_404(aircraft_id)
1596 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY)
1597 if not meta or meta.get("aircraft_id") != ac.id:
1598 flash(_("Import session expired. Please upload the file again."), "warning")
1599 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1601 tmp_path: str = meta["tmp_path"]
1602 original_filename: str = meta["original_filename"]
1603 norm_cols: list[str] = meta["norm_cols"]
1604 fingerprint: str = meta["fingerprint"]
1606 if not os.path.isfile(tmp_path):
1607 flash(_("Temporary file not found. Please upload the file again."), "warning")
1608 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None)
1609 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1611 mapping: dict[str, str] = {}
1612 for col in norm_cols:
1613 val = request.form.get(f"mapping_{col}", "ignore").strip()
1614 mapping[col] = val if val in AIRFRAME_TARGET_FIELDS else "ignore"
1616 is_historical = bool(request.form.get("is_historical"))
1618 with open(tmp_path, "rb") as fh:
1619 data = fh.read()
1620 try:
1621 parsed = parse_file(data, original_filename)
1622 except ValueError as exc:
1623 flash(str(exc), "danger")
1624 _airframe_cleanup_tmp()
1625 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1627 if "date" not in mapping.values():
1628 flash(_("You must map at least one column to 'Date'."), "danger")
1629 return _render_airframe_map(
1630 ac,
1631 parsed,
1632 mapping,
1633 "alias",
1634 original_filename,
1635 is_historical,
1636 ), 422
1638 opening_counters = {
1639 "flight": parse_duration_value(
1640 request.form.get("ob_flight_counter", "").strip()
1641 )
1642 if request.form.get("ob_flight_counter", "").strip()
1643 else None,
1644 "engine": parse_duration_value(
1645 request.form.get("ob_engine_counter", "").strip()
1646 )
1647 if request.form.get("ob_engine_counter", "").strip()
1648 else None,
1649 }
1651 mapping_record = None
1652 for m in AirframeImportMapping.query.filter_by(tenant_id=ac.tenant_id).all():
1653 if m.source_fingerprint == fingerprint:
1654 m.column_mapping = _json.dumps(mapping)
1655 mapping_record = m
1656 break
1657 if mapping_record is None:
1658 mapping_record = AirframeImportMapping(
1659 tenant_id=ac.tenant_id,
1660 source_fingerprint=fingerprint,
1661 column_mapping=_json.dumps(mapping),
1662 source_columns=_json.dumps(norm_cols),
1663 created_at=_datetime.now(UTC),
1664 )
1665 db.session.add(mapping_record)
1666 db.session.flush()
1668 batch = AirframeImportBatch(
1669 aircraft_id=ac.id,
1670 mapping_id=mapping_record.id,
1671 source_filename=original_filename,
1672 imported_at=_datetime.now(UTC),
1673 is_historical=is_historical,
1674 )
1675 db.session.add(batch)
1676 db.session.flush()
1678 resolved_opening_counters = (
1679 opening_counters
1680 if any(v is not None for v in opening_counters.values())
1681 else None
1682 )
1684 # Rows that look like they might be an edited version of an existing
1685 # flight (score >= _CANDIDATE_MIN_SCORE) need a human decision, not a
1686 # guess — carve them out of this pass and route them through the
1687 # interactive review step below instead of silently importing or
1688 # skipping them.
1689 conflicts = find_conflicting_airframe_rows(parsed, mapping, ac.id)
1691 result = execute_airframe_import(
1692 parsed=parsed,
1693 mapping=mapping,
1694 aircraft=ac,
1695 batch_id=batch.id,
1696 opening_counters=resolved_opening_counters,
1697 skip_row_nums={c.row_num for c in conflicts},
1698 )
1699 batch.row_count = result.imported
1700 batch.subtotal_count = result.subtotals
1701 batch.skipped_count = len(result.skipped)
1702 batch.warning_count = len(result.continuity_warnings)
1703 batch.has_opening_counters = result.has_opening_counters
1704 db.session.commit()
1706 if conflicts:
1707 # Defer activity logging and tmp-file cleanup until every conflict
1708 # is resolved — _finalize_airframe_import_review does both, covering
1709 # entries added during review as well as the ones just committed.
1710 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = {
1711 "aircraft_id": ac.id,
1712 "tmp_path": tmp_path,
1713 "original_filename": original_filename,
1714 "mapping": mapping,
1715 "batch_id": batch.id,
1716 "resolved": {},
1717 }
1718 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None)
1719 session.modified = True
1721 flash(
1722 _(
1723 "%(imported)d flights imported so far. %(n)d rows look like "
1724 "they might already be in this aircraft's log with "
1725 "different data — please review them below.",
1726 imported=result.imported,
1727 n=len(conflicts),
1728 ),
1729 "info",
1730 )
1731 if result.duplicates:
1732 detail = "; ".join(
1733 f"row {r}: {reason}" for r, reason in result.duplicates[:5]
1734 )
1735 if len(result.duplicates) > 5:
1736 detail += f" … and {len(result.duplicates) - 5} more"
1737 flash(
1738 _(
1739 "Rows already in this aircraft's log, skipped: %(detail)s",
1740 detail=detail,
1741 ),
1742 "info",
1743 )
1744 if result.skipped:
1745 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.skipped[:5])
1746 if len(result.skipped) > 5:
1747 detail += f" … and {len(result.skipped) - 5} more"
1748 flash(_("Skipped rows: %(detail)s", detail=detail), "warning")
1750 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
1752 activity(
1753 "flights.airframe_import",
1754 aircraft_id=ac.id,
1755 batch_id=batch.id,
1756 imported=result.imported,
1757 )
1758 _airframe_cleanup_tmp()
1760 # Duplicates are a normal, expected outcome — e.g. re-uploading a
1761 # spreadsheet after appending a few new flights — not an error, so these
1762 # messages stay in the "success"/"info" register rather than "warning".
1763 if result.imported == 0 and result.duplicates:
1764 flash(
1765 _(
1766 "All flights in this file were already in this aircraft's "
1767 "log — nothing new was imported."
1768 ),
1769 "success",
1770 )
1771 elif result.duplicates:
1772 flash(
1773 _(
1774 "Import complete: %(imported)d new flights imported, "
1775 "%(duplicates)d rows were already in this aircraft's log and "
1776 "were skipped, %(subtotals)d subtotal rows skipped, "
1777 "%(skipped)d rows could not be parsed.",
1778 imported=result.imported,
1779 duplicates=len(result.duplicates),
1780 subtotals=result.subtotals,
1781 skipped=len(result.skipped),
1782 ),
1783 "success",
1784 )
1785 else:
1786 flash(
1787 _(
1788 "Import complete: %(imported)d flights imported, %(subtotals)d "
1789 "subtotal rows skipped, %(skipped)d rows could not be parsed.",
1790 imported=result.imported,
1791 subtotals=result.subtotals,
1792 skipped=len(result.skipped),
1793 ),
1794 "success",
1795 )
1796 if result.duplicates:
1797 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.duplicates[:5])
1798 if len(result.duplicates) > 5:
1799 detail += f" … and {len(result.duplicates) - 5} more"
1800 flash(
1801 _(
1802 "Rows already in this aircraft's log, skipped: %(detail)s",
1803 detail=detail,
1804 ),
1805 "info",
1806 )
1807 if result.continuity_warnings:
1808 detail = "; ".join(
1809 _(
1810 "row %(row)d: %(kind)s counter starts at %(got).1f but the previous "
1811 "entry ended at %(prev).1f",
1812 row=row,
1813 kind=kind,
1814 got=got,
1815 prev=prev,
1816 )
1817 for row, kind, prev, got in result.continuity_warnings[:5]
1818 )
1819 if len(result.continuity_warnings) > 5:
1820 detail += _(" … and %(n)d more", n=len(result.continuity_warnings) - 5)
1821 flash(_("Counter continuity warnings: %(detail)s", detail=detail), "warning")
1822 if result.skipped:
1823 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.skipped[:5])
1824 if len(result.skipped) > 5:
1825 detail += f" … and {len(result.skipped) - 5} more"
1826 flash(_("Skipped rows: %(detail)s", detail=detail), "warning")
1828 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1831def _finalize_airframe_import_review(
1832 ac: Aircraft, state: dict[str, Any]
1833) -> ResponseReturnValue:
1834 """Common tail once every conflict row from a review has a decision:
1835 activity log, tmp-file/session cleanup, summary flash, redirect — the
1836 same shape as the no-conflicts tail of airframe_import_execute above."""
1837 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
1839 batch_id: int = state["batch_id"]
1840 tmp_path: str = state["tmp_path"]
1841 batch = db.session.get(AirframeImportBatch, batch_id)
1843 activity(
1844 "flights.airframe_import",
1845 aircraft_id=ac.id,
1846 batch_id=batch_id,
1847 imported=batch.row_count if batch else 0,
1848 )
1850 with contextlib.suppress(OSError):
1851 os.remove(tmp_path)
1852 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1854 resolved: dict[str, str] = state.get("resolved", {})
1855 kept = sum(1 for d in resolved.values() if d == "keep")
1856 overwritten = sum(1 for d in resolved.values() if d.startswith("overwrite:"))
1857 added_new = sum(1 for d in resolved.values() if d == "new")
1859 flash(
1860 _(
1861 "Review complete: %(overwritten)d flights updated, %(new)d "
1862 "imported as new, %(kept)d left unchanged.",
1863 overwritten=overwritten,
1864 new=added_new,
1865 kept=kept,
1866 ),
1867 "success",
1868 )
1870 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1873@flights_bp.route("/aircraft/<aircraft_ref:aircraft_id>/flights/import/review")
1874@login_required
1875@require_role(Role.ADMIN, Role.OWNER)
1876def airframe_import_review(aircraft_id: int) -> ResponseReturnValue:
1877 from pilots.logbook_import import (
1878 parse_file, # pyright: ignore[reportMissingImports]
1879 )
1881 from flights.airframe_import import (
1882 find_conflicting_airframe_rows, # pyright: ignore[reportMissingImports]
1883 )
1885 ac = _get_aircraft_or_404(aircraft_id)
1886 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY)
1887 if not state or state.get("aircraft_id") != ac.id:
1888 flash(_("Import session expired. Please upload the file again."), "warning")
1889 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1891 tmp_path: str = state["tmp_path"]
1892 if not os.path.isfile(tmp_path):
1893 flash(_("Temporary file not found. Please upload the file again."), "warning")
1894 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1895 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1897 mapping: dict[str, str] = state["mapping"]
1898 resolved: dict[str, str] = state.get("resolved", {})
1900 with open(tmp_path, "rb") as fh:
1901 data = fh.read()
1902 try:
1903 parsed = parse_file(data, state["original_filename"])
1904 except ValueError:
1905 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1906 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1908 exclude_row_nums = {int(k) for k in resolved}
1909 conflicts = find_conflicting_airframe_rows(
1910 parsed, mapping, ac.id, exclude_row_nums=exclude_row_nums
1911 )
1913 if not conflicts:
1914 return _finalize_airframe_import_review(ac, state)
1916 candidate_ids = {cid for c in conflicts for _score, cid in c.candidates}
1917 candidate_entries: dict[int, Flight] = (
1918 {e.id: e for e in Flight.query.filter(Flight.id.in_(candidate_ids))}
1919 if candidate_ids
1920 else {}
1921 )
1923 rows = [
1924 {
1925 "row_num": c.row_num,
1926 "fields": c.fields,
1927 "crew_name": c.crew_name,
1928 "candidates": [
1929 {"id": cid, "score": score, "entry": candidate_entries.get(cid)}
1930 for score, cid in c.candidates
1931 ],
1932 }
1933 for c in conflicts
1934 ]
1936 return render_template(
1937 "flights/airframe_import_review.html",
1938 aircraft=ac,
1939 rows=rows,
1940 resolved_count=len(resolved),
1941 total_count=len(resolved) + len(conflicts),
1942 )
1945@flights_bp.route(
1946 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/review/resolve",
1947 methods=["POST"],
1948)
1949@login_required
1950@require_role(Role.ADMIN, Role.OWNER)
1951def airframe_import_review_resolve(aircraft_id: int) -> ResponseReturnValue:
1952 from pilots.logbook_import import (
1953 parse_file, # pyright: ignore[reportMissingImports]
1954 )
1956 from flights.airframe_import import ( # pyright: ignore[reportMissingImports]
1957 AirframeConflictRow,
1958 _fields_to_flight_entry_kwargs,
1959 find_conflicting_airframe_rows,
1960 )
1962 ac = _get_aircraft_or_404(aircraft_id)
1963 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY)
1964 if not state or state.get("aircraft_id") != ac.id:
1965 flash(_("Import session expired. Please upload the file again."), "warning")
1966 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1968 tmp_path: str = state["tmp_path"]
1969 mapping: dict[str, str] = state["mapping"]
1970 resolved: dict[str, str] = state.get("resolved", {})
1972 try:
1973 row_num = int(request.form.get("row_num", ""))
1974 except (ValueError, TypeError):
1975 flash(_("Invalid row number."), "danger")
1976 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
1978 if str(row_num) in resolved:
1979 flash(_("This row has already been resolved."), "info")
1980 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
1982 if not os.path.isfile(tmp_path):
1983 flash(_("Temporary file not found. Please upload the file again."), "warning")
1984 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1985 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1987 with open(tmp_path, "rb") as fh:
1988 data = fh.read()
1989 try:
1990 parsed = parse_file(data, state["original_filename"])
1991 except ValueError:
1992 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1993 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
1995 exclude_row_nums = {int(k) for k in resolved}
1996 conflicts = find_conflicting_airframe_rows(
1997 parsed, mapping, ac.id, exclude_row_nums=exclude_row_nums
1998 )
1999 conflict: AirframeConflictRow | None = next(
2000 (c for c in conflicts if c.row_num == row_num), None
2001 )
2002 if conflict is None:
2003 flash(_("Invalid row number."), "danger")
2004 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2006 decision = request.form.get("decision", "")
2007 candidate_ids = {cid for _score, cid in conflict.candidates}
2009 if decision == "keep":
2010 pass # the freshly-parsed row is discarded; the existing entry is untouched
2011 elif decision.startswith("overwrite:"):
2012 try:
2013 existing_id = int(decision.split(":", 1)[1])
2014 except ValueError:
2015 existing_id = -1
2016 if existing_id not in candidate_ids:
2017 flash(_("Invalid selection."), "danger")
2018 return redirect(
2019 url_for("flights.airframe_import_review", aircraft_id=ac.id)
2020 )
2021 existing = Flight.query.filter_by(id=existing_id, aircraft_id=ac.id).first()
2022 if existing is None:
2023 # candidate_ids just came from a live query for this aircraft in
2024 # the same request — only a concurrent delete reaches this.
2025 abort(404) # pragma: no cover
2026 # Full replace of every field this row provides — id and
2027 # airframe_import_batch_id are deliberately left untouched, so this
2028 # entry stays outside the *current* batch's rollback (it wasn't
2029 # created by it).
2030 for field_name, value in _fields_to_flight_entry_kwargs(
2031 conflict.fields
2032 ).items():
2033 setattr(existing, field_name, value)
2034 if conflict.crew_name and not existing.pic_name:
2035 existing.pic_name = conflict.crew_name
2036 db.session.commit()
2037 elif decision == "new":
2038 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2040 batch_id: int = state["batch_id"]
2041 fe = Flight(
2042 aircraft_id=ac.id,
2043 airframe_import_batch_id=batch_id,
2044 source="import",
2045 pic_name=conflict.crew_name,
2046 **_fields_to_flight_entry_kwargs(conflict.fields),
2047 )
2048 db.session.add(fe)
2049 db.session.flush()
2050 batch = db.session.get(AirframeImportBatch, batch_id)
2051 if batch is not None:
2052 batch.row_count += 1
2053 else:
2054 current_app.logger.debug( # pragma: no cover — batch was just created
2055 "airframe_import_review_resolve: batch %s vanished before resolve",
2056 batch_id,
2057 )
2058 db.session.commit()
2059 else:
2060 flash(_("Invalid decision."), "danger")
2061 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2063 resolved[str(row_num)] = decision
2064 state["resolved"] = resolved
2065 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = state
2066 session.modified = True
2068 remaining = find_conflicting_airframe_rows(
2069 parsed, mapping, ac.id, exclude_row_nums={int(k) for k in resolved}
2070 )
2071 if not remaining:
2072 return _finalize_airframe_import_review(ac, state)
2074 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2077@flights_bp.route(
2078 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/<int:batch_id>/rollback",
2079 methods=["POST"],
2080)
2081@login_required
2082@require_role(Role.ADMIN, Role.OWNER)
2083def airframe_import_rollback(aircraft_id: int, batch_id: int) -> ResponseReturnValue:
2084 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2086 ac = _get_aircraft_or_404(aircraft_id)
2087 batch = db.session.get(AirframeImportBatch, batch_id)
2088 if not batch or batch.aircraft_id != ac.id:
2089 abort(404)
2091 entry_ids = [
2092 row.id
2093 for row in Flight.query.filter_by(airframe_import_batch_id=batch.id)
2094 .with_entities(Flight.id)
2095 .all()
2096 ]
2097 if entry_ids:
2098 Flight.query.filter(Flight.id.in_(entry_ids)).delete(synchronize_session=False)
2099 db.session.delete(batch)
2100 db.session.commit()
2102 flash(
2103 _(
2104 "Import deleted: %(n)d flight entries removed.",
2105 n=len(entry_ids),
2106 ),
2107 "success",
2108 )
2109 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2112@flights_bp.route(
2113 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/<int:batch_id>/toggle-historical",
2114 methods=["POST"],
2115)
2116@login_required
2117@require_role(Role.ADMIN, Role.OWNER)
2118def airframe_import_toggle_historical(
2119 aircraft_id: int, batch_id: int
2120) -> ResponseReturnValue:
2121 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2123 ac = _get_aircraft_or_404(aircraft_id)
2124 batch = db.session.get(AirframeImportBatch, batch_id)
2125 if not batch or batch.aircraft_id != ac.id:
2126 abort(404)
2128 batch.is_historical = not batch.is_historical
2129 db.session.commit()
2131 if batch.is_historical:
2132 flash(
2133 _(
2134 "Import marked as historical: edits to its flights will no "
2135 "longer be blocked by validation on old, imprecise values."
2136 ),
2137 "success",
2138 )
2139 else:
2140 flash(
2141 _(
2142 "Import no longer marked as historical: edits to its "
2143 "flights are now subject to the normal validation rules."
2144 ),
2145 "success",
2146 )
2147 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))