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.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user