"""Client for AirTrail's REST API (POST /api/flight/save, GET /api/flight/list). Confirmed directly against AirTrail's source (johanohly/AirTrail): - src/routes/api/flight/save/+server.ts - src/lib/zod/flight.ts, src/lib/track/schema.ts, src/lib/db/types.ts Deliberately does NOT touch AirTrail's Postgres database directly - the documented API does everything this pipeline needs (including embedded track data in one call) without needing DB credentials or trusting a schema snapshot that could drift. """ import json import urllib.error import urllib.request from datetime import date, datetime, timezone import config import models import notifier # IATA cabin compartment codes -> AirTrail SeatClasses ('economy', 'economy+', # 'business', 'first', 'private'). Unmapped codes are left null rather than # guessed. COMPARTMENT_TO_SEATCLASS = { "F": "first", "A": "first", "P": "first", "J": "business", "C": "business", "D": "business", "I": "business", "Z": "business", "W": "economy+", "S": "economy+", "Y": "economy", "B": "economy", "H": "economy", "K": "economy", "L": "economy", "M": "economy", "N": "economy", "Q": "economy", "T": "economy", "V": "economy", "X": "economy", "G": "economy", "U": "economy", "O": "economy", "E": "economy", } class AirTrailError(Exception): pass def _request(method: str, path: str, body: dict | None = None) -> dict: if not config.AIRTRAIL_BASE_URL or not config.AIRTRAIL_API_KEY: raise AirTrailError("AIRTRAIL_BASE_URL / AIRTRAIL_API_KEY not configured") url = f"{config.AIRTRAIL_BASE_URL}{path}" data = json.dumps(body).encode("utf-8") if body is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Authorization", f"Bearer {config.AIRTRAIL_API_KEY}") req.add_header("Content-Type", "application/json") try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode("utf-8")) except urllib.error.HTTPError as e: try: payload = json.loads(e.read().decode("utf-8")) message = payload.get("message") or payload.get("errors") or str(payload) except Exception: message = f"HTTP {e.code}" raise AirTrailError(f"AirTrail API error ({e.code}): {message}") from e except urllib.error.URLError as e: raise AirTrailError(f"failed to reach AirTrail: {e}") from e def list_my_flights() -> list[dict]: result = _request("GET", "/api/flight/list?scope=mine") return result.get("flights", []) def find_existing_flight(flight_number: str, flight_date: date) -> dict | None: if not flight_number: return None target = flight_date.isoformat() for flight in list_my_flights(): if (flight.get("flightNumber") or "").strip().upper() != flight_number.strip().upper(): continue departure = flight.get("departure") if departure and str(departure)[:10] == target: return flight return None def downsample_track( coordinates: list[list[float]], times: list[int] | None, max_points: int = config.MAX_TRACK_POINTS ) -> tuple[list[list[float]], list[int] | None]: n = len(coordinates) if n <= max_points: return coordinates, times indices = [round(i * (n - 1) / (max_points - 1)) for i in range(max_points)] new_coords = [coordinates[i] for i in indices] new_times = [times[i] for i in indices] if times else None return new_coords, new_times def _hhmm(iso_str: str | None) -> str | None: """Extract 'HH:MM' from one of our seconds-precision ISO datetime strings (e.g. '2026-09-13T13:57:00+10:00' -> '13:57'). AirTrail does NOT read the time-of-day out of departure/arrival/ departureScheduled/etc. at all - confirmed against its source (src/lib/server/utils/flight.ts + src/lib/utils/datetime/parse.ts's mergeTimeWithDate()): it takes only the DATE portion of that field and merges it with a separate companion '*Time' field (departureTime, arrivalTime, departureScheduledTime, ...) for the actual clock time, interpreted in the airport's own timezone. Sending a fully-populated ISO datetime without its companion Time field means AirTrail silently drops the time for departure/departureScheduled/etc., and outright rejects arrival with "Cannot have arrival date without time" - both confirmed live. Every date field this module sets below needs its matching *Time companion or the value doesn't actually land.""" if not iso_str: return None return iso_str[11:16] def build_save_payload(row: dict, existing_id: int | None) -> dict: flight_number = f"{row.get('operating_carrier_iata') or ''}{row.get('flight_number') or ''}" flight_date_str = row["flight_date"] departure_value = row.get("gate_departure_actual") or f"{flight_date_str}T00:00:00.000+00:00" payload = { "from": row["from_iata"], "to": row["to_iata"], # Falls back to the bare date (day precision, no time) exactly like # before if AeroDataBox never returned a gate-actual time - e.g. no # AERODATABOX_API_KEY configured, or the lookup came up empty. No # departureTime is set in that case, which AirTrail treats the same # way (silently date-only) rather than erroring - unlike arrival. "departure": departure_value, "departureTime": _hhmm(row.get("gate_departure_actual")), "datePrecision": "day", "flightNumber": flight_number or None, "airline": row.get("operating_carrier_icao"), # ICAO *type* code (e.g. 'B38M'); AirTrail validates it against its # own aircraft table and 500s the whole save if it doesn't know it, # so save_flight() drops it and retries rather than losing the import. "aircraft": row.get("aircraft_icao"), "aircraftReg": row.get("aircraft_reg"), # Seat position and flight reason are deliberately left unset - the # BCBP carries neither and no API knows them, so they stay a manual # choice in AirTrail rather than a guess made here. "flightReason": None, "note": f"Imported via boarding-pass-pipeline (PNR {row.get('pnr') or 'unknown'})", "seats": [ { "userId": "", "guestName": None, "seat": None, "seatNumber": row.get("seat"), "seatClass": COMPARTMENT_TO_SEATCLASS.get((row.get("compartment") or "").upper()), } ], } # Scheduled/actual gate times from AeroDataBox (scheduler._fetch_gate_times), # and actual takeoff/landing derived from the FlightAware track's first/last # timestamp (scheduler.process_one_flight). Any of these can be missing - # e.g. no AERODATABOX_API_KEY set, the lookup came up empty, or no track was # found - in which case the field is simply omitted and AirTrail's own # manual Search remains the fallback, same as before this change. if row.get("gate_departure_scheduled"): payload["departureScheduled"] = row["gate_departure_scheduled"] payload["departureScheduledTime"] = _hhmm(row["gate_departure_scheduled"]) if row.get("gate_arrival_scheduled"): payload["arrivalScheduled"] = row["gate_arrival_scheduled"] payload["arrivalScheduledTime"] = _hhmm(row["gate_arrival_scheduled"]) if row.get("gate_arrival_actual"): # Unlike departure, AirTrail hard-rejects an arrival date with no # arrivalTime ("Cannot have arrival date without time") rather than # silently dropping it - this pairing is not optional. payload["arrival"] = row["gate_arrival_actual"] payload["arrivalTime"] = _hhmm(row["gate_arrival_actual"]) if row.get("takeoff_actual"): payload["takeoffActual"] = row["takeoff_actual"] payload["takeoffActualTime"] = _hhmm(row["takeoff_actual"]) if row.get("landing_actual"): payload["landingActual"] = row["landing_actual"] payload["landingActualTime"] = _hhmm(row["landing_actual"]) for payload_key, row_key in ( ("departureTerminal", "departure_terminal"), ("departureGate", "departure_gate"), ("arrivalTerminal", "arrival_terminal"), ("arrivalGate", "arrival_gate"), ): if row.get(row_key): payload[payload_key] = row[row_key] coordinates = row.get("track_coordinates") if coordinates: times = row.get("track_times") coordinates, times = downsample_track(coordinates, times) payload["track"] = { "coordinates": coordinates, "sourceFormat": "kml", "sourceName": f"{flight_number} {flight_date_str}".strip(), } if times: payload["track"]["times"] = times else: payload["track"] = None if existing_id is not None: payload["id"] = existing_id return payload def save_flight(payload: dict) -> dict: try: return _request("POST", "/api/flight/save", payload) except AirTrailError as e: # AirTrail resolves `aircraft` against the `icao` column of its own # aircraft table and fails the ENTIRE save if the type code isn't # there (apiError('Invalid aircraft'), HTTP 500). The type is a # nicety; the flight, its times and its track are not. Drop it and # retry once rather than losing the import over an unknown variant. if "Invalid aircraft" in str(e) and payload.get("aircraft"): retry = dict(payload) retry["aircraft"] = None return _request("POST", "/api/flight/save", retry) raise def approve_flight(flight_id: int) -> tuple[bool, str]: """Idempotent: shared by the review page's Approve button and the ntfy-callback route, so both paths behave identically.""" row = models.get_flight(flight_id) if not row: return False, "flight not found" if row["status"] in ("approved", "rejected"): return True, f"already {row['status']}" existing_id = None try: flight_date = date.fromisoformat(row["flight_date"]) existing = find_existing_flight( f"{row.get('operating_carrier_iata') or ''}{row.get('flight_number') or ''}", flight_date, ) existing_id = existing["id"] if existing else None payload = build_save_payload(row, existing_id) result = save_flight(payload) if not result.get("success"): raise AirTrailError(result.get("message", "unknown AirTrail error")) except Exception as e: message = str(e) # A failed ntfy-triggered approve has no UI to surface this to. notifier.get_notifier().send( title=f"Approve failed: {row.get('flight_number') or 'flight'}", body=message[:200], url=notifier.flight_url(flight_id), ) return False, message models.update_flight( flight_id, status="approved", approved_at=datetime.now(timezone.utc).isoformat(), airtrail_flight_id=result.get("id") or existing_id, ) return True, "approved" def reject_flight(flight_id: int) -> tuple[bool, str]: """Idempotent: shared by the review page's Reject button and the ntfy-callback route.""" row = models.get_flight(flight_id) if not row: return False, "flight not found" if row["status"] in ("approved", "rejected"): return True, f"already {row['status']}" models.update_flight( flight_id, status="rejected", reviewed_at=datetime.now(timezone.utc).isoformat() ) return True, "rejected"