217 lines
8.2 KiB
Python
217 lines
8.2 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 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"],
|
|
# 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.
|
|
"departure": row.get("gate_departure_actual") or 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": "<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"]
|
|
if row.get("gate_arrival_scheduled"):
|
|
payload["arrivalScheduled"] = row["gate_arrival_scheduled"]
|
|
if row.get("gate_arrival_actual"):
|
|
payload["arrival"] = row["gate_arrival_actual"]
|
|
if row.get("takeoff_actual"):
|
|
payload["takeoffActual"] = row["takeoff_actual"]
|
|
if row.get("landing_actual"):
|
|
payload["landingActual"] = row["landing_actual"]
|
|
|
|
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"
|