Files
boarding-pass/airtrail_client.py
T

278 lines
12 KiB
Python

"""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": "<USER_ID>",
"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"