commit cbc681ddbcea550185b44fbaff3e58f4f0fa9784 Author: Edward Betts Date: Sat Sep 3 21:38:46 2022 +0100 Initial commit diff --git a/main.py b/main.py new file mode 100755 index 0000000..0f218c3 --- /dev/null +++ b/main.py @@ -0,0 +1,234 @@ +#!/usr/bin/python3 +"""Check prices of ferries to France.""" + +import inspect +from datetime import date, datetime +from typing import Any + +import configparser + +import flask +import requests +import werkzeug.exceptions +from werkzeug.debug.tbtools import get_current_traceback + +import pytz + +app = flask.Flask(__name__) +app.debug = True + +ports = { + "PORTSMOUTH": "GBPME", + "PLYMOUTH": "GBPLY", + "POOLE": "GBPOO", + "CAEN": "FROUI", + "CHERBOURG": "FRCER", + "ST MALO": "FRSML", +} + + +ferry_config = configparser.ConfigParser() +ferry_config.read("/home/edward/.config/brittany-ferries/config") + + +def get_vehicle() -> dict[str, str | int | list[str] | dict[str, None]]: + """Return vehicle detail in the format for the Brittany Ferries API.""" + return { + "type": ferry_config.get("vehicle", "type"), + "registrations": [ferry_config.get("vehicle", "registration")], + "height": ferry_config.getint("vehicle", "height"), + "length": ferry_config.getint("vehicle", "length"), + "extras": {"rearMountedBikeCarrier": None}, + } + + +@app.errorhandler(werkzeug.exceptions.InternalServerError) +def exception_handler(e): + tb = get_current_traceback() + 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: + """Parse an ISO date.""" + return datetime.strptime(d, "%Y-%m-%d").date() + + +def get_accommodations( + departure_port: str, arrival_port: str, departure_date: str, ticket_tier: str +): + url = "https://www.brittany-ferries.co.uk/api/ferry/v1/crossing/accommodations" + post_data = { + "bookingReference": None, + "departurePort": departure_port, + "arrivalPort": arrival_port, + "departureDate": departure_date, + "passengers": {"adults": 2, "children": 0, "infants": 0}, + "disability": None, + "vehicle": get_vehicle(), + "petCabinsNeeded": False, + "ticketTier": ticket_tier, + "pets": {"smallDogs": 0, "largeDogs": 0, "cats": 0}, + "sponsor": None, + } + + r = requests.post(url, json=post_data) + return r.json() + + +def get_prices( + departure_port: str, arrival_port: str, from_date: str, to_date: str +) -> dict[str, Any]: + """Call Brittany Ferries API to get details of crossings.""" + url = "https://www.brittany-ferries.co.uk/api/ferry/v1/crossing/prices" + + post_data = { + "bookingReference": None, + "pets": {"smallDogs": 0, "largeDogs": 0, "cats": 0}, + "passengers": {"adults": 2, "children": 0, "infants": 0}, + "vehicle": get_vehicle(), + "departurePort": departure_port, + "arrivalPort": arrival_port, + "disability": None, + "sponsor": None, + "fromDate": f"{from_date}T00:00:00", + "toDate": f"{to_date}T23:59:59", + } + + r = requests.post(url, json=post_data) + data: dict[str, Any] = r.json() + return data + + +@app.route("/route////") +def show_route( + departure_port: str, arrival_port: str, from_date: str, to_date: str +) -> str: + """Page showing list of prices.""" + prices = get_prices(departure_port, arrival_port) + + port_lookup = {code: name for name, code in ports.items()} + + return flask.render_template( + "route.html", + departure_port=port_lookup[departure_port], + arrival_port=port_lookup[arrival_port], + days=prices["crossings"], + parse_date=parse_date, + ) + + +@app.route("/") +def start(): + """Start page.""" + return flask.redirect(flask.url_for("outbound_page")) + + +def cabins_url(dep, arr, crossing, ticket_tier): + dt = datetime.fromisoformat(crossing["departureDateTime"]["iso"]) + utc_dt = dt.astimezone(pytz.utc) + + return flask.url_for( + "cabins", + departure_port=ports[dep], + arrival_port=ports[arr], + departure_date=utc_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z"), + ticket_tier=ticket_tier, + ) + + +def get_days_until_start(): + start = date.fromisoformat(ferry_config.get("dates", "start")) + return (start - date.today()).days + + +@app.route("/outbound") +def outbound_page() -> str: + """Show all routes on one page.""" + selection = [ + ["PORTSMOUTH", "CAEN"], + ["PORTSMOUTH", "CHERBOURG"], + ["PORTSMOUTH", "ST MALO"], + ["POOLE", "CHERBOURG"], + ] + + from_date = ferry_config.get("outbound", "from") + to_date = ferry_config.get("outbound", "to") + + all_data = [ + (dep, arr, get_prices(ports[dep], ports[arr], from_date, to_date)["crossings"]) + for dep, arr in selection + ] + return flask.render_template( + "all_routes.html", + data=all_data, + days_until_start=get_days_until_start(), + ports=ports, + parse_date=parse_date, + from_date=from_date, + to_date=to_date, + other="return", + cabins_url=cabins_url, + ) + + +@app.route("/return") +def return_page() -> str: + """Show all routes on one page.""" + selection = [ + ["CAEN", "PORTSMOUTH"], + ["CHERBOURG", "PORTSMOUTH"], + ["ST MALO", "PORTSMOUTH"], + ["CHERBOURG", "POOLE"], + ] + + from_date = ferry_config.get("return", "from") + to_date = ferry_config.get("return", "to") + + all_data = [ + (dep, arr, get_prices(ports[dep], ports[arr], from_date, to_date)["crossings"]) + for dep, arr in selection + ] + return flask.render_template( + "all_routes.html", + data=all_data, + ports=ports, + days_until_start=get_days_until_start(), + parse_date=parse_date, + from_date=from_date, + to_date=to_date, + other="outbound", + cabins_url=cabins_url, + ) + + +@app.route("/cabins////") +def cabins(departure_port, arrival_port, departure_date, ticket_tier): + data = get_accommodations(departure_port, arrival_port, departure_date, ticket_tier) + return flask.render_template( + "cabins.html", + departure_port=departure_port, + arrival_port=arrival_port, + departure_date=departure_date, + ticket_tier=ticket_tier, + accommodations=data["accommodations"], + ) + + +@app.route("/routes") +def route_list() -> str: + """List of routes.""" + return flask.render_template("index.html", routes=routes, ports=ports) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5001) diff --git a/static/css/exception.css b/static/css/exception.css new file mode 100644 index 0000000..1f141c5 --- /dev/null +++ b/static/css/exception.css @@ -0,0 +1,78 @@ +div.debugger { text-align: left; padding: 12px; margin: auto; + background-color: white; } +div.detail { cursor: pointer; } +div.detail p { margin: 0 0 8px 13px; font-size: 14px; white-space: pre-wrap; + font-family: monospace; } +div.explanation { margin: 20px 13px; font-size: 15px; color: #555; } +div.footer { font-size: 13px; text-align: right; margin: 30px 0; + color: #86989B; } + +h2 { font-size: 16px; margin: 1.3em 0 0.0 0; padding: 9px; + background-color: #11557C; color: white; } +h2 em, h3 em { font-style: normal; color: #A5D6D9; font-weight: normal; } + +div.traceback, div.plain { border: 1px solid #ddd; margin: 0 0 1em 0; padding: 10px; } +div.plain p { margin: 0; } +div.plain textarea, +div.plain pre { margin: 10px 0 0 0; padding: 4px; + background-color: #E8EFF0; border: 1px solid #D3E7E9; } +div.plain textarea { width: 99%; height: 300px; } +div.traceback h3 { font-size: 1em; margin: 0 0 0.8em 0; } +div.traceback ul { list-style: none; margin: 0; padding: 0 0 0 1em; } +div.traceback h4 { font-size: 13px; font-weight: normal; margin: 0.7em 0 0.1em 0; } +div.traceback pre { margin: 0; padding: 5px 0 3px 15px; + background-color: #E8EFF0; border: 1px solid #D3E7E9; } +div.traceback .library .current { background: white; color: #555; } +div.traceback .expanded .current { background: #E8EFF0; color: black; } +div.traceback pre:hover { background-color: #DDECEE; color: black; cursor: pointer; } +div.traceback div.source.expanded pre + pre { border-top: none; } + +div.traceback span.ws { display: none; } +div.traceback pre.before, div.traceback pre.after { display: none; background: white; } +div.traceback div.source.expanded pre.before, +div.traceback div.source.expanded pre.after { + display: block; +} + +div.traceback div.source.expanded span.ws { + display: inline; +} + +div.traceback blockquote { margin: 1em 0 0 0; padding: 0; white-space: pre-line; } +div.traceback img { float: right; padding: 2px; margin: -3px 2px 0 0; display: none; } +div.traceback img:hover { background-color: #ddd; cursor: pointer; + border-color: #BFDDE0; } +div.traceback pre:hover img { display: block; } +div.traceback cite.filename { font-style: normal; color: #3B666B; } + +pre.console { border: 1px solid #ccc; background: white!important; + color: black; padding: 5px!important; + margin: 3px 0 0 0!important; cursor: default!important; + max-height: 400px; overflow: auto; } +pre.console form { color: #555; } +pre.console input { background-color: transparent; color: #555; + width: 90%; font-family: 'Consolas', 'Deja Vu Sans Mono', + 'Bitstream Vera Sans Mono', monospace; font-size: 14px; + border: none!important; } + +span.string { color: #30799B; } +span.number { color: #9C1A1C; } +span.help { color: #3A7734; } +span.object { color: #485F6E; } +span.extended { opacity: 0.5; } +span.extended:hover { opacity: 1; } +a.toggle { text-decoration: none; background-repeat: no-repeat; + background-position: center center; + background-image: url(?__debugger__=yes&cmd=resource&f=more.png); } +a.toggle:hover { background-color: #444; } +a.open { background-image: url(?__debugger__=yes&cmd=resource&f=less.png); } + +div.traceback pre, div.console pre { + white-space: pre-wrap; /* css-3 should we be so lucky... */ + white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ + white-space: -pre-wrap; /* Opera 4-6 ?? */ + white-space: -o-pre-wrap; /* Opera 7 ?? */ + word-wrap: break-word; /* Internet Explorer 5.5+ */ + _white-space: pre; /* IE only hack to re-specify in + addition to word-wrap */ +} diff --git a/templates/all_routes.html b/templates/all_routes.html new file mode 100644 index 0000000..0fabcdf --- /dev/null +++ b/templates/all_routes.html @@ -0,0 +1,33 @@ + + + + + Ferries to France + + + + +{% from "individual_route.html" import route_table with context %} + + +
+ +

