Initial commit: boarding pass to AirTrail pipeline
Photograph/screenshot a boarding pass, decode its BCBP barcode (PDF417/ Aztec/QR/DataMatrix), fetch the historical flight track from FlightAware the day after the flight, stage it for review, and write it into AirTrail via its REST API on approval. ntfy notifications carry signed Approve/Deny/Investigate actions.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Boarding Pass{% endblock %}</title>
|
||||
<script>
|
||||
(function () {
|
||||
var saved = localStorage.getItem("theme");
|
||||
if (saved === "light" || saved === "dark") {
|
||||
document.documentElement.classList.add(saved);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<nav class="topbar-nav">
|
||||
<a href="{{ url_for('upload.upload') }}" class="brand">Boarding Pass</a>
|
||||
<a href="{{ url_for('upload.upload') }}">Upload</a>
|
||||
<a href="{{ url_for('review.list_flights') }}">Review</a>
|
||||
<a href="{{ url_for('settings.settings') }}">Settings</a>
|
||||
</nav>
|
||||
<button type="button" id="theme-toggle" class="icon-btn" aria-label="Toggle theme">☼</button>
|
||||
</header>
|
||||
<main class="page">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Flight {{ flight.id }} — Boarding Pass{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if flight.track_coordinates %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="{{ url_for('review.list_flights') }}" class="back-link">← All flights</a>
|
||||
|
||||
<div class="detail-header">
|
||||
<h1>{{ flight.from_iata or '?' }} → {{ flight.to_iata or '?' }}
|
||||
<span class="badge badge-{{ flight.status }}">{{ flight.status.replace('_', ' ') }}</span>
|
||||
</h1>
|
||||
<p class="muted">Flight #{{ flight.id }} · created {{ flight.created_at }}</p>
|
||||
</div>
|
||||
|
||||
{% if flight.error_message %}
|
||||
<div class="alert alert-danger">{{ flight.error_message }}</div>
|
||||
{% endif %}
|
||||
{% if flight.parse_warnings %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>Parse warnings:</strong>
|
||||
<ul>{% for w in flight.parse_warnings %}<li>{{ w }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="card">
|
||||
<h2>Details</h2>
|
||||
<form method="post" action="{{ url_for('review.edit', flight_id=flight.id) }}" class="edit-form">
|
||||
<label>Passenger name <input name="passenger_name" value="{{ flight.passenger_name or '' }}"></label>
|
||||
<label>PNR <input name="pnr" value="{{ flight.pnr or '' }}"></label>
|
||||
<div class="field-row">
|
||||
<label>From (IATA) <input name="from_iata" value="{{ flight.from_iata or '' }}" maxlength="3"></label>
|
||||
<label>To (IATA) <input name="to_iata" value="{{ flight.to_iata or '' }}" maxlength="3"></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Carrier (IATA) <input name="operating_carrier_iata" value="{{ flight.operating_carrier_iata or '' }}" maxlength="3"></label>
|
||||
<label>Flight number <input name="flight_number" value="{{ flight.flight_number or '' }}"></label>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<label>Flight date <input type="date" name="flight_date" value="{{ flight.flight_date or '' }}"></label>
|
||||
<label>Seat <input name="seat" value="{{ flight.seat or '' }}"></label>
|
||||
</div>
|
||||
<label>Compartment <input name="compartment" value="{{ flight.compartment or '' }}" maxlength="1"></label>
|
||||
<button type="submit" class="btn btn-secondary">Save changes</button>
|
||||
</form>
|
||||
|
||||
<dl class="meta-list">
|
||||
<dt>ICAO route</dt><dd>{{ flight.from_icao or '—' }} → {{ flight.to_icao or '—' }}</dd>
|
||||
<dt>Carrier ICAO</dt><dd>{{ flight.operating_carrier_icao or '—' }}</dd>
|
||||
<dt>Process after</dt><dd>{{ flight.process_after or '—' }}</dd>
|
||||
<dt>Fetch attempts</dt><dd>{{ flight.fetch_attempts }}</dd>
|
||||
{% if flight.flightaware_url %}
|
||||
<dt>FlightAware</dt><dd><a href="{{ flight.flightaware_url }}" target="_blank" rel="noopener">{{ flight.flightaware_url }}</a></dd>
|
||||
{% endif %}
|
||||
{% if flight.track_point_count %}
|
||||
<dt>Track points</dt><dd>{{ flight.track_point_count }}</dd>
|
||||
{% endif %}
|
||||
{% if flight.airtrail_flight_id %}
|
||||
<dt>AirTrail flight</dt><dd>{{ flight.airtrail_flight_id }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Photo</h2>
|
||||
<a href="{{ url_for('uploaded_file', name=flight.image_path) }}" target="_blank" rel="noopener">
|
||||
<img src="{{ url_for('uploaded_file', name=flight.image_path) }}" alt="Boarding pass photo" class="bp-photo">
|
||||
</a>
|
||||
{% if flight.raw_bcbp %}
|
||||
<details class="raw-bcbp">
|
||||
<summary>Raw BCBP data</summary>
|
||||
<pre>{{ flight.raw_bcbp }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if flight.track_coordinates %}
|
||||
<div class="card map-card">
|
||||
<h2>Flight track</h2>
|
||||
<div id="track-map" data-track='{{ flight.track_coordinates | tojson }}'></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<form method="post" action="{{ url_for('review.requery', flight_id=flight.id) }}">
|
||||
<button type="submit" class="btn btn-secondary">Re-query FlightAware</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('review.approve', flight_id=flight.id, token=approve_token) }}">
|
||||
<button type="submit" class="btn btn-success" {% if flight.status not in ('pending_review', 'track_unavailable') %}disabled{% endif %}>Approve</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('review.reject', flight_id=flight.id, token=reject_token) }}">
|
||||
<button type="submit" class="btn btn-danger" {% if flight.status in ('approved', 'rejected') %}disabled{% endif %}>Reject</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if flight.status not in ('pending_review', 'track_unavailable', 'approved', 'rejected') %}
|
||||
<p class="muted">Approve is unavailable until FlightAware has been checked for this flight
|
||||
(happens automatically the day after the flight, or click Re-query to check now).</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Review — Boarding Pass{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Flights</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<a class="tab {{ 'tab-active' if not active_status }}" href="{{ url_for('review.list_flights') }}">Needs review</a>
|
||||
{% for s in statuses %}
|
||||
<a class="tab {{ 'tab-active' if active_status == s }}" href="{{ url_for('review.list_flights', status=s) }}">{{ s.replace('_', ' ') }}</a>
|
||||
{% endfor %}
|
||||
<a class="tab {{ 'tab-active' if active_status == 'all' }}" href="{{ url_for('review.list_flights', status='all') }}">All</a>
|
||||
</div>
|
||||
|
||||
{% if not flights %}
|
||||
<p class="muted">No flights here.</p>
|
||||
{% else %}
|
||||
<div class="flight-list">
|
||||
{% for f in flights %}
|
||||
<a href="{{ url_for('review.detail', flight_id=f.id) }}" class="card flight-row">
|
||||
<span class="badge badge-{{ f.status }}">{{ f.status.replace('_', ' ') }}</span>
|
||||
<span class="flight-route">{{ f.from_iata or '?' }} → {{ f.to_iata or '?' }}</span>
|
||||
<span class="flight-number">{{ f.operating_carrier_iata or '' }}{{ f.flight_number or '' }}</span>
|
||||
<span class="flight-date muted">{{ f.flight_date or 'unknown date' }}</span>
|
||||
<span class="muted">{{ f.passenger_name or '' }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Settings — Boarding Pass{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
|
||||
{% if message %}<div class="alert alert-success">{{ message }}</div>{% endif %}
|
||||
|
||||
<form method="post" class="card settings-form">
|
||||
<h2>Notifications (ntfy)</h2>
|
||||
<label>Server URL
|
||||
<input name="ntfy_server_url" value="{{ settings.ntfy_server_url }}" placeholder="https://ntfy.sh">
|
||||
</label>
|
||||
<label>Topic
|
||||
<input name="ntfy_topic" value="{{ settings.ntfy_topic }}" placeholder="boarding-pass-alerts">
|
||||
</label>
|
||||
<label>Auth token <span class="muted">(optional, for self-hosted ntfy)</span>
|
||||
<input name="ntfy_auth_token" value="{{ settings.ntfy_auth_token }}" type="password">
|
||||
</label>
|
||||
<label>Daily job run time (24h, container timezone)
|
||||
<input name="daily_run_time" value="{{ settings.daily_run_time }}" placeholder="03:00">
|
||||
</label>
|
||||
<div class="settings-actions">
|
||||
<button type="submit" name="action" value="save" class="btn btn-primary">Save settings</button>
|
||||
<button type="submit" name="action" value="test" class="btn btn-secondary">Send test notification</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Upload — Boarding Pass{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Upload a boarding pass</h1>
|
||||
<p class="muted">Upload a photo or screenshot showing the barcode on your boarding pass.
|
||||
It'll be decoded, queued, and tracked automatically the day after your flight.</p>
|
||||
|
||||
{% if error %}<div class="alert alert-danger">{{ error }}</div>{% endif %}
|
||||
|
||||
<form method="post" enctype="multipart/form-data" class="card upload-form">
|
||||
<label for="photo" class="upload-drop">
|
||||
<span>Tap to choose a photo or screenshot</span>
|
||||
<input type="file" id="photo" name="photo" accept="image/*" required>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Upload & decode</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user