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