Coverage for app/maintenance/form_parsing.py: 100%

146 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-31 10:13 +0000

1"""Shared validation for MaintenanceTrigger and MaintenanceRecord fields. 

2 

3``parse_trigger_fields``/``parse_service_fields`` extract the validation 

4previously inlined directly in ``_save_trigger``/``service_trigger`` in 

5maintenance/routes.py, following the same pattern as 

6flights/form_parsing.py and pilots/form_parsing.py: standalone, 

7importable functions that never raise on arbitrary form data. 

8""" 

9 

10from __future__ import annotations 

11 

12import math 

13from collections.abc import Mapping 

14from datetime import date as _date 

15from typing import Any 

16 

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

18from models import HoursBasis, TriggerType # pyright: ignore[reportMissingImports] 

19 

20 

21def _parse_iso_date(raw: str) -> _date | None: 

22 try: 

23 return _date.fromisoformat(raw) 

24 except ValueError: 

25 return None 

26 

27 

28def _parse_positive_int(raw: str) -> int | None: 

29 try: 

30 n = int(raw) 

31 except ValueError: 

32 return None 

33 return n if n > 0 else None 

34 

35 

36def _parse_nonneg_float(raw: str) -> float | None: 

37 try: 

38 v = float(raw) 

39 except ValueError: 

40 return None 

41 return v if math.isfinite(v) and v >= 0 else None 

42 

43 

44def _parse_nonneg_int(raw: str) -> int | None: 

45 try: 

46 n = int(raw) 

47 except ValueError: 

48 return None 

49 return n if n >= 0 else None 

50 

51 

52def _parse_positive_float(raw: str) -> float | None: 

53 try: 

54 v = float(raw) 

55 except ValueError: 

56 return None 

57 return v if math.isfinite(v) and v > 0 else None 

58 

59 

60def _parse_optional_float(raw: str) -> float | None: 

61 try: 

62 v = float(raw) 

63 except ValueError: 

64 return None 

65 return v if math.isfinite(v) else None 

66 

67 

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

69 """Parse + validate the editable MaintenanceTrigger fields. 

70 

71 Mirrors ``_save_trigger``'s pre-existing logic exactly. 

72 """ 

73 errors: list[str] = [] 

74 

75 name = (f.get("name") or "").strip() 

76 trigger_type = (f.get("trigger_type") or "").strip() 

77 component_id_raw = (f.get("component_id") or "").strip() 

78 due_date_raw = (f.get("due_date") or "").strip() 

79 interval_days_raw = (f.get("interval_days") or "").strip() 

80 warn_days_raw = (f.get("warn_days") or "").strip() 

81 due_engine_hours_raw = (f.get("due_engine_hours") or "").strip() 

82 interval_hours_raw = (f.get("interval_hours") or "").strip() 

83 warn_hours_raw = (f.get("warn_hours") or "").strip() 

84 hours_basis_raw = (f.get("hours_basis") or "").strip() 

85 due_landings_raw = (f.get("due_landings") or "").strip() 

86 interval_landings_raw = (f.get("interval_landings") or "").strip() 

87 warn_landings_raw = (f.get("warn_landings") or "").strip() 

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

89 

90 if not name: 

91 errors.append(_("Name is required.")) 

92 if trigger_type not in TriggerType.ALL: 

93 errors.append(_("Trigger type must be 'calendar', 'hours', or 'landings'.")) 

94 

95 # Ownership (does this ID actually belong to the aircraft?) is checked by 

96 # the caller, which has the aircraft in scope — this parser only knows 

97 # whether the value looks like an ID at all. 

98 component_id: int | None = None 

99 if component_id_raw: 

100 component_id = _parse_positive_int(component_id_raw) 

101 if component_id is None: 

102 errors.append(_("Component selection is invalid.")) 

103 

104 due_date = interval_days = warn_days = due_engine_hours = interval_hours = None 

105 warn_hours = due_landings = interval_landings = warn_landings = None 

106 hours_basis = ( 

107 hours_basis_raw if hours_basis_raw in HoursBasis.ALL else HoursBasis.ENGINE 

108 ) 

109 

110 if trigger_type == TriggerType.CALENDAR: 

111 if not due_date_raw: 

112 errors.append(_("Due date is required for calendar triggers.")) 

113 else: 

114 due_date = _parse_iso_date(due_date_raw) 

115 if due_date is None: 

116 errors.append(_("Due date must be a valid date (YYYY-MM-DD).")) 

117 if interval_days_raw: 

118 interval_days = _parse_positive_int(interval_days_raw) 

119 if interval_days is None: 

120 errors.append(_("Interval (days) must be a positive integer.")) 

121 if warn_days_raw: 

122 warn_days = _parse_nonneg_int(warn_days_raw) 

123 if warn_days is None: 

124 errors.append(_("Warning lead time (days) must be a positive number.")) 

