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 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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -351,6 +351,18 @@
 | 
			
		|||
      <div>Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg</div>
 | 
			
		||||
    {% 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) }}
 | 
			
		||||
 | 
			
		||||
    {% for day, elements in trip.elements_grouped_by_day() %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 [])
 | 
			
		||||
  %}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										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 %}
 | 
			
		||||
      <div>How long until trip: {{ delta }}</div>
 | 
			
		||||
    {% 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>
 | 
			
		||||
 | 
			
		||||
    {% 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.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,12 +395,18 @@ 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")
 | 
			
		||||
def trip_list() -> werkzeug.Response:
 | 
			
		||||
| 
						 | 
				
			
			@ -528,6 +542,9 @@ def trip_page(start: str) -> str:
 | 
			
		|||
    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."""
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in a new issue