Coverage for app/pilots/form_parsing.py: 100%
143 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 standalone Flight (pilot-only, aircraft_id NULL)
2editable field set.
4``parse_pilot_fields`` / ``apply_pilot_fields`` are used by both the online
5pilot-logbook form (``new_entry`` / ``edit_entry`` in ``pilots/routes.py``)
6and the offline sync API (``offline/routes.py``) so the two paths can never
7diverge. The field set matches
8``offline.serialize.PILOT_EDITABLE_FIELDS`` exactly — the full,
9standalone-entry set. A *linked* entry (aircraft_id set) only ever exposes
10``PILOT_LINKED_EDITABLE_FIELDS``, handled separately by
11``apply_pilot_identity`` in ``flights/routes.py``.
12"""
14import math
15from collections.abc import Mapping
16from datetime import date as _date
17from datetime import time as _time
18from typing import Any
20from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
21from models import ( # pyright: ignore[reportMissingImports]
22 Flight,
23 FstdType,
24 LogbookEntryType,
25)
28def _parse_time(val: str, field: str) -> tuple[_time | None, str | None]:
29 val = val.strip()
30 if not val:
31 return None, None
32 try:
33 h, m = val.split(":")
34 t = _time(int(h), int(m))
35 return t, None
36 except (ValueError, AttributeError, OverflowError):
37 # int(h)/int(m) accept arbitrary-precision digit strings, but
38 # datetime.time() is C-backed and raises OverflowError (not
39 # ValueError) once the value no longer fits a C long — e.g. an
40 # hour string of 20+ digits.
41 return None, _("%(field)s: enter a valid HH:MM time.", field=field)
44def _parse_decimal(val: str, field: str) -> tuple[float | None, str | None]:
45 val = val.strip()
46 if not val:
47 return None, None
48 try:
49 n = float(val)
50 if not math.isfinite(n):
51 return None, _("%(field)s: must be a number.", field=field)
52 if n < 0:
53 return None, _("%(field)s: must be non-negative.", field=field)
54 return n, None
55 except ValueError:
56 return None, _("%(field)s: must be a number.", field=field)
59def _parse_int(val: str, field: str) -> tuple[int | None, str | None]:
60 val = val.strip()
61 if not val:
62 return None, None
63 try:
64 n = int(val)
65 if n < 0:
66 return None, _("%(field)s: must be non-negative.", field=field)
67 return n, None
68 except ValueError:
69 return None, _("%(field)s: must be a whole number.", field=field)
72def _parse_date(val: str, field: str) -> tuple[_date | None, str | None]:
73 val = val.strip()
74 if not val:
75 return None, None
76 try:
77 return _date.fromisoformat(val), None
78 except ValueError:
79 return None, _("%(field)s: enter a valid date (YYYY-MM-DD).", field=field)
82def parse_pilot_fields(f: Mapping[str, str]) -> tuple[dict[str, Any], list[str]]:
83 """Parse + validate the editable standalone PilotLogbookEntry fields.
85 Mirrors ``_entry_from_form``'s existing logic exactly: date required/ISO;
86 times ``HH:MM``; decimals/ints non-negative; the FSTD toggle nulls
87 flight-only fields when ``entry_type == "fstd"`` and nulls
88 ``fstd_type``/``fstd_duration`` otherwise.
89 """
90 errors: list[str] = []
92 entry_type = (f.get("entry_type") or "").strip() or LogbookEntryType.FLIGHT
93 if entry_type not in LogbookEntryType.ALL:
94 entry_type = LogbookEntryType.FLIGHT
95 is_fstd = entry_type == LogbookEntryType.FSTD
97 fstd_type = (f.get("fstd_type") or "").strip() or None
98 if fstd_type not in FstdType.ALL:
99 fstd_type = None
100 fstd_duration, err = _parse_decimal(f.get("fstd_duration", ""), "Sim duration")
101 if err:
102 errors.append(err)
104 date_val, err = _parse_date(f.get("date", ""), "Date")
105 if err:
106 errors.append(err)
107 elif date_val is None:
108 from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
110 errors.append(_("Date is required."))
112 dep_time, err = _parse_time(f.get("departure_time", ""), "Departure time")
113 if err:
114 errors.append(err)
115 arr_time, err = _parse_time(f.get("arrival_time", ""), "Arrival time")
116 if err:
117 errors.append(err)
118 takeoff_time, err = _parse_time(f.get("takeoff_time", ""), "Takeoff time")
119 if err:
120 errors.append(err)
121 landing_time, err = _parse_time(f.get("landing_time", ""), "Landing time")
122 if err:
123 errors.append(err)
125 night_time, err = _parse_decimal(f.get("night_time", ""), "Night time")
126 if err:
127 errors.append(err)
128 instrument_time, err = _parse_decimal(
129 f.get("instrument_time", ""), "Instrument time"
130 )
131 if err:
132 errors.append(err)
133 landings_day, err = _parse_int(f.get("landings_day", ""), "Day landings")
134 if err:
135 errors.append(err)
136 landings_night, err = _parse_int(f.get("landings_night", ""), "Night landings")
137 if err:
138 errors.append(err)
139 sp_se, err = _parse_decimal(f.get("single_pilot_se", ""), "S/E time")
140 if err:
141 errors.append(err)
142 sp_me, err = _parse_decimal(f.get("single_pilot_me", ""), "M/E time")
143 if err:
144 errors.append(err)
145 multi_pilot, err = _parse_decimal(f.get("multi_pilot", ""), "Multi-pilot time")
146 if err:
147 errors.append(err)
148 fn_pic, err = _parse_decimal(f.get("function_pic", ""), "PIC function")
149 if err:
150 errors.append(err)
151 fn_co, err = _parse_decimal(f.get("function_copilot", ""), "Co-pilot function")
152 if err:
153 errors.append(err)
154 fn_dual, err = _parse_decimal(f.get("function_dual", ""), "Dual function")
155 if err:
156 errors.append(err)
157 fn_inst, err = _parse_decimal(
158 f.get("function_instructor", ""), "Instructor function"
159 )
160 if err:
161 errors.append(err)
163 values: dict[str, Any] = {
164 "date": date_val,
165 "other_aircraft_type": None
166 if is_fstd
167 else (f.get("aircraft_type") or "").strip() or None,
168 "other_aircraft_type_icao": None
169 if is_fstd
170 else (f.get("aircraft_type_icao") or "").strip() or None,
171 "other_aircraft_registration": None
172 if is_fstd
173 else (f.get("aircraft_registration") or "").strip() or None,
174 "departure_icao": None
175 if is_fstd
176 else (f.get("departure_place") or "").strip() or None,
177 "departure_time": None if is_fstd else dep_time,
178 "arrival_icao": None
179 if is_fstd
180 else (f.get("arrival_place") or "").strip() or None,
181 "arrival_time": None if is_fstd else arr_time,
182 "takeoff_time": None if is_fstd else takeoff_time,
183 "landing_time": None if is_fstd else landing_time,
184 "pic_name": (f.get("pic_name") or "").strip() or None,
185 "night_time": night_time,
186 "instrument_time": instrument_time,
187 "landings_day": None if is_fstd else landings_day,
188 "landings_night": None if is_fstd else landings_night,
189 "single_pilot_se": None if is_fstd else sp_se,
190 "single_pilot_me": None if is_fstd else sp_me,
191 "multi_pilot": None if is_fstd else multi_pilot,
192 "function_pic": fn_pic,
193 "function_copilot": fn_co,
194 "function_dual": fn_dual,
195 "function_instructor": fn_inst,
196 "notes": (f.get("remarks") or "").strip() or None,
197 "entry_type": entry_type,
198 "fstd_type": fstd_type if is_fstd else None,
199 "fstd_duration": fstd_duration if is_fstd else None,
200 }
201 return values, errors
204def parse_linked_pilot_fields(f: Mapping[str, str]) -> tuple[dict[str, Any], list[str]]:
205 """Parse + validate the user-entered subset of a *linked* PilotLogbookEntry.
207 Field set matches ``offline.serialize.PILOT_LINKED_EDITABLE_FIELDS``. An
208 empty ``departure_time``/``arrival_time`` parses to ``None``, meaning
209 "mirror the flight's corresponding time" — resolved by the caller
210 (``flights.routes.apply_linked_pilot_entry``) against the current flight.
211 """
212 errors: list[str] = []
213 night_time, err = _parse_decimal(f.get("night_time", ""), "Night time")
214 if err:
215 errors.append(err)
216 instrument_time, err = _parse_decimal(
217 f.get("instrument_time", ""), "Instrument time"
218 )
219 if err:
220 errors.append(err)
221 landings_day, err = _parse_int(f.get("landings_day", ""), "Day landings")
222 if err:
223 errors.append(err)
224 landings_night, err = _parse_int(f.get("landings_night", ""), "Night landings")
225 if err:
226 errors.append(err)
227 multi_pilot, err = _parse_decimal(f.get("multi_pilot", ""), "Multi-pilot time")
228 if err:
229 errors.append(err)
230 dep_time, err = _parse_time(f.get("departure_time", ""), "Departure time")
231 if err:
232 errors.append(err)
233 arr_time, err = _parse_time(f.get("arrival_time", ""), "Arrival time")
234 if err:
235 errors.append(err)
237 values: dict[str, Any] = {
238 "night_time": night_time,
239 "instrument_time": instrument_time,
240 "landings_day": landings_day,
241 "landings_night": landings_night,
242 "multi_pilot": multi_pilot,
243 "pic_name": (f.get("pic_name") or "").strip() or None,
244 "departure_time": dep_time,
245 "arrival_time": arr_time,
246 }
247 return values, errors
250def apply_pilot_fields(entry: Flight, values: dict[str, Any]) -> None:
251 """Assign parsed editable-field values onto ``entry`` (a standalone
252 Flight row, aircraft_id NULL).
254 Mirrors ``edit_entry``'s pre-existing behaviour exactly: ``cross_country``
255 has no form field anywhere, so it is always nulled on a standalone save
256 (this matches copying *all* table columns from a freshly-built entry,
257 which is what the online form did before this extraction).
258 """
259 for key, value in values.items():
260 setattr(entry, key, value)
261 entry.cross_country = None