diff --git a/main.py b/main.py index 0f218c3..83f213a 100755 --- a/main.py +++ b/main.py @@ -2,44 +2,60 @@ """Check prices of ferries to France.""" import inspect -from datetime import date, datetime +import json +import os +import os.path +import re +from datetime import date, datetime, timedelta from typing import Any -import configparser - import flask -import requests +import pytz +import routes import werkzeug.exceptions from werkzeug.debug.tbtools import get_current_traceback +from werkzeug.wrappers import Response -import pytz +from ferry import ports +from ferry.api import get_accommodations, get_prices +from ferry.read_config import ferry_config, vehicle_from_config app = flask.Flask(__name__) -app.debug = True - -ports = { - "PORTSMOUTH": "GBPME", - "PLYMOUTH": "GBPLY", - "POOLE": "GBPOO", - "CAEN": "FROUI", - "CHERBOURG": "FRCER", - "ST MALO": "FRSML", -} +app.debug = False -ferry_config = configparser.ConfigParser() -ferry_config.read("/home/edward/.config/brittany-ferries/config") +def cache_location() -> str: + return os.path.expanduser(ferry_config.get("cache", "location")) -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}, - } +def cache_filename(params: str) -> str: + """Get a filename to use for caching.""" + now_str = datetime.utcnow().strftime("%Y-%m-%d_%H%M") + params = params.replace(":", "_").replace(".", "_") + + return os.path.join(cache_location(), now_str + "_" + params + ".json") + + +def time_to_minutes(t: str) -> int: + """Convert time (22:50) into minutes since midnight.""" + m = re.match(r"^(\d\d):(\d\d)$", t) + assert m + hours, minutes = [int(j) for j in m.groups()] + + return (60 * hours) + minutes + + +def get_duration(depart: str, arrive: str, time_delta: int) -> str: + """Given two times calculate the duration and return as string.""" + depart_min = time_to_minutes(depart) + arrive_min = time_to_minutes(arrive) + time_delta + + duration = arrive_min - depart_min + + if depart_min > arrive_min: + duration += 60 * 24 + + return f"{duration // 60}h{ duration % 60:02d}m" @app.errorhandler(werkzeug.exceptions.InternalServerError) @@ -59,56 +75,10 @@ def exception_handler(e): def parse_date(d: str) -> date: - """Parse an ISO date.""" + """Parse a date from a string in ISO format.""" 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 @@ -128,8 +98,9 @@ def show_route( @app.route("/") -def start(): +def start() -> Response: """Start page.""" + return flask.render_template("index.html") return flask.redirect(flask.url_for("outbound_page")) @@ -146,81 +117,183 @@ def cabins_url(dep, arr, crossing, ticket_tier): ) -def get_days_until_start(): +def get_days_until_start() -> int: + """How long until the travel date.""" 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"], - ] +def get_prices_with_cache( + name: str, + start: str, + end: str, + selection: list[tuple[str, str]], + refresh: bool = False, +) -> list[tuple[str, str, dict[str, Any]]]: - from_date = ferry_config.get("outbound", "from") - to_date = ferry_config.get("outbound", "to") + params = f"{name}_{start}_{end}" + existing_files = os.listdir(cache_location()) + existing = [f for f in existing_files if f.endswith(params + ".json")] + if not refresh and existing: + recent_filename = max(existing) + recent = datetime.strptime(recent_filename, f"%Y-%m-%d_%H%M_{params}.json") + + now = datetime.utcnow() + + delta = now - recent + if delta < timedelta(hours=1): + full = os.path.join(cache_location(), recent_filename) + data = json.load(open(full)) + return data + + vehicle = vehicle_from_config(ferry_config) + filename = cache_filename(params) all_data = [ - (dep, arr, get_prices(ports[dep], ports[arr], from_date, to_date)["crossings"]) + (dep, arr, get_prices(ports[dep], ports[arr], start, end, vehicle)["crossings"]) for dep, arr in selection ] + + with open(filename, "w") as out: + print(filename) + json.dump(all_data, out, indent=2) + + return all_data + + +def build_outbound(section: str) -> str: + """Show all routes on one page.""" + selection = [ + ("PORTSMOUTH", "CAEN"), + ("PORTSMOUTH", "CHERBOURG"), + ("PORTSMOUTH", "ST MALO"), + # ("POOLE", "CHERBOURG"), + ] + + start = ferry_config.get(section, "from") + end = ferry_config.get(section, "to") + refresh = bool(flask.request.args.get("refresh")) + + all_data = get_prices_with_cache(section, start, end, selection, refresh) + 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", + from_date=start, + to_date=end, cabins_url=cabins_url, + get_duration=get_duration, + time_delta=-60, + format_pet_options=format_pet_options, ) +@app.route("/outbound1") +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.""" selection = [ - ["CAEN", "PORTSMOUTH"], - ["CHERBOURG", "PORTSMOUTH"], - ["ST MALO", "PORTSMOUTH"], - ["CHERBOURG", "POOLE"], + ("CAEN", "PORTSMOUTH"), + ("CHERBOURG", "PORTSMOUTH"), + ("ST MALO", "PORTSMOUTH"), + # ("CHERBOURG", "POOLE"), ] - from_date = ferry_config.get("return", "from") - to_date = ferry_config.get("return", "to") + start = ferry_config.get("return", "from") + end = ferry_config.get("return", "to") + refresh = bool(flask.request.args.get("refresh")) + + all_data = get_prices_with_cache("return", start, end, selection, refresh) - 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", + from_date=start, + to_date=end, cabins_url=cabins_url, + get_duration=get_duration, + time_delta=60, + format_pet_options=format_pet_options, ) +def get_accommodations_with_cache( + dep: str, arr: str, d: str, ticket_tier: str, refresh: bool = False +) -> dict[str, list[dict[str, Any]]]: + params = f"{dep}_{arr}_{d}_{ticket_tier}" + existing_files = os.listdir(cache_location()) + existing = [f for f in existing_files if f.endswith(params + ".json")] + if not refresh and existing: + recent_filename = max(existing) + recent = datetime.strptime(recent_filename, f"%Y-%m-%d_%H%M_{params}.json") + + now = datetime.utcnow() + + delta = now - recent + if delta < timedelta(hours=1): + full = os.path.join(cache_location(), recent_filename) + data = json.load(open(full)) + return data + + vehicle = vehicle_from_config(ferry_config) + filename = cache_filename(params) + data = get_accommodations(dep, arr, d, ticket_tier, vehicle) + + with open(filename, "w") as out: + print(filename) + json.dump(data, out, indent=2) + + return data + + @app.route("/cabins////") -def cabins(departure_port, arrival_port, departure_date, ticket_tier): - data = get_accommodations(departure_port, arrival_port, departure_date, ticket_tier) +def cabins( + departure_port: str, arrival_port: str, departure_date: str, ticket_tier: str +) -> str: + cabin_data = get_accommodations_with_cache( + departure_port, arrival_port, departure_date, ticket_tier + ) + accommodations = [ + a + for a in cabin_data["accommodations"] + if a["quantityAvailable"] > 0 and a["code"] != "RS" + # and "Inside" not in a["description"] + ] 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"], + accommodations=accommodations, + pet_accommodations=cabin_data["petAccommodations"], ) diff --git a/templates/all_routes.html b/templates/all_routes.html index 0fabcdf..667cdc5 100644 --- a/templates/all_routes.html +++ b/templates/all_routes.html @@ -5,6 +5,24 @@ Ferries to France + + + + {% from "individual_route.html" import route_table with context %} @@ -12,9 +30,11 @@
-

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

+

{{ 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

+ {#

{{ other }}

+ #} {% if extra_routes %}
    @@ -28,6 +48,10 @@

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

    {{ route_table(dep, arr, days) }} {% endfor %} + +
diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..9d3c9ad --- /dev/null +++ b/templates/base.html @@ -0,0 +1,29 @@ + + + + + + + + + {% block title %}{% endblock %} + + + + + {% block style %}{% endblock %} + + + + {% block content %}{% endblock %} + + + + {% block script %}{% endblock %} + + diff --git a/templates/cabins.html b/templates/cabins.html index 0720f22..9254ff6 100644 --- a/templates/cabins.html +++ b/templates/cabins.html @@ -5,6 +5,25 @@ Ferries to France + + + + + @@ -14,12 +33,12 @@

{{ departure_date }} {{ ticket_tier }}

- +
- - + + {% for a in accommodations if a.quantityAvailable > 0 %} @@ -27,12 +46,28 @@ - + {% endfor %}
code descriptionmax adultsquantity availablebirthsquantity
available
price
{{ a.code }} {{ a.description }} {{ a.maxAdults }}{{ a.quantityAvailable }} + {% if a.quantityAvailable == 10 %} + 10+ + {% else %} + {{ a.quantityAvailable }} + {% endif %} + £{{ a.unitCost.amount }}
+

Pet accommodations

+ +

+ o.petStayInCar = 'G',
+ o.petLargeKennel = 'B',
+ o.petSmallKennel = 'K',
+

+ +
{{ pet_accommodations | tojson }}
+ diff --git a/templates/index.html b/templates/index.html index f8b87c6..93ab256 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,9 +11,9 @@
diff --git a/templates/individual_route.html b/templates/individual_route.html index 29a2002..13248eb 100644 --- a/templates/individual_route.html +++ b/templates/individual_route.html @@ -1,8 +1,10 @@ {% macro headings() %} + ID day depart arrive + duration ship economy standard @@ -17,10 +19,12 @@ {{ headings() }} {% for day in days %} {% set date = parse_date(day.date) %} - {% for crossing in day.prices %} + {% for i in day.prices if i.crossingPrices.sailingId not in ("385413", "384486", "386181", "386191", "388752", "385445", "384550") %} + {% set crossing = i.crossingPrices %} {# {{ crossing }} #} + {{ crossing.sailingId }} {{ date.strftime("%a, %d %b") }} {{ crossing.departureDateTime.time }} @@ -28,6 +32,9 @@ {{ crossing.arrivalDateTime.time }} + + {{ get_duration(crossing.departureDateTime.time, crossing.arrivalDateTime.time, time_delta) }} + {{ crossing.shipName }} @@ -46,6 +53,9 @@ £{{ crossing.flexiPrice.amount }} + + {{ format_pet_options(crossing.petAvailabilities) | join(", ") }} + {% if crossing.full %}full |{% endif %} {% if crossing.isCabinSpaceFull %}no cabin space |{% endif %} diff --git a/templates/show_error.html b/templates/show_error.html index e75e75e..8d97299 100644 --- a/templates/show_error.html +++ b/templates/show_error.html @@ -18,8 +18,6 @@ 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 }}