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

1"""Shared validation for the standalone Flight (pilot-only, aircraft_id NULL) 

2editable field set. 

3 

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

13 

14import math 

15from collections.abc import Mapping 

16from datetime import date as _date 

17from datetime import time as _time 

18from typing import Any 

19 

20from flask_babel import gettext as _ # pyright: ignore[reportMissingImports] 

21from models import ( # pyright: ignore[reportMissingImports] 

22 Flight, 

23 FstdType, 

24 LogbookEntryType, 

25) 

26 

27 

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) 

42 

43 

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) 

57 

58 

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) 

70 

71 

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) 

80 

81 

82def parse_pilot_fields(f: Mapping[str, str]) -> tuple[dict[str, Any], list[str]]: 

83 """Parse + validate the editable standalone PilotLogbookEntry fields. 

84 

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

91 

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 

96 

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) 

103 

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] 

109 

110 errors.append(_("Date is required.")) 

111 

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) 

124 

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) 

162 

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 

202 

203 

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. 

206 

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) 

236 

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 

248 

249 

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

253 

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