Add conference detail pages and cached flight searches

This commit is contained in:
Edward Betts 2026-10-02 10:46:26 +01:00
parent 6b253f2d04
commit dcaf723336
24 changed files with 12198 additions and 12 deletions

View file

@ -0,0 +1,587 @@
"""Conference detail pages, airport resolution and flight caching."""
import typing
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
import pytest
import yaml
import agenda.conference_detail as detail
import agenda.conference_flights as flights
import agenda.conference_list
import agenda.conference_page as page
import agenda.flight_search_cache as cache
import agenda.trip
import web_view
from agenda.types import StrDict
def test_airport_suggestions(tmp_path: Path) -> None:
"""Mappings can be overridden; proximity stays within the conference country."""
conf: StrDict = {"location": "Brussels", "country": "be"}
assert detail.suggest_airport(conf, str(tmp_path))[0] == "BRU"
(tmp_path / "conference_airports.yaml").write_text("be:brussels: CRL\n")
assert detail.suggest_airport(conf, str(tmp_path))[0] == "CRL"
(tmp_path / "airports.yaml").write_text(
yaml.safe_dump(
{
"AAA": {
"city": "Elsewhere",
"country": "fr",
"latitude": 50,
"longitude": 4,
},
"BBB": {
"city": "Another",
"country": "be",
"latitude": 50.1,
"longitude": 4.1,
},
}
)
)
assert (
detail.suggest_airport(
{"location": "Town", "country": "be", "latitude": 50, "longitude": 4},
str(tmp_path),
)[0]
== "BBB"
)
assert detail.suggest_airport({"location": "TBC"}, str(tmp_path))[0] is None
def test_short_haul() -> None:
"""European geography determines Bristol preference, with a distance limit."""
assert detail.is_short_haul({"country": "BE"})
assert not detail.is_short_haul({"country": "us"})
assert not detail.is_short_haul({"country": "fr", "latitude": -21, "longitude": 55})
def test_page_is_cache_only(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""GET displays venue/details without initiating a search; POST requires a token."""
conf: StrDict = {
"name": "Mapping 2099",
"location": "Brussels",
"country": "be",
"topic": "Maps",
"start": date(2099, 5, 5),
"end": date(2099, 5, 6),
"venue": "Test Venue",
"address": "123 Test Street",
"latitude": 50.8,
"longitude": 4.3,
"url": "https://example.com/conference",
"description": "A great conference",
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
monkeypatch.setattr(
flights, "lookup", lambda *args: pytest.fail("GET made a lookup")
)
monkeypatch.setattr(flights, "valid_airport", lambda code: code == "BRU")
url = f"/conference/2099-05-05/{detail.conference_slug(conf)}"
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
for value in (
b"Test Venue",
b"123 Test Street",
b"View venue on map",
b"A great conference",
b"Look up flights",
b"Conference website",
):
assert value in response.data
assert client.post(url, data={"airport": "BRU"}).status_code == 400
assert client.get("/conference/2099-05-05/missing").status_code == 404
cached: StrDict = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"destination": "BRU",
"searches": [],
}
path = flights.cache_path(
str(tmp_path), conf["start"], conf["end"], "BRU", True
)
path.parent.mkdir()
flights.write_cache(path, cached)
response = client.get(url)
assert b"Last updated" in response.data and b"Update flights" in response.data
with client.session_transaction() as session:
token = session["conference_flight_token"]
calls: list[str] = []
def lookup(*args: typing.Any) -> StrDict:
calls.append(args[3])
return cached
monkeypatch.setattr(flights, "lookup", lookup)
assert (
client.post(url, data={"airport": "BRU", "csrf_token": token}).status_code
== 302
)
assert calls == ["BRU"]
monkeypatch.setattr(
flights,
"resolve_airport",
lambda query: ("CPH", "Copenhagen Kastrup Airport"),
)
response = client.post(
url + "?airport=BRU",
data={"airport": "Copenhagen, Denmark", "csrf_token": token},
)
assert response.status_code == 302
assert response.headers["Location"].endswith("airport=CPH")
assert calls == ["BRU", "CPH"]
def test_slug_collision(tmp_path: Path) -> None:
"""Events of one series on one day receive distinct reproducible URLs."""
conferences = [
{
"name": name,
"series": "maps",
"location": "Berlin",
"start": date(2099, 5, 1),
}
for name in ("Maps morning", "Maps evening")
]
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump(conferences))
rows = agenda.conference_list.build_conference_list(str(tmp_path), [])
assert rows[0]["slug"] != rows[1]["slug"]
assert (
detail.conference_slug({"name": "FOSDEM", "location": "Brussels"})
== "fosdem-brussels"
)
@pytest.mark.parametrize(
"short_haul,bristol_complete,expected",
[(True, True, ["BRS"]), (True, False, ["BRS", "LON"]), (False, True, ["LON"])],
)
def test_cache_and_fallback(
tmp_path: Path,
monkeypatch: typing.Any,
short_haul: bool,
bristol_complete: bool,
expected: list[str],
) -> None:
"""Bristol preference, London fallback, and refresh throttling."""
calls: list[str] = []
def search(
origin: str, destination: str, start: date, end: date, flexible: bool
) -> StrDict:
calls.append(origin)
return {
"origin": origin,
"outbound": [{"price": 50}],
"inbound": [{"price": 60}] if bristol_complete else [],
}
monkeypatch.setattr(flights, "search_origin", search)
start, end = date(2099, 1, 5), date(2099, 1, 6)
path = flights.cache_path(str(tmp_path), start, end, "BRU", short_haul)
result = flights.lookup(path, start, end, "BRU", short_haul)
assert calls == expected
assert flights.lookup(path, start, end, "BRU", short_haul) == result
assert calls == expected
def test_failed_refresh_keeps_results(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""Failures retain old results and get a short retry cooldown."""
path = flights.cache_path(
str(tmp_path), date(2099, 1, 5), date(2099, 1, 6), "BRU", True
)
path.parent.mkdir()
old: StrDict = {
"updated_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
"destination": "BRU",
"searches": [{"origin": "BRS", "outbound": [1], "inbound": [2]}],
}
flights.write_cache(path, old)
calls: list[bool] = []
def fail(*args: typing.Any, **kwargs: typing.Any) -> StrDict:
calls.append(True)
raise RuntimeError("network unavailable")
monkeypatch.setattr(flights, "search_origin", fail)
result = flights.lookup(path, date(2099, 1, 5), date(2099, 1, 6), "BRU", True)
assert result["updated_at"] == old["updated_at"]
assert result["searches"] == old["searches"]
assert result["error"]
assert "RuntimeError: network unavailable" in result["error_details"]
flights.lookup(path, date(2099, 1, 5), date(2099, 1, 6), "BRU", True)
assert len(calls) == 1
@pytest.mark.parametrize(
"outbound_offset,inbound_offset,expected_calls",
[(1, 1, 2), (3, 1, 4), (1, 3, 4), (None, None, 8)],
)
def test_flexible_direct_dates(
monkeypatch: typing.Any,
outbound_offset: int | None,
inbound_offset: int | None,
expected_calls: int,
) -> None:
"""Each direction expands only as needed, excluding late outbound arrivals."""
calls: list[tuple[str, str, date, bool]] = []
start, end = date(2099, 1, 5), date(2099, 1, 6)
def search(origin: str, destination: str, day: date, direct: bool) -> list[StrDict]:
calls.append((origin, destination, day, direct))
if origin == "BRS":
offset = (start - day).days
if offset == 1 and outbound_offset != 1:
return [{"arrival": start.isoformat() + "T18:00:00", "price": 30}]
available = offset == outbound_offset
else:
available = (day - end).days == inbound_offset
return (
[{"arrival": day.isoformat() + "T18:00:00", "price": 30}]
if available
else []
)
monkeypatch.setattr(flights, "search_day", search)
result = flights.search_origin("BRS", "BRU", start, end, True)
assert len(calls) == expected_calls
assert all(call[3] for call in calls)
assert bool(result["outbound"]) == (outbound_offset is not None)
assert bool(result["inbound"]) == (inbound_offset is not None)
assert sum(call[0] == "BRS" for call in calls) == (outbound_offset or 4)
assert sum(call[0] == "BRU" for call in calls) == (inbound_offset or 4)
def test_bad_cache(tmp_path: Path) -> None:
"""Invalid JSON and naive timestamps are ignored."""
path = tmp_path / "cache.json"
path.write_text('{"updated_at": "2026-01-01T12:00:00", "searches": []}')
assert flights.read_cache(path) is None
path.write_text("bad json")
assert flights.read_cache(path) is None
def test_airport_autocomplete(monkeypatch: typing.Any) -> None:
"""Autocomplete uses the local index and returns airport names and codes."""
queries: list[str] = []
def matches(query: str) -> list[StrDict]:
queries.append(query)
return [{"code": "CPH", "name": "Copenhagen Kastrup Airport"}]
monkeypatch.setattr(flights, "airport_matches", matches)
with web_view.app.test_client() as client:
assert client.get("/conference/airports?q=C").json == []
response = client.get("/conference/airports?q=Copenhagen%2C%20Denmark")
assert response.json == [{"code": "CPH", "name": "Copenhagen Kastrup Airport"}]
assert queries == ["Copenhagen, Denmark"]
def test_multi_airport_location_mapping(tmp_path: Path) -> None:
"""Mappings support airport lists, accents, and personal overrides."""
for location in ("Malmö", "Malmo"):
assert (
detail.suggest_airport(
{"country": "se", "location": location}, str(tmp_path)
)[0]
== "MMX,CPH"
)
(tmp_path / "conference_airports.yaml").write_text('"se:malmo": [CPH, MMX]\n')
assert (
detail.suggest_airport({"country": "se", "location": "Malmö"}, str(tmp_path))[0]
== "CPH,MMX"
)
@pytest.mark.parametrize("available_offset", [2, 3, None])
def test_london_earlier_departure(
monkeypatch: typing.Any, available_offset: int | None
) -> None:
"""London expands the outbound by one day, without repeating the return search."""
start, end = date(2099, 1, 5), date(2099, 1, 6)
calls: list[tuple[str, date]] = []
def search(origin: str, destination: str, day: date, direct: bool) -> list[StrDict]:
calls.append((origin, day))
if origin == "LON" and (start - day).days != available_offset:
return []
return [{"arrival": day.isoformat() + "T18:00:00"}]
monkeypatch.setattr(flights, "search_day", search)
result = flights.search_origin("LON", "BKW", start, end, False)
expected = ["2099-01-03"] if available_offset == 2 else ["2099-01-03", "2099-01-02"]
assert result["outbound_dates"] == expected
assert result["inbound_dates"] == ["2099-01-07"]
assert sum(origin == "BKW" for origin, _ in calls) == 1
assert bool(result["outbound"]) == (available_offset is not None)
def test_legacy_empty_search_can_expand(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""An old fresh empty London cache can immediately try the new earlier date."""
start, end = date(2099, 1, 5), date(2099, 1, 6)
path = flights.cache_path(str(tmp_path), start, end, "BKW", False)
path.parent.mkdir()
flights.write_cache(
path,
{
"updated_at": datetime.now(timezone.utc).isoformat(),
"searches": [{"origin": "LON", "outbound": [], "inbound": []}],
"transport": cache.TRANSPORT,
},
)
calls: list[date] = []
def search(origin: str, destination: str, day: date, direct: bool) -> list[StrDict]:
calls.append(day)
return []
monkeypatch.setattr(flights, "search_day", search)
result = flights.lookup(path, start, end, "BKW", False)
assert result["searches"][0]["outbound_dates"] == ["2099-01-03", "2099-01-02"]
assert len(calls) == 3
assert flights.lookup(path, start, end, "BKW", False) == result
assert len(calls) == 3
def test_empty_flights_show_airport_and_dates(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""Cached empty results show full airport names and checked departure dates."""
conf: StrDict = {
"name": "Mapping 2099",
"location": "Raleigh",
"country": "us",
"start": date(2099, 5, 5),
"end": date(2099, 5, 6),
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
path = flights.cache_path(str(tmp_path), conf["start"], conf["end"], "BKW", False)
path.parent.mkdir()
cached: StrDict = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"destination": "BKW",
"searches": [
{
"origin": "LON",
"outbound": [],
"inbound": [],
"outbound_dates": ["2099-05-03", "2099-05-02"],
"inbound_dates": ["2099-05-07"],
}
],
}
flights.write_cache(path, cached)
url = "/conference/2099-05-05/" + detail.conference_slug(conf) + "?airport=BKW"
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
assert b"Raleigh County Memorial Airport (BKW)</h3>" in response.data
for day in ("2099-05-03", "2099-05-02", "2099-05-07"):
assert page.format_date(day).encode() in response.data
# Legacy cache data reconstructs the single London departure date.
del cached["searches"][0]["outbound_dates"]
del cached["searches"][0]["inbound_dates"]
flights.write_cache(path, cached)
response = client.get(url)
assert page.format_date("2099-05-03").encode() in response.data
@pytest.mark.parametrize(
"location,online,eligible",
[("Belfast", False, True), ("Belfast", True, False), ("London", False, False)],
)
def test_belfast_domestic_exception(
tmp_path: Path, monkeypatch: typing.Any, location: str, online: bool, eligible: bool
) -> None:
"""Belfast alone permits UK flight lookups; online events remain excluded."""
conf: StrDict = {
"name": "Test conference",
"location": location,
"country": "gb",
"start": date(2099, 5, 5),
"end": date(2099, 5, 6),
"online": online,
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
calls: list[tuple[str, bool]] = []
def lookup(
path: Path, start: date, end: date, destination: str, short_haul: bool
) -> StrDict:
calls.append((destination, short_haul))
return {}
monkeypatch.setattr(flights, "lookup", lookup)
url = "/conference/2099-05-05/" + detail.conference_slug(conf)
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
assert (b"Look up flights" in response.data) == eligible
assert calls == []
if eligible:
assert b"BFS,BHD" in response.data
with client.session_transaction() as session:
token = session["conference_flight_token"]
response = client.post(url, data={"airport": location, "csrf_token": token})
assert response.status_code == (302 if eligible else 400)
assert calls == ([("BFS,BHD", True)] if eligible else [])
def test_personal_airport_names(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""Personal names apply to codes, groups and autocomplete, with live updates."""
path = tmp_path / "airports.yaml"
path.write_text(
yaml.safe_dump({"BSL": {"name": "My Basel Airport"}, "BRN": {"name": ""}})
)
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
with web_view.app.app_context():
assert flights.resolve_airport("BSL") == ("BSL", "My Basel Airport")
code, name = flights.resolve_airport("Bern")
assert code == "BRN,BSL"
assert name == "Bern Airport / My Basel Airport"
assert flights.resolve_airport("BRN,BSL")[1] == name
assert flights.airport_matches("My Basel Airport")[0] == {
"code": "BSL",
"name": "My Basel Airport",
}
assert any(
match["code"] == "BSL" for match in flights.airport_matches("EuroAirport")
)
with web_view.app.test_client() as client:
assert client.get("/conference/airports?q=BSL").json == [
{"code": "BSL", "name": "My Basel Airport"}
]
path.write_text(yaml.safe_dump({"BSL": {"name": "Renamed Basel Airport"}}))
assert client.get("/conference/airports?q=BSL").json == [
{"code": "BSL", "name": "Renamed Basel Airport"}
]
path.unlink()
fallback = client.get("/conference/airports?q=BSL").json
assert isinstance(fallback, list)
assert "EuroAirport" in fallback[0]["name"]
def test_date_limit_banner(tmp_path: Path, monkeypatch: typing.Any) -> None:
"""A wrapped date rejection asks to retry nearer departure, keeping cached fares."""
from agenda.google_flights import FlightDateUnavailableError
start, end = date(2099, 1, 5), date(2099, 1, 6)
path = flights.cache_path(str(tmp_path), start, end, "AKJ", False)
path.parent.mkdir()
old: StrDict = {
"updated_at": (datetime.now(timezone.utc) - timedelta(days=1)).isoformat(),
"destination": "AKJ",
"searches": [{"origin": "LON", "outbound": [{"price": 100}], "inbound": []}],
}
flights.write_cache(path, old)
def fail(*args: typing.Any, **kwargs: typing.Any) -> StrDict:
try:
raise FlightDateUnavailableError(
"Requested flight date is too far in the future. Please try again closer to departure."
)
except FlightDateUnavailableError as exc:
raise RuntimeError("AKJ → LON on 2099-01-07") from exc
monkeypatch.setattr(flights, "search_origin", fail)
result = flights.lookup(path, start, end, "AKJ", False)
assert "closer to departure" in result["error"]
assert "15 minutes" not in result["error"]
assert result["searches"] == old["searches"]
assert result["updated_at"] == old["updated_at"]
def test_previous_parser_error_can_retry_once(
tmp_path: Path, monkeypatch: typing.Any
) -> None:
"""The parser fix unlocks a recent old-format error, but fresh errors still throttle."""
start, end = date(2099, 6, 2), date(2099, 6, 4)
path = flights.cache_path(str(tmp_path), start, end, "BLQ", True)
path.parent.mkdir()
flights.write_cache(
path,
{
"updated_at": datetime.now(timezone.utc).isoformat(),
"searches": [],
"error": "Flight lookup failed",
"error_details": "ValueError: Unrecognized Google Flights result format",
"transport": cache.TRANSPORT,
},
)
calls: list[bool] = []
def fail(*args: typing.Any, **kwargs: typing.Any) -> StrDict:
calls.append(True)
raise ValueError("Unrecognized Google Flights result format")
monkeypatch.setattr(flights, "search_origin", fail)
refreshed = flights.lookup(path, start, end, "BLQ", True)
assert len(calls) == 1
assert flights.lookup(path, start, end, "BLQ", True) == refreshed
assert len(calls) == 1
@pytest.mark.parametrize(
"status,eligible", [("exact", True), ("tentative", True), ("approximate", False)]
)
def test_flight_lookup_with_tentative_dates(
tmp_path: Path, monkeypatch: typing.Any, status: str, eligible: bool
) -> None:
"""Tentative start/end dates support lookup and links; approximate ranges don't."""
start, end = date(2099, 6, 2), date(2099, 6, 4)
dates: StrDict = {"status": status}
dates.update(
{"earliest": start, "latest": end}
if status == "approximate"
else {"start": start, "end": end}
)
conf: StrDict = {
"name": "PyCon test",
"location": "Bologna",
"country": "it",
"dates": dates,
}
(tmp_path / "conferences.yaml").write_text(yaml.safe_dump([conf]))
monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path))
monkeypatch.setitem(web_view.app.config, "DATA_DIR", str(tmp_path))
monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: [])
calls: list[tuple[date, date, str]] = []
def lookup(
path: Path, departure: date, returning: date, destination: str, short_haul: bool
) -> StrDict:
calls.append((departure, returning, destination))
return {}
monkeypatch.setattr(flights, "lookup", lookup)
url = "/conference/2099-06-02/" + detail.conference_slug(conf)
with web_view.app.test_client() as client:
response = client.get(url)
assert response.status_code == 200
assert (b"Look up flights" in response.data) == eligible
assert (b"Open in Google Flights" in response.data) == eligible
assert (b"Using tentative conference dates" in response.data) == (
status == "tentative"
)
if status == "tentative":
assert page.format_date(start).encode() in response.data
assert page.format_date(end).encode() in response.data
assert calls == []
with client.session_transaction() as session:
token = session["conference_flight_token"]
response = client.post(url, data={"airport": "BLQ", "csrf_token": token})
assert response.status_code == (302 if eligible else 400)
assert calls == ([(start, end, "BLQ")] if eligible else [])