Coverage for app/pilots/personal_minimums.py: 100%
32 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"""
2Personal minimums — starter templates and recency-nudge computation.
4See docs/backlog.md "Pilots: personal minimums" for the implementation-ready
5spec. Routes live in pilots/routes.py; this module holds the non-route logic
6so the starter content and the recency math can be unit tested in isolation.
7"""
9import math
10from datetime import date
11from typing import Any
13from flask_babel import lazy_gettext as _l # pyright: ignore[reportMissingImports]
14from models import ( # pyright: ignore[reportMissingImports]
15 PersonalMinimumsRevision,
16 PersonalMinimumsStatus,
17 PersonalMinimumsTag,
18)
21def get_active_revision(uid: int) -> PersonalMinimumsRevision | None:
22 revision: PersonalMinimumsRevision | None = (
23 PersonalMinimumsRevision.query.filter_by(
24 user_id=uid, status=PersonalMinimumsStatus.ACTIVE
25 ).first()
26 )
27 return revision
30# Each starter is a list of (section_title, [(item_label, tag), ...]).
31# Values are intentionally left blank — the starter gives structure, not
32# prescriptive numbers; the pilot fills in their own via the edit form.
33# Labels are lazy-translated so pybabel's static extractor can see them
34# despite living in a plain data structure rather than a template; callers
35# must str() them at request time (see pilots.routes._create_starter_revision).
36STARTER_LIGHT: list[tuple[object, list[tuple[object, str | None]]]] = [
37 (
38 _l("Winds"),
39 [
40 (_l("Max surface wind"), None),
41 (_l("Max wind / gust differential"), None),
42 (_l("Max crosswind component"), None),
43 ],
44 ),
45 (
46 _l("Weather"),
47 [
48 (_l("Minimum ceiling, day"), None),
49 (_l("Minimum ceiling, night"), None),
50 (_l("Minimum visibility, day"), None),
51 (_l("Minimum visibility, night"), None),
52 ],
53 ),
54 (
55 _l("Fuel"),
56 [
57 (
58 _l("Fuel reserve at landing (minutes)"),
59 PersonalMinimumsTag.MIN_FUEL_RESERVE_MINUTES,
60 ),
61 ],
62 ),
63]
65STARTER_FULL: list[tuple[object, list[tuple[object, str | None]]]] = [
66 *STARTER_LIGHT,
67 (
68 _l("Guiding principles"),
69 [
70 (_l("Credo"), None),
71 (_l("Flight-plan / route commitments"), None),
72 ],
73 ),
74 (
75 _l("Pre-flight checklists"),
76 [
77 (_l("PAVE (Pilot, Aircraft, enVironment, External pressures)"), None),
78 (
79 _l("I'M SAFE (Illness, Medication, Stress, Alcohol, Fatigue, Emotion)"),
80 None,
81 ),
82 ],
83 ),
84 (
85 _l("Ceilings by mission profile"),
86 [
87 (_l("Pattern work"), None),
88 (_l("Local (< 50 nm)"), None),
89 (_l("Short cross-country (< 100 nm)"), None),
90 (_l("Long cross-country (> 100 nm)"), None),
91 ],
92 ),
93 (
94 _l("Performance"),
95 [
96 (_l("Cruise altitude without oxygen, max"), None),
97 (_l("Minimum runway length at unfamiliar fields"), None),
98 ],
99 ),
100 (
101 _l("Night flying rules"),
102 [
103 (_l("Night flying commitments"), None),
104 ],
105 ),
106 (
107 _l("Decision-making rules"),
108 [
109 (_l("Three-strikes rule — pre-flight NO-GO"), None),
110 (_l("Three-strikes rule — in-flight TERMINATE"), None),
111 ],
112 ),
113 (
114 _l("Recency commitments"),
115 [
116 (
117 _l("Manoeuvres practice interval (months)"),
118 PersonalMinimumsTag.MANOEUVRES_PRACTICE_INTERVAL_MONTHS,
119 ),
120 (
121 _l("Familiar airports only after (days without flying)"),
122 PersonalMinimumsTag.MAX_DAYS_SINCE_LAST_FLIGHT,
123 ),
124 (
125 _l("Instructor flight after (days without flying)"),
126 PersonalMinimumsTag.MAX_DAYS_SINCE_INSTRUCTOR_FLIGHT,
127 ),
128 ],
129 ),
130 (
131 _l("Adjustments"),
132 [
133 (_l("If fatigued / unfamiliar aircraft / unfamiliar airport"), None),
134 ],
135 ),
136]
138STARTERS = {"light": STARTER_LIGHT, "full": STARTER_FULL}
141def recency_breaches(revision: object, pilot_user_id: int) -> list[dict[str, Any]]:
142 """Return a list of {item, days_since, threshold} for every item on
143 `revision` tagged with a recency-checkable tag whose threshold has been
144 exceeded. Only MAX_DAYS_SINCE_LAST_FLIGHT and
145 MAX_DAYS_SINCE_INSTRUCTOR_FLIGHT are automatically checkable in v1."""
146 from models import Flight # pyright: ignore[reportMissingImports]
147 from sqlalchemy import or_ # pyright: ignore[reportMissingImports]
149 breaches = []
150 for section in revision.sections: # type: ignore[attr-defined]
151 for item in section.items:
152 if item.semantic_tag not in PersonalMinimumsTag.HAS_RECENCY_CHECK:
153 continue
154 if item.numeric_value is None:
155 continue
156 threshold = float(item.numeric_value)
157 if not math.isfinite(threshold):
158 # Defense in depth: pilots/routes.py's _validate_tag_and_numeric
159 # already rejects a non-finite numeric_value on write, but this
160 # column has no DB-level schema enforcement — a corrupted or
161 # otherwise-written value must degrade to "can't check this
162 # item" (int(threshold) below would raise OverflowError for
163 # inf), not crash the dashboard/notification check.
164 continue
165 # function_dual is always the second_crew_* slot's own hours (see
166 # Flight's docstring), so "last dual/instructor flight" must
167 # match this pilot specifically as the second-crew occupant, not
168 # just any row they appear on.
169 query = Flight.query.filter(
170 or_(
171 Flight.pic_user_id == pilot_user_id,
172 Flight.second_crew_user_id == pilot_user_id,
173 )
174 )
175 if (
176 item.semantic_tag
177 == PersonalMinimumsTag.MAX_DAYS_SINCE_INSTRUCTOR_FLIGHT
178 ):
179 query = query.filter(
180 Flight.second_crew_user_id == pilot_user_id,
181 Flight.function_dual > 0,
182 )
183 last_entry = query.order_by(Flight.date.desc()).first()
184 days_since = (
185 (date.today() - last_entry.date).days
186 if last_entry is not None
187 else None
188 )
189 if days_since is None or days_since > threshold:
190 breaches.append(
191 {
192 "item": item,
193 "days_since": days_since,
194 "threshold": int(threshold),
195 }
196 )
197 return breaches