Compare commits

...

5 commits

4 changed files with 111 additions and 45 deletions

View file

@ -4,7 +4,7 @@ from datetime import timedelta, timezone
import dateutil.tz
import exchange_calendars # type: ignore
import pandas # type: ignore
import pandas
from . import utils

View file

@ -224,28 +224,6 @@ def export_schengen_data_for_external_calculator(
return export_data
def add_schengen_info_to_events(events: list, data_dir: str | None = None) -> list:
"""Add Schengen compliance info to event list."""
if data_dir is None:
data_dir = flask.current_app.config["PERSONAL_DATA"]
# Load all trips to get travel context
trip_list = trip.build_trip_list(data_dir)
all_travel_items = []
for trip_obj in trip_list:
all_travel_items.extend(trip_obj.travel)
# Add Schengen status to each event
for event in events:
if hasattr(event, "date"):
calculation = calculate_schengen_time(all_travel_items, event.date)
event.schengen_days_used = calculation.total_days_used
event.schengen_compliant = calculation.is_compliant
event.schengen_days_remaining = calculation.days_remaining
return events
# Integration with existing trip.py functions
def enhanced_build_trip_list(
data_dir: str | None = None,

View file

@ -83,7 +83,7 @@ class SchengenCalculation:
days_remaining: int
is_compliant: bool
current_180_day_period: tuple[date, date] # (start, end)
stays_in_period: SchengenStay
stays_in_period: list[SchengenStay]
next_reset_date: typing.Optional[date] # When the 180-day window resets
@property

View file

@ -5,10 +5,10 @@ import os
import sys
import typing
from datetime import date, datetime, timedelta
from typing import Tuple, TypeVar
from typing import Tuple, TypeVar, cast
import yaml
from geopy.distance import distance
from geopy.distance import distance # type: ignore[import-untyped]
from rich.pretty import pprint
import agenda
@ -23,6 +23,8 @@ data_dir = config.PERSONAL_DATA
currencies = set(config.CURRENCIES + ["GBP"])
LatLon = Tuple[float, float]
def check_currency(item: agenda.types.StrDict) -> None:
"""Throw error if currency is not in config."""
@ -34,11 +36,15 @@ def check_currency(item: agenda.types.StrDict) -> None:
sys.exit(-1)
def get_coords(item):
def get_coords(item: agenda.types.StrDict) -> LatLon | None:
"""Return latitude/longitude tuple when present."""
if "latitude" in item and "longitude" in item:
return (item["latitude"], item["longitude"])
else:
return None
latitude = item["latitude"]
longitude = item["longitude"]
assert isinstance(latitude, (int, float))
assert isinstance(longitude, (int, float))
return (float(latitude), float(longitude))
return None
T = TypeVar("T")
@ -49,12 +55,33 @@ def remove_nones(items: list[T | None]) -> list[T]:
return [item for item in items if item is not None]
LatLon = Tuple[float, float]
def distance_km(a: LatLon, b: LatLon) -> float:
"""Return the great-circle distance between two (lat, lon) points in km."""
return distance(a, b).km
return cast(float, distance(a, b).km)
def parse_datetime_value(value: typing.Any) -> datetime | None:
"""Return naive datetime for supported input types."""
if value is None:
return None
if isinstance(value, str):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError(f"Invalid ISO datetime string: {value}") from exc
return parsed.replace(tzinfo=None)
if isinstance(value, datetime):
return value.replace(tzinfo=None)
if isinstance(value, date):
return datetime.combine(value, datetime.min.time())
raise TypeError(f"Unsupported datetime value type: {type(value)}")
def ranges_overlap(
start_a: datetime, end_a: datetime, start_b: datetime, end_b: datetime
) -> bool:
"""Return True when two datetime ranges overlap."""
return start_a < end_b and start_b < end_a
def check_trips() -> None:
@ -67,6 +94,7 @@ def check_trips() -> None:
for trip_data in trips_data:
current_trip = normalize_datetime(trip_data["trip"])
if prev_trip and current_trip < prev_trip:
assert prev_trip_data is not None
print("Out of order trip found:")
print(
f" Previous: {prev_trip_data.get('trip')} - "
@ -86,12 +114,69 @@ def check_trips() -> None:
for trip in trip_list:
if not trip.accommodation or not trip.conferences:
continue
accommodation_coords = remove_nones([get_coords(a) for a in trip.accommodation])
conference_coords = remove_nones([get_coords(c) for c in trip.conferences])
if len(accommodation_coords) != 1 or len(conference_coords) != 1:
accommodation_entries: list[
tuple[agenda.types.StrDict, LatLon, datetime, datetime]
] = []
for accommodation in trip.accommodation:
accommodation_coords = get_coords(accommodation)
if accommodation_coords is None:
continue
start_dt = parse_datetime_value(accommodation.get("from"))
end_dt = parse_datetime_value(accommodation.get("to"))
if start_dt is None or end_dt is None:
continue
accommodation_entries.append(
(accommodation, accommodation_coords, start_dt, end_dt)
)
if not accommodation_entries:
continue
dist = distance_km(conference_coords[0], accommodation_coords[0])
assert dist < 5.0
for conference in trip.conferences:
if conference.get("online"):
continue
conference_coords = get_coords(conference)
if conference_coords is None:
continue
start_dt = parse_datetime_value(conference.get("start"))
end_value = conference.get("end") or conference.get("start")
end_dt = parse_datetime_value(end_value)
if start_dt is None or end_dt is None:
continue
conference_country = (
str(conference.get("country")).lower()
if conference.get("country")
else None
)
overlapping_distances = []
for (
accommodation_item,
accommodation_coords,
accommodation_start,
accommodation_end,
) in accommodation_entries:
accommodation_country = (
str(accommodation_item.get("country")).lower()
if accommodation_item.get("country")
else None
)
if (
conference_country
and accommodation_country
and accommodation_country != conference_country
):
continue
if not ranges_overlap(
accommodation_start, accommodation_end, start_dt, end_dt
):
continue
overlapping_distances.append(
distance_km(conference_coords, accommodation_coords)
)
if not overlapping_distances:
continue
assert min(overlapping_distances) < config.ACCOMODATION_MAX_DISTANCE_KM
coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir)
print(len(coords), "coords")
@ -132,14 +217,13 @@ def check_flights(airlines: set[str]) -> None:
)
def normalize_datetime(dt_value):
def normalize_datetime(dt_value: date | datetime) -> datetime:
"""Convert date or datetime to datetime for comparison, removing timezone info."""
if isinstance(dt_value, date) and not isinstance(dt_value, datetime):
return datetime.combine(dt_value, datetime.min.time())
elif isinstance(dt_value, datetime):
# Remove timezone info to allow comparison between naive and aware datetimes
if isinstance(dt_value, datetime):
return dt_value.replace(tzinfo=None)
return dt_value
if isinstance(dt_value, date):
return datetime.combine(dt_value, datetime.min.time())
raise TypeError(f"Unsupported datetime value type: {type(dt_value)}")
def check_trains() -> None:
@ -151,6 +235,7 @@ def check_trains() -> None:
for train in trains:
current_depart = normalize_datetime(train["depart"])
if prev_depart and current_depart < prev_depart:
assert prev_train is not None
print(f"Out of order train found:")
print(
f" Previous: {prev_train.get('depart')} {prev_train.get('from', '')} -> {prev_train.get('to', '')}"
@ -184,6 +269,7 @@ def check_conferences() -> None:
current_start = normalize_datetime(conf_data["start"])
if prev_start and current_start < prev_start:
assert prev_conf_data is not None
print(f"Out of order conference found:")
print(
f" Previous: {prev_conf_data.get('start')} - {prev_conf_data.get('name', 'No name')}"
@ -237,6 +323,7 @@ def check_accommodation() -> None:
current_from = normalize_datetime(stay["from"])
if prev_from and current_from < prev_from:
assert prev_stay is not None
print(f"Out of order accommodation found:")
print(
f" Previous: {prev_stay.get('from')} - {prev_stay.get('name', 'No name')} ({prev_stay.get('location', '')})"
@ -282,6 +369,7 @@ def check_ferries() -> None:
for ferry in ferries:
current_depart = normalize_datetime(ferry["depart"])
if prev_depart and current_depart < prev_depart:
assert prev_ferry is not None
print(f"Out of order ferry found:")
print(
f" Previous: {prev_ferry.get('depart')} {prev_ferry.get('from', '')} -> {prev_ferry.get('to', '')}"