Files
boarding-pass/airtrail_client.py
T
bsncubed 408fde440d Initial commit: boarding pass to AirTrail pipeline
Photograph/screenshot a boarding pass, decode its BCBP barcode (PDF417/
Aztec/QR/DataMatrix), fetch the historical flight track from FlightAware
the day after the flight, stage it for review, and write it into AirTrail
via its REST API on approval. ntfy notifications carry signed
Approve/Deny/Investigate actions.
2026-07-04 22:21:02 +10:00

197 lines
7.0 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"],
"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": "<USER_ID>",
"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"