This commit is contained in:
Edward Betts 2023-01-08 11:49:57 +00:00
parent cbc681ddbc
commit ebe672b972
7 changed files with 286 additions and 117 deletions

285
main.py
View file

@ -2,44 +2,60 @@
"""Check prices of ferries to France.""" """Check prices of ferries to France."""
import inspect 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 from typing import Any
import configparser
import flask import flask
import requests import pytz
import routes
import werkzeug.exceptions import werkzeug.exceptions
from werkzeug.debug.tbtools import get_current_traceback 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 = flask.Flask(__name__)
app.debug = True app.debug = False
ports = {
"PORTSMOUTH": "GBPME",
"PLYMOUTH": "GBPLY",
"POOLE": "GBPOO",
"CAEN": "FROUI",
"CHERBOURG": "FRCER",
"ST MALO": "FRSML",
}
ferry_config = configparser.ConfigParser() def cache_location() -> str:
ferry_config.read("/home/edward/.config/brittany-ferries/config") return os.path.expanduser(ferry_config.get("cache", "location"))
def get_vehicle() -> dict[str, str | int | list[str] | dict[str, None]]: def cache_filename(params: str) -> str:
"""Return vehicle detail in the format for the Brittany Ferries API.""" """Get a filename to use for caching."""
return { now_str = datetime.utcnow().strftime("%Y-%m-%d_%H%M")
"type": ferry_config.get("vehicle", "type"), params = params.replace(":", "_").replace(".", "_")
"registrations": [ferry_config.get("vehicle", "registration")],
"height": ferry_config.getint("vehicle", "height"), return os.path.join(cache_location(), now_str + "_" + params + ".json")
"length": ferry_config.getint("vehicle", "length"),
"extras": {"rearMountedBikeCarrier": None},
} 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) @app.errorhandler(werkzeug.exceptions.InternalServerError)
@ -59,56 +75,10 @@ def exception_handler(e):
def parse_date(d: str) -> date: 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() 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/<departure_port>/<arrival_port>/<from_date>/<to_date>") @app.route("/route/<departure_port>/<arrival_port>/<from_date>/<to_date>")
def show_route( def show_route(
departure_port: str, arrival_port: str, from_date: str, to_date: str departure_port: str, arrival_port: str, from_date: str, to_date: str
@ -128,8 +98,9 @@ def show_route(
@app.route("/") @app.route("/")
def start(): def start() -> Response:
"""Start page.""" """Start page."""
return flask.render_template("index.html")
return flask.redirect(flask.url_for("outbound_page")) 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")) start = date.fromisoformat(ferry_config.get("dates", "start"))
return (start - date.today()).days return (start - date.today()).days
@app.route("/outbound") def get_prices_with_cache(
def outbound_page() -> str: name: str,
"""Show all routes on one page.""" start: str,
selection = [ end: str,
["PORTSMOUTH", "CAEN"], selection: list[tuple[str, str]],
["PORTSMOUTH", "CHERBOURG"], refresh: bool = False,
["PORTSMOUTH", "ST MALO"], ) -> list[tuple[str, str, dict[str, Any]]]:
["POOLE", "CHERBOURG"],
]
from_date = ferry_config.get("outbound", "from") params = f"{name}_{start}_{end}"
to_date = ferry_config.get("outbound", "to") 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 = [ 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 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( return flask.render_template(
"all_routes.html", "all_routes.html",
data=all_data, data=all_data,
days_until_start=get_days_until_start(), days_until_start=get_days_until_start(),
ports=ports, ports=ports,
parse_date=parse_date, parse_date=parse_date,
from_date=from_date, from_date=start,
to_date=to_date, to_date=end,
other="return",
cabins_url=cabins_url, 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") @app.route("/return")
def return_page() -> str: def return_page() -> str:
"""Show all routes on one page.""" """Show all routes on one page."""
selection = [ selection = [
["CAEN", "PORTSMOUTH"], ("CAEN", "PORTSMOUTH"),
["CHERBOURG", "PORTSMOUTH"], ("CHERBOURG", "PORTSMOUTH"),
["ST MALO", "PORTSMOUTH"], ("ST MALO", "PORTSMOUTH"),
["CHERBOURG", "POOLE"], # ("CHERBOURG", "POOLE"),
] ]
from_date = ferry_config.get("return", "from") start = ferry_config.get("return", "from")
to_date = ferry_config.get("return", "to") 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( return flask.render_template(
"all_routes.html", "all_routes.html",
data=all_data, data=all_data,
ports=ports, ports=ports,
days_until_start=get_days_until_start(), days_until_start=get_days_until_start(),
parse_date=parse_date, parse_date=parse_date,
from_date=from_date, from_date=start,
to_date=to_date, to_date=end,
other="outbound",
cabins_url=cabins_url, 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/<departure_port>/<arrival_port>/<departure_date>/<ticket_tier>") @app.route("/cabins/<departure_port>/<arrival_port>/<departure_date>/<ticket_tier>")
def cabins(departure_port, arrival_port, departure_date, ticket_tier): def cabins(
data = get_accommodations(departure_port, arrival_port, departure_date, ticket_tier) 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( return flask.render_template(
"cabins.html", "cabins.html",
departure_port=departure_port, departure_port=departure_port,
arrival_port=arrival_port, arrival_port=arrival_port,
departure_date=departure_date, departure_date=departure_date,
ticket_tier=ticket_tier, ticket_tier=ticket_tier,
accommodations=data["accommodations"], accommodations=accommodations,
pet_accommodations=cabin_data["petAccommodations"],
) )

View file

@ -5,6 +5,24 @@
<title>Ferries to France</title> <title>Ferries to France</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
<!--
<style>
body {
background: black;
color: white;
}
.table { color: white; }
a:link {
color: rgb(127, 127, 255);
}
</style>
-->
</head> </head>
{% from "individual_route.html" import route_table with context %} {% from "individual_route.html" import route_table with context %}
@ -12,9 +30,11 @@
<body> <body>
<div class="m-3"> <div class="m-3">
<p>{{ days_until_start }} days until start of Dodainville week: Friday 16 September 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 17 March 2022</p>
{#
<p><a href="{{ url_for(other + "_page") }}">{{ other }}</a></p> <p><a href="{{ url_for(other + "_page") }}">{{ other }}</a></p>
#}
{% if extra_routes %} {% if extra_routes %}
<ul> <ul>
@ -28,6 +48,10 @@
<h4>{{ dep.title() }} to {{ arr.title() }}</h4> <h4>{{ dep.title() }} to {{ arr.title() }}</h4>
{{ route_table(dep, arr, days) }} {{ route_table(dep, arr, days) }}
{% endfor %} {% endfor %}
<!--
{{ data | pprint | safe }}
-->
</div> </div>
</body> </body>
</html> </html>

29
templates/base.html Normal file
View file

@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>
{% block title %}{% endblock %}
</title>
<style>
/* body {
background: black;
color: white;
} */
</style>
{% block style %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa" crossorigin="anonymous"></script>
{% block script %}{% endblock %}
</body>
</html>

View file

@ -5,6 +5,25 @@
<title>Ferries to France</title> <title>Ferries to France</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-gH2yIJqKdNHPEq0n4Mqa/HGKIhSkIHeL5AyhkYV8i59U5AR6csBvApHHNl/vI1Bx" crossorigin="anonymous">
<!--
<style>
body {
background: black;
color: white;
}
.table { color: white; }
a:link {
color: rgb(127, 127, 255);
}
</style>
-->
</head> </head>
<body> <body>
@ -14,12 +33,12 @@
<p>{{ departure_date }} {{ ticket_tier }}</p> <p>{{ departure_date }} {{ ticket_tier }}</p>
<table class="table"> <table class="table w-auto">
<tr> <tr>
<th>code</th> <th>code</th>
<th>description</th> <th>description</th>
<th>max adults</th> <th>births</th>
<th>quantity available</th> <th>quantity<br/>available</th>
<th>price</th> <th>price</th>
</tr> </tr>
{% for a in accommodations if a.quantityAvailable > 0 %} {% for a in accommodations if a.quantityAvailable > 0 %}
@ -27,12 +46,28 @@
<td>{{ a.code }}</td> <td>{{ a.code }}</td>
<td>{{ a.description }}</td> <td>{{ a.description }}</td>
<td>{{ a.maxAdults }}</td> <td>{{ a.maxAdults }}</td>
<td>{{ a.quantityAvailable }}</td> <td>
{% if a.quantityAvailable == 10 %}
10+
{% else %}
{{ a.quantityAvailable }}
{% endif %}
</td>
<td>£{{ a.unitCost.amount }}</td> <td>£{{ a.unitCost.amount }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
<h4>Pet accommodations</h4>
<p>
o.petStayInCar = 'G',<br>
o.petLargeKennel = 'B',<br>
o.petSmallKennel = 'K',<br>
</p>
<pre>{{ pet_accommodations | tojson }}</pre>
</div> </div>
</body> </body>
</html> </html>

View file

@ -11,9 +11,9 @@
<div class="m-3"> <div class="m-3">
<ul> <ul>
{% for dep, arr in routes %} <li><a href="{{ url_for("outbound1_page") }}">Outbound: 10 March</a>
<li><a href="{{ url_for("show_route", departure_port=ports[dep], arrival_port=ports[arr]) }}">{{ dep }} - {{ arr }}</a></li> <li><a href="{{ url_for("outbound2_page") }}">Outbound: 17 March</a>
{% endfor %} <li><a href="{{ url_for("return_page") }}">Return: 24 March</a>
</ul> </ul>
</div> </div>

View file

@ -1,8 +1,10 @@
{% macro headings() %} {% macro headings() %}
<tr> <tr>
<th>ID</th>
<th>day</th> <th>day</th>
<th>depart</th> <th>depart</th>
<th>arrive</th> <th>arrive</th>
<th>duration</th>
<th>ship</th> <th>ship</th>
<th>economy</th> <th>economy</th>
<th>standard</th> <th>standard</th>
@ -17,10 +19,12 @@
{{ headings() }} {{ headings() }}
{% for day in days %} {% for day in days %}
{% set date = parse_date(day.date) %} {% 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 %}
{# <tr><td colspan="7">{{ crossing }}</td></tr> #} {# <tr><td colspan="7">{{ crossing }}</td></tr> #}
<tr> <tr>
<td class="text-nowrap">{{ crossing.sailingId }}</td>
<td class="text-nowrap text-end">{{ date.strftime("%a, %d %b") }}</td> <td class="text-nowrap text-end">{{ date.strftime("%a, %d %b") }}</td>
<td class="text-nowrap"> <td class="text-nowrap">
{{ crossing.departureDateTime.time }} {{ crossing.departureDateTime.time }}
@ -28,6 +32,9 @@
<td class="text-nowrap"> <td class="text-nowrap">
{{ crossing.arrivalDateTime.time }} {{ crossing.arrivalDateTime.time }}
</td> </td>
<td class="text-nowrap">
{{ get_duration(crossing.departureDateTime.time, crossing.arrivalDateTime.time, time_delta) }}
</td>
<td class="text-nowrap"> <td class="text-nowrap">
{{ crossing.shipName }} {{ crossing.shipName }}
</td> </td>
@ -46,6 +53,9 @@
£{{ crossing.flexiPrice.amount }} £{{ crossing.flexiPrice.amount }}
</a> </a>
</td> </td>
<td>
{{ format_pet_options(crossing.petAvailabilities) | join(", ") }}
</td>
<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

@ -18,8 +18,6 @@ URL: {{ request.url }}
{{ tb.plaintext | safe }} {{ tb.plaintext | safe }}
{% endset %} {% endset %}
<p><a class="btn btn-primary btn-lg" role="button" href="https://github.com/EdwardBetts/dab-mechanic/issues/new?title={{ tb.exception + " " + request.url | urlencode }}&body={{ body | urlencode }}">Submit as an issue on GitHub</a> (requires an account with GitHub)</p>
<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 }} {{ tb.render_summary(include_title=False) | safe }}