Coverage for app/pilots/currency.py: 100%
111 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-28 23:33 +0000
1from datetime import date as _date, timedelta
2from typing import Any
4WINDOW_DAYS = 90
5PASSENGER_REQUIRED = 3 # FCL.060(b)(1): 3 take-offs/landings (any) in 90 days
6NIGHT_REQUIRED = 1 # FCL.060(b)(2): 1 night landing in 90 days
7EXPIRY_WARN_DAYS = 90 # warn when medical/SEP expires within this many days
8CURRENCY_WARN_DAYS = 30 # warn when landing currency expires within this many days
10STATUS_OK = "ok"
11STATUS_WARNING = "warning"
12STATUS_EXPIRED = "expired"
13STATUS_UNKNOWN = "unknown"
16def _rolling_landing_currency(
17 entries: Any, landing_fields: str | tuple[str, ...], required: int, today: _date
18) -> dict[str, Any]:
19 """Compute rolling currency for one or more landing fields summed together."""
20 if isinstance(landing_fields, str):
21 landing_fields = (landing_fields,)
23 def _count(e: Any) -> int:
24 return sum(getattr(e, f) or 0 for f in landing_fields)
26 window_start = today - timedelta(days=WINDOW_DAYS)
27 qualifying = [e for e in entries if e.date >= window_start and _count(e) > 0]
28 qualifying.sort(key=lambda e: (e.date, e.id), reverse=True)
30 total = sum(_count(e) for e in qualifying)
31 shortfall = max(0, required - total)
33 if total >= required:
34 cum = 0
35 anchor_date: _date | None = None
36 for e in qualifying:
37 cum += _count(e)
38 if cum >= required:
39 anchor_date = e.date
40 break
41 assert anchor_date is not None # nosec B101 # mypy narrowing invariant
42 expires_on = anchor_date + timedelta(days=WINDOW_DAYS)
43 days_left = (expires_on - today).days
44 status = STATUS_WARNING if days_left <= CURRENCY_WARN_DAYS else STATUS_OK
45 else:
46 expires_on = None
47 days_left = None
48 status = STATUS_EXPIRED if qualifying else STATUS_UNKNOWN
50 return {
51 "count": total,
52 "required": required,
53 "status": status,
54 "expires_on": expires_on,
55 "days_left": days_left,
56 "shortfall": shortfall,
57 }
60def _expiry_status(
61 expiry_date: _date | None, today: _date, warn_days: int
62) -> tuple[str, int | None]:
63 if expiry_date is None:
64 return STATUS_UNKNOWN, None
65 days = (expiry_date - today).days
66 if days < 0:
67 return STATUS_EXPIRED, days
68 if days <= warn_days:
69 return STATUS_WARNING, days
70 return STATUS_OK, days
73def per_type_currency(entries: Any, today: _date | None = None) -> dict[str, Any]:
74 """Rolling 90-day landing currency grouped by ICAO aircraft type.
76 EASA FCL.060 per type:
77 - Passenger carry: 3 landings (day OR night combined) in 90 days.
78 - Night passenger carry: 1 night landing in 90 days.
80 Entries whose aircraft_type_icao is blank are resolved on-the-fly from
81 aircraft_type via resolve_aircraft_type_icao(). Those that still cannot be
82 resolved are tallied in unresolved_count so the UI can surface a warning.
84 Returns::
86 {
87 "by_type": {
88 "C172": {"passenger": {...}, "night": {...}, "status": "ok"},
89 "P28A": {"passenger": {...}, "night": {...}, "status": "warning"},
90 },
91 "unresolved_count": 3,
92 }
93 """
94 if today is None:
95 today = _date.today()
97 from utils import resolve_aircraft_type_icao # pyright: ignore[reportMissingImports]
99 buckets: dict[str, list[Any]] = {}
100 unresolved_count = 0
102 for entry in entries:
103 icao: str | None = getattr(entry, "aircraft_type_icao", None) or None
104 if not icao:
105 icao = resolve_aircraft_type_icao(getattr(entry, "aircraft_type", None))
106 if not icao:
107 unresolved_count += 1
108 continue
109 buckets.setdefault(icao, []).append(entry)
111 by_type: dict[str, dict[str, Any]] = {}
112 for icao, type_entries in sorted(buckets.items()):
113 pax = _rolling_landing_currency(
114 type_entries,
115 ("landings_day", "landings_night"),
116 PASSENGER_REQUIRED,
117 today,
118 )
119 night = _rolling_landing_currency(
120 type_entries, "landings_night", NIGHT_REQUIRED, today
121 )
122 statuses = [pax["status"], night["status"]]
123 if STATUS_EXPIRED in statuses:
124 status = STATUS_EXPIRED
125 elif STATUS_WARNING in statuses:
126 status = STATUS_WARNING
127 elif STATUS_UNKNOWN in statuses:
128 status = STATUS_UNKNOWN
129 else:
130 status = STATUS_OK
131 by_type[icao] = {"passenger": pax, "night": night, "status": status}
133 return {"by_type": by_type, "unresolved_count": unresolved_count}
136def passenger_currency(entries: Any, today: _date | None = None) -> dict[str, Any]:
137 """3 landings (day or night) in rolling 90-day window (EASA FCL.060 passenger carry)."""
138 if today is None:
139 today = _date.today()
140 return _rolling_landing_currency(
141 entries, ("landings_day", "landings_night"), PASSENGER_REQUIRED, today
142 )
145def night_currency(entries: Any, today: _date | None = None) -> dict[str, Any]:
146 """1 night landing in rolling 90-day window (EASA FCL.060 night passenger carry)."""
147 if today is None:
148 today = _date.today()
149 return _rolling_landing_currency(entries, "landings_night", NIGHT_REQUIRED, today)
152def medical_status(profile: Any, today: _date | None = None) -> dict[str, Any]:
153 if today is None:
154 today = _date.today()
155 expiry = profile.medical_expiry if profile else None
156 status, days = _expiry_status(expiry, today, EXPIRY_WARN_DAYS)
157 return {"expiry": expiry, "status": status, "days_remaining": days}
160def sep_status(profile: Any, today: _date | None = None) -> dict[str, Any]:
161 if today is None:
162 today = _date.today()
163 expiry = profile.sep_expiry if profile else None
164 status, days = _expiry_status(expiry, today, EXPIRY_WARN_DAYS)
165 return {"expiry": expiry, "status": status, "days_remaining": days}
168def currency_summary(
169 profile: Any, entries: Any, today: _date | None = None
170) -> dict[str, Any] | None:
171 """
172 Aggregate all currency checks. Returns None if profile is None.
173 """
174 if profile is None:
175 return None
176 if today is None:
177 today = _date.today()
179 pax = passenger_currency(entries, today)
180 nite = night_currency(entries, today)
181 med = medical_status(profile, today)
182 sep = sep_status(profile, today)
183 per_type = per_type_currency(entries, today)
185 statuses = [pax["status"], nite["status"], med["status"], sep["status"]]
186 if STATUS_EXPIRED in statuses:
187 overall = STATUS_EXPIRED
188 elif STATUS_WARNING in statuses or STATUS_UNKNOWN in statuses:
189 overall = STATUS_WARNING
190 else:
191 overall = STATUS_OK
193 return {
194 "passenger": pax,
195 "night": nite,
196 "medical": med,
197 "sep": sep,
198 "per_type": per_type,
199 "overall": overall,
200 }