agenda/agenda/conference_page.py

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