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,7 @@
|
|||||||
|
instance/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Public URL this service is reached at through NPMplus/Authelia. Required
|
||||||
|
# for ntfy notification links and the Approve/Deny callback URLs to work.
|
||||||
|
PUBLIC_BASE_URL=https://boarding-pass.example.com
|
||||||
|
|
||||||
|
# AirTrail instance and API key (create the key in AirTrail's account UI).
|
||||||
|
AIRTRAIL_BASE_URL=https://airtrail.example.com
|
||||||
|
AIRTRAIL_API_KEY=
|
||||||
|
|
||||||
|
# Container timezone. Set explicitly - flight_date + 1 day and the daily
|
||||||
|
# job's run time are date-sensitive.
|
||||||
|
TZ=Australia/Sydney
|
||||||
|
|
||||||
|
# Max boarding pass photo upload size, in MB.
|
||||||
|
MAX_UPLOAD_MB=15
|
||||||
|
|
||||||
|
PORT=8080
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
instance/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV INSTANCE_DIR=/app/instance
|
||||||
|
VOLUME ["/app/instance"]
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# Status: build complete
|
||||||
|
|
||||||
|
All 13 tasks are done. This file is now just a pointer, not a live handover -
|
||||||
|
see `README.md` for setup/deploy instructions and
|
||||||
|
`/home/ben/.claude/plans/glittery-weaving-moth.md` for the original design
|
||||||
|
rationale (AirTrail REST API choice, FlightAware two-step scrape, schema).
|
||||||
|
|
||||||
|
## What was verified, inside a real Docker build
|
||||||
|
|
||||||
|
- Built the image (`docker build .`) and ran it standalone.
|
||||||
|
- Uploaded a synthetic PDF417 boarding pass photo through `/upload` →
|
||||||
|
decoded → parsed → correctly staged as `queued` with ICAO codes resolved
|
||||||
|
(SYD→YSSY, SIN→WSSS, QF→QFA).
|
||||||
|
- A photo with no barcode correctly landed on `decode_failed` with an error
|
||||||
|
message, not a crash.
|
||||||
|
- `/flights/<id>/requery` hit real FlightAware for a real past flight
|
||||||
|
(QFA1, 2026-06-22) and got back the same 1834-point track verified earlier
|
||||||
|
in the session — end-to-end scrape confirmed working from inside the
|
||||||
|
container.
|
||||||
|
- The review detail page renders the track into a Leaflet map via
|
||||||
|
`data-track`.
|
||||||
|
- ntfy notifications fire with correctly signed Approve/Deny URLs; both
|
||||||
|
wrong and missing tokens are correctly rejected with 403.
|
||||||
|
- Approve, with no AirTrail configured, failed gracefully and pushed a
|
||||||
|
distinct "Approve failed" ntfy notification rather than crashing or
|
||||||
|
silently losing the flight — matches the design in `airtrail_client.py`.
|
||||||
|
- Reject works and is idempotent.
|
||||||
|
- The edit form re-resolves ICAO codes and `process_after` when IATA
|
||||||
|
codes/date are corrected.
|
||||||
|
|
||||||
|
## What's NOT verified (no live AirTrail instance available here)
|
||||||
|
|
||||||
|
- An actual `POST /api/flight/save` against a real AirTrail instance has
|
||||||
|
never been exercised. The payload shape matches AirTrail's source
|
||||||
|
(`build_save_payload` in `airtrail_client.py`), but the
|
||||||
|
`seat`/`seatNumber`/`seatClass` field semantics are a best-effort mapping,
|
||||||
|
not confirmed against real AirTrail behavior. **First real approve should
|
||||||
|
be checked against the AirTrail UI** to confirm the flight, seat, and
|
||||||
|
track show up as expected.
|
||||||
|
- HEIC photos from iPhones weren't tested (only a synthetic JPEG barcode was
|
||||||
|
used here). Should be fine since browsers typically convert to JPEG on
|
||||||
|
`<input type=file>`, but worth confirming on a real device.
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# boarding-pass-pipeline
|
||||||
|
|
||||||
|
Photograph a boarding pass → decode its BCBP (IATA Resolution 792) PDF417
|
||||||
|
barcode → wait until the day after the flight → fetch the historical flight
|
||||||
|
track from FlightAware's public site → stage it for manual review → notify
|
||||||
|
via ntfy with Approve/Deny/Investigate actions → on approval, write the
|
||||||
|
flight (with track) into [AirTrail](https://github.com/johanohly/AirTrail)
|
||||||
|
via its REST API.
|
||||||
|
|
||||||
|
Not part of any other microservice stack. Single container, SQLite for all
|
||||||
|
local state, no task queue — a background thread handles the daily job.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill in `PUBLIC_BASE_URL`,
|
||||||
|
`AIRTRAIL_BASE_URL`, `AIRTRAIL_API_KEY`, and `TZ`.
|
||||||
|
|
||||||
|
To get an AirTrail API key: log into your AirTrail instance → Account →
|
||||||
|
API Keys → create a new key. This pipeline authenticates to AirTrail as
|
||||||
|
that user and writes flights on their behalf.
|
||||||
|
|
||||||
|
2. Create the external Docker network shared with NPMplus, if it doesn't
|
||||||
|
already exist:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker network create proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
(or edit `docker-compose.yml` to point at whatever network your NPMplus
|
||||||
|
container is already on.)
|
||||||
|
|
||||||
|
3. Build and start:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Add a proxy host in NPMplus pointing at `boarding-pass:8080`, behind
|
||||||
|
Authelia, **except** for the two paths below (see "Approve/Deny callback
|
||||||
|
paths").
|
||||||
|
|
||||||
|
5. Open the site, go to **Settings**, and configure your ntfy server/topic
|
||||||
|
(defaults to the public `ntfy.sh` — use a private topic name, or point at
|
||||||
|
a self-hosted ntfy with an auth token). Send a test notification to
|
||||||
|
confirm it arrives.
|
||||||
|
|
||||||
|
6. Go to **Upload** and photograph a boarding pass to confirm end-to-end
|
||||||
|
decoding works.
|
||||||
|
|
||||||
|
## Approve/Deny callback paths — read this before exposing the service
|
||||||
|
|
||||||
|
ntfy's action buttons fire a plain HTTP request from wherever the
|
||||||
|
notification is opened (phone, desktop) with **no Authelia session**. For
|
||||||
|
those buttons to work, `/flights/*/approve` and `/flights/*/reject` must
|
||||||
|
bypass the Authelia `auth_request` check in NPMplus. Add a location block
|
||||||
|
like this in the proxy host's advanced config:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location ~ ^/flights/[0-9]+/(approve|reject) {
|
||||||
|
# Bypasses Authelia for these two paths only.
|
||||||
|
proxy_pass http://boarding-pass:8080;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**This bypass applies to every request on that path, not just ntfy's** —
|
||||||
|
there is no way to distinguish "Authelia already checked this" from "anyone
|
||||||
|
on the internet hit this URL directly" once the bypass is in place. Because
|
||||||
|
of that, these two routes are protected only by a signed, expiring,
|
||||||
|
per-flight HMAC token (`tokens.py`) baked into the URL — including the
|
||||||
|
review page's own Approve/Reject buttons, which render a freshly-signed
|
||||||
|
token on every page load rather than relying on the surrounding page being
|
||||||
|
behind Authelia. Don't add a `?token=` bypass anywhere else, and don't widen
|
||||||
|
the nginx match beyond these two paths.
|
||||||
|
|
||||||
|
## Re-running the reference data build
|
||||||
|
|
||||||
|
`data/airports.csv` and `data/airlines.csv` are trimmed snapshots of
|
||||||
|
OurAirports/OpenFlights data, fetched once via `scripts/build_reference_data.py`
|
||||||
|
and committed. Re-run it if IATA/ICAO mappings go stale:
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 scripts/build_reference_data.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Known corrections in `reference_data.py`'s `CARRIER_ICAO_OVERRIDES` (e.g.
|
||||||
|
Virgin Australia VA → VOZ, where OpenFlights has a stale `VAU`) take priority
|
||||||
|
over the CSV and aren't touched by re-running the script.
|
||||||
|
|
||||||
|
## Testing the daily job on demand
|
||||||
|
|
||||||
|
The daily job normally runs once at `daily_run_time` (Settings page, default
|
||||||
|
03:00 container time). To trigger it immediately against whatever is queued:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec boarding-pass python -c "from scheduler import run_daily_job; run_daily_job()"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Uploaded photos are stored under `instance/uploads/`; the SQLite database
|
||||||
|
is `instance/boarding_pass.db`. Both are in the `instance/` volume — back
|
||||||
|
that directory up if you care about upload history.
|
||||||
|
- No OCR fallback: if barcode decoding fails, the flight is queued as
|
||||||
|
`decode_failed` and every field is editable on the review page instead.
|
||||||
|
- AirTrail integration only ever calls its documented REST API
|
||||||
|
(`/api/flight/save`, `/api/flight/list`) — it never touches AirTrail's
|
||||||
|
Postgres database directly.
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask, redirect, send_from_directory, url_for
|
||||||
|
from waitress import serve
|
||||||
|
|
||||||
|
import config
|
||||||
|
import db
|
||||||
|
import routes_review
|
||||||
|
import routes_settings
|
||||||
|
import routes_upload
|
||||||
|
import scheduler
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> Flask:
|
||||||
|
config.ensure_dirs()
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = config.MAX_UPLOAD_MB * 1024 * 1024
|
||||||
|
|
||||||
|
app.register_blueprint(routes_upload.bp)
|
||||||
|
app.register_blueprint(routes_review.bp)
|
||||||
|
app.register_blueprint(routes_settings.bp)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return redirect(url_for("upload.upload"))
|
||||||
|
|
||||||
|
@app.route("/uploads/<path:name>")
|
||||||
|
def uploaded_file(name):
|
||||||
|
return send_from_directory(config.UPLOADS_DIR, name)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
scheduler.start_background_thread()
|
||||||
|
serve(app, host="0.0.0.0", port=config.PORT)
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Parser for the mandatory fixed-width fields of an IATA Resolution 792
|
||||||
|
(BCBP) barcode string - the format encoded in PDF417 boarding pass barcodes.
|
||||||
|
|
||||||
|
Only the mandatory "unique" + first "repeated" (leg) field block is parsed
|
||||||
|
(offsets 0-59). Conditional/optional items beyond that are not needed for
|
||||||
|
this pipeline and are intentionally skipped.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
("format_code", 1),
|
||||||
|
("legs", 1),
|
||||||
|
("name", 20),
|
||||||
|
("eticket_indicator", 1),
|
||||||
|
("pnr", 7),
|
||||||
|
("from_iata", 3),
|
||||||
|
("to_iata", 3),
|
||||||
|
("operating_carrier", 3),
|
||||||
|
("flight_number", 5),
|
||||||
|
("julian_day", 3),
|
||||||
|
("compartment", 1),
|
||||||
|
("seat", 4),
|
||||||
|
("checkin_sequence", 5),
|
||||||
|
("passenger_status", 1),
|
||||||
|
("field_size_hex", 2),
|
||||||
|
]
|
||||||
|
MANDATORY_LENGTH = sum(length for _, length in FIELDS) # 60
|
||||||
|
|
||||||
|
_AIRPORT_RE = re.compile(r"^[A-Z]{3}$")
|
||||||
|
_SEAT_RE = re.compile(r"^\d{1,3}[A-Z]$")
|
||||||
|
|
||||||
|
|
||||||
|
class BCBPParseError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedBCBP:
|
||||||
|
raw: str
|
||||||
|
format_code: str = ""
|
||||||
|
passenger_name: str = ""
|
||||||
|
pnr: str = ""
|
||||||
|
from_iata: str = ""
|
||||||
|
to_iata: str = ""
|
||||||
|
operating_carrier: str = ""
|
||||||
|
flight_number: str = ""
|
||||||
|
julian_day: int | None = None
|
||||||
|
compartment: str = ""
|
||||||
|
seat: str = ""
|
||||||
|
warnings: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_leading_zeros(raw: str) -> str:
|
||||||
|
"""'0001 ' -> '1', '0025B' -> '25B'. Leaves non-numeric-prefixed values alone."""
|
||||||
|
s = raw.strip()
|
||||||
|
if not s:
|
||||||
|
return s
|
||||||
|
m = re.match(r"^(\d+)(.*)$", s)
|
||||||
|
if not m:
|
||||||
|
return s
|
||||||
|
digits, rest = m.groups()
|
||||||
|
digits = digits.lstrip("0") or "0"
|
||||||
|
return digits + rest.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_name(raw: str) -> str:
|
||||||
|
s = raw.strip()
|
||||||
|
if "/" in s:
|
||||||
|
last, _, first = s.partition("/")
|
||||||
|
return f"{last.strip()} {first.strip()}".strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bcbp(raw: str, upload_dt: datetime | None = None) -> ParsedBCBP:
|
||||||
|
if not raw or len(raw) < MANDATORY_LENGTH:
|
||||||
|
raise BCBPParseError(
|
||||||
|
f"barcode data too short ({len(raw) if raw else 0} chars, "
|
||||||
|
f"need at least {MANDATORY_LENGTH})"
|
||||||
|
)
|
||||||
|
|
||||||
|
mandatory = raw[:MANDATORY_LENGTH].upper()
|
||||||
|
offsets = {}
|
||||||
|
pos = 0
|
||||||
|
for name, length in FIELDS:
|
||||||
|
offsets[name] = mandatory[pos : pos + length]
|
||||||
|
pos += length
|
||||||
|
|
||||||
|
result = ParsedBCBP(raw=raw)
|
||||||
|
warnings = result.warnings
|
||||||
|
|
||||||
|
result.format_code = offsets["format_code"]
|
||||||
|
if result.format_code != "M":
|
||||||
|
warnings.append(f"unexpected format code {result.format_code!r} (expected 'M')")
|
||||||
|
|
||||||
|
result.passenger_name = _clean_name(offsets["name"])
|
||||||
|
result.pnr = offsets["pnr"].strip()
|
||||||
|
result.compartment = offsets["compartment"].strip()
|
||||||
|
|
||||||
|
from_iata = offsets["from_iata"].strip()
|
||||||
|
to_iata = offsets["to_iata"].strip()
|
||||||
|
if not _AIRPORT_RE.match(from_iata):
|
||||||
|
warnings.append(f"from_iata {from_iata!r} doesn't look like a valid IATA code")
|
||||||
|
if not _AIRPORT_RE.match(to_iata):
|
||||||
|
warnings.append(f"to_iata {to_iata!r} doesn't look like a valid IATA code")
|
||||||
|
result.from_iata = from_iata
|
||||||
|
result.to_iata = to_iata
|
||||||
|
|
||||||
|
result.operating_carrier = offsets["operating_carrier"].strip()
|
||||||
|
result.flight_number = _clean_leading_zeros(offsets["flight_number"])
|
||||||
|
|
||||||
|
seat = _clean_leading_zeros(offsets["seat"])
|
||||||
|
if seat and not _SEAT_RE.match(seat):
|
||||||
|
warnings.append(f"seat {seat!r} doesn't match the expected pattern")
|
||||||
|
result.seat = seat
|
||||||
|
|
||||||
|
julian_raw = offsets["julian_day"].strip()
|
||||||
|
try:
|
||||||
|
julian_day = int(julian_raw)
|
||||||
|
if not (1 <= julian_day <= 366):
|
||||||
|
raise ValueError
|
||||||
|
result.julian_day = julian_day
|
||||||
|
except ValueError:
|
||||||
|
warnings.append(f"julian_day {julian_raw!r} is not a valid day-of-year (1-366)")
|
||||||
|
result.julian_day = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_flight_date(julian_day: int, upload_dt: datetime) -> date:
|
||||||
|
"""BCBP encodes only a day-of-year, no year. Pick whichever of last/this/
|
||||||
|
next year's occurrence of that day-of-year is closest to the upload time -
|
||||||
|
handles both "photographed just before departure" and "photographed after
|
||||||
|
landing" cases."""
|
||||||
|
candidates = [
|
||||||
|
date(upload_dt.year + delta, 1, 1) + timedelta(days=julian_day - 1)
|
||||||
|
for delta in (-1, 0, 1)
|
||||||
|
]
|
||||||
|
return min(candidates, key=lambda d: abs((d - upload_dt.date()).days))
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Boarding Pass → AirTrail Pipeline
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A self-hosted service that lets me photograph a boarding pass on my phone,
|
||||||
|
stores it, and ~1 day after the flight lands automatically fetches the full
|
||||||
|
flight track from FlightAware and stages it for my approval before writing
|
||||||
|
into AirTrail (my self-hosted flight logbook at `airtrail.apointless.space`).
|
||||||
|
|
||||||
|
This is a **standalone project**, not part of Herespace. Ship it as a
|
||||||
|
**Docker container** (docker-compose for local dev/deploy). It can still
|
||||||
|
sit behind my existing Authelia instance via NPMplus forward-auth, but it
|
||||||
|
is not one of the Herespace microservices and shouldn't assume that
|
||||||
|
infrastructure beyond Authelia/NPMplus being available on the network.
|
||||||
|
|
||||||
|
## Conventions to follow
|
||||||
|
|
||||||
|
- Backend language/framework is open — pick whatever's the best fit for
|
||||||
|
the job (barcode decoding, image handling, scheduling, small web UI).
|
||||||
|
Python is a reasonable default given the barcode/image processing
|
||||||
|
libraries available, but not a hard requirement.
|
||||||
|
- Minimal/zero third-party dependencies where reasonably possible.
|
||||||
|
- SQLite for local state, unless there's a good reason otherwise.
|
||||||
|
- A scheduled job for the daily "check the queue" pass — in-container
|
||||||
|
cron, a simple sleep-loop, or systemd timer on the host, whichever is
|
||||||
|
simplest to run inside a single Docker container without extra infra.
|
||||||
|
- Any web UI should sit behind Authelia (forward-auth via NPMplus) and use
|
||||||
|
my `apointless.css` design system: dark-first, JetBrains Mono + DM Sans,
|
||||||
|
blue accent `#3b82f6`, bg `#0b0d11`, surfaces `#12151b`/`#1a1e28`/`#232838`,
|
||||||
|
semantic color tokens, light mode via `html.light` + localStorage.
|
||||||
|
- Direct, concise code. No unnecessary abstraction layers.
|
||||||
|
|
||||||
|
## Pipeline stages
|
||||||
|
|
||||||
|
### 1. Upload page (mobile-friendly)
|
||||||
|
|
||||||
|
- Single page, big upload/camera button, works well on a phone browser.
|
||||||
|
- Accepts a photo of a boarding pass.
|
||||||
|
- Behind Authelia.
|
||||||
|
|
||||||
|
### 2. BCBP barcode decode
|
||||||
|
|
||||||
|
- Boarding passes encode data in an IATA BCBP barcode (usually PDF417).
|
||||||
|
- Decode the barcode from the uploaded photo (e.g. via `pyzbar` +
|
||||||
|
`pillow`, or `zxing-cpp` if pyzbar's PDF417 support is insufficient —
|
||||||
|
check which actually decodes PDF417 reliably before committing).
|
||||||
|
- BCBP format reference: fixed-width fields, gives you at minimum:
|
||||||
|
- Passenger name
|
||||||
|
- PNR / booking reference
|
||||||
|
- Origin/destination airport codes (IATA)
|
||||||
|
- Operating carrier + flight number
|
||||||
|
- Julian date of flight (day-of-year, need current/implied year — BCBP
|
||||||
|
doesn't encode year, so use "nearest future or past occurrence of
|
||||||
|
this day-of-year relative to upload time" as the heuristic)
|
||||||
|
- Seat number, compartment/class
|
||||||
|
- Do NOT rely on OCR of the printed text as the primary path — the
|
||||||
|
barcode is structured and far more reliable. OCR can be a fallback if
|
||||||
|
barcode decode fails, but flag those for manual correction rather than
|
||||||
|
guessing.
|
||||||
|
- Store the decoded fields plus the original image (for manual reference)
|
||||||
|
in a local SQLite table, e.g. `pending_flights`.
|
||||||
|
|
||||||
|
### 3. Queue / scheduling
|
||||||
|
|
||||||
|
- Each row in `pending_flights` gets a `process_after` timestamp =
|
||||||
|
(flight date, derived from BCBP Julian date) + 1 day.
|
||||||
|
- A systemd timer runs a daily job that queries for rows where
|
||||||
|
`process_after <= now()` and `status = 'queued'`.
|
||||||
|
|
||||||
|
### 4. FlightAware track fetch
|
||||||
|
|
||||||
|
- **Do not use AeroAPI for this.** Its free Personal tier excludes
|
||||||
|
Historical Flight Data, and by the time this job runs (flight date + 1
|
||||||
|
day) the flight will likely have aged into "historical" — that tier
|
||||||
|
boundary is not clearly documented so it's an unreliable free option
|
||||||
|
for this specific timing.
|
||||||
|
- Instead, replicate the manual FlightAware KML fetch we already proved
|
||||||
|
works:
|
||||||
|
- Build the flight history URL:
|
||||||
|
`https://www.flightaware.com/live/flight/{ICAO_CALLSIGN}/history/{YYYYMMDD}/{HHMM}Z/{FROM_ICAO}/{TO_ICAO}/google_earth`
|
||||||
|
- No session cookie/login required — this endpoint is publicly
|
||||||
|
accessible.
|
||||||
|
- This endpoint returns a `.kml` file with a `gx:Track` containing
|
||||||
|
`<when>` (ISO timestamp) and `<gx:coord>` (lon lat elevation_m)
|
||||||
|
elements — parse those into `[[lon, lat, elev_m], ...]` coordinate
|
||||||
|
array and `[unix_ts, ...]` times array.
|
||||||
|
- This is a LOW VOLUME job (one flight per day, not bulk), so no
|
||||||
|
aggressive rate limiting is needed — but still be polite: this is
|
||||||
|
scraping a public page, not a documented API, so keep it to one
|
||||||
|
request per pending flight per run and don't retry-hammer on
|
||||||
|
failure (back off and retry next day instead).
|
||||||
|
- Converting IATA flight number (from boarding pass) to ICAO callsign
|
||||||
|
for the URL:
|
||||||
|
- QF → QFA, VA → VOZ, JQ → JST, EK → UAE (extend as needed)
|
||||||
|
- Airport IATA → ICAO codes will also need a lookup (a static local
|
||||||
|
table is fine; AirTrail's own airport data, or OurAirports CSV, both
|
||||||
|
work as a source).
|
||||||
|
- If the flight can't be found on FlightAware (URL 404s, wrong flight
|
||||||
|
matched, no track available for old/low-coverage flights), mark the
|
||||||
|
row `status = 'track_unavailable'` and still let it be reviewed
|
||||||
|
manually with flight metadata only (no track) — don't just fail
|
||||||
|
silently.
|
||||||
|
|
||||||
|
### 5. Staging (not direct DB write)
|
||||||
|
|
||||||
|
- Per my earlier decision: **do not write directly into AirTrail's live
|
||||||
|
tables.** Insert into a separate staging table (in the pipeline's own
|
||||||
|
SQLite DB, or a staging schema in AirTrail's Postgres — either is fine,
|
||||||
|
SQLite is simpler and keeps this service decoupled) with all the
|
||||||
|
parsed flight + track data, `status = 'pending_review'`.
|
||||||
|
|
||||||
|
### 6. Notification (ntfy)
|
||||||
|
|
||||||
|
- When a flight moves to `pending_review`, send an ntfy notification.
|
||||||
|
- Build the notification backend as a small interface/abstraction (even
|
||||||
|
if ntfy is the only implementation right now) so other backends could
|
||||||
|
be added later without a rewrite — e.g. a `notify(title, body, url,
|
||||||
|
actions)` function with a config-selected backend, not ntfy calls
|
||||||
|
hardcoded throughout the codebase.
|
||||||
|
- Notification content: flight number, route, date, and a short summary
|
||||||
|
(e.g. "track found, 271 points" or "no track found"). Keep the body
|
||||||
|
short — this is a push notification, not the review UI itself.
|
||||||
|
- **Use ntfy action buttons** (`Actions` header) for a 3-button quick
|
||||||
|
response, so I can act straight from the notification without opening
|
||||||
|
the review page for the common cases:
|
||||||
|
- **Approve** — an HTTP action button that calls back into the service
|
||||||
|
(e.g. `POST /flights/{id}/approve`) to write the flight straight into
|
||||||
|
AirTrail. No further UI interaction needed.
|
||||||
|
- **Deny** — an HTTP action button that calls `POST /flights/{id}/reject`,
|
||||||
|
marks the row `status = 'rejected'`.
|
||||||
|
- **Investigate** — a `view` action button that opens the review page
|
||||||
|
for that specific flight in the browser (see stage 7). This is the
|
||||||
|
path for anything that needs a closer look — wrong flight matched,
|
||||||
|
missing track, boarding pass fields decoded incorrectly, etc.
|
||||||
|
- Ntfy action button docs: use `Actions:` header with comma-separated
|
||||||
|
action definitions (`http, Approve, https://.../approve, method=POST`
|
||||||
|
etc. — check current ntfy docs for exact syntax since it's changed
|
||||||
|
across versions).
|
||||||
|
- Approve/Deny should require some form of auth on the callback (e.g. a
|
||||||
|
per-flight signed token in the URL) since ntfy action buttons fire an
|
||||||
|
unauthenticated HTTP request from wherever the notification is
|
||||||
|
received — don't just trust a bare flight ID.
|
||||||
|
- ntfy config (topic, server URL — self-hosted vs ntfy.sh, auth token if
|
||||||
|
self-hosted) lives in the settings page (see below), not hardcoded.
|
||||||
|
|
||||||
|
### 7. Review page
|
||||||
|
|
||||||
|
- Lists flights with `status = 'pending_review'` (reachable from the
|
||||||
|
service's own nav, and directly via the ntfy "Investigate" action
|
||||||
|
button which deep-links to a specific flight's row on this page).
|
||||||
|
- For each: show the decoded boarding pass fields, matched flight route,
|
||||||
|
and — if a track was found — a small map preview of the track (a
|
||||||
|
static Leaflet/MapLibre map is fine, doesn't need to match AirTrail's
|
||||||
|
map exactly).
|
||||||
|
- **Editable fields**: every decoded/matched field (flight number, date,
|
||||||
|
origin/destination, carrier, etc.) should be editable inline here —
|
||||||
|
this is the "investigate" workflow. If the BCBP decode got something
|
||||||
|
wrong (e.g. wrong year inferred, misread airport code), I fix it here.
|
||||||
|
- **Re-query button**: after editing fields, a button to re-run the
|
||||||
|
FlightAware lookup (stage 4) using the corrected data, replacing
|
||||||
|
whatever track/match was previously staged for this row. This lets me
|
||||||
|
fix a bad initial match and pull the correct track without restarting
|
||||||
|
the whole pipeline from the boarding pass photo.
|
||||||
|
- Approve button: on confirm, writes the flight into AirTrail's `flight`
|
||||||
|
table (or updates an existing flight if one already matches — check
|
||||||
|
by flight number + date first, since I may have already manually
|
||||||
|
entered some flights) and the track into AirTrail's track table.
|
||||||
|
- AirTrail Postgres track table stores: `flightId`, `coordinates`
|
||||||
|
(JSON array of `[lon, lat, elev_m]`), `times` (JSON array of unix
|
||||||
|
timestamps), `sourceFormat` (string, e.g. `"kml"`), `sourceName`
|
||||||
|
(string, original filename/identifier). Confirm exact column names
|
||||||
|
and types against the live schema before writing
|
||||||
|
(`\d flight_track` or equivalent) — don't assume the names I've
|
||||||
|
given are byte-exact.
|
||||||
|
- Deny/reject button: mark `status = 'rejected'`, keep the row for
|
||||||
|
reference rather than hard-deleting.
|
||||||
|
- Also behind Authelia.
|
||||||
|
- This page is also what the ntfy Approve/Deny action buttons act on
|
||||||
|
server-side (same underlying approve/reject logic, just triggered
|
||||||
|
without opening this page).
|
||||||
|
|
||||||
|
### 8. Settings page
|
||||||
|
|
||||||
|
- Notification backend selector (dropdown, ntfy is the only real option
|
||||||
|
today but the UI should not assume it always will be).
|
||||||
|
- ntfy-specific fields: server URL (default `https://ntfy.sh` but
|
||||||
|
overridable for self-hosting), topic name, optional auth token.
|
||||||
|
- Save to local config (SQLite table or simple JSON/YAML file — your
|
||||||
|
call, whichever fits the existing service patterns better).
|
||||||
|
|
||||||
|
## Open questions to resolve during the build (not blocking, use judgment)
|
||||||
|
|
||||||
|
- Exact AirTrail Postgres schema for the flight and track tables (get
|
||||||
|
this from `\d flight` / `\d flight_track` on the live DB rather than
|
||||||
|
trusting anything written here from memory).
|
||||||
|
- Whether pyzbar or an alternative PDF417 decoder actually handles real
|
||||||
|
phone-photo boarding passes reliably (glare, skew, compression) —
|
||||||
|
test against a few real captures early rather than assuming.
|
||||||
|
- BCBP year disambiguation heuristic — pick something sane (nearest
|
||||||
|
occurrence to now) and revisit if it causes mismatches in practice.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
INSTANCE_DIR = Path(os.environ.get("INSTANCE_DIR", BASE_DIR / "instance"))
|
||||||
|
UPLOADS_DIR = INSTANCE_DIR / "uploads"
|
||||||
|
DB_PATH = INSTANCE_DIR / "boarding_pass.db"
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("PORT", "8080"))
|
||||||
|
MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "15"))
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/")
|
||||||
|
AIRTRAIL_BASE_URL = os.environ.get("AIRTRAIL_BASE_URL", "").rstrip("/")
|
||||||
|
AIRTRAIL_API_KEY = os.environ.get("AIRTRAIL_API_KEY", "")
|
||||||
|
|
||||||
|
FLIGHTAWARE_USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_TRACK_POINTS = 2000
|
||||||
|
MAX_FETCH_ATTEMPTS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
INSTANCE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -0,0 +1,984 @@
|
|||||||
|
iata,icao,name
|
||||||
|
&T,T&O,Tom\\'s & co airliners
|
||||||
|
++,---,Lombards Air
|
||||||
|
-+,--+,U.S. Air
|
||||||
|
--,ELK,ELK Airways
|
||||||
|
..,...,Avilu
|
||||||
|
00,000,AirOne Continental
|
||||||
|
04,ABV,Antrak Air
|
||||||
|
07,770,Samurai Airlines
|
||||||
|
0A,GNT,Amber Air
|
||||||
|
0B,JOR,Blue Air
|
||||||
|
0D,DWT,Darwin Airline
|
||||||
|
0G,GA0,Global Airlines
|
||||||
|
0J,JCS,Jetclub
|
||||||
|
0M,0MM,All America MX
|
||||||
|
0P,PYB,All America BOPY
|
||||||
|
0X,CX0,Copenhagen Express
|
||||||
|
0Y,7ZC,All America CO
|
||||||
|
10,CNN,Canadian World
|
||||||
|
12,N12,12 North
|
||||||
|
13,EAV,Eastern Atlantic Virtual Airlines
|
||||||
|
1A,AGT,Amadeus Global Travel Distribution
|
||||||
|
1C,1CH,OneChina
|
||||||
|
1E,RGG,TransRussiaAirlines
|
||||||
|
1F,CIF,CB Airways UK ( Interliging Flights )
|
||||||
|
1H,HEY,Hellenic Airways
|
||||||
|
1I,AMB,Deutsche Rettungsflugwacht
|
||||||
|
1K,BG1,BudgetAir
|
||||||
|
1L,OSY,Open Skies Consultative Commission
|
||||||
|
1R,R1R,All America CL
|
||||||
|
1T,RNX,1Time Airline
|
||||||
|
1Y,A9B,All America BR
|
||||||
|
20,RNE,Air Salone
|
||||||
|
2B,ARD,Aerocondor
|
||||||
|
2D,AOG,Aero VIP
|
||||||
|
2F,FTA,Frontier Flying Service
|
||||||
|
2G,CRG,Cargoitalia
|
||||||
|
2J,VBW,Air Burkina
|
||||||
|
2K,GLG,Aerolineas Galapagos (Aerogal)
|
||||||
|
2L,OAW,Helvetic Airways
|
||||||
|
2M,MDV,Moldavian Airlines
|
||||||
|
2N,NTJ,NextJet
|
||||||
|
2P,GAP,Air Philippines
|
||||||
|
2Q,SNC,Air Cargo Carriers
|
||||||
|
2R,M7A,All America AR
|
||||||
|
2S,SDY,Island Express
|
||||||
|
2T,HAM,Haiti Ambassador Airlines
|
||||||
|
2U,GIP,Air Guinee Express
|
||||||
|
2W,WLC,Welcome Air
|
||||||
|
2X,2K2,Regionalia Uruguay
|
||||||
|
2Y,AOW,Air Andaman (2Y)
|
||||||
|
2Z,CGN,Changan Airlines
|
||||||
|
3C,CEA,RegionsAir
|
||||||
|
3D,PMK,Palair Macedonia
|
||||||
|
3F,3FF,Fly Colombia ( Interliging Flights )
|
||||||
|
3G,AYZ,Atlant-Soyuz Airlines
|
||||||
|
3J,WZP,Zip
|
||||||
|
3K,JSA,Jetstar Asia Airways
|
||||||
|
3L,ISK,Intersky
|
||||||
|
3N,URG,Air Urga
|
||||||
|
3P,TNM,Tiara Air
|
||||||
|
3Q,CYH,Yunnan Airlines
|
||||||
|
3R,GAI,Moskovia Airlines
|
||||||
|
3T,URN,Turan Air
|
||||||
|
3U,CSC,Sichuan Airlines
|
||||||
|
3V,TAY,TNT Airways
|
||||||
|
3W,EMX,Euromanx Airways
|
||||||
|
3X,GUI,Aguilar Connect
|
||||||
|
47,VVN,88
|
||||||
|
4A,AKL,Air Kiribati
|
||||||
|
4B,BTQ,Boutique Air (Priv)
|
||||||
|
4C,ARE,Aires
|
||||||
|
4D,ASD,Air Sinai
|
||||||
|
4F,ECE,Air City
|
||||||
|
4G,GZP,Gazpromavia
|
||||||
|
4H,UBD,United Airways
|
||||||
|
4K,AAS,Askari Aviation
|
||||||
|
4L,MJX,Euroline
|
||||||
|
4M,DSM,LAN Argentina
|
||||||
|
4N,ANT,Air North Charter - Canada
|
||||||
|
4R,HHI,Hamburg International
|
||||||
|
4S,FNC,Finalair Congo
|
||||||
|
4T,BHP,Belair Airlines
|
||||||
|
4U,GWI,Germanwings
|
||||||
|
4Y,RBU,Airbus France
|
||||||
|
5A,AIP,Alpine Air Express
|
||||||
|
5B,BSX,Bassaka airlines
|
||||||
|
5C,ICL,CAL Cargo Air Lines
|
||||||
|
5D,SLI,Aerolitoral
|
||||||
|
5F,CIR,Arctic Circle Air Service
|
||||||
|
5G,SSV,Skyservice Airlines
|
||||||
|
5H,FFV,Fly540
|
||||||
|
5J,CEB,Cebu Pacific
|
||||||
|
5K,HFY,Hi Fly
|
||||||
|
5L,RSU,Aerosur
|
||||||
|
5M,SIB,Sibaviatrans
|
||||||
|
5N,AUL,Aeroflot-Nord
|
||||||
|
5T,MPE,Canadian North
|
||||||
|
5V,UKW,Lviv Airlines
|
||||||
|
5W,AEU,Astraeus
|
||||||
|
5X,UPS,United Parcel Service
|
||||||
|
5Y,GTI,Atlas Air
|
||||||
|
5Z,BML,Bismillah Airlines
|
||||||
|
6A,CHP,Consorcio Aviaxsa
|
||||||
|
6B,BLX,TUIfly Nordic
|
||||||
|
6C,6CC,Vuela Cuba
|
||||||
|
6E,IGO,IndiGo Airlines
|
||||||
|
6F,MKD,MAT Airways
|
||||||
|
6G,AWW,Air Wales
|
||||||
|
6H,ISR,Israir
|
||||||
|
6I,IBZ,International Business Air
|
||||||
|
6J,SNJ,Skynet Asia Airways
|
||||||
|
6K,RIT,Asian Spirit
|
||||||
|
6N,NRD,Nordic Regional
|
||||||
|
6P,ISG,Club Air
|
||||||
|
6Q,SLL,Slovak Airlines
|
||||||
|
6R,DRU,Alrosa Mirny Air Enterprise
|
||||||
|
6U,UKR,Air Ukraine
|
||||||
|
6V,VGA,Air Vegas
|
||||||
|
6W,SOV,Saratov Aviation Division
|
||||||
|
6Z,UKS,Ukrainian Cargo Airways
|
||||||
|
76,SJS,Southjet
|
||||||
|
77,ZCS,Southjet connect
|
||||||
|
78,XAN,Southjet cargo
|
||||||
|
7B,KJC,Krasnojarsky Airlines
|
||||||
|
7C,JJA,Jeju Air
|
||||||
|
7E,AWU,Aeroline GmbH
|
||||||
|
7F,FAB,First Air
|
||||||
|
7G,SFJ,Star Flyer
|
||||||
|
7H,ERR,Era Alaska
|
||||||
|
7K,KGL,Kogalymavia Air Company
|
||||||
|
7L,ERO,Sun D'Or
|
||||||
|
7M,ZTF,Mongolian International Air Lines
|
||||||
|
7N,CNA,Centavia
|
||||||
|
7O,GAL,Galaxy Air
|
||||||
|
7P,BTV,Metro Batavia
|
||||||
|
7R,BRB,BRA-Transportes Aereos
|
||||||
|
7T,AGV,Air Glaciers
|
||||||
|
7V,ROB,ROYAL BRITAIN
|
||||||
|
88,8K8,All Australia
|
||||||
|
8A,BMM,Atlas Blue
|
||||||
|
8B,GFI,Caribbean Star Airlines
|
||||||
|
8C,ATN,Air Transport International
|
||||||
|
8D,EXV,Expo Aviation
|
||||||
|
8E,BRG,Bering Air
|
||||||
|
8F,FFR,Fischer Air
|
||||||
|
8H,HFR,Heli France
|
||||||
|
8J,JFU,Jet4You
|
||||||
|
8K,K88,Voestar
|
||||||
|
8L,CGP,Cargo Plus Aviation
|
||||||
|
8M,MXL,Maxair
|
||||||
|
8N,NKF,Barents AirLink
|
||||||
|
8P,PCO,Pacific Coastal Airline
|
||||||
|
8Q,BAJ,Baker Aviation
|
||||||
|
8R,TIB,TRIP Linhas A
|
||||||
|
8U,AAW,Afriqiyah Airways
|
||||||
|
8V,ACP,Astral Aviation
|
||||||
|
8W,PWF,Private Wings Flugcharter
|
||||||
|
8Y,PBU,Air Burundi
|
||||||
|
8Z,LER,Linea Aerea de Servicio Ejecutivo Regional
|
||||||
|
9A,99F,All Africa
|
||||||
|
9E,FLG,Pinnacle Airlines
|
||||||
|
9F,TLM,Tramm Airlines
|
||||||
|
9I,TKY,Thai Sky Airlines
|
||||||
|
9J,CR1,Regionalia Chile
|
||||||
|
9K,KAP,Cape Air
|
||||||
|
9L,CJC,Colgan Air
|
||||||
|
9N,N78,Regional Air Iceland
|
||||||
|
9Q,PBA,PB Air
|
||||||
|
9R,VAP,Phuket Air
|
||||||
|
9S,CQH,Spring Airlines
|
||||||
|
9T,ABS,Transwest Air
|
||||||
|
9U,MLD,Air Moldova
|
||||||
|
9V,VC9,Volotea Costa Rica
|
||||||
|
9W,JAI,Jet Airways
|
||||||
|
9X,ACL,Itali Airlines
|
||||||
|
9Y,KZK,Air Kazakhstan
|
||||||
|
::,:::,Llloyd Helicopters
|
||||||
|
??,???,Court Line
|
||||||
|
A1,A1F,Atifly
|
||||||
|
A2,CIU,Cielos Airlines
|
||||||
|
A3,AEE,Aegean Airlines
|
||||||
|
A4,SWD,Southern Winds Airlines
|
||||||
|
A5,RLA,Airlinair
|
||||||
|
A6,LPV,Air Alps Aviation
|
||||||
|
A7,MPD,Air Plus Comet
|
||||||
|
A8,BGL,Benin Golf Air
|
||||||
|
A9,TGZ,Georgian Airways
|
||||||
|
AA,AAL,American Airlines
|
||||||
|
AB,BER,Air Berlin
|
||||||
|
AC,ACA,Air Canada
|
||||||
|
AD,PRZ,Air Paradise International
|
||||||
|
AE,MDA,Mandarin Airlines
|
||||||
|
AF,AFR,Air France
|
||||||
|
AG,ABR,Air Contractors
|
||||||
|
AH,DAH,Air Algerie
|
||||||
|
AI,AIC,Air India Limited
|
||||||
|
AJ,NIG,Aero Contractors
|
||||||
|
AK,AXM,AirAsia
|
||||||
|
AL,SYX,Skywalk Airlines
|
||||||
|
AM,AMX,AeroMéxico
|
||||||
|
AN,AAA,Ansett Australia
|
||||||
|
AO,AUZ,Australian Airlines
|
||||||
|
AP,AIB,Airbus Industrie
|
||||||
|
AQ,AAH,Aloha Airlines
|
||||||
|
AR,ARG,Aerolineas Argentinas
|
||||||
|
AS,ASA,Alaska Airlines
|
||||||
|
AT,RAM,Royal Air Maroc
|
||||||
|
AU,AUT,Austral Lineas Aereas
|
||||||
|
AV,AVA,Avianca - Aerovias Nacionales de Colombia
|
||||||
|
AW,SCH,CHC Airways
|
||||||
|
AX,LOF,Trans States Airlines
|
||||||
|
AY,FIN,Finnair
|
||||||
|
AZ,AZA,Alitalia
|
||||||
|
B0,666,Aws express
|
||||||
|
B1,BA1,Baltic Air lines
|
||||||
|
B2,BRU,Belavia Belarusian Airlines
|
||||||
|
B3,BLV,Bellview Airlines
|
||||||
|
B4,BCF,BACH Flugbetriebsges
|
||||||
|
B5,FLT,Flightline
|
||||||
|
B6,JBU,JetBlue Airways
|
||||||
|
B7,UIA,Uni Air
|
||||||
|
B8,ERT,Eritrean Airlines
|
||||||
|
B9,BGD,Air Bangladesh
|
||||||
|
BA,BAW,British Airways
|
||||||
|
BB,SBS,Seaborne Airlines
|
||||||
|
BC,SKY,Skymark Airlines
|
||||||
|
BD,BMA,bmi
|
||||||
|
BE,BEE,Flybe
|
||||||
|
BF,RSR,Aero-Service
|
||||||
|
BG,BBC,Biman Bangladesh Airlines
|
||||||
|
BI,RBA,Royal Brunei Airlines
|
||||||
|
BJ,LBT,Nouvel Air Tunisie
|
||||||
|
BK,PDC,Potomac Air
|
||||||
|
BL,PIC,Jetstar Pacific
|
||||||
|
BM,BYE,Bayu Indonesia Air
|
||||||
|
BN,BNF,Braniff International Airways
|
||||||
|
BO,BOU,Bouraq Indonesia Airlines
|
||||||
|
BP,BOT,Air Botswana
|
||||||
|
BQ,ROM,Aeromar
|
||||||
|
BR,EVA,EVA Air
|
||||||
|
BS,BIH,British International Helicopters
|
||||||
|
BT,BTI,Air Baltic
|
||||||
|
BU,BRA,Braathens
|
||||||
|
BV,BPA,Blue Panorama Airlines
|
||||||
|
BW,BWA,Caribbean Airlines
|
||||||
|
BX,CST,Coast Air
|
||||||
|
BY,TOM,Thomsonfly
|
||||||
|
BZ,BDA,Blue Dart Aviation
|
||||||
|
C0,CLW,Centralwings
|
||||||
|
C1,CA1,CanXpress
|
||||||
|
C2,CAP,CanXplorer
|
||||||
|
C3,KIS,Contact Air
|
||||||
|
C4,IMX,Zimex Aviation
|
||||||
|
C5,UCA,CommutAir
|
||||||
|
C6,CJA,CanJet
|
||||||
|
C7,RLE,Rico Linhas A
|
||||||
|
C8,WDY,Chicago Express
|
||||||
|
C9,RUS,Cirrus Airlines
|
||||||
|
CA,CCA,Air China
|
||||||
|
CB,CCC,CCML Airlines
|
||||||
|
CC,ABD,Air Atlanta Icelandic
|
||||||
|
CD,LLR,Alliance Air
|
||||||
|
CE,NTW,Nationwide Airlines
|
||||||
|
CF,SDR,City Airline
|
||||||
|
CG,TOK,Airlines PNG
|
||||||
|
CH,BMJ,Bemidji Airlines
|
||||||
|
CI,CAL,China Airlines
|
||||||
|
CJ,CBF,China Northern Airlines
|
||||||
|
CK,CKK,China Cargo Airlines
|
||||||
|
CL,CLH,Lufthansa CityLine
|
||||||
|
CM,CMP,Copa Airlines
|
||||||
|
CN,WWD,Westward Airways
|
||||||
|
CO,COA,Continental Airlines
|
||||||
|
CP,CDN,Canadian Airlines
|
||||||
|
CQ,EXL,Sunshine Express Airlines
|
||||||
|
CS,CMI,Continental Micronesia
|
||||||
|
CT,CAT,Civil Air Transport
|
||||||
|
CU,CUB,Cubana de Aviación
|
||||||
|
CV,CVA,Air Chathams
|
||||||
|
CW,CWM,Air Marshall Islands
|
||||||
|
CX,CPA,Cathay Pacific
|
||||||
|
CY,CYP,Cyprus Airways
|
||||||
|
CZ,CSN,China Southern Airlines
|
||||||
|
D1,MDO,Domenican Airlines
|
||||||
|
D3,DAO,Daallo Airlines
|
||||||
|
D4,LID,Alidaunia
|
||||||
|
D5,DAU,Dauair
|
||||||
|
D6,ILN,Interair South Africa
|
||||||
|
D7,RDN,Dinar
|
||||||
|
D8,DJB,Djibouti Airlines
|
||||||
|
D9,DNV,Aeroflot-Don
|
||||||
|
DA,GRG,Air Georgia
|
||||||
|
DB,BZH,Brit Air
|
||||||
|
DC,GAO,Golden Air
|
||||||
|
DD,NOK,Nok Air
|
||||||
|
DE,CFG,Condor Flugdienst
|
||||||
|
DF,MJG,Michael Airlines
|
||||||
|
DG,SRQ,South East Asian Airlines
|
||||||
|
DH,DVA,Discovery Airways
|
||||||
|
DI,BAG,dba
|
||||||
|
DJ,PBN,Pacific Blue
|
||||||
|
DK,ELA,Eastland Air
|
||||||
|
DL,DAL,Delta Air Lines
|
||||||
|
DN,SGG,Senegal Airlines
|
||||||
|
DO,DOA,Dominicana de Aviaci
|
||||||
|
DP,FCA,First Choice Airways
|
||||||
|
DR,BIE,Air Mediterranee
|
||||||
|
DT,DTA,TAAG Angola Airlines
|
||||||
|
DU,HMS,Hemus Air
|
||||||
|
DV,LTF,Lufttaxi Fluggesellschaft
|
||||||
|
DW,UCR,Aero-Charter Ukraine
|
||||||
|
DX,DTR,DAT Danish Air Transport
|
||||||
|
DY,NAX,Norwegian Air Shuttle
|
||||||
|
E0,ESS,Eos Airlines
|
||||||
|
E1,ES2,Usa Sky Cargo
|
||||||
|
E2,KMP,Kampuchea Airlines
|
||||||
|
E3,DMO,Domodedovo Airlines
|
||||||
|
E4,RSO,Aero Asia International
|
||||||
|
E5,BRZ,Samara Airlines
|
||||||
|
E7,ESF,Estafeta Carga Aerea
|
||||||
|
E8,ELG,Alpi Eagles
|
||||||
|
E9,CXS,Boston-Maine Airways
|
||||||
|
EA,EAL,European Air Express
|
||||||
|
EC,TWN,Avialeasing Aviation Company
|
||||||
|
ED,ABQ,Airblue
|
||||||
|
EE,EAY,Aero Airlines
|
||||||
|
EF,EFA,Far Eastern Air Transport
|
||||||
|
EG,JAA,Japan Asia Airways
|
||||||
|
EH,AKX,Air Nippon Network Co. Ltd.
|
||||||
|
EI,EIN,Aer Lingus
|
||||||
|
EJ,NEA,New England Airlines
|
||||||
|
EK,UAE,Emirates
|
||||||
|
EL,ANK,Air Nippon
|
||||||
|
EM,AEB,Aero Benin
|
||||||
|
EN,DLA,Air Dolomiti
|
||||||
|
EO,LHN,Express One International
|
||||||
|
EP,IRC,Iran Aseman Airlines
|
||||||
|
EQ,TAE,TAME
|
||||||
|
ER,DHL,Astar Air Cargo
|
||||||
|
ES,DHX,DHL International
|
||||||
|
ET,ETH,Ethiopian Airlines
|
||||||
|
EU,EEA,Empresa Ecuatoriana De Aviacion
|
||||||
|
EV,ASQ,Atlantic Southeast Airlines
|
||||||
|
EW,EWG,Eurowings
|
||||||
|
EX,SDO,Air Santo Domingo
|
||||||
|
EY,ETD,Etihad Airways
|
||||||
|
EZ,EIA,Evergreen International Airlines
|
||||||
|
F1,FBL,Fly Brasil
|
||||||
|
F2,FLM,Fly Air
|
||||||
|
F3,FSW,Faso Airways
|
||||||
|
F4,NBK,Albarka Air
|
||||||
|
F5,COZ,Cosmic Air
|
||||||
|
F6,RCK,Faroejet
|
||||||
|
F7,BBO,Flybaboo
|
||||||
|
F9,FFT,Frontier Airlines
|
||||||
|
FA,SFR,Safair
|
||||||
|
FB,LZB,Bulgaria Air
|
||||||
|
FC,WBA,Finncomm Airlines
|
||||||
|
FD,AIQ,Thai AirAsia
|
||||||
|
FE,WCP,Primaris Airlines
|
||||||
|
FF,TOW,Tower Air
|
||||||
|
FG,AFG,Ariana Afghan Airlines
|
||||||
|
FH,FUA,Futura International Airways
|
||||||
|
FI,ICE,Icelandair
|
||||||
|
FJ,FJI,Air Pacific
|
||||||
|
FK,WTA,Africa West
|
||||||
|
FL,TRS,AirTran Airways
|
||||||
|
FM,CSH,Shanghai Airlines
|
||||||
|
FO,ATM,Airlines Of Tasmania
|
||||||
|
FP,FRE,Freedom Air
|
||||||
|
FQ,TCW,Thomas Cook Airlines
|
||||||
|
FR,RYR,Ryanair
|
||||||
|
FS,STU,Servicios de Transportes A
|
||||||
|
FT,SRH,Siem Reap Airways
|
||||||
|
FU,FXX,Felix Airways
|
||||||
|
FV,SDM,Rossiya-Russian Airlines
|
||||||
|
FW,IBX,Ibex Airlines
|
||||||
|
FX,FDX,Federal Express
|
||||||
|
FY,FFM,Firefly
|
||||||
|
FZ,FDB,Fly Dubai
|
||||||
|
G0,GHB,Ghana International Airlines
|
||||||
|
G1,IKA,Gorkha Airlines
|
||||||
|
G2,VXG,Avirex
|
||||||
|
G3,CIX,City Connexion Airlines
|
||||||
|
G4,AAY,Allegiant Air
|
||||||
|
G6,BSR,Guine Bissaur Airlines
|
||||||
|
G7,GNF,Gandalf Airlines
|
||||||
|
G8,AGB,Air Service Gabon
|
||||||
|
G9,ABY,Air Arabia
|
||||||
|
GA,GIA,Garuda Indonesia
|
||||||
|
GB,ABX,Airborne Express
|
||||||
|
GC,GNR,Gambia International Airlines
|
||||||
|
GD,AHA,Air Alpha Greenland
|
||||||
|
GE,TNA,TransAsia Airways
|
||||||
|
GF,GBA,Gulf Air Bahrain
|
||||||
|
GG,GUY,Air Guyane
|
||||||
|
GH,GLP,Globus
|
||||||
|
GI,IKA,Itek Air
|
||||||
|
GJ,MXC,Compania Mexicargo
|
||||||
|
GL,GRL,Air Greenland
|
||||||
|
GM,SVK,Air Slovakia
|
||||||
|
GN,AGN,Air Gabon
|
||||||
|
GO,KZU,Kuzu Airlines Cargo
|
||||||
|
GP,GES,Gestair
|
||||||
|
GQ,BSY,Big Sky Airlines
|
||||||
|
GR,AUR,Aurigny Air Services
|
||||||
|
GS,UPA,Air Foyle
|
||||||
|
GT,GBL,GB Airways
|
||||||
|
GU,GU1,Gulisano airways
|
||||||
|
GV,ARF,Aero Flight
|
||||||
|
GW,KIL,Kuban Airlines
|
||||||
|
GX,JXX,Jetx Airlines
|
||||||
|
GY,TMG,Tri-MG Intra Asia Airlines
|
||||||
|
GZ,RAR,Air Rarotonga
|
||||||
|
H1,HA1,Hankook Air US
|
||||||
|
H2,SKU,Sky Airline
|
||||||
|
H3,T33,THREE
|
||||||
|
H4,IIN,Inter Islands Airlines
|
||||||
|
H5,HOA,Hola Airlines
|
||||||
|
H6,HAG,Hageland Aviation Services
|
||||||
|
H8,KHB,Dalavia
|
||||||
|
H9,HAD,Air D'Ayiti
|
||||||
|
HA,HAL,Hawaiian Airlines
|
||||||
|
HB,HAR,Harbor Airlines
|
||||||
|
HC,ATI,Aero-Tropics Air Services
|
||||||
|
HD,ADO,Hokkaido International Airlines
|
||||||
|
HE,LGW,Luftfahrtgesellschaft Walter
|
||||||
|
HF,HLF,Hapagfly
|
||||||
|
HG,NLY,Niki
|
||||||
|
HH,AHO,Air Hamburg (AHO)
|
||||||
|
HJ,AXF,Asian Express Airlines
|
||||||
|
HK,FSC,Four Star Aviation / Four Star Cargo
|
||||||
|
HM,SEY,Air Seychelles
|
||||||
|
HN,HVY,Heavylift Cargo Airlines
|
||||||
|
HO,DJA,Antinea Airlines
|
||||||
|
HP,AWE,America West Airlines
|
||||||
|
HQ,HMY,Harmony Airways
|
||||||
|
HR,CUA,China United Airlines
|
||||||
|
HT,AHW,Aeromist-Kharkiv
|
||||||
|
HU,CHH,Hainan Airlines
|
||||||
|
HV,TRA,Transavia Holland
|
||||||
|
HW,FHE,Hello
|
||||||
|
HX,CRK,Hong Kong Airlines
|
||||||
|
HY,UZB,Uzbekistan Airways
|
||||||
|
HZ,SOZ,Sat Airlines
|
||||||
|
I2,IBS,Iberia Express
|
||||||
|
I4,FWA,Interstate Airline
|
||||||
|
I5,IDS,Indonesia Sky
|
||||||
|
I6,SEQ,Sky Eyes
|
||||||
|
I7,PMW,Paramount Airways
|
||||||
|
I9,AEY,Air Italy
|
||||||
|
IA,IAW,Iraqi Airways
|
||||||
|
IB,IBE,Iberia Airlines
|
||||||
|
IC,IAC,Indian Airlines
|
||||||
|
ID,ITK,Interlink Airlines
|
||||||
|
IE,SOL,Solomon Airlines
|
||||||
|
IF,ISW,Islas Airways
|
||||||
|
IG,ISS,Meridiana
|
||||||
|
II,CSQ,IBC Airways
|
||||||
|
IJ,GWL,Great Wall Airlines
|
||||||
|
IK,ITX,Imair Airlines
|
||||||
|
IL,ILW,Illinois Airways
|
||||||
|
IM,MNJ,Menajet
|
||||||
|
IN,MAK,MAT Macedonian Airlines
|
||||||
|
IO,IAA,Indonesian Airlines
|
||||||
|
IP,JOL,Atyrau Air Ways
|
||||||
|
IQ,AUB,Augsburg Airways
|
||||||
|
IR,IRA,Iran Air
|
||||||
|
IT,KFR,Kingfisher Airlines
|
||||||
|
IV,JET,Wind Jet
|
||||||
|
IW,AOM,AOM French Airlines
|
||||||
|
IX,AXB,Air India Express
|
||||||
|
IY,IYE,Yemenia
|
||||||
|
IZ,AIZ,Arkia Israel Airlines
|
||||||
|
J2,AHY,Azerbaijan Airlines
|
||||||
|
J3,PLR,Northwestern Air
|
||||||
|
J4,BFL,Buffalo Airways
|
||||||
|
J6,AOC,AVCOM
|
||||||
|
J7,CVC,Centre-Avia
|
||||||
|
J8,BVT,Berjaya Air
|
||||||
|
J9,GIF,Guinee Airlines
|
||||||
|
JA,BON,Air Bosna
|
||||||
|
JB,JBA,Helijet
|
||||||
|
JC,JEX,JAL Express
|
||||||
|
JD,JAS,Japan Air System
|
||||||
|
JE,MNO,Mango
|
||||||
|
JF,JAF,Jetairfly
|
||||||
|
JH,NES,Nordeste Linhas Aereas Regionais
|
||||||
|
JI,MDW,Midway Airlines
|
||||||
|
JJ,TAM,TAM Brazilian Airlines
|
||||||
|
JK,JKK,Spanair
|
||||||
|
JL,JAL,Japan Airlines
|
||||||
|
JM,AJM,Air Jamaica
|
||||||
|
JN,XLA,Excel Airways
|
||||||
|
JO,JAZ,JALways
|
||||||
|
JP,ADR,Adria Airways
|
||||||
|
JQ,JST,Jetstar Airways
|
||||||
|
JR,SER,Aero California
|
||||||
|
JS,KOR,Air Koryo
|
||||||
|
JT,LNI,Lion Mentari Airlines
|
||||||
|
JU,JAT,Jat Airways
|
||||||
|
JV,BLS,Bearskin Lake Air Service
|
||||||
|
JW,APW,Arrow Air
|
||||||
|
JX,JEC,Jett8 Airlines Cargo
|
||||||
|
JY,TCI,Air Turks and Caicos
|
||||||
|
JZ,SKX,Skyways Express
|
||||||
|
K1,KOQ,Kostromskie avialinii
|
||||||
|
K2,ELO,Eurolot
|
||||||
|
K4,CKS,Kalitta Air
|
||||||
|
K5,ABE,Aban Air
|
||||||
|
K6,BRV,Bravo Air Congo
|
||||||
|
K7,KBR,KoralBlue Airlines
|
||||||
|
K8,ZAK,Airlink Zambia
|
||||||
|
K9,KRI,Krylo Airlines
|
||||||
|
KA,HDA,Dragonair
|
||||||
|
KB,DRK,Druk Air
|
||||||
|
KC,KZR,Air Astana
|
||||||
|
KD,KNI,KD Avia
|
||||||
|
KE,KAL,Korean Air
|
||||||
|
KF,BLF,Blue1
|
||||||
|
KG,BNX,LAI - Linea Aerea IAACA
|
||||||
|
KH,KHK,Kharkiv Airlines
|
||||||
|
KI,AAG,Air Atlantique
|
||||||
|
KJ,LAJ,British Mediterranean Airways
|
||||||
|
KK,KKK,Atlasjet
|
||||||
|
KL,KLM,KLM Royal Dutch Airlines
|
||||||
|
KM,AMC,Air Malta
|
||||||
|
KO,AER,Alaska Central Express
|
||||||
|
KP,KIA,Kiwi International Air Lines
|
||||||
|
KQ,KQA,Kenya Airways
|
||||||
|
KR,CWK,Comores Airlines
|
||||||
|
KS,PEN,Peninsula Airways
|
||||||
|
KT,CWS,California Western
|
||||||
|
KU,KAC,Kuwait Airways
|
||||||
|
KV,MVD,Kavminvodyavia
|
||||||
|
KX,CAY,Cayman Airways
|
||||||
|
KY,EQL,Air S
|
||||||
|
KZ,NCA,Nippon Cargo Airlines
|
||||||
|
L1,AL1,All Argentina
|
||||||
|
L2,LYC,Lynden Air Cargo
|
||||||
|
L3,JOS,DHL de Guatemala
|
||||||
|
L4,LJJ,Luchsh Airlines
|
||||||
|
L5,LTR,Lufttransport
|
||||||
|
L6,MAI,Mauritania Airlines International
|
||||||
|
L7,LPN,Laoag International Airlines
|
||||||
|
L8,LXG,Air Luxor GB
|
||||||
|
L9,TLW,Teamline Air
|
||||||
|
LA,LAN,LAN Airlines
|
||||||
|
LB,LLB,Lloyd Aereo Boliviano
|
||||||
|
LC,VLO,Varig Log
|
||||||
|
LD,AHK,Air Hong Kong
|
||||||
|
LE,LTY,Liberty Airways
|
||||||
|
LF,NDC,FlyNordic
|
||||||
|
LG,LGL,Luxair
|
||||||
|
LH,DLH,Lufthansa
|
||||||
|
LI,LIA,Leeward Islands Air Transport
|
||||||
|
LJ,SLA,Sierra National Airlines
|
||||||
|
LK,LXR,Air Luxor
|
||||||
|
LL,GRO,Allegro
|
||||||
|
LM,LAM,Linhas A
|
||||||
|
LN,LAA,Libyan Arab Airlines
|
||||||
|
LO,LOT,LOT Polish Airlines
|
||||||
|
LP,LPE,LAN Peru
|
||||||
|
LQ,LAQ,Lebanese Air Transport
|
||||||
|
LR,LRC,LACSA
|
||||||
|
LS,EXS,Jet2.com
|
||||||
|
LT,LTU,LTU International
|
||||||
|
LU,LXP,LAN Express
|
||||||
|
LV,LBC,Albanian Airlines
|
||||||
|
LW,NMI,Pacific Wings
|
||||||
|
LX,SWR,Swiss International Air Lines
|
||||||
|
LY,ELY,El Al Israel Airlines
|
||||||
|
M0,MNG,Aero Mongolia
|
||||||
|
M1,M1F,Maryland Air
|
||||||
|
M2,MZS,Mahfooz Aviation
|
||||||
|
M3,TUS,ABSA - Aerolinhas Brasileiras
|
||||||
|
M4,NOV,Nova Airline
|
||||||
|
M5,KEN,Kenmore Air
|
||||||
|
M6,AJT,Amerijet International
|
||||||
|
M7,MAA,MasAir
|
||||||
|
M8,TMW,Trans Maldivian Airways
|
||||||
|
M9,MSI,Motor Sich
|
||||||
|
MA,MAH,Malév
|
||||||
|
MB,EXA,Execair Aviation
|
||||||
|
MC,RCH,Air Mobility Command
|
||||||
|
MD,MDG,Air Madagascar
|
||||||
|
ME,MEA,Middle East Airlines
|
||||||
|
MF,CXA,Xiamen Airlines
|
||||||
|
MG,CCP,Champion Air
|
||||||
|
MH,MAS,Malaysia Airlines
|
||||||
|
MI,SLK,SilkAir
|
||||||
|
MJ,LPR,L
|
||||||
|
MK,MAU,Air Mauritius
|
||||||
|
ML,MAV,Maldivo Airlines
|
||||||
|
MN,CAW,Comair
|
||||||
|
MO,AUH,Abu Dhabi Amiri Flight
|
||||||
|
MP,MPH,Martinair
|
||||||
|
MQ,EGF,American Eagle Airlines
|
||||||
|
MR,MRT,Air Mauritanie
|
||||||
|
MS,MSR,Egyptair
|
||||||
|
MT,TCX,Thomas Cook Airlines
|
||||||
|
MU,CES,China Eastern Airlines
|
||||||
|
MV,RML,Armenian International Airways
|
||||||
|
MW,MYD,Maya Island Air
|
||||||
|
MX,MXA,Mexicana de Aviaci
|
||||||
|
MY,MXJ,Maxjet Airways
|
||||||
|
MZ,MNA,Merpati Nusantara Airlines
|
||||||
|
N2,DAG,Dagestan Airlines
|
||||||
|
N3,OMS,Omskavia Airline
|
||||||
|
N4,MTC,Mountain Air Company
|
||||||
|
N5,KGZ,Kyrgyz Airlines
|
||||||
|
N6,JEV,Lagun Air
|
||||||
|
N7,ROK,National Airlines
|
||||||
|
N8,HGK,Fika Salaama Airlines
|
||||||
|
N9,N99,All Europe
|
||||||
|
NA,NAL,National Airlines
|
||||||
|
NB,SNB,Sterling Airlines
|
||||||
|
NC,NJS,National Jet Systems
|
||||||
|
NE,ESK,SkyEurope
|
||||||
|
NF,AVN,Air Vanuatu
|
||||||
|
NG,LDA,Lauda Air
|
||||||
|
NH,ANA,All Nippon Airways
|
||||||
|
NI,PGA,Portugalia
|
||||||
|
NJ,NGB,Nordic Global Airlines
|
||||||
|
NK,NKS,Spirit Airlines
|
||||||
|
NL,SAI,Shaheen Air International
|
||||||
|
NM,DRD,Air Madrid
|
||||||
|
NN,MOV,VIM Airlines
|
||||||
|
NO,AUS,Aus-Air
|
||||||
|
NP,NIA,Nile Air
|
||||||
|
NQ,AJX,Air Japan
|
||||||
|
NR,PIR,Pamir Airways
|
||||||
|
NT,IBB,Binter Canarias
|
||||||
|
NU,JTA,Japan Transocean Air
|
||||||
|
NV,CRF,Air Central
|
||||||
|
NW,NWA,Northwest Airlines
|
||||||
|
NX,AMU,Air Macau
|
||||||
|
NY,FXI,Air Iceland
|
||||||
|
NZ,ANZ,Air New Zealand
|
||||||
|
O1,OAB,Orbit Airlines Azerbaijan
|
||||||
|
O6,ONE,Oceanair
|
||||||
|
O7,OZJ,Ozjet Airlines
|
||||||
|
O8,OHK,Oasis Hong Kong Airlines
|
||||||
|
OA,OAL,Olympic Airlines
|
||||||
|
OB,ASZ,Astrakhan Airlines
|
||||||
|
OD,ULA,Zuliana de Aviacion
|
||||||
|
OE,AOT,Asia Overnight Express
|
||||||
|
OF,FIF,Air Finland
|
||||||
|
OH,COM,Comair
|
||||||
|
OI,ORC,Orchid Airlines
|
||||||
|
OJ,OLA,Overland Airways
|
||||||
|
OK,CSA,Czech Airlines
|
||||||
|
OL,OLT,Ostfriesische Lufttransport
|
||||||
|
OM,MGL,MIAT Mongolian Airlines
|
||||||
|
ON,RON,Nauru Air Corporation
|
||||||
|
OO,SKW,SkyWest
|
||||||
|
OP,CHK,Chalk's Ocean Airways
|
||||||
|
OQ,CQN,Chongqing Airlines
|
||||||
|
OR,TFL,Arkefly
|
||||||
|
OS,AUA,Austrian Airlines
|
||||||
|
OT,PEL,Aeropelican Air Services
|
||||||
|
OU,CTN,Croatia Airlines
|
||||||
|
OV,ELL,Estonian Air
|
||||||
|
OW,EXK,Executive Airlines
|
||||||
|
OX,OEA,Orient Thai Airlines
|
||||||
|
OY,OAE,Omni Air International
|
||||||
|
OZ,AAR,Asiana Airlines
|
||||||
|
P5,RPB,AeroRep
|
||||||
|
P7,ESL,Russian Sky Airlines
|
||||||
|
P8,PTN,Pantanal Linhas Aéreas
|
||||||
|
P9,PGP,Perm Airlines
|
||||||
|
PA,FCL,Florida Coastal Airlines
|
||||||
|
PC,FAJ,Air Fiji
|
||||||
|
PD,POE,Porter Airlines
|
||||||
|
PE,AEL,Air Europe
|
||||||
|
PF,PNW,Palestinian Airlines
|
||||||
|
PG,BKP,Bangkok Airways
|
||||||
|
PH,PAO,Polynesian Airlines
|
||||||
|
PI,PDT,Piedmont Airlines (1948-1989)
|
||||||
|
PJ,SPM,Air Saint Pierre
|
||||||
|
PK,PIA,Pakistan International Airlines
|
||||||
|
PL,PLI,Aeroper
|
||||||
|
PM,TOS,Tropic Air
|
||||||
|
PN,CHB,West Air China
|
||||||
|
PO,PAC,Polar Air Cargo
|
||||||
|
PP,AI0,Air Indus
|
||||||
|
PQ,PNF,Panafrican Airways
|
||||||
|
PR,PAL,Philippine Airlines
|
||||||
|
PS,PSX,Pacific Southwest Airlines
|
||||||
|
PT,CCI,Capital Cargo International Airlines
|
||||||
|
PU,PUA,PLUNA
|
||||||
|
PV,PNR,PAN Air
|
||||||
|
PW,PRF,Precision Air
|
||||||
|
PX,ANG,Air Niugini
|
||||||
|
PY,SLM,Surinam Airways
|
||||||
|
PZ,LAP,TAM Mercosur
|
||||||
|
Q3,MBN,Zambian Airways
|
||||||
|
Q4,SAE,SOCHI AIR EXPRESS
|
||||||
|
Q5,MLA,40-Mile Air
|
||||||
|
Q6,CDP,Aero Condor Peru
|
||||||
|
Q8,PEC,Pacific East Asia Cargo Airlines
|
||||||
|
Q9,AFU,Afrinat International Airlines
|
||||||
|
QB,GFG,Georgian National Airlines
|
||||||
|
QC,CRD,Air Corridor
|
||||||
|
QD,QCL,Air Class Lineas Aereas
|
||||||
|
QE,ECC,Crossair Europe
|
||||||
|
QF,QFA,Qantas
|
||||||
|
QH,FLZ,Air Florida
|
||||||
|
QI,CIM,Cimber Air
|
||||||
|
QK,JZA,Air Canada Jazz
|
||||||
|
QL,RLN,Aero Lanka
|
||||||
|
QM,AML,Air Malawi
|
||||||
|
QN,ARR,Air Armenia
|
||||||
|
QO,MPX,Aeromexpress
|
||||||
|
QQ,UTY,Alliance Airlines
|
||||||
|
QR,QTR,Qatar Airways
|
||||||
|
QS,QSC,African Safari Airways
|
||||||
|
QT,TPA,TAMPA
|
||||||
|
QU,UGX,East African
|
||||||
|
QV,LAO,Lao Airlines
|
||||||
|
QW,BWG,Blue Wings
|
||||||
|
QX,QXE,Horizon Air
|
||||||
|
QY,BCS,European Air Transport
|
||||||
|
QZ,AWQ,Indonesia AirAsia
|
||||||
|
R0,RPK,Royal Airlines
|
||||||
|
R1,RS1,Royal Southern Airlines.
|
||||||
|
R2,ORB,Orenburg Airlines
|
||||||
|
R3,SYL,Aircompany Yakutia
|
||||||
|
R5,JAV,Jordan Aviation
|
||||||
|
R7,OCA,Aserca Airlines
|
||||||
|
R8,KGA,Kyrgyzstan Airlines
|
||||||
|
R9,CAM,Camai Air
|
||||||
|
RA,RNA,Nepal Airlines
|
||||||
|
RB,SBK,Air Srpska
|
||||||
|
RC,FLI,Atlantic Airways
|
||||||
|
RD,RYN,Ryan International Airlines
|
||||||
|
RE,REA,Aer Arann
|
||||||
|
RF,FWL,Florida West International Airways
|
||||||
|
RG,VRN,VRG Linhas Aereas
|
||||||
|
RH,RPH,Republic Express Airlines
|
||||||
|
RI,MDL,Mandala Airlines
|
||||||
|
RJ,RJA,Royal Jordanian
|
||||||
|
RK,RKA,Air Afrique
|
||||||
|
RL,RFJ,Royal Falcon
|
||||||
|
RM,RNY,Rainbow Air US
|
||||||
|
RN,RAB,Rainbow Air (RAI)
|
||||||
|
RO,ROT,Tarom
|
||||||
|
RP,CHQ,Chautauqua Airlines
|
||||||
|
RQ,KMF,Kam Air
|
||||||
|
RR,RFR,Royal Air Force
|
||||||
|
RS,ICT,Intercontinental de Aviaci
|
||||||
|
RU,ABW,AirBridge Cargo
|
||||||
|
RV,CPN,Caspian Airlines
|
||||||
|
RW,RPA,Republic Airlines
|
||||||
|
RX,AEH,Aviaexpress
|
||||||
|
RY,RAY,Rainbow Air Canada
|
||||||
|
S0,OKS,Slok Air Gambia
|
||||||
|
S1,SA1,Serbian Airlines
|
||||||
|
S2,RSH,Air Sahara
|
||||||
|
S3,BBR,Santa Barbara Airlines
|
||||||
|
S4,RZO,SATA International
|
||||||
|
S5,TCF,Shuttle America
|
||||||
|
S7,SBI,S7 Airlines
|
||||||
|
S8,CSU,Chari Aviation Services
|
||||||
|
S9,HSA,East African Safari Air
|
||||||
|
SA,SAA,South African Airways
|
||||||
|
SB,ACI,Air Caledonie International
|
||||||
|
SC,CDG,Shandong Airlines
|
||||||
|
SD,SUD,Sudan Airways
|
||||||
|
SE,SEU,XL Airways France
|
||||||
|
SF,DTH,Tassili Airlines
|
||||||
|
SG,JGO,JetsGo
|
||||||
|
SH,FLY,Fly Me Sweden
|
||||||
|
SI,SIH,Skynet Airlines
|
||||||
|
SJ,SJY,Sriwijaya Air
|
||||||
|
SK,SAS,Scandinavian Airlines System
|
||||||
|
SL,RSL,Rio Sul Servi
|
||||||
|
SM,SRL,Swedline Express
|
||||||
|
SN,DAT,Brussels Airlines
|
||||||
|
SO,HKA,Superior Aviation
|
||||||
|
SP,SAT,SATA Air Acores
|
||||||
|
SQ,SIA,Singapore Airlines
|
||||||
|
SR,SWR,Swissair
|
||||||
|
SS,CRL,Corsairfly
|
||||||
|
ST,GMI,Germania
|
||||||
|
SU,AFL,Aeroflot Russian Airlines
|
||||||
|
SV,SVA,Saudi Arabian Airlines
|
||||||
|
SW,NMB,Air Namibia
|
||||||
|
SX,SRK,Sky Work Airlines
|
||||||
|
SY,SCX,Sun Country Airlines
|
||||||
|
T1,TP3,TROPICAL LINHAS AEREAS
|
||||||
|
T2,TCG,Thai Air Cargo
|
||||||
|
T3,EZE,Eastern Airways
|
||||||
|
T4,HEJ,Hellas Jet
|
||||||
|
T5,TUA,Turkmenistan Airlines
|
||||||
|
T6,TVR,Tavrey Airlines
|
||||||
|
T7,TJT,Twin Jet
|
||||||
|
T9,TRZ,TransMeridian Airlines
|
||||||
|
TA,TAT,Grupo TACA
|
||||||
|
TB,TUB,TUI Airlines Belgium
|
||||||
|
TC,ATC,Air Tanzania
|
||||||
|
TD,LUR,Atlantis European Airways
|
||||||
|
TE,LIL,FlyLal
|
||||||
|
TF,SCW,Malmö Aviation
|
||||||
|
TG,THA,Thai Airways International
|
||||||
|
TH,TSE,Transmile Air Services
|
||||||
|
TI,TOL,Tol-Air Services
|
||||||
|
TJ,TJA,T.J. Air
|
||||||
|
TK,THY,Turkish Airlines
|
||||||
|
TL,ANO,Airnorth
|
||||||
|
TN,THT,Air Tahiti Nui
|
||||||
|
TO,PSD,President Airlines
|
||||||
|
TP,TAP,TAP Portugal
|
||||||
|
TQ,TDM,Tandem Aero
|
||||||
|
TR,TGW,Tiger Airways
|
||||||
|
TS,TSC,Air Transat
|
||||||
|
TT,KLA,Air Lithuania
|
||||||
|
TU,TAR,Tunisair
|
||||||
|
TV,VEX,Virgin Express
|
||||||
|
TW,TWA,Trans World Airlines
|
||||||
|
TX,FWI,Air Caraïbes
|
||||||
|
TY,TPC,Air Cal
|
||||||
|
TZ,SCO,Scoot
|
||||||
|
U1,001,AirOne Polska
|
||||||
|
U2,EZY,easyJet
|
||||||
|
U3,AIA,Avies
|
||||||
|
U4,PMT,PMTair
|
||||||
|
U5,GWY,USA3000 Airlines
|
||||||
|
U6,SVR,Ural Airlines
|
||||||
|
U7,JUS,USA Jet Airlines
|
||||||
|
U8,RNV,Armavia
|
||||||
|
U9,TAK,Tatarstan Airlines
|
||||||
|
UA,UAL,United Airlines
|
||||||
|
UB,UBA,Myanma Airways
|
||||||
|
UD,HER,Hex'Air
|
||||||
|
UE,NAS,Nasair
|
||||||
|
UF,UKM,UM Airlines
|
||||||
|
UG,TUI,Tuninter
|
||||||
|
UI,ECA,Eurocypria Airlines
|
||||||
|
UJ,LMU,AlMasria Universal Airlines
|
||||||
|
UK,BUZ,buzz
|
||||||
|
UL,ALK,SriLankan Airlines
|
||||||
|
UM,AZW,Air Zimbabwe
|
||||||
|
UN,TSO,Transaero Airlines
|
||||||
|
UO,HKE,Hong Kong Express Airways
|
||||||
|
UP,BHS,Bahamasair
|
||||||
|
UQ,OCM,O'Connor Airlines
|
||||||
|
US,USA,US Airways
|
||||||
|
UT,UTA,UTair Aviation
|
||||||
|
UU,REU,Air Austral
|
||||||
|
UX,AEA,Air Europa
|
||||||
|
UY,UYC,Cameroon Airlines
|
||||||
|
UZ,BRQ,El-Buraq Air Transport
|
||||||
|
V0,VCV,Conviasa
|
||||||
|
V1,VIA,VIA Líneas Aéreas
|
||||||
|
V2,AKT,Karat
|
||||||
|
V3,KRP,Carpatair
|
||||||
|
V4,REK,Reem Air
|
||||||
|
V5,VLI,Avolar Aerolineas
|
||||||
|
V7,SNG,Air Senegal International
|
||||||
|
V8,VAS,ATRAN Cargo Airlines
|
||||||
|
V9,BTC,BAL Bashkirian Airlines
|
||||||
|
VA,VAU,V Australia Airlines
|
||||||
|
VC,VCX,Ocean Airlines
|
||||||
|
VD,KPA,Kunpeng Airlines
|
||||||
|
VE,AVE,Avensa
|
||||||
|
VF,VLU,Valuair
|
||||||
|
VG,VLM,VLM Airlines
|
||||||
|
VH,VNP,Virgin Pacific
|
||||||
|
VI,VDA,Volga-Dnepr Airlines
|
||||||
|
VJ,JTY,Jatayu Airlines
|
||||||
|
VK,VGN,Virgin Nigeria Airways
|
||||||
|
VL,VIM,Air VIA
|
||||||
|
VM,VOA,Viaggio Air
|
||||||
|
VN,HVN,Vietnam Airlines
|
||||||
|
VO,TYR,Tyrolean Airways
|
||||||
|
VP,VSP,VASP
|
||||||
|
VQ,VKH,Viking Hellas
|
||||||
|
VR,TCV,TACV
|
||||||
|
VS,VIR,Virgin Atlantic Airways
|
||||||
|
VT,VTA,Air Tahiti
|
||||||
|
VU,VUN,Air Ivoire
|
||||||
|
VV,AEW,Aerosvit Airlines
|
||||||
|
VW,TAO,Aeromar
|
||||||
|
VX,VRD,Virgin America
|
||||||
|
VY,FOS,Formosa Airlines
|
||||||
|
VZ,MYT,MyTravel Airways
|
||||||
|
W1,WE1,World Experience Airline
|
||||||
|
W2,CWA,Canadian Western Airlines
|
||||||
|
W3,ARA,Arik Air
|
||||||
|
W4,BES,Aero Services Executive
|
||||||
|
W5,IRM,Mahan Air
|
||||||
|
W6,WZZ,Wizz Air
|
||||||
|
W8,CJT,Cargojet Airways
|
||||||
|
W9,AAB,Abelag Aviation
|
||||||
|
WA,KLC,KLM Cityhopper
|
||||||
|
WB,RWD,Rwandair Express
|
||||||
|
WC,ISV,Islena De Inversiones
|
||||||
|
WD,DSR,DAS Air Cargo
|
||||||
|
WE,CWC,Centurion Air Cargo
|
||||||
|
WF,WIF,Widerøe
|
||||||
|
WG,SWG,Sunwing Airlines
|
||||||
|
WH,CNW,China Northwest Airlines
|
||||||
|
WJ,WEB,WebJet Linhas A
|
||||||
|
WK,AFB,American Falcon
|
||||||
|
WL,FQR,CheapFlyingInternational
|
||||||
|
WN,SWA,Southwest Airlines
|
||||||
|
WO,WOA,World Airways
|
||||||
|
WP,MKU,Island Air (WP)
|
||||||
|
WQ,PQW,PanAm World Airways
|
||||||
|
WR,HRH,Royal Tongan Airlines
|
||||||
|
WS,WJA,WestJet
|
||||||
|
WU,WAU,Wizz Air Ukraine
|
||||||
|
WV,SWV,Swe Fly
|
||||||
|
WW,BMI,bmibaby
|
||||||
|
WX,BCY,CityJet
|
||||||
|
WY,OMA,Oman Air
|
||||||
|
WZ,RWZ,Red Wings
|
||||||
|
X3,HLX,TUIfly
|
||||||
|
X5,OTJ,Fly Romania
|
||||||
|
X7,CHF,Chitaavia
|
||||||
|
X9,X9F,FRA Air
|
||||||
|
XA,XAU,XAIR USA
|
||||||
|
XB,NXB,NEXT Brasil
|
||||||
|
XE,BTA,ExpressJet
|
||||||
|
XF,VLK,Vladivostok Air
|
||||||
|
XG,CLI,Calima Aviacion
|
||||||
|
XJ,MES,Mesaba Airlines
|
||||||
|
XK,CCM,Corse-Mediterranee
|
||||||
|
XL,LNE,Aerolane
|
||||||
|
XM,SMX,Alitalia Express
|
||||||
|
XO,CXH,China Xinhua Airlines
|
||||||
|
XP,CXP,Xtra Airways
|
||||||
|
XQ,SXS,SunExpress
|
||||||
|
XS,SIT,SITA
|
||||||
|
XT,AXL,Air Exel
|
||||||
|
XW,SXR,Sky Express
|
||||||
|
XX,GFY,Greenfly
|
||||||
|
XY,KNE,Nas Air
|
||||||
|
Y4,VOI,Volaris
|
||||||
|
Y5,PCE,Pace Airlines
|
||||||
|
Y8,YZR,Yangtze River Express
|
||||||
|
Y9,IRK,Kish Air
|
||||||
|
YC,YCC,Ciel Canadien
|
||||||
|
YE,YEL,Yellowtail
|
||||||
|
YL,LLM,Yamal Airlines
|
||||||
|
YM,MGX,Montenegro Airlines
|
||||||
|
YO,TYS,TransHolding System
|
||||||
|
YP,AEF,Aero Lloyd (YP)
|
||||||
|
YS,RAE,Régional
|
||||||
|
YT,TGA,Air Togo
|
||||||
|
YV,ASH,Mesa Airlines
|
||||||
|
YW,ANE,Air Nostrum
|
||||||
|
YX,MEP,Midwest Airlines
|
||||||
|
YY,VWA,Virginwings
|
||||||
|
YZ,YZZ,LSM AIRLINES
|
||||||
|
Z0,Z9H,All Argentina Express
|
||||||
|
Z3,SMJ,Avient Aviation
|
||||||
|
Z4,OOM,Zoom Airlines
|
||||||
|
Z5,IIR,INAVIA Internacional
|
||||||
|
Z6,ZTT,ZABAIKAL AIRLINES
|
||||||
|
Z7,ADK,ADC Airlines
|
||||||
|
Z8,AZN,Amaszonas
|
||||||
|
ZA,CYD,Access Air
|
||||||
|
ZB,BUB,Air Bourbon
|
||||||
|
ZC,KGO,Korongo Airlines
|
||||||
|
ZE,AZE,Arcus-Air Logistic
|
||||||
|
ZG,VVM,Viva Macau
|
||||||
|
ZH,CSZ,Shenzhen Airlines
|
||||||
|
ZI,AAF,Aigle Azur
|
||||||
|
ZK,GLA,Great Lakes Airlines
|
||||||
|
ZL,RXA,Regional Express
|
||||||
|
ZM,IWA,Apache Air
|
||||||
|
ZN,ZNA,Zenith International Airline
|
||||||
|
ZP,AZQ,Silk Way Airlines
|
||||||
|
ZQ,LOC,Locair
|
||||||
|
ZS,SMY,Sama Airlines
|
||||||
|
ZT,AWC,Titan Airways
|
||||||
|
ZU,HCY,Helios Airways
|
||||||
|
ZV,AMW,Air Midwest
|
||||||
|
ZW,AWI,Air Wisconsin
|
||||||
|
ZX,GGN,Air Georgian
|
||||||
|
ZY,ADE,Ada Air
|
||||||
|
+8257
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_flights (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued'
|
||||||
|
CHECK (status IN ('queued','pending_review','track_unavailable',
|
||||||
|
'approved','rejected','decode_failed')),
|
||||||
|
image_path TEXT NOT NULL,
|
||||||
|
raw_bcbp TEXT,
|
||||||
|
passenger_name TEXT,
|
||||||
|
pnr TEXT,
|
||||||
|
from_iata TEXT,
|
||||||
|
to_iata TEXT,
|
||||||
|
from_icao TEXT,
|
||||||
|
to_icao TEXT,
|
||||||
|
operating_carrier_iata TEXT,
|
||||||
|
operating_carrier_icao TEXT,
|
||||||
|
flight_number TEXT,
|
||||||
|
julian_day INTEGER,
|
||||||
|
flight_date TEXT,
|
||||||
|
known_departure_time TEXT,
|
||||||
|
compartment TEXT,
|
||||||
|
seat TEXT,
|
||||||
|
process_after TEXT,
|
||||||
|
fetch_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
track_coordinates TEXT,
|
||||||
|
track_times TEXT,
|
||||||
|
track_point_count INTEGER,
|
||||||
|
flightaware_url TEXT,
|
||||||
|
airtrail_flight_id INTEGER,
|
||||||
|
parse_warnings TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
reviewed_at TEXT,
|
||||||
|
approved_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pending_flights_status_process_after
|
||||||
|
ON pending_flights(status, process_after);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_connection() -> sqlite3.Connection:
|
||||||
|
config.ensure_dirs()
|
||||||
|
conn = sqlite3.connect(config.DB_PATH, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect():
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
with connect() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""PDF417 barcode decoding for boarding pass photos.
|
||||||
|
|
||||||
|
zxing-cpp is used as the primary (and only) decoder: it ships prebuilt wheels
|
||||||
|
for common platforms and has more reliable PDF417 support on real-world
|
||||||
|
skewed/glare phone photos than pyzbar/zbar. Kept behind this single function
|
||||||
|
so the library can be swapped later without touching call sites, per the
|
||||||
|
brief's note that decoder reliability should be validated against real
|
||||||
|
captures rather than assumed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
import zxingcpp
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
MIN_DIMENSION = 800
|
||||||
|
MAX_DIMENSION = 3000
|
||||||
|
|
||||||
|
# IATA Resolution 792 (BCBP) permits any of these 2D symbologies - PDF417 is
|
||||||
|
# typical for printed passes, Aztec/QR/DataMatrix are common for on-screen
|
||||||
|
# mobile passes (e.g. Emirates' app uses Aztec).
|
||||||
|
BARCODE_FORMATS = (
|
||||||
|
zxingcpp.BarcodeFormat.PDF417
|
||||||
|
| zxingcpp.BarcodeFormat.Aztec
|
||||||
|
| zxingcpp.BarcodeFormat.QRCode
|
||||||
|
| zxingcpp.BarcodeFormat.DataMatrix
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare(img: Image.Image) -> Image.Image:
|
||||||
|
img = ImageOps.exif_transpose(img)
|
||||||
|
img = img.convert("L")
|
||||||
|
w, h = img.size
|
||||||
|
if min(w, h) < MIN_DIMENSION:
|
||||||
|
scale = MIN_DIMENSION / min(w, h)
|
||||||
|
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
||||||
|
elif max(w, h) > MAX_DIMENSION:
|
||||||
|
scale = MAX_DIMENSION / max(w, h)
|
||||||
|
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def decode_boarding_pass(image_bytes: bytes) -> str | None:
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(image_bytes))
|
||||||
|
img = _prepare(img)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
results = zxingcpp.read_barcodes(img, formats=BARCODE_FORMATS)
|
||||||
|
if results:
|
||||||
|
return results[0].text
|
||||||
|
|
||||||
|
# One retry with autocontrast - cheap, catches glare/low-contrast photos.
|
||||||
|
contrasted = ImageOps.autocontrast(img)
|
||||||
|
results = zxingcpp.read_barcodes(contrasted, formats=BARCODE_FORMATS)
|
||||||
|
if results:
|
||||||
|
return results[0].text
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
boarding-pass:
|
||||||
|
build: .
|
||||||
|
container_name: boarding-pass
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: ${TZ:-Etc/UTC}
|
||||||
|
volumes:
|
||||||
|
- ./instance:/app/instance
|
||||||
|
# No host port published by default - reach this via NPMplus on the
|
||||||
|
# shared proxy network (see README for the network + bypass config).
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
"""Scrape a public FlightAware history page for a specific past flight's
|
||||||
|
track, without using AeroAPI (whose free tier excludes historical data).
|
||||||
|
|
||||||
|
Two requests per flight, verified against a real flight (QFA1) during design:
|
||||||
|
|
||||||
|
1. GET the flight's public activity page, which lists direct links to every
|
||||||
|
recent/upcoming specific instance of that flight number in the form
|
||||||
|
/live/flight/{callsign}/history/{YYYYMMDD}/{HHMM}Z/{FROM_ICAO}/{TO_ICAO}
|
||||||
|
- this sidesteps the fact that a BCBP barcode never encodes a
|
||||||
|
time-of-day, which the history URL otherwise requires.
|
||||||
|
2. GET that specific history URL + "/google_earth", which returns a KML file
|
||||||
|
with a gx:Track of <when>/<gx:coord> pairs.
|
||||||
|
|
||||||
|
Low volume by design (one flight per day) - no retry-hammering on failure,
|
||||||
|
a transient error just leaves the row queued for next day's run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
BASE = "https://www.flightaware.com"
|
||||||
|
KML_NAMESPACES = {
|
||||||
|
"kml": "http://www.opengis.net/kml/2.2",
|
||||||
|
"gx": "http://www.google.com/kml/ext/2.2",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FlightAwareTransientError(Exception):
|
||||||
|
"""Network/timeout/5xx - safe to retry on a later run."""
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch(url: str) -> bytes:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": config.FLIGHTAWARE_USER_AGENT})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||||
|
return resp.read()
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
raise
|
||||||
|
raise FlightAwareTransientError(f"HTTP {e.code} fetching {url}") from e
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise FlightAwareTransientError(f"error fetching {url}: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def find_history_url(
|
||||||
|
icao_callsign: str, flight_date: date, from_icao: str, to_icao: str
|
||||||
|
) -> str | None:
|
||||||
|
"""Returns the specific history page URL for this flight instance, or
|
||||||
|
None if no matching link is found on the activity page (definitive -
|
||||||
|
the flight isn't there, don't retry). Raises FlightAwareTransientError
|
||||||
|
on network-level failures (retry next run)."""
|
||||||
|
activity_url = f"{BASE}/live/flight/{icao_callsign}"
|
||||||
|
try:
|
||||||
|
html = _fetch(activity_url).decode("utf-8", errors="replace")
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
date_str = flight_date.strftime("%Y%m%d")
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"/live/flight/{re.escape(icao_callsign)}/history/{date_str}/"
|
||||||
|
rf"(\d{{4}})Z/{re.escape(from_icao)}/{re.escape(to_icao)}"
|
||||||
|
)
|
||||||
|
match = pattern.search(html)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
return f"{BASE}{match.group(0)}"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_kml(history_url: str) -> bytes | None:
|
||||||
|
"""Fetches the KML for a history URL found by find_history_url. Returns
|
||||||
|
None if the specific flight has no track available (definitive)."""
|
||||||
|
kml_url = history_url.rstrip("/") + "/google_earth"
|
||||||
|
try:
|
||||||
|
return _fetch(kml_url)
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kml(kml_bytes: bytes) -> tuple[list[list[float]], list[int]] | None:
|
||||||
|
"""Returns (coordinates, times) where coordinates is [[lon,lat,elev],...]
|
||||||
|
and times is [unix_seconds,...], aligned. None if no track found."""
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(kml_bytes)
|
||||||
|
except ET.ParseError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
track = root.find(".//gx:Track", KML_NAMESPACES)
|
||||||
|
if track is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
coordinates: list[list[float]] = []
|
||||||
|
times: list[int] = []
|
||||||
|
for child in track:
|
||||||
|
tag = child.tag.split("}")[-1]
|
||||||
|
if tag == "when":
|
||||||
|
ts = child.text.strip()
|
||||||
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
times.append(int(dt.timestamp()))
|
||||||
|
elif tag == "coord":
|
||||||
|
parts = child.text.strip().split()
|
||||||
|
lon, lat = float(parts[0]), float(parts[1])
|
||||||
|
elev = float(parts[2]) if len(parts) > 2 else 0.0
|
||||||
|
coordinates.append([lon, lat, elev])
|
||||||
|
|
||||||
|
if not coordinates:
|
||||||
|
return None
|
||||||
|
if len(times) != len(coordinates):
|
||||||
|
times = []
|
||||||
|
|
||||||
|
return coordinates, times
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
JSON_FIELDS = {"track_coordinates", "track_times", "parse_warnings"}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row) -> dict:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
for field in JSON_FIELDS:
|
||||||
|
if d.get(field):
|
||||||
|
try:
|
||||||
|
d[field] = json.loads(d[field])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
d[field] = None
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# --- settings -----------------------------------------------------------
|
||||||
|
|
||||||
|
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
with db.connect() as conn:
|
||||||
|
row = conn.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||||
|
return row["value"] if row else default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(key: str, value: str) -> None:
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_secret_key() -> str:
|
||||||
|
existing = get_setting("secret_key")
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
new_key = secrets.token_hex(32)
|
||||||
|
set_setting("secret_key", new_key)
|
||||||
|
return new_key
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {
|
||||||
|
"notify_backend": "ntfy",
|
||||||
|
"ntfy_server_url": "https://ntfy.sh",
|
||||||
|
"ntfy_topic": "",
|
||||||
|
"ntfy_auth_token": "",
|
||||||
|
"daily_run_time": "03:00",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_settings() -> dict:
|
||||||
|
return {key: get_setting(key, default) for key, default in DEFAULT_SETTINGS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# --- pending_flights ------------------------------------------------------
|
||||||
|
|
||||||
|
INSERT_FIELDS = [
|
||||||
|
"image_path", "raw_bcbp", "passenger_name", "pnr", "from_iata", "to_iata",
|
||||||
|
"from_icao", "to_icao", "operating_carrier_iata", "operating_carrier_icao",
|
||||||
|
"flight_number", "julian_day", "flight_date", "known_departure_time",
|
||||||
|
"compartment", "seat", "process_after", "parse_warnings", "error_message",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def create_pending_flight(**fields) -> int:
|
||||||
|
now = _now()
|
||||||
|
cols = ["created_at", "updated_at"]
|
||||||
|
vals = [now, now]
|
||||||
|
for f in INSERT_FIELDS:
|
||||||
|
if f in fields:
|
||||||
|
v = fields[f]
|
||||||
|
if f in JSON_FIELDS and v is not None:
|
||||||
|
v = json.dumps(v)
|
||||||
|
cols.append(f)
|
||||||
|
vals.append(v)
|
||||||
|
placeholders = ", ".join("?" for _ in cols)
|
||||||
|
with db.connect() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
f"INSERT INTO pending_flights ({', '.join(cols)}) VALUES ({placeholders})",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_flight(flight_id: int) -> dict | None:
|
||||||
|
with db.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM pending_flights WHERE id = ?", (flight_id,)
|
||||||
|
).fetchone()
|
||||||
|
return _row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def list_flights(statuses: list[str] | None = None) -> list[dict]:
|
||||||
|
with db.connect() as conn:
|
||||||
|
if statuses:
|
||||||
|
placeholders = ", ".join("?" for _ in statuses)
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM pending_flights WHERE status IN ({placeholders}) "
|
||||||
|
"ORDER BY created_at DESC",
|
||||||
|
statuses,
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM pending_flights ORDER BY created_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_due_flights(now_iso: str) -> list[dict]:
|
||||||
|
with db.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM pending_flights WHERE status = 'queued' AND process_after <= ?",
|
||||||
|
(now_iso,),
|
||||||
|
).fetchall()
|
||||||
|
return [_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def update_flight(flight_id: int, **fields) -> None:
|
||||||
|
if not fields:
|
||||||
|
return
|
||||||
|
cols = []
|
||||||
|
vals = []
|
||||||
|
for k, v in fields.items():
|
||||||
|
if k in JSON_FIELDS and v is not None and not isinstance(v, str):
|
||||||
|
v = json.dumps(v)
|
||||||
|
cols.append(f"{k} = ?")
|
||||||
|
vals.append(v)
|
||||||
|
cols.append("updated_at = ?")
|
||||||
|
vals.append(_now())
|
||||||
|
vals.append(flight_id)
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE pending_flights SET {', '.join(cols)} WHERE id = ?", vals
|
||||||
|
)
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
"""Notification backend abstraction. ntfy is the only implementation today,
|
||||||
|
but call sites always go through get_notifier() / notify_flight_staged() so
|
||||||
|
a second backend could be added later (a new class + registry entry) without
|
||||||
|
touching the pipeline code that triggers notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import config
|
||||||
|
import models
|
||||||
|
import tokens
|
||||||
|
|
||||||
|
|
||||||
|
class Notifier(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def send(self, title: str, body: str, url: str | None = None, actions: list[dict] | None = None) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class NtfyNotifier(Notifier):
|
||||||
|
def __init__(self, server_url: str, topic: str, auth_token: str | None = None):
|
||||||
|
self.server_url = server_url.rstrip("/")
|
||||||
|
self.topic = topic
|
||||||
|
self.auth_token = auth_token
|
||||||
|
|
||||||
|
def send(self, title: str, body: str, url: str | None = None, actions: list[dict] | None = None) -> None:
|
||||||
|
if not self.topic:
|
||||||
|
return # not configured yet - silently skip rather than crash the pipeline
|
||||||
|
|
||||||
|
payload = {"topic": self.topic, "title": title, "message": body}
|
||||||
|
if url:
|
||||||
|
payload["click"] = url
|
||||||
|
if actions:
|
||||||
|
payload["actions"] = actions
|
||||||
|
|
||||||
|
req = urllib.request.Request(
|
||||||
|
self.server_url,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if self.auth_token:
|
||||||
|
req.add_header("Authorization", f"Bearer {self.auth_token}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15):
|
||||||
|
pass
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||||
|
pass # best-effort; a failed push shouldn't crash the pipeline run
|
||||||
|
|
||||||
|
|
||||||
|
def get_notifier() -> Notifier:
|
||||||
|
settings = models.get_all_settings()
|
||||||
|
# Only one backend exists today; settings.notify_backend is read here so
|
||||||
|
# a second implementation can be switched in later without changing
|
||||||
|
# call sites.
|
||||||
|
return NtfyNotifier(
|
||||||
|
server_url=settings["ntfy_server_url"],
|
||||||
|
topic=settings["ntfy_topic"],
|
||||||
|
auth_token=settings["ntfy_auth_token"] or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def flight_url(flight_id: int) -> str | None:
|
||||||
|
if not config.PUBLIC_BASE_URL:
|
||||||
|
return None
|
||||||
|
return f"{config.PUBLIC_BASE_URL}/flights/{flight_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def notify_flight_staged(row: dict) -> None:
|
||||||
|
flight_id = row["id"]
|
||||||
|
route = f"{row.get('from_iata') or '?'}→{row.get('to_iata') or '?'}"
|
||||||
|
flight_number = f"{row.get('operating_carrier_iata') or ''}{row.get('flight_number') or ''}"
|
||||||
|
title = f"{flight_number or 'Flight'} staged for review"
|
||||||
|
|
||||||
|
if row["status"] == "track_unavailable":
|
||||||
|
summary = "no track found"
|
||||||
|
else:
|
||||||
|
count = row.get("track_point_count") or 0
|
||||||
|
summary = f"track found, {count} points"
|
||||||
|
|
||||||
|
body = f"{route} · {row.get('flight_date') or '?'} · {summary}"
|
||||||
|
|
||||||
|
review_url = flight_url(flight_id)
|
||||||
|
actions = []
|
||||||
|
if review_url:
|
||||||
|
actions.append({"action": "view", "label": "Investigate", "url": review_url})
|
||||||
|
|
||||||
|
base = config.PUBLIC_BASE_URL
|
||||||
|
if base:
|
||||||
|
approve_token = tokens.sign_token(flight_id, "approve")
|
||||||
|
reject_token = tokens.sign_token(flight_id, "reject")
|
||||||
|
actions.append({
|
||||||
|
"action": "http", "label": "Approve", "method": "POST", "clear": True,
|
||||||
|
"url": f"{base}/flights/{flight_id}/approve?token={approve_token}",
|
||||||
|
})
|
||||||
|
actions.append({
|
||||||
|
"action": "http", "label": "Deny", "method": "POST", "clear": True,
|
||||||
|
"url": f"{base}/flights/{flight_id}/reject?token={reject_token}",
|
||||||
|
})
|
||||||
|
|
||||||
|
get_notifier().send(title=title, body=body, url=review_url, actions=actions or None)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""IATA <-> ICAO lookups for airports and airlines, loaded once from the
|
||||||
|
trimmed CSVs in data/ (see scripts/build_reference_data.py). No network
|
||||||
|
access at runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||||
|
|
||||||
|
# Known-correct overrides, checked before the bundled CSV. The CSV (OpenFlights)
|
||||||
|
# can lag real-world rebrands - e.g. it still maps VA to the ICAO code of the
|
||||||
|
# defunct "V Australia Airlines" (VAU) rather than the current Virgin
|
||||||
|
# Australia code (VOZ). These are the examples called out in the brief.
|
||||||
|
CARRIER_ICAO_OVERRIDES = {
|
||||||
|
"QF": "QFA",
|
||||||
|
"VA": "VOZ",
|
||||||
|
"JQ": "JST",
|
||||||
|
"EK": "UAE",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_csv(path: Path) -> dict[str, tuple[str, str]]:
|
||||||
|
"""Returns iata -> (icao, name)."""
|
||||||
|
result = {}
|
||||||
|
if not path.exists():
|
||||||
|
return result
|
||||||
|
with path.open(newline="", encoding="utf-8") as f:
|
||||||
|
for row in csv.DictReader(f):
|
||||||
|
iata = (row.get("iata") or "").strip().upper()
|
||||||
|
icao = (row.get("icao") or "").strip().upper()
|
||||||
|
name = (row.get("name") or "").strip()
|
||||||
|
if iata and icao:
|
||||||
|
result[iata] = (icao, name)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_AIRPORTS = _load_csv(DATA_DIR / "airports.csv")
|
||||||
|
_AIRLINES = _load_csv(DATA_DIR / "airlines.csv")
|
||||||
|
|
||||||
|
|
||||||
|
def iata_to_icao_airport(iata_code: str) -> str | None:
|
||||||
|
if not iata_code:
|
||||||
|
return None
|
||||||
|
entry = _AIRPORTS.get(iata_code.strip().upper())
|
||||||
|
return entry[0] if entry else None
|
||||||
|
|
||||||
|
|
||||||
|
def airport_name(iata_code: str) -> str | None:
|
||||||
|
if not iata_code:
|
||||||
|
return None
|
||||||
|
entry = _AIRPORTS.get(iata_code.strip().upper())
|
||||||
|
return entry[1] if entry else None
|
||||||
|
|
||||||
|
|
||||||
|
def iata_to_icao_airline(iata_code: str) -> str | None:
|
||||||
|
if not iata_code:
|
||||||
|
return None
|
||||||
|
code = iata_code.strip().upper()
|
||||||
|
if code in CARRIER_ICAO_OVERRIDES:
|
||||||
|
return CARRIER_ICAO_OVERRIDES[code]
|
||||||
|
entry = _AIRLINES.get(code)
|
||||||
|
return entry[0] if entry else None
|
||||||
|
|
||||||
|
|
||||||
|
def airline_name(iata_code: str) -> str | None:
|
||||||
|
if not iata_code:
|
||||||
|
return None
|
||||||
|
entry = _AIRLINES.get(iata_code.strip().upper())
|
||||||
|
return entry[1] if entry else None
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
flask==3.0.*
|
||||||
|
waitress==3.0.*
|
||||||
|
zxing-cpp==2.2.*
|
||||||
|
pillow==10.*
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""GET /flights (list), GET/POST /flights/<id> (detail/edit/requery), and the
|
||||||
|
approve/reject callbacks used both by the review page's own buttons and by
|
||||||
|
ntfy's action buttons.
|
||||||
|
|
||||||
|
Approve/reject require a signed token (see tokens.py) with NO special-casing
|
||||||
|
for "this came from the authenticated review page" - the reverse-proxy bypass
|
||||||
|
needed for ntfy's unauthenticated callback applies to the whole path, so the
|
||||||
|
token is the only thing standing between the internet and these two routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
import airtrail_client
|
||||||
|
import models
|
||||||
|
import reference_data
|
||||||
|
import scheduler
|
||||||
|
import tokens
|
||||||
|
|
||||||
|
bp = Blueprint("review", __name__)
|
||||||
|
|
||||||
|
REVIEWABLE_STATUSES = ["pending_review", "track_unavailable", "decode_failed"]
|
||||||
|
# A flight can only be approved once the FlightAware fetch has actually been
|
||||||
|
# attempted (daily job or manual re-query) - never straight from 'queued',
|
||||||
|
# so nothing reaches AirTrail before we know whether a track exists.
|
||||||
|
APPROVABLE_STATUSES = ("pending_review", "track_unavailable")
|
||||||
|
ALL_STATUSES = [
|
||||||
|
"queued", "pending_review", "track_unavailable", "approved", "rejected", "decode_failed",
|
||||||
|
]
|
||||||
|
|
||||||
|
EDITABLE_FIELDS = [
|
||||||
|
"passenger_name", "pnr", "from_iata", "to_iata", "operating_carrier_iata",
|
||||||
|
"flight_number", "flight_date", "compartment", "seat",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_404(flight_id: int) -> dict:
|
||||||
|
row = models.get_flight(flight_id)
|
||||||
|
if not row:
|
||||||
|
abort(404)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/flights")
|
||||||
|
def list_flights():
|
||||||
|
status = request.args.get("status")
|
||||||
|
if status and status in ALL_STATUSES:
|
||||||
|
rows = models.list_flights([status])
|
||||||
|
elif status == "all":
|
||||||
|
rows = models.list_flights()
|
||||||
|
else:
|
||||||
|
rows = models.list_flights(REVIEWABLE_STATUSES)
|
||||||
|
status = None
|
||||||
|
return render_template(
|
||||||
|
"review_list.html", flights=rows, active_status=status, statuses=ALL_STATUSES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/flights/<int:flight_id>")
|
||||||
|
def detail(flight_id: int):
|
||||||
|
row = _get_or_404(flight_id)
|
||||||
|
approve_token = tokens.sign_token(flight_id, "approve")
|
||||||
|
reject_token = tokens.sign_token(flight_id, "reject")
|
||||||
|
return render_template(
|
||||||
|
"review_detail.html", flight=row, approve_token=approve_token, reject_token=reject_token
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/flights/<int:flight_id>/edit", methods=["POST"])
|
||||||
|
def edit(flight_id: int):
|
||||||
|
row = _get_or_404(flight_id)
|
||||||
|
updates = {}
|
||||||
|
for f in EDITABLE_FIELDS:
|
||||||
|
if f in request.form:
|
||||||
|
value = request.form[f].strip()
|
||||||
|
updates[f] = value or None
|
||||||
|
|
||||||
|
from_iata = updates.get("from_iata", row.get("from_iata"))
|
||||||
|
to_iata = updates.get("to_iata", row.get("to_iata"))
|
||||||
|
carrier_iata = updates.get("operating_carrier_iata", row.get("operating_carrier_iata"))
|
||||||
|
updates["from_icao"] = reference_data.iata_to_icao_airport(from_iata)
|
||||||
|
updates["to_icao"] = reference_data.iata_to_icao_airport(to_iata)
|
||||||
|
updates["operating_carrier_icao"] = reference_data.iata_to_icao_airline(carrier_iata)
|
||||||
|
|
||||||
|
flight_date_str = updates.get("flight_date", row.get("flight_date"))
|
||||||
|
if flight_date_str:
|
||||||
|
try:
|
||||||
|
flight_date = date.fromisoformat(flight_date_str)
|
||||||
|
updates["process_after"] = datetime.combine(
|
||||||
|
flight_date + timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
models.update_flight(flight_id, **updates)
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/flights/<int:flight_id>/requery", methods=["POST"])
|
||||||
|
def requery(flight_id: int):
|
||||||
|
row = _get_or_404(flight_id)
|
||||||
|
models.update_flight(flight_id, status="queued", error_message=None)
|
||||||
|
scheduler.process_one_flight(models.get_flight(flight_id))
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/flights/<int:flight_id>/approve", methods=["POST"])
|
||||||
|
def approve(flight_id: int):
|
||||||
|
token = request.values.get("token", "")
|
||||||
|
if not tokens.verify_token(flight_id, "approve", token):
|
||||||
|
abort(403)
|
||||||
|
row = _get_or_404(flight_id)
|
||||||
|
if row["status"] not in APPROVABLE_STATUSES and row["status"] != "approved":
|
||||||
|
abort(409, "flight hasn't been fetched from FlightAware yet")
|
||||||
|
ok, message = airtrail_client.approve_flight(flight_id)
|
||||||
|
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||||
|
return {"success": ok, "message": message}
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/flights/<int:flight_id>/reject", methods=["POST"])
|
||||||
|
def reject(flight_id: int):
|
||||||
|
token = request.values.get("token", "")
|
||||||
|
if not tokens.verify_token(flight_id, "reject", token):
|
||||||
|
abort(403)
|
||||||
|
ok, message = airtrail_client.reject_flight(flight_id)
|
||||||
|
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
|
||||||
|
return {"success": ok, "message": message}
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""GET/POST /settings - notification backend configuration, plus a
|
||||||
|
send-test-notification action."""
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
import models
|
||||||
|
import notifier
|
||||||
|
|
||||||
|
bp = Blueprint("settings", __name__)
|
||||||
|
|
||||||
|
FORM_FIELDS = ["ntfy_server_url", "ntfy_topic", "ntfy_auth_token", "daily_run_time"]
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/settings", methods=["GET", "POST"])
|
||||||
|
def settings():
|
||||||
|
message = None
|
||||||
|
if request.method == "POST":
|
||||||
|
if request.form.get("action") == "test":
|
||||||
|
notifier.get_notifier().send(
|
||||||
|
title="Test notification",
|
||||||
|
body="This is a test notification from boarding-pass-pipeline.",
|
||||||
|
url=None,
|
||||||
|
)
|
||||||
|
message = "Test notification sent."
|
||||||
|
else:
|
||||||
|
for f in FORM_FIELDS:
|
||||||
|
if f in request.form:
|
||||||
|
models.set_setting(f, request.form[f].strip())
|
||||||
|
message = "Settings saved."
|
||||||
|
|
||||||
|
return render_template("settings.html", settings=models.get_all_settings(), message=message)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""GET/POST /upload - photograph or pick a boarding pass image, decode it,
|
||||||
|
and queue it for the daily FlightAware job."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
import bcbp
|
||||||
|
import config
|
||||||
|
import decode
|
||||||
|
import models
|
||||||
|
import reference_data
|
||||||
|
|
||||||
|
bp = Blueprint("upload", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/upload", methods=["GET", "POST"])
|
||||||
|
def upload():
|
||||||
|
if request.method == "GET":
|
||||||
|
return render_template("upload.html")
|
||||||
|
|
||||||
|
file = request.files.get("photo")
|
||||||
|
if not file or not file.filename:
|
||||||
|
return render_template("upload.html", error="No photo selected."), 400
|
||||||
|
|
||||||
|
image_bytes = file.read()
|
||||||
|
upload_dt = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
image_name = f"{uuid.uuid4().hex}.jpg"
|
||||||
|
image_path = config.UPLOADS_DIR / image_name
|
||||||
|
config.ensure_dirs()
|
||||||
|
with open(image_path, "wb") as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
raw_bcbp = decode.decode_boarding_pass(image_bytes)
|
||||||
|
|
||||||
|
if not raw_bcbp:
|
||||||
|
flight_id = models.create_pending_flight(
|
||||||
|
image_path=image_name,
|
||||||
|
status="decode_failed",
|
||||||
|
error_message="Could not detect a PDF417 barcode in the photo.",
|
||||||
|
)
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = bcbp.parse_bcbp(raw_bcbp, upload_dt=upload_dt)
|
||||||
|
except bcbp.BCBPParseError as e:
|
||||||
|
flight_id = models.create_pending_flight(
|
||||||
|
image_path=image_name,
|
||||||
|
raw_bcbp=raw_bcbp,
|
||||||
|
status="decode_failed",
|
||||||
|
error_message=str(e),
|
||||||
|
)
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
|
|
||||||
|
if parsed.julian_day is None:
|
||||||
|
flight_id = models.create_pending_flight(
|
||||||
|
image_path=image_name,
|
||||||
|
raw_bcbp=raw_bcbp,
|
||||||
|
passenger_name=parsed.passenger_name,
|
||||||
|
pnr=parsed.pnr,
|
||||||
|
from_iata=parsed.from_iata,
|
||||||
|
to_iata=parsed.to_iata,
|
||||||
|
operating_carrier_iata=parsed.operating_carrier,
|
||||||
|
flight_number=parsed.flight_number,
|
||||||
|
compartment=parsed.compartment,
|
||||||
|
seat=parsed.seat,
|
||||||
|
parse_warnings=parsed.warnings,
|
||||||
|
status="decode_failed",
|
||||||
|
error_message="Could not determine the flight date from the barcode.",
|
||||||
|
)
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
|
|
||||||
|
flight_date = bcbp.resolve_flight_date(parsed.julian_day, upload_dt)
|
||||||
|
process_after = datetime.combine(
|
||||||
|
flight_date + timedelta(days=1), datetime.min.time(), tzinfo=timezone.utc
|
||||||
|
).isoformat()
|
||||||
|
|
||||||
|
carrier_icao = reference_data.iata_to_icao_airline(parsed.operating_carrier)
|
||||||
|
from_icao = reference_data.iata_to_icao_airport(parsed.from_iata)
|
||||||
|
to_icao = reference_data.iata_to_icao_airport(parsed.to_iata)
|
||||||
|
|
||||||
|
flight_id = models.create_pending_flight(
|
||||||
|
image_path=image_name,
|
||||||
|
raw_bcbp=raw_bcbp,
|
||||||
|
passenger_name=parsed.passenger_name,
|
||||||
|
pnr=parsed.pnr,
|
||||||
|
from_iata=parsed.from_iata,
|
||||||
|
to_iata=parsed.to_iata,
|
||||||
|
from_icao=from_icao,
|
||||||
|
to_icao=to_icao,
|
||||||
|
operating_carrier_iata=parsed.operating_carrier,
|
||||||
|
operating_carrier_icao=carrier_icao,
|
||||||
|
flight_number=parsed.flight_number,
|
||||||
|
julian_day=parsed.julian_day,
|
||||||
|
flight_date=flight_date.isoformat(),
|
||||||
|
compartment=parsed.compartment,
|
||||||
|
seat=parsed.seat,
|
||||||
|
process_after=process_after,
|
||||||
|
parse_warnings=parsed.warnings,
|
||||||
|
status="queued",
|
||||||
|
)
|
||||||
|
return redirect(url_for("review.detail", flight_id=flight_id))
|
||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
"""Daily queue-processing job (stages 3-6 of the pipeline) plus the daemon
|
||||||
|
thread that runs it once a day. No cron/APScheduler dependency - a single
|
||||||
|
background thread sleeping until the next configured run time is enough for
|
||||||
|
a one-flight-a-day, single-container workload.
|
||||||
|
|
||||||
|
process_one_flight() is also called synchronously, for a single row, by the
|
||||||
|
review page's "re-query" button - same function, so a corrected field
|
||||||
|
(wrong year, fixed airport code, etc.) re-runs exactly the same lookup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
import config
|
||||||
|
import flightaware
|
||||||
|
import models
|
||||||
|
import notifier
|
||||||
|
import reference_data
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def next_run_datetime(hhmm: str, now: datetime | None = None) -> datetime:
|
||||||
|
now = now or datetime.now()
|
||||||
|
try:
|
||||||
|
hour, minute = (int(p) for p in hhmm.split(":"))
|
||||||
|
except ValueError:
|
||||||
|
hour, minute = 3, 0
|
||||||
|
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
|
if candidate <= now:
|
||||||
|
candidate += timedelta(days=1)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def process_one_flight(row: dict) -> None:
|
||||||
|
flight_id = row["id"]
|
||||||
|
|
||||||
|
from_icao = row.get("from_icao") or reference_data.iata_to_icao_airport(row.get("from_iata"))
|
||||||
|
to_icao = row.get("to_icao") or reference_data.iata_to_icao_airport(row.get("to_iata"))
|
||||||
|
carrier_icao = row.get("operating_carrier_icao") or reference_data.iata_to_icao_airline(
|
||||||
|
row.get("operating_carrier_iata")
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = {"from_icao": from_icao, "to_icao": to_icao, "operating_carrier_icao": carrier_icao}
|
||||||
|
|
||||||
|
if not from_icao or not to_icao or not carrier_icao:
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, val in (("from airport", from_icao), ("to airport", to_icao), ("carrier", carrier_icao))
|
||||||
|
if not val
|
||||||
|
]
|
||||||
|
updates["status"] = "track_unavailable"
|
||||||
|
updates["error_message"] = f"could not resolve ICAO code(s) for: {', '.join(missing)}"
|
||||||
|
models.update_flight(flight_id, **updates)
|
||||||
|
notifier.notify_flight_staged(models.get_flight(flight_id))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
flight_date = date.fromisoformat(row["flight_date"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
updates["status"] = "track_unavailable"
|
||||||
|
updates["error_message"] = f"invalid flight_date: {row.get('flight_date')!r}"
|
||||||
|
models.update_flight(flight_id, **updates)
|
||||||
|
notifier.notify_flight_staged(models.get_flight(flight_id))
|
||||||
|
return
|
||||||
|
|
||||||
|
icao_callsign = f"{carrier_icao}{row.get('flight_number') or ''}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
history_url = flightaware.find_history_url(icao_callsign, flight_date, from_icao, to_icao)
|
||||||
|
kml_bytes = flightaware.fetch_kml(history_url) if history_url else None
|
||||||
|
track = flightaware.parse_kml(kml_bytes) if kml_bytes else None
|
||||||
|
except flightaware.FlightAwareTransientError as e:
|
||||||
|
attempts = row.get("fetch_attempts", 0) + 1
|
||||||
|
updates["fetch_attempts"] = attempts
|
||||||
|
if attempts >= config.MAX_FETCH_ATTEMPTS:
|
||||||
|
updates["status"] = "track_unavailable"
|
||||||
|
updates["error_message"] = f"gave up after {attempts} attempts: {e}"
|
||||||
|
models.update_flight(flight_id, **updates)
|
||||||
|
notifier.notify_flight_staged(models.get_flight(flight_id))
|
||||||
|
else:
|
||||||
|
updates["error_message"] = str(e)
|
||||||
|
models.update_flight(flight_id, **updates)
|
||||||
|
return
|
||||||
|
|
||||||
|
if history_url:
|
||||||
|
updates["flightaware_url"] = history_url
|
||||||
|
|
||||||
|
if track is None:
|
||||||
|
updates["status"] = "track_unavailable"
|
||||||
|
updates["error_message"] = updates.get("error_message") or "no track found on FlightAware"
|
||||||
|
else:
|
||||||
|
coordinates, times = track
|
||||||
|
updates["status"] = "pending_review"
|
||||||
|
updates["track_coordinates"] = coordinates
|
||||||
|
updates["track_times"] = times
|
||||||
|
updates["track_point_count"] = len(coordinates)
|
||||||
|
updates["error_message"] = None
|
||||||
|
|
||||||
|
models.update_flight(flight_id, **updates)
|
||||||
|
notifier.notify_flight_staged(models.get_flight(flight_id))
|
||||||
|
|
||||||
|
|
||||||
|
def run_daily_job() -> None:
|
||||||
|
now_iso = datetime.now().isoformat()
|
||||||
|
due = models.list_due_flights(now_iso)
|
||||||
|
log.info("daily job: %d flight(s) due", len(due))
|
||||||
|
for row in due:
|
||||||
|
try:
|
||||||
|
process_one_flight(row)
|
||||||
|
except Exception:
|
||||||
|
log.exception("failed to process flight %s", row.get("id"))
|
||||||
|
|
||||||
|
|
||||||
|
def scheduler_loop() -> None:
|
||||||
|
while True:
|
||||||
|
run_time = models.get_setting("daily_run_time", "03:00")
|
||||||
|
run_at = next_run_datetime(run_time)
|
||||||
|
sleep_seconds = max((run_at - datetime.now()).total_seconds(), 1)
|
||||||
|
time.sleep(sleep_seconds)
|
||||||
|
try:
|
||||||
|
run_daily_job()
|
||||||
|
except Exception:
|
||||||
|
log.exception("daily job crashed")
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_thread() -> threading.Thread:
|
||||||
|
thread = threading.Thread(target=scheduler_loop, name="scheduler", daemon=True)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""One-off dev script: fetch and trim public airport/airline reference data.
|
||||||
|
|
||||||
|
Not run inside the container - output is committed to data/*.csv and loaded
|
||||||
|
at runtime by reference_data.py with zero network dependency.
|
||||||
|
|
||||||
|
Usage: python3 scripts/build_reference_data.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
|
||||||
|
AIRPORTS_URL = "https://davidmegginson.github.io/ourairports-data/airports.csv"
|
||||||
|
AIRLINES_URL = "https://raw.githubusercontent.com/jpatokal/openflights/master/data/airlines.dat"
|
||||||
|
|
||||||
|
USER_AGENT = "Mozilla/5.0 (compatible; boarding-pass-pipeline/1.0)"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str) -> bytes:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
def build_airports() -> None:
|
||||||
|
raw = fetch(AIRPORTS_URL).decode("utf-8", errors="replace")
|
||||||
|
reader = csv.DictReader(io.StringIO(raw))
|
||||||
|
out_path = DATA_DIR / "airports.csv"
|
||||||
|
seen = set()
|
||||||
|
rows = []
|
||||||
|
for row in reader:
|
||||||
|
iata = (row.get("iata_code") or "").strip().upper()
|
||||||
|
icao = (row.get("ident") or row.get("gps_code") or "").strip().upper()
|
||||||
|
name = (row.get("name") or "").strip()
|
||||||
|
if not iata or not icao or len(iata) != 3 or len(icao) != 4:
|
||||||
|
continue
|
||||||
|
if iata in seen:
|
||||||
|
continue
|
||||||
|
seen.add(iata)
|
||||||
|
rows.append((iata, icao, name))
|
||||||
|
rows.sort(key=lambda r: r[0])
|
||||||
|
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(["iata", "icao", "name"])
|
||||||
|
writer.writerows(rows)
|
||||||
|
print(f"wrote {len(rows)} airports to {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def build_airlines() -> None:
|
||||||
|
raw = fetch(AIRLINES_URL).decode("utf-8", errors="replace")
|
||||||
|
reader = csv.reader(io.StringIO(raw))
|
||||||
|
out_path = DATA_DIR / "airlines.csv"
|
||||||
|
seen = set()
|
||||||
|
rows = []
|
||||||
|
for cols in reader:
|
||||||
|
# OpenFlights airlines.dat columns (no header):
|
||||||
|
# id, name, alias, iata, icao, callsign, country, active
|
||||||
|
if len(cols) < 5:
|
||||||
|
continue
|
||||||
|
name = cols[1].strip().strip('"')
|
||||||
|
iata = cols[3].strip().strip('"').upper()
|
||||||
|
icao = cols[4].strip().strip('"').upper()
|
||||||
|
if not iata or not icao or iata == r"\N" or icao == r"\N":
|
||||||
|
continue
|
||||||
|
if len(iata) != 2 or len(icao) != 3:
|
||||||
|
continue
|
||||||
|
if iata in seen:
|
||||||
|
continue
|
||||||
|
seen.add(iata)
|
||||||
|
rows.append((iata, icao, name))
|
||||||
|
rows.sort(key=lambda r: r[0])
|
||||||
|
with out_path.open("w", newline="", encoding="utf-8") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(["iata", "icao", "name"])
|
||||||
|
writer.writerows(rows)
|
||||||
|
print(f"wrote {len(rows)} airlines to {out_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
build_airports()
|
||||||
|
build_airlines()
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
/* Design tokens - kept as CSS custom properties under these exact names so a
|
||||||
|
real apointless.css can drop in later without touching any template. */
|
||||||
|
:root {
|
||||||
|
--bg: #0b0d11;
|
||||||
|
--surface-1: #12151b;
|
||||||
|
--surface-2: #1a1e28;
|
||||||
|
--surface-3: #232838;
|
||||||
|
--border: #2a3040;
|
||||||
|
--text-primary: #e6e9ef;
|
||||||
|
--text-secondary: #9aa3b2;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #5b95f7;
|
||||||
|
--success: #22c55e;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--font-body: "DM Sans", system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.light {
|
||||||
|
--bg: #f5f6f8;
|
||||||
|
--surface-1: #ffffff;
|
||||||
|
--surface-2: #f0f1f4;
|
||||||
|
--surface-3: #e5e7eb;
|
||||||
|
--border: #d7dae0;
|
||||||
|
--text-primary: #14161a;
|
||||||
|
--text-secondary: #565d6b;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-hover: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--accent-hover); }
|
||||||
|
|
||||||
|
.muted { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.9rem 1.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-nav { display: flex; align-items: center; gap: 1.25rem; }
|
||||||
|
.topbar-nav a { color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.topbar-nav a:hover { color: var(--text-primary); }
|
||||||
|
.brand { color: var(--text-primary) !important; font-weight: 700; }
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--surface-3); }
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { margin-top: 0; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--surface-1);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.alert-danger { background: rgba(239, 68, 68, 0.12); border-color: var(--danger); color: var(--danger); }
|
||||||
|
.alert-warning { background: rgba(245, 158, 11, 0.12); border-color: var(--warning); color: var(--warning); }
|
||||||
|
.alert-success { background: rgba(34, 197, 94, 0.12); border-color: var(--success); color: var(--success); }
|
||||||
|
.alert ul { margin: 0.4rem 0 0; padding-left: 1.2rem; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--surface-3); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-hover); }
|
||||||
|
.btn-success { background: var(--success); border-color: var(--success); color: #06210f; }
|
||||||
|
.btn-danger { background: var(--danger); border-color: var(--danger); color: #2a0a0a; }
|
||||||
|
.btn-secondary { background: var(--surface-2); }
|
||||||
|
|
||||||
|
.upload-form { display: flex; flex-direction: column; gap: 1rem; align-items: flex-start; }
|
||||||
|
.upload-drop {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 10rem;
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.upload-drop:hover { border-color: var(--accent); color: var(--text-primary); }
|
||||||
|
.upload-drop input { display: none; }
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.tab:hover { background: var(--surface-2); color: var(--text-primary); }
|
||||||
|
.tab-active { background: var(--accent); color: #fff; }
|
||||||
|
.tab-active:hover { background: var(--accent-hover); color: #fff; }
|
||||||
|
|
||||||
|
.flight-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||||
|
.flight-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.flight-row:hover { border-color: var(--accent); }
|
||||||
|
.flight-route { font-family: var(--font-mono); }
|
||||||
|
.flight-number { font-family: var(--font-mono); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
background: var(--surface-3);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.badge-pending_review { background: rgba(59, 130, 246, 0.15); color: var(--accent); }
|
||||||
|
.badge-track_unavailable { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
|
||||||
|
.badge-decode_failed { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||||
|
.badge-approved { background: rgba(34, 197, 94, 0.15); color: var(--success); }
|
||||||
|
.badge-rejected { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
|
||||||
|
.badge-queued { background: rgba(154, 163, 178, 0.15); color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.back-link { display: inline-block; margin-bottom: 1rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.detail-header h1 { display: flex; align-items: center; gap: 0.75rem; font-size: 1.5rem; }
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.map-card { grid-column: 1 / -1; }
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
.flight-row { grid-template-columns: auto 1fr; grid-template-rows: auto auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-form { display: flex; flex-direction: column; gap: 0.85rem; }
|
||||||
|
.edit-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.field-row { display: flex; gap: 1rem; }
|
||||||
|
.field-row label { flex: 1; }
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 0.35rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.meta-list dt { color: var(--text-secondary); }
|
||||||
|
.meta-list dd { margin: 0; font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.bp-photo { width: 100%; border-radius: var(--radius); display: block; }
|
||||||
|
.raw-bcbp { margin-top: 1rem; }
|
||||||
|
.raw-bcbp pre {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#track-map { height: 360px; border-radius: var(--radius); overflow: hidden; }
|
||||||
|
|
||||||
|
.action-bar { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.settings-form { display: flex; flex-direction: column; gap: 1rem; max-width: 480px; }
|
||||||
|
.settings-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.settings-actions { display: flex; gap: 0.75rem; }
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
(function () {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var toggle = document.getElementById("theme-toggle");
|
||||||
|
if (toggle) {
|
||||||
|
toggle.addEventListener("click", function () {
|
||||||
|
var isLight = root.classList.contains("light");
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
var next = isLight ? "dark" : "light";
|
||||||
|
root.classList.add(next);
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var mapEl = document.getElementById("track-map");
|
||||||
|
if (mapEl && window.L) {
|
||||||
|
var coords = JSON.parse(mapEl.dataset.track || "[]");
|
||||||
|
// KML/GeoJSON order is [lon, lat, elev?]; Leaflet wants [lat, lon].
|
||||||
|
var latLngs = coords.map(function (c) { return [c[1], c[0]]; });
|
||||||
|
if (latLngs.length) {
|
||||||
|
var map = L.map(mapEl);
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: "© OpenStreetMap contributors",
|
||||||
|
maxZoom: 18,
|
||||||
|
}).addTo(map);
|
||||||
|
var line = L.polyline(latLngs, { color: "#3b82f6", weight: 3 }).addTo(map);
|
||||||
|
map.fitBounds(line.getBounds(), { padding: [20, 20] });
|
||||||
|
L.marker(latLngs[0]).addTo(map);
|
||||||
|
L.marker(latLngs[latLngs.length - 1]).addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}Boarding Pass{% endblock %}</title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var saved = localStorage.getItem("theme");
|
||||||
|
if (saved === "light" || saved === "dark") {
|
||||||
|
document.documentElement.classList.add(saved);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<nav class="topbar-nav">
|
||||||
|
<a href="{{ url_for('upload.upload') }}" class="brand">Boarding Pass</a>
|
||||||
|
<a href="{{ url_for('upload.upload') }}">Upload</a>
|
||||||
|
<a href="{{ url_for('review.list_flights') }}">Review</a>
|
||||||
|
<a href="{{ url_for('settings.settings') }}">Settings</a>
|
||||||
|
</nav>
|
||||||
|
<button type="button" id="theme-toggle" class="icon-btn" aria-label="Toggle theme">☼</button>
|
||||||
|
</header>
|
||||||
|
<main class="page">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Flight {{ flight.id }} — Boarding Pass{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
{% if flight.track_coordinates %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<a href="{{ url_for('review.list_flights') }}" class="back-link">← All flights</a>
|
||||||
|
|
||||||
|
<div class="detail-header">
|
||||||
|
<h1>{{ flight.from_iata or '?' }} → {{ flight.to_iata or '?' }}
|
||||||
|
<span class="badge badge-{{ flight.status }}">{{ flight.status.replace('_', ' ') }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="muted">Flight #{{ flight.id }} · created {{ flight.created_at }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if flight.error_message %}
|
||||||
|
<div class="alert alert-danger">{{ flight.error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if flight.parse_warnings %}
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<strong>Parse warnings:</strong>
|
||||||
|
<ul>{% for w in flight.parse_warnings %}<li>{{ w }}</li>{% endfor %}</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Details</h2>
|
||||||
|
<form method="post" action="{{ url_for('review.edit', flight_id=flight.id) }}" class="edit-form">
|
||||||
|
<label>Passenger name <input name="passenger_name" value="{{ flight.passenger_name or '' }}"></label>
|
||||||
|
<label>PNR <input name="pnr" value="{{ flight.pnr or '' }}"></label>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>From (IATA) <input name="from_iata" value="{{ flight.from_iata or '' }}" maxlength="3"></label>
|
||||||
|
<label>To (IATA) <input name="to_iata" value="{{ flight.to_iata or '' }}" maxlength="3"></label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Carrier (IATA) <input name="operating_carrier_iata" value="{{ flight.operating_carrier_iata or '' }}" maxlength="3"></label>
|
||||||
|
<label>Flight number <input name="flight_number" value="{{ flight.flight_number or '' }}"></label>
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<label>Flight date <input type="date" name="flight_date" value="{{ flight.flight_date or '' }}"></label>
|
||||||
|
<label>Seat <input name="seat" value="{{ flight.seat or '' }}"></label>
|
||||||
|
</div>
|
||||||
|
<label>Compartment <input name="compartment" value="{{ flight.compartment or '' }}" maxlength="1"></label>
|
||||||
|
<button type="submit" class="btn btn-secondary">Save changes</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<dl class="meta-list">
|
||||||
|
<dt>ICAO route</dt><dd>{{ flight.from_icao or '—' }} → {{ flight.to_icao or '—' }}</dd>
|
||||||
|
<dt>Carrier ICAO</dt><dd>{{ flight.operating_carrier_icao or '—' }}</dd>
|
||||||
|
<dt>Process after</dt><dd>{{ flight.process_after or '—' }}</dd>
|
||||||
|
<dt>Fetch attempts</dt><dd>{{ flight.fetch_attempts }}</dd>
|
||||||
|
{% if flight.flightaware_url %}
|
||||||
|
<dt>FlightAware</dt><dd><a href="{{ flight.flightaware_url }}" target="_blank" rel="noopener">{{ flight.flightaware_url }}</a></dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if flight.track_point_count %}
|
||||||
|
<dt>Track points</dt><dd>{{ flight.track_point_count }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% if flight.airtrail_flight_id %}
|
||||||
|
<dt>AirTrail flight</dt><dd>{{ flight.airtrail_flight_id }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Photo</h2>
|
||||||
|
<a href="{{ url_for('uploaded_file', name=flight.image_path) }}" target="_blank" rel="noopener">
|
||||||
|
<img src="{{ url_for('uploaded_file', name=flight.image_path) }}" alt="Boarding pass photo" class="bp-photo">
|
||||||
|
</a>
|
||||||
|
{% if flight.raw_bcbp %}
|
||||||
|
<details class="raw-bcbp">
|
||||||
|
<summary>Raw BCBP data</summary>
|
||||||
|
<pre>{{ flight.raw_bcbp }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if flight.track_coordinates %}
|
||||||
|
<div class="card map-card">
|
||||||
|
<h2>Flight track</h2>
|
||||||
|
<div id="track-map" data-track='{{ flight.track_coordinates | tojson }}'></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-bar">
|
||||||
|
<form method="post" action="{{ url_for('review.requery', flight_id=flight.id) }}">
|
||||||
|
<button type="submit" class="btn btn-secondary">Re-query FlightAware</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('review.approve', flight_id=flight.id, token=approve_token) }}">
|
||||||
|
<button type="submit" class="btn btn-success" {% if flight.status not in ('pending_review', 'track_unavailable') %}disabled{% endif %}>Approve</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('review.reject', flight_id=flight.id, token=reject_token) }}">
|
||||||
|
<button type="submit" class="btn btn-danger" {% if flight.status in ('approved', 'rejected') %}disabled{% endif %}>Reject</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if flight.status not in ('pending_review', 'track_unavailable', 'approved', 'rejected') %}
|
||||||
|
<p class="muted">Approve is unavailable until FlightAware has been checked for this flight
|
||||||
|
(happens automatically the day after the flight, or click Re-query to check now).</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Review — Boarding Pass{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Flights</h1>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<a class="tab {{ 'tab-active' if not active_status }}" href="{{ url_for('review.list_flights') }}">Needs review</a>
|
||||||
|
{% for s in statuses %}
|
||||||
|
<a class="tab {{ 'tab-active' if active_status == s }}" href="{{ url_for('review.list_flights', status=s) }}">{{ s.replace('_', ' ') }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
<a class="tab {{ 'tab-active' if active_status == 'all' }}" href="{{ url_for('review.list_flights', status='all') }}">All</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not flights %}
|
||||||
|
<p class="muted">No flights here.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="flight-list">
|
||||||
|
{% for f in flights %}
|
||||||
|
<a href="{{ url_for('review.detail', flight_id=f.id) }}" class="card flight-row">
|
||||||
|
<span class="badge badge-{{ f.status }}">{{ f.status.replace('_', ' ') }}</span>
|
||||||
|
<span class="flight-route">{{ f.from_iata or '?' }} → {{ f.to_iata or '?' }}</span>
|
||||||
|
<span class="flight-number">{{ f.operating_carrier_iata or '' }}{{ f.flight_number or '' }}</span>
|
||||||
|
<span class="flight-date muted">{{ f.flight_date or 'unknown date' }}</span>
|
||||||
|
<span class="muted">{{ f.passenger_name or '' }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Settings — Boarding Pass{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
|
||||||
|
{% if message %}<div class="alert alert-success">{{ message }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="card settings-form">
|
||||||
|
<h2>Notifications (ntfy)</h2>
|
||||||
|
<label>Server URL
|
||||||
|
<input name="ntfy_server_url" value="{{ settings.ntfy_server_url }}" placeholder="https://ntfy.sh">
|
||||||
|
</label>
|
||||||
|
<label>Topic
|
||||||
|
<input name="ntfy_topic" value="{{ settings.ntfy_topic }}" placeholder="boarding-pass-alerts">
|
||||||
|
</label>
|
||||||
|
<label>Auth token <span class="muted">(optional, for self-hosted ntfy)</span>
|
||||||
|
<input name="ntfy_auth_token" value="{{ settings.ntfy_auth_token }}" type="password">
|
||||||
|
</label>
|
||||||
|
<label>Daily job run time (24h, container timezone)
|
||||||
|
<input name="daily_run_time" value="{{ settings.daily_run_time }}" placeholder="03:00">
|
||||||
|
</label>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="submit" name="action" value="save" class="btn btn-primary">Save settings</button>
|
||||||
|
<button type="submit" name="action" value="test" class="btn btn-secondary">Send test notification</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Upload — Boarding Pass{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Upload a boarding pass</h1>
|
||||||
|
<p class="muted">Upload a photo or screenshot showing the barcode on your boarding pass.
|
||||||
|
It'll be decoded, queued, and tracked automatically the day after your flight.</p>
|
||||||
|
|
||||||
|
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data" class="card upload-form">
|
||||||
|
<label for="photo" class="upload-drop">
|
||||||
|
<span>Tap to choose a photo or screenshot</span>
|
||||||
|
<input type="file" id="photo" name="photo" accept="image/*" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn-primary">Upload & decode</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Signed tokens authorizing the ntfy Approve/Deny callback URLs.
|
||||||
|
|
||||||
|
ntfy action buttons fire a plain, unauthenticated HTTP request from wherever
|
||||||
|
the notification is received - they carry no Authelia session. The reverse
|
||||||
|
proxy bypass needed for those routes (see README) applies to the whole path,
|
||||||
|
so this token is the ONLY auth on /flights/<id>/approve and /reject,
|
||||||
|
including when the request comes from the review page's own buttons.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
import models
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> bytes:
|
||||||
|
return models.get_or_create_secret_key().encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _signature(flight_id: int, action: str, expiry: int) -> str:
|
||||||
|
message = f"{flight_id}:{action}:{expiry}".encode("utf-8")
|
||||||
|
return hmac.new(_secret(), message, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def sign_token(flight_id: int, action: str, ttl_seconds: int = 30 * 86400) -> str:
|
||||||
|
expiry = int(time.time()) + ttl_seconds
|
||||||
|
sig = _signature(flight_id, action, expiry)
|
||||||
|
return f"{expiry}.{sig}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(flight_id: int, action: str, token: str) -> bool:
|
||||||
|
if not token or "." not in token:
|
||||||
|
return False
|
||||||
|
expiry_str, _, sig = token.partition(".")
|
||||||
|
try:
|
||||||
|
expiry = int(expiry_str)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if expiry < time.time():
|
||||||
|
return False
|
||||||
|
expected = _signature(flight_id, action, expiry)
|
||||||
|
return hmac.compare_digest(expected, sig)
|
||||||
Reference in New Issue
Block a user