"""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 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"] payload = { "from": row["from_iata"], "to": row["to_iata"], "departure": f"{flight_date_str}T00:00:00.000+00:00", "datePrecision": "day", "flightNumber": flight_number or None, "airline": row.get("operating_carrier_icao"), "aircraft": None, "aircraftReg": None, "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()), } ], } 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: return _request("POST", "/api/flight/save", payload) 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"