This commit is contained in:
Edward Betts 2023-04-16 19:56:26 +01:00
parent c26383c9c0
commit a5195cba1a
7 changed files with 263 additions and 71 deletions

View file

@ -7,6 +7,9 @@ import requests
from . import Vehicle from . import Vehicle
api_root_url = "https://www.brittany-ferries.co.uk/api/ferry/v1/" api_root_url = "https://www.brittany-ferries.co.uk/api/ferry/v1/"
ua = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/109.0"
headers = {"User-Agent": ua}
class VehicleDict(TypedDict): class VehicleDict(TypedDict):
@ -36,14 +39,16 @@ def get_prices(
from_date: str, from_date: str,
to_date: str, to_date: str,
vehicle: Vehicle, vehicle: Vehicle,
adults: int = 2,
small_dogs: int = 1,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Call Brittany Ferries API to get details of crossings.""" """Call Brittany Ferries API to get details of crossings."""
url = api_root_url + "crossing/prices" url = api_root_url + "crossing/prices"
post_data = { post_data = {
"bookingReference": None, "bookingReference": None,
"pets": {"smallDogs": 1, "largeDogs": 0, "cats": 0}, "pets": {"smallDogs": small_dogs, "largeDogs": 0, "cats": 0},
"passengers": {"adults": 2, "children": 0, "infants": 0}, "passengers": {"adults": adults, "children": 0, "infants": 0},
"vehicle": vehicle_dict(vehicle), "vehicle": vehicle_dict(vehicle),
"departurePort": departure_port, "departurePort": departure_port,
"arrivalPort": arrival_port, "arrivalPort": arrival_port,
@ -53,7 +58,7 @@ def get_prices(
"toDate": f"{to_date}T23:59:59", "toDate": f"{to_date}T23:59:59",
} }
r = requests.post(url, json=post_data) r = requests.post(url, json=post_data, headers=headers)
data: dict[str, Any] = r.json() data: dict[str, Any] = r.json()
return data return data
@ -82,5 +87,7 @@ def get_accommodations(
"offerType": "NONE", "offerType": "NONE",
} }
json_data: dict[str, Any] = requests.post(url, json=post_data).json() json_data: dict[str, Any] = requests.post(
url, json=post_data, headers=headers
).json()
return json_data return json_data

271
main.py
View file

@ -6,22 +6,46 @@ import json
import os import os
import os.path import os.path
import re import re
import sys
import traceback
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import Any from typing import Any
import dateutil.parser import dateutil.parser
import flask import flask
import pytz import pytz
import werkzeug.exceptions import werkzeug
from werkzeug.debug.tbtools import get_current_traceback from werkzeug.debug.tbtools import DebugTraceback
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
import ferry import ferry
from ferry.api import get_accommodations, get_prices from ferry.api import get_accommodations, get_prices
from ferry.read_config import ferry_config, vehicle_from_config from ferry.read_config import ferry_config, vehicle_from_config
# import werkzeug.exceptions
# from werkzeug.debug.tbtools import DebugTraceback
# from werkzeug.debug.tbtools import get_current_traceback
app = flask.Flask(__name__) app = flask.Flask(__name__)
app.debug = False app.debug = True
routes = {
"outbound": [
("PORTSMOUTH", "CAEN"),
("PORTSMOUTH", "CHERBOURG"),
("PORTSMOUTH", "ST MALO"),
("PORTSMOUTH", "LE HAVRE"),
("POOLE", "CHERBOURG"),
],
"return": [
("CAEN", "PORTSMOUTH"),
("CHERBOURG", "PORTSMOUTH"),
("ST MALO", "PORTSMOUTH"),
("LE HAVRE", "PORTSMOUTH"),
("CHERBOURG", "POOLE"),
],
}
def cache_location() -> str: def cache_location() -> str:
@ -60,14 +84,26 @@ def get_duration(depart: str, arrive: str, time_delta: int) -> str:
@app.errorhandler(werkzeug.exceptions.InternalServerError) @app.errorhandler(werkzeug.exceptions.InternalServerError)
def exception_handler(e): def exception_handler(e: Exception) -> tuple[str, int]:
tb = get_current_traceback() """Show error page."""
last_frame = next(frame for frame in reversed(tb.frames) if not frame.is_library) exec_type, exc_value, current_traceback = sys.exc_info()
last_frame_args = inspect.getargs(last_frame.code) assert exc_value
tb = DebugTraceback(exc_value)
summary = tb.render_traceback_html(include_title=False)
exc_lines = list(tb._te.format_exception_only())
frames = [f for f, lineno in traceback.walk_tb(current_traceback)]
last_frame = frames[-1]
last_frame_args = inspect.getargs(last_frame.f_code)
return ( return (
flask.render_template( flask.render_template(
"show_error.html", "show_error.html",
tb=tb, plaintext=tb.render_traceback_text(),
exception="".join(exc_lines),
exception_type=tb._te.exc_type.__name__,
summary=summary,
last_frame=last_frame, last_frame=last_frame,
last_frame_args=last_frame_args, last_frame_args=last_frame_args,
), ),
@ -75,6 +111,23 @@ def exception_handler(e):
) )
# @app.errorhandler(werkzeug.exceptions.InternalServerError)
# def exception_handler(e):
# # tb = get_current_traceback()
# tb = DebugTraceback()
# last_frame = next(frame for frame in reversed(tb.frames) if not frame.is_library)
# last_frame_args = inspect.getargs(last_frame.code)
# return (
# flask.render_template(
# "show_error.html",
# tb=tb,
# last_frame=last_frame,
# last_frame_args=last_frame_args,
# ),
# 500,
# )
def parse_date(d: str) -> date: def parse_date(d: str) -> date:
"""Parse a date from a string in ISO format.""" """Parse a date from a string in ISO format."""
return datetime.strptime(d, "%Y-%m-%d").date() return datetime.strptime(d, "%Y-%m-%d").date()
@ -93,6 +146,7 @@ def cabins_url(dep: str, arr: str, crossing: dict[str, Any], ticket_tier: str) -
return flask.url_for( return flask.url_for(
"cabins", "cabins",
sailing_id=int(crossing["sailingId"]),
departure_port=ferry.ports[dep], departure_port=ferry.ports[dep],
arrival_port=ferry.ports[arr], arrival_port=ferry.ports[arr],
departure_date=utc_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z"), departure_date=utc_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
@ -106,7 +160,7 @@ def get_days_until_start() -> int:
return (start - date.today()).days return (start - date.today()).days
PriceData = list[tuple[str, str, dict[str, Any]]] PriceData = list[tuple[str, str, list[dict[str, Any]]]]
def check_cache_for_prices(params: str) -> PriceData | None: def check_cache_for_prices(params: str) -> PriceData | None:
@ -130,14 +184,16 @@ def check_cache_for_prices(params: str) -> PriceData | None:
def get_prices_with_cache( def get_prices_with_cache(
name: str, direction: str,
start: str, start: str,
end: str, end: str,
selection: list[tuple[str, str]], selection: list[tuple[str, str]],
refresh: bool = False, refresh: bool = False,
adults: int = 2,
small_dogs: int = 1,
) -> PriceData: ) -> PriceData:
"""Get price data using cache.""" """Get price data using cache."""
params = f"{name}_{start}_{end}" params = f"{direction}_{start}_{end}_{adults}_{small_dogs}"
if not refresh: if not refresh:
data = check_cache_for_prices(params) data = check_cache_for_prices(params)
if data: if data:
@ -149,9 +205,15 @@ def get_prices_with_cache(
( (
dep, dep,
arr, arr,
get_prices(ferry.ports[dep], ferry.ports[arr], start, end, vehicle)[ get_prices(
"crossings" ferry.ports[dep],
], ferry.ports[arr],
start,
end,
vehicle,
adults,
small_dogs,
)["crossings"],
) )
for dep, arr in selection for dep, arr in selection
] ]
@ -164,18 +226,21 @@ def get_prices_with_cache(
def build_outbound(section: str) -> str: def build_outbound(section: str) -> str:
"""Show all routes on one page.""" """Show all routes on one page."""
selection = [
("PORTSMOUTH", "CAEN"),
("PORTSMOUTH", "CHERBOURG"),
("PORTSMOUTH", "ST MALO"),
# ("POOLE", "CHERBOURG"),
]
start = ferry_config.get(section, "from") start = ferry_config.get(section, "from")
end = ferry_config.get(section, "to") end = ferry_config.get(section, "to")
refresh = bool(flask.request.args.get("refresh")) refresh = bool(flask.request.args.get("refresh"))
all_data = get_prices_with_cache(section, start, end, selection, refresh) adults_str = flask.request.args.get("adults")
adults = int(adults_str) if adults_str else 2
small_dogs_str = flask.request.args.get("small_dogs")
small_dogs = int(small_dogs_str) if small_dogs_str else 2
direction = section[:-1] if section[-1].isdigit() else section
all_data = get_prices_with_cache(
direction, start, end, routes["outbound"], refresh, adults, small_dogs
)
return flask.render_template( return flask.render_template(
"all_routes.html", "all_routes.html",
@ -192,43 +257,23 @@ def build_outbound(section: str) -> str:
) )
@app.route("/outbound1") def build_return(section: str) -> str:
def outbound1_page() -> str:
return build_outbound("outbound1")
@app.route("/outbound2")
def outbound2_page() -> str:
return build_outbound("outbound2")
def format_pet_options(o: dict[str, bool]) -> list[str]:
ret = []
if o.get("petCabinAvailable"):
ret.append("pet cabins")
if o.get("smallKennelAvailable"):
ret.append("small kennel")
if o.get("stayInCarAvailable"):
ret.append("pets stay in car")
return ret
@app.route("/return")
def return_page() -> str:
"""Show all routes on one page.""" """Show all routes on one page."""
selection = [ start = ferry_config.get(section, "from")
("CAEN", "PORTSMOUTH"), end = ferry_config.get(section, "to")
("CHERBOURG", "PORTSMOUTH"),
("ST MALO", "PORTSMOUTH"),
# ("CHERBOURG", "POOLE"),
]
start = ferry_config.get("return", "from")
end = ferry_config.get("return", "to")
refresh = bool(flask.request.args.get("refresh")) refresh = bool(flask.request.args.get("refresh"))
all_data = get_prices_with_cache("return", start, end, selection, refresh) direction = section[:-1] if section[-1].isdigit() else section
adults_str = flask.request.args.get("adults")
adults = int(adults_str) if adults_str else 2
small_dogs_str = flask.request.args.get("small_dogs")
small_dogs = int(small_dogs_str) if small_dogs_str else 2
all_data = get_prices_with_cache(
direction, start, end, routes["return"], refresh, adults, small_dogs
)
return flask.render_template( return flask.render_template(
"all_routes.html", "all_routes.html",
@ -245,6 +290,44 @@ def return_page() -> str:
) )
@app.route("/outbound1")
def outbound1_page() -> str:
return build_outbound("outbound1")
@app.route("/outbound2")
def outbound2_page() -> str:
return build_outbound("outbound2")
@app.route("/outbound3")
def outbound3_page() -> str:
return build_outbound("outbound3")
@app.route("/return1")
def return1_page() -> str:
return build_return("return1")
@app.route("/return2")
def return2_page() -> str:
return build_return("return2")
def format_pet_options(o: dict[str, bool]) -> list[str]:
"""Read pet options from API and format for display."""
ret = []
if o.get("petCabinAvailable"):
ret.append("pet cabins")
if o.get("smallKennelAvailable"):
ret.append("small kennel")
if o.get("stayInCarAvailable"):
ret.append("pets stay in car")
return ret
def get_accommodations_with_cache( def get_accommodations_with_cache(
dep: str, arr: str, d: str, ticket_tier: str, refresh: bool = False dep: str, arr: str, d: str, ticket_tier: str, refresh: bool = False
) -> dict[str, list[dict[str, Any]]]: ) -> dict[str, list[dict[str, Any]]]:
@ -274,10 +357,77 @@ def get_accommodations_with_cache(
return data return data
@app.route("/cabins/<departure_port>/<arrival_port>/<departure_date>/<ticket_tier>") def get_start_end(section: str) -> tuple[str, str]:
start = ferry_config.get(section, "from")
end = ferry_config.get(section, "to")
return start, end
def get_return_section(departure_date: str) -> tuple[str, str, str]:
section = "return1"
start, end = get_start_end(section)
dep = departure_date[:10]
if start <= dep <= end:
return section, start, end
section = "return2"
start, end = get_start_end(section)
if start <= dep <= end:
return section, start, end
def get_outbound_section(departure_date: str) -> tuple[str, str, str]:
section = "outbound1"
start, end = get_start_end(section)
dep = departure_date[:10]
if start <= dep <= end:
return section, start, end
section = "outbound2"
start, end = get_start_end(section)
if start <= dep <= end:
return section, start, end
section = "outbound3"
start, end = get_start_end(section)
if start <= dep <= end:
return section, start, end
def lookup_sailing_id(prices: PriceData, sailing_id: int) -> dict[str, Any] | None:
for dep, arr, price_list in prices:
for i in price_list:
for j in i["prices"]:
crossing: dict[str, Any] = j["crossingPrices"]
if int(crossing["sailingId"]) == sailing_id:
return crossing
return None
@app.route(
"/cabins/<int:sailing_id>/<departure_port>/<arrival_port>/<departure_date>/<ticket_tier>"
)
def cabins( def cabins(
departure_port: str, arrival_port: str, departure_date: str, ticket_tier: str sailing_id: int,
departure_port: str,
arrival_port: str,
departure_date: str,
ticket_tier: str,
) -> str: ) -> str:
"""Get details of cabins."""
direction = {"GB": "outbound", "FR": "return"}[departure_port[:2]]
if direction == "return":
section, start, end = get_return_section(departure_date[:10])
start, end = get_start_end(section)
time_delta = 60
else:
section, start, end = get_outbound_section(departure_date[:10])
time_delta = -60
prices = get_prices_with_cache(direction, start, end, routes[direction])
crossing = lookup_sailing_id(prices, sailing_id)
cabin_data = get_accommodations_with_cache( cabin_data = get_accommodations_with_cache(
departure_port, arrival_port, departure_date, ticket_tier departure_port, arrival_port, departure_date, ticket_tier
) )
@ -299,6 +449,11 @@ def cabins(
ticket_tier=ticket_tier, ticket_tier=ticket_tier,
accommodations=accommodations, accommodations=accommodations,
pet_accommodations=cabin_data["petAccommodations"], pet_accommodations=cabin_data["petAccommodations"],
crossing=crossing,
get_duration=get_duration,
time_delta=time_delta,
format_pet_options=format_pet_options,
section=section,
) )

View file

@ -30,12 +30,18 @@ a:link {
<body> <body>
<div class="m-3"> <div class="m-3">
<p>{{ days_until_start }} days / {{ (days_until_start / 7) | int }} weeks / {{ "{:.1f}".format(days_until_start / 30.5) }} months until start of Dodainville week: Friday 17 March 2022</p> <p>{{ days_until_start }} days / {{ (days_until_start / 7) | int }} weeks / {{ "{:.1f}".format(days_until_start / 30.5) }} months until start of Dodainville week: Friday 5 May 2022</p>
{# {#
<p><a href="{{ url_for(other + "_page") }}">{{ other }}</a></p> <p><a href="{{ url_for(other + "_page") }}">{{ other }}</a></p>
#} #}
<ul>
<li><a href="?adults=2&small_dogs=1">2 adults and a dog</a></li>
<li><a href="?adults=2&small_dogs=0">2 adults</a></li>
<li><a href="?adults=1&small_dogs=0">1 adult</a></li>
</ul>
{% if extra_routes %} {% if extra_routes %}
<ul> <ul>
{% for dep, arr in extra_routes %} {% for dep, arr in extra_routes %}

View file

@ -34,13 +34,15 @@ a:link {
<p>{{ departure_date.strftime("%A, %d %B %Y %H:%M UTC") }} {{ ticket_tier }}</p> <p>{{ departure_date.strftime("%A, %d %B %Y %H:%M UTC") }} {{ ticket_tier }}</p>
<p><a href="{{ url_for(section + "_page") }}">back to sailings</a></p>
<table class="table w-auto"> <table class="table w-auto">
<tr> <tr>
<th>code</th> <th>code</th>
<th>description</th> <th>description</th>
<th>births</th> <th>births</th>
<th>quantity<br/>available</th> <th>quantity<br/>available</th>
<th>price</th> <th class="text-end">price</th>
</tr> </tr>
{% for a in accommodations if a.quantityAvailable > 0 %} {% for a in accommodations if a.quantityAvailable > 0 %}
<tr> <tr>
@ -54,7 +56,7 @@ a:link {
{{ a.quantityAvailable }} {{ a.quantityAvailable }}
{% endif %} {% endif %}
</td> </td>
<td>£{{ a.unitCost.amount }}</td> <td class="text-end">£{{ a.unitCost.amount }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
@ -69,6 +71,19 @@ a:link {
{% endfor %} {% endfor %}
</ul> </ul>
<h4>Sailing</h4>
<ul>
<li>sailing ID: {{ crossing.sailingId }}</li>
<li>depart: {{ crossing.departureDateTime.time }}</li>
<li>arrive: {{ crossing.arrivalDateTime.time }}</li>
<li>duration: {{ get_duration(crossing.departureDateTime.time, crossing.arrivalDateTime.time, time_delta) }}</li>
<li>ship: {{ crossing.shipName }}</li>
<li>economy price: £{{ crossing.economyPrice.amount }}</li>
<li>standard price: £{{ crossing.standardPrice.amount }}</li>
<li>flexi price: £{{ crossing.flexiPrice.amount }}</li>
</ul>
</div> </div>
</body> </body>
</html> </html>

View file

@ -11,9 +11,10 @@
<div class="m-3"> <div class="m-3">
<ul> <ul>
<li><a href="{{ url_for("outbound1_page") }}">Outbound: 10 March</a> <li><a href="{{ url_for("outbound1_page") }}">Outbound: 5 May</a>
<li><a href="{{ url_for("outbound2_page") }}">Outbound: 17 March</a> <li><a href="{{ url_for("return1_page") }}">Return: 12 May</a>
<li><a href="{{ url_for("return_page") }}">Return: 24 March</a> <li><a href="{{ url_for("outbound3_page") }}">Outbound: 29 September</a>
<li><a href="{{ url_for("return2_page") }}">Return: 6 October</a>
</ul> </ul>
</div> </div>

View file

@ -39,9 +39,13 @@
{{ crossing.shipName }} {{ crossing.shipName }}
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
{% if crossing.economyPrice %}
<a href="{{ cabins_url(dep, arr, crossing, "ECONOMY") }}"> <a href="{{ cabins_url(dep, arr, crossing, "ECONOMY") }}">
£{{ crossing.economyPrice.amount }} £{{ crossing.economyPrice.amount }}
</a> </a>
{% else %}
n/a
{% endif %}
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
<a href="{{ cabins_url(dep, arr, crossing, "STANDARD") }}"> <a href="{{ cabins_url(dep, arr, crossing, "STANDARD") }}">
@ -53,9 +57,11 @@
£{{ crossing.flexiPrice.amount }} £{{ crossing.flexiPrice.amount }}
</a> </a>
</td> </td>
{% if crossing.petAvailabilities %}
<td> <td>
{{ format_pet_options(crossing.petAvailabilities) | join(", ") }} {{ format_pet_options(crossing.petAvailabilities) | join(", ") }}
</td> </td>
{% endif %}
<td class="text-nowrap"> <td class="text-nowrap">
{% if crossing.full %}full |{% endif %} {% if crossing.full %}full |{% endif %}
{% if crossing.isCabinSpaceFull %}no cabin space |{% endif %} {% if crossing.isCabinSpaceFull %}no cabin space |{% endif %}

View file

@ -7,9 +7,9 @@
{% block content %} {% block content %}
<div class="p-2"> <div class="p-2">
<h1>Software error: {{ tb.exception_type }}</h1> <h1>Software error: {{ exception_type }}</h1>
<div> <div>
<pre>{{ tb.exception }}</pre> <pre>{{ exception }}</pre>
</div> </div>
{% set body %} {% set body %}
@ -19,10 +19,12 @@ URL: {{ request.url }}
{% endset %} {% endset %}
<h2 class="traceback">Traceback <em>(most recent call last)</em></h2> <h2 class="traceback">Traceback <em>(most recent call last)</em></h2>
{{ tb.render_summary(include_title=False) | safe }} {{ summary | safe }}
{#
<p>Error in function "{{ last_frame.function_name }}": {{ last_frame_args | pprint }}</p> <p>Error in function "{{ last_frame.function_name }}": {{ last_frame_args | pprint }}</p>
<pre>{{ last_frame.locals | pprint }}</pre> <pre>{{ last_frame.locals | pprint }}</pre>
#}
</div> </div>
{% endblock %} {% endblock %}