Update
This commit is contained in:
parent
cbc681ddbc
commit
ebe672b972
285
main.py
285
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/<departure_port>/<arrival_port>/<from_date>/<to_date>")
|
||||
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/<departure_port>/<arrival_port>/<departure_date>/<ticket_tier>")
|
||||
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"],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -5,6 +5,24 @@
|
|||
<title>Ferries to France</title>
|
||||
<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">
|
||||
|
||||
<!--
|
||||
<style>
|
||||
body {
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table { color: white; }
|
||||
|
||||
a:link {
|
||||
color: rgb(127, 127, 255);
|
||||
}
|
||||
|
||||
</style>
|
||||
-->
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
{% from "individual_route.html" import route_table with context %}
|
||||
|
@ -12,9 +30,11 @@
|
|||
<body>
|
||||
<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>
|
||||
#}
|
||||
|
||||
{% if extra_routes %}
|
||||
<ul>
|
||||
|
@ -28,6 +48,10 @@
|
|||
<h4>{{ dep.title() }} to {{ arr.title() }}</h4>
|
||||
{{ route_table(dep, arr, days) }}
|
||||
{% endfor %}
|
||||
|
||||
<!--
|
||||
{{ data | pprint | safe }}
|
||||
-->
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
29
templates/base.html
Normal file
29
templates/base.html
Normal 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>
|
|
@ -5,6 +5,25 @@
|
|||
<title>Ferries to France</title>
|
||||
<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">
|
||||
|
||||
<!--
|
||||
<style>
|
||||
body {
|
||||
background: black;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.table { color: white; }
|
||||
|
||||
a:link {
|
||||
color: rgb(127, 127, 255);
|
||||
}
|
||||
|
||||
</style>
|
||||
-->
|
||||
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
@ -14,12 +33,12 @@
|
|||
|
||||
<p>{{ departure_date }} {{ ticket_tier }}</p>
|
||||
|
||||
<table class="table">
|
||||
<table class="table w-auto">
|
||||
<tr>
|
||||
<th>code</th>
|
||||
<th>description</th>
|
||||
<th>max adults</th>
|
||||
<th>quantity available</th>
|
||||
<th>births</th>
|
||||
<th>quantity<br/>available</th>
|
||||
<th>price</th>
|
||||
</tr>
|
||||
{% for a in accommodations if a.quantityAvailable > 0 %}
|
||||
|
@ -27,12 +46,28 @@
|
|||
<td>{{ a.code }}</td>
|
||||
<td>{{ a.description }}</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>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -11,9 +11,9 @@
|
|||
<div class="m-3">
|
||||
|
||||
<ul>
|
||||
{% for dep, arr in routes %}
|
||||
<li><a href="{{ url_for("show_route", departure_port=ports[dep], arrival_port=ports[arr]) }}">{{ dep }} - {{ arr }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a href="{{ url_for("outbound1_page") }}">Outbound: 10 March</a>
|
||||
<li><a href="{{ url_for("outbound2_page") }}">Outbound: 17 March</a>
|
||||
<li><a href="{{ url_for("return_page") }}">Return: 24 March</a>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
{% macro headings() %}
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>day</th>
|
||||
<th>depart</th>
|
||||
<th>arrive</th>
|
||||
<th>duration</th>
|
||||
<th>ship</th>
|
||||
<th>economy</th>
|
||||
<th>standard</th>
|
||||
|
@ -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 %}
|
||||
|
||||
{# <tr><td colspan="7">{{ crossing }}</td></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">
|
||||
{{ crossing.departureDateTime.time }}
|
||||
|
@ -28,6 +32,9 @@
|
|||
<td class="text-nowrap">
|
||||
{{ crossing.arrivalDateTime.time }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ get_duration(crossing.departureDateTime.time, crossing.arrivalDateTime.time, time_delta) }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{{ crossing.shipName }}
|
||||
</td>
|
||||
|
@ -46,6 +53,9 @@
|
|||
£{{ crossing.flexiPrice.amount }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ format_pet_options(crossing.petAvailabilities) | join(", ") }}
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if crossing.full %}full |{% endif %}
|
||||
{% if crossing.isCabinSpaceFull %}no cabin space |{% endif %}
|
||||
|
|
|
@ -18,8 +18,6 @@ URL: {{ request.url }}
|
|||
{{ tb.plaintext | safe }}
|
||||
{% 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>
|
||||
{{ tb.render_summary(include_title=False) | safe }}
|
||||
|
||||
|
|
Loading…
Reference in a new issue