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:
2026-07-04 22:21:02 +10:00
commit 408fde440d
34 changed files with 11657 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
instance/
__pycache__/
*.pyc
.git
.env
.env.example
README.md
+16
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
instance/
__pycache__/
*.pyc
.env
+15
View File
@@ -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
View File
@@ -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.
+107
View File
@@ -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.
+196
View File
@@ -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"
+43
View File
@@ -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)
+141
View File
@@ -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))
+200
View File
@@ -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.
+27
View File
@@ -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)
+984
View File
@@ -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
1 iata icao name
2 &T T&O Tom\\'s & co airliners
3 ++ --- Lombards Air
4 -+ --+ U.S. Air
5 -- ELK ELK Airways
6 .. ... Avilu
7 00 000 AirOne Continental
8 04 ABV Antrak Air
9 07 770 Samurai Airlines
10 0A GNT Amber Air
11 0B JOR Blue Air
12 0D DWT Darwin Airline
13 0G GA0 Global Airlines
14 0J JCS Jetclub
15 0M 0MM All America MX
16 0P PYB All America BOPY
17 0X CX0 Copenhagen Express
18 0Y 7ZC All America CO
19 10 CNN Canadian World
20 12 N12 12 North
21 13 EAV Eastern Atlantic Virtual Airlines
22 1A AGT Amadeus Global Travel Distribution
23 1C 1CH OneChina
24 1E RGG TransRussiaAirlines
25 1F CIF CB Airways UK ( Interliging Flights )
26 1H HEY Hellenic Airways
27 1I AMB Deutsche Rettungsflugwacht
28 1K BG1 BudgetAir
29 1L OSY Open Skies Consultative Commission
30 1R R1R All America CL
31 1T RNX 1Time Airline
32 1Y A9B All America BR
33 20 RNE Air Salone
34 2B ARD Aerocondor
35 2D AOG Aero VIP
36 2F FTA Frontier Flying Service
37 2G CRG Cargoitalia
38 2J VBW Air Burkina
39 2K GLG Aerolineas Galapagos (Aerogal)
40 2L OAW Helvetic Airways
41 2M MDV Moldavian Airlines
42 2N NTJ NextJet
43 2P GAP Air Philippines
44 2Q SNC Air Cargo Carriers
45 2R M7A All America AR
46 2S SDY Island Express
47 2T HAM Haiti Ambassador Airlines
48 2U GIP Air Guinee Express
49 2W WLC Welcome Air
50 2X 2K2 Regionalia Uruguay
51 2Y AOW Air Andaman (2Y)
52 2Z CGN Changan Airlines
53 3C CEA RegionsAir
54 3D PMK Palair Macedonia
55 3F 3FF Fly Colombia ( Interliging Flights )
56 3G AYZ Atlant-Soyuz Airlines
57 3J WZP Zip
58 3K JSA Jetstar Asia Airways
59 3L ISK Intersky
60 3N URG Air Urga
61 3P TNM Tiara Air
62 3Q CYH Yunnan Airlines
63 3R GAI Moskovia Airlines
64 3T URN Turan Air
65 3U CSC Sichuan Airlines
66 3V TAY TNT Airways
67 3W EMX Euromanx Airways
68 3X GUI Aguilar Connect
69 47 VVN 88
70 4A AKL Air Kiribati
71 4B BTQ Boutique Air (Priv)
72 4C ARE Aires
73 4D ASD Air Sinai
74 4F ECE Air City
75 4G GZP Gazpromavia
76 4H UBD United Airways
77 4K AAS Askari Aviation
78 4L MJX Euroline
79 4M DSM LAN Argentina
80 4N ANT Air North Charter - Canada
81 4R HHI Hamburg International
82 4S FNC Finalair Congo
83 4T BHP Belair Airlines
84 4U GWI Germanwings
85 4Y RBU Airbus France
86 5A AIP Alpine Air Express
87 5B BSX Bassaka airlines
88 5C ICL CAL Cargo Air Lines
89 5D SLI Aerolitoral
90 5F CIR Arctic Circle Air Service
91 5G SSV Skyservice Airlines
92 5H FFV Fly540
93 5J CEB Cebu Pacific
94 5K HFY Hi Fly
95 5L RSU Aerosur
96 5M SIB Sibaviatrans
97 5N AUL Aeroflot-Nord
98 5T MPE Canadian North
99 5V UKW Lviv Airlines
100 5W AEU Astraeus
101 5X UPS United Parcel Service
102 5Y GTI Atlas Air
103 5Z BML Bismillah Airlines
104 6A CHP Consorcio Aviaxsa
105 6B BLX TUIfly Nordic
106 6C 6CC Vuela Cuba
107 6E IGO IndiGo Airlines
108 6F MKD MAT Airways
109 6G AWW Air Wales
110 6H ISR Israir
111 6I IBZ International Business Air
112 6J SNJ Skynet Asia Airways
113 6K RIT Asian Spirit
114 6N NRD Nordic Regional
115 6P ISG Club Air
116 6Q SLL Slovak Airlines
117 6R DRU Alrosa Mirny Air Enterprise
118 6U UKR Air Ukraine
119 6V VGA Air Vegas
120 6W SOV Saratov Aviation Division
121 6Z UKS Ukrainian Cargo Airways
122 76 SJS Southjet
123 77 ZCS Southjet connect
124 78 XAN Southjet cargo
125 7B KJC Krasnojarsky Airlines
126 7C JJA Jeju Air
127 7E AWU Aeroline GmbH
128 7F FAB First Air
129 7G SFJ Star Flyer
130 7H ERR Era Alaska
131 7K KGL Kogalymavia Air Company
132 7L ERO Sun D'Or
133 7M ZTF Mongolian International Air Lines
134 7N CNA Centavia
135 7O GAL Galaxy Air
136 7P BTV Metro Batavia
137 7R BRB BRA-Transportes Aereos
138 7T AGV Air Glaciers
139 7V ROB ROYAL BRITAIN
140 88 8K8 All Australia
141 8A BMM Atlas Blue
142 8B GFI Caribbean Star Airlines
143 8C ATN Air Transport International
144 8D EXV Expo Aviation
145 8E BRG Bering Air
146 8F FFR Fischer Air
147 8H HFR Heli France
148 8J JFU Jet4You
149 8K K88 Voestar
150 8L CGP Cargo Plus Aviation
151 8M MXL Maxair
152 8N NKF Barents AirLink
153 8P PCO Pacific Coastal Airline
154 8Q BAJ Baker Aviation
155 8R TIB TRIP Linhas A
156 8U AAW Afriqiyah Airways
157 8V ACP Astral Aviation
158 8W PWF Private Wings Flugcharter
159 8Y PBU Air Burundi
160 8Z LER Linea Aerea de Servicio Ejecutivo Regional
161 9A 99F All Africa
162 9E FLG Pinnacle Airlines
163 9F TLM Tramm Airlines
164 9I TKY Thai Sky Airlines
165 9J CR1 Regionalia Chile
166 9K KAP Cape Air
167 9L CJC Colgan Air
168 9N N78 Regional Air Iceland
169 9Q PBA PB Air
170 9R VAP Phuket Air
171 9S CQH Spring Airlines
172 9T ABS Transwest Air
173 9U MLD Air Moldova
174 9V VC9 Volotea Costa Rica
175 9W JAI Jet Airways
176 9X ACL Itali Airlines
177 9Y KZK Air Kazakhstan
178 :: ::: Llloyd Helicopters
179 ?? ??? Court Line
180 A1 A1F Atifly
181 A2 CIU Cielos Airlines
182 A3 AEE Aegean Airlines
183 A4 SWD Southern Winds Airlines
184 A5 RLA Airlinair
185 A6 LPV Air Alps Aviation
186 A7 MPD Air Plus Comet
187 A8 BGL Benin Golf Air
188 A9 TGZ Georgian Airways
189 AA AAL American Airlines
190 AB BER Air Berlin
191 AC ACA Air Canada
192 AD PRZ Air Paradise International
193 AE MDA Mandarin Airlines
194 AF AFR Air France
195 AG ABR Air Contractors
196 AH DAH Air Algerie
197 AI AIC Air India Limited
198 AJ NIG Aero Contractors
199 AK AXM AirAsia
200 AL SYX Skywalk Airlines
201 AM AMX AeroMéxico
202 AN AAA Ansett Australia
203 AO AUZ Australian Airlines
204 AP AIB Airbus Industrie
205 AQ AAH Aloha Airlines
206 AR ARG Aerolineas Argentinas
207 AS ASA Alaska Airlines
208 AT RAM Royal Air Maroc
209 AU AUT Austral Lineas Aereas
210 AV AVA Avianca - Aerovias Nacionales de Colombia
211 AW SCH CHC Airways
212 AX LOF Trans States Airlines
213 AY FIN Finnair
214 AZ AZA Alitalia
215 B0 666 Aws express
216 B1 BA1 Baltic Air lines
217 B2 BRU Belavia Belarusian Airlines
218 B3 BLV Bellview Airlines
219 B4 BCF BACH Flugbetriebsges
220 B5 FLT Flightline
221 B6 JBU JetBlue Airways
222 B7 UIA Uni Air
223 B8 ERT Eritrean Airlines
224 B9 BGD Air Bangladesh
225 BA BAW British Airways
226 BB SBS Seaborne Airlines
227 BC SKY Skymark Airlines
228 BD BMA bmi
229 BE BEE Flybe
230 BF RSR Aero-Service
231 BG BBC Biman Bangladesh Airlines
232 BI RBA Royal Brunei Airlines
233 BJ LBT Nouvel Air Tunisie
234 BK PDC Potomac Air
235 BL PIC Jetstar Pacific
236 BM BYE Bayu Indonesia Air
237 BN BNF Braniff International Airways
238 BO BOU Bouraq Indonesia Airlines
239 BP BOT Air Botswana
240 BQ ROM Aeromar
241 BR EVA EVA Air
242 BS BIH British International Helicopters
243 BT BTI Air Baltic
244 BU BRA Braathens
245 BV BPA Blue Panorama Airlines
246 BW BWA Caribbean Airlines
247 BX CST Coast Air
248 BY TOM Thomsonfly
249 BZ BDA Blue Dart Aviation
250 C0 CLW Centralwings
251 C1 CA1 CanXpress
252 C2 CAP CanXplorer
253 C3 KIS Contact Air
254 C4 IMX Zimex Aviation
255 C5 UCA CommutAir
256 C6 CJA CanJet
257 C7 RLE Rico Linhas A
258 C8 WDY Chicago Express
259 C9 RUS Cirrus Airlines
260 CA CCA Air China
261 CB CCC CCML Airlines
262 CC ABD Air Atlanta Icelandic
263 CD LLR Alliance Air
264 CE NTW Nationwide Airlines
265 CF SDR City Airline
266 CG TOK Airlines PNG
267 CH BMJ Bemidji Airlines
268 CI CAL China Airlines
269 CJ CBF China Northern Airlines
270 CK CKK China Cargo Airlines
271 CL CLH Lufthansa CityLine
272 CM CMP Copa Airlines
273 CN WWD Westward Airways
274 CO COA Continental Airlines
275 CP CDN Canadian Airlines
276 CQ EXL Sunshine Express Airlines
277 CS CMI Continental Micronesia
278 CT CAT Civil Air Transport
279 CU CUB Cubana de Aviación
280 CV CVA Air Chathams
281 CW CWM Air Marshall Islands
282 CX CPA Cathay Pacific
283 CY CYP Cyprus Airways
284 CZ CSN China Southern Airlines
285 D1 MDO Domenican Airlines
286 D3 DAO Daallo Airlines
287 D4 LID Alidaunia
288 D5 DAU Dauair
289 D6 ILN Interair South Africa
290 D7 RDN Dinar
291 D8 DJB Djibouti Airlines
292 D9 DNV Aeroflot-Don
293 DA GRG Air Georgia
294 DB BZH Brit Air
295 DC GAO Golden Air
296 DD NOK Nok Air
297 DE CFG Condor Flugdienst
298 DF MJG Michael Airlines
299 DG SRQ South East Asian Airlines
300 DH DVA Discovery Airways
301 DI BAG dba
302 DJ PBN Pacific Blue
303 DK ELA Eastland Air
304 DL DAL Delta Air Lines
305 DN SGG Senegal Airlines
306 DO DOA Dominicana de Aviaci
307 DP FCA First Choice Airways
308 DR BIE Air Mediterranee
309 DT DTA TAAG Angola Airlines
310 DU HMS Hemus Air
311 DV LTF Lufttaxi Fluggesellschaft
312 DW UCR Aero-Charter Ukraine
313 DX DTR DAT Danish Air Transport
314 DY NAX Norwegian Air Shuttle
315 E0 ESS Eos Airlines
316 E1 ES2 Usa Sky Cargo
317 E2 KMP Kampuchea Airlines
318 E3 DMO Domodedovo Airlines
319 E4 RSO Aero Asia International
320 E5 BRZ Samara Airlines
321 E7 ESF Estafeta Carga Aerea
322 E8 ELG Alpi Eagles
323 E9 CXS Boston-Maine Airways
324 EA EAL European Air Express
325 EC TWN Avialeasing Aviation Company
326 ED ABQ Airblue
327 EE EAY Aero Airlines
328 EF EFA Far Eastern Air Transport
329 EG JAA Japan Asia Airways
330 EH AKX Air Nippon Network Co. Ltd.
331 EI EIN Aer Lingus
332 EJ NEA New England Airlines
333 EK UAE Emirates
334 EL ANK Air Nippon
335 EM AEB Aero Benin
336 EN DLA Air Dolomiti
337 EO LHN Express One International
338 EP IRC Iran Aseman Airlines
339 EQ TAE TAME
340 ER DHL Astar Air Cargo
341 ES DHX DHL International
342 ET ETH Ethiopian Airlines
343 EU EEA Empresa Ecuatoriana De Aviacion
344 EV ASQ Atlantic Southeast Airlines
345 EW EWG Eurowings
346 EX SDO Air Santo Domingo
347 EY ETD Etihad Airways
348 EZ EIA Evergreen International Airlines
349 F1 FBL Fly Brasil
350 F2 FLM Fly Air
351 F3 FSW Faso Airways
352 F4 NBK Albarka Air
353 F5 COZ Cosmic Air
354 F6 RCK Faroejet
355 F7 BBO Flybaboo
356 F9 FFT Frontier Airlines
357 FA SFR Safair
358 FB LZB Bulgaria Air
359 FC WBA Finncomm Airlines
360 FD AIQ Thai AirAsia
361 FE WCP Primaris Airlines
362 FF TOW Tower Air
363 FG AFG Ariana Afghan Airlines
364 FH FUA Futura International Airways
365 FI ICE Icelandair
366 FJ FJI Air Pacific
367 FK WTA Africa West
368 FL TRS AirTran Airways
369 FM CSH Shanghai Airlines
370 FO ATM Airlines Of Tasmania
371 FP FRE Freedom Air
372 FQ TCW Thomas Cook Airlines
373 FR RYR Ryanair
374 FS STU Servicios de Transportes A
375 FT SRH Siem Reap Airways
376 FU FXX Felix Airways
377 FV SDM Rossiya-Russian Airlines
378 FW IBX Ibex Airlines
379 FX FDX Federal Express
380 FY FFM Firefly
381 FZ FDB Fly Dubai
382 G0 GHB Ghana International Airlines
383 G1 IKA Gorkha Airlines
384 G2 VXG Avirex
385 G3 CIX City Connexion Airlines
386 G4 AAY Allegiant Air
387 G6 BSR Guine Bissaur Airlines
388 G7 GNF Gandalf Airlines
389 G8 AGB Air Service Gabon
390 G9 ABY Air Arabia
391 GA GIA Garuda Indonesia
392 GB ABX Airborne Express
393 GC GNR Gambia International Airlines
394 GD AHA Air Alpha Greenland
395 GE TNA TransAsia Airways
396 GF GBA Gulf Air Bahrain
397 GG GUY Air Guyane
398 GH GLP Globus
399 GI IKA Itek Air
400 GJ MXC Compania Mexicargo
401 GL GRL Air Greenland
402 GM SVK Air Slovakia
403 GN AGN Air Gabon
404 GO KZU Kuzu Airlines Cargo
405 GP GES Gestair
406 GQ BSY Big Sky Airlines
407 GR AUR Aurigny Air Services
408 GS UPA Air Foyle
409 GT GBL GB Airways
410 GU GU1 Gulisano airways
411 GV ARF Aero Flight
412 GW KIL Kuban Airlines
413 GX JXX Jetx Airlines
414 GY TMG Tri-MG Intra Asia Airlines
415 GZ RAR Air Rarotonga
416 H1 HA1 Hankook Air US
417 H2 SKU Sky Airline
418 H3 T33 THREE
419 H4 IIN Inter Islands Airlines
420 H5 HOA Hola Airlines
421 H6 HAG Hageland Aviation Services
422 H8 KHB Dalavia
423 H9 HAD Air D'Ayiti
424 HA HAL Hawaiian Airlines
425 HB HAR Harbor Airlines
426 HC ATI Aero-Tropics Air Services
427 HD ADO Hokkaido International Airlines
428 HE LGW Luftfahrtgesellschaft Walter
429 HF HLF Hapagfly
430 HG NLY Niki
431 HH AHO Air Hamburg (AHO)
432 HJ AXF Asian Express Airlines
433 HK FSC Four Star Aviation / Four Star Cargo
434 HM SEY Air Seychelles
435 HN HVY Heavylift Cargo Airlines
436 HO DJA Antinea Airlines
437 HP AWE America West Airlines
438 HQ HMY Harmony Airways
439 HR CUA China United Airlines
440 HT AHW Aeromist-Kharkiv
441 HU CHH Hainan Airlines
442 HV TRA Transavia Holland
443 HW FHE Hello
444 HX CRK Hong Kong Airlines
445 HY UZB Uzbekistan Airways
446 HZ SOZ Sat Airlines
447 I2 IBS Iberia Express
448 I4 FWA Interstate Airline
449 I5 IDS Indonesia Sky
450 I6 SEQ Sky Eyes
451 I7 PMW Paramount Airways
452 I9 AEY Air Italy
453 IA IAW Iraqi Airways
454 IB IBE Iberia Airlines
455 IC IAC Indian Airlines
456 ID ITK Interlink Airlines
457 IE SOL Solomon Airlines
458 IF ISW Islas Airways
459 IG ISS Meridiana
460 II CSQ IBC Airways
461 IJ GWL Great Wall Airlines
462 IK ITX Imair Airlines
463 IL ILW Illinois Airways
464 IM MNJ Menajet
465 IN MAK MAT Macedonian Airlines
466 IO IAA Indonesian Airlines
467 IP JOL Atyrau Air Ways
468 IQ AUB Augsburg Airways
469 IR IRA Iran Air
470 IT KFR Kingfisher Airlines
471 IV JET Wind Jet
472 IW AOM AOM French Airlines
473 IX AXB Air India Express
474 IY IYE Yemenia
475 IZ AIZ Arkia Israel Airlines
476 J2 AHY Azerbaijan Airlines
477 J3 PLR Northwestern Air
478 J4 BFL Buffalo Airways
479 J6 AOC AVCOM
480 J7 CVC Centre-Avia
481 J8 BVT Berjaya Air
482 J9 GIF Guinee Airlines
483 JA BON Air Bosna
484 JB JBA Helijet
485 JC JEX JAL Express
486 JD JAS Japan Air System
487 JE MNO Mango
488 JF JAF Jetairfly
489 JH NES Nordeste Linhas Aereas Regionais
490 JI MDW Midway Airlines
491 JJ TAM TAM Brazilian Airlines
492 JK JKK Spanair
493 JL JAL Japan Airlines
494 JM AJM Air Jamaica
495 JN XLA Excel Airways
496 JO JAZ JALways
497 JP ADR Adria Airways
498 JQ JST Jetstar Airways
499 JR SER Aero California
500 JS KOR Air Koryo
501 JT LNI Lion Mentari Airlines
502 JU JAT Jat Airways
503 JV BLS Bearskin Lake Air Service
504 JW APW Arrow Air
505 JX JEC Jett8 Airlines Cargo
506 JY TCI Air Turks and Caicos
507 JZ SKX Skyways Express
508 K1 KOQ Kostromskie avialinii
509 K2 ELO Eurolot
510 K4 CKS Kalitta Air
511 K5 ABE Aban Air
512 K6 BRV Bravo Air Congo
513 K7 KBR KoralBlue Airlines
514 K8 ZAK Airlink Zambia
515 K9 KRI Krylo Airlines
516 KA HDA Dragonair
517 KB DRK Druk Air
518 KC KZR Air Astana
519 KD KNI KD Avia
520 KE KAL Korean Air
521 KF BLF Blue1
522 KG BNX LAI - Linea Aerea IAACA
523 KH KHK Kharkiv Airlines
524 KI AAG Air Atlantique
525 KJ LAJ British Mediterranean Airways
526 KK KKK Atlasjet
527 KL KLM KLM Royal Dutch Airlines
528 KM AMC Air Malta
529 KO AER Alaska Central Express
530 KP KIA Kiwi International Air Lines
531 KQ KQA Kenya Airways
532 KR CWK Comores Airlines
533 KS PEN Peninsula Airways
534 KT CWS California Western
535 KU KAC Kuwait Airways
536 KV MVD Kavminvodyavia
537 KX CAY Cayman Airways
538 KY EQL Air S
539 KZ NCA Nippon Cargo Airlines
540 L1 AL1 All Argentina
541 L2 LYC Lynden Air Cargo
542 L3 JOS DHL de Guatemala
543 L4 LJJ Luchsh Airlines
544 L5 LTR Lufttransport
545 L6 MAI Mauritania Airlines International
546 L7 LPN Laoag International Airlines
547 L8 LXG Air Luxor GB
548 L9 TLW Teamline Air
549 LA LAN LAN Airlines
550 LB LLB Lloyd Aereo Boliviano
551 LC VLO Varig Log
552 LD AHK Air Hong Kong
553 LE LTY Liberty Airways
554 LF NDC FlyNordic
555 LG LGL Luxair
556 LH DLH Lufthansa
557 LI LIA Leeward Islands Air Transport
558 LJ SLA Sierra National Airlines
559 LK LXR Air Luxor
560 LL GRO Allegro
561 LM LAM Linhas A
562 LN LAA Libyan Arab Airlines
563 LO LOT LOT Polish Airlines
564 LP LPE LAN Peru
565 LQ LAQ Lebanese Air Transport
566 LR LRC LACSA
567 LS EXS Jet2.com
568 LT LTU LTU International
569 LU LXP LAN Express
570 LV LBC Albanian Airlines
571 LW NMI Pacific Wings
572 LX SWR Swiss International Air Lines
573 LY ELY El Al Israel Airlines
574 M0 MNG Aero Mongolia
575 M1 M1F Maryland Air
576 M2 MZS Mahfooz Aviation
577 M3 TUS ABSA - Aerolinhas Brasileiras
578 M4 NOV Nova Airline
579 M5 KEN Kenmore Air
580 M6 AJT Amerijet International
581 M7 MAA MasAir
582 M8 TMW Trans Maldivian Airways
583 M9 MSI Motor Sich
584 MA MAH Malév
585 MB EXA Execair Aviation
586 MC RCH Air Mobility Command
587 MD MDG Air Madagascar
588 ME MEA Middle East Airlines
589 MF CXA Xiamen Airlines
590 MG CCP Champion Air
591 MH MAS Malaysia Airlines
592 MI SLK SilkAir
593 MJ LPR L
594 MK MAU Air Mauritius
595 ML MAV Maldivo Airlines
596 MN CAW Comair
597 MO AUH Abu Dhabi Amiri Flight
598 MP MPH Martinair
599 MQ EGF American Eagle Airlines
600 MR MRT Air Mauritanie
601 MS MSR Egyptair
602 MT TCX Thomas Cook Airlines
603 MU CES China Eastern Airlines
604 MV RML Armenian International Airways
605 MW MYD Maya Island Air
606 MX MXA Mexicana de Aviaci
607 MY MXJ Maxjet Airways
608 MZ MNA Merpati Nusantara Airlines
609 N2 DAG Dagestan Airlines
610 N3 OMS Omskavia Airline
611 N4 MTC Mountain Air Company
612 N5 KGZ Kyrgyz Airlines
613 N6 JEV Lagun Air
614 N7 ROK National Airlines
615 N8 HGK Fika Salaama Airlines
616 N9 N99 All Europe
617 NA NAL National Airlines
618 NB SNB Sterling Airlines
619 NC NJS National Jet Systems
620 NE ESK SkyEurope
621 NF AVN Air Vanuatu
622 NG LDA Lauda Air
623 NH ANA All Nippon Airways
624 NI PGA Portugalia
625 NJ NGB Nordic Global Airlines
626 NK NKS Spirit Airlines
627 NL SAI Shaheen Air International
628 NM DRD Air Madrid
629 NN MOV VIM Airlines
630 NO AUS Aus-Air
631 NP NIA Nile Air
632 NQ AJX Air Japan
633 NR PIR Pamir Airways
634 NT IBB Binter Canarias
635 NU JTA Japan Transocean Air
636 NV CRF Air Central
637 NW NWA Northwest Airlines
638 NX AMU Air Macau
639 NY FXI Air Iceland
640 NZ ANZ Air New Zealand
641 O1 OAB Orbit Airlines Azerbaijan
642 O6 ONE Oceanair
643 O7 OZJ Ozjet Airlines
644 O8 OHK Oasis Hong Kong Airlines
645 OA OAL Olympic Airlines
646 OB ASZ Astrakhan Airlines
647 OD ULA Zuliana de Aviacion
648 OE AOT Asia Overnight Express
649 OF FIF Air Finland
650 OH COM Comair
651 OI ORC Orchid Airlines
652 OJ OLA Overland Airways
653 OK CSA Czech Airlines
654 OL OLT Ostfriesische Lufttransport
655 OM MGL MIAT Mongolian Airlines
656 ON RON Nauru Air Corporation
657 OO SKW SkyWest
658 OP CHK Chalk's Ocean Airways
659 OQ CQN Chongqing Airlines
660 OR TFL Arkefly
661 OS AUA Austrian Airlines
662 OT PEL Aeropelican Air Services
663 OU CTN Croatia Airlines
664 OV ELL Estonian Air
665 OW EXK Executive Airlines
666 OX OEA Orient Thai Airlines
667 OY OAE Omni Air International
668 OZ AAR Asiana Airlines
669 P5 RPB AeroRep
670 P7 ESL Russian Sky Airlines
671 P8 PTN Pantanal Linhas Aéreas
672 P9 PGP Perm Airlines
673 PA FCL Florida Coastal Airlines
674 PC FAJ Air Fiji
675 PD POE Porter Airlines
676 PE AEL Air Europe
677 PF PNW Palestinian Airlines
678 PG BKP Bangkok Airways
679 PH PAO Polynesian Airlines
680 PI PDT Piedmont Airlines (1948-1989)
681 PJ SPM Air Saint Pierre
682 PK PIA Pakistan International Airlines
683 PL PLI Aeroper
684 PM TOS Tropic Air
685 PN CHB West Air China
686 PO PAC Polar Air Cargo
687 PP AI0 Air Indus
688 PQ PNF Panafrican Airways
689 PR PAL Philippine Airlines
690 PS PSX Pacific Southwest Airlines
691 PT CCI Capital Cargo International Airlines
692 PU PUA PLUNA
693 PV PNR PAN Air
694 PW PRF Precision Air
695 PX ANG Air Niugini
696 PY SLM Surinam Airways
697 PZ LAP TAM Mercosur
698 Q3 MBN Zambian Airways
699 Q4 SAE SOCHI AIR EXPRESS
700 Q5 MLA 40-Mile Air
701 Q6 CDP Aero Condor Peru
702 Q8 PEC Pacific East Asia Cargo Airlines
703 Q9 AFU Afrinat International Airlines
704 QB GFG Georgian National Airlines
705 QC CRD Air Corridor
706 QD QCL Air Class Lineas Aereas
707 QE ECC Crossair Europe
708 QF QFA Qantas
709 QH FLZ Air Florida
710 QI CIM Cimber Air
711 QK JZA Air Canada Jazz
712 QL RLN Aero Lanka
713 QM AML Air Malawi
714 QN ARR Air Armenia
715 QO MPX Aeromexpress
716 QQ UTY Alliance Airlines
717 QR QTR Qatar Airways
718 QS QSC African Safari Airways
719 QT TPA TAMPA
720 QU UGX East African
721 QV LAO Lao Airlines
722 QW BWG Blue Wings
723 QX QXE Horizon Air
724 QY BCS European Air Transport
725 QZ AWQ Indonesia AirAsia
726 R0 RPK Royal Airlines
727 R1 RS1 Royal Southern Airlines.
728 R2 ORB Orenburg Airlines
729 R3 SYL Aircompany Yakutia
730 R5 JAV Jordan Aviation
731 R7 OCA Aserca Airlines
732 R8 KGA Kyrgyzstan Airlines
733 R9 CAM Camai Air
734 RA RNA Nepal Airlines
735 RB SBK Air Srpska
736 RC FLI Atlantic Airways
737 RD RYN Ryan International Airlines
738 RE REA Aer Arann
739 RF FWL Florida West International Airways
740 RG VRN VRG Linhas Aereas
741 RH RPH Republic Express Airlines
742 RI MDL Mandala Airlines
743 RJ RJA Royal Jordanian
744 RK RKA Air Afrique
745 RL RFJ Royal Falcon
746 RM RNY Rainbow Air US
747 RN RAB Rainbow Air (RAI)
748 RO ROT Tarom
749 RP CHQ Chautauqua Airlines
750 RQ KMF Kam Air
751 RR RFR Royal Air Force
752 RS ICT Intercontinental de Aviaci
753 RU ABW AirBridge Cargo
754 RV CPN Caspian Airlines
755 RW RPA Republic Airlines
756 RX AEH Aviaexpress
757 RY RAY Rainbow Air Canada
758 S0 OKS Slok Air Gambia
759 S1 SA1 Serbian Airlines
760 S2 RSH Air Sahara
761 S3 BBR Santa Barbara Airlines
762 S4 RZO SATA International
763 S5 TCF Shuttle America
764 S7 SBI S7 Airlines
765 S8 CSU Chari Aviation Services
766 S9 HSA East African Safari Air
767 SA SAA South African Airways
768 SB ACI Air Caledonie International
769 SC CDG Shandong Airlines
770 SD SUD Sudan Airways
771 SE SEU XL Airways France
772 SF DTH Tassili Airlines
773 SG JGO JetsGo
774 SH FLY Fly Me Sweden
775 SI SIH Skynet Airlines
776 SJ SJY Sriwijaya Air
777 SK SAS Scandinavian Airlines System
778 SL RSL Rio Sul Servi
779 SM SRL Swedline Express
780 SN DAT Brussels Airlines
781 SO HKA Superior Aviation
782 SP SAT SATA Air Acores
783 SQ SIA Singapore Airlines
784 SR SWR Swissair
785 SS CRL Corsairfly
786 ST GMI Germania
787 SU AFL Aeroflot Russian Airlines
788 SV SVA Saudi Arabian Airlines
789 SW NMB Air Namibia
790 SX SRK Sky Work Airlines
791 SY SCX Sun Country Airlines
792 T1 TP3 TROPICAL LINHAS AEREAS
793 T2 TCG Thai Air Cargo
794 T3 EZE Eastern Airways
795 T4 HEJ Hellas Jet
796 T5 TUA Turkmenistan Airlines
797 T6 TVR Tavrey Airlines
798 T7 TJT Twin Jet
799 T9 TRZ TransMeridian Airlines
800 TA TAT Grupo TACA
801 TB TUB TUI Airlines Belgium
802 TC ATC Air Tanzania
803 TD LUR Atlantis European Airways
804 TE LIL FlyLal
805 TF SCW Malmö Aviation
806 TG THA Thai Airways International
807 TH TSE Transmile Air Services
808 TI TOL Tol-Air Services
809 TJ TJA T.J. Air
810 TK THY Turkish Airlines
811 TL ANO Airnorth
812 TN THT Air Tahiti Nui
813 TO PSD President Airlines
814 TP TAP TAP Portugal
815 TQ TDM Tandem Aero
816 TR TGW Tiger Airways
817 TS TSC Air Transat
818 TT KLA Air Lithuania
819 TU TAR Tunisair
820 TV VEX Virgin Express
821 TW TWA Trans World Airlines
822 TX FWI Air Caraïbes
823 TY TPC Air Cal
824 TZ SCO Scoot
825 U1 001 AirOne Polska
826 U2 EZY easyJet
827 U3 AIA Avies
828 U4 PMT PMTair
829 U5 GWY USA3000 Airlines
830 U6 SVR Ural Airlines
831 U7 JUS USA Jet Airlines
832 U8 RNV Armavia
833 U9 TAK Tatarstan Airlines
834 UA UAL United Airlines
835 UB UBA Myanma Airways
836 UD HER Hex'Air
837 UE NAS Nasair
838 UF UKM UM Airlines
839 UG TUI Tuninter
840 UI ECA Eurocypria Airlines
841 UJ LMU AlMasria Universal Airlines
842 UK BUZ buzz
843 UL ALK SriLankan Airlines
844 UM AZW Air Zimbabwe
845 UN TSO Transaero Airlines
846 UO HKE Hong Kong Express Airways
847 UP BHS Bahamasair
848 UQ OCM O'Connor Airlines
849 US USA US Airways
850 UT UTA UTair Aviation
851 UU REU Air Austral
852 UX AEA Air Europa
853 UY UYC Cameroon Airlines
854 UZ BRQ El-Buraq Air Transport
855 V0 VCV Conviasa
856 V1 VIA VIA Líneas Aéreas
857 V2 AKT Karat
858 V3 KRP Carpatair
859 V4 REK Reem Air
860 V5 VLI Avolar Aerolineas
861 V7 SNG Air Senegal International
862 V8 VAS ATRAN Cargo Airlines
863 V9 BTC BAL Bashkirian Airlines
864 VA VAU V Australia Airlines
865 VC VCX Ocean Airlines
866 VD KPA Kunpeng Airlines
867 VE AVE Avensa
868 VF VLU Valuair
869 VG VLM VLM Airlines
870 VH VNP Virgin Pacific
871 VI VDA Volga-Dnepr Airlines
872 VJ JTY Jatayu Airlines
873 VK VGN Virgin Nigeria Airways
874 VL VIM Air VIA
875 VM VOA Viaggio Air
876 VN HVN Vietnam Airlines
877 VO TYR Tyrolean Airways
878 VP VSP VASP
879 VQ VKH Viking Hellas
880 VR TCV TACV
881 VS VIR Virgin Atlantic Airways
882 VT VTA Air Tahiti
883 VU VUN Air Ivoire
884 VV AEW Aerosvit Airlines
885 VW TAO Aeromar
886 VX VRD Virgin America
887 VY FOS Formosa Airlines
888 VZ MYT MyTravel Airways
889 W1 WE1 World Experience Airline
890 W2 CWA Canadian Western Airlines
891 W3 ARA Arik Air
892 W4 BES Aero Services Executive
893 W5 IRM Mahan Air
894 W6 WZZ Wizz Air
895 W8 CJT Cargojet Airways
896 W9 AAB Abelag Aviation
897 WA KLC KLM Cityhopper
898 WB RWD Rwandair Express
899 WC ISV Islena De Inversiones
900 WD DSR DAS Air Cargo
901 WE CWC Centurion Air Cargo
902 WF WIF Widerøe
903 WG SWG Sunwing Airlines
904 WH CNW China Northwest Airlines
905 WJ WEB WebJet Linhas A
906 WK AFB American Falcon
907 WL FQR CheapFlyingInternational
908 WN SWA Southwest Airlines
909 WO WOA World Airways
910 WP MKU Island Air (WP)
911 WQ PQW PanAm World Airways
912 WR HRH Royal Tongan Airlines
913 WS WJA WestJet
914 WU WAU Wizz Air Ukraine
915 WV SWV Swe Fly
916 WW BMI bmibaby
917 WX BCY CityJet
918 WY OMA Oman Air
919 WZ RWZ Red Wings
920 X3 HLX TUIfly
921 X5 OTJ Fly Romania
922 X7 CHF Chitaavia
923 X9 X9F FRA Air
924 XA XAU XAIR USA
925 XB NXB NEXT Brasil
926 XE BTA ExpressJet
927 XF VLK Vladivostok Air
928 XG CLI Calima Aviacion
929 XJ MES Mesaba Airlines
930 XK CCM Corse-Mediterranee
931 XL LNE Aerolane
932 XM SMX Alitalia Express
933 XO CXH China Xinhua Airlines
934 XP CXP Xtra Airways
935 XQ SXS SunExpress
936 XS SIT SITA
937 XT AXL Air Exel
938 XW SXR Sky Express
939 XX GFY Greenfly
940 XY KNE Nas Air
941 Y4 VOI Volaris
942 Y5 PCE Pace Airlines
943 Y8 YZR Yangtze River Express
944 Y9 IRK Kish Air
945 YC YCC Ciel Canadien
946 YE YEL Yellowtail
947 YL LLM Yamal Airlines
948 YM MGX Montenegro Airlines
949 YO TYS TransHolding System
950 YP AEF Aero Lloyd (YP)
951 YS RAE Régional
952 YT TGA Air Togo
953 YV ASH Mesa Airlines
954 YW ANE Air Nostrum
955 YX MEP Midwest Airlines
956 YY VWA Virginwings
957 YZ YZZ LSM AIRLINES
958 Z0 Z9H All Argentina Express
959 Z3 SMJ Avient Aviation
960 Z4 OOM Zoom Airlines
961 Z5 IIR INAVIA Internacional
962 Z6 ZTT ZABAIKAL AIRLINES
963 Z7 ADK ADC Airlines
964 Z8 AZN Amaszonas
965 ZA CYD Access Air
966 ZB BUB Air Bourbon
967 ZC KGO Korongo Airlines
968 ZE AZE Arcus-Air Logistic
969 ZG VVM Viva Macau
970 ZH CSZ Shenzhen Airlines
971 ZI AAF Aigle Azur
972 ZK GLA Great Lakes Airlines
973 ZL RXA Regional Express
974 ZM IWA Apache Air
975 ZN ZNA Zenith International Airline
976 ZP AZQ Silk Way Airlines
977 ZQ LOC Locair
978 ZS SMY Sama Airlines
979 ZT AWC Titan Airways
980 ZU HCY Helios Airways
981 ZV AMW Air Midwest
982 ZW AWI Air Wisconsin
983 ZX GGN Air Georgian
984 ZY ADE Ada Air
+8257
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -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)
+60
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
+146
View File
@@ -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
View File
@@ -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)
+70
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
flask==3.0.*
waitress==3.0.*
zxing-cpp==2.2.*
pillow==10.*
+130
View File
@@ -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))
+31
View File
@@ -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)
+104
View File
@@ -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
View File
@@ -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
+85
View File
@@ -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()
+258
View File
@@ -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; }
+31
View File
@@ -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: "&copy; 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);
}
}
})();
+35
View File
@@ -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&nbsp;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">&#9788;</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>
+106
View File
@@ -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">&larr; All flights</a>
<div class="detail-header">
<h1>{{ flight.from_iata or '?' }} &rarr; {{ 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 '—' }} &rarr; {{ 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 %}
+29
View File
@@ -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 '?' }} &rarr; {{ 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 %}
+27
View File
@@ -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 %}
+17
View File
@@ -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 &amp; decode</button>
</form>
{% endblock %}
+43
View File
@@ -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)