Compare commits
	
		
			5 commits
		
	
	
		
			a8652d881c
			...
			8a9126b729
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
							
							
								
									
								
								 | 
						8a9126b729 | ||
| 
							
							
								
									
								
								 | 
						e82ca9d9e9 | ||
| 
							
							
								
									
								
								 | 
						83e1406ab4 | ||
| 
							
							
								
									
								
								 | 
						a7ec33c500 | ||
| 
							
							
								
									
								
								 | 
						4364c95f25 | 
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										130
									
								
								validate_yaml.py
									
									
									
									
									
								
							
							
						
						
									
										130
									
								
								validate_yaml.py
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -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', '')}"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in a new issue