Stream advance fares and selectable ticket classes

This commit is contained in:
Edward Betts 2026-05-20 15:52:53 +01:00
parent 2f3f01171c
commit a5023d0672
4 changed files with 441 additions and 204 deletions

113
app.py
View file

@ -2,9 +2,10 @@
Combine GWR BristolPaddington trains with Eurostar St Pancrasdestination trains.
"""
from flask import Flask, render_template, redirect, url_for, request, abort, jsonify
from flask import Flask, render_template, redirect, url_for, request, abort, jsonify, Response, stream_with_context
from datetime import date, timedelta
from pathlib import Path
import json
import os
from cache import get_cached, set_cached
@ -80,6 +81,10 @@ def index():
VALID_MIN_CONNECTIONS = {45, 50, 60, 70, 80, 90, 100, 110, 120}
VALID_MAX_CONNECTIONS = {60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180}
VALID_NR_CLASSES = {'walkon', 'advance_std', 'advance_1st'}
VALID_ES_CLASSES = {'standard', 'plus'}
DEFAULT_NR_CLASS = 'walkon'
DEFAULT_ES_CLASS = 'standard'
def _get_defaults():
@ -111,6 +116,12 @@ def search():
max_conn = _parse_connection(
request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS
)
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES:
nr_class = DEFAULT_NR_CLASS
es_class = request.args.get("es_class", DEFAULT_ES_CLASS)
if es_class not in VALID_ES_CLASSES:
es_class = DEFAULT_ES_CLASS
if slug in DESTINATIONS and travel_date:
return redirect(
url_for(
@ -120,6 +131,8 @@ def search():
travel_date=travel_date,
min_connection=None if min_conn == default_min else min_conn,
max_connection=None if max_conn == default_max else max_conn,
nr_class=None if nr_class == DEFAULT_NR_CLASS else nr_class,
es_class=None if es_class == DEFAULT_ES_CLASS else es_class,
)
)
return redirect(url_for("index"))
@ -141,11 +154,21 @@ def results(station_crs, slug, travel_date):
max_connection = _parse_connection(
request.args.get("max_connection"), default_max, VALID_MAX_CONNECTIONS
)
nr_class = request.args.get("nr_class", DEFAULT_NR_CLASS)
if nr_class not in VALID_NR_CLASSES:
nr_class = DEFAULT_NR_CLASS
es_class = request.args.get("es_class", DEFAULT_ES_CLASS)
if es_class not in VALID_ES_CLASSES:
es_class = DEFAULT_ES_CLASS
# Redirect to clean URL when both params are at their defaults
if (
"min_connection" in request.args or "max_connection" in request.args
) and min_connection == default_min and max_connection == default_max:
# Redirect to clean URL when all params are at their defaults
_clean_url_params = ["min_connection", "max_connection", "nr_class", "es_class"]
if any(k in request.args for k in _clean_url_params) and (
min_connection == default_min
and max_connection == default_max
and nr_class == DEFAULT_NR_CLASS
and es_class == DEFAULT_ES_CLASS
):
return redirect(
url_for("results", station_crs=station_crs, slug=slug, travel_date=travel_date)
)
@ -287,6 +310,39 @@ def results(station_crs, slug, travel_date):
url_min = None if min_connection == default_min else min_connection
url_max = None if max_connection == default_max else max_connection
url_nr = None if nr_class == DEFAULT_NR_CLASS else nr_class
url_es = None if es_class == DEFAULT_ES_CLASS else es_class
# Build per-row fare data for JS consumption
trip_fares = {}
for row in result_rows:
stp = row.get("depart_st_pancras")
if not stp:
continue
circle_svcs = row.get("circle_services") or []
circle_fare = circle_svcs[0]["fare"] if circle_svcs else 0
walkon = (
{"price": row["ticket_price"], "ticket": row.get("ticket_name", "")}
if row.get("ticket_price") is not None
else None
)
es_std = (
{"price": row["eurostar_price"], "seats": row.get("eurostar_seats")}
if row.get("eurostar_price") is not None
else None
)
es_plus = (
{"price": row["eurostar_plus_price"], "seats": row.get("eurostar_plus_seats")}
if row.get("eurostar_plus_price") is not None
else None
)
trip_fares[stp] = {
"depart_bristol": row.get("depart_bristol"),
"walkon": walkon,
"es_standard": es_std,
"es_plus": es_plus,
"circle_fare": circle_fare,
}
return render_template(
"results.html",
@ -316,7 +372,14 @@ def results(station_crs, slug, travel_date):
default_max_connection=default_max,
url_min_connection=url_min,
url_max_connection=url_max,
cached_advance_fares=cached_advance_fares,
nr_class=nr_class,
es_class=es_class,
url_nr_class=url_nr,
url_es_class=url_es,
trip_fares_json=json.dumps(trip_fares),
advance_fares_json=json.dumps(cached_advance_fares),
advance_fares_api_url=url_for("api_advance_fares", station_crs=station_crs, travel_date=travel_date),
advance_fares_stream_url=url_for("api_advance_fares_stream", station_crs=station_crs, travel_date=travel_date),
valid_min_connections=sorted(VALID_MIN_CONNECTIONS),
valid_max_connections=sorted(VALID_MAX_CONNECTIONS),
)
@ -338,5 +401,43 @@ def api_advance_fares(station_crs, travel_date):
return jsonify({"error": str(e)}), 500
@app.route("/api/advance_fares_stream/<station_crs>/<travel_date>")
def api_advance_fares_stream(station_crs, travel_date):
if station_crs not in STATION_BY_CRS:
abort(404)
cache_key = f"gwr_advance_{station_crs}_{travel_date}"
def generate():
cached = get_cached(cache_key, ttl=24 * 3600)
if cached is not None:
yield f"data: {json.dumps({'type': 'fares', 'fares': cached})}\n\n"
yield f"data: {json.dumps({'type': 'done'})}\n\n"
return
accumulated: dict = {}
try:
for page_fares in gwr_fares_scraper.fetch_advance_streaming(station_crs, travel_date):
for dep_time, fare_data in page_fares.items():
if dep_time not in accumulated:
accumulated[dep_time] = {"advance_std": None, "advance_1st": None}
if fare_data.get("advance_std"):
accumulated[dep_time]["advance_std"] = fare_data["advance_std"]
if fare_data.get("advance_1st"):
accumulated[dep_time]["advance_1st"] = fare_data["advance_1st"]
yield f"data: {json.dumps({'type': 'fares', 'fares': page_fares})}\n\n"
except Exception as e:
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
return
set_cached(cache_key, accumulated)
yield f"data: {json.dumps({'type': 'done'})}\n\n"
return Response(
stream_with_context(generate()),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0")