408fde440d
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.
131 lines
4.9 KiB
Python
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))
|