From f9b79d5a511038e7ada6fe6539cfd790c4da3ffb Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 7 Mar 2026 12:58:38 +0000 Subject: [PATCH 1/5] Fix trip map unbooked flight pins and timezone-safe busy date comparisons --- agenda/busy.py | 21 +++++--- tests/test_busy_timezone.py | 35 +++++++++++++ tests/test_trip.py | 101 ++++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 tests/test_busy_timezone.py create mode 100644 tests/test_trip.py diff --git a/agenda/busy.py b/agenda/busy.py index 582bbce..7dc744c 100644 --- a/agenda/busy.py +++ b/agenda/busy.py @@ -2,7 +2,7 @@ import itertools import typing -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone import flask import pycountry @@ -84,13 +84,20 @@ def get_busy_events( def _parse_datetime_field(datetime_obj: datetime | date | str) -> tuple[datetime, date]: """Parse a datetime field that could be datetime object or string.""" if isinstance(datetime_obj, datetime): - return datetime_obj, datetime_obj.date() - if isinstance(datetime_obj, date): - return datetime.combine(datetime_obj, datetime.min.time()), datetime_obj - if isinstance(datetime_obj, str): + dt = datetime_obj + elif isinstance(datetime_obj, date): + dt = datetime.combine(datetime_obj, datetime.min.time(), tzinfo=timezone.utc) + elif isinstance(datetime_obj, str): dt = datetime.fromisoformat(datetime_obj.replace("Z", "+00:00")) - return dt, dt.date() - raise ValueError(f"Invalid datetime format: {datetime_obj}") + else: + raise ValueError(f"Invalid datetime format: {datetime_obj}") + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + + return dt, dt.date() def _get_accommodation_location( diff --git a/tests/test_busy_timezone.py b/tests/test_busy_timezone.py new file mode 100644 index 0000000..73d35dd --- /dev/null +++ b/tests/test_busy_timezone.py @@ -0,0 +1,35 @@ +"""Regression tests for timezone handling in busy location logic.""" + +from datetime import date, datetime, timezone + +import agenda.busy +from agenda.types import Trip + + +def test_mixed_naive_and_aware_arrivals_do_not_crash() -> None: + """Most recent travel should compare mixed timezone styles safely.""" + trips = [ + Trip( + start=date(2099, 12, 30), + travel=[ + { + "type": "flight", + "arrive": datetime(2100, 1, 1, 10, 0, 0), + "to": "CDG", + "to_airport": {"country": "fr", "city": "Paris"}, + }, + { + "type": "flight", + "arrive": datetime(2100, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "to": "AMS", + "to_airport": {"country": "nl", "city": "Amsterdam"}, + }, + ], + ) + ] + + location = agenda.busy._find_most_recent_travel_before_date(date(2100, 1, 1), trips) + assert location is not None + assert location[0] == "Amsterdam" + assert location[1] is not None + assert location[1].alpha_2 == "NL" diff --git a/tests/test_trip.py b/tests/test_trip.py new file mode 100644 index 0000000..8c07a3c --- /dev/null +++ b/tests/test_trip.py @@ -0,0 +1,101 @@ +"""Tests for trip map coordinate assembly.""" + +from datetime import date + +import agenda.trip +from agenda.types import Trip +from web_view import app + + +def test_add_coordinates_for_unbooked_flights_adds_missing_airports() -> None: + """Unbooked routes should contribute missing airport pins.""" + routes = [ + { + "type": "unbooked_flight", + "key": "LHR_Paris_fr", + "from_iata": "LHR", + "to_iata": "CDG", + "from": (51.47, -0.45), + "to": (49.01, 2.55), + } + ] + coordinates = [ + { + "name": "Heathrow Airport", + "type": "airport", + "latitude": 51.47, + "longitude": -0.45, + } + ] + airports = { + "LHR": { + "name": "Heathrow Airport", + "latitude": 51.47, + "longitude": -0.45, + }, + "CDG": { + "name": "Paris Charles de Gaulle Airport", + "latitude": 49.01, + "longitude": 2.55, + }, + } + + with app.app_context(): + original_parse_yaml = agenda.trip.travel.parse_yaml + try: + agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports + agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates) + finally: + agenda.trip.travel.parse_yaml = original_parse_yaml + + airport_names = { + coord["name"] for coord in coordinates if coord["type"] == "airport" + } + assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"} + + +def test_get_coordinates_and_routes_adds_unbooked_flight_airports() -> None: + """Trip list map data should include pins for unbooked flights.""" + trips = [Trip(start=date(2026, 7, 20))] + unbooked_routes = [ + { + "type": "unbooked_flight", + "key": "LHR_Paris_fr", + "from_iata": "LHR", + "to_iata": "CDG", + "from": (51.47, -0.45), + "to": (49.01, 2.55), + } + ] + airports = { + "LHR": { + "name": "Heathrow Airport", + "latitude": 51.47, + "longitude": -0.45, + }, + "CDG": { + "name": "Paris Charles de Gaulle Airport", + "latitude": 49.01, + "longitude": 2.55, + }, + } + + with app.app_context(): + original_collect_trip_coordinates = agenda.trip.collect_trip_coordinates + original_get_trip_routes = agenda.trip.get_trip_routes + original_parse_yaml = agenda.trip.travel.parse_yaml + try: + agenda.trip.collect_trip_coordinates = lambda _trip: [] + agenda.trip.get_trip_routes = lambda _trip, _data_dir: unbooked_routes + agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports + + coordinates, _routes = agenda.trip.get_coordinates_and_routes(trips) + finally: + agenda.trip.collect_trip_coordinates = original_collect_trip_coordinates + agenda.trip.get_trip_routes = original_get_trip_routes + agenda.trip.travel.parse_yaml = original_parse_yaml + + airport_names = { + coord["name"] for coord in coordinates if coord["type"] == "airport" + } + assert airport_names == {"Heathrow Airport", "Paris Charles de Gaulle Airport"} From 0c88ad46387b4838a8e2cc5756934a8bf31f20b1 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 7 Mar 2026 13:10:33 +0000 Subject: [PATCH 2/5] Validate weekends year range and fix trip page unbooked-flight helper call --- tests/test_trip.py | 4 ++- tests/test_trip_page_route.py | 66 +++++++++++++++++++++++++++++++++++ tests/test_weekends_route.py | 31 ++++++++++++++++ web_view.py | 27 +++++++++++--- 4 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 tests/test_trip_page_route.py create mode 100644 tests/test_weekends_route.py diff --git a/tests/test_trip.py b/tests/test_trip.py index 8c07a3c..69c0e6e 100644 --- a/tests/test_trip.py +++ b/tests/test_trip.py @@ -44,7 +44,9 @@ def test_add_coordinates_for_unbooked_flights_adds_missing_airports() -> None: original_parse_yaml = agenda.trip.travel.parse_yaml try: agenda.trip.travel.parse_yaml = lambda _name, _data_dir: airports - agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates) + agenda.trip.add_coordinates_for_unbooked_flights( + routes, coordinates, app.config["PERSONAL_DATA"] + ) finally: agenda.trip.travel.parse_yaml = original_parse_yaml diff --git a/tests/test_trip_page_route.py b/tests/test_trip_page_route.py new file mode 100644 index 0000000..db7abb4 --- /dev/null +++ b/tests/test_trip_page_route.py @@ -0,0 +1,66 @@ +"""Regression tests for trip page route wiring.""" + +from datetime import date +import typing + +import web_view +from agenda.types import Trip + + +def test_trip_page_passes_data_dir_to_unbooked_flight_helper() -> None: + """Trip page should call helper with routes, coordinates and data_dir.""" + trip = Trip(start=date(2025, 1, 28)) + captured: dict[str, str] = {} + + with web_view.app.app_context(): + original_get_trip_list = web_view.get_trip_list + original_add_schengen = ( + web_view.agenda.trip_schengen.add_schengen_compliance_to_trip + ) + original_collect_trip_coordinates = ( + web_view.agenda.trip.collect_trip_coordinates + ) + original_get_trip_routes = web_view.agenda.trip.get_trip_routes + original_add_coordinates = ( + web_view.agenda.trip.add_coordinates_for_unbooked_flights + ) + original_get_trip_weather = web_view.agenda.weather.get_trip_weather + original_render_template = web_view.flask.render_template + try: + web_view.get_trip_list = lambda: [trip] + web_view.agenda.trip_schengen.add_schengen_compliance_to_trip = lambda t: t + web_view.agenda.trip.collect_trip_coordinates = lambda _trip: [] + web_view.agenda.trip.get_trip_routes = lambda _trip, _data_dir: [] + + def fake_add_coordinates( + _routes: list[typing.Any], + _coordinates: list[typing.Any], + data_dir: str, + ) -> None: + captured["data_dir"] = data_dir + + web_view.agenda.trip.add_coordinates_for_unbooked_flights = ( + fake_add_coordinates + ) + web_view.agenda.weather.get_trip_weather = lambda *_args, **_kwargs: [] + web_view.flask.render_template = lambda *_args, **_kwargs: "ok" + + with web_view.app.test_request_context("/trip/2025-01-28"): + result = web_view.trip_page("2025-01-28") + + assert result == "ok" + assert captured["data_dir"] == web_view.app.config["PERSONAL_DATA"] + finally: + web_view.get_trip_list = original_get_trip_list + web_view.agenda.trip_schengen.add_schengen_compliance_to_trip = ( + original_add_schengen + ) + web_view.agenda.trip.collect_trip_coordinates = ( + original_collect_trip_coordinates + ) + web_view.agenda.trip.get_trip_routes = original_get_trip_routes + web_view.agenda.trip.add_coordinates_for_unbooked_flights = ( + original_add_coordinates + ) + web_view.agenda.weather.get_trip_weather = original_get_trip_weather + web_view.flask.render_template = original_render_template diff --git a/tests/test_weekends_route.py b/tests/test_weekends_route.py new file mode 100644 index 0000000..46de40c --- /dev/null +++ b/tests/test_weekends_route.py @@ -0,0 +1,31 @@ +"""Tests for weekends route query validation.""" + +from datetime import date +import typing + +import pytest + +import web_view + + +@pytest.fixture # type: ignore[untyped-decorator] +def client() -> typing.Any: + """Flask test client.""" + web_view.app.config["TESTING"] = True + with web_view.app.test_client() as c: + yield c + + +def test_weekends_rejects_year_before_2020(client: typing.Any) -> None: + """Years before 2020 should return HTTP 400.""" + response = client.get("/weekends?year=2019&week=1") + assert response.status_code == 400 + assert b"Year must be between 2020" in response.data + + +def test_weekends_rejects_year_more_than_five_years_ahead(client: typing.Any) -> None: + """Years beyond current year + 5 should return HTTP 400.""" + too_far = date.today().year + 6 + response = client.get(f"/weekends?year={too_far}&week=1") + assert response.status_code == 400 + assert b"Year must be between 2020" in response.data diff --git a/web_view.py b/web_view.py index 4fa2d7c..18a62dc 100755 --- a/web_view.py +++ b/web_view.py @@ -268,6 +268,16 @@ async def gaps_page() -> str: async def weekends() -> str: """List of available weekends using an optional date, week, or year parameter.""" today = datetime.now().date() + min_year = 2020 + max_year = today.year + 5 + + def validate_year(year: int) -> None: + """Validate year parameter range for weekends page.""" + if year < min_year or year > max_year: + flask.abort( + 400, description=f"Year must be between {min_year} and {max_year}." + ) + date_str = flask.request.args.get("date") week_str = flask.request.args.get("week") year_str = flask.request.args.get("year") @@ -275,12 +285,14 @@ async def weekends() -> str: if date_str: try: start = datetime.strptime(date_str, "%Y-%m-%d").date() + validate_year(start.year) except ValueError: return flask.abort(400, description="Invalid date format. Use YYYY-MM-DD.") elif week_str: try: week = int(week_str) year = int(year_str) if year_str else today.year + validate_year(year) if week < 1 or week > 53: return flask.abort( 400, description="Week number must be between 1 and 53." @@ -293,6 +305,13 @@ async def weekends() -> str: return flask.abort( 400, description="Invalid week or year format. Use integers." ) + elif year_str: + try: + year = int(year_str) + validate_year(year) + start = date(year, 1, 1) + except ValueError: + return flask.abort(400, description="Invalid year format. Use an integer.") else: start = date(today.year, 1, 1) @@ -914,9 +933,7 @@ def get_destination_timezones(trip: Trip) -> list[StrDict]: if flight_country: flight_locations.append((city, flight_country)) - existing_location_keys = { - (loc, c.alpha_2.lower()) for loc, c in trip.locations() - } + existing_location_keys = {(loc, c.alpha_2.lower()) for loc, c in trip.locations()} all_locations = list(trip.locations()) + [ (city, country) for city, country in flight_locations @@ -1018,7 +1035,9 @@ def trip_page(start: str) -> str: coordinates = agenda.trip.collect_trip_coordinates(trip) routes = agenda.trip.get_trip_routes(trip, app.config["PERSONAL_DATA"]) - agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates) + agenda.trip.add_coordinates_for_unbooked_flights( + routes, coordinates, app.config["PERSONAL_DATA"] + ) for route in routes: if "geojson_filename" in route: From 2a69904ef1c4cc79d4657cb4f9c58a1c117b9fe9 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 7 Mar 2026 13:11:57 +0000 Subject: [PATCH 3/5] Update busy datetime parsing test for UTC-normalized output --- tests/test_busy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_busy.py b/tests/test_busy.py index 8a12d8c..d3b80fc 100644 --- a/tests/test_busy.py +++ b/tests/test_busy.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import date, datetime, timezone import agenda.busy import agenda.travel as travel @@ -157,7 +157,8 @@ def test_parse_datetime_field(): # Test with datetime object dt = datetime(2023, 1, 1, 12, 0, 0) parsed_dt, parsed_date = _parse_datetime_field(dt) - assert parsed_dt == dt + assert parsed_dt == dt.replace(tzinfo=timezone.utc) + assert parsed_dt.tzinfo == timezone.utc assert parsed_date == date(2023, 1, 1) # Test with ISO string From d9b08f3e5bcc4235d98d398bf36725e93111ad8c Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sat, 7 Mar 2026 13:25:56 +0000 Subject: [PATCH 4/5] Handle date-only transport times in trip page template --- templates/trip_page.html | 8 +++-- tests/test_trip_page_route.py | 67 ++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/templates/trip_page.html b/templates/trip_page.html index 0e3f80c..6b51dee 100644 --- a/templates/trip_page.html +++ b/templates/trip_page.html @@ -281,7 +281,9 @@ {% set item = e.detail %} {% set full_flight_number = item.airline_code + item.flight_number %} {% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %} - {% set is_overnight = item.arrive and item.depart.date() != item.arrive.date() %} + {% set depart_date = item.depart.date() if item.depart.hour is defined else item.depart %} + {% set arrive_date = item.arrive.date() if (item.arrive and item.arrive.hour is defined) else item.arrive %} + {% set is_overnight = item.arrive and depart_date != arrive_date %}
@@ -317,7 +319,9 @@ {% elif e.element_type == "train" %} {% set item = e.detail %} - {% set is_overnight = item.depart.date() != item.arrive.date() %} + {% set depart_date = item.depart.date() if item.depart.hour is defined else item.depart %} + {% set arrive_date = item.arrive.date() if item.arrive.hour is defined else item.arrive %} + {% set is_overnight = depart_date != arrive_date %}
diff --git a/tests/test_trip_page_route.py b/tests/test_trip_page_route.py index db7abb4..d471caa 100644 --- a/tests/test_trip_page_route.py +++ b/tests/test_trip_page_route.py @@ -1,4 +1,4 @@ -"""Regression tests for trip page route wiring.""" +"""Regression tests for trip page route wiring and rendering.""" from datetime import date import typing @@ -64,3 +64,68 @@ def test_trip_page_passes_data_dir_to_unbooked_flight_helper() -> None: ) web_view.agenda.weather.get_trip_weather = original_get_trip_weather web_view.flask.render_template = original_render_template + + +def test_trip_page_renders_with_date_only_train_leg() -> None: + """Trip page should render when train legs use date values (no time).""" + trip = Trip( + start=date(2025, 1, 28), + travel=[ + { + "type": "train", + "depart": date(2025, 1, 28), + "from": "A", + "to": "B", + "from_station": { + "name": "A", + "country": "gb", + "latitude": 51.5, + "longitude": -0.1, + }, + "to_station": { + "name": "B", + "country": "gb", + "latitude": 51.6, + "longitude": -0.2, + }, + "legs": [ + { + "from": "A", + "to": "B", + "depart": date(2025, 1, 28), + "arrive": date(2025, 1, 28), + "from_station": { + "name": "A", + "country": "gb", + "latitude": 51.5, + "longitude": -0.1, + }, + "to_station": { + "name": "B", + "country": "gb", + "latitude": 51.6, + "longitude": -0.2, + }, + "operator": "Test Rail", + } + ], + } + ], + ) + + with web_view.app.app_context(): + original_get_trip_list = web_view.get_trip_list + original_get_trip_weather = web_view.agenda.weather.get_trip_weather + try: + web_view.get_trip_list = lambda: [trip] + web_view.agenda.weather.get_trip_weather = lambda *_args, **_kwargs: [] + web_view.app.config["TESTING"] = True + + with web_view.app.test_client() as client: + response = client.get("/trip/2025-01-28") + + assert response.status_code == 200 + assert b"Test Rail" in response.data + finally: + web_view.get_trip_list = original_get_trip_list + web_view.agenda.weather.get_trip_weather = original_get_trip_weather From 04cb3e8179113553c63330a8bc77b8cbb4a662cb Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Thu, 12 Mar 2026 13:28:10 +0000 Subject: [PATCH 5/5] Add 2-hour TTL cache check to SpaceDevs launch updater Prevents hitting the SpaceDevs rate limit by skipping the API call when the launches cache is still fresh (< 2 hours old), matching the same TTL pattern already used for the active crewed flights cache. Co-Authored-By: Claude Sonnet 4.6 --- agenda/thespacedevs.py | 12 ++++++++++++ update.py | 3 +++ 2 files changed, 15 insertions(+) diff --git a/agenda/thespacedevs.py b/agenda/thespacedevs.py index 5b5cbf9..648d4ea 100644 --- a/agenda/thespacedevs.py +++ b/agenda/thespacedevs.py @@ -322,6 +322,18 @@ def summarize_launch(launch: Launch) -> Summary: } +def is_launches_cache_fresh(rocket_dir: str) -> bool: + """Return True if the launches cache is younger than the TTL.""" + now = datetime.now() + existing = [ + x for x in (filename_timestamp(f, "json") for f in os.listdir(rocket_dir)) if x + ] + if not existing: + return False + existing.sort(reverse=True) + return (now - existing[0][0]).total_seconds() <= ttl + + def load_cached_launches(rocket_dir: str) -> StrDict | None: """Read the most recent cache of launches.""" filename = get_most_recent_file(rocket_dir, "json") diff --git a/update.py b/update.py index 97e4d41..f6e5ef3 100755 --- a/update.py +++ b/update.py @@ -321,6 +321,9 @@ def update_thespacedevs(config: flask.config.Config) -> None: existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir) assert existing_data + if agenda.thespacedevs.is_launches_cache_fresh(rocket_dir): + return + # Update active crewed mission cache used by the launches page. # Uses the 2-hour TTL; failures are handled internally with cache fallback. active_crewed = agenda.thespacedevs.get_active_crewed_flights(rocket_dir)