Coverage for app/flights/routes.py: 100%
1154 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-17 15:29 +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 flask_babel import ngettext # pyright: ignore[reportMissingImports]
41from models import (
42 Aircraft,
43 AppSetting,
44 Component,
45 CorrectionStatus,
46 CrewInviteKind,
47 CrewInviteStatus,
48 CrewRole,
49 CrewSlot,
50 Document,
51 Flight,
52 FlightCorrectionSuggestion,
53 FlightCrewInvite,
54 GpsTrack,
55 Reservation,
56 ReservationStatus,
57 Role,
58 TenantUser,
59 User,
60 db,
61) # pyright: ignore[reportMissingImports]
62from pilots.personal_minimums import ( # pyright: ignore[reportMissingImports]
63 get_active_revision,
64 recency_breaches,
65)
66from sqlalchemy import case, func, or_ # pyright: ignore[reportMissingImports]
67from utils import (
68 accessible_aircraft,
69 activity,
70 current_user_role,
71 login_required,
72 require_pilot_access,
73 require_role,
74 tenant_pilots,
75 user_can_access_aircraft,
76) # pyright: ignore[reportMissingImports]
77from werkzeug.utils import secure_filename
79from flights.crew_invites import ( # pyright: ignore[reportMissingImports]
80 accept_invite,
81 claim_option,
82 create_claim,
83 decline_invite,
84 notify_claim_answered,
85 notify_claim_created,
86 notify_invite_answered,
87 notify_invites,
88 own_slot,
89 pending_invites_for_flight,
90 requested_invites,
91 sync_crew_invites,
92)
93from flights.crew_removal import ( # pyright: ignore[reportMissingImports]
94 flash_kept_in_pilot_logbooks,
95 remove_from_aircraft_log,
96 unlink_user,
97)
98from flights.form_parsing import ( # pyright: ignore[reportMissingImports]
99 apply_flight_fields,
100 flight_is_lenient,
101 parse_flight_fields,
102)
103from flights.shared_flight import ( # pyright: ignore[reportMissingImports]
104 FIELD_SPECS,
105 apply_personal_fields,
106 apply_suggestion,
107 build_suggestion,
108 can_edit_shared,
109 current_values,
110 field_label,
111 notify_shared_changes,
112 notify_suggestion_created,
113 notify_suggestion_rejected,
114 pending_suggestions_by,
115 personal_function_hours,
116 protect_other_pilots,
117 reject_suggestion,
118 restore_other_pilots,
119 shared_editor_ids,
120 shared_snapshot,
121 suggestable_fields,
122)
124flights_bp = Blueprint("flights", __name__)
126_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
127_ALLOWED_GPS_EXTS = {".gpx", ".kml", ".csv"}
128_FUEL_UNITS = ["L", "gal"]
129_NATURE_SUGGESTIONS = [
130 "Local flight",
131 "Navigation",
132 "Cross-country",
133 "Training",
134 "IFR practice",
135 "Night flight",
136 "Touch-and-go",
137 "Ferry flight",
138 "Air test",
139 "Sightseeing",
140]
142_HOUR_MILESTONES = [100, 500, 1000, 2000, 5000]
144# Aircraft logbook pagination (list_flights) -- same values/convention as
145# the pilot logbook (pilots/routes.py _VALID_PER_PAGE/_DEFAULT_PER_PAGE).
146_FLIGHTS_VALID_PER_PAGE = (10, 20, 50, 100)
147_FLIGHTS_DEFAULT_PER_PAGE = 20
150def _skipped_rows_summary(result: Any) -> list[str]:
151 """The sentences of a logbook import summary (airframe or pilot) about
152 rows left out — only those with something to report, each pluralised
153 on its own count."""
154 parts = []
155 if result.subtotals:
156 parts.append(
157 ngettext(
158 "One subtotal row skipped.",
159 "%(n)d subtotal rows skipped.",
160 result.subtotals,
161 n=result.subtotals,
162 )
163 )
164 if result.skipped:
165 parts.append(
166 ngettext(
167 "One row could not be parsed.",
168 "%(n)d rows could not be parsed.",
169 len(result.skipped),
170 n=len(result.skipped),
171 )
172 )
173 return parts
176def _openaip_key() -> str | None:
177 s = db.session.get(AppSetting, "openaip_api_key")
178 return s.value if s and s.value else None
181def _tenant_id() -> int:
182 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
183 if not tu:
184 abort(403)
185 return int(tu.tenant_id)
188def _check_flight_hour_milestone(fe: Flight) -> None:
189 """Set a one-shot session flag when total fleet hours cross a milestone."""
190 this_flight = float(fe.flight_time or 0)
191 if this_flight <= 0:
192 return
193 tid = _tenant_id()
194 aircraft_ids = [a.id for a in accessible_aircraft(tid).all()]
195 new_total = float(
196 db.session.query(func.sum(Flight.flight_time))
197 .filter(Flight.aircraft_id.in_(aircraft_ids))
198 .scalar()
199 or 0
200 )
201 old_total = new_total - this_flight
202 for milestone in _HOUR_MILESTONES:
203 if old_total < milestone <= new_total:
204 session["milestone_hours"] = milestone
205 flash(
206 _(
207 "🎉 You just crossed %(hours)s flight hours!",
208 hours=milestone,
209 ),
210 "info",
211 )
212 break
215def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
216 ac = db.session.get(Aircraft, aircraft_id)
217 if (
218 not ac
219 or ac.tenant_id != _tenant_id()
220 or not user_can_access_aircraft(aircraft_id)
221 ):
222 abort(404)
223 return ac
226def _get_flight_or_404(flight_id: int) -> Flight:
227 """Fetch a Flight row, authorizing by tenant (managed-aircraft rows) or
228 by crew identity (standalone rows, aircraft_id NULL — no tenant to check,
229 so only the pic/second-crew occupant may access it)."""
230 fe = db.session.get(Flight, flight_id)
231 if not fe:
232 abort(404)
233 if fe.aircraft_id is not None:
234 ac = db.session.get(Aircraft, fe.aircraft_id)
235 if not ac or ac.tenant_id != _tenant_id():
236 abort(404)
237 else:
238 uid = session.get("user_id")
239 if fe.pic_user_id != uid and fe.second_crew_user_id != uid:
240 abort(404)
241 return fe
244def _save_upload(file: Any, flight_id: int, label: str) -> str | None:
245 # secure_filename() raises TypeError on None — file.filename is None
246 # (not just "") when a multipart part omits the filename= attribute
247 # entirely. The only current caller already guards this, but this
248 # function shouldn't rely on that (found auditing every secure_filename
249 # call site for the fuzzing backlog's "extension-allowlist logic"
250 # entry).
251 ext = os.path.splitext(secure_filename(file.filename or ""))[1].lower()
252 if ext not in _ALLOWED_PHOTO_EXTS:
253 return None
254 stored = f"flight_{flight_id}_{label}_{uuid.uuid4().hex[:8]}{ext}"
255 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
256 os.makedirs(folder, exist_ok=True)
257 file.save(os.path.join(folder, stored))
258 return stored
261def _delete_upload(filename: str | None) -> None:
262 if not filename:
263 return
264 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
265 try:
266 os.remove(os.path.join(folder, filename))
267 except OSError:
268 current_app.logger.debug(
269 "Could not delete upload %s (already absent?)", filename
270 )
273def _nature_suggestions(aircraft_id: int) -> list[str]:
274 used = [
275 row[0]
276 for row in db.session.query(Flight.nature_of_flight)
277 .filter_by(aircraft_id=aircraft_id)
278 .filter(Flight.nature_of_flight.isnot(None))
279 .distinct()
280 .all()
281 ]
282 return _NATURE_SUGGESTIONS + [n for n in used if n not in _NATURE_SUGGESTIONS]
285def _parse_gps_upload(file: Any) -> dict[str, Any] | None:
286 """Parse a single GPS file. Returns autofill dict or None."""
287 try:
288 from aircraft.gps_import import ( # pyright: ignore[reportMissingImports]
289 detect_segments,
290 merge_and_sort,
291 parse_gps_file,
292 )
293 except ImportError:
294 return None
295 filename = secure_filename(file.filename or "")
296 ext = os.path.splitext(filename)[1].lower()
297 if ext not in _ALLOWED_GPS_EXTS:
298 return None
299 data = file.read()
300 try:
301 parsed = parse_gps_file(data, filename)
302 all_points = merge_and_sort([parsed])
303 segments = detect_segments(all_points)
304 except Exception: # noqa: BLE001 -- untrusted uploaded GPS file, many possible parse failures
305 return None
306 if not segments:
307 return None
308 seg = segments[0]
309 return {
310 "filename": filename,
311 "device_id": parsed.device_id,
312 "block_off_utc": seg.block_off_utc,
313 "block_on_utc": seg.block_on_utc,
314 "date": seg.block_off_utc.date(),
315 "departure_icao": seg.departure_icao or seg.hint_departure_icao or "",
316 "arrival_icao": seg.arrival_icao or seg.hint_arrival_icao or "",
317 "departure_time": seg.block_off_utc.time(),
318 "arrival_time": seg.block_on_utc.time(),
319 "flight_time_h": round(seg.flight_time_raw_h, 1),
320 "geojson": seg.track_geojson,
321 "landing_count": seg.landing_count,
322 }
325def _find_duplicate_flight(
326 aircraft_id: int | None,
327 pilot_user_id: int,
328 date: _date,
329 dep_icao: str,
330 arr_icao: str,
331 block_off: _datetime | None,
332 block_on: _datetime | None,
333 exclude_flight_id: int | None = None,
334) -> dict[str, Any] | None:
335 """Return info about a matching Flight row, or None.
337 Unified model note: a pilot's own standalone entry and an aircraft-log
338 entry are the same table now, so there's only one id space to exclude —
339 the old separate ``exclude_pilot_entry_id`` parameter is gone, since
340 "my own linked entry for this flight" and "this flight" are the same
341 row and the same id.
342 """
343 if aircraft_id and block_off and block_on:
344 q = Flight.query.filter(
345 Flight.aircraft_id == aircraft_id,
346 Flight.block_off_utc.isnot(None),
347 Flight.block_on_utc.isnot(None),
348 Flight.block_off_utc < block_on,
349 Flight.block_on_utc > block_off,
350 )
351 if exclude_flight_id:
352 q = q.filter(Flight.id != exclude_flight_id)
353 existing = q.first()
354 if existing:
355 return {"type": "flight", "entry": existing}
357 if aircraft_id and not block_off:
358 q2 = Flight.query.filter_by(
359 aircraft_id=aircraft_id,
360 date=date,
361 departure_icao=dep_icao,
362 arrival_icao=arr_icao,
363 )
364 if exclude_flight_id:
365 q2 = q2.filter(Flight.id != exclude_flight_id)
366 existing2 = q2.first()
367 if existing2:
368 return {"type": "flight", "entry": existing2}
370 q3 = Flight.query.filter(
371 or_(
372 Flight.pic_user_id == pilot_user_id,
373 Flight.second_crew_user_id == pilot_user_id,
374 ),
375 Flight.date == date,
376 Flight.departure_icao == dep_icao,
377 Flight.arrival_icao == arr_icao,
378 )
379 if exclude_flight_id:
380 q3 = q3.filter(Flight.id != exclude_flight_id)
381 existing3 = q3.first()
382 if existing3:
383 return {"type": "pilot", "entry": existing3}
385 return None
388def _get_counter_hint(aircraft_id: int) -> dict[str, float | None]:
389 last = (
390 Flight.query.filter_by(aircraft_id=aircraft_id)
391 .filter(
392 db.or_(
393 Flight.flight_time_counter_end.isnot(None),
394 Flight.engine_time_counter_end.isnot(None),
395 )
396 )
397 .order_by(
398 Flight.date.desc(),
399 Flight.departure_time.desc().nullslast(),
400 Flight.id.desc(),
401 )
402 .first()
403 )
404 if not last:
405 return {"flight": None, "engine": None}
406 return {
407 "flight": float(last.flight_time_counter_end)
408 if last.flight_time_counter_end is not None
409 else None,
410 "engine": float(last.engine_time_counter_end)
411 if last.engine_time_counter_end is not None
412 else None,
413 }
416# Phase 37d: how far outside a reservation's booked window a flight may
417# still fall and be auto-linked to it — absorbs early departures / late
418# returns. A constant, not a per-tenant setting, per the spec.
419_RESERVATION_LINK_BEFORE = _timedelta(hours=2)
420_RESERVATION_LINK_AFTER = _timedelta(hours=6)
423def _find_covering_reservation(
424 aircraft_id: int, pilot_user_id: int, anchor: _datetime
425) -> Reservation | None:
426 """A CONFIRMED reservation for this pilot on this aircraft whose booked
427 window (± tolerance) contains *anchor* — never linked across pilots."""
428 candidates: list[Reservation] = Reservation.query.filter_by(
429 aircraft_id=aircraft_id,
430 pilot_user_id=pilot_user_id,
431 status=ReservationStatus.CONFIRMED,
432 ).all()
433 for r in candidates:
434 # SQLite returns naive datetimes even for DateTime(timezone=True)
435 # columns; PostgreSQL returns timezone-aware. Normalize the compare.
436 cmp_anchor = (
437 anchor.replace(tzinfo=None) if r.start_dt.tzinfo is None else anchor
438 )
439 if (
440 r.start_dt - _RESERVATION_LINK_BEFORE
441 <= cmp_anchor
442 <= r.end_dt + _RESERVATION_LINK_AFTER
443 ):
444 return r
445 return None
448def _ac_category(ac: Aircraft) -> str:
449 return getattr(ac, "category", "SEP") or "SEP"
452def pilot_time(fe: Flight) -> Any:
453 """The duration that goes into the pilot logbook's EASA figures.
455 EASA FCL.050 flight time is block-to-block — the engine-side track
456 (`engine_time`: engine counters, else departure/arrival clock times) —
457 not the airborne segment `flight_time` measures for the airframe log.
458 Filling in an aircraft's flight counters must never shrink the pilot's
459 own logged time to the airborne figure. `flight_time` is only the
460 fallback when nothing engine-side is known (e.g. a GPS import, which
461 stores its block-to-block duration there).
462 """
463 return fe.engine_time if fe.engine_time is not None else fe.flight_time
466def apply_pilot_identity(
467 fe: Flight,
468 ac: Aircraft | None,
469 uid: int,
470 pilot_role: str,
471) -> None:
472 """Resolve the current user's own EASA figures onto whichever crew slot
473 matches their `pilot_role` ("pic" -> `pic_user_id`, "dual" ->
474 `second_crew_user_id`), and (re)compute the single_pilot_se/me split
475 from the flight's pilot time (see `pilot_time`) and `ac`'s category.
477 Call only when `pilot_role` is "pic" or "dual". The caller is
478 responsible for assigning `fe`'s other shared EASA fields (night_time,
479 instrument_time, landings_day/night, multi_pilot) from the submitted
480 form values first — those aren't tied to identity resolution, unlike
481 the fields here.
483 `ac` is None for the "other aircraft" case (no fleet aircraft to derive
484 a category from) — treated as single-engine, matching the old inline
485 no-fleet-aircraft branch's behaviour.
487 Two pilots logging the same real flight must never end up with
488 different figures for it — that was a bug in the pre-refactor
489 two-table design, not a feature; this unified row makes it structurally
490 impossible, since there's only one set of EASA figures per flight.
491 """
492 ft_decimal = pilot_time(fe)
493 cat = _ac_category(ac) if ac is not None else "SEP"
494 fe.single_pilot_se = ft_decimal if cat in ("SEP", "SET", "") else None
495 fe.single_pilot_me = ft_decimal if cat in ("MEP", "MET") else None
497 _u = db.session.get(User, uid)
498 display_name = _u.display_name if _u else ""
500 if pilot_role == "pic":
501 fe.pic_user_id = uid
502 if not fe.pic_name:
503 fe.pic_name = display_name
504 fe.function_pic = ft_decimal
505 fe.function_dual = None
506 else: # "dual"
507 fe.second_crew_user_id = uid
508 if not fe.second_crew_name:
509 fe.second_crew_name = display_name
510 if not fe.second_crew_role:
511 fe.second_crew_role = CrewRole.STUDENT
512 fe.function_dual = ft_decimal
513 fe.function_pic = None
516# ── Serve uploads ─────────────────────────────────────────────────────────────
519@flights_bp.route("/uploads/<path:filename>")
520@login_required
521def serve_upload(filename: str) -> ResponseReturnValue:
522 # Verify the requesting user may see this file before serving it.
523 doc = Document.query.filter_by(filename=filename).first()
524 if doc is not None:
525 if doc.aircraft_id is not None:
526 # Covers aircraft docs and component docs (which always carry aircraft_id too).
527 _get_aircraft_or_404(
528 doc.aircraft_id
529 ) # aborts 404 if wrong tenant/no access
530 elif doc.flight_entry_id is not None:
531 _get_flight_or_404(doc.flight_entry_id)
532 elif doc.pilot_user_id is not None:
533 if doc.pilot_user_id != session["user_id"]:
534 abort(404)
535 else:
536 abort(404)
537 else:
538 # Counter and fuel photos are stored directly on Flight (not via Document).
539 fe = Flight.query.filter(
540 or_(
541 Flight.flight_counter_photo == filename,
542 Flight.engine_counter_photo == filename,
543 Flight.fuel_photo == filename,
544 Flight.flight_counter_photo_preflight == filename,
545 Flight.engine_counter_photo_preflight == filename,
546 )
547 ).first()
548 if fe is None:
549 abort(404)
550 _get_flight_or_404(fe.id)
551 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
552 return send_from_directory(folder, filename)
555# ── Fleet logbook ─────────────────────────────────────────────────────────────
558@flights_bp.route("/flights")
559@login_required
560def fleet_flights() -> ResponseReturnValue:
561 tid = _tenant_id()
562 aircraft_list = accessible_aircraft(tid, include_archived=True).all()
563 aircraft_map = {ac.id: ac for ac in aircraft_list}
564 flights = (
565 Flight.query.filter(Flight.aircraft_id.in_([ac.id for ac in aircraft_list]))
566 .order_by(
567 Flight.date.desc(),
568 Flight.departure_time.desc().nullslast(),
569 Flight.id.desc(),
570 )
571 .all()
572 )
573 return render_template(
574 "flights/fleet.html", flights=flights, aircraft_map=aircraft_map
575 )
578# ── Airframe logbook ──────────────────────────────────────────────────────────
581def _flights_total_hours(aircraft_id: int) -> float:
582 """Sum of flight_time (falling back to the flight-counter delta when
583 flight_time itself is unset) across ALL of this aircraft's flights --
584 computed in SQL so the footer total in flights/list.html stays correct
585 once that list is paginated, instead of only summing the current page."""
586 duration = func.coalesce(
587 Flight.flight_time,
588 case(
589 (
590 Flight.flight_time_counter_end.isnot(None)
591 & Flight.flight_time_counter_start.isnot(None),
592 Flight.flight_time_counter_end - Flight.flight_time_counter_start,
593 ),
594 else_=0,
595 ),
596 )
597 total = (
598 db.session.query(func.sum(duration))
599 .filter(Flight.aircraft_id == aircraft_id)
600 .scalar()
601 )
602 return float(total) if total is not None else 0.0
605@flights_bp.route("/aircraft/<aircraft_ref:aircraft_id>/flights")
606@login_required
607def list_flights(aircraft_id: int) -> ResponseReturnValue:
608 ac = _get_aircraft_or_404(aircraft_id)
609 page = request.args.get("page", 1, type=int)
610 pp_raw = request.args.get("per_page", str(_FLIGHTS_DEFAULT_PER_PAGE))
611 show_all = pp_raw == "all"
612 per_page = (
613 None
614 if show_all
615 else (
616 int(pp_raw)
617 if pp_raw.isdigit() and int(pp_raw) in _FLIGHTS_VALID_PER_PAGE
618 else _FLIGHTS_DEFAULT_PER_PAGE
619 )
620 )
622 query = Flight.query.filter_by(aircraft_id=ac.id).order_by(
623 Flight.date.desc(),
624 Flight.departure_time.desc().nullslast(),
625 Flight.id.desc(),
626 )
627 if show_all:
628 flights = query.all()
629 pagination = None
630 total_count = len(flights)
631 else:
632 pagination = query.paginate(page=page, per_page=per_page, error_out=False)
633 flights = pagination.items
634 total_count = pagination.total
636 milestone_hours = session.pop("milestone_hours", None)
637 return render_template(
638 "flights/list.html",
639 aircraft=ac,
640 flights=flights,
641 pagination=pagination,
642 per_page=pp_raw,
643 valid_per_page=_FLIGHTS_VALID_PER_PAGE,
644 total_count=total_count,
645 total_hours=_flights_total_hours(ac.id),
646 milestone_hours=milestone_hours,
647 )
650# ── Component logbook ─────────────────────────────────────────────────────────
653@flights_bp.route(
654 "/aircraft/<aircraft_ref:aircraft_id>/components/<int:component_id>/logbook"
655)
656@login_required
657def component_logbook(aircraft_id: int, component_id: int) -> ResponseReturnValue:
658 ac = _get_aircraft_or_404(aircraft_id)
659 comp = db.session.get(Component, component_id)
660 if not comp or comp.aircraft_id != ac.id:
661 abort(404)
663 query = Flight.query.filter_by(aircraft_id=ac.id)
664 if comp.installed_at:
665 query = query.filter(Flight.date >= comp.installed_at)
666 if comp.removed_at:
667 query = query.filter(Flight.date <= comp.removed_at)
669 flights_asc = query.order_by(
670 Flight.date.asc(),
671 Flight.departure_time.asc().nullslast(),
672 Flight.id.asc(),
673 ).all()
675 base = float(comp.time_at_install or 0)
676 cumulative = base
677 flights_with_hours = []
678 for f in flights_asc:
679 # Engine/propeller TBO tracking is engine-hours based — mirrors
680 # services/component_limits.py::component_hours().
681 if f.engine_time is not None:
682 cumulative += float(f.engine_time)
683 elif (
684 f.engine_time_counter_end is not None
685 and f.engine_time_counter_start is not None
686 ):
687 cumulative += float(f.engine_time_counter_end) - float(
688 f.engine_time_counter_start
689 )
690 flights_with_hours.append((f, cumulative))
692 flights_with_hours.reverse()
694 # TBO from the dedicated column (legacy data may still carry it in extras);
695 # a recorded overhaul resets the reference point.
696 tbo_hours = (
697 float(comp.tbo_hours)
698 if comp.tbo_hours is not None
699 else (comp.extras or {}).get("tbo_hours")
700 )
701 since_overhaul = cumulative - float(comp.overhauled_at_hours or 0)
702 tbo_remaining = (tbo_hours - since_overhaul) if tbo_hours else None
704 return render_template(
705 "flights/logbook_component.html",
706 aircraft=ac,
707 component=comp,
708 flights_with_hours=flights_with_hours,
709 total_component_hours=cumulative,
710 since_overhaul=since_overhaul,
711 tbo_hours=tbo_hours,
712 tbo_remaining=tbo_remaining,
713 )
716# ── Unified log / edit flight ─────────────────────────────────────────────────
719@flights_bp.route("/flights/new", methods=["GET", "POST"])
720@login_required
721@require_pilot_access
722def log_flight() -> ResponseReturnValue:
723 tid = _tenant_id()
724 managed_aircraft = accessible_aircraft(tid).all()
725 uid = int(session["user_id"])
726 preselect_id = request.args.get("aircraft_id", type=int)
728 if request.method == "POST":
729 return _handle_log_flight_post(managed_aircraft, uid, fe=None)
731 gps_prefill = session.pop("gps_prefill", None)
732 gps_review_return_aircraft_id = request.args.get("gps_review_return", type=int)
733 gps_review_return_seg_idx = request.args.get("gps_seg", type=int)
734 _u = db.session.get(User, uid)
735 pilot_name_hint = _u.display_name if _u else ""
736 nature_suggestions = _NATURE_SUGGESTIONS
737 aircraft: Aircraft | None = None
738 if preselect_id:
739 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None)
740 if aircraft:
741 nature_suggestions = _nature_suggestions(aircraft.id)
742 counter_hint = _get_counter_hint(aircraft.id) if aircraft else None
743 covering_reservation = (
744 _find_covering_reservation(aircraft.id, uid, _datetime.now(UTC))
745 if aircraft
746 else None
747 )
749 active_minimums = get_active_revision(uid)
750 minimums_breaches = (
751 recency_breaches(active_minimums, uid) if active_minimums else []
752 )
754 return render_template(
755 "flights/flight_form.html",
756 flight=None,
757 pilot_entry=None,
758 aircraft=aircraft,
759 managed_aircraft=managed_aircraft,
760 preselect_aircraft_id=preselect_id,
761 gps_prefill=gps_prefill,
762 nature_suggestions=nature_suggestions,
763 pilot_name_hint=pilot_name_hint,
764 crew_pilots=tenant_pilots(tid),
765 crew_pending_invites={},
766 crew_roles=CrewRole,
767 fuel_units=_FUEL_UNITS,
768 duplicate=None,
769 counter_hint=counter_hint,
770 openaip_key=_openaip_key(),
771 today_date=_date.today().isoformat(),
772 gps_review_return_aircraft_id=gps_review_return_aircraft_id,
773 gps_review_return_seg_idx=gps_review_return_seg_idx,
774 covering_reservation=covering_reservation,
775 active_minimums=active_minimums,
776 minimums_breaches=minimums_breaches,
777 )
780@flights_bp.route("/flights/<int:flight_id>/edit", methods=["GET", "POST"])
781@login_required
782@require_pilot_access
783def edit_flight(flight_id: int) -> ResponseReturnValue:
784 tid = _tenant_id()
785 managed_aircraft = accessible_aircraft(tid, include_archived=True).all()
786 uid = int(session["user_id"])
787 fe = _get_flight_or_404(flight_id)
789 # A pilot who confirmed someone else's flight edits only their own part
790 # of it and suggests corrections to the rest (flights/shared_flight.py).
791 if not can_edit_shared(fe, uid, current_user_role()):
792 return redirect(url_for("flights.crew_entry", flight_id=fe.id))
794 if request.method == "POST":
795 return _handle_log_flight_post(managed_aircraft, uid, fe=fe)
797 gps_prefill = session.pop("gps_prefill", None)
798 # Unified model: every pilot-log field now lives on `fe` itself — no
799 # separate PilotLogbookEntry to fetch.
800 aircraft = db.session.get(Aircraft, fe.aircraft_id) if fe.aircraft_id else None
801 counter_hint = _get_counter_hint(fe.aircraft_id) if fe.aircraft_id else None
803 return render_template(
804 "flights/flight_form.html",
805 flight=fe,
806 pilot_entry=None,
807 aircraft=aircraft,
808 managed_aircraft=managed_aircraft,
809 preselect_aircraft_id=fe.aircraft_id,
810 gps_prefill=gps_prefill,
811 nature_suggestions=_nature_suggestions(fe.aircraft_id),
812 pilot_name_hint=None,
813 crew_pilots=tenant_pilots(tid),
814 crew_pending_invites=pending_invites_for_flight(fe),
815 crew_locked_slots=_crew_locked_slots(fe, uid),
816 crew_roles=CrewRole,
817 fuel_units=_FUEL_UNITS,
818 duplicate=None,
819 counter_hint=counter_hint,
820 openaip_key=_openaip_key(),
821 gps_review_return_aircraft_id=None,
822 gps_review_return_seg_idx=None,
823 covering_reservation=None,
824 active_minimums=None,
825 minimums_breaches=[],
826 )
829@flights_bp.app_template_global("correction_field_label")
830def correction_field_label(field: str) -> str:
831 return field_label(field)
834def _crew_locked_slots(fe: Flight, uid: int | None) -> dict[str, str]:
835 """Slots linked to another pilot's account → that pilot's name. The
836 flight form shows their name/role read-only: only they change them."""
837 locked: dict[str, str] = {}
838 for slot, occupant in (
839 (CrewSlot.PIC, fe.pic_user_id),
840 (CrewSlot.SECOND, fe.second_crew_user_id),
841 ):
842 if occupant is not None and occupant != uid:
843 user = db.session.get(User, occupant)
844 locked[slot] = user.display_name if user else "—"
845 return locked
848# ── My part of a shared flight ───────────────────────────────────────────────
851@flights_bp.route("/flights/<int:flight_id>/my-part", methods=["GET", "POST"])
852@login_required
853@require_pilot_access
854def crew_entry(flight_id: int) -> ResponseReturnValue:
855 """A linked pilot's own name, role, function time and remark on a flight,
856 plus (when they don't own its shared fields) suggesting corrections."""
857 uid = int(session["user_id"])
858 fe = db.session.get(Flight, flight_id)
859 slot = fe.slot_for(uid) if fe else None
860 if fe is None or slot is None:
861 abort(404)
862 can_edit = can_edit_shared(fe, uid, current_user_role())
863 form_values = current_values(fe)
865 if request.method == "POST":
866 action = request.form.get("action")
867 # flask.abort() always raises — the `else` branch below never falls
868 # through — but static analysis doesn't know that, so this is
869 # initialized unconditionally rather than relying on it.
870 errors: list[str] = []
871 if action == "personal":
872 errors = apply_personal_fields(fe, slot, request.form)
873 if not errors:
874 db.session.commit()
875 flash(_("Your part of this flight was saved."), "success")
876 return redirect(url_for("flights.crew_entry", flight_id=fe.id))
877 elif action == "suggest" and not can_edit:
878 changes, errors = build_suggestion(fe, request.form)
879 form_values.update(
880 {k: request.form.get(k, "") for k in suggestable_fields(fe)}
881 )
882 if not errors and not changes:
883 errors = [_("Nothing to suggest — the values are unchanged.")]
884 if not errors:
885 suggestion = FlightCorrectionSuggestion(
886 flight_id=fe.id, suggested_by_user_id=uid, changes=changes
887 )
888 db.session.add(suggestion)
889 db.session.commit()
890 notify_suggestion_created(suggestion)
891 flash(
892 _(
893 "Correction sent. It is applied once the pilot who logged "
894 "this flight accepts it."
895 ),
896 "success",
897 )
898 return redirect(url_for("flights.crew_entry", flight_id=fe.id))
899 else:
900 abort(400)
901 for msg in errors:
902 flash(msg, "danger")
904 logger_ids = shared_editor_ids(fe) - {uid}
905 logger = db.session.get(User, min(logger_ids)) if logger_ids else None
906 return render_template(
907 "flights/crew_entry.html",
908 flight=fe,
909 slot=slot,
910 can_edit_shared=can_edit,
911 logger=logger,
912 function_hours=personal_function_hours(fe, slot),
913 crew_roles=CrewRole,
914 suggestable=suggestable_fields(fe),
915 field_specs=FIELD_SPECS,
916 form_values=form_values,
917 my_suggestions=pending_suggestions_by(uid, fe),
918 field_label=field_label,
919 )
922def _get_reviewable_suggestion_or_404(suggestion_id: int) -> FlightCorrectionSuggestion:
923 uid = session.get("user_id")
924 suggestion = db.session.get(FlightCorrectionSuggestion, suggestion_id)
925 if suggestion is None:
926 abort(404)
927 fe = db.session.get(Flight, suggestion.flight_id)
928 assert fe is not None, "flight_id is a non-null CASCADE foreign key"
929 if uid == suggestion.suggested_by_user_id or uid not in shared_editor_ids(fe):
930 abort(404)
931 return suggestion
934def _correction_redirect() -> ResponseReturnValue:
935 """Fixed allow-list, like _crew_invite_redirect."""
936 if request.form.get("next") == "dashboard":
937 return redirect(url_for("index") + "#flight-corrections")
938 return redirect(url_for("pilots.logbook") + "#flight-corrections")
941@flights_bp.route("/flights/corrections/<int:suggestion_id>/accept", methods=["POST"])
942@login_required
943@require_pilot_access
944def accept_correction(suggestion_id: int) -> ResponseReturnValue:
945 suggestion = _get_reviewable_suggestion_or_404(suggestion_id)
946 if suggestion.status != CorrectionStatus.PENDING:
947 flash(_("This correction has already been handled."), "warning")
948 return _correction_redirect()
949 uid = int(session["user_id"])
950 before = apply_suggestion(suggestion, uid)
951 db.session.commit()
952 fe = db.session.get(Flight, suggestion.flight_id)
953 assert fe is not None, "flight_id is a non-null CASCADE foreign key"
954 notify_shared_changes(fe, uid, before)
955 flash(_("Correction applied to the flight."), "success")
956 return _correction_redirect()
959@flights_bp.route("/flights/corrections/<int:suggestion_id>/reject", methods=["POST"])
960@login_required
961@require_pilot_access
962def reject_correction(suggestion_id: int) -> ResponseReturnValue:
963 suggestion = _get_reviewable_suggestion_or_404(suggestion_id)
964 if suggestion.status != CorrectionStatus.PENDING:
965 flash(_("This correction has already been handled."), "warning")
966 return _correction_redirect()
967 reject_suggestion(suggestion)
968 db.session.commit()
969 notify_suggestion_rejected(suggestion, int(session["user_id"]))
970 flash(_("Correction rejected."), "info")
971 return _correction_redirect()
974def _get_own_pending_invite_or_404(invite_id: int) -> FlightCrewInvite:
975 invite = db.session.get(FlightCrewInvite, invite_id)
976 if (
977 not invite
978 or invite.kind != CrewInviteKind.INVITE
979 or invite.invited_user_id != session.get("user_id")
980 ):
981 abort(404)
982 return invite
985def _get_reviewable_claim_or_404(claim_id: int) -> FlightCrewInvite:
986 claim = db.session.get(FlightCrewInvite, claim_id)
987 if claim is None or claim.kind != CrewInviteKind.CLAIM:
988 abort(404)
989 fe = db.session.get(Flight, claim.flight_id)
990 assert fe is not None, "flight_id is a non-null CASCADE foreign key"
991 if session.get("user_id") not in shared_editor_ids(fe):
992 abort(404)
993 return claim
996def _claim_redirect() -> ResponseReturnValue:
997 """Fixed allow-list, like _crew_invite_redirect."""
998 if request.form.get("next") == "dashboard":
999 return redirect(url_for("index") + "#crew-claims")
1000 return redirect(url_for("pilots.logbook") + "#crew-claims")
1003@flights_bp.route("/flights/crew-claims/<int:claim_id>/approve", methods=["POST"])
1004@login_required
1005@require_pilot_access
1006def approve_crew_claim(claim_id: int) -> ResponseReturnValue:
1007 claim = _get_reviewable_claim_or_404(claim_id)
1008 if claim.status != CrewInviteStatus.PENDING:
1009 flash(_("This request has already been handled."), "warning")
1010 return _claim_redirect()
1011 claimant = db.session.get(User, claim.invited_user_id)
1012 assert claimant is not None, "invited_user_id is a non-null CASCADE foreign key"
1013 accepted = accept_invite(claim, claimant)
1014 db.session.commit()
1015 if accepted:
1016 notify_claim_answered(claim, accepted=True, actor_id=int(session["user_id"]))
1017 flash(
1018 _("%(name)s was added to the flight.", name=claimant.display_name),
1019 "success",
1020 )
1021 else:
1022 flash(
1023 _("That crew position has already been filled — nothing to approve."),
1024 "warning",
1025 )
1026 return _claim_redirect()
1029@flights_bp.route("/flights/crew-claims/<int:claim_id>/decline", methods=["POST"])
1030@login_required
1031@require_pilot_access
1032def decline_crew_claim(claim_id: int) -> ResponseReturnValue:
1033 claim = _get_reviewable_claim_or_404(claim_id)
1034 if claim.status != CrewInviteStatus.PENDING:
1035 flash(_("This request has already been handled."), "warning")
1036 return _claim_redirect()
1037 decline_invite(claim)
1038 db.session.commit()
1039 notify_claim_answered(claim, accepted=False, actor_id=int(session["user_id"]))
1040 flash(_("Request declined."), "info")
1041 return _claim_redirect()
1044def _crew_invite_redirect() -> ResponseReturnValue:
1045 """Back to where the Confirm/Decline button was clicked — a fixed
1046 allow-list, so the `next` field can never become an open redirect."""
1047 if request.form.get("next") == "dashboard":
1048 return redirect(url_for("index") + "#crew-invites")
1049 return redirect(url_for("pilots.logbook") + "#crew-invites")
1052@flights_bp.route("/flights/crew-invites/<int:invite_id>/accept", methods=["POST"])
1053@login_required
1054@require_pilot_access
1055def accept_crew_invite(invite_id: int) -> ResponseReturnValue:
1056 invite = _get_own_pending_invite_or_404(invite_id)
1057 if invite.status != CrewInviteStatus.PENDING:
1058 flash(_("This flight invitation is no longer open."), "warning")
1059 return _crew_invite_redirect()
1060 user = db.session.get(User, invite.invited_user_id)
1061 assert user is not None, "invited_user_id is a non-null CASCADE foreign key"
1062 accepted = accept_invite(invite, user)
1063 db.session.commit()
1064 if accepted:
1065 notify_invite_answered(invite, accepted=True)
1066 flash(_("Flight confirmed — it's now in your pilot logbook."), "success")
1067 else:
1068 flash(
1069 _("This flight's crew slot has already been filled — nothing to confirm."),
1070 "warning",
1071 )
1072 return _crew_invite_redirect()
1075@flights_bp.route("/flights/crew-invites/<int:invite_id>/decline", methods=["POST"])
1076@login_required
1077@require_pilot_access
1078def decline_crew_invite(invite_id: int) -> ResponseReturnValue:
1079 invite = _get_own_pending_invite_or_404(invite_id)
1080 if invite.status != CrewInviteStatus.PENDING:
1081 flash(_("This flight invitation is no longer open."), "warning")
1082 return _crew_invite_redirect()
1083 decline_invite(invite)
1084 db.session.commit()
1085 notify_invite_answered(invite, accepted=False)
1086 flash(_("Flight invitation declined."), "info")
1087 return _crew_invite_redirect()
1090@flights_bp.route("/flights/<int:flight_id>/track/image.png")
1091@login_required
1092@require_pilot_access
1093def flight_track_image(flight_id: int) -> ResponseReturnValue:
1094 """Return a static PNG of the flight's GPS track."""
1095 from flask import Response # pyright: ignore[reportMissingImports]
1096 from utils import (
1097 generate_single_track_image, # pyright: ignore[reportMissingImports]
1098 )
1100 fe = _get_flight_or_404(flight_id)
1101 track = fe.gps_track
1102 if not track or not track.geojson:
1103 abort(404)
1105 hires = request.args.get("quality") == "hires"
1106 portrait = request.args.get("orientation") == "portrait"
1107 is_default = not hires and not portrait
1109 if is_default and track.cached_png:
1110 png_bytes = bytes(track.cached_png)
1111 else:
1112 tile_s = db.session.get(AppSetting, "openaip_api_key")
1113 base_w, base_h = (480, 800) if portrait else (800, 480)
1114 mul = 2 if hires else 1
1115 canvas_w, canvas_h = base_w * mul, base_h * mul
1117 png_bytes = generate_single_track_image(
1118 track.geojson,
1119 date=str(fe.date),
1120 dep=fe.departure_icao or "",
1121 arr=fe.arrival_icao or "",
1122 _openaip_key=tile_s.value if tile_s and tile_s.value else None,
1123 canvas_w=canvas_w,
1124 canvas_h=canvas_h,
1125 high_res=hires,
1126 )
1127 if is_default:
1128 track.cached_png = png_bytes # type: ignore[attr-defined]
1129 db.session.commit()
1130 orient_sfx = "-portrait" if portrait else ""
1131 qual_sfx = "-hires" if hires else ""
1132 suffix = orient_sfx + qual_sfx
1133 filename = f"flight_{flight_id}_track{suffix}.png"
1134 return Response(
1135 png_bytes,
1136 mimetype="image/png",
1137 headers={
1138 "Content-Disposition": f'attachment; filename="{filename}"',
1139 "Cache-Control": "public, max-age=31536000, immutable",
1140 "ETag": f'"{track.id}"',
1141 },
1142 )
1145@flights_bp.route("/flights/<int:flight_id>/track/animation.gif")
1146@login_required
1147@require_pilot_access
1148def flight_track_gif(flight_id: int) -> ResponseReturnValue:
1149 """Return an animated GIF of the flight's GPS track drawn progressively."""
1150 from flask import Response # pyright: ignore[reportMissingImports]
1151 from utils import generate_single_track_gif # pyright: ignore[reportMissingImports]
1153 fe = _get_flight_or_404(flight_id)
1154 track = fe.gps_track
1155 if not track or not track.geojson:
1156 abort(404)
1158 hires = request.args.get("quality") == "hires"
1159 portrait = request.args.get("orientation") == "portrait"
1160 is_default = not hires and not portrait
1162 if is_default and track.cached_gif:
1163 gif_bytes = bytes(track.cached_gif)
1164 else:
1165 tile_s = db.session.get(AppSetting, "openaip_api_key")
1166 base_w, base_h = (480, 800) if portrait else (800, 480)
1167 mul = 2 if hires else 1
1168 canvas_w, canvas_h = base_w * mul, base_h * mul
1170 gif_bytes = generate_single_track_gif(
1171 track.geojson,
1172 date=str(fe.date),
1173 dep=fe.departure_icao or "",
1174 arr=fe.arrival_icao or "",
1175 _openaip_key=tile_s.value if tile_s and tile_s.value else None,
1176 canvas_w=canvas_w,
1177 canvas_h=canvas_h,
1178 high_res=hires,
1179 )
1180 if is_default:
1181 track.cached_gif = gif_bytes # type: ignore[attr-defined]
1182 db.session.commit()
1183 orient_sfx = "-portrait" if portrait else ""
1184 qual_sfx = "-hires" if hires else ""
1185 suffix = orient_sfx + qual_sfx
1186 filename = f"flight_{flight_id}_track{suffix}.gif"
1187 return Response(
1188 gif_bytes,
1189 mimetype="image/gif",
1190 headers={
1191 "Content-Disposition": f'attachment; filename="{filename}"',
1192 "Cache-Control": "public, max-age=31536000, immutable",
1193 "ETag": f'"{track.id}"',
1194 },
1195 )
1198@flights_bp.route("/flights/registration-lookup")
1199@login_required
1200@require_pilot_access
1201def registration_lookup() -> ResponseReturnValue:
1202 """AJAX endpoint: return aircraft type for a previously logged registration.
1204 Sources (in priority order):
1205 1. Current user's own logbook entries (most recent first).
1206 2. Any user in the same tenant (shared pool within the organisation).
1207 Sources 3 (cross-tenant) and 4 (external registry) are intentionally omitted.
1209 Matching is normalised: case-insensitive, ignoring dashes and spaces.
1210 """
1211 q = request.args.get("q", "").strip()
1212 if not q:
1213 return jsonify({"result": None})
1215 def _norm(s: str) -> str:
1216 return s.upper().replace("-", "").replace(" ", "")
1218 q_norm = _norm(q)
1219 uid = int(session["user_id"])
1220 tid = _tenant_id()
1222 # Only "other aircraft" (unmanaged) rows carry a free-text registration —
1223 # a managed aircraft's type is already known via its own Aircraft record.
1224 # Source 1: current user's own history (either crew slot)
1225 user_entries = (
1226 Flight.query.filter(Flight.aircraft_id.is_(None))
1227 .filter(or_(Flight.pic_user_id == uid, Flight.second_crew_user_id == uid))
1228 .filter(Flight.other_aircraft_registration.isnot(None))
1229 .order_by(
1230 Flight.date.desc(),
1231 Flight.departure_time.desc().nullslast(),
1232 Flight.id.desc(),
1233 )
1234 .all()
1235 )
1236 for e in user_entries:
1237 if (
1238 _norm(e.other_aircraft_registration or "") == q_norm
1239 and e.other_aircraft_type
1240 ):
1241 return jsonify(
1242 {
1243 "result": {
1244 "aircraft_type": e.other_aircraft_type,
1245 "aircraft_type_icao": e.other_aircraft_type_icao or "",
1246 }
1247 }
1248 )
1250 # Source 2: any user in the same tenant
1251 from models import TenantUser as _TU # pyright: ignore[reportMissingImports]
1253 tenant_user_ids = db.session.query(_TU.user_id).filter(_TU.tenant_id == tid)
1254 tenant_entries = (
1255 Flight.query.filter(Flight.aircraft_id.is_(None))
1256 .filter(
1257 or_(
1258 Flight.pic_user_id.in_(tenant_user_ids),
1259 Flight.second_crew_user_id.in_(tenant_user_ids),
1260 )
1261 )
1262 .filter(Flight.other_aircraft_registration.isnot(None))
1263 .filter(Flight.other_aircraft_type.isnot(None))
1264 .order_by(
1265 Flight.date.desc(),
1266 Flight.departure_time.desc().nullslast(),
1267 Flight.id.desc(),
1268 )
1269 .all()
1270 )
1271 for e in tenant_entries:
1272 if _norm(e.other_aircraft_registration or "") == q_norm:
1273 return jsonify(
1274 {
1275 "result": {
1276 "aircraft_type": e.other_aircraft_type,
1277 "aircraft_type_icao": e.other_aircraft_type_icao or "",
1278 }
1279 }
1280 )
1282 return jsonify({"result": None})
1285@flights_bp.route("/flights/parse-gps", methods=["POST"])
1286@_limiter.limit("30 per minute", exempt_when=_rate_limiting_disabled)
1287@login_required
1288@require_pilot_access
1289def parse_gps_api() -> ResponseReturnValue:
1290 """AJAX endpoint: parse a GPS upload, check for duplicates, return JSON."""
1291 gps_file = request.files.get("gps_file")
1292 if not gps_file or not gps_file.filename:
1293 return jsonify(
1294 {
1295 "success": False,
1296 "error": str(
1297 _("Could not parse GPS file. Fill in the fields manually.")
1298 ),
1299 }
1300 )
1301 gps_data = _parse_gps_upload(gps_file)
1302 if not gps_data:
1303 return jsonify(
1304 {
1305 "success": False,
1306 "error": str(
1307 _("Could not parse GPS file. Fill in the fields manually.")
1308 ),
1309 }
1310 )
1311 return jsonify(
1312 {
1313 "success": True,
1314 "message": str(
1315 _(
1316 "GPS file parsed: %(filename)s — fields pre-filled below. Review and save.",
1317 filename=gps_data["filename"],
1318 )
1319 ),
1320 "data": {
1321 "filename": gps_data["filename"],
1322 "date": gps_data["date"].isoformat(),
1323 "departure_icao": gps_data["departure_icao"],
1324 "arrival_icao": gps_data["arrival_icao"],
1325 "departure_time": gps_data["departure_time"].strftime("%H:%M")
1326 if gps_data["departure_time"]
1327 else "",
1328 "arrival_time": gps_data["arrival_time"].strftime("%H:%M")
1329 if gps_data["arrival_time"]
1330 else "",
1331 "flight_time_h": str(gps_data["flight_time_h"]),
1332 "block_off_utc": gps_data["block_off_utc"].isoformat()
1333 if gps_data["block_off_utc"]
1334 else "",
1335 "block_on_utc": gps_data["block_on_utc"].isoformat()
1336 if gps_data["block_on_utc"]
1337 else "",
1338 "geojson": _json.dumps(gps_data["geojson"])
1339 if gps_data["geojson"]
1340 else "",
1341 "landing_count": gps_data["landing_count"] or 0,
1342 "device_id": gps_data["device_id"] or "",
1343 },
1344 "duplicate": _check_gps_duplicate(gps_data),
1345 "suggested_aircraft_id": _suggested_aircraft_for_device(
1346 gps_data["device_id"]
1347 ),
1348 }
1349 )
1352def _suggested_aircraft_for_device(device_id: str | None) -> int | None:
1353 """Return the aircraft_id most recently used with this device_id, or None."""
1354 if not device_id:
1355 return None
1356 row = (
1357 db.session.query(Flight.aircraft_id)
1358 .join(GpsTrack, Flight.gps_track_id == GpsTrack.id)
1359 .filter(GpsTrack.device_id == device_id)
1360 .order_by(
1361 Flight.date.desc(),
1362 Flight.departure_time.desc().nullslast(),
1363 Flight.id.desc(),
1364 )
1365 .first()
1366 )
1367 return int(row[0]) if row else None
1370def _check_gps_duplicate(gps_data: dict[str, Any]) -> dict[str, Any] | None:
1371 """Return a duplicate summary dict if a matching entry exists, else None."""
1372 uid = int(session.get("user_id", 0))
1373 aircraft_id = request.form.get("aircraft_id", type=int)
1374 if aircraft_id is not None:
1375 ac = db.session.get(Aircraft, aircraft_id)
1376 if not ac or ac.tenant_id != _tenant_id():
1377 aircraft_id = None
1378 dup = _find_duplicate_flight(
1379 aircraft_id=aircraft_id,
1380 pilot_user_id=uid,
1381 date=gps_data["date"],
1382 dep_icao=gps_data["departure_icao"],
1383 arr_icao=gps_data["arrival_icao"],
1384 block_off=gps_data["block_off_utc"],
1385 block_on=gps_data["block_on_utc"],
1386 )
1387 if not dup:
1388 return None
1389 entry = dup["entry"]
1390 return {
1391 "type": dup["type"],
1392 "date": str(gps_data["date"]),
1393 "dep": gps_data["departure_icao"],
1394 "arr": gps_data["arrival_icao"],
1395 "entry_id": entry.id,
1396 }
1399def _handle_log_flight_post(
1400 managed_aircraft: list[Aircraft],
1401 uid: int,
1402 fe: Flight | None,
1403) -> ResponseReturnValue:
1404 f = request.form
1405 gps_file = request.files.get("gps_file")
1407 # ── GPS parse step ─────────────────────────────────────────────────────────
1408 if request.form.get("action") == "parse_gps" and gps_file and gps_file.filename:
1409 gps_data = _parse_gps_upload(gps_file)
1410 if gps_data:
1411 session["gps_prefill"] = {
1412 "filename": gps_data["filename"],
1413 "date": gps_data["date"].isoformat(),
1414 "departure_icao": gps_data["departure_icao"],
1415 "arrival_icao": gps_data["arrival_icao"],
1416 "departure_time": gps_data["departure_time"].strftime("%H:%M")
1417 if gps_data["departure_time"]
1418 else "",
1419 "arrival_time": gps_data["arrival_time"].strftime("%H:%M")
1420 if gps_data["arrival_time"]
1421 else "",
1422 "flight_time_h": str(gps_data["flight_time_h"]),
1423 "block_off_utc": gps_data["block_off_utc"].isoformat(),
1424 "block_on_utc": gps_data["block_on_utc"].isoformat(),
1425 "geojson": _json.dumps(gps_data["geojson"])
1426 if gps_data["geojson"]
1427 else "",
1428 "landing_count": gps_data["landing_count"],
1429 }
1430 flash(_("GPS file parsed — fields pre-filled. Review and save."), "info")
1431 else:
1432 flash(
1433 _("Could not parse GPS file. Fill in the fields manually."), "warning"
1434 )
1435 if fe:
1436 return redirect(url_for("flights.edit_flight", flight_id=fe.id))
1437 aircraft_id = f.get("aircraft_id", type=int)
1438 qs: dict[str, Any] = {"aircraft_id": aircraft_id} if aircraft_id else {}
1439 return redirect(url_for("flights.log_flight", **qs))
1441 # ── Determine aircraft ─────────────────────────────────────────────────────
1442 other_aircraft = f.get("other_aircraft") == "1"
1443 aircraft_id_raw = f.get("aircraft_id", type=int)
1444 # When editing an existing flight, fall back to the flight's own aircraft_id
1445 # so the `if ac:` block is entered even when aircraft_id is absent from the form.
1446 if aircraft_id_raw is None and fe is not None:
1447 aircraft_id_raw = fe.aircraft_id
1448 ac: Aircraft | None = None
1449 if not other_aircraft and aircraft_id_raw:
1450 ac = next((a for a in managed_aircraft if a.id == aircraft_id_raw), None)
1452 other_ac_make_model = f.get("other_ac_make_model", "").strip()
1453 other_ac_reg = f.get("other_ac_reg", "").strip().upper()
1455 # ── Parse common fields ────────────────────────────────────────────────────
1456 pilot_role = f.get("pilot_role", "none").strip()
1457 if pilot_role not in ("pic", "dual", "none"):
1458 pilot_role = "none"
1460 # Pilot-log fields
1461 night_time_raw = f.get("night_time", "").strip()
1462 instrument_time_raw = f.get("instrument_time", "").strip()
1463 landings_day_raw = f.get("landings_day", "").strip()
1464 landings_night_raw = f.get("landings_night", "").strip()
1465 multi_pilot_raw = f.get("multi_pilot", "").strip()
1467 def _parse_dec(raw: str) -> decimal.Decimal | None:
1468 if not raw:
1469 return None
1470 try:
1471 v = decimal.Decimal(raw)
1472 return v if v >= 0 else None
1473 except decimal.InvalidOperation:
1474 return None
1476 night_time = _parse_dec(night_time_raw)
1477 instrument_time = _parse_dec(instrument_time_raw)
1478 multi_pilot = _parse_dec(multi_pilot_raw)
1479 landings_day: int | None = (
1480 int(landings_day_raw) if landings_day_raw.isdigit() else None
1481 )
1482 landings_night: int | None = (
1483 int(landings_night_raw) if landings_night_raw.isdigit() else None
1484 )
1486 # GPS hidden fields (carried from parse step or re-render)
1487 gps_filename = f.get("gps_filename", "").strip() or None
1488 gps_device_id = f.get("gps_device_id", "").strip() or None
1489 gps_block_off_raw = f.get("gps_block_off_utc", "").strip()
1490 gps_block_on_raw = f.get("gps_block_on_utc", "").strip()
1491 gps_geojson_raw = f.get("gps_geojson", "").strip()
1493 duplicate_action = f.get("duplicate_action", "").strip()
1495 errors = []
1497 if not fe and not ac and not other_aircraft:
1498 errors.append(_("Please select an aircraft."))
1500 if other_aircraft and pilot_role not in ("pic", "dual"):
1501 errors.append(_("Pilot role is required for other aircraft flights."))
1502 if (
1503 other_aircraft
1504 and pilot_role in ("pic", "dual")
1505 and not f.get("crew_name_0", "").strip()
1506 ):
1507 errors.append(_("Pilot name is required."))
1508 if other_aircraft and not other_ac_make_model:
1509 errors.append(
1510 _("Aircraft type (make/model) is required for other aircraft flights.")
1511 )
1512 if other_aircraft and not other_ac_reg:
1513 errors.append(
1514 _("Aircraft registration is required for other aircraft flights.")
1515 )
1516 # The slot this pilot picks for themselves must not already belong to the
1517 # other pilot who confirmed this flight.
1518 requested_slot = own_slot(pilot_role)
1519 if fe is not None and requested_slot is not None:
1520 occupant = (
1521 fe.pic_user_id if requested_slot == CrewSlot.PIC else fe.second_crew_user_id
1522 )
1523 if occupant is not None and occupant != uid:
1524 occupant_user = db.session.get(User, occupant)
1525 errors.append(
1526 _(
1527 "That crew position is already confirmed by %(name)s — pick "
1528 "your own role on this flight.",
1529 name=occupant_user.display_name if occupant_user else "—",
1530 )
1531 )
1533 # The aircraft-log `landing_count` is derived from the pilot-log day/night
1534 # split (there is no separate `landing_count` form field); when neither is
1535 # given, an existing value is preserved rather than cleared — mirrored here
1536 # by injecting the resolved value into the field map handed to
1537 # parse_flight_fields, which always assigns it unconditionally.
1538 if landings_day is not None or landings_night is not None:
1539 landing_count_for_fe: int | None = (landings_day or 0) + (landings_night or 0)
1540 else:
1541 landing_count_for_fe = fe.landing_count if fe else None
1542 field_map = dict(f)
1543 field_map["landing_count"] = (
1544 str(landing_count_for_fe) if landing_count_for_fe is not None else ""
1545 )
1546 values, field_errors = parse_flight_fields(
1547 field_map, ac, strict=not flight_is_lenient(fe)
1548 )
1549 errors.extend(field_errors)
1551 flight_date = values["date"]
1552 dep = values["departure_icao"]
1553 arr = values["arrival_icao"]
1554 departure_time = values["departure_time"]
1556 gps_block_off: _datetime | None = None
1557 gps_block_on: _datetime | None = None
1558 if gps_block_off_raw:
1559 with contextlib.suppress(
1560 ValueError
1561 ): # malformed hidden field — treat as absent
1562 gps_block_off = _datetime.fromisoformat(gps_block_off_raw)
1563 if gps_block_on_raw:
1564 with contextlib.suppress(
1565 ValueError
1566 ): # malformed hidden field — treat as absent
1567 gps_block_on = _datetime.fromisoformat(gps_block_on_raw)
1569 gps_geojson: Any = None
1570 if gps_geojson_raw:
1571 with contextlib.suppress(
1572 Exception
1573 ): # malformed hidden field — GPS track simply not applied
1574 gps_geojson = _json.loads(gps_geojson_raw)
1576 if errors:
1577 for msg in errors:
1578 flash(msg, "danger")
1579 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, None)
1581 # ── Duplicate detection (first pass) ──────────────────────────────────────
1582 if not duplicate_action and flight_date and dep and arr:
1583 dup = _find_duplicate_flight(
1584 aircraft_id=ac.id if ac else None,
1585 pilot_user_id=uid,
1586 date=flight_date,
1587 dep_icao=dep,
1588 arr_icao=arr,
1589 block_off=gps_block_off,
1590 block_on=gps_block_on,
1591 exclude_flight_id=fe.id if fe else None,
1592 )
1593 if dup:
1594 # Someone else already logged this flight: offer to ask them to
1595 # add this pilot to it, instead of logging a duplicate.
1596 dup["claim"] = (
1597 claim_option(dup["entry"], uid, pilot_role) if fe is None else None
1598 )
1599 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, dup)
1601 # ── Claim path: ask to be added to the already-logged flight ──────────────
1602 if duplicate_action == "claim" and fe is None and flight_date:
1603 dup = _find_duplicate_flight(
1604 aircraft_id=ac.id if ac else None,
1605 pilot_user_id=uid,
1606 date=flight_date,
1607 dep_icao=dep,
1608 arr_icao=arr,
1609 block_off=gps_block_off,
1610 block_on=gps_block_on,
1611 )
1612 option = claim_option(dup["entry"], uid, pilot_role) if dup else None
1613 if dup is None or option is None or option["already_requested"]:
1614 flash(
1615 _("Could not send the request — this flight can't be claimed."),
1616 "warning",
1617 )
1618 return redirect(url_for("pilots.logbook"))
1619 claim = create_claim(dup["entry"], uid, option["slot"], f.get("crew_role_1"))
1620 db.session.commit()
1621 notify_claim_created(claim)
1622 flash(
1623 _(
1624 "Request sent to %(names)s. The flight appears in your logbook "
1625 "once they approve it.",
1626 names=option["approver_names"],
1627 ),
1628 "success",
1629 )
1630 return redirect(url_for("pilots.logbook"))
1632 # ── GPS-attach-only path ───────────────────────────────────────────────────
1633 if duplicate_action == "link_gps" and flight_date:
1634 dup = _find_duplicate_flight(
1635 aircraft_id=ac.id if ac else None,
1636 pilot_user_id=uid,
1637 date=flight_date,
1638 dep_icao=dep,
1639 arr_icao=arr,
1640 block_off=gps_block_off,
1641 block_on=gps_block_on,
1642 exclude_flight_id=fe.id if fe else None,
1643 )
1644 if dup and (gps_geojson or gps_filename):
1645 link_track = GpsTrack(
1646 source_filename=gps_filename,
1647 device_id=gps_device_id,
1648 block_off_utc=gps_block_off,
1649 block_on_utc=gps_block_on,
1650 departure_icao=dep,
1651 arrival_icao=arr,
1652 geojson=gps_geojson,
1653 )
1654 db.session.add(link_track)
1655 db.session.flush()
1656 entry = dup["entry"]
1657 entry.gps_track_id = link_track.id
1658 db.session.commit()
1659 flash(_("GPS track linked to the existing flight entry."), "success")
1660 else:
1661 flash(_("Could not link GPS track — no matching entry found."), "warning")
1662 return redirect(url_for("pilots.logbook"))
1664 # ── Build GpsTrack if GPS data is present ─────────────────────────────────
1665 create_pilot = pilot_role in ("pic", "dual")
1667 gps_track: GpsTrack | None = None
1668 if gps_geojson or gps_filename:
1669 existing_track_id: int | None = fe.gps_track_id if fe else None
1670 if existing_track_id:
1671 gps_track = db.session.get(GpsTrack, existing_track_id)
1672 if gps_track:
1673 if gps_geojson:
1674 gps_track.geojson = gps_geojson
1675 if gps_filename:
1676 gps_track.source_filename = gps_filename
1677 if gps_block_off:
1678 gps_track.block_off_utc = gps_block_off
1679 if gps_block_on:
1680 gps_track.block_on_utc = gps_block_on
1681 if gps_track and gps_device_id:
1682 gps_track.device_id = gps_device_id
1683 if not gps_track:
1684 gps_track = GpsTrack(
1685 source_filename=gps_filename,
1686 device_id=gps_device_id,
1687 block_off_utc=gps_block_off,
1688 block_on_utc=gps_block_on,
1689 departure_icao=dep,
1690 arrival_icao=arr,
1691 geojson=gps_geojson,
1692 )
1693 db.session.add(gps_track)
1694 db.session.flush()
1696 # ── Unified flight row ─────────────────────────────────────────────────────
1697 # A Flight row now always exists, whether or not the aircraft is managed
1698 # here — the old "other aircraft" path built a standalone PilotLogbookEntry
1699 # instead of a FlightEntry; that distinction no longer exists in the
1700 # unified schema, only aircraft_id being NULL vs set.
1701 _fe_is_new = fe is None
1702 shared_before: dict[str, str] | None = None
1703 if fe is None:
1704 fe = Flight(aircraft_id=ac.id if ac else None, created_by_user_id=uid)
1705 db.session.add(fe)
1706 other_pilots: dict[str, Any] = {"total": None, "slots": {}}
1707 else:
1708 # Editing a flight another pilot has in their logbook: never touch
1709 # their name/role/function hours/remark, and tell them what changed.
1710 shared_before = shared_snapshot(fe)
1711 other_pilots = protect_other_pilots(fe, uid)
1712 fe.aircraft_id = ac.id if ac else None
1714 if ac:
1715 fe.other_aircraft_type = None
1716 fe.other_aircraft_type_icao = None
1717 fe.other_aircraft_registration = None
1718 else:
1719 fe.other_aircraft_type = other_ac_make_model or None
1720 fe.other_aircraft_type_icao = f.get("aircraft_type_icao", "").strip() or None
1721 fe.other_aircraft_registration = other_ac_reg or None
1723 apply_flight_fields(fe, values)
1725 if gps_track:
1726 fe.gps_track_id = gps_track.id
1727 if gps_block_off:
1728 fe.block_off_utc = gps_block_off
1729 if gps_block_on:
1730 fe.block_on_utc = gps_block_on
1732 if ac:
1733 if _fe_is_new and flight_date is not None:
1734 anchor = _datetime.combine(
1735 flight_date, departure_time or _time(12, 0), tzinfo=UTC
1736 )
1737 covering = _find_covering_reservation(ac.id, uid, anchor)
1738 fe.reservation_id = covering.id if covering else None
1740 db.session.flush()
1742 for photo_field, label, attr in [
1743 ("flight_counter_photo", "flight", "flight_counter_photo"),
1744 ("engine_counter_photo", "engine", "engine_counter_photo"),
1745 ("fuel_photo", "fuel", "fuel_photo"),
1746 (
1747 "flight_counter_photo_preflight",
1748 "flight_preflight",
1749 "flight_counter_photo_preflight",
1750 ),
1751 (
1752 "engine_counter_photo_preflight",
1753 "engine_preflight",
1754 "engine_counter_photo_preflight",
1755 ),
1756 ]:
1757 photo_file = request.files.get(photo_field)
1758 if photo_file and photo_file.filename:
1759 stored = _save_upload(photo_file, fe.id, label)
1760 if stored:
1761 _delete_upload(getattr(fe, attr))
1762 setattr(fe, attr, stored)
1764 # ── Pilot log figures ──────────────────────────────────────────────────────
1765 if create_pilot:
1766 fe.night_time = night_time
1767 fe.instrument_time = instrument_time
1768 fe.landings_day = landings_day if landings_day is not None else 0
1769 fe.landings_night = landings_night
1770 fe.multi_pilot = multi_pilot
1771 apply_pilot_identity(fe, ac, uid, pilot_role)
1772 else:
1773 # pilot_role == "none": this user isn't tracking a personal logbook
1774 # entry for this flight. If they previously occupied a slot on this
1775 # same flight (editing), un-claim just their identity + function
1776 # figures — the shared EASA figures (night_time etc.) stay, since
1777 # they describe the flight itself and another crew member may still
1778 # depend on them.
1779 unlink_user(fe, uid)
1781 restore_other_pilots(fe, other_pilots)
1783 # ── Crew invites (a tenant pilot picked in a crew name field) ──────────────
1784 tid = _tenant_id()
1785 db.session.flush()
1786 new_invites = sync_crew_invites(fe, uid, requested_invites(f, uid, tid, pilot_role))
1788 db.session.commit()
1789 notify_invites(new_invites, tid)
1790 if shared_before is not None:
1791 notify_shared_changes(fe, uid, shared_before)
1793 if ac:
1794 event_name = "flight.logged" if _fe_is_new else "flight.updated"
1795 activity(
1796 event_name,
1797 flight_id=fe.id,
1798 aircraft_id=ac.id,
1799 dep=dep,
1800 arr=arr,
1801 date=str(flight_date),
1802 )
1803 _check_flight_hour_milestone(fe)
1805 flash(
1806 _(
1807 "Flight %(dep)s→%(arr)s on %(date)s saved.",
1808 dep=dep,
1809 arr=arr,
1810 date=flight_date,
1811 ),
1812 "success",
1813 )
1814 return_ac_id = f.get("gps_review_return_aircraft_id", type=int)
1815 return_seg_idx = f.get("gps_review_return_seg_idx", type=int)
1816 if return_ac_id is not None:
1817 gps_state = session.get("gps_import", {})
1818 if (
1819 gps_state.get("aircraft_id") == return_ac_id
1820 and return_seg_idx is not None
1821 ):
1822 confirmed = gps_state.get("confirmed_segments", {})
1823 confirmed[str(return_seg_idx)] = fe.id
1824 gps_state["confirmed_segments"] = confirmed
1825 session["gps_import"] = gps_state
1826 session.modified = True
1827 return redirect(
1828 url_for("aircraft.gps_import_review", aircraft_id=return_ac_id)
1829 )
1830 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1832 flash(
1833 _(
1834 "Flight %(dep)s→%(arr)s on %(date)s saved to your pilot logbook.",
1835 dep=dep,
1836 arr=arr,
1837 date=flight_date,
1838 ),
1839 "success",
1840 )
1841 return redirect(url_for("pilots.logbook"))
1844def _render_form(
1845 managed_aircraft: list[Aircraft],
1846 flight: Flight | None,
1847 pilot_entry: None,
1848 preselect_id: int | None,
1849 duplicate: dict[str, Any] | None,
1850) -> ResponseReturnValue:
1851 """`pilot_entry` is always None now — kept as a parameter only so every
1852 call site doesn't need touching; every pilot-log field lives on `flight`
1853 itself in the unified model."""
1854 nature_suggestions = _NATURE_SUGGESTIONS
1855 aircraft: Aircraft | None = None
1856 if preselect_id:
1857 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None)
1858 if aircraft:
1859 nature_suggestions = _nature_suggestions(aircraft.id)
1860 counter_hint = _get_counter_hint(aircraft.id) if aircraft else None
1861 return render_template(
1862 "flights/flight_form.html",
1863 flight=flight,
1864 pilot_entry=pilot_entry,
1865 aircraft=aircraft,
1866 managed_aircraft=managed_aircraft,
1867 preselect_aircraft_id=preselect_id,
1868 gps_prefill=None,
1869 nature_suggestions=nature_suggestions,
1870 pilot_name_hint=None,
1871 crew_pilots=tenant_pilots(_tenant_id()),
1872 crew_pending_invites=pending_invites_for_flight(flight) if flight else {},
1873 crew_locked_slots=_crew_locked_slots(flight, session.get("user_id"))
1874 if flight
1875 else {},
1876 crew_roles=CrewRole,
1877 fuel_units=_FUEL_UNITS,
1878 duplicate=duplicate,
1879 counter_hint=counter_hint,
1880 openaip_key=_openaip_key(),
1881 gps_review_return_aircraft_id=None,
1882 gps_review_return_seg_idx=None,
1883 covering_reservation=None,
1884 active_minimums=None,
1885 minimums_breaches=[],
1886 )
1889# ── Delete flight ─────────────────────────────────────────────────────────────
1892@flights_bp.route(
1893 "/aircraft/<aircraft_ref:aircraft_id>/flights/<int:flight_id>/delete",
1894 methods=["POST"],
1895)
1896@login_required
1897@require_pilot_access
1898def delete_flight(aircraft_id: int, flight_id: int) -> ResponseReturnValue:
1899 ac = _get_aircraft_or_404(aircraft_id)
1900 fe = db.session.get(Flight, flight_id)
1901 if not fe or fe.aircraft_id != ac.id:
1902 abort(404)
1903 label = f"{fe.departure_icao}→{fe.arrival_icao} on {fe.date}"
1904 # Linked to another pilot's logbook: take it off this aircraft's log but
1905 # keep it for them as an "other aircraft" flight (flights/crew_removal.py).
1906 if fe.other_linked_user_ids(session.get("user_id")):
1907 remove_from_aircraft_log(fe, session.get("user_id"))
1908 db.session.commit()
1909 activity(
1910 "flight.detached", flight_id=flight_id, aircraft_id=aircraft_id, label=label
1911 )
1912 flash(
1913 _(
1914 "Flight %(label)s removed from the aircraft log. It stays in the "
1915 "pilot logbook of the crew linked to it.",
1916 label=label,
1917 ),
1918 "success",
1919 )
1920 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1922 activity(
1923 "flight.deleted", flight_id=flight_id, aircraft_id=aircraft_id, label=label
1924 )
1925 _delete_upload(fe.flight_counter_photo)
1926 _delete_upload(fe.engine_counter_photo)
1927 _delete_upload(fe.flight_counter_photo_preflight)
1928 _delete_upload(fe.engine_counter_photo_preflight)
1929 db.session.delete(fe)
1930 db.session.commit()
1931 flash(_("Flight %(label)s deleted.", label=label), "success")
1932 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1935# ── Bulk airframe logbook import (CSV / Excel) ────────────────────────────────
1937_AIRFRAME_IMPORT_SESSION_KEY = "airframe_import"
1938_AIRFRAME_IMPORT_REVIEW_SESSION_KEY = "airframe_import_review"
1939_AIRFRAME_IMPORT_EXTS = {".csv", ".xlsx", ".xls"}
1940_AIRFRAME_IMPORT_MAX_BYTES = 10 * 1024 * 1024 # 10 MB
1943def _airframe_tmp_dir() -> str:
1944 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
1945 d = os.path.join(folder, "import_tmp")
1946 os.makedirs(d, exist_ok=True)
1947 return d
1950def _airframe_cleanup_tmp() -> None:
1951 """Delete any leftover temp import file, including one left behind by
1952 an abandoned conflict-review (started a fresh upload instead of
1953 finishing it)."""
1954 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY)
1955 if meta:
1956 tmp = meta.get("tmp_path")
1957 if tmp and os.path.isfile(tmp):
1958 with contextlib.suppress(OSError):
1959 os.remove(tmp)
1960 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None)
1962 review_state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY)
1963 if review_state:
1964 tmp = review_state.get("tmp_path")
1965 if tmp and os.path.isfile(tmp):
1966 with contextlib.suppress(OSError):
1967 os.remove(tmp)
1968 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
1971def _render_airframe_map(
1972 ac: Aircraft,
1973 parsed: Any,
1974 mapping: dict[str, str],
1975 match_type: str,
1976 filename: str,
1977 is_historical: bool = False,
1978) -> str:
1979 from pilots.logbook_import import ( # pyright: ignore[reportMissingImports]
1980 _norm,
1981 preview_rows,
1982 )
1984 from flights.airframe_import import ( # pyright: ignore[reportMissingImports]
1985 AIRFRAME_TARGET_FIELDS,
1986 airframe_type_hints,
1987 )
1989 return render_template(
1990 "flights/airframe_import_map.html",
1991 aircraft=ac,
1992 norm_cols=parsed.norm_cols,
1993 raw_cols=parsed.raw_cols,
1994 base_norm_cols=[_norm(r) for r in parsed.raw_cols],
1995 mapping=mapping,
1996 match_type=match_type,
1997 target_fields=AIRFRAME_TARGET_FIELDS,
1998 is_historical=is_historical,
1999 preview=preview_rows(parsed, mapping, n=5),
2000 filename=filename,
2001 type_hints=airframe_type_hints(parsed, mapping),
2002 )
2005@flights_bp.route(
2006 "/aircraft/<aircraft_ref:aircraft_id>/flights/import", methods=["GET", "POST"]
2007)
2008@login_required
2009@require_role(Role.ADMIN, Role.OWNER)
2010def airframe_import_upload(aircraft_id: int) -> ResponseReturnValue:
2011 from models import ( # pyright: ignore[reportMissingImports]
2012 AirframeImportBatch,
2013 AirframeImportMapping,
2014 )
2015 from pilots.logbook_import import (
2016 parse_file, # pyright: ignore[reportMissingImports]
2017 )
2019 from flights.airframe_import import (
2020 propose_airframe_mapping, # pyright: ignore[reportMissingImports]
2021 )
2023 ac = _get_aircraft_or_404(aircraft_id)
2024 batches = (
2025 AirframeImportBatch.query.filter_by(aircraft_id=ac.id)
2026 .order_by(AirframeImportBatch.imported_at.desc())
2027 .all()
2028 )
2030 if request.method == "GET":
2031 return render_template(
2032 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
2033 )
2035 uploaded = request.files.get("logbook_file")
2036 if not uploaded or not uploaded.filename:
2037 flash(_("Please select a file to upload."), "danger")
2038 return render_template(
2039 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
2040 ), 422
2042 ext = os.path.splitext(uploaded.filename)[1].lower()
2043 if ext not in _AIRFRAME_IMPORT_EXTS:
2044 flash(_("Unsupported format. Please upload a .csv or .xlsx file."), "danger")
2045 return render_template(
2046 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
2047 ), 422
2049 data = uploaded.read()
2050 if len(data) > _AIRFRAME_IMPORT_MAX_BYTES:
2051 flash(_("File too large (maximum 10 MB)."), "danger")
2052 return render_template(
2053 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
2054 ), 422
2056 try:
2057 parsed = parse_file(data, uploaded.filename)
2058 except ValueError as exc:
2059 flash(str(exc), "danger")
2060 return render_template(
2061 "flights/airframe_import_upload.html", aircraft=ac, batches=batches
2062 ), 422
2064 _airframe_cleanup_tmp()
2065 safe_base = secure_filename(uploaded.filename) or "upload"
2066 tmp_path = os.path.join(
2067 _airframe_tmp_dir(), f"airframe_{ac.id}_{uuid.uuid4().hex}_{safe_base}"
2068 )
2069 with open(tmp_path, "wb") as fh:
2070 fh.write(data)
2072 session[_AIRFRAME_IMPORT_SESSION_KEY] = {
2073 "aircraft_id": ac.id,
2074 "tmp_path": tmp_path,
2075 "original_filename": uploaded.filename,
2076 "norm_cols": parsed.norm_cols,
2077 "fingerprint": parsed.fingerprint,
2078 }
2080 saved = AirframeImportMapping.query.filter_by(tenant_id=ac.tenant_id).all()
2081 mapping, match_type = propose_airframe_mapping(parsed, saved)
2082 return _render_airframe_map(ac, parsed, mapping, match_type, uploaded.filename)
2085@flights_bp.route(
2086 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/execute", methods=["POST"]
2087)
2088@login_required
2089@require_role(Role.ADMIN, Role.OWNER)
2090def airframe_import_execute(aircraft_id: int) -> ResponseReturnValue:
2091 from models import ( # pyright: ignore[reportMissingImports]
2092 AirframeImportBatch,
2093 AirframeImportMapping,
2094 )
2095 from pilots.logbook_import import ( # pyright: ignore[reportMissingImports]
2096 parse_duration_value,
2097 parse_file,
2098 )
2100 from flights.airframe_import import ( # pyright: ignore[reportMissingImports]
2101 AIRFRAME_TARGET_FIELDS,
2102 execute_airframe_import,
2103 find_conflicting_airframe_rows,
2104 merge_airframe_into_existing,
2105 split_airframe_auto_merges,
2106 )
2108 ac = _get_aircraft_or_404(aircraft_id)
2109 meta = session.get(_AIRFRAME_IMPORT_SESSION_KEY)
2110 if not meta or meta.get("aircraft_id") != ac.id:
2111 flash(_("Import session expired. Please upload the file again."), "warning")
2112 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2114 tmp_path: str = meta["tmp_path"]
2115 original_filename: str = meta["original_filename"]
2116 norm_cols: list[str] = meta["norm_cols"]
2117 fingerprint: str = meta["fingerprint"]
2119 if not os.path.isfile(tmp_path):
2120 flash(_("Temporary file not found. Please upload the file again."), "warning")
2121 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None)
2122 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2124 mapping: dict[str, str] = {}
2125 for col in norm_cols:
2126 val = request.form.get(f"mapping_{col}", "ignore").strip()
2127 mapping[col] = val if val in AIRFRAME_TARGET_FIELDS else "ignore"
2129 is_historical = bool(request.form.get("is_historical"))
2131 with open(tmp_path, "rb") as fh:
2132 data = fh.read()
2133 try:
2134 parsed = parse_file(data, original_filename)
2135 except ValueError as exc:
2136 flash(str(exc), "danger")
2137 _airframe_cleanup_tmp()
2138 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2140 if "date" not in mapping.values():
2141 flash(_("You must map at least one column to 'Date'."), "danger")
2142 return _render_airframe_map(
2143 ac,
2144 parsed,
2145 mapping,
2146 "alias",
2147 original_filename,
2148 is_historical,
2149 ), 422
2151 opening_counters = {
2152 "flight": parse_duration_value(
2153 request.form.get("ob_flight_counter", "").strip()
2154 )
2155 if request.form.get("ob_flight_counter", "").strip()
2156 else None,
2157 "engine": parse_duration_value(
2158 request.form.get("ob_engine_counter", "").strip()
2159 )
2160 if request.form.get("ob_engine_counter", "").strip()
2161 else None,
2162 }
2164 mapping_record = None
2165 for m in AirframeImportMapping.query.filter_by(tenant_id=ac.tenant_id).all():
2166 if m.source_fingerprint == fingerprint:
2167 m.column_mapping = _json.dumps(mapping)
2168 mapping_record = m
2169 break
2170 if mapping_record is None:
2171 mapping_record = AirframeImportMapping(
2172 tenant_id=ac.tenant_id,
2173 source_fingerprint=fingerprint,
2174 column_mapping=_json.dumps(mapping),
2175 source_columns=_json.dumps(norm_cols),
2176 created_at=_datetime.now(UTC),
2177 )
2178 db.session.add(mapping_record)
2179 db.session.flush()
2181 batch = AirframeImportBatch(
2182 aircraft_id=ac.id,
2183 mapping_id=mapping_record.id,
2184 source_filename=original_filename,
2185 imported_at=_datetime.now(UTC),
2186 is_historical=is_historical,
2187 )
2188 db.session.add(batch)
2189 db.session.flush()
2191 resolved_opening_counters = (
2192 opening_counters
2193 if any(v is not None for v in opening_counters.values())
2194 else None
2195 )
2197 # Rows that look like they might be an edited version of an existing
2198 # flight (score >= _CANDIDATE_MIN_SCORE) need a human decision, not a
2199 # guess — carve them out of this pass and route them through the
2200 # interactive review step below instead of silently importing or
2201 # skipping them.
2202 all_conflicts = find_conflicting_airframe_rows(parsed, mapping, ac.id)
2203 # A row whose only match is the same flight logged from the pilot side
2204 # (e.g. a personal-logbook import) just completes it — merged straight
2205 # away, no decision needed.
2206 auto_merges, conflicts = split_airframe_auto_merges(all_conflicts, ac)
2207 for auto_conflict, existing_id in auto_merges:
2208 merge_airframe_into_existing(
2209 db.session.get(Flight, existing_id),
2210 auto_conflict.fields,
2211 auto_conflict.crew_name,
2212 fill_only=True,
2213 )
2215 result = execute_airframe_import(
2216 parsed=parsed,
2217 mapping=mapping,
2218 aircraft=ac,
2219 batch_id=batch.id,
2220 opening_counters=resolved_opening_counters,
2221 skip_row_nums={c.row_num for c in all_conflicts},
2222 )
2223 batch.row_count = result.imported
2224 batch.subtotal_count = result.subtotals
2225 batch.skipped_count = len(result.skipped)
2226 batch.warning_count = len(result.continuity_warnings)
2227 batch.has_opening_counters = result.has_opening_counters
2228 db.session.commit()
2230 if auto_merges:
2231 flash(
2232 ngettext(
2233 "One row matched a flight already in a pilot logbook and was "
2234 "merged into it.",
2235 "%(n)d rows matched a flight already in a pilot logbook and "
2236 "were merged into it.",
2237 len(auto_merges),
2238 n=len(auto_merges),
2239 ),
2240 "info",
2241 )
2243 if conflicts:
2244 # Defer activity logging and tmp-file cleanup until every conflict
2245 # is resolved — _finalize_airframe_import_review does both, covering
2246 # entries added during review as well as the ones just committed.
2247 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = {
2248 "aircraft_id": ac.id,
2249 "tmp_path": tmp_path,
2250 "original_filename": original_filename,
2251 "mapping": mapping,
2252 "batch_id": batch.id,
2253 "resolved": {},
2254 "auto_merged": [c.row_num for c, _eid in auto_merges],
2255 }
2256 session.pop(_AIRFRAME_IMPORT_SESSION_KEY, None)
2257 session.modified = True
2259 flash(
2260 _(
2261 "%(imported)d flights imported so far. %(n)d rows look like "
2262 "they might already be in this aircraft's log with "
2263 "different data — please review them below.",
2264 imported=result.imported,
2265 n=len(conflicts),
2266 ),
2267 "info",
2268 )
2269 if result.duplicates:
2270 detail = "; ".join(
2271 f"row {r}: {reason}" for r, reason in result.duplicates[:5]
2272 )
2273 if len(result.duplicates) > 5:
2274 detail += f" … and {len(result.duplicates) - 5} more"
2275 flash(
2276 _(
2277 "Rows already in this aircraft's log, skipped: %(detail)s",
2278 detail=detail,
2279 ),
2280 "info",
2281 )
2282 if result.skipped:
2283 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.skipped[:5])
2284 if len(result.skipped) > 5:
2285 detail += f" … and {len(result.skipped) - 5} more"
2286 flash(_("Skipped rows: %(detail)s", detail=detail), "warning")
2288 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2290 activity(
2291 "flights.airframe_import",
2292 aircraft_id=ac.id,
2293 batch_id=batch.id,
2294 imported=result.imported,
2295 )
2296 _airframe_cleanup_tmp()
2298 # Duplicates are a normal, expected outcome — e.g. re-uploading a
2299 # spreadsheet after appending a few new flights — not an error, so these
2300 # messages stay in the "success"/"info" register rather than "warning".
2301 if result.imported == 0 and result.duplicates:
2302 flash(
2303 _(
2304 "All flights in this file were already in this aircraft's "
2305 "log — nothing new was imported."
2306 ),
2307 "success",
2308 )
2309 else:
2310 n_dup = len(result.duplicates)
2311 if result.duplicates:
2312 parts = [
2313 ngettext(
2314 "Import complete: one new flight imported.",
2315 "Import complete: %(n)d new flights imported.",
2316 result.imported,
2317 n=result.imported,
2318 ),
2319 ngettext(
2320 "One row was already in this aircraft's log and was skipped.",
2321 "%(n)d rows were already in this aircraft's log and were skipped.",
2322 n_dup,
2323 n=n_dup,
2324 ),
2325 ]
2326 elif result.imported:
2327 parts = [
2328 ngettext(
2329 "Import complete: one flight imported.",
2330 "Import complete: %(n)d flights imported.",
2331 result.imported,
2332 n=result.imported,
2333 )
2334 ]
2335 else:
2336 parts = [_("Import complete: no flights imported.")]
2337 flash(" ".join(parts + _skipped_rows_summary(result)), "success")
2338 if result.duplicates:
2339 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.duplicates[:5])
2340 if len(result.duplicates) > 5:
2341 detail += f" … and {len(result.duplicates) - 5} more"
2342 flash(
2343 _(
2344 "Rows already in this aircraft's log, skipped: %(detail)s",
2345 detail=detail,
2346 ),
2347 "info",
2348 )
2349 if result.continuity_warnings:
2350 detail = "; ".join(
2351 _(
2352 "row %(row)d: %(kind)s counter starts at %(got).1f but the previous "
2353 "entry ended at %(prev).1f",
2354 row=row,
2355 kind=kind,
2356 got=got,
2357 prev=prev,
2358 )
2359 for row, kind, prev, got in result.continuity_warnings[:5]
2360 )
2361 if len(result.continuity_warnings) > 5:
2362 detail += _(" … and %(n)d more", n=len(result.continuity_warnings) - 5)
2363 flash(_("Counter continuity warnings: %(detail)s", detail=detail), "warning")
2364 if result.skipped:
2365 detail = "; ".join(f"row {r}: {reason}" for r, reason in result.skipped[:5])
2366 if len(result.skipped) > 5:
2367 detail += f" … and {len(result.skipped) - 5} more"
2368 flash(_("Skipped rows: %(detail)s", detail=detail), "warning")
2370 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
2373def _airframe_review_excluded_rows(state: dict[str, Any]) -> set[int]:
2374 """Rows the review step no longer offers: already resolved, or merged
2375 automatically when the import ran."""
2376 return {int(k) for k in state.get("resolved", {})} | set(
2377 state.get("auto_merged", [])
2378 )
2381def _finalize_airframe_import_review(
2382 ac: Aircraft, state: dict[str, Any]
2383) -> ResponseReturnValue:
2384 """Common tail once every conflict row from a review has a decision:
2385 activity log, tmp-file/session cleanup, summary flash, redirect — the
2386 same shape as the no-conflicts tail of airframe_import_execute above."""
2387 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2389 batch_id: int = state["batch_id"]
2390 tmp_path: str = state["tmp_path"]
2391 batch = db.session.get(AirframeImportBatch, batch_id)
2393 activity(
2394 "flights.airframe_import",
2395 aircraft_id=ac.id,
2396 batch_id=batch_id,
2397 imported=batch.row_count if batch else 0,
2398 )
2400 with contextlib.suppress(OSError):
2401 os.remove(tmp_path)
2402 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
2404 resolved: dict[str, str] = state.get("resolved", {})
2405 kept = sum(1 for d in resolved.values() if d == "keep")
2406 overwritten = sum(1 for d in resolved.values() if d.startswith("overwrite:"))
2407 added_new = sum(1 for d in resolved.values() if d == "new")
2409 flash(
2410 _(
2411 "Review complete: %(overwritten)d flights updated, %(new)d "
2412 "imported as new, %(kept)d left unchanged.",
2413 overwritten=overwritten,
2414 new=added_new,
2415 kept=kept,
2416 ),
2417 "success",
2418 )
2420 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
2423@flights_bp.route("/aircraft/<aircraft_ref:aircraft_id>/flights/import/review")
2424@login_required
2425@require_role(Role.ADMIN, Role.OWNER)
2426def airframe_import_review(aircraft_id: int) -> ResponseReturnValue:
2427 from pilots.logbook_import import (
2428 parse_file, # pyright: ignore[reportMissingImports]
2429 )
2431 from flights.airframe_import import (
2432 find_conflicting_airframe_rows, # pyright: ignore[reportMissingImports]
2433 )
2435 ac = _get_aircraft_or_404(aircraft_id)
2436 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY)
2437 if not state or state.get("aircraft_id") != ac.id:
2438 flash(_("Import session expired. Please upload the file again."), "warning")
2439 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2441 tmp_path: str = state["tmp_path"]
2442 if not os.path.isfile(tmp_path):
2443 flash(_("Temporary file not found. Please upload the file again."), "warning")
2444 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
2445 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2447 mapping: dict[str, str] = state["mapping"]
2448 resolved: dict[str, str] = state.get("resolved", {})
2450 with open(tmp_path, "rb") as fh:
2451 data = fh.read()
2452 try:
2453 parsed = parse_file(data, state["original_filename"])
2454 except ValueError:
2455 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
2456 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2458 exclude_row_nums = _airframe_review_excluded_rows(state)
2459 conflicts = find_conflicting_airframe_rows(
2460 parsed, mapping, ac.id, exclude_row_nums=exclude_row_nums
2461 )
2463 if not conflicts:
2464 return _finalize_airframe_import_review(ac, state)
2466 candidate_ids = {cid for c in conflicts for _score, cid in c.candidates}
2467 candidate_entries: dict[int, Flight] = (
2468 {e.id: e for e in Flight.query.filter(Flight.id.in_(candidate_ids))}
2469 if candidate_ids
2470 else {}
2471 )
2473 rows = [
2474 {
2475 "row_num": c.row_num,
2476 "fields": c.fields,
2477 "crew_name": c.crew_name,
2478 "candidates": [
2479 {"id": cid, "score": score, "entry": candidate_entries.get(cid)}
2480 for score, cid in c.candidates
2481 ],
2482 }
2483 for c in conflicts
2484 ]
2486 return render_template(
2487 "flights/airframe_import_review.html",
2488 aircraft=ac,
2489 rows=rows,
2490 resolved_count=len(resolved),
2491 total_count=len(resolved) + len(conflicts),
2492 )
2495@flights_bp.route(
2496 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/review/resolve",
2497 methods=["POST"],
2498)
2499@login_required
2500@require_role(Role.ADMIN, Role.OWNER)
2501def airframe_import_review_resolve(aircraft_id: int) -> ResponseReturnValue:
2502 from pilots.logbook_import import (
2503 parse_file, # pyright: ignore[reportMissingImports]
2504 )
2506 from flights.airframe_import import ( # pyright: ignore[reportMissingImports]
2507 AirframeConflictRow,
2508 _fields_to_flight_entry_kwargs,
2509 find_conflicting_airframe_rows,
2510 merge_airframe_into_existing,
2511 )
2513 ac = _get_aircraft_or_404(aircraft_id)
2514 state = session.get(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY)
2515 if not state or state.get("aircraft_id") != ac.id:
2516 flash(_("Import session expired. Please upload the file again."), "warning")
2517 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2519 tmp_path: str = state["tmp_path"]
2520 mapping: dict[str, str] = state["mapping"]
2521 resolved: dict[str, str] = state.get("resolved", {})
2523 try:
2524 row_num = int(request.form.get("row_num", ""))
2525 except (ValueError, TypeError):
2526 flash(_("Invalid row number."), "danger")
2527 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2529 if str(row_num) in resolved:
2530 flash(_("This row has already been resolved."), "info")
2531 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2533 if not os.path.isfile(tmp_path):
2534 flash(_("Temporary file not found. Please upload the file again."), "warning")
2535 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
2536 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2538 with open(tmp_path, "rb") as fh:
2539 data = fh.read()
2540 try:
2541 parsed = parse_file(data, state["original_filename"])
2542 except ValueError:
2543 session.pop(_AIRFRAME_IMPORT_REVIEW_SESSION_KEY, None)
2544 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2546 exclude_row_nums = _airframe_review_excluded_rows(state)
2547 conflicts = find_conflicting_airframe_rows(
2548 parsed, mapping, ac.id, exclude_row_nums=exclude_row_nums
2549 )
2550 conflict: AirframeConflictRow | None = next(
2551 (c for c in conflicts if c.row_num == row_num), None
2552 )
2553 if conflict is None:
2554 flash(_("Invalid row number."), "danger")
2555 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2557 decision = request.form.get("decision", "")
2558 candidate_ids = {cid for _score, cid in conflict.candidates}
2560 if decision == "keep":
2561 pass # the freshly-parsed row is discarded; the existing entry is untouched
2562 elif decision.startswith("overwrite:"):
2563 try:
2564 existing_id = int(decision.split(":", 1)[1])
2565 except ValueError:
2566 existing_id = -1
2567 if existing_id not in candidate_ids:
2568 flash(_("Invalid selection."), "danger")
2569 return redirect(
2570 url_for("flights.airframe_import_review", aircraft_id=ac.id)
2571 )
2572 existing = Flight.query.filter_by(id=existing_id, aircraft_id=ac.id).first()
2573 if existing is None:
2574 # candidate_ids just came from a live query for this aircraft in
2575 # the same request — only a concurrent delete reaches this.
2576 abort(404) # pragma: no cover
2577 # Replace with the row's values — see merge_airframe_into_existing
2578 # for what's deliberately left alone. id and
2579 # airframe_import_batch_id are never touched, so this entry stays
2580 # outside the *current* batch's rollback (it wasn't created by it).
2581 merge_airframe_into_existing(
2582 existing, conflict.fields, conflict.crew_name, fill_only=False
2583 )
2584 db.session.commit()
2585 elif decision == "new":
2586 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2588 batch_id: int = state["batch_id"]
2589 fe = Flight(
2590 aircraft_id=ac.id,
2591 airframe_import_batch_id=batch_id,
2592 source="import",
2593 pic_name=conflict.crew_name,
2594 **_fields_to_flight_entry_kwargs(conflict.fields),
2595 )
2596 db.session.add(fe)
2597 db.session.flush()
2598 batch = db.session.get(AirframeImportBatch, batch_id)
2599 if batch is not None:
2600 batch.row_count += 1
2601 else:
2602 current_app.logger.debug( # pragma: no cover — batch was just created
2603 "airframe_import_review_resolve: batch %s vanished before resolve",
2604 batch_id,
2605 )
2606 db.session.commit()
2607 else:
2608 flash(_("Invalid decision."), "danger")
2609 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2611 resolved[str(row_num)] = decision
2612 state["resolved"] = resolved
2613 session[_AIRFRAME_IMPORT_REVIEW_SESSION_KEY] = state
2614 session.modified = True
2616 remaining = find_conflicting_airframe_rows(
2617 parsed, mapping, ac.id, exclude_row_nums=_airframe_review_excluded_rows(state)
2618 )
2619 if not remaining:
2620 return _finalize_airframe_import_review(ac, state)
2622 return redirect(url_for("flights.airframe_import_review", aircraft_id=ac.id))
2625@flights_bp.route(
2626 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/<int:batch_id>/rollback",
2627 methods=["POST"],
2628)
2629@login_required
2630@require_role(Role.ADMIN, Role.OWNER)
2631def airframe_import_rollback(aircraft_id: int, batch_id: int) -> ResponseReturnValue:
2632 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2634 ac = _get_aircraft_or_404(aircraft_id)
2635 batch = db.session.get(AirframeImportBatch, batch_id)
2636 if not batch or batch.aircraft_id != ac.id:
2637 abort(404)
2639 actor_id = session.get("user_id")
2640 entries = Flight.query.filter_by(airframe_import_batch_id=batch.id).all()
2641 # Rows a pilot has since confirmed in their logbook are detached instead
2642 # of deleted, so undoing the import never removes pilot hours.
2643 kept = sum(not remove_from_aircraft_log(fe, actor_id) for fe in entries)
2644 db.session.delete(batch)
2645 db.session.commit()
2647 flash(
2648 _(
2649 "Import deleted: %(n)d flight entries removed.",
2650 n=len(entries) - kept,
2651 ),
2652 "success",
2653 )
2654 flash_kept_in_pilot_logbooks(kept)
2655 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))
2658@flights_bp.route(
2659 "/aircraft/<aircraft_ref:aircraft_id>/flights/import/<int:batch_id>/toggle-historical",
2660 methods=["POST"],
2661)
2662@login_required
2663@require_role(Role.ADMIN, Role.OWNER)
2664def airframe_import_toggle_historical(
2665 aircraft_id: int, batch_id: int
2666) -> ResponseReturnValue:
2667 from models import AirframeImportBatch # pyright: ignore[reportMissingImports]
2669 ac = _get_aircraft_or_404(aircraft_id)
2670 batch = db.session.get(AirframeImportBatch, batch_id)
2671 if not batch or batch.aircraft_id != ac.id:
2672 abort(404)
2674 batch.is_historical = not batch.is_historical
2675 db.session.commit()
2677 if batch.is_historical:
2678 flash(
2679 _(
2680 "Import marked as historical: edits to its flights will no "
2681 "longer be blocked by validation on old, imprecise values."
2682 ),
2683 "success",
2684 )
2685 else:
2686 flash(
2687 _(
2688 "Import no longer marked as historical: edits to its "
2689 "flights are now subject to the normal validation rules."
2690 ),
2691 "success",
2692 )
2693 return redirect(url_for("flights.airframe_import_upload", aircraft_id=ac.id))