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

1""" 

2Personal minimums — starter templates and recency-nudge computation. 

3 

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

8 

9import math 

10from datetime import date 

11from typing import Any 

12 

13from flask_babel import lazy_gettext as _l # pyright: ignore[reportMissingImports] 

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

15 PersonalMinimumsRevision, 

16 PersonalMinimumsStatus, 

17 PersonalMinimumsTag, 

18) 

19 

20 

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 

28 

29 

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] 

64 

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] 

137 

138STARTERS = {"light": STARTER_LIGHT, "full": STARTER_FULL} 

139 

140 

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] 

148 

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