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

1"""Shared validation for the unified Flight editable field set (airframe side). 

2 

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""" 

8 

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 

24 

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) 

35 

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 

44 

45 

46def _parse_clock_time(raw: str) -> _time: 

47 """Parse an HH:MM(:SS) UTC clock time. 

48 

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 

59 

60 

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 

70 

71 

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 ) 

81 

82 

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. 

87 

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. 

92 

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] = [] 

101 

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).")) 

111 

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.")) 

118 

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.")) 

125 

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).")) 

140 

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).")) 

157 

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 

188 

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 ) 

205 

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) 

217 

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 ) 

239 

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) 

265 

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) 

269 

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 ) 

291 

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.")) 

302 

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.")) 

313 

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() 

329 

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() 

343 

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.")) 

354 

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 ) 

367 

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 ) 

380 

381 nature_of_flight = (f.get("nature_of_flight") or "").strip() or None 

382 notes = (f.get("notes") or "").strip() or None 

383 

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 

423 

424 

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. 

428 

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"] 

461 

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 

469 

470 db.session.flush()