brittany-ferries/main.py

307 lines
8.3 KiB
Python
Raw Normal View History

2022-09-03 21:38:46 +01:00
#!/usr/bin/python3
"""Check prices of ferries to France."""
import inspect
2023-01-08 11:49:57 +00:00
import json
import os
import os.path
import re
from datetime import date, datetime, timedelta
2022-09-03 21:38:46 +01:00
from typing import Any
2023-01-08 12:22:36 +00:00
import dateutil.parser
2022-09-03 21:38:46 +01:00
import flask
2023-01-08 11:49:57 +00:00
import pytz
2022-09-03 21:38:46 +01:00
import werkzeug.exceptions
from werkzeug.debug.tbtools import get_current_traceback
2023-01-08 11:49:57 +00:00
from werkzeug.wrappers import Response
2022-09-03 21:38:46 +01:00
2023-01-08 12:22:36 +00:00
import ferry
2023-01-08 11:49:57 +00:00
from ferry.api import get_accommodations, get_prices
from ferry.read_config import ferry_config, vehicle_from_config
2022-09-03 21:38:46 +01:00
app = flask.Flask(__name__)
2023-01-08 11:49:57 +00:00
app.debug = False
def cache_location() -> str:
2023-01-08 13:03:23 +00:00
"""Cache location for the current user."""
2023-01-08 11:49:57 +00:00
return os.path.expanduser(ferry_config.get("cache", "location"))
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(".", "_")
2022-09-03 21:38:46 +01:00
2023-01-08 11:49:57 +00:00
return os.path.join(cache_location(), now_str + "_" + params + ".json")
2022-09-03 21:38:46 +01:00
2023-01-08 11:49:57 +00:00
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()]
2022-09-03 21:38:46 +01:00
2023-01-08 11:49:57 +00:00
return (60 * hours) + minutes
2022-09-03 21:38:46 +01:00
2023-01-08 11:49:57 +00:00
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"
2022-09-03 21:38:46 +01:00
@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:
2023-01-08 11:49:57 +00:00
"""Parse a date from a string in ISO format."""
2022-09-03 21:38:46 +01:00
return datetime.strptime(d, "%Y-%m-%d").date()
@app.route("/")
2023-01-08 12:22:36 +00:00
def start() -> Response | str:
2022-09-03 21:38:46 +01:00
"""Start page."""
2023-01-08 11:49:57 +00:00
return flask.render_template("index.html")
2022-09-03 21:38:46 +01:00
2023-01-08 13:03:23 +00:00
def cabins_url(dep: str, arr: str, crossing: dict[str, Any], ticket_tier: str) -> str:
"""Generate a URL for the cabins on a given crossing."""
2022-09-03 21:38:46 +01:00
dt = datetime.fromisoformat(crossing["departureDateTime"]["iso"])
utc_dt = dt.astimezone(pytz.utc)
return flask.url_for(
"cabins",
2023-01-08 12:22:36 +00:00
departure_port=ferry.ports[dep],
arrival_port=ferry.ports[arr],
2022-09-03 21:38:46 +01:00
departure_date=utc_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
ticket_tier=ticket_tier,
)
2023-01-08 11:49:57 +00:00
def get_days_until_start() -> int:
"""How long until the travel date."""
2022-09-03 21:38:46 +01:00
start = date.fromisoformat(ferry_config.get("dates", "start"))
return (start - date.today()).days
2023-01-08 13:03:23 +00:00
PriceData = list[tuple[str, str, dict[str, Any]]]
def check_cache_for_prices(params: str) -> PriceData | None:
"""Look for prices in cache."""
existing_files = os.listdir(cache_location())
existing = [f for f in existing_files if f.endswith(params + ".json")]
if not existing:
return None
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: PriceData = json.load(open(full))
return data
return None
2023-01-08 11:49:57 +00:00
def get_prices_with_cache(
name: str,
start: str,
end: str,
selection: list[tuple[str, str]],
refresh: bool = False,
2023-01-08 13:03:23 +00:00
) -> PriceData:
"""Get price data using cache."""
2023-01-08 11:49:57 +00:00
params = f"{name}_{start}_{end}"
2023-01-08 13:03:23 +00:00
if not refresh:
data = check_cache_for_prices(params)
if data:
2023-01-08 11:49:57 +00:00
return data
vehicle = vehicle_from_config(ferry_config)
2022-09-03 21:38:46 +01:00
all_data = [
2023-01-08 12:22:36 +00:00
(
dep,
arr,
get_prices(ferry.ports[dep], ferry.ports[arr], start, end, vehicle)[
"crossings"
],
)
2022-09-03 21:38:46 +01:00
for dep, arr in selection
]
2023-01-08 11:49:57 +00:00
2023-01-08 13:03:23 +00:00
with open(cache_filename(params), "w") as out:
2023-01-08 11:49:57 +00:00
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)
2022-09-03 21:38:46 +01:00
return flask.render_template(
"all_routes.html",
data=all_data,
days_until_start=get_days_until_start(),
2023-01-08 12:22:36 +00:00
ports=ferry.ports,
2022-09-03 21:38:46 +01:00
parse_date=parse_date,
2023-01-08 11:49:57 +00:00
from_date=start,
to_date=end,
2022-09-03 21:38:46 +01:00
cabins_url=cabins_url,
2023-01-08 11:49:57 +00:00
get_duration=get_duration,
time_delta=-60,
format_pet_options=format_pet_options,
2022-09-03 21:38:46 +01:00
)
2023-01-08 11:49:57 +00:00
@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
2022-09-03 21:38:46 +01:00
@app.route("/return")
def return_page() -> str:
"""Show all routes on one page."""
selection = [
2023-01-08 11:49:57 +00:00
("CAEN", "PORTSMOUTH"),
("CHERBOURG", "PORTSMOUTH"),
("ST MALO", "PORTSMOUTH"),
# ("CHERBOURG", "POOLE"),
2022-09-03 21:38:46 +01:00
]
2023-01-08 11:49:57 +00:00
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)
2022-09-03 21:38:46 +01:00
return flask.render_template(
"all_routes.html",
data=all_data,
2023-01-08 12:22:36 +00:00
ports=ferry.ports,
2022-09-03 21:38:46 +01:00
days_until_start=get_days_until_start(),
parse_date=parse_date,
2023-01-08 11:49:57 +00:00
from_date=start,
to_date=end,
2022-09-03 21:38:46 +01:00
cabins_url=cabins_url,
2023-01-08 11:49:57 +00:00
get_duration=get_duration,
time_delta=60,
format_pet_options=format_pet_options,
2022-09-03 21:38:46 +01:00
)
2023-01-08 11:49:57 +00:00
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
2022-09-03 21:38:46 +01:00
@app.route("/cabins/<departure_port>/<arrival_port>/<departure_date>/<ticket_tier>")
2023-01-08 11:49:57 +00:00
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"]
]
2023-01-08 12:22:36 +00:00
dep = dateutil.parser.isoparse(departure_date)
2022-09-03 21:38:46 +01:00
return flask.render_template(
"cabins.html",
2023-01-08 12:22:36 +00:00
port_lookup=ferry.port_lookup,
2022-09-03 21:38:46 +01:00
departure_port=departure_port,
arrival_port=arrival_port,
2023-01-08 12:22:36 +00:00
departure_date=dep,
2022-09-03 21:38:46 +01:00
ticket_tier=ticket_tier,
2023-01-08 11:49:57 +00:00
accommodations=accommodations,
pet_accommodations=cabin_data["petAccommodations"],
2022-09-03 21:38:46 +01:00
)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5001)