fill aircraft type/registration and terminal/gate from AeroDataBox

This commit is contained in:
2026-09-15 22:58:54 +10:00
parent 480b896d39
commit 4ed4dd1ec2
6 changed files with 391 additions and 29 deletions
+68 -26
View File
@@ -43,6 +43,7 @@ wheels-up/touchdown).
import json
import re
import urllib.error
import urllib.parse
import urllib.request
from datetime import date, datetime
@@ -90,6 +91,64 @@ def _utc_offset(local: str | None) -> str | None:
return f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else None
def _get(url: str):
"""GET an AeroDataBox endpoint and return the decoded JSON, or None for
a "no content" answer. Raises AeroDataBoxError for anything retryable.
A default urllib request (no User-Agent) gets HTTP 403 from AeroDataBox's
Cloudflare-fronted gateway even with a perfectly valid, subscribed key -
confirmed live, side by side, same key/URL: bare urllib -> 403, same
request + this UA -> 200 with real data. See config.BROWSER_USER_AGENT."""
req = urllib.request.Request(
url,
headers={
"x-rapidapi-key": config.AERODATABOX_API_KEY,
"User-Agent": config.BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
if resp.status == 204:
return None
raw = resp.read()
except urllib.error.HTTPError as e:
if e.code == 204:
return None
raise AeroDataBoxError(f"HTTP {e.code} from AeroDataBox") from e
except urllib.error.URLError as e:
raise AeroDataBoxError(f"error reaching AeroDataBox: {e}") from e
if not raw:
return None
try:
return json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e
def lookup_aircraft_icao(registration: str) -> str | None:
"""Registration (e.g. 'VH-8VE') -> ICAO type code (e.g. 'B38M').
Costs one extra API unit per flight. Needed because AirTrail's
`aircraft` field is matched against the `icao` column of its own
aircraft table (getAircraftByIcao, confirmed in its source), so the
plain model string AeroDataBox puts on the flight record
('Boeing 737 MAX 8') is not something AirTrail can resolve."""
if not config.AERODATABOX_API_KEY or not registration:
return None
data = _get(f"{BASE_URL}/aircrafts/reg/{urllib.parse.quote(registration)}")
if not isinstance(data, dict):
return None
# AirTrail tries model before icaoCode against the same column, so both
# are worth passing on in that order.
for key in ("icaoCode", "model"):
value = (data.get(key) or "").strip()
if value:
return value
return None
def _first_offset(leg: dict) -> str | None:
"""The UTC offset for one leg, from whichever of its timestamps exists.
All of a leg's times share the same airport and day, so any of them
@@ -126,35 +185,10 @@ def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None:
return None
cleaned = _sanitize(flight_number)
url = (
data = _get(
f"{BASE_URL}/flights/number/{cleaned}/{flight_date.isoformat()}"
"?dateLocalRole=Both&withAircraftImage=false&withLocation=false"
)
# A default urllib request (no User-Agent) gets HTTP 403 from AeroDataBox's
# Cloudflare-fronted gateway even with a perfectly valid, subscribed key -
# confirmed live, side by side, same key/URL: bare urllib -> 403, same
# request + this UA -> 200 with real data. See config.BROWSER_USER_AGENT.
req = urllib.request.Request(
url,
headers={
"x-rapidapi-key": config.AERODATABOX_API_KEY,
"User-Agent": config.BROWSER_USER_AGENT,
},
)
try:
with urllib.request.urlopen(req, timeout=20) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
if e.code == 204:
return None
raise AeroDataBoxError(f"HTTP {e.code} from AeroDataBox") from e
except urllib.error.URLError as e:
raise AeroDataBoxError(f"error reaching AeroDataBox: {e}") from e
try:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e
if not isinstance(data, list) or not data:
return None
@@ -191,4 +225,12 @@ def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None:
# into the same airport-local terms as everything else here.
"departureUtcOffset": _first_offset(dep),
"arrivalUtcOffset": _first_offset(arr),
# Straight off the flight record, no extra call. aircraftReg is a
# free-text field in AirTrail; the ICAO *type* code needs a separate
# registration lookup (see lookup_aircraft_icao).
"aircraftReg": ((flight.get("aircraft") or {}).get("reg") or "").strip() or None,
"departureTerminal": (dep.get("terminal") or "").strip() or None,
"departureGate": (dep.get("gate") or "").strip() or None,
"arrivalTerminal": (arr.get("terminal") or "").strip() or None,
"arrivalGate": (arr.get("gate") or "").strip() or None,
}
+29 -2
View File
@@ -131,8 +131,14 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict:
"datePrecision": "day",
"flightNumber": flight_number or None,
"airline": row.get("operating_carrier_icao"),
"aircraft": None,
"aircraftReg": None,
# 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": [
@@ -171,6 +177,15 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict:
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")
@@ -192,7 +207,19 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict:
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]:
+259
View File
@@ -0,0 +1,259 @@
diff --git a/aerodatabox.py b/aerodatabox.py
index 5764b82..94accf3 100644
--- a/aerodatabox.py
+++ b/aerodatabox.py
@@ -43,6 +43,7 @@ wheels-up/touchdown).
import json
import re
import urllib.error
+import urllib.parse
import urllib.request
from datetime import date, datetime
@@ -90,6 +91,64 @@ def _utc_offset(local: str | None) -> str | None:
return f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else None
+def _get(url: str):
+ """GET an AeroDataBox endpoint and return the decoded JSON, or None for
+ a "no content" answer. Raises AeroDataBoxError for anything retryable.
+
+ A default urllib request (no User-Agent) gets HTTP 403 from AeroDataBox's
+ Cloudflare-fronted gateway even with a perfectly valid, subscribed key -
+ confirmed live, side by side, same key/URL: bare urllib -> 403, same
+ request + this UA -> 200 with real data. See config.BROWSER_USER_AGENT."""
+ req = urllib.request.Request(
+ url,
+ headers={
+ "x-rapidapi-key": config.AERODATABOX_API_KEY,
+ "User-Agent": config.BROWSER_USER_AGENT,
+ },
+ )
+ try:
+ with urllib.request.urlopen(req, timeout=20) as resp:
+ if resp.status == 204:
+ return None
+ raw = resp.read()
+ except urllib.error.HTTPError as e:
+ if e.code == 204:
+ return None
+ raise AeroDataBoxError(f"HTTP {e.code} from AeroDataBox") from e
+ except urllib.error.URLError as e:
+ raise AeroDataBoxError(f"error reaching AeroDataBox: {e}") from e
+
+ if not raw:
+ return None
+ try:
+ return json.loads(raw.decode("utf-8"))
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
+ raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e
+
+
+def lookup_aircraft_icao(registration: str) -> str | None:
+ """Registration (e.g. 'VH-8VE') -> ICAO type code (e.g. 'B38M').
+
+ Costs one extra API unit per flight. Needed because AirTrail's
+ `aircraft` field is matched against the `icao` column of its own
+ aircraft table (getAircraftByIcao, confirmed in its source), so the
+ plain model string AeroDataBox puts on the flight record
+ ('Boeing 737 MAX 8') is not something AirTrail can resolve."""
+ if not config.AERODATABOX_API_KEY or not registration:
+ return None
+
+ data = _get(f"{BASE_URL}/aircrafts/reg/{urllib.parse.quote(registration)}")
+ if not isinstance(data, dict):
+ return None
+ # AirTrail tries model before icaoCode against the same column, so both
+ # are worth passing on in that order.
+ for key in ("icaoCode", "model"):
+ value = (data.get(key) or "").strip()
+ if value:
+ return value
+ return None
+
+
def _first_offset(leg: dict) -> str | None:
"""The UTC offset for one leg, from whichever of its timestamps exists.
All of a leg's times share the same airport and day, so any of them
@@ -126,35 +185,10 @@ def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None:
return None
cleaned = _sanitize(flight_number)
- url = (
+ data = _get(
f"{BASE_URL}/flights/number/{cleaned}/{flight_date.isoformat()}"
"?dateLocalRole=Both&withAircraftImage=false&withLocation=false"
)
- # A default urllib request (no User-Agent) gets HTTP 403 from AeroDataBox's
- # Cloudflare-fronted gateway even with a perfectly valid, subscribed key -
- # confirmed live, side by side, same key/URL: bare urllib -> 403, same
- # request + this UA -> 200 with real data. See config.BROWSER_USER_AGENT.
- req = urllib.request.Request(
- url,
- headers={
- "x-rapidapi-key": config.AERODATABOX_API_KEY,
- "User-Agent": config.BROWSER_USER_AGENT,
- },
- )
- try:
- with urllib.request.urlopen(req, timeout=20) as resp:
- raw = resp.read()
- except urllib.error.HTTPError as e:
- if e.code == 204:
- return None
- raise AeroDataBoxError(f"HTTP {e.code} from AeroDataBox") from e
- except urllib.error.URLError as e:
- raise AeroDataBoxError(f"error reaching AeroDataBox: {e}") from e
-
- try:
- data = json.loads(raw.decode("utf-8"))
- except (json.JSONDecodeError, UnicodeDecodeError) as e:
- raise AeroDataBoxError(f"bad AeroDataBox response: {e}") from e
if not isinstance(data, list) or not data:
return None
@@ -191,4 +225,12 @@ def lookup_gate_times(flight_number: str, flight_date: date) -> dict | None:
# into the same airport-local terms as everything else here.
"departureUtcOffset": _first_offset(dep),
"arrivalUtcOffset": _first_offset(arr),
+ # Straight off the flight record, no extra call. aircraftReg is a
+ # free-text field in AirTrail; the ICAO *type* code needs a separate
+ # registration lookup (see lookup_aircraft_icao).
+ "aircraftReg": ((flight.get("aircraft") or {}).get("reg") or "").strip() or None,
+ "departureTerminal": (dep.get("terminal") or "").strip() or None,
+ "departureGate": (dep.get("gate") or "").strip() or None,
+ "arrivalTerminal": (arr.get("terminal") or "").strip() or None,
+ "arrivalGate": (arr.get("gate") or "").strip() or None,
}
diff --git a/airtrail_client.py b/airtrail_client.py
index afc19b8..aaf138a 100644
--- a/airtrail_client.py
+++ b/airtrail_client.py
@@ -131,8 +131,14 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict:
"datePrecision": "day",
"flightNumber": flight_number or None,
"airline": row.get("operating_carrier_icao"),
- "aircraft": None,
- "aircraftReg": None,
+ # 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": [
@@ -171,6 +177,15 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict:
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")
@@ -192,7 +207,19 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict:
def save_flight(payload: dict) -> dict:
- return _request("POST", "/api/flight/save", payload)
+ 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]:
diff --git a/db.py b/db.py
index 7a5e551..5db1d96 100644
--- a/db.py
+++ b/db.py
@@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS pending_flights (
gate_arrival_actual TEXT,
departure_utc_offset TEXT,
arrival_utc_offset TEXT,
+ aircraft_reg TEXT,
+ aircraft_icao TEXT,
+ departure_terminal TEXT,
+ departure_gate TEXT,
+ arrival_terminal TEXT,
+ arrival_gate TEXT,
schedule_error TEXT,
airtrail_flight_id INTEGER,
parse_warnings TEXT,
@@ -90,6 +96,12 @@ MIGRATIONS = [
("gate_arrival_actual", "TEXT"),
("departure_utc_offset", "TEXT"),
("arrival_utc_offset", "TEXT"),
+ ("aircraft_reg", "TEXT"),
+ ("aircraft_icao", "TEXT"),
+ ("departure_terminal", "TEXT"),
+ ("departure_gate", "TEXT"),
+ ("arrival_terminal", "TEXT"),
+ ("arrival_gate", "TEXT"),
("schedule_error", "TEXT"),
]
diff --git a/scheduler.py b/scheduler.py
index 4f3cb08..ad309c7 100644
--- a/scheduler.py
+++ b/scheduler.py
@@ -104,6 +104,22 @@ def _fetch_gate_times(flight_number: str, flight_date: date, updates: dict) -> N
# AeroDataBox came up empty.
updates["takeoff_actual"] = gate_times.get("takeoffActual")
updates["landing_actual"] = gate_times.get("landingActual")
+ updates["departure_terminal"] = gate_times.get("departureTerminal")
+ updates["departure_gate"] = gate_times.get("departureGate")
+ updates["arrival_terminal"] = gate_times.get("arrivalTerminal")
+ updates["arrival_gate"] = gate_times.get("arrivalGate")
+
+ registration = gate_times.get("aircraftReg")
+ updates["aircraft_reg"] = registration
+ updates["aircraft_icao"] = None
+ if registration:
+ # One extra API unit, and only worth spending once we actually have a
+ # registration to look up. A failure here is not worth losing the rest
+ # of the lookup over - the registration alone still reaches AirTrail.
+ try:
+ updates["aircraft_icao"] = aerodatabox.lookup_aircraft_icao(registration)
+ except aerodatabox.AeroDataBoxError as e:
+ log.warning("aircraft type lookup failed for %s: %s", registration, e)
def process_one_flight(row: dict) -> None:
diff --git a/templates/review_detail.html b/templates/review_detail.html
index b2d7202..d4dffd7 100644
--- a/templates/review_detail.html
+++ b/templates/review_detail.html
@@ -73,6 +73,12 @@
{% if flight.landing_actual %}
<dt>Landing (actual)</dt><dd>{{ flight.landing_actual }}</dd>
{% endif %}
+ {% if flight.aircraft_reg or flight.aircraft_icao %}
+ <dt>Aircraft</dt><dd>{{ flight.aircraft_icao or '—' }} · {{ flight.aircraft_reg or '—' }}</dd>
+ {% endif %}
+ {% if flight.departure_terminal or flight.departure_gate or flight.arrival_terminal or flight.arrival_gate %}
+ <dt>Terminal / gate</dt><dd>{{ flight.departure_terminal or '—' }}/{{ flight.departure_gate or '—' }} &rarr; {{ flight.arrival_terminal or '—' }}/{{ flight.arrival_gate or '—' }}</dd>
+ {% endif %}
{% if flight.departure_utc_offset or flight.arrival_utc_offset %}
<dt>Airport UTC offsets</dt><dd>{{ flight.departure_utc_offset or '—' }} &rarr; {{ flight.arrival_utc_offset or '—' }}</dd>
{% endif %}
+12
View File
@@ -41,6 +41,12 @@ CREATE TABLE IF NOT EXISTS pending_flights (
gate_arrival_actual TEXT,
departure_utc_offset TEXT,
arrival_utc_offset TEXT,
aircraft_reg TEXT,
aircraft_icao TEXT,
departure_terminal TEXT,
departure_gate TEXT,
arrival_terminal TEXT,
arrival_gate TEXT,
schedule_error TEXT,
airtrail_flight_id INTEGER,
parse_warnings TEXT,
@@ -90,6 +96,12 @@ MIGRATIONS = [
("gate_arrival_actual", "TEXT"),
("departure_utc_offset", "TEXT"),
("arrival_utc_offset", "TEXT"),
("aircraft_reg", "TEXT"),
("aircraft_icao", "TEXT"),
("departure_terminal", "TEXT"),
("departure_gate", "TEXT"),
("arrival_terminal", "TEXT"),
("arrival_gate", "TEXT"),
("schedule_error", "TEXT"),
]
+16
View File
@@ -104,6 +104,22 @@ def _fetch_gate_times(flight_number: str, flight_date: date, updates: dict) -> N
# AeroDataBox came up empty.
updates["takeoff_actual"] = gate_times.get("takeoffActual")
updates["landing_actual"] = gate_times.get("landingActual")
updates["departure_terminal"] = gate_times.get("departureTerminal")
updates["departure_gate"] = gate_times.get("departureGate")
updates["arrival_terminal"] = gate_times.get("arrivalTerminal")
updates["arrival_gate"] = gate_times.get("arrivalGate")
registration = gate_times.get("aircraftReg")
updates["aircraft_reg"] = registration
updates["aircraft_icao"] = None
if registration:
# One extra API unit, and only worth spending once we actually have a
# registration to look up. A failure here is not worth losing the rest
# of the lookup over - the registration alone still reaches AirTrail.
try:
updates["aircraft_icao"] = aerodatabox.lookup_aircraft_icao(registration)
except aerodatabox.AeroDataBoxError as e:
log.warning("aircraft type lookup failed for %s: %s", registration, e)
def process_one_flight(row: dict) -> None:
+6
View File
@@ -73,6 +73,12 @@
{% if flight.landing_actual %}
<dt>Landing (actual)</dt><dd>{{ flight.landing_actual }}</dd>
{% endif %}
{% if flight.aircraft_reg or flight.aircraft_icao %}
<dt>Aircraft</dt><dd>{{ flight.aircraft_icao or '—' }} · {{ flight.aircraft_reg or '—' }}</dd>
{% endif %}
{% if flight.departure_terminal or flight.departure_gate or flight.arrival_terminal or flight.arrival_gate %}
<dt>Terminal / gate</dt><dd>{{ flight.departure_terminal or '—' }}/{{ flight.departure_gate or '—' }} &rarr; {{ flight.arrival_terminal or '—' }}/{{ flight.arrival_gate or '—' }}</dd>
{% endif %}
{% if flight.departure_utc_offset or flight.arrival_utc_offset %}
<dt>Airport UTC offsets</dt><dd>{{ flight.departure_utc_offset or '—' }} &rarr; {{ flight.arrival_utc_offset or '—' }}</dd>
{% endif %}