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

131 lines
4.9 KiB
Python

"""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))