125 

126 elif trigger_type == TriggerType.HOURS: 

127 if not due_engine_hours_raw: 

128 errors.append(_("Due engine hours is required for hours triggers.")) 

129 else: 

130 due_engine_hours = _parse_nonneg_float(due_engine_hours_raw) 

131 if due_engine_hours is None: 

132 errors.append(_("Due engine hours must be a positive number.")) 

133 if interval_hours_raw: 

134 interval_hours = _parse_positive_float(interval_hours_raw) 

135 if interval_hours is None: 

136 errors.append(_("Interval (hours) must be a positive number.")) 

137 if warn_hours_raw: 

138 warn_hours = _parse_nonneg_float(warn_hours_raw) 

139 if warn_hours is None: 

140 errors.append(_("Warning lead time (hours) must be a positive number.")) 

141 

142 elif trigger_type == TriggerType.LANDINGS: 

143 if not due_landings_raw: 

144 errors.append(_("Due landings is required for landings triggers.")) 

145 else: 

146 due_landings = _parse_nonneg_int(due_landings_raw) 

147 if due_landings is None: 

148 errors.append(_("Due landings must be a positive number.")) 

149 if interval_landings_raw: 

150 interval_landings = _parse_positive_int(interval_landings_raw) 

151 if interval_landings is None: 

152 errors.append(_("Interval (landings) must be a positive number.")) 

153 if warn_landings_raw: 

154 warn_landings = _parse_nonneg_int(warn_landings_raw) 

155 if warn_landings is None: 

156 errors.append( 

157 _("Warning lead time (landings) must be a positive number.") 

158 ) 

159 

160 values: dict[str, Any] = { 

161 "name": name, 

162 "trigger_type": trigger_type, 

163 "component_id": component_id, 

164 "due_date": due_date, 

165 "interval_days": interval_days, 

166 "warn_days": warn_days, 

167 "due_engine_hours": due_engine_hours, 

168 "interval_hours": interval_hours, 

169 "warn_hours": warn_hours, 

170 "hours_basis": hours_basis, 

171 "due_landings": due_landings, 

172 "interval_landings": interval_landings, 

173 "warn_landings": warn_landings, 

174 "notes": notes, 

175 } 

176 return values, errors 

177 

178 

179def parse_service_fields( 

180 f: Mapping[str, str], requires_hobbs: bool, requires_landings: bool 

181) -> tuple[dict[str, Any], list[str]]: 

182 """Parse + validate the editable MaintenanceRecord (service) fields. 

183 

184 ``requires_hobbs``/``requires_landings`` reflect which due-field groups 

185 are actually populated on the trigger being serviced (``due_engine_hours 

186 is not None`` / ``due_landings is not None``) rather than its 

187 ``trigger_type`` — a combined-interval trigger (Phase 40) can have more 

188 than one group populated at once, and each populated group's reading is 

189 required; an unpopulated group's reading stays optional (parsed 

190 opportunistically if provided, ignored otherwise). 

191 """ 

192 errors: list[str] = [] 

193 

194 performed_raw = (f.get("performed_at") or "").strip() 

195 hobbs_raw = (f.get("hobbs_at_service") or "").strip() 

196 landings_raw = (f.get("landings_at_service") or "").strip() 

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

198 

199 performed_at: _date | None = None 

200 if not performed_raw: 

201 errors.append(_("Service date is required.")) 

202 else: 

203 performed_at = _parse_iso_date(performed_raw) 

204 if performed_at is None: 

205 errors.append(_("Service date must be a valid date (YYYY-MM-DD).")) 

206 

207 hobbs_at_service: float | None = None 

208 if requires_hobbs: 

209 if not hobbs_raw: 

210 errors.append(_("Hobbs at service is required for hours-based triggers.")) 

211 else: 

212 hobbs_at_service = _parse_nonneg_float(hobbs_raw) 

213 if hobbs_at_service is None: 

214 errors.append(_("Hobbs at service must be a positive number.")) 

215 elif hobbs_raw: 

216 hobbs_at_service = _parse_optional_float(hobbs_raw) 

217 

218 landings_at_service: int | None = None 

219 if requires_landings: 

220 if not landings_raw: 

221 errors.append( 

222 _("Landings at service is required for landings-based triggers.") 

223 ) 

224 else: 

225 landings_at_service = _parse_nonneg_int(landings_raw) 

226 if landings_at_service is None: 

227 errors.append(_("Landings at service must be a positive number.")) 

228 elif landings_raw: 

229 landings_at_service = _parse_nonneg_int(landings_raw) 

230 

231 values: dict[str, Any] = { 

232 "performed_at": performed_at, 

233 "hobbs_at_service": hobbs_at_service, 

234 "landings_at_service": landings_at_service, 

235 "notes": notes, 

236 } 

237 return values, errors