Coverage for app/flights/form_parsing.py: 100%
222 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""Shared validation for the unified Flight editable field set (airframe side).
3``parse_flight_fields`` / ``apply_flight_fields`` are used by both the
4online flight form (``_handle_log_flight_post``) and the offline sync API
5(``offline/routes.py``) so the two paths can never diverge in validation.
6The field set matches ``offline.serialize.FLIGHT_EDITABLE_FIELDS`` exactly.
7"""
9import math
10from collections.abc import Mapping
11from datetime import (
12 date as _date,
13)
14from datetime import (
15 datetime as _datetime,
16)
17from datetime import (
18 time as _time,
19)
20from datetime import (
21 timedelta as _timedelta,
22)
23from typing import Any
25from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
26from models import ( # pyright: ignore[reportMissingImports]
27 Aircraft,
28 CrewRole,
29 Flight,
30 db,
31)
32from pilots.logbook_import import ( # pyright: ignore[reportMissingImports]
33 parse_duration_value,
34)
36# engine_time/flight_time are never taken as free-text user input — always
37# recomputed from counters (preferred) or clock times (departure/arrival for
38# engine, takeoff/landing for flight), matching whichever data the pilot
39# actually entered. When both a counter pair and a clock-time pair are
40# present for the same category, they're two independent measurements of
41# the same thing and must agree within this tolerance — a bigger gap means
42# a data-entry mistake (wrong counter digit, wrong time), not rounding.
43_DURATION_MISMATCH_TOLERANCE_HOURS = 0.2
46def _parse_clock_time(raw: str) -> _time:
47 """Parse an HH:MM(:SS) UTC clock time.
49 ``time.fromisoformat`` also accepts a UTC-offset suffix (e.g.
50 ``"04:23+02:00"``), which would produce a timezone-aware ``time`` that
51 can't be compared against the naive ones from plain ``"HH:MM"`` input in
52 ``_hours_between``. These fields are always UTC — reject offsets rather
53 than silently accept them.
54 """
55 parsed = _time.fromisoformat(raw)
56 if parsed.tzinfo is not None:
57 raise ValueError("UTC clock time must not carry a timezone offset")
58 return parsed
61def _hours_between(start: _time, end: _time) -> float:
62 """Duration in hours between two clock times, assuming ``end`` is on the
63 same day as ``start`` unless it's earlier (then the flight crossed
64 midnight)."""
65 start_dt = _datetime.combine(_date.min, start)
66 end_dt = _datetime.combine(_date.min, end)
67 if end_dt < start_dt:
68 end_dt += _timedelta(days=1)
69 return (end_dt - start_dt).total_seconds() / 3600.0
72def flight_is_lenient(fe: Flight | None) -> bool:
73 """True when *fe* came from a bulk airframe import batch that's been
74 flagged as historical (digitizing pre-existing paper records) — such a
75 batch's rows may carry OCR/paper-log imprecision that was never wrong,
76 just imprecise, and must not block later edits. ``fe`` is ``None`` when
77 creating a new flight, which is always strict."""
78 return bool(
79 fe and fe.airframe_import_batch and fe.airframe_import_batch.is_historical
80 )
83def parse_flight_fields(
84 f: Mapping[str, str], ac: Aircraft | None, strict: bool = True
85) -> tuple[dict[str, Any], list[str]]:
86 """Parse + validate the editable FlightEntry fields from raw strings.
88 ``ac`` gates the aircraft-log-specific rules (counters, flight-time
89 derivation from counters, crew-1 required) exactly like the ``if ac:``
90 branches in the online form — pass ``None`` for flights with no
91 fleet aircraft (the "other aircraft" case), matching today's behaviour.
93 ``strict=False`` (see ``flight_is_lenient``) suppresses only the two
94 checks that can block saving an edit to data that was never freshly
95 typed — pilot-name-required and counter/clock duration mismatches.
96 Everything else (date required, parse failures, counter-end-before-
97 start) still applies regardless: those are new mistakes made today, not
98 pre-existing historical imprecision.
99 """
100 errors: list[str] = []
102 date_raw = (f.get("date") or "").strip()
103 flight_date: _date | None = None
104 if not date_raw:
105 errors.append(_("Date is required."))
106 else:
107 try:
108 flight_date = _date.fromisoformat(date_raw)
109 except ValueError:
110 errors.append(_("Date must be a valid date (YYYY-MM-DD)."))
112 dep = (f.get("departure_icao") or "").strip().upper()[:4]
113 arr = (f.get("arrival_icao") or "").strip().upper()[:4]
114 if not dep:
115 errors.append(_("Departure airfield is required."))
116 if not arr:
117 errors.append(_("Arrival airfield is required."))
119 crew_name_0 = (f.get("crew_name_0") or "").strip()
120 crew_role_0_raw = (f.get("crew_role_0") or CrewRole.PIC).strip()
121 crew_name_1 = (f.get("crew_name_1") or "").strip()
122 crew_role_1_raw = (f.get("crew_role_1") or CrewRole.COPILOT).strip()
123 if ac and not crew_name_0 and strict:
124 errors.append(_("Pilot (crew 1) name is required."))
126 departure_time_raw = (f.get("departure_time") or "").strip()
127 arrival_time_raw = (f.get("arrival_time") or "").strip()
128 departure_time: _time | None = None
129 arrival_time: _time | None = None
130 if departure_time_raw:
131 try:
132 departure_time = _parse_clock_time(departure_time_raw)
133 except ValueError:
134 errors.append(_("Departure time must be a valid UTC time (HH:MM)."))
135 if arrival_time_raw:
136 try:
137 arrival_time = _parse_clock_time(arrival_time_raw)
138 except ValueError:
139 errors.append(_("Arrival time must be a valid UTC time (HH:MM)."))
141 # Actual airborne segment — optional, independent of the block times
142 # above, never defaulted from them.
143 takeoff_time_raw = (f.get("takeoff_time") or "").strip()
144 landing_time_raw = (f.get("landing_time") or "").strip()
145 takeoff_time: _time | None = None
146 landing_time: _time | None = None
147 if takeoff_time_raw:
148 try:
149 takeoff_time = _parse_clock_time(takeoff_time_raw)
150 except ValueError:
151 errors.append(_("Takeoff time must be a valid UTC time (HH:MM)."))
152 if landing_time_raw:
153 try:
154 landing_time = _parse_clock_time(landing_time_raw)
155 except ValueError:
156 errors.append(_("Landing time must be a valid UTC time (HH:MM)."))
158 flight_time_counter_start = flight_time_counter_end = None
159 engine_time_counter_start = engine_time_counter_end = None
160 if ac:
161 for raw, dest in [
162 ((f.get("flight_time_counter_start") or "").strip(), "fc_start"),
163 ((f.get("flight_time_counter_end") or "").strip(), "fc_end"),
164 ((f.get("engine_time_counter_start") or "").strip(), "ec_start"),
165 ((f.get("engine_time_counter_end") or "").strip(), "ec_end"),
166 ]:
167 if raw:
168 # Accepts either a plain decimal ("972.2") or unambiguous
169 # "H:MM" colon notation ("972:12") — same parser the CSV
170 # airframe/pilot log importers already use for counters, so
171 # a value typed either way behaves identically everywhere.
172 val = parse_duration_value(raw)
173 if val is None:
174 errors.append(
175 _(
176 "Counter value must be a positive number "
177 "(decimal hours or H:MM)."
178 )
179 )
180 elif dest == "fc_start":
181 flight_time_counter_start = val
182 elif dest == "fc_end":
183 flight_time_counter_end = val
184 elif dest == "ec_start":
185 engine_time_counter_start = val
186 else:
187 engine_time_counter_end = val
189 if (
190 flight_time_counter_start is not None
191 and flight_time_counter_end is not None
192 and flight_time_counter_end < flight_time_counter_start
193 ):
194 errors.append(
195 _("Flight counter end must not be less than flight counter start.")
196 )
197 if (
198 engine_time_counter_start is not None
199 and engine_time_counter_end is not None
200 and engine_time_counter_end < engine_time_counter_start
201 ):
202 errors.append(
203 _("Engine counter end must not be less than engine counter start.")
204 )
206 # engine_time: never user-entered — computed from the engine counters
207 # and/or the engine start/end clock times (departure_time/arrival_time),
208 # whichever are present. If both are present they must roughly agree.
209 engine_time_from_counters: float | None = None
210 if engine_time_counter_start is not None and engine_time_counter_end is not None:
211 engine_time_from_counters = round(
212 max(0.0, engine_time_counter_end - engine_time_counter_start), 1
213 )
214 engine_time_from_clock: float | None = None
215 if departure_time is not None and arrival_time is not None:
216 engine_time_from_clock = round(_hours_between(departure_time, arrival_time), 1)
218 if (
219 strict
220 and engine_time_from_counters is not None
221 and engine_time_from_clock is not None
222 and abs(engine_time_from_counters - engine_time_from_clock)
223 > _DURATION_MISMATCH_TOLERANCE_HOURS
224 ):
225 errors.append(
226 _(
227 "Engine time from the counters (%(counters)s h) doesn't match the "
228 "departure/arrival times (%(clock)s h) — check for a data entry "
229 "mistake.",
230 counters=f"{engine_time_from_counters:.1f}",
231 clock=f"{engine_time_from_clock:.1f}",
232 )
233 )
234 engine_time = (
235 engine_time_from_counters
236 if engine_time_from_counters is not None
237 else engine_time_from_clock
238 )
240 # flight_time: same principle — computed from the flight counters (or,
241 # for aircraft with no separate flight-hour meter, the engine counters
242 # minus the configured offset) and/or the takeoff/landing clock times.
243 flight_time_from_counters: float | None = None
244 if (
245 ac
246 and flight_time_counter_start is not None
247 and flight_time_counter_end is not None
248 ):
249 # Clamped: an end-before-start counter pair already appends an error
250 # above, but flight_time is still returned to the caller regardless
251 # of errors, so it must never come back negative.
252 flight_time_from_counters = round(
253 max(0.0, flight_time_counter_end - flight_time_counter_start), 1
254 )
255 elif (
256 ac
257 and not getattr(ac, "has_flight_counter", True)
258 and engine_time_counter_start is not None
259 and engine_time_counter_end is not None
260 ):
261 raw_diff = (engine_time_counter_end - engine_time_counter_start) - float(
262 getattr(ac, "flight_counter_offset", 0) or 0
263 )
264 flight_time_from_counters = round(max(0.0, raw_diff), 1)
266 flight_time_from_clock: float | None = None
267 if takeoff_time is not None and landing_time is not None:
268 flight_time_from_clock = round(_hours_between(takeoff_time, landing_time), 1)
270 if (
271 strict
272 and flight_time_from_counters is not None
273 and flight_time_from_clock is not None
274 and abs(flight_time_from_counters - flight_time_from_clock)
275 > _DURATION_MISMATCH_TOLERANCE_HOURS
276 ):
277 errors.append(
278 _(
279 "Flight time from the counters (%(counters)s h) doesn't match the "
280 "takeoff/landing times (%(clock)s h) — check for a data entry "
281 "mistake.",
282 counters=f"{flight_time_from_counters:.1f}",
283 clock=f"{flight_time_from_clock:.1f}",
284 )
285 )
286 flight_time = (
287 flight_time_from_counters
288 if flight_time_from_counters is not None
289 else flight_time_from_clock
290 )
292 passenger_count_raw = (f.get("passenger_count") or "").strip()
293 passenger_count: int | None = None
294 if passenger_count_raw:
295 try:
296 passenger_count = int(passenger_count_raw)
297 if passenger_count < 0:
298 raise ValueError
299 except (ValueError, TypeError):
300 passenger_count = None
301 errors.append(_("Passenger count must be a non-negative integer."))
303 landing_count_raw = (f.get("landing_count") or "").strip()
304 landing_count: int | None = None
305 if landing_count_raw:
306 try:
307 landing_count = int(landing_count_raw)
308 if landing_count < 0:
309 raise ValueError
310 except (ValueError, TypeError):
311 landing_count = None
312 errors.append(_("Landing count must be a non-negative integer."))
314 fuel_added_before_qty_raw = (f.get("fuel_added_before_qty") or "").strip()
315 fuel_added_before_qty: float | None = None
316 if fuel_added_before_qty_raw:
317 try:
318 fuel_added_before_qty = float(fuel_added_before_qty_raw)
319 if not math.isfinite(fuel_added_before_qty) or fuel_added_before_qty < 0:
320 raise ValueError
321 except (ValueError, TypeError):
322 fuel_added_before_qty = None
323 errors.append(
324 _(
325 "Fuel quantity added before the flight must be a non-negative number."
326 )
327 )
328 fuel_added_before_unit = (f.get("fuel_added_before_unit") or "L").strip()
330 fuel_added_after_qty_raw = (f.get("fuel_added_after_qty") or "").strip()
331 fuel_added_after_qty: float | None = None
332 if fuel_added_after_qty_raw:
333 try:
334 fuel_added_after_qty = float(fuel_added_after_qty_raw)
335 if not math.isfinite(fuel_added_after_qty) or fuel_added_after_qty < 0:
336 raise ValueError
337 except (ValueError, TypeError):
338 fuel_added_after_qty = None
339 errors.append(
340 _("Fuel quantity added after the flight must be a non-negative number.")
341 )
342 fuel_added_after_unit = (f.get("fuel_added_after_unit") or "L").strip()
344 fuel_remaining_qty_raw = (f.get("fuel_remaining_qty") or "").strip()
345 fuel_remaining_qty: float | None = None
346 if fuel_remaining_qty_raw:
347 try:
348 fuel_remaining_qty = float(fuel_remaining_qty_raw)
349 if not math.isfinite(fuel_remaining_qty) or fuel_remaining_qty < 0:
350 raise ValueError
351 except (ValueError, TypeError):
352 fuel_remaining_qty = None
353 errors.append(_("Fuel remaining must be a non-negative number."))
355 oil_added_before_l_raw = (f.get("oil_added_before_l") or "").strip()
356 oil_added_before_l: float | None = None
357 if oil_added_before_l_raw:
358 try:
359 oil_added_before_l = float(oil_added_before_l_raw)
360 if not math.isfinite(oil_added_before_l) or oil_added_before_l < 0:
361 raise ValueError
362 except (ValueError, TypeError):
363 oil_added_before_l = None
364 errors.append(
365 _("Oil added before the flight must be a non-negative number.")
366 )
368 oil_added_after_l_raw = (f.get("oil_added_after_l") or "").strip()
369 oil_added_after_l: float | None = None
370 if oil_added_after_l_raw:
371 try:
372 oil_added_after_l = float(oil_added_after_l_raw)
373 if not math.isfinite(oil_added_after_l) or oil_added_after_l < 0:
374 raise ValueError
375 except (ValueError, TypeError):
376 oil_added_after_l = None
377 errors.append(
378 _("Oil added after the flight must be a non-negative number.")
379 )
381 nature_of_flight = (f.get("nature_of_flight") or "").strip() or None
382 notes = (f.get("notes") or "").strip() or None
384 values: dict[str, Any] = {
385 "date": flight_date,
386 "departure_icao": dep,
387 "arrival_icao": arr,
388 "departure_time": departure_time,
389 "arrival_time": arrival_time,
390 "takeoff_time": takeoff_time,
391 "landing_time": landing_time,
392 "flight_time": flight_time,
393 "flight_time_counter_start": flight_time_counter_start,
394 "flight_time_counter_end": flight_time_counter_end,
395 "engine_time": engine_time,
396 "engine_time_counter_start": engine_time_counter_start,
397 "engine_time_counter_end": engine_time_counter_end,
398 "fuel_added_before_qty": fuel_added_before_qty,
399 "fuel_added_before_unit": fuel_added_before_unit
400 if fuel_added_before_qty is not None
401 else None,
402 "fuel_added_after_qty": fuel_added_after_qty,
403 "fuel_added_after_unit": fuel_added_after_unit
404 if fuel_added_after_qty is not None
405 else None,
406 "fuel_remaining_qty": fuel_remaining_qty,
407 "oil_added_before_l": oil_added_before_l,
408 "oil_added_after_l": oil_added_after_l,
409 "passenger_count": passenger_count,
410 "landing_count": landing_count,
411 "nature_of_flight": nature_of_flight,
412 "notes": notes,
413 "crew_name_0": crew_name_0,
414 "crew_role_0": crew_role_0_raw
415 if crew_role_0_raw in CrewRole.ALL
416 else CrewRole.PIC,
417 "crew_name_1": crew_name_1,
418 "crew_role_1": crew_role_1_raw
419 if crew_role_1_raw in CrewRole.ALL
420 else CrewRole.COPILOT,
421 }
422 return values, errors
425def apply_flight_fields(fe: Flight, values: dict[str, Any]) -> None:
426 """Assign parsed editable-field values onto ``fe``, including the crew
427 identity slots.
429 Mirrors ``_handle_log_flight_post``'s aircraft-log assignment exactly:
430 scalar fields are always overwritten. ``crew_name_0``/``crew_role_0``
431 (role fixed to PIC) write ``pic_name``; ``crew_name_1``/``crew_role_1``
432 write ``second_crew_name``/``second_crew_role`` — a blank name clears
433 the slot. Resolving either slot's ``*_user_id`` (matching the form's
434 submitter or another OpenHangar user into a slot) is the caller's job
435 in ``flights/routes.py``, not this shared field-parsing layer.
436 """
437 fe.date = values["date"]
438 fe.departure_icao = values["departure_icao"]
439 fe.arrival_icao = values["arrival_icao"]
440 fe.departure_time = values["departure_time"]
441 fe.arrival_time = values["arrival_time"]
442 fe.takeoff_time = values["takeoff_time"]
443 fe.landing_time = values["landing_time"]
444 fe.flight_time = values["flight_time"]
445 fe.nature_of_flight = values["nature_of_flight"]
446 fe.passenger_count = values["passenger_count"]
447 fe.landing_count = values["landing_count"]
448 fe.flight_time_counter_start = values["flight_time_counter_start"]
449 fe.flight_time_counter_end = values["flight_time_counter_end"]
450 fe.notes = values["notes"]
451 fe.engine_time = values["engine_time"]
452 fe.engine_time_counter_start = values["engine_time_counter_start"]
453 fe.engine_time_counter_end = values["engine_time_counter_end"]
454 fe.fuel_added_before_qty = values["fuel_added_before_qty"]
455 fe.fuel_added_before_unit = values["fuel_added_before_unit"]
456 fe.fuel_added_after_qty = values["fuel_added_after_qty"]
457 fe.fuel_added_after_unit = values["fuel_added_after_unit"]
458 fe.fuel_remaining_qty = values["fuel_remaining_qty"]
459 fe.oil_added_before_l = values["oil_added_before_l"]
460 fe.oil_added_after_l = values["oil_added_after_l"]
462 fe.pic_name = values["crew_name_0"] or None
463 if values["crew_name_1"]:
464 fe.second_crew_name = values["crew_name_1"]
465 fe.second_crew_role = values["crew_role_1"]
466 else:
467 fe.second_crew_name = None
468 fe.second_crew_role = None
470 db.session.flush()