Coverage for app/flights/routes.py: 100%
676 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1import contextlib
2import decimal
3import json as _json
4import os
5import uuid
6from datetime import date as _date, time as _time, datetime as _datetime
8from typing import Any
10from flask import ( # pyright: ignore[reportMissingImports]
11 Blueprint,
12 abort,
13 current_app,
14 flash,
15 jsonify,
16 redirect,
17 render_template,
18 request,
19 send_from_directory,
20 session,
21 url_for,
22)
23from flask.typing import ResponseReturnValue # pyright: ignore[reportMissingImports]
24from werkzeug.utils import secure_filename
26from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
28from sqlalchemy import func, or_ # pyright: ignore[reportMissingImports]
30from extensions import _rate_limiting_disabled, limiter as _limiter # pyright: ignore[reportMissingImports]
32from models import (
33 Aircraft,
34 AppSetting,
35 Component,
36 CrewRole,
37 Document,
38 FlightCrew,
39 FlightEntry,
40 GpsTrack,
41 PilotLogbookEntry,
42 TenantUser,
43 User,
44 db,
45) # pyright: ignore[reportMissingImports]
46from utils import (
47 accessible_aircraft,
48 activity,
49 login_required,
50 require_pilot_access,
51 user_can_access_aircraft,
52) # pyright: ignore[reportMissingImports]
54flights_bp = Blueprint("flights", __name__)
56_ALLOWED_PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"}
57_ALLOWED_GPS_EXTS = {".gpx", ".kml", ".csv"}
58_FUEL_UNITS = ["L", "gal"]
59_NATURE_SUGGESTIONS = [
60 "Local flight",
61 "Navigation",
62 "Cross-country",
63 "Training",
64 "IFR practice",
65 "Night flight",
66 "Touch-and-go",
67 "Ferry flight",
68 "Air test",
69 "Sightseeing",
70]
72_HOUR_MILESTONES = [100, 500, 1000, 2000, 5000]
75def _openaip_key() -> str | None:
76 s = db.session.get(AppSetting, "openaip_api_key")
77 return s.value if s and s.value else None
80def _tenant_id() -> int:
81 tu = TenantUser.query.filter_by(user_id=session["user_id"]).first()
82 if not tu:
83 abort(403)
84 return int(tu.tenant_id)
87def _check_flight_hour_milestone(fe: FlightEntry) -> None:
88 """Set a one-shot session flag when total fleet hours cross a milestone."""
89 this_flight = float(fe.flight_time or 0)
90 if this_flight <= 0:
91 return
92 tid = _tenant_id()
93 aircraft_ids = [a.id for a in accessible_aircraft(tid).all()]
94 new_total = float(
95 db.session.query(func.sum(FlightEntry.flight_time))
96 .filter(FlightEntry.aircraft_id.in_(aircraft_ids))
97 .scalar()
98 or 0
99 )
100 old_total = new_total - this_flight
101 for milestone in _HOUR_MILESTONES:
102 if old_total < milestone <= new_total:
103 session["milestone_hours"] = milestone
104 flash(
105 _(
106 "🎉 You just crossed %(hours)s flight hours!",
107 hours=milestone,
108 ),
109 "info",
110 )
111 break
114def _get_aircraft_or_404(aircraft_id: int) -> Aircraft:
115 ac = db.session.get(Aircraft, aircraft_id)
116 if (
117 not ac
118 or ac.tenant_id != _tenant_id()
119 or not user_can_access_aircraft(aircraft_id)
120 ):
121 abort(404)
122 return ac
125def _get_flight_or_404(flight_id: int) -> FlightEntry:
126 fe = db.session.get(FlightEntry, flight_id)
127 if not fe:
128 abort(404)
129 ac = db.session.get(Aircraft, fe.aircraft_id)
130 if not ac or ac.tenant_id != _tenant_id():
131 abort(404)
132 return fe
135def _save_upload(file: Any, flight_id: int, label: str) -> str | None:
136 ext = os.path.splitext(secure_filename(file.filename))[1].lower()
137 if ext not in _ALLOWED_PHOTO_EXTS:
138 return None
139 stored = f"flight_{flight_id}_{label}_{uuid.uuid4().hex[:8]}{ext}"
140 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
141 os.makedirs(folder, exist_ok=True)
142 file.save(os.path.join(folder, stored))
143 return stored
146def _delete_upload(filename: str | None) -> None:
147 if not filename:
148 return
149 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
150 try:
151 os.remove(os.path.join(folder, filename))
152 except OSError:
153 current_app.logger.debug(
154 "Could not delete upload %s (already absent?)", filename
155 )
158def _nature_suggestions(aircraft_id: int) -> list[str]:
159 used = [
160 row[0]
161 for row in db.session.query(FlightEntry.nature_of_flight)
162 .filter_by(aircraft_id=aircraft_id)
163 .filter(FlightEntry.nature_of_flight.isnot(None))
164 .distinct()
165 .all()
166 ]
167 return _NATURE_SUGGESTIONS + [n for n in used if n not in _NATURE_SUGGESTIONS]
170def _parse_gps_upload(file: Any) -> dict[str, Any] | None:
171 """Parse a single GPS file. Returns autofill dict or None."""
172 try:
173 from aircraft.gps_import import ( # pyright: ignore[reportMissingImports]
174 detect_segments,
175 merge_and_sort,
176 parse_gps_file,
177 )
178 except ImportError:
179 return None
180 filename = secure_filename(file.filename or "")
181 ext = os.path.splitext(filename)[1].lower()
182 if ext not in _ALLOWED_GPS_EXTS:
183 return None
184 data = file.read()
185 try:
186 parsed = parse_gps_file(data, filename)
187 all_points = merge_and_sort([parsed])
188 segments = detect_segments(all_points)
189 except Exception:
190 return None
191 if not segments:
192 return None
193 seg = segments[0]
194 return {
195 "filename": filename,
196 "device_id": parsed.device_id,
197 "block_off_utc": seg.block_off_utc,
198 "block_on_utc": seg.block_on_utc,
199 "date": seg.block_off_utc.date(),
200 "departure_icao": seg.departure_icao or seg.hint_departure_icao or "",
201 "arrival_icao": seg.arrival_icao or seg.hint_arrival_icao or "",
202 "departure_time": seg.block_off_utc.time(),
203 "arrival_time": seg.block_on_utc.time(),
204 "flight_time_h": round(seg.flight_time_raw_h, 1),
205 "geojson": seg.track_geojson,
206 "landing_count": seg.landing_count,
207 }
210def _find_duplicate_flight(
211 aircraft_id: int | None,
212 pilot_user_id: int,
213 date: _date,
214 dep_icao: str,
215 arr_icao: str,
216 block_off: _datetime | None,
217 block_on: _datetime | None,
218 exclude_flight_id: int | None = None,
219 exclude_pilot_entry_id: int | None = None,
220) -> dict[str, Any] | None:
221 """Return info about a matching FlightEntry or PilotLogbookEntry, or None."""
222 if aircraft_id and block_off and block_on:
223 q = FlightEntry.query.filter(
224 FlightEntry.aircraft_id == aircraft_id,
225 FlightEntry.block_off_utc.isnot(None),
226 FlightEntry.block_on_utc.isnot(None),
227 FlightEntry.block_off_utc < block_on,
228 FlightEntry.block_on_utc > block_off,
229 )
230 if exclude_flight_id:
231 q = q.filter(FlightEntry.id != exclude_flight_id)
232 existing = q.first()
233 if existing:
234 return {"type": "flight", "entry": existing}
236 if aircraft_id and not block_off:
237 q2 = FlightEntry.query.filter_by(
238 aircraft_id=aircraft_id,
239 date=date,
240 departure_icao=dep_icao,
241 arrival_icao=arr_icao,
242 )
243 if exclude_flight_id:
244 q2 = q2.filter(FlightEntry.id != exclude_flight_id)
245 existing2 = q2.first()
246 if existing2:
247 return {"type": "flight", "entry": existing2}
249 q3 = PilotLogbookEntry.query.filter_by(
250 pilot_user_id=pilot_user_id,
251 date=date,
252 departure_place=dep_icao,
253 arrival_place=arr_icao,
254 )
255 if exclude_pilot_entry_id:
256 q3 = q3.filter(PilotLogbookEntry.id != exclude_pilot_entry_id)
257 existing3 = q3.first()
258 if existing3:
259 return {"type": "pilot", "entry": existing3}
261 return None
264def _get_counter_hint(aircraft_id: int) -> dict[str, float | None]:
265 last = (
266 FlightEntry.query.filter_by(aircraft_id=aircraft_id)
267 .filter(
268 db.or_(
269 FlightEntry.flight_time_counter_end.isnot(None),
270 FlightEntry.engine_time_counter_end.isnot(None),
271 )
272 )
273 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc())
274 .first()
275 )
276 if not last:
277 return {"flight": None, "engine": None}
278 return {
279 "flight": float(last.flight_time_counter_end)
280 if last.flight_time_counter_end is not None
281 else None,
282 "engine": float(last.engine_time_counter_end)
283 if last.engine_time_counter_end is not None
284 else None,
285 }
288def _ac_category(ac: Aircraft) -> str:
289 return getattr(ac, "category", "SEP") or "SEP"
292# ── Serve uploads ─────────────────────────────────────────────────────────────
295@flights_bp.route("/uploads/<path:filename>")
296@login_required
297def serve_upload(filename: str) -> ResponseReturnValue:
298 # Verify the requesting user may see this file before serving it.
299 doc = Document.query.filter_by(filename=filename).first()
300 if doc is not None:
301 if doc.aircraft_id is not None:
302 # Covers aircraft docs and component docs (which always carry aircraft_id too).
303 _get_aircraft_or_404(
304 doc.aircraft_id
305 ) # aborts 404 if wrong tenant/no access
306 elif doc.flight_entry_id is not None:
307 _get_flight_or_404(doc.flight_entry_id)
308 elif doc.pilot_user_id is not None:
309 if doc.pilot_user_id != session["user_id"]:
310 abort(404)
311 else:
312 abort(404)
313 else:
314 # Counter and fuel photos are stored directly on FlightEntry (not via Document).
315 fe = FlightEntry.query.filter(
316 or_(
317 FlightEntry.flight_counter_photo == filename,
318 FlightEntry.engine_counter_photo == filename,
319 FlightEntry.fuel_photo == filename,
320 )
321 ).first()
322 if fe is None:
323 abort(404)
324 _get_flight_or_404(fe.id)
325 folder = current_app.config.get("UPLOAD_FOLDER", "/data/uploads")
326 return send_from_directory(folder, filename)
329# ── Fleet logbook ─────────────────────────────────────────────────────────────
332@flights_bp.route("/flights")
333@login_required
334def fleet_flights() -> ResponseReturnValue:
335 tid = _tenant_id()
336 aircraft_list = accessible_aircraft(tid).all()
337 aircraft_map = {ac.id: ac for ac in aircraft_list}
338 flights = (
339 FlightEntry.query.filter(
340 FlightEntry.aircraft_id.in_([ac.id for ac in aircraft_list])
341 )
342 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc())
343 .all()
344 )
345 return render_template(
346 "flights/fleet.html", flights=flights, aircraft_map=aircraft_map
347 )
350# ── Airframe logbook ──────────────────────────────────────────────────────────
353@flights_bp.route("/aircraft/<int:aircraft_id>/flights")
354@login_required
355def list_flights(aircraft_id: int) -> ResponseReturnValue:
356 ac = _get_aircraft_or_404(aircraft_id)
357 flights = (
358 FlightEntry.query.filter_by(aircraft_id=ac.id)
359 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc())
360 .all()
361 )
362 milestone_hours = session.pop("milestone_hours", None)
363 return render_template(
364 "flights/list.html",
365 aircraft=ac,
366 flights=flights,
367 milestone_hours=milestone_hours,
368 )
371# ── Component logbook ─────────────────────────────────────────────────────────
374@flights_bp.route("/aircraft/<int:aircraft_id>/components/<int:component_id>/logbook")
375@login_required
376def component_logbook(aircraft_id: int, component_id: int) -> ResponseReturnValue:
377 ac = _get_aircraft_or_404(aircraft_id)
378 comp = db.session.get(Component, component_id)
379 if not comp or comp.aircraft_id != ac.id:
380 abort(404)
382 query = FlightEntry.query.filter_by(aircraft_id=ac.id)
383 if comp.installed_at:
384 query = query.filter(FlightEntry.date >= comp.installed_at)
385 if comp.removed_at:
386 query = query.filter(FlightEntry.date <= comp.removed_at)
388 flights_asc = query.order_by(FlightEntry.date.asc(), FlightEntry.id.asc()).all()
390 base = float(comp.time_at_install or 0)
391 cumulative = base
392 flights_with_hours = []
393 for f in flights_asc:
394 if (
395 f.flight_time_counter_end is not None
396 and f.flight_time_counter_start is not None
397 ):
398 cumulative += float(f.flight_time_counter_end) - float(
399 f.flight_time_counter_start
400 )
401 flights_with_hours.append((f, cumulative))
403 flights_with_hours.reverse()
405 tbo_hours = (comp.extras or {}).get("tbo_hours")
406 tbo_remaining = (tbo_hours - cumulative) if tbo_hours else None
408 return render_template(
409 "flights/logbook_component.html",
410 aircraft=ac,
411 component=comp,
412 flights_with_hours=flights_with_hours,
413 total_component_hours=cumulative,
414 tbo_hours=tbo_hours,
415 tbo_remaining=tbo_remaining,
416 )
419# ── Unified log / edit flight ─────────────────────────────────────────────────
422@flights_bp.route("/flights/new", methods=["GET", "POST"])
423@login_required
424@require_pilot_access
425def log_flight() -> ResponseReturnValue:
426 tid = _tenant_id()
427 managed_aircraft = accessible_aircraft(tid).all()
428 uid = int(session["user_id"])
429 preselect_id = request.args.get("aircraft_id", type=int)
431 if request.method == "POST":
432 return _handle_log_flight_post(managed_aircraft, uid, fe=None)
434 gps_prefill = session.pop("gps_prefill", None)
435 gps_review_return_aircraft_id = request.args.get("gps_review_return", type=int)
436 gps_review_return_seg_idx = request.args.get("gps_seg", type=int)
437 _u = db.session.get(User, uid)
438 pilot_name_hint = _u.display_name if _u else ""
439 nature_suggestions = _NATURE_SUGGESTIONS
440 aircraft: Aircraft | None = None
441 if preselect_id:
442 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None)
443 if aircraft:
444 nature_suggestions = _nature_suggestions(aircraft.id)
445 counter_hint = _get_counter_hint(preselect_id) if preselect_id else None
447 return render_template(
448 "flights/flight_form.html",
449 flight=None,
450 pilot_entry=None,
451 aircraft=aircraft,
452 managed_aircraft=managed_aircraft,
453 preselect_aircraft_id=preselect_id,
454 gps_prefill=gps_prefill,
455 nature_suggestions=nature_suggestions,
456 pilot_name_hint=pilot_name_hint,
457 crew_roles=CrewRole,
458 fuel_units=_FUEL_UNITS,
459 duplicate=None,
460 counter_hint=counter_hint,
461 openaip_key=_openaip_key(),
462 today_date=_date.today().isoformat(),
463 gps_review_return_aircraft_id=gps_review_return_aircraft_id,
464 gps_review_return_seg_idx=gps_review_return_seg_idx,
465 )
468@flights_bp.route("/flights/<int:flight_id>/edit", methods=["GET", "POST"])
469@login_required
470@require_pilot_access
471def edit_flight(flight_id: int) -> ResponseReturnValue:
472 tid = _tenant_id()
473 managed_aircraft = accessible_aircraft(tid).all()
474 uid = int(session["user_id"])
475 fe = _get_flight_or_404(flight_id)
477 if request.method == "POST":
478 return _handle_log_flight_post(managed_aircraft, uid, fe=fe)
480 gps_prefill = session.pop("gps_prefill", None)
481 pilot_entry = PilotLogbookEntry.query.filter_by(
482 flight_id=fe.id, pilot_user_id=uid
483 ).first()
484 aircraft = db.session.get(Aircraft, fe.aircraft_id)
485 counter_hint = _get_counter_hint(fe.aircraft_id)
487 return render_template(
488 "flights/flight_form.html",
489 flight=fe,
490 pilot_entry=pilot_entry,
491 aircraft=aircraft,
492 managed_aircraft=managed_aircraft,
493 preselect_aircraft_id=fe.aircraft_id,
494 gps_prefill=gps_prefill,
495 nature_suggestions=_nature_suggestions(fe.aircraft_id),
496 pilot_name_hint=None,
497 crew_roles=CrewRole,
498 fuel_units=_FUEL_UNITS,
499 duplicate=None,
500 counter_hint=counter_hint,
501 openaip_key=_openaip_key(),
502 gps_review_return_aircraft_id=None,
503 gps_review_return_seg_idx=None,
504 )
507@flights_bp.route("/flights/<int:flight_id>/track/image.png")
508@login_required
509@require_pilot_access
510def flight_track_image(flight_id: int) -> ResponseReturnValue:
511 """Return a static PNG of the flight's GPS track."""
512 from flask import Response # pyright: ignore[reportMissingImports]
513 from utils import generate_single_track_image # pyright: ignore[reportMissingImports]
515 fe = _get_flight_or_404(flight_id)
516 if not fe.gps_track or not fe.gps_track.geojson:
517 abort(404)
519 tile_s = db.session.get(AppSetting, "openaip_api_key")
520 hires = request.args.get("quality") == "hires"
521 portrait = request.args.get("orientation") == "portrait"
522 base_w, base_h = (480, 800) if portrait else (800, 480)
523 mul = 2 if hires else 1
524 canvas_w, canvas_h = base_w * mul, base_h * mul
526 png_bytes = generate_single_track_image(
527 fe.gps_track.geojson,
528 date=str(fe.date),
529 dep=fe.departure_icao or "",
530 arr=fe.arrival_icao or "",
531 _openaip_key=tile_s.value if tile_s and tile_s.value else None,
532 canvas_w=canvas_w,
533 canvas_h=canvas_h,
534 high_res=hires,
535 )
536 orient_sfx = "-portrait" if portrait else ""
537 qual_sfx = "-hires" if hires else ""
538 suffix = orient_sfx + qual_sfx
539 filename = f"flight_{flight_id}_track{suffix}.png"
540 return Response(
541 png_bytes,
542 mimetype="image/png",
543 headers={
544 "Content-Disposition": f'attachment; filename="{filename}"',
545 "Cache-Control": "public, max-age=31536000, immutable",
546 "ETag": f'"{fe.gps_track.id}"',
547 },
548 )
551@flights_bp.route("/flights/<int:flight_id>/track/animation.gif")
552@login_required
553@require_pilot_access
554def flight_track_gif(flight_id: int) -> ResponseReturnValue:
555 """Return an animated GIF of the flight's GPS track drawn progressively."""
556 from flask import Response # pyright: ignore[reportMissingImports]
557 from utils import generate_single_track_gif # pyright: ignore[reportMissingImports]
559 fe = _get_flight_or_404(flight_id)
560 if not fe.gps_track or not fe.gps_track.geojson:
561 abort(404)
563 tile_s = db.session.get(AppSetting, "openaip_api_key")
564 hires = request.args.get("quality") == "hires"
565 portrait = request.args.get("orientation") == "portrait"
566 base_w, base_h = (480, 800) if portrait else (800, 480)
567 mul = 2 if hires else 1
568 canvas_w, canvas_h = base_w * mul, base_h * mul
570 gif_bytes = generate_single_track_gif(
571 fe.gps_track.geojson,
572 date=str(fe.date),
573 dep=fe.departure_icao or "",
574 arr=fe.arrival_icao or "",
575 _openaip_key=tile_s.value if tile_s and tile_s.value else None,
576 canvas_w=canvas_w,
577 canvas_h=canvas_h,
578 high_res=hires,
579 )
580 orient_sfx = "-portrait" if portrait else ""
581 qual_sfx = "-hires" if hires else ""
582 suffix = orient_sfx + qual_sfx
583 filename = f"flight_{flight_id}_track{suffix}.gif"
584 return Response(
585 gif_bytes,
586 mimetype="image/gif",
587 headers={
588 "Content-Disposition": f'attachment; filename="{filename}"',
589 "Cache-Control": "public, max-age=31536000, immutable",
590 "ETag": f'"{fe.gps_track.id}"',
591 },
592 )
595@flights_bp.route("/flights/registration-lookup")
596@login_required
597@require_pilot_access
598def registration_lookup() -> ResponseReturnValue:
599 """AJAX endpoint: return aircraft type for a previously logged registration.
601 Sources (in priority order):
602 1. Current user's own logbook entries (most recent first).
603 2. Any user in the same tenant (shared pool within the organisation).
604 Sources 3 (cross-tenant) and 4 (external registry) are intentionally omitted.
606 Matching is normalised: case-insensitive, ignoring dashes and spaces.
607 """
608 q = request.args.get("q", "").strip()
609 if not q:
610 return jsonify({"result": None})
612 def _norm(s: str) -> str:
613 return s.upper().replace("-", "").replace(" ", "")
615 q_norm = _norm(q)
616 uid = int(session["user_id"])
617 tid = _tenant_id()
619 # Source 1: current user's own history
620 user_entries = (
621 PilotLogbookEntry.query.filter_by(pilot_user_id=uid)
622 .filter(PilotLogbookEntry.aircraft_registration.isnot(None))
623 .order_by(PilotLogbookEntry.date.desc(), PilotLogbookEntry.id.desc())
624 .all()
625 )
626 for e in user_entries:
627 if _norm(e.aircraft_registration or "") == q_norm and e.aircraft_type:
628 return jsonify(
629 {
630 "result": {
631 "aircraft_type": e.aircraft_type,
632 "aircraft_type_icao": e.aircraft_type_icao or "",
633 }
634 }
635 )
637 # Source 2: any user in the same tenant
638 from models import TenantUser as _TU # pyright: ignore[reportMissingImports]
640 tenant_entries = (
641 PilotLogbookEntry.query.join(
642 _TU, _TU.user_id == PilotLogbookEntry.pilot_user_id
643 )
644 .filter(_TU.tenant_id == tid)
645 .filter(PilotLogbookEntry.aircraft_registration.isnot(None))
646 .filter(PilotLogbookEntry.aircraft_type.isnot(None))
647 .order_by(PilotLogbookEntry.date.desc(), PilotLogbookEntry.id.desc())
648 .all()
649 )
650 for e in tenant_entries:
651 if _norm(e.aircraft_registration or "") == q_norm:
652 return jsonify(
653 {
654 "result": {
655 "aircraft_type": e.aircraft_type,
656 "aircraft_type_icao": e.aircraft_type_icao or "",
657 }
658 }
659 )
661 return jsonify({"result": None})
664@flights_bp.route("/flights/parse-gps", methods=["POST"])
665@_limiter.limit("30 per minute", exempt_when=_rate_limiting_disabled)
666@login_required
667@require_pilot_access
668def parse_gps_api() -> ResponseReturnValue:
669 """AJAX endpoint: parse a GPS upload, check for duplicates, return JSON."""
670 gps_file = request.files.get("gps_file")
671 if not gps_file or not gps_file.filename:
672 return jsonify(
673 {
674 "success": False,
675 "error": str(
676 _("Could not parse GPS file. Fill in the fields manually.")
677 ),
678 }
679 )
680 gps_data = _parse_gps_upload(gps_file)
681 if not gps_data:
682 return jsonify(
683 {
684 "success": False,
685 "error": str(
686 _("Could not parse GPS file. Fill in the fields manually.")
687 ),
688 }
689 )
690 return jsonify(
691 {
692 "success": True,
693 "message": str(
694 _(
695 "GPS file parsed: %(filename)s — fields pre-filled below. Review and save.",
696 filename=gps_data["filename"],
697 )
698 ),
699 "data": {
700 "filename": gps_data["filename"],
701 "date": gps_data["date"].isoformat(),
702 "departure_icao": gps_data["departure_icao"],
703 "arrival_icao": gps_data["arrival_icao"],
704 "departure_time": gps_data["departure_time"].strftime("%H:%M")
705 if gps_data["departure_time"]
706 else "",
707 "arrival_time": gps_data["arrival_time"].strftime("%H:%M")
708 if gps_data["arrival_time"]
709 else "",
710 "flight_time_h": str(gps_data["flight_time_h"]),
711 "block_off_utc": gps_data["block_off_utc"].isoformat()
712 if gps_data["block_off_utc"]
713 else "",
714 "block_on_utc": gps_data["block_on_utc"].isoformat()
715 if gps_data["block_on_utc"]
716 else "",
717 "geojson": _json.dumps(gps_data["geojson"])
718 if gps_data["geojson"]
719 else "",
720 "landing_count": gps_data["landing_count"] or 0,
721 "device_id": gps_data["device_id"] or "",
722 },
723 "duplicate": _check_gps_duplicate(gps_data),
724 "suggested_aircraft_id": _suggested_aircraft_for_device(
725 gps_data["device_id"]
726 ),
727 }
728 )
731def _suggested_aircraft_for_device(device_id: str | None) -> int | None:
732 """Return the aircraft_id most recently used with this device_id, or None."""
733 if not device_id:
734 return None
735 row = (
736 db.session.query(FlightEntry.aircraft_id)
737 .join(GpsTrack, FlightEntry.gps_track_id == GpsTrack.id)
738 .filter(GpsTrack.device_id == device_id)
739 .order_by(FlightEntry.date.desc(), FlightEntry.id.desc())
740 .first()
741 )
742 return int(row[0]) if row else None
745def _check_gps_duplicate(gps_data: dict[str, Any]) -> dict[str, Any] | None:
746 """Return a duplicate summary dict if a matching entry exists, else None."""
747 uid = int(session.get("user_id", 0))
748 aircraft_id = request.form.get("aircraft_id", type=int)
749 dup = _find_duplicate_flight(
750 aircraft_id=aircraft_id,
751 pilot_user_id=uid,
752 date=gps_data["date"],
753 dep_icao=gps_data["departure_icao"],
754 arr_icao=gps_data["arrival_icao"],
755 block_off=gps_data["block_off_utc"],
756 block_on=gps_data["block_on_utc"],
757 )
758 if not dup:
759 return None
760 entry = dup["entry"]
761 return {
762 "type": dup["type"],
763 "date": str(gps_data["date"]),
764 "dep": gps_data["departure_icao"],
765 "arr": gps_data["arrival_icao"],
766 "entry_id": entry.id,
767 }
770def _handle_log_flight_post(
771 managed_aircraft: list[Aircraft],
772 uid: int,
773 fe: FlightEntry | None,
774) -> ResponseReturnValue:
775 f = request.form
776 gps_file = request.files.get("gps_file")
778 # ── GPS parse step ─────────────────────────────────────────────────────────
779 if request.form.get("action") == "parse_gps" and gps_file and gps_file.filename:
780 gps_data = _parse_gps_upload(gps_file)
781 if gps_data:
782 session["gps_prefill"] = {
783 "filename": gps_data["filename"],
784 "date": gps_data["date"].isoformat(),
785 "departure_icao": gps_data["departure_icao"],
786 "arrival_icao": gps_data["arrival_icao"],
787 "departure_time": gps_data["departure_time"].strftime("%H:%M")
788 if gps_data["departure_time"]
789 else "",
790 "arrival_time": gps_data["arrival_time"].strftime("%H:%M")
791 if gps_data["arrival_time"]
792 else "",
793 "flight_time_h": str(gps_data["flight_time_h"]),
794 "block_off_utc": gps_data["block_off_utc"].isoformat(),
795 "block_on_utc": gps_data["block_on_utc"].isoformat(),
796 "geojson": _json.dumps(gps_data["geojson"])
797 if gps_data["geojson"]
798 else "",
799 "landing_count": gps_data["landing_count"],
800 }
801 flash(_("GPS file parsed — fields pre-filled. Review and save."), "info")
802 else:
803 flash(
804 _("Could not parse GPS file. Fill in the fields manually."), "warning"
805 )
806 if fe:
807 return redirect(url_for("flights.edit_flight", flight_id=fe.id))
808 aircraft_id = f.get("aircraft_id", type=int)
809 qs: dict[str, Any] = {"aircraft_id": aircraft_id} if aircraft_id else {}
810 return redirect(url_for("flights.log_flight", **qs))
812 # ── Determine aircraft ─────────────────────────────────────────────────────
813 other_aircraft = f.get("other_aircraft") == "1"
814 aircraft_id_raw = f.get("aircraft_id", type=int)
815 # When editing an existing flight, fall back to the flight's own aircraft_id
816 # so the `if ac:` block is entered even when aircraft_id is absent from the form.
817 if aircraft_id_raw is None and fe is not None:
818 aircraft_id_raw = fe.aircraft_id
819 ac: Aircraft | None = None
820 if not other_aircraft and aircraft_id_raw:
821 ac = next((a for a in managed_aircraft if a.id == aircraft_id_raw), None)
823 other_ac_make_model = f.get("other_ac_make_model", "").strip()
824 other_ac_reg = f.get("other_ac_reg", "").strip().upper()
826 # ── Parse common fields ────────────────────────────────────────────────────
827 date_raw = f.get("date", "").strip()
828 dep = (f.get("departure_icao") or "").strip().upper()[:4]
829 arr = (f.get("arrival_icao") or "").strip().upper()[:4]
830 departure_time_raw = f.get("departure_time", "").strip()
831 arrival_time_raw = f.get("arrival_time", "").strip()
832 flight_time_raw = f.get("flight_time", "").strip()
833 nature_of_flight = f.get("nature_of_flight", "").strip() or None
834 notes = f.get("notes", "").strip() or None
835 pilot_role = f.get("pilot_role", "none").strip()
836 if pilot_role not in ("pic", "dual", "none"):
837 pilot_role = "none"
839 # Aircraft-log fields
840 flight_time_counter_start_raw = f.get("flight_time_counter_start", "").strip()
841 flight_time_counter_end_raw = f.get("flight_time_counter_end", "").strip()
842 engine_time_counter_start_raw = f.get("engine_time_counter_start", "").strip()
843 engine_time_counter_end_raw = f.get("engine_time_counter_end", "").strip()
844 passenger_count_raw = f.get("passenger_count", "").strip()
845 fuel_event_raw = f.get("fuel_event", "none").strip()
846 fuel_added_qty_raw = f.get("fuel_added_qty", "").strip()
847 fuel_added_unit = f.get("fuel_added_unit", "L").strip()
848 fuel_remaining_qty_raw = f.get("fuel_remaining_qty", "").strip()
849 crew_name_0 = f.get("crew_name_0", "").strip()
850 crew_role_0 = f.get("crew_role_0", CrewRole.PIC).strip()
851 crew_name_1 = f.get("crew_name_1", "").strip()
852 crew_role_1 = f.get("crew_role_1", CrewRole.COPILOT).strip()
854 # Pilot-log fields
855 night_time_raw = f.get("night_time", "").strip()
856 instrument_time_raw = f.get("instrument_time", "").strip()
857 landings_day_raw = f.get("landings_day", "").strip()
858 landings_night_raw = f.get("landings_night", "").strip()
859 multi_pilot_raw = f.get("multi_pilot", "").strip()
860 pic_name = f.get("pic_name", "").strip() or None
862 # GPS hidden fields (carried from parse step or re-render)
863 gps_filename = f.get("gps_filename", "").strip() or None
864 gps_device_id = f.get("gps_device_id", "").strip() or None
865 gps_block_off_raw = f.get("gps_block_off_utc", "").strip()
866 gps_block_on_raw = f.get("gps_block_on_utc", "").strip()
867 gps_geojson_raw = f.get("gps_geojson", "").strip()
869 duplicate_action = f.get("duplicate_action", "").strip()
871 errors = []
873 flight_date: _date | None = None
874 if not date_raw:
875 errors.append(_("Date is required."))
876 else:
877 try:
878 flight_date = _date.fromisoformat(date_raw)
879 except ValueError:
880 errors.append(_("Date must be a valid date (YYYY-MM-DD)."))
882 if not dep:
883 errors.append(_("Departure airfield is required."))
884 if not arr:
885 errors.append(_("Arrival airfield is required."))
887 if not fe and not ac and not other_aircraft:
888 errors.append(_("Please select an aircraft."))
890 if ac and not crew_name_0:
891 errors.append(_("Pilot (crew 1) name is required."))
893 if other_aircraft and pilot_role not in ("pic", "dual"):
894 errors.append(_("Pilot role is required for other aircraft flights."))
895 if other_aircraft and pilot_role in ("pic", "dual") and not crew_name_0:
896 errors.append(_("Pilot name is required."))
897 if other_aircraft and not other_ac_make_model:
898 errors.append(
899 _("Aircraft type (make/model) is required for other aircraft flights.")
900 )
901 if other_aircraft and not other_ac_reg:
902 errors.append(
903 _("Aircraft registration is required for other aircraft flights.")
904 )
906 departure_time: _time | None = None
907 arrival_time: _time | None = None
908 if departure_time_raw:
909 try:
910 departure_time = _time.fromisoformat(departure_time_raw)
911 except ValueError:
912 errors.append(_("Departure time must be a valid UTC time (HH:MM)."))
913 if arrival_time_raw:
914 try:
915 arrival_time = _time.fromisoformat(arrival_time_raw)
916 except ValueError:
917 errors.append(_("Arrival time must be a valid UTC time (HH:MM)."))
919 flight_time_counter_start = flight_time_counter_end = None
920 engine_time_counter_start = engine_time_counter_end = None
921 if ac:
922 for raw, dest in [
923 (flight_time_counter_start_raw, "fc_start"),
924 (flight_time_counter_end_raw, "fc_end"),
925 (engine_time_counter_start_raw, "ec_start"),
926 (engine_time_counter_end_raw, "ec_end"),
927 ]:
928 if raw:
929 try:
930 val = float(raw)
931 if val < 0:
932 raise ValueError
933 if dest == "fc_start":
934 flight_time_counter_start = val
935 elif dest == "fc_end":
936 flight_time_counter_end = val
937 elif dest == "ec_start":
938 engine_time_counter_start = val
939 else:
940 engine_time_counter_end = val
941 except (ValueError, TypeError):
942 errors.append(_("Counter value must be a positive number."))
944 if (
945 flight_time_counter_start is not None
946 and flight_time_counter_end is not None
947 and flight_time_counter_end <= flight_time_counter_start
948 ):
949 errors.append(
950 _("Flight counter end must be greater than flight counter start.")
951 )
952 if (
953 engine_time_counter_start is not None
954 and engine_time_counter_end is not None
955 and engine_time_counter_end <= engine_time_counter_start
956 ):
957 errors.append(
958 _("Engine counter end must be greater than engine counter start.")
959 )
961 flight_time: float | None = None
962 if flight_time_raw:
963 try:
964 flight_time = round(float(flight_time_raw), 1)
965 if flight_time < 0:
966 raise ValueError
967 except (ValueError, TypeError):
968 errors.append(_("Flight time must be a non-negative number."))
969 elif (
970 ac
971 and flight_time_counter_start is not None
972 and flight_time_counter_end is not None
973 ):
974 flight_time = round(flight_time_counter_end - flight_time_counter_start, 1)
975 elif (
976 ac
977 and not getattr(ac, "has_flight_counter", True)
978 and engine_time_counter_start is not None
979 and engine_time_counter_end is not None
980 ):
981 raw_diff = (engine_time_counter_end - engine_time_counter_start) - float(
982 getattr(ac, "flight_counter_offset", 0) or 0
983 )
984 flight_time = round(max(0.0, raw_diff), 1)
986 passenger_count: int | None = None
987 if passenger_count_raw:
988 try:
989 passenger_count = int(passenger_count_raw)
990 if passenger_count < 0:
991 raise ValueError
992 except (ValueError, TypeError):
993 errors.append(_("Passenger count must be a non-negative integer."))
995 fuel_event = fuel_event_raw if fuel_event_raw in ("before", "after") else None
996 fuel_added_qty: float | None = None
997 if fuel_event and fuel_added_qty_raw:
998 try:
999 fuel_added_qty = float(fuel_added_qty_raw)
1000 if fuel_added_qty < 0:
1001 raise ValueError
1002 except (ValueError, TypeError):
1003 errors.append(_("Fuel quantity added must be a non-negative number."))
1005 fuel_remaining_qty: float | None = None
1006 if fuel_remaining_qty_raw:
1007 try:
1008 fuel_remaining_qty = float(fuel_remaining_qty_raw)
1009 if fuel_remaining_qty < 0:
1010 raise ValueError
1011 except (ValueError, TypeError):
1012 errors.append(_("Fuel remaining must be a non-negative number."))
1014 def _parse_dec(raw: str) -> decimal.Decimal | None:
1015 if not raw:
1016 return None
1017 try:
1018 v = decimal.Decimal(raw)
1019 return v if v >= 0 else None
1020 except Exception:
1021 return None
1023 night_time = _parse_dec(night_time_raw)
1024 instrument_time = _parse_dec(instrument_time_raw)
1025 multi_pilot = _parse_dec(multi_pilot_raw)
1026 landings_day: int | None = (
1027 int(landings_day_raw) if landings_day_raw.isdigit() else None
1028 )
1029 landings_night: int | None = (
1030 int(landings_night_raw) if landings_night_raw.isdigit() else None
1031 )
1033 gps_block_off: _datetime | None = None
1034 gps_block_on: _datetime | None = None
1035 if gps_block_off_raw:
1036 with contextlib.suppress(
1037 ValueError
1038 ): # malformed hidden field — treat as absent
1039 gps_block_off = _datetime.fromisoformat(gps_block_off_raw)
1040 if gps_block_on_raw:
1041 with contextlib.suppress(
1042 ValueError
1043 ): # malformed hidden field — treat as absent
1044 gps_block_on = _datetime.fromisoformat(gps_block_on_raw)
1046 gps_geojson: Any = None
1047 if gps_geojson_raw:
1048 with contextlib.suppress(
1049 Exception
1050 ): # malformed hidden field — GPS track simply not applied
1051 gps_geojson = _json.loads(gps_geojson_raw)
1053 if errors:
1054 for msg in errors:
1055 flash(msg, "danger")
1056 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, None)
1058 # ── Duplicate detection (first pass) ──────────────────────────────────────
1059 if not duplicate_action and flight_date and dep and arr:
1060 dup = _find_duplicate_flight(
1061 aircraft_id=ac.id if ac else None,
1062 pilot_user_id=uid,
1063 date=flight_date,
1064 dep_icao=dep,
1065 arr_icao=arr,
1066 block_off=gps_block_off,
1067 block_on=gps_block_on,
1068 exclude_flight_id=fe.id if fe else None,
1069 )
1070 if dup:
1071 return _render_form(managed_aircraft, fe, None, aircraft_id_raw, dup)
1073 # ── GPS-attach-only path ───────────────────────────────────────────────────
1074 if duplicate_action == "link_gps" and flight_date:
1075 dup = _find_duplicate_flight(
1076 aircraft_id=ac.id if ac else None,
1077 pilot_user_id=uid,
1078 date=flight_date,
1079 dep_icao=dep,
1080 arr_icao=arr,
1081 block_off=gps_block_off,
1082 block_on=gps_block_on,
1083 exclude_flight_id=fe.id if fe else None,
1084 )
1085 if dup and (gps_geojson or gps_filename):
1086 link_track = GpsTrack(
1087 source_filename=gps_filename,
1088 device_id=gps_device_id,
1089 block_off_utc=gps_block_off,
1090 block_on_utc=gps_block_on,
1091 departure_icao=dep,
1092 arrival_icao=arr,
1093 geojson=gps_geojson,
1094 )
1095 db.session.add(link_track)
1096 db.session.flush()
1097 entry = dup["entry"]
1098 if isinstance(entry, FlightEntry):
1099 entry.gps_track_id = link_track.id
1100 plink = PilotLogbookEntry.query.filter_by(
1101 flight_id=entry.id, pilot_user_id=uid
1102 ).first()
1103 if plink:
1104 plink.gps_track_id = link_track.id
1105 else:
1106 entry.gps_track_id = link_track.id
1107 db.session.commit()
1108 flash(_("GPS track linked to the existing flight entry."), "success")
1109 else:
1110 flash(_("Could not link GPS track — no matching entry found."), "warning")
1111 return redirect(url_for("pilots.logbook"))
1113 # ── Build GpsTrack if GPS data is present ─────────────────────────────────
1114 ft_decimal = decimal.Decimal(str(flight_time)) if flight_time is not None else None
1115 create_pilot = pilot_role in ("pic", "dual")
1117 gps_track: GpsTrack | None = None
1118 if gps_geojson or gps_filename:
1119 existing_track_id: int | None = fe.gps_track_id if fe else None
1120 if existing_track_id:
1121 gps_track = db.session.get(GpsTrack, existing_track_id)
1122 if gps_track:
1123 if gps_geojson:
1124 gps_track.geojson = gps_geojson
1125 if gps_filename:
1126 gps_track.source_filename = gps_filename
1127 if gps_block_off:
1128 gps_track.block_off_utc = gps_block_off
1129 if gps_block_on:
1130 gps_track.block_on_utc = gps_block_on
1131 if gps_track and gps_device_id:
1132 gps_track.device_id = gps_device_id
1133 if not gps_track:
1134 gps_track = GpsTrack(
1135 source_filename=gps_filename,
1136 device_id=gps_device_id,
1137 block_off_utc=gps_block_off,
1138 block_on_utc=gps_block_on,
1139 departure_icao=dep,
1140 arrival_icao=arr,
1141 geojson=gps_geojson,
1142 )
1143 db.session.add(gps_track)
1144 db.session.flush()
1146 # ── Pilot log aircraft fields ──────────────────────────────────────────────
1147 plog_ac_type: str | None
1148 if ac:
1149 plog_ac_type = f"{ac.make} {ac.model}".strip()
1150 plog_ac_type_icao = getattr(ac, "aircraft_type_icao", None)
1151 plog_ac_reg = ac.registration
1152 cat = _ac_category(ac)
1153 plog_sp_se = ft_decimal if cat in ("SEP", "SET", "") else None
1154 plog_sp_me = ft_decimal if cat in ("MEP", "MET") else None
1155 else:
1156 plog_ac_type = other_ac_make_model or None
1157 plog_ac_type_icao = f.get("aircraft_type_icao", "").strip() or None
1158 plog_ac_reg = other_ac_reg or None
1159 plog_sp_se = ft_decimal
1160 plog_sp_me = None
1162 # ── Aircraft log entry ─────────────────────────────────────────────────────
1163 _fe_is_new = fe is None
1164 if ac:
1165 if fe is None:
1166 fe = FlightEntry(aircraft_id=ac.id)
1167 db.session.add(fe)
1169 fe.date = flight_date
1170 fe.departure_icao = dep
1171 fe.arrival_icao = arr
1172 fe.departure_time = departure_time
1173 fe.arrival_time = arrival_time
1174 fe.flight_time = ft_decimal
1175 fe.nature_of_flight = nature_of_flight
1176 fe.passenger_count = passenger_count
1177 if landings_day is not None or landings_night is not None:
1178 fe.landing_count = (landings_day or 0) + (landings_night or 0)
1179 fe.flight_time_counter_start = flight_time_counter_start
1180 fe.flight_time_counter_end = flight_time_counter_end
1181 fe.notes = notes
1182 fe.engine_time_counter_start = engine_time_counter_start
1183 fe.engine_time_counter_end = engine_time_counter_end
1184 fe.fuel_event = fuel_event
1185 fe.fuel_added_qty = fuel_added_qty
1186 fe.fuel_added_unit = fuel_added_unit if fuel_added_qty is not None else None
1187 fe.fuel_remaining_qty = fuel_remaining_qty
1188 if gps_track:
1189 fe.gps_track_id = gps_track.id
1190 if gps_block_off:
1191 fe.block_off_utc = gps_block_off
1192 if gps_block_on:
1193 fe.block_on_utc = gps_block_on
1195 db.session.flush()
1197 FlightCrew.query.filter_by(flight_id=fe.id).delete()
1198 if crew_name_0:
1199 db.session.add(
1200 FlightCrew(
1201 flight_id=fe.id,
1202 name=crew_name_0,
1203 role=crew_role_0 if crew_role_0 in CrewRole.ALL else CrewRole.PIC,
1204 sort_order=0,
1205 )
1206 )
1207 if crew_name_1:
1208 db.session.add(
1209 FlightCrew(
1210 flight_id=fe.id,
1211 name=crew_name_1,
1212 role=crew_role_1
1213 if crew_role_1 in CrewRole.ALL
1214 else CrewRole.COPILOT,
1215 sort_order=1,
1216 )
1217 )
1219 for photo_field, label, attr in [
1220 ("flight_counter_photo", "flight", "flight_counter_photo"),
1221 ("engine_counter_photo", "engine", "engine_counter_photo"),
1222 ("fuel_photo", "fuel", "fuel_photo"),
1223 ]:
1224 photo_file = request.files.get(photo_field)
1225 if photo_file and photo_file.filename:
1226 stored = _save_upload(photo_file, fe.id, label)
1227 if stored:
1228 _delete_upload(getattr(fe, attr))
1229 setattr(fe, attr, stored)
1231 # ── Pilot log entry ────────────────────────────────────────────────────────
1232 if create_pilot:
1233 _u = db.session.get(User, uid)
1234 effective_pic_name = (
1235 pic_name
1236 or (crew_name_0 if pilot_role == "pic" else None)
1237 or (_u.display_name if _u else "")
1238 )
1239 existing_pe: PilotLogbookEntry | None = None
1240 if fe and fe.id:
1241 existing_pe = PilotLogbookEntry.query.filter_by(
1242 flight_id=fe.id, pilot_user_id=uid
1243 ).first()
1245 pe = existing_pe or PilotLogbookEntry(pilot_user_id=uid)
1246 if not existing_pe:
1247 db.session.add(pe)
1249 pe.flight_id = fe.id if fe else None
1250 pe.date = flight_date
1251 pe.aircraft_type = plog_ac_type
1252 pe.aircraft_type_icao = plog_ac_type_icao
1253 pe.aircraft_registration = plog_ac_reg
1254 pe.departure_place = dep
1255 pe.departure_time = departure_time
1256 pe.arrival_place = arr
1257 pe.arrival_time = arrival_time
1258 pe.pic_name = effective_pic_name
1259 pe.night_time = night_time
1260 pe.instrument_time = instrument_time
1261 pe.landings_day = landings_day if landings_day is not None else 0
1262 pe.landings_night = landings_night
1263 pe.single_pilot_se = plog_sp_se
1264 pe.single_pilot_me = plog_sp_me
1265 pe.multi_pilot = multi_pilot
1266 pe.function_pic = ft_decimal if pilot_role == "pic" else None
1267 pe.function_dual = ft_decimal if pilot_role == "dual" else None
1268 pe.remarks = notes
1269 if gps_track:
1270 pe.gps_track_id = gps_track.id
1272 elif fe and fe.id:
1273 detach_action = f.get("detach_pilot_log", "").strip()
1274 if detach_action in ("detach", "delete"):
1275 existing_pe2 = PilotLogbookEntry.query.filter_by(
1276 flight_id=fe.id, pilot_user_id=uid
1277 ).first()
1278 if existing_pe2:
1279 if detach_action == "delete":
1280 db.session.delete(existing_pe2)
1281 else:
1282 existing_pe2.flight_id = None
1284 db.session.commit()
1286 if fe and ac:
1287 event_name = "flight.logged" if _fe_is_new else "flight.updated"
1288 activity(
1289 event_name,
1290 flight_id=fe.id,
1291 aircraft_id=ac.id,
1292 dep=dep,
1293 arr=arr,
1294 date=str(flight_date),
1295 )
1296 _check_flight_hour_milestone(fe)
1298 if ac and fe:
1299 flash(
1300 _(
1301 "Flight %(dep)s→%(arr)s on %(date)s saved.",
1302 dep=dep,
1303 arr=arr,
1304 date=flight_date,
1305 ),
1306 "success",
1307 )
1308 return_ac_id = f.get("gps_review_return_aircraft_id", type=int)
1309 return_seg_idx = f.get("gps_review_return_seg_idx", type=int)
1310 if return_ac_id is not None:
1311 gps_state = session.get("gps_import", {})
1312 if (
1313 gps_state.get("aircraft_id") == return_ac_id
1314 and return_seg_idx is not None
1315 ):
1316 confirmed = gps_state.get("confirmed_segments", {})
1317 confirmed[str(return_seg_idx)] = fe.id
1318 gps_state["confirmed_segments"] = confirmed
1319 session["gps_import"] = gps_state
1320 session.modified = True
1321 return redirect(
1322 url_for("aircraft.gps_import_review", aircraft_id=return_ac_id)
1323 )
1324 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))
1326 flash(
1327 _(
1328 "Flight %(dep)s→%(arr)s on %(date)s saved to your pilot logbook.",
1329 dep=dep,
1330 arr=arr,
1331 date=flight_date,
1332 ),
1333 "success",
1334 )
1335 return redirect(url_for("pilots.logbook"))
1338def _render_form(
1339 managed_aircraft: list[Aircraft],
1340 flight: FlightEntry | None,
1341 pilot_entry: PilotLogbookEntry | None,
1342 preselect_id: int | None,
1343 duplicate: dict[str, Any] | None,
1344) -> ResponseReturnValue:
1345 nature_suggestions = _NATURE_SUGGESTIONS
1346 aircraft: Aircraft | None = None
1347 if preselect_id:
1348 aircraft = next((a for a in managed_aircraft if a.id == preselect_id), None)
1349 if aircraft:
1350 nature_suggestions = _nature_suggestions(aircraft.id)
1351 counter_hint = _get_counter_hint(preselect_id) if preselect_id else None
1352 return render_template(
1353 "flights/flight_form.html",
1354 flight=flight,
1355 pilot_entry=pilot_entry,
1356 aircraft=aircraft,
1357 managed_aircraft=managed_aircraft,
1358 preselect_aircraft_id=preselect_id,
1359 gps_prefill=None,
1360 nature_suggestions=nature_suggestions,
1361 pilot_name_hint=None,
1362 crew_roles=CrewRole,
1363 fuel_units=_FUEL_UNITS,
1364 duplicate=duplicate,
1365 counter_hint=counter_hint,
1366 openaip_key=_openaip_key(),
1367 gps_review_return_aircraft_id=None,
1368 gps_review_return_seg_idx=None,
1369 )
1372# ── Delete flight ─────────────────────────────────────────────────────────────
1375@flights_bp.route(
1376 "/aircraft/<int:aircraft_id>/flights/<int:flight_id>/delete", methods=["POST"]
1377)
1378@login_required
1379@require_pilot_access
1380def delete_flight(aircraft_id: int, flight_id: int) -> ResponseReturnValue:
1381 ac = _get_aircraft_or_404(aircraft_id)
1382 fe = db.session.get(FlightEntry, flight_id)
1383 if not fe or fe.aircraft_id != ac.id:
1384 abort(404)
1385 label = f"{fe.departure_icao}→{fe.arrival_icao} on {fe.date}"
1386 activity(
1387 "flight.deleted", flight_id=flight_id, aircraft_id=aircraft_id, label=label
1388 )
1389 _delete_upload(fe.flight_counter_photo)
1390 _delete_upload(fe.engine_counter_photo)
1391 db.session.delete(fe)
1392 db.session.commit()
1393 flash(_("Flight %(label)s deleted.", label=label), "success")
1394 return redirect(url_for("flights.list_flights", aircraft_id=ac.id))