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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-31 10:13 +0000
1"""Shared validation for MaintenanceTrigger and MaintenanceRecord fields.
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"""
10from __future__ import annotations
12import math
13from collections.abc import Mapping
14from datetime import date as _date
15from typing import Any
17from flask_babel import gettext as _ # pyright: ignore[reportMissingImports]
18from models import HoursBasis, TriggerType # pyright: ignore[reportMissingImports]
21def _parse_iso_date(raw: str) -> _date | None:
22 try:
23 return _date.fromisoformat(raw)
24 except ValueError:
25 return None
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
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
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
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
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
68def parse_trigger_fields(f: Mapping[str, str]) -> tuple[dict[str, Any], list[str]]:
69 """Parse + validate the editable MaintenanceTrigger fields.
71 Mirrors ``_save_trigger``'s pre-existing logic exactly.
72 """
73 errors: list[str] = []
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
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'."))
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."))
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 )
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."))
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."))
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 )
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
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.
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] = []
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
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)."))
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)
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)
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