Stream advance fares and selectable ticket classes
This commit is contained in:
parent
2f3f01171c
commit
a5023d0672
4 changed files with 441 additions and 204 deletions
113
app.py
113
app.py
|
|
@ -2,9 +2,10 @@
|
|||
Combine GWR Bristol→Paddington trains with Eurostar St Pancras→destination 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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue