From f7185356247cdb8f215f971e851542d2b8260ba0 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 12:21:59 +0000 Subject: [PATCH 1/5] Adjust code formatting. --- agenda/trip.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/agenda/trip.py b/agenda/trip.py index 978434d..d73cd42 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -409,7 +409,9 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]: # Use GeoJSON route when available, otherwise draw straight line if t.get("geojson_filename"): filename = os.path.join("coach_routes", t["geojson_filename"]) - routes.append({"type": "coach", "key": key, "geojson_filename": filename}) + routes.append( + {"type": "coach", "key": key, "geojson_filename": filename} + ) else: routes.append( { @@ -424,7 +426,9 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]: for leg in t["legs"]: train_from, train_to = leg["from_station"], leg["to_station"] geojson_filename = train_from.get("routes", {}).get(train_to["name"]) - key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]])) + key = "_".join( + ["train"] + sorted([train_from["name"], train_to["name"]]) + ) if not geojson_filename: routes.append( { @@ -444,7 +448,9 @@ def get_trip_routes(trip: Trip, data_dir: str) -> list[StrDict]: { "type": "train", "key": key, - "geojson_filename": os.path.join("train_routes", geojson_filename), + "geojson_filename": os.path.join( + "train_routes", geojson_filename + ), } ) From b4126d04f8e00207b3d6656231bdf7d1f0a892b3 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 12:22:27 +0000 Subject: [PATCH 2/5] Move two functions to agenda/trip.py --- agenda/schengen.py | 2 +- agenda/trip.py | 48 +++++++++++++++++++++++++------------ agenda/utils.py | 18 ++++++++++++-- web_view.py | 59 +++++++++++----------------------------------- 4 files changed, 64 insertions(+), 63 deletions(-) diff --git a/agenda/schengen.py b/agenda/schengen.py index 016cc01..7f1d355 100644 --- a/agenda/schengen.py +++ b/agenda/schengen.py @@ -2,8 +2,8 @@ from datetime import date, datetime, timedelta -from .trip import depart_datetime from .types import SchengenCalculation, SchengenStay, StrDict +from .utils import depart_datetime # Schengen Area countries as of 2025 SCHENGEN_COUNTRIES = { diff --git a/agenda/trip.py b/agenda/trip.py index d73cd42..9aad250 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -3,14 +3,14 @@ import decimal import os import typing -from datetime import date, datetime, time -from zoneinfo import ZoneInfo +from datetime import date import flask import yaml -from agenda import travel +from agenda import travel, trip_schengen from agenda.types import StrDict, Trip +from agenda.utils import depart_datetime class Airline(typing.TypedDict, total=False): @@ -122,18 +122,6 @@ def load_coaches( return coaches -def depart_datetime(item: StrDict) -> datetime: - """Return a datetime for this travel item. - - If the travel item already has a datetime return that, otherwise if the - departure time is just a date return midnight UTC for that date. - """ - depart = item["depart"] - if isinstance(depart, datetime): - return depart - return datetime.combine(depart, time.min).replace(tzinfo=ZoneInfo("UTC")) - - def process_flight( flight: StrDict, by_iata: dict[str, Airline], airports: list[StrDict] ) -> None: @@ -502,3 +490,33 @@ def get_coordinates_and_routes( route["geojson"] = read_geojson(data_dir, route.pop("geojson_filename")) return (coordinates, routes) + + +def get_trip_list( + route_distances: travel.RouteDistances | None = None, +) -> list[Trip]: + """Get list of trips respecting current authentication status.""" + trips = [ + trip + for trip in build_trip_list(route_distances=route_distances) + if flask.g.user.is_authenticated or not trip.private + ] + + # Add Schengen compliance information to each trip + for trip in trips: + trip_schengen.add_schengen_compliance_to_trip(trip) + + return trips + + +def get_current_trip(today: date) -> Trip | None: + """Get current trip.""" + trip_list = get_trip_list(route_distances=None) + + current = [ + item + for item in trip_list + if item.start <= today and (item.end or item.start) >= today + ] + assert len(current) < 2 + return current[0] if current else None diff --git a/agenda/utils.py b/agenda/utils.py index 5f8573c..15c9100 100644 --- a/agenda/utils.py +++ b/agenda/utils.py @@ -2,8 +2,10 @@ import os import typing -from datetime import date, datetime, timedelta, timezone -from time import time +from datetime import date, datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo + +from .types import StrDict def as_date(d: datetime | date) -> date: @@ -118,3 +120,15 @@ async def time_function( exception = e end_time = time() return name, result, end_time - start_time, exception + + +def depart_datetime(item: StrDict) -> datetime: + """Return a datetime for this travel item. + + If the travel item already has a datetime return that, otherwise if the + departure time is just a date return midnight UTC for that date. + """ + depart = item["depart"] + if isinstance(depart, datetime): + return depart + return datetime.combine(depart, time.min).replace(tzinfo=ZoneInfo("UTC")) diff --git a/web_view.py b/web_view.py index bc04d08..8e41aef 100755 --- a/web_view.py +++ b/web_view.py @@ -57,6 +57,7 @@ def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, last_frame = list(traceback.walk_tb(current_traceback))[-1][0] last_frame_args = inspect.getargs(last_frame.f_code) + assert tb._te.exc_type return ( flask.render_template( @@ -72,19 +73,6 @@ def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, ) -def get_current_trip(today: date) -> Trip | None: - """Get current trip.""" - trip_list = get_trip_list(route_distances=None) - - current = [ - item - for item in trip_list - if item.start <= today and (item.end or item.start) >= today - ] - assert len(current) < 2 - return current[0] if current else None - - @app.route("/") async def index() -> str: """Index page.""" @@ -106,7 +94,7 @@ async def index() -> str: today=today, events=events, get_country=agenda.get_country, - current_trip=get_current_trip(today), + current_trip=agenda.trip.get_current_trip(today), start_event_list=date.today() - timedelta(days=1), end_event_list=date.today() + timedelta(days=365 * 2), render_time=(time.time() - t0), @@ -429,23 +417,6 @@ def accommodation_list() -> str: ) -def get_trip_list( - route_distances: agenda.travel.RouteDistances | None = None, -) -> list[Trip]: - """Get list of trips respecting current authentication status.""" - trips = [ - trip - for trip in agenda.trip.build_trip_list(route_distances=route_distances) - if flask.g.user.is_authenticated or not trip.private - ] - - # Add Schengen compliance information to each trip - for trip in trips: - agenda.trip_schengen.add_schengen_compliance_to_trip(trip) - - return trips - - @app.route("/trip") def trip_list() -> werkzeug.Response: """Trip list to redirect to future trip list.""" @@ -477,15 +448,17 @@ def sum_distances_by_transport_type(trips: list[Trip]) -> list[tuple[str, float] return list(distances_by_transport_type.items()) +def get_trip_list() -> list[Trip]: + """Get trip list with route distances.""" + route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) + return agenda.trip.get_trip_list(route_distances) + + @app.route("/trip/past") def trip_past_list() -> str: """Page showing a list of past trips.""" - route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) - trip_list = get_trip_list(route_distances) today = date.today() - - past = [item for item in trip_list if (item.end or item.start) < today] - + past = [item for item in get_trip_list() if (item.end or item.start) < today] coordinates, routes = agenda.trip.get_coordinates_and_routes(past) return flask.render_template( @@ -507,8 +480,7 @@ def trip_past_list() -> str: @app.route("/trip/future") def trip_future_list() -> str: """Page showing a list of future trips.""" - route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) - trip_list = get_trip_list(route_distances) + trip_list = get_trip_list() today = date.today() current = [ @@ -540,7 +512,7 @@ def trip_future_list() -> str: @app.route("/trip/text") def trip_list_text() -> str: """Page showing a list of trips.""" - trip_list = get_trip_list() + trip_list = agenda.trip.get_trip_list() today = date.today() future = [item for item in trip_list if item.start > today] @@ -573,8 +545,7 @@ def get_prev_current_and_next_trip( @app.route("/trip/") def trip_page(start: str) -> str: """Individual trip page.""" - route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) - trip_list = get_trip_list(route_distances) + trip_list = get_trip_list() prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list) if not trip: @@ -616,8 +587,7 @@ def trip_debug_page(start: str) -> str: if not flask.g.user.is_authenticated: flask.abort(401) - route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) - trip_list = get_trip_list(route_distances) + trip_list = get_trip_list() prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list) if not trip: @@ -713,8 +683,7 @@ def birthday_list() -> str: @app.route("/trip/stats") def trip_stats() -> str: """Travel stats: distance and price by year and travel type.""" - route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) - trip_list = get_trip_list(route_distances) + trip_list = get_trip_list() conferences = sum(len(item.conferences) for item in trip_list) From 7e3f9a9b1e911bc2836beec2b1dfe66e237fa82e Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 12:36:41 +0000 Subject: [PATCH 3/5] Show trip stats in reverse chronological order. --- templates/trip/stats.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/trip/stats.html b/templates/trip/stats.html index b31cb58..883cb0d 100644 --- a/templates/trip/stats.html +++ b/templates/trip/stats.html @@ -20,7 +20,7 @@ {% endfor %} - {% for year, year_stats in yearly_stats | dictsort %} + {% for year, year_stats in yearly_stats | dictsort(reverse=True) %} {% set countries = year_stats.countries | sort(attribute="name") %}

{{ year }}

Trips in {{ year }}: {{ year_stats.count }}
From 5d5ce61da40497e0007d85e829afa98cf84000f4 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 12:52:24 +0000 Subject: [PATCH 4/5] Hide compliance status on none-Schengen trips. --- agenda/trip_schengen.py | 29 +++++++++++----- agenda/types.py | 75 +++++++++++++++++++++-------------------- 2 files changed, 59 insertions(+), 45 deletions(-) diff --git a/agenda/trip_schengen.py b/agenda/trip_schengen.py index 8d3cd8b..6e34044 100644 --- a/agenda/trip_schengen.py +++ b/agenda/trip_schengen.py @@ -7,26 +7,37 @@ from datetime import date, timedelta import flask from . import get_country, trip -from .schengen import calculate_schengen_time, extract_schengen_stays_from_travel +from .schengen import ( + SCHENGEN_COUNTRIES, + calculate_schengen_time, + extract_schengen_stays_from_travel, +) from .types import SchengenCalculation, SchengenStay, StrDict, Trip -def add_schengen_compliance_to_trip(trip_obj: Trip) -> Trip: +def trip_includes_schengen(trip: Trip) -> bool: + return bool({c.alpha_2.lower() for c in trip.countries} & SCHENGEN_COUNTRIES) + + +def add_schengen_compliance_to_trip(trip: Trip) -> Trip: """Add Schengen compliance information to a trip object.""" + if not trip_includes_schengen(trip): + return trip + try: # Calculate Schengen compliance for the trip - calculation = calculate_schengen_time(trip_obj.travel) + calculation = calculate_schengen_time(trip.travel) # Add the calculation to the trip object - trip_obj.schengen_compliance = calculation + trip.schengen_compliance = calculation except Exception as e: # Log the error but don't fail the trip loading logging.warning( - f"Failed to calculate Schengen compliance for trip {trip_obj.start}: {e}" + f"Failed to calculate Schengen compliance for trip {trip.start}: {e}" ) - trip_obj.schengen_compliance = None + trip.schengen_compliance = None - return trip_obj + return trip def get_schengen_compliance_for_all_trips( @@ -127,7 +138,9 @@ def schengen_dashboard_data(data_dir: str | None = None) -> dict[str, typing.Any data_dir = flask.current_app.config["PERSONAL_DATA"] # Load all trips - trip_list = trip.build_trip_list(data_dir) + trip_list = [ + trip for trip in trip.build_trip_list(data_dir) if trip_includes_schengen(trip) + ] # Calculate current compliance with trip information all_travel_items = [] diff --git a/agenda/types.py b/agenda/types.py index d1ecdda..d6041ed 100644 --- a/agenda/types.py +++ b/agenda/types.py @@ -55,6 +55,43 @@ def airport_label(airport: StrDict) -> str: return f"{name} ({airport['iata']})" +@dataclass +class SchengenStay: + """Represents a stay in the Schengen area.""" + + entry_date: date + exit_date: date | None # None if currently in Schengen + country: str + days: int + trip_date: date | None = None # Trip start date for linking + trip_name: str | None = None # Trip name for display + + def __post_init__(self) -> None: + """Post init.""" + if self.exit_date is None: + # Currently in Schengen, calculate days up to today + self.days = (date.today() - self.entry_date).days + 1 + else: + self.days = (self.exit_date - self.entry_date).days + 1 + + +@dataclass +class SchengenCalculation: + """Result of Schengen time calculation.""" + + total_days_used: int + days_remaining: int + is_compliant: bool + current_180_day_period: tuple[date, date] # (start, end) + stays_in_period: SchengenStay + next_reset_date: typing.Optional[date] # When the 180-day window resets + + @property + def days_over_limit(self) -> int: + """Days over the 90-day limit.""" + return max(0, self.total_days_used - 90) + + @dataclass class Trip: """Trip.""" @@ -67,7 +104,7 @@ class Trip: flight_bookings: list[StrDict] = field(default_factory=list) name: str | None = None private: bool = False - schengen_compliance: typing.Optional["SchengenCalculation"] = None + schengen_compliance: SchengenCalculation | None = None @property def title(self) -> str: @@ -409,39 +446,3 @@ class Holiday: if self.local_name and self.local_name != self.name else self.name ) - - -@dataclass -class SchengenStay: - """Represents a stay in the Schengen area.""" - - entry_date: date - exit_date: typing.Optional[date] # None if currently in Schengen - country: str - days: int - trip_date: typing.Optional[date] = None # Trip start date for linking - trip_name: typing.Optional[str] = None # Trip name for display - - def __post_init__(self) -> None: - if self.exit_date is None: - # Currently in Schengen, calculate days up to today - self.days = (date.today() - self.entry_date).days + 1 - else: - self.days = (self.exit_date - self.entry_date).days + 1 - - -@dataclass -class SchengenCalculation: - """Result of Schengen time calculation.""" - - total_days_used: int - days_remaining: int - is_compliant: bool - current_180_day_period: tuple[date, date] # (start, end) - stays_in_period: list["SchengenStay"] - next_reset_date: typing.Optional[date] # When the 180-day window resets - - @property - def days_over_limit(self) -> int: - """Days over the 90-day limit.""" - return max(0, self.total_days_used - 90) From a8652d881c08e296647eb80df0c91a7584bebe4a Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Nov 2025 13:05:09 +0000 Subject: [PATCH 5/5] Show country flags in Schengen compliance history --- templates/schengen_report.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/schengen_report.html b/templates/schengen_report.html index 9222785..b34c02d 100644 --- a/templates/schengen_report.html +++ b/templates/schengen_report.html @@ -162,8 +162,8 @@ {{ trip_date.strftime('%Y-%m-%d') }} - {% for trip_obj in trip_list if trip_obj.start == trip_date %} - {{ trip_obj.title }} + {% for trip in trip_list if trip.start == trip_date %} + {{ trip.title }} {{ trip.country_flags }} {% endfor %}