Implement Schengen Area Compliance Report
How many close am I to 90 days in the last 180. Fixes #193.
This commit is contained in:
parent
084e5f44e3
commit
9c64a9fc99
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import collections
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
|
from datetime import date
|
||||||
import functools
|
import functools
|
||||||
import typing
|
import typing
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
@ -65,6 +66,7 @@ class Trip:
|
||||||
flight_bookings: list[StrDict] = field(default_factory=list)
|
flight_bookings: list[StrDict] = field(default_factory=list)
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
private: bool = False
|
private: bool = False
|
||||||
|
schengen_compliance: typing.Optional["SchengenCalculation"] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self) -> str:
|
def title(self) -> str:
|
||||||
|
@ -365,3 +367,37 @@ class Holiday:
|
||||||
if self.local_name and self.local_name != self.name
|
if self.local_name and self.local_name != self.name
|
||||||
else 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)
|
||||||
|
|
|
@ -351,6 +351,18 @@
|
||||||
<div>Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg</div>
|
<div>Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if trip.schengen_compliance %}
|
||||||
|
<div>
|
||||||
|
<strong>Schengen:</strong>
|
||||||
|
{% if trip.schengen_compliance.is_compliant %}
|
||||||
|
<span class="badge bg-success">✅ Compliant</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">❌ Non-compliant</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-muted small">({{ trip.schengen_compliance.total_days_used }}/90 days used)</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ conference_list(trip) }}
|
{{ conference_list(trip) }}
|
||||||
|
|
||||||
{% for day, elements in trip.elements_grouped_by_day() %}
|
{% for day, elements in trip.elements_grouped_by_day() %}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
{"endpoint": "weekends", "label": "Weekends" },
|
{"endpoint": "weekends", "label": "Weekends" },
|
||||||
{"endpoint": "launch_list", "label": "Space launches" },
|
{"endpoint": "launch_list", "label": "Space launches" },
|
||||||
{"endpoint": "holiday_list", "label": "Holidays" },
|
{"endpoint": "holiday_list", "label": "Holidays" },
|
||||||
|
{"endpoint": "schengen_report", "label": "Schengen" },
|
||||||
] + ([{"endpoint": "birthday_list", "label": "Birthdays" }]
|
] + ([{"endpoint": "birthday_list", "label": "Birthdays" }]
|
||||||
if g.user.is_authenticated else [])
|
if g.user.is_authenticated else [])
|
||||||
%}
|
%}
|
||||||
|
|
211
templates/schengen_report.html
Normal file
211
templates/schengen_report.html
Normal file
|
@ -0,0 +1,211 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% set heading = "Schengen Area Compliance Report" %}
|
||||||
|
|
||||||
|
{% block title %}{{ heading }} - Edward Betts{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container-fluid">
|
||||||
|
<h1>Schengen Area Compliance Report</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Current Status</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Compliance Status</h6>
|
||||||
|
{% if current_compliance.is_compliant %}
|
||||||
|
<span class="badge bg-success fs-6">✅ COMPLIANT</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger fs-6">❌ NON-COMPLIANT</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6>Days Used</h6>
|
||||||
|
<div class="fs-4">{{ current_compliance.total_days_used }}/90</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
{% if current_compliance.is_compliant %}
|
||||||
|
<h6>Days Remaining</h6>
|
||||||
|
<div class="fs-5 text-success">{{ current_compliance.days_remaining }}</div>
|
||||||
|
{% else %}
|
||||||
|
<h6>Days Over Limit</h6>
|
||||||
|
<div class="fs-5 text-danger">{{ current_compliance.days_over_limit }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
{% if current_compliance.next_reset_date %}
|
||||||
|
<h6>Next Reset Date</h6>
|
||||||
|
<div class="fs-6">{{ current_compliance.next_reset_date.strftime('%Y-%m-%d') }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<h6>Current 180-day Period</h6>
|
||||||
|
<div class="text-muted">
|
||||||
|
{{ current_compliance.current_180_day_period[0].strftime('%Y-%m-%d') }} to
|
||||||
|
{{ current_compliance.current_180_day_period[1].strftime('%Y-%m-%d') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_compliance.stays_in_period %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Stays in Current 180-day Period</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entry Date</th>
|
||||||
|
<th>Exit Date</th>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Days</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for stay in current_compliance.stays_in_period %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ stay.entry_date.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if stay.exit_date %}
|
||||||
|
{{ stay.exit_date.strftime('%Y-%m-%d') }}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">ongoing</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ stay.country | country_flag }} {{ stay.country.upper() }}</td>
|
||||||
|
<td>{{ stay.days }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
{% if warnings %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Warnings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% for warning in warnings %}
|
||||||
|
<div class="alert alert-{{ 'danger' if warning.severity == 'error' else 'warning' }} alert-dismissible fade show" role="alert">
|
||||||
|
<strong>{{ warning.date.strftime('%Y-%m-%d') }}</strong><br>
|
||||||
|
{{ warning.message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Compliance History</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chart-container" style="height: 300px;">
|
||||||
|
<canvas id="complianceChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if trips_with_compliance %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title">Trip Compliance History</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Trip Date</th>
|
||||||
|
<th>Days Used</th>
|
||||||
|
<th>Days Remaining</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for trip_date, calculation in trips_with_compliance.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ trip_date.strftime('%Y-%m-%d') }}</td>
|
||||||
|
<td>{{ calculation.total_days_used }}/90</td>
|
||||||
|
<td>{{ calculation.days_remaining }}</td>
|
||||||
|
<td>
|
||||||
|
{% if calculation.is_compliant %}
|
||||||
|
<span class="badge bg-success">Compliant</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">Non-compliant</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
const ctx = document.getElementById('complianceChart').getContext('2d');
|
||||||
|
const complianceHistory = {{ compliance_history | tojson }};
|
||||||
|
|
||||||
|
const chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: complianceHistory.map(item => item.date),
|
||||||
|
datasets: [{
|
||||||
|
label: 'Days Used',
|
||||||
|
data: complianceHistory.map(item => item.days_used),
|
||||||
|
borderColor: 'rgb(75, 192, 192)',
|
||||||
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
||||||
|
tension: 0.1
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
max: 90,
|
||||||
|
ticks: {
|
||||||
|
callback: function(value) {
|
||||||
|
return value + '/90';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
|
@ -117,6 +117,25 @@
|
||||||
{% if delta %}
|
{% if delta %}
|
||||||
<div>How long until trip: {{ delta }}</div>
|
<div>How long until trip: {{ delta }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if trip.schengen_compliance %}
|
||||||
|
<div class="mt-3">
|
||||||
|
<strong>Schengen Compliance:</strong>
|
||||||
|
{% if trip.schengen_compliance.is_compliant %}
|
||||||
|
<span class="badge bg-success">✅ Compliant</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-danger">❌ Non-compliant</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-muted small">
|
||||||
|
{{ 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 %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% for item in trip.conferences %}
|
{% for item in trip.conferences %}
|
||||||
|
|
25
web_view.py
25
web_view.py
|
@ -25,6 +25,7 @@ import agenda.holidays
|
||||||
import agenda.stats
|
import agenda.stats
|
||||||
import agenda.thespacedevs
|
import agenda.thespacedevs
|
||||||
import agenda.trip
|
import agenda.trip
|
||||||
|
import agenda.trip_schengen
|
||||||
import agenda.utils
|
import agenda.utils
|
||||||
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
|
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
|
||||||
from agenda.types import StrDict, Trip
|
from agenda.types import StrDict, Trip
|
||||||
|
@ -36,6 +37,13 @@ app.config.from_object("config.default")
|
||||||
agenda.error_mail.setup_error_mail(app)
|
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
|
@app.before_request
|
||||||
def handle_auth() -> None:
|
def handle_auth() -> None:
|
||||||
"""Handle authentication and set global user."""
|
"""Handle authentication and set global user."""
|
||||||
|
@ -387,11 +395,17 @@ def get_trip_list(
|
||||||
route_distances: agenda.travel.RouteDistances | None = None,
|
route_distances: agenda.travel.RouteDistances | None = None,
|
||||||
) -> list[Trip]:
|
) -> list[Trip]:
|
||||||
"""Get list of trips respecting current authentication status."""
|
"""Get list of trips respecting current authentication status."""
|
||||||
return [
|
trips = [
|
||||||
trip
|
trip
|
||||||
for trip in agenda.trip.build_trip_list(route_distances=route_distances)
|
for trip in agenda.trip.build_trip_list(route_distances=route_distances)
|
||||||
if flask.g.user.is_authenticated or not trip.private
|
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")
|
@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)
|
prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list)
|
||||||
if not trip:
|
if not trip:
|
||||||
flask.abort(404)
|
flask.abort(404)
|
||||||
|
|
||||||
|
# Add Schengen compliance information
|
||||||
|
trip = agenda.trip_schengen.add_schengen_compliance_to_trip(trip)
|
||||||
|
|
||||||
coordinates = agenda.trip.collect_trip_coordinates(trip)
|
coordinates = agenda.trip.collect_trip_coordinates(trip)
|
||||||
routes = agenda.trip.get_trip_routes(trip, app.config["PERSONAL_DATA"])
|
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")
|
@app.route("/callback")
|
||||||
def auth_callback() -> tuple[str, int] | werkzeug.Response:
|
def auth_callback() -> tuple[str, int] | werkzeug.Response:
|
||||||
"""Process the authentication callback."""
|
"""Process the authentication callback."""
|
||||||
|
|
Loading…
Reference in a new issue