281 lines
9.1 KiB
Python
281 lines
9.1 KiB
Python
"""Conference detail page and explicit flight lookup action."""
|
|
|
|
import hmac
|
|
import secrets
|
|
import typing
|
|
from datetime import date, datetime, timedelta
|
|
|
|
import flask
|
|
import werkzeug
|
|
|
|
import agenda
|
|
import agenda.conference
|
|
import agenda.conference_detail as detail
|
|
import agenda.conference_flights as flights
|
|
import agenda.conference_list
|
|
import agenda.flight_search_cache
|
|
import agenda.google_flights
|
|
import agenda.trip
|
|
from agenda.types import StrDict
|
|
|
|
blueprint = flask.Blueprint("conference", __name__)
|
|
|
|
|
|
@blueprint.app_template_filter("conference_date")
|
|
def format_date(value: typing.Any, part: str = "full") -> str:
|
|
"""Render dates without converting flight times out of the airport's local time."""
|
|
parsed = value
|
|
if isinstance(value, str):
|
|
try:
|
|
parsed = (
|
|
datetime.fromisoformat(value)
|
|
if "T" in value or " " in value
|
|
else date.fromisoformat(value)
|
|
)
|
|
except ValueError:
|
|
return value
|
|
if isinstance(parsed, datetime):
|
|
if part == "time":
|
|
return parsed.strftime("%H:%M")
|
|
if part == "date":
|
|
return parsed.strftime("%a %-d %b %Y")
|
|
return parsed.strftime("%a %-d %b %Y at %H:%M")
|
|
if isinstance(parsed, date):
|
|
return parsed.strftime("%a %-d %b %Y")
|
|
return str(value)
|
|
|
|
|
|
def searched_dates(
|
|
search: StrDict, direction: str, start: date, end: date
|
|
) -> list[str]:
|
|
"""Read checked dates; reconstruct the old policy for legacy cached results."""
|
|
field = direction + "_dates"
|
|
if field in search:
|
|
return typing.cast(list[str], search[field])
|
|
rows = search.get(direction, [])
|
|
if rows:
|
|
return sorted({str(row["departure"])[:10] for row in rows})
|
|
offsets = (
|
|
range(1, 5)
|
|
if search["origin"] == "BRS"
|
|
else (2 if direction == "outbound" else 1,)
|
|
)
|
|
return [
|
|
(
|
|
start - timedelta(days=offset)
|
|
if direction == "outbound"
|
|
else end + timedelta(days=offset)
|
|
).isoformat()
|
|
for offset in offsets
|
|
if direction != "outbound" or start - timedelta(days=offset) >= date.today()
|
|
]
|
|
|
|
|
|
def google_flights_link(
|
|
origin: str, destination: str, start: date, end: date, search: StrDict | None = None
|
|
) -> StrDict:
|
|
"""Open a round trip with the displayed route, dates and stop preference."""
|
|
departure = start - timedelta(days=1 if origin == "BRS" else 2)
|
|
returning = end + timedelta(days=1)
|
|
if search:
|
|
for direction in ("outbound", "inbound"):
|
|
rows = search.get(direction, [])
|
|
checked = search.get(direction + "_dates", [])
|
|
selected = (
|
|
str(rows[0]["departure"])[:10]
|
|
if rows
|
|
else checked[-1] if checked else None
|
|
)
|
|
if selected:
|
|
if direction == "outbound":
|
|
departure = date.fromisoformat(selected)
|
|
else:
|
|
returning = date.fromisoformat(selected)
|
|
max_stops = 0 if origin == "BRS" else 1
|
|
if (
|
|
search
|
|
and origin != "BRS"
|
|
and any(
|
|
row.get("stops", 0) > 1
|
|
for direction in ("outbound", "inbound")
|
|
for row in search.get(direction, [])
|
|
)
|
|
):
|
|
max_stops = 2
|
|
return {
|
|
"url": agenda.google_flights.search_url(
|
|
origin, destination, departure, max_stops, returning
|
|
),
|
|
"departure": departure,
|
|
"return": returning,
|
|
}
|
|
|
|
|
|
def find_conference(event_date: str, slug: str) -> StrDict:
|
|
"""Resolve a dated conference identity, including approximate dates."""
|
|
for conf in agenda.conference_list.build_conference_list(
|
|
flask.current_app.config["PERSONAL_DATA"], agenda.trip.build_trip_list()
|
|
):
|
|
if conf["sort_date"].isoformat() == event_date and conf["slug"] == slug:
|
|
return conf
|
|
flask.abort(404)
|
|
|
|
|
|
@blueprint.route("/conference/<event_date>/<slug>", methods=["GET", "POST"])
|
|
def page(event_date: str, slug: str) -> str | werkzeug.Response:
|
|
"""GET reads only cached data; POST explicitly requests a flight search."""
|
|
conf = find_conference(event_date, slug)
|
|
config = flask.current_app.config
|
|
suggested, airport_reason = detail.suggest_airport(conf, config["PERSONAL_DATA"])
|
|
parameters = (
|
|
flask.request.form if flask.request.method == "POST" else flask.request.args
|
|
)
|
|
default_location = str(conf.get("location") or "")
|
|
airport_input = parameters.get("airport", default_location).strip()[:200]
|
|
destination = airport_input.upper()
|
|
resolved_name = ""
|
|
resolution_error = ""
|
|
if airport_input:
|
|
try:
|
|
lookup_query = (
|
|
suggested
|
|
if suggested and airport_input == default_location
|
|
else airport_input
|
|
)
|
|
destination, resolved_name = flights.resolve_airport(lookup_query)
|
|
except ValueError as exc:
|
|
resolution_error = str(exc)
|
|
eligible = (
|
|
conf["date_status"] in agenda.conference.DATED_STATUSES
|
|
and conf["start_date"] > date.today()
|
|
and not conf.get("online")
|
|
and (
|
|
str(conf.get("country", "")).lower() != "gb"
|
|
or str(conf.get("location", "")).partition(",")[0].strip().casefold()
|
|
== "belfast"
|
|
)
|
|
)
|
|
cache = flights.cache_path(
|
|
config["DATA_DIR"],
|
|
conf["start_date"],
|
|
conf["end_date"],
|
|
destination,
|
|
detail.is_short_haul(conf),
|
|
)
|
|
token = flask.session.setdefault(
|
|
"conference_flight_token", secrets.token_urlsafe(32)
|
|
)
|
|
if flask.request.method == "POST":
|
|
if not hmac.compare_digest(
|
|
str(token), flask.request.form.get("csrf_token", "")
|
|
):
|
|
flask.abort(400)
|
|
if not eligible:
|
|
flask.abort(400)
|
|
if resolution_error or destination in {"BRS", "LON"}:
|
|
flask.flash(
|
|
resolution_error or "Choose a destination outside Bristol or London.",
|
|
"warning",
|
|
)
|
|
else:
|
|
try:
|
|
flights.lookup(
|
|
cache,
|
|
conf["start_date"],
|
|
conf["end_date"],
|
|
destination,
|
|
detail.is_short_haul(conf),
|
|
)
|
|
except ValueError as exc:
|
|
flask.flash(str(exc), "warning")
|
|
return flask.redirect(
|
|
flask.url_for(
|
|
"conference.page",
|
|
event_date=event_date,
|
|
slug=slug,
|
|
airport=airport_input if resolution_error else destination,
|
|
)
|
|
)
|
|
excluded = {
|
|
"name",
|
|
"location",
|
|
"country",
|
|
"venue",
|
|
"address",
|
|
"latitude",
|
|
"longitude",
|
|
"url",
|
|
"dates",
|
|
"date_status",
|
|
"start",
|
|
"end",
|
|
"start_date",
|
|
"end_date",
|
|
"sort_date",
|
|
"latest_date",
|
|
"display_date",
|
|
"has_exact_dates",
|
|
"series",
|
|
"series_detail",
|
|
"linked_trip",
|
|
"slug",
|
|
}
|
|
fields = [
|
|
(key.replace("_", " ").capitalize(), value)
|
|
for key, value in conf.items()
|
|
if key not in excluded and value is not None
|
|
]
|
|
cached = flights.read_cache(cache) if destination and not resolution_error else None
|
|
if cached:
|
|
for search in cached.get("searches", []):
|
|
for direction in ("outbound", "inbound"):
|
|
search[direction + "_dates"] = searched_dates(
|
|
search, direction, conf["start_date"], conf["end_date"]
|
|
)
|
|
search["google_flights"] = google_flights_link(
|
|
search["origin"],
|
|
destination,
|
|
conf["start_date"],
|
|
conf["end_date"],
|
|
search,
|
|
)
|
|
default_link = (
|
|
google_flights_link(
|
|
"BRS" if detail.is_short_haul(conf) else "LON",
|
|
destination,
|
|
conf["start_date"],
|
|
conf["end_date"],
|
|
)
|
|
if eligible and not resolution_error and destination
|
|
else None
|
|
)
|
|
return flask.render_template(
|
|
"conference_detail.html",
|
|
conf=conf,
|
|
fields=fields,
|
|
get_country=agenda.get_country,
|
|
airport=airport_input,
|
|
resolved_airport=destination if not resolution_error else "",
|
|
resolved_name=resolved_name,
|
|
resolution_error=resolution_error,
|
|
airport_reason=airport_reason,
|
|
suggested=suggested,
|
|
short_haul=detail.is_short_haul(conf),
|
|
eligible=eligible,
|
|
cached=cached,
|
|
default_google_flights=default_link,
|
|
csrf_token=token,
|
|
cooldown=agenda.flight_search_cache.cooldown_message(cache.parent),
|
|
)
|
|
|
|
|
|
@blueprint.get("/conference/airports")
|
|
def airport_search() -> flask.Response:
|
|
"""Autocomplete airport names locally; this endpoint never searches Google."""
|
|
query = flask.request.args.get("q", "").strip()[:200]
|
|
if len(query) < 2:
|
|
return flask.jsonify([])
|
|
try:
|
|
return flask.jsonify(flights.airport_matches(query))
|
|
except ValueError as exc:
|
|
return flask.jsonify(error=str(exc))
|