{{ days_until_start }} days until start of Dodainville week: Friday 16 September 2022

+ +

{{ other }}

+ + {% if extra_routes %} + + {% endif %} + + {% for dep, arr, days in data %} +

{{ dep.title() }} to {{ arr.title() }}

+ {{ route_table(dep, arr, days) }} + {% endfor %} +
+ + diff --git a/templates/cabins.html b/templates/cabins.html new file mode 100644 index 0000000..0720f22 --- /dev/null +++ b/templates/cabins.html @@ -0,0 +1,38 @@ + + + + + Ferries to France + + + + + +
+ +

{{ departure_port }} to {{ arrival_port }}

+ +

{{ departure_date }} {{ ticket_tier }}

+ + + + + + + + + + {% for a in accommodations if a.quantityAvailable > 0 %} + + + + + + + + {% endfor %} +
codedescriptionmax adultsquantity availableprice
{{ a.code }}{{ a.description }}{{ a.maxAdults }}{{ a.quantityAvailable }}£{{ a.unitCost.amount }}
+ +
+ + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f8b87c6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + +
+ + +
+ + + diff --git a/templates/individual_route.html b/templates/individual_route.html new file mode 100644 index 0000000..29a2002 --- /dev/null +++ b/templates/individual_route.html @@ -0,0 +1,57 @@ +{% macro headings() %} + + day + depart + arrive + ship + economy + standard + flexi + + + +{% endmacro %} + +{% macro route_table(dep, arr, days) %} + + {{ headings() }} + {% for day in days %} + {% set date = parse_date(day.date) %} + {% for crossing in day.prices %} + + {# #} + + + + + + + + + + + {% endfor %} + {% endfor %} +
{{ crossing }}
{{ date.strftime("%a, %d %b") }} + {{ crossing.departureDateTime.time }} + + {{ crossing.arrivalDateTime.time }} + + {{ crossing.shipName }} + + + £{{ crossing.economyPrice.amount }} + + + + £{{ crossing.standardPrice.amount }} + + + + £{{ crossing.flexiPrice.amount }} + + + {% if crossing.full %}full |{% endif %} + {% if crossing.isCabinSpaceFull %}no cabin space |{% endif %} +
+{% endmacro %} diff --git a/templates/route.html b/templates/route.html new file mode 100644 index 0000000..b0b4064 --- /dev/null +++ b/templates/route.html @@ -0,0 +1,17 @@ + + + + + + + + +{% from "individual_route.html" import route_table with context %} + + +
+

{{ departure_port.title() }} to {{ arrival_port.title() }}

+ {{ route_table(days) }} +
+ + diff --git a/templates/route_backup.html b/templates/route_backup.html new file mode 100644 index 0000000..5118ea2 --- /dev/null +++ b/templates/route_backup.html @@ -0,0 +1,60 @@ + + + + + + + + +{% macro headings() %} + + depart + arrive + ship + economy + standard + flexi + + + +{% endmacro %} + + +
+

{{ departure_port.title() }} to {{ arrival_port.title() }}

+ + {% for day in days %} + {% set date = parse_date(day.date) %} + + {{ headings() }} + {% for crossing in day.prices %} + + + + + + + + + + {% endfor %} + {% endfor %} +

{{ date.strftime("%A, %d %B %Y") }}

+ {{ crossing.departureDateTime.time }} + + {{ crossing.arrivalDateTime.time }} + + {{ crossing.shipName }} + + £{{ crossing.economyPrice.amount }} + + £{{ crossing.standardPrice.amount }} + + £{{ crossing.flexiPrice.amount }} + + {% if crossing.full %}full |{% endif %} + {% if crossing.isCabinSpaceFull %}no cabin space |{% endif %} +
+
+ + diff --git a/templates/show_error.html b/templates/show_error.html new file mode 100644 index 0000000..e75e75e --- /dev/null +++ b/templates/show_error.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block style %} + +{% endblock %} + +{% block content %} +
+ +

Software error: {{ tb.exception_type }}

+
+
{{ tb.exception }}
+
+ +{% set body %} +URL: {{ request.url }} + +{{ tb.plaintext | safe }} +{% endset %} + +

Submit as an issue on GitHub (requires an account with GitHub)

+ +

Traceback (most recent call last)

+{{ tb.render_summary(include_title=False) | safe }} + +

Error in function "{{ last_frame.function_name }}": {{ last_frame_args | pprint }}

+
{{ last_frame.locals | pprint }}
+
+ +{% endblock %}