Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""City registry module for multi-city support"""

View File

@@ -0,0 +1,377 @@
# services/external/app/registry/calendar_registry.py
"""
Calendar Registry - Pre-configured school calendars and local events
"""
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from datetime import date
from enum import Enum
class SchoolType(str, Enum):
PRIMARY = "primary"
SECONDARY = "secondary"
UNIVERSITY = "university"
@dataclass
class HolidayPeriod:
"""School holiday period definition"""
name: str
start_date: str # ISO format: "2024-12-20"
end_date: str # ISO format: "2025-01-08"
description: Optional[str] = None
@dataclass
class SchoolHours:
"""School operating hours configuration"""
morning_start: str # "09:00"
morning_end: str # "14:00"
has_afternoon_session: bool # True/False
afternoon_start: Optional[str] = None # "15:00" if has_afternoon_session
afternoon_end: Optional[str] = None # "17:00" if has_afternoon_session
@dataclass
class CalendarDefinition:
"""School calendar configuration for a specific city and school type"""
calendar_id: str
calendar_name: str
city_id: str
school_type: SchoolType
academic_year: str # "2024-2025"
holiday_periods: List[HolidayPeriod]
school_hours: SchoolHours
source: str
enabled: bool = True
class CalendarRegistry:
"""Central registry of school calendars for forecasting"""
# Madrid Primary School Calendar 2024-2025 (Official Comunidad de Madrid - ORDEN 1177/2024)
MADRID_PRIMARY_2024_2025 = CalendarDefinition(
calendar_id="madrid_primary_2024_2025",
calendar_name="Madrid Primary School Calendar 2024-2025",
city_id="madrid",
school_type=SchoolType.PRIMARY,
academic_year="2024-2025",
holiday_periods=[
HolidayPeriod(
name="Christmas Holiday",
start_date="2024-12-21",
end_date="2025-01-07",
description="Official Christmas break - Comunidad de Madrid (Dec 21 - Jan 7)"
),
HolidayPeriod(
name="Easter Holiday (Semana Santa)",
start_date="2025-04-11",
end_date="2025-04-21",
description="Official Easter break - Comunidad de Madrid (Apr 11-21)"
),
HolidayPeriod(
name="Summer Holiday",
start_date="2025-06-21",
end_date="2025-09-08",
description="Summer vacation (Last day Jun 20, classes resume Sep 9)"
),
HolidayPeriod(
name="All Saints Long Weekend",
start_date="2024-10-31",
end_date="2024-11-03",
description="October 31 - November 3 non-working days"
),
HolidayPeriod(
name="February Long Weekend",
start_date="2025-02-28",
end_date="2025-03-03",
description="February 28 - March 3 non-working days"
),
],
school_hours=SchoolHours(
morning_start="09:00",
morning_end="14:00",
has_afternoon_session=False
),
source="comunidad_madrid_orden_1177_2024",
enabled=True
)
# Madrid Secondary School Calendar 2024-2025 (Official Comunidad de Madrid - ORDEN 1177/2024)
MADRID_SECONDARY_2024_2025 = CalendarDefinition(
calendar_id="madrid_secondary_2024_2025",
calendar_name="Madrid Secondary School Calendar 2024-2025",
city_id="madrid",
school_type=SchoolType.SECONDARY,
academic_year="2024-2025",
holiday_periods=[
HolidayPeriod(
name="Christmas Holiday",
start_date="2024-12-21",
end_date="2025-01-07",
description="Official Christmas break - Comunidad de Madrid (Dec 21 - Jan 7)"
),
HolidayPeriod(
name="Easter Holiday (Semana Santa)",
start_date="2025-04-11",
end_date="2025-04-21",
description="Official Easter break - Comunidad de Madrid (Apr 11-21)"
),
HolidayPeriod(
name="Summer Holiday",
start_date="2025-06-21",
end_date="2025-09-09",
description="Summer vacation (Last day Jun 20, classes resume Sep 10)"
),
HolidayPeriod(
name="All Saints Long Weekend",
start_date="2024-10-31",
end_date="2024-11-03",
description="October 31 - November 3 non-working days"
),
HolidayPeriod(
name="February Long Weekend",
start_date="2025-02-28",
end_date="2025-03-03",
description="February 28 - March 3 non-working days"
),
],
school_hours=SchoolHours(
morning_start="09:00",
morning_end="14:00",
has_afternoon_session=False
),
source="comunidad_madrid_orden_1177_2024",
enabled=True
)
# Madrid Primary School Calendar 2025-2026 (Official Comunidad de Madrid - ORDEN 1476/2025)
MADRID_PRIMARY_2025_2026 = CalendarDefinition(
calendar_id="madrid_primary_2025_2026",
calendar_name="Madrid Primary School Calendar 2025-2026",
city_id="madrid",
school_type=SchoolType.PRIMARY,
academic_year="2025-2026",
holiday_periods=[
HolidayPeriod(
name="Christmas Holiday",
start_date="2025-12-20",
end_date="2026-01-07",
description="Official Christmas break - Comunidad de Madrid (Dec 20 - Jan 7)"
),
HolidayPeriod(
name="Easter Holiday (Semana Santa)",
start_date="2026-03-27",
end_date="2026-04-06",
description="Official Easter break - Comunidad de Madrid (Mar 27 - Apr 6)"
),
HolidayPeriod(
name="Summer Holiday",
start_date="2026-06-21",
end_date="2026-09-08",
description="Summer vacation (classes resume Sep 9)"
),
HolidayPeriod(
name="October Long Weekend",
start_date="2025-10-13",
end_date="2025-10-13",
description="October 13 non-working day (after Día de la Hispanidad)"
),
HolidayPeriod(
name="All Saints Long Weekend",
start_date="2025-11-03",
end_date="2025-11-03",
description="November 3 non-working day (after All Saints)"
),
],
school_hours=SchoolHours(
morning_start="09:00",
morning_end="14:00",
has_afternoon_session=False
),
source="comunidad_madrid_orden_1476_2025",
enabled=True
)
# Madrid Secondary School Calendar 2025-2026 (Official Comunidad de Madrid - ORDEN 1476/2025)
MADRID_SECONDARY_2025_2026 = CalendarDefinition(
calendar_id="madrid_secondary_2025_2026",
calendar_name="Madrid Secondary School Calendar 2025-2026",
city_id="madrid",
school_type=SchoolType.SECONDARY,
academic_year="2025-2026",
holiday_periods=[
HolidayPeriod(
name="Christmas Holiday",
start_date="2025-12-20",
end_date="2026-01-07",
description="Official Christmas break - Comunidad de Madrid (Dec 20 - Jan 7)"
),
HolidayPeriod(
name="Easter Holiday (Semana Santa)",
start_date="2026-03-27",
end_date="2026-04-06",
description="Official Easter break - Comunidad de Madrid (Mar 27 - Apr 6)"
),
HolidayPeriod(
name="Summer Holiday",
start_date="2026-06-21",
end_date="2026-09-09",
description="Summer vacation (classes resume Sep 10)"
),
HolidayPeriod(
name="October Long Weekend",
start_date="2025-10-13",
end_date="2025-10-13",
description="October 13 non-working day (after Día de la Hispanidad)"
),
HolidayPeriod(
name="All Saints Long Weekend",
start_date="2025-11-03",
end_date="2025-11-03",
description="November 3 non-working day (after All Saints)"
),
],
school_hours=SchoolHours(
morning_start="09:00",
morning_end="14:00",
has_afternoon_session=False
),
source="comunidad_madrid_orden_1476_2025",
enabled=True
)
# Registry of all calendars
CALENDARS: List[CalendarDefinition] = [
MADRID_PRIMARY_2024_2025,
MADRID_SECONDARY_2024_2025,
MADRID_PRIMARY_2025_2026,
MADRID_SECONDARY_2025_2026,
]
@classmethod
def get_all_calendars(cls) -> List[CalendarDefinition]:
"""Get all calendars"""
return cls.CALENDARS
@classmethod
def get_enabled_calendars(cls) -> List[CalendarDefinition]:
"""Get all enabled calendars"""
return [cal for cal in cls.CALENDARS if cal.enabled]
@classmethod
def get_calendar(cls, calendar_id: str) -> Optional[CalendarDefinition]:
"""Get calendar by ID"""
for cal in cls.CALENDARS:
if cal.calendar_id == calendar_id:
return cal
return None
@classmethod
def get_calendars_for_city(cls, city_id: str) -> List[CalendarDefinition]:
"""Get all enabled calendars for a specific city"""
return [
cal for cal in cls.CALENDARS
if cal.city_id == city_id and cal.enabled
]
@classmethod
def get_calendar_for_city_and_type(
cls,
city_id: str,
school_type: SchoolType,
academic_year: Optional[str] = None
) -> Optional[CalendarDefinition]:
"""Get specific calendar for city, type, and optionally year"""
for cal in cls.CALENDARS:
if (cal.city_id == city_id and
cal.school_type == school_type and
cal.enabled and
(academic_year is None or cal.academic_year == academic_year)):
return cal
return None
@classmethod
def to_dict(cls, calendar: CalendarDefinition) -> Dict[str, Any]:
"""Convert calendar definition to dictionary for JSON serialization"""
return {
"calendar_id": calendar.calendar_id,
"calendar_name": calendar.calendar_name,
"city_id": calendar.city_id,
"school_type": calendar.school_type.value,
"academic_year": calendar.academic_year,
"holiday_periods": [
{
"name": hp.name,
"start_date": hp.start_date,
"end_date": hp.end_date,
"description": hp.description
}
for hp in calendar.holiday_periods
],
"school_hours": {
"morning_start": calendar.school_hours.morning_start,
"morning_end": calendar.school_hours.morning_end,
"has_afternoon_session": calendar.school_hours.has_afternoon_session,
"afternoon_start": calendar.school_hours.afternoon_start,
"afternoon_end": calendar.school_hours.afternoon_end,
},
"source": calendar.source,
"enabled": calendar.enabled
}
# Local Events Registry for Madrid
@dataclass
class LocalEventDefinition:
"""Local event that impacts demand"""
event_id: str
name: str
city_id: str
date: str # ISO format or "annual-MM-DD" for recurring
impact_level: str # "low", "medium", "high"
description: Optional[str] = None
recurring: bool = False # True for annual events
class LocalEventsRegistry:
"""Registry of local events and festivals"""
MADRID_EVENTS = [
LocalEventDefinition(
event_id="madrid_san_isidro",
name="San Isidro Festival",
city_id="madrid",
date="annual-05-15",
impact_level="high",
description="Madrid's patron saint festival - major citywide celebration",
recurring=True
),
LocalEventDefinition(
event_id="madrid_dos_de_mayo",
name="Dos de Mayo",
city_id="madrid",
date="annual-05-02",
impact_level="medium",
description="Madrid regional holiday",
recurring=True
),
LocalEventDefinition(
event_id="madrid_almudena",
name="Virgen de la Almudena",
city_id="madrid",
date="annual-11-09",
impact_level="medium",
description="Madrid patron saint day",
recurring=True
),
]
@classmethod
def get_events_for_city(cls, city_id: str) -> List[LocalEventDefinition]:
"""Get all local events for a city"""
if city_id == "madrid":
return cls.MADRID_EVENTS
return []

View File

@@ -0,0 +1,163 @@
# services/external/app/registry/city_registry.py
"""
City Registry - Configuration-driven multi-city support
"""
from dataclasses import dataclass
from typing import List, Optional, Dict, Any
from enum import Enum
import math
class Country(str, Enum):
SPAIN = "ES"
FRANCE = "FR"
class WeatherProvider(str, Enum):
AEMET = "aemet"
METEO_FRANCE = "meteo_france"
OPEN_WEATHER = "open_weather"
class TrafficProvider(str, Enum):
MADRID_OPENDATA = "madrid_opendata"
VALENCIA_OPENDATA = "valencia_opendata"
BARCELONA_OPENDATA = "barcelona_opendata"
@dataclass
class CityDefinition:
"""City configuration with data source specifications"""
city_id: str
name: str
country: Country
latitude: float
longitude: float
radius_km: float
weather_provider: WeatherProvider
weather_config: Dict[str, Any]
traffic_provider: TrafficProvider
traffic_config: Dict[str, Any]
timezone: str
population: int
enabled: bool = True
class CityRegistry:
"""Central registry of supported cities"""
CITIES: List[CityDefinition] = [
CityDefinition(
city_id="madrid",
name="Madrid",
country=Country.SPAIN,
latitude=40.4168,
longitude=-3.7038,
radius_km=30.0,
weather_provider=WeatherProvider.AEMET,
weather_config={
"station_ids": ["3195", "3129", "3197"],
"municipality_code": "28079"
},
traffic_provider=TrafficProvider.MADRID_OPENDATA,
traffic_config={
"current_xml_url": "https://datos.madrid.es/egob/catalogo/...",
"historical_base_url": "https://datos.madrid.es/...",
"measurement_points_csv": "https://datos.madrid.es/..."
},
timezone="Europe/Madrid",
population=3_200_000
),
CityDefinition(
city_id="valencia",
name="Valencia",
country=Country.SPAIN,
latitude=39.4699,
longitude=-0.3763,
radius_km=25.0,
weather_provider=WeatherProvider.AEMET,
weather_config={
"station_ids": ["8416"],
"municipality_code": "46250"
},
traffic_provider=TrafficProvider.VALENCIA_OPENDATA,
traffic_config={
"api_endpoint": "https://valencia.opendatasoft.com/api/..."
},
timezone="Europe/Madrid",
population=800_000,
enabled=False
),
CityDefinition(
city_id="barcelona",
name="Barcelona",
country=Country.SPAIN,
latitude=41.3851,
longitude=2.1734,
radius_km=30.0,
weather_provider=WeatherProvider.AEMET,
weather_config={
"station_ids": ["0076"],
"municipality_code": "08019"
},
traffic_provider=TrafficProvider.BARCELONA_OPENDATA,
traffic_config={
"api_endpoint": "https://opendata-ajuntament.barcelona.cat/..."
},
timezone="Europe/Madrid",
population=1_600_000,
enabled=False
)
]
@classmethod
def get_enabled_cities(cls) -> List[CityDefinition]:
"""Get all enabled cities"""
return [city for city in cls.CITIES if city.enabled]
@classmethod
def get_city(cls, city_id: str) -> Optional[CityDefinition]:
"""Get city by ID"""
for city in cls.CITIES:
if city.city_id == city_id:
return city
return None
@classmethod
def find_nearest_city(cls, latitude: float, longitude: float) -> Optional[CityDefinition]:
"""Find nearest enabled city to coordinates"""
enabled_cities = cls.get_enabled_cities()
if not enabled_cities:
return None
min_distance = float('inf')
nearest_city = None
for city in enabled_cities:
distance = cls._haversine_distance(
latitude, longitude,
city.latitude, city.longitude
)
if distance <= city.radius_km and distance < min_distance:
min_distance = distance
nearest_city = city
return nearest_city
@staticmethod
def _haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Calculate distance in km between two coordinates"""
R = 6371
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat/2) ** 2 +
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
math.sin(dlon/2) ** 2)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
return R * c

View File

@@ -0,0 +1,58 @@
# services/external/app/registry/geolocation_mapper.py
"""
Geolocation Mapper - Maps tenant locations to cities
"""
from typing import Optional, Tuple
import structlog
from .city_registry import CityRegistry, CityDefinition
logger = structlog.get_logger()
class GeolocationMapper:
"""Maps tenant coordinates to nearest supported city"""
def __init__(self):
self.registry = CityRegistry()
def map_tenant_to_city(
self,
latitude: float,
longitude: float
) -> Optional[Tuple[CityDefinition, float]]:
"""
Map tenant coordinates to nearest city
Returns:
Tuple of (CityDefinition, distance_km) or None if no match
"""
nearest_city = self.registry.find_nearest_city(latitude, longitude)
if not nearest_city:
logger.warning(
"No supported city found for coordinates",
lat=latitude,
lon=longitude
)
return None
distance = self.registry._haversine_distance(
latitude, longitude,
nearest_city.latitude, nearest_city.longitude
)
logger.info(
"Mapped tenant to city",
lat=latitude,
lon=longitude,
city=nearest_city.name,
distance_km=round(distance, 2)
)
return (nearest_city, distance)
def validate_location_support(self, latitude: float, longitude: float) -> bool:
"""Check if coordinates are supported"""
result = self.map_tenant_to_city(latitude, longitude)
return result is not None