From 9c64a9fc9959a85f1a33db1e7447b7eaf4838e11 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Tue, 15 Jul 2025 14:40:42 +0200 Subject: [PATCH] Implement Schengen Area Compliance Report How many close am I to 90 days in the last 180. Fixes #193. --- agenda/types.py | 36 ++++++ templates/macros.html | 12 ++ templates/navbar.html | 1 + templates/schengen_report.html | 211 +++++++++++++++++++++++++++++++++ templates/trip_page.html | 19 +++ web_view.py | 25 +++- 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 templates/schengen_report.html diff --git a/agenda/types.py b/agenda/types.py index 4f9d273..789c37c 100644 --- a/agenda/types.py +++ b/agenda/types.py @@ -2,6 +2,7 @@ import collections import datetime +from datetime import date import functools import typing from collections import defaultdict @@ -65,6 +66,7 @@ class Trip: flight_bookings: list[StrDict] = field(default_factory=list) name: str | None = None private: bool = False + schengen_compliance: typing.Optional["SchengenCalculation"] = None @property def title(self) -> str: @@ -365,3 +367,37 @@ 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 + + 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) diff --git a/templates/macros.html b/templates/macros.html index 9a7b068..d5592a6 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -351,6 +351,18 @@
Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg
{% endif %} + {% if trip.schengen_compliance %} +
+ Schengen: + {% if trip.schengen_compliance.is_compliant %} + ✅ Compliant + {% else %} + ❌ Non-compliant + {% endif %} + ({{ trip.schengen_compliance.total_days_used }}/90 days used) +
+ {% endif %} + {{ conference_list(trip) }} {% for day, elements in trip.elements_grouped_by_day() %} diff --git a/templates/navbar.html b/templates/navbar.html index 52f2a3d..eed9c1f 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -14,6 +14,7 @@ {"endpoint": "weekends", "label": "Weekends" }, {"endpoint": "launch_list", "label": "Space launches" }, {"endpoint": "holiday_list", "label": "Holidays" }, + {"endpoint": "schengen_report", "label": "Schengen" }, ] + ([{"endpoint": "birthday_list", "label": "Birthdays" }] if g.user.is_authenticated else []) %} diff --git a/templates/schengen_report.html b/templates/schengen_report.html new file mode 100644 index 0000000..4311a0b --- /dev/null +++ b/templates/schengen_report.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} + +{% set heading = "Schengen Area Compliance Report" %} + +{% block title %}{{ heading }} - Edward Betts{% endblock %} + +{% block content %} +
+

Schengen Area Compliance Report

+ +
+
+
+
+
Current Status
+
+
+
+
+
Compliance Status
+ {% if current_compliance.is_compliant %} + ✅ COMPLIANT + {% else %} + ❌ NON-COMPLIANT + {% endif %} +
+
+
Days Used
+
{{ current_compliance.total_days_used }}/90
+
+
+ +
+
+ {% if current_compliance.is_compliant %} +
Days Remaining
+
{{ current_compliance.days_remaining }}
+ {% else %} +
Days Over Limit
+
{{ current_compliance.days_over_limit }}
+ {% endif %} +
+
+ {% if current_compliance.next_reset_date %} +
Next Reset Date
+
{{ current_compliance.next_reset_date.strftime('%Y-%m-%d') }}
+ {% endif %} +
+
+ +
+
Current 180-day Period
+
+ {{ current_compliance.current_180_day_period[0].strftime('%Y-%m-%d') }} to + {{ current_compliance.current_180_day_period[1].strftime('%Y-%m-%d') }} +
+
+
+
+ + {% if current_compliance.stays_in_period %} +
+
+
Stays in Current 180-day Period
+
+
+
+ + + + + + + + + + + {% for stay in current_compliance.stays_in_period %} + + + + + + + {% endfor %} + +
Entry DateExit DateCountryDays
{{ stay.entry_date.strftime('%Y-%m-%d') }} + {% if stay.exit_date %} + {{ stay.exit_date.strftime('%Y-%m-%d') }} + {% else %} + ongoing + {% endif %} + {{ stay.country | country_flag }} {{ stay.country.upper() }}{{ stay.days }}
+
+
+
+ {% endif %} +
+ +
+ {% if warnings %} +
+
+
Warnings
+
+
+ {% for warning in warnings %} + + {% endfor %} +
+
+ {% endif %} + +
+
+
Compliance History
+
+
+
+ +
+
+
+
+
+ + {% if trips_with_compliance %} +
+
+
Trip Compliance History
+
+
+
+ + + + + + + + + + + {% for trip_date, calculation in trips_with_compliance.items() %} + + + + + + + {% endfor %} + +
Trip DateDays UsedDays RemainingStatus
{{ trip_date.strftime('%Y-%m-%d') }}{{ calculation.total_days_used }}/90{{ calculation.days_remaining }} + {% if calculation.is_compliant %} + Compliant + {% else %} + Non-compliant + {% endif %} +
+
+
+
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} \ No newline at end of file diff --git a/templates/trip_page.html b/templates/trip_page.html index 3f853ca..23d48c3 100644 --- a/templates/trip_page.html +++ b/templates/trip_page.html @@ -117,6 +117,25 @@ {% if delta %}
How long until trip: {{ delta }}
{% endif %} + + {% if trip.schengen_compliance %} +
+ Schengen Compliance: + {% if trip.schengen_compliance.is_compliant %} + ✅ Compliant + {% else %} + ❌ Non-compliant + {% endif %} +
+ {{ trip.schengen_compliance.total_days_used }}/90 days used + {% if trip.schengen_compliance.is_compliant %} + ({{ trip.schengen_compliance.days_remaining }} remaining) + {% else %} + ({{ trip.schengen_compliance.days_over_limit }} over limit) + {% endif %} +
+
+ {% endif %} {% for item in trip.conferences %} diff --git a/web_view.py b/web_view.py index 62222be..2042432 100755 --- a/web_view.py +++ b/web_view.py @@ -25,6 +25,7 @@ import agenda.holidays import agenda.stats import agenda.thespacedevs import agenda.trip +import agenda.trip_schengen import agenda.utils from agenda import calendar, format_list_with_ampersand, travel, uk_tz from agenda.types import StrDict, Trip @@ -36,6 +37,13 @@ app.config.from_object("config.default") agenda.error_mail.setup_error_mail(app) +@app.template_filter("country_flag") +def country_flag_filter(country_code: str) -> str: + """Convert country code to emoji flag.""" + from agenda.schengen import get_country_flag + return get_country_flag(country_code) + + @app.before_request def handle_auth() -> None: """Handle authentication and set global user.""" @@ -387,11 +395,17 @@ def get_trip_list( route_distances: agenda.travel.RouteDistances | None = None, ) -> list[Trip]: """Get list of trips respecting current authentication status.""" - return [ + 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") @@ -527,6 +541,9 @@ def trip_page(start: str) -> str: prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list) if not trip: flask.abort(404) + + # Add Schengen compliance information + trip = agenda.trip_schengen.add_schengen_compliance_to_trip(trip) coordinates = agenda.trip.collect_trip_coordinates(trip) routes = agenda.trip.get_trip_routes(trip, app.config["PERSONAL_DATA"]) @@ -603,6 +620,12 @@ def trip_stats() -> str: ) +@app.route("/schengen") +def schengen_report() -> str: + """Schengen compliance report.""" + return agenda.trip_schengen.flask_route_schengen_report() + + @app.route("/callback") def auth_callback() -> tuple[str, int] | werkzeug.Response: """Process the authentication callback."""