# shared/clients/external_client.py """ External Service Client Handles all API calls to the external service (weather and traffic data) """ import httpx import structlog from typing import Dict, Any, Optional, List from .base_service_client import BaseServiceClient from shared.config.base import BaseServiceSettings logger = structlog.get_logger() class ExternalServiceClient(BaseServiceClient): """Client for communicating with the external service""" def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"): super().__init__(calling_service_name, config) self.service_url = config.EXTERNAL_SERVICE_URL def get_service_base_path(self) -> str: return "/api/v1" # ================================================================ # WEATHER DATA # ================================================================ async def get_weather_historical( self, tenant_id: str, start_date: str, end_date: str, latitude: Optional[float] = None, longitude: Optional[float] = None ) -> Optional[List[Dict[str, Any]]]: """ Get historical weather data using NEW v2.0 optimized city-based endpoint This uses pre-loaded data from the database with Redis caching for <100ms response times """ # Prepare query parameters params = { "latitude": latitude or 40.4168, # Default Madrid coordinates "longitude": longitude or -3.7038, "start_date": start_date, # ISO format datetime "end_date": end_date # ISO format datetime } logger.info(f"Weather request (v2.0 optimized): {params}", tenant_id=tenant_id) # Use GET request to new optimized endpoint with short timeout (data is cached) result = await self._make_request( "GET", "external/operations/historical-weather-optimized", tenant_id=tenant_id, params=params, timeout=10.0 # Much shorter - data is pre-loaded and cached ) if result: logger.info(f"Successfully fetched {len(result)} weather records from v2.0 endpoint") return result else: logger.warning("No weather data returned from v2.0 endpoint") return [] async def get_current_weather( self, tenant_id: str, latitude: Optional[float] = None, longitude: Optional[float] = None ) -> Optional[Dict[str, Any]]: """ Get current weather for a location (real-time data) Uses new v2.0 endpoint """ params = { "latitude": latitude or 40.4168, "longitude": longitude or -3.7038 } logger.info(f"Current weather request (v2.0): {params}", tenant_id=tenant_id) result = await self._make_request( "GET", "external/operations/weather/current", tenant_id=tenant_id, params=params, timeout=10.0 ) if result: logger.info("Successfully fetched current weather") return result else: logger.warning("No current weather data available") return None async def get_weather_forecast( self, tenant_id: str, days: int = 7, latitude: Optional[float] = None, longitude: Optional[float] = None ) -> Optional[List[Dict[str, Any]]]: """ Get weather forecast for location (from AEMET) Uses new v2.0 endpoint """ params = { "latitude": latitude or 40.4168, "longitude": longitude or -3.7038, "days": days } logger.info(f"Weather forecast request (v2.0): {params}", tenant_id=tenant_id) result = await self._make_request( "GET", "external/operations/weather/forecast", tenant_id=tenant_id, params=params, timeout=10.0 ) if result: logger.info(f"Successfully fetched weather forecast for {days} days") return result else: logger.warning("No forecast data available") return [] # ================================================================ # TRAFFIC DATA # ================================================================ async def get_traffic_data( self, tenant_id: str, start_date: str, end_date: str, latitude: Optional[float] = None, longitude: Optional[float] = None ) -> Optional[List[Dict[str, Any]]]: """ Get historical traffic data using NEW v2.0 optimized city-based endpoint This uses pre-loaded data from the database with Redis caching for <100ms response times """ # Prepare query parameters params = { "latitude": latitude or 40.4168, # Default Madrid coordinates "longitude": longitude or -3.7038, "start_date": start_date, # ISO format datetime "end_date": end_date # ISO format datetime } logger.info(f"Traffic request (v2.0 optimized): {params}", tenant_id=tenant_id) # Use GET request to new optimized endpoint with short timeout (data is cached) result = await self._make_request( "GET", "external/operations/historical-traffic-optimized", tenant_id=tenant_id, params=params, timeout=10.0 # Much shorter - data is pre-loaded and cached ) if result: logger.info(f"Successfully fetched {len(result)} traffic records from v2.0 endpoint") return result else: logger.warning("No traffic data returned from v2.0 endpoint") return [] async def get_stored_traffic_data_for_training( self, tenant_id: str, start_date: str, end_date: str, latitude: Optional[float] = None, longitude: Optional[float] = None ) -> Optional[List[Dict[str, Any]]]: """ Get stored traffic data for model training/re-training In v2.0, this uses the same optimized endpoint as get_traffic_data since all data is pre-loaded and cached """ logger.info("Training traffic data request - delegating to optimized endpoint", tenant_id=tenant_id) # Delegate to the same optimized endpoint return await self.get_traffic_data( tenant_id=tenant_id, start_date=start_date, end_date=end_date, latitude=latitude, longitude=longitude ) async def get_current_traffic( self, tenant_id: str, latitude: Optional[float] = None, longitude: Optional[float] = None ) -> Optional[Dict[str, Any]]: """ Get current traffic conditions for a location (real-time data) Uses new v2.0 endpoint """ params = { "latitude": latitude or 40.4168, "longitude": longitude or -3.7038 } logger.info(f"Current traffic request (v2.0): {params}", tenant_id=tenant_id) result = await self._make_request( "GET", "external/operations/traffic/current", tenant_id=tenant_id, params=params, timeout=10.0 ) if result: logger.info("Successfully fetched current traffic") return result else: logger.warning("No current traffic data available") return None # ================================================================ # CALENDAR DATA (School Calendars and Hyperlocal Information) # ================================================================ async def get_tenant_location_context( self, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Get tenant location context including school calendar assignment """ logger.info("Fetching tenant location context", tenant_id=tenant_id) result = await self._make_request( "GET", "external/location-context", tenant_id=tenant_id, timeout=5.0 ) if result: logger.info("Successfully fetched tenant location context", tenant_id=tenant_id) return result else: logger.info("No location context found for tenant", tenant_id=tenant_id) return None async def create_tenant_location_context( self, tenant_id: str, city_id: str, school_calendar_id: Optional[str] = None, neighborhood: Optional[str] = None, local_events: Optional[List[Dict[str, Any]]] = None, notes: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Create or update location context for a tenant. This establishes the city association for a tenant and optionally assigns a school calendar. Typically called during tenant registration to set up location-based context for ML features. Args: tenant_id: Tenant UUID city_id: Normalized city ID (e.g., "madrid", "barcelona") school_calendar_id: Optional school calendar UUID to assign neighborhood: Optional neighborhood name local_events: Optional list of local events with impact data notes: Optional notes about the location context Returns: Dict with created location context including nested calendar details, or None if creation failed """ payload = {"city_id": city_id} if school_calendar_id: payload["school_calendar_id"] = school_calendar_id if neighborhood: payload["neighborhood"] = neighborhood if local_events: payload["local_events"] = local_events if notes: payload["notes"] = notes logger.info( "Creating tenant location context", tenant_id=tenant_id, city_id=city_id, has_calendar=bool(school_calendar_id) ) result = await self._make_request( "POST", "external/location-context", tenant_id=tenant_id, json=payload, timeout=10.0 ) if result: logger.info( "Successfully created tenant location context", tenant_id=tenant_id, city_id=city_id ) return result else: logger.warning( "Failed to create tenant location context", tenant_id=tenant_id, city_id=city_id ) return None async def suggest_calendar_for_tenant( self, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Get smart calendar suggestion for a tenant based on POI data and location. Analyzes tenant's location context, nearby schools from POI detection, and available calendars to provide an intelligent suggestion with confidence score and reasoning. Args: tenant_id: Tenant UUID Returns: Dict with: - suggested_calendar_id: Suggested calendar UUID - calendar_name: Name of suggested calendar - confidence: Float 0.0-1.0 - confidence_percentage: Percentage format - reasoning: List of reasoning steps - fallback_calendars: Alternative suggestions - should_auto_assign: Boolean recommendation - admin_message: Formatted message for display - school_analysis: Analysis of nearby schools Or None if request failed """ logger.info("Requesting calendar suggestion", tenant_id=tenant_id) result = await self._make_request( "POST", "external/location-context/suggest-calendar", tenant_id=tenant_id, timeout=10.0 ) if result: confidence = result.get("confidence_percentage", 0) suggested = result.get("calendar_name", "None") logger.info( "Calendar suggestion received", tenant_id=tenant_id, suggested_calendar=suggested, confidence=confidence ) return result else: logger.warning( "Failed to get calendar suggestion", tenant_id=tenant_id ) return None async def get_school_calendar( self, calendar_id: str, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Get school calendar details by ID """ logger.info("Fetching school calendar", calendar_id=calendar_id, tenant_id=tenant_id) result = await self._make_request( "GET", f"external/operations/school-calendars/{calendar_id}", tenant_id=tenant_id, timeout=5.0 ) if result: logger.info("Successfully fetched school calendar", calendar_id=calendar_id) return result else: logger.warning("School calendar not found", calendar_id=calendar_id) return None async def check_is_school_holiday( self, calendar_id: str, check_date: str, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Check if a specific date is a school holiday Args: calendar_id: School calendar UUID check_date: Date to check in ISO format (YYYY-MM-DD) tenant_id: Tenant ID for auth Returns: Dict with is_holiday, holiday_name, etc. """ params = {"check_date": check_date} logger.debug( "Checking school holiday status", calendar_id=calendar_id, date=check_date, tenant_id=tenant_id ) result = await self._make_request( "GET", f"external/operations/school-calendars/{calendar_id}/is-holiday", tenant_id=tenant_id, params=params, timeout=5.0 ) return result async def get_city_school_calendars( self, city_id: str, tenant_id: str, school_type: Optional[str] = None, academic_year: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Get all school calendars for a city with optional filters Args: city_id: City ID (e.g., "madrid") tenant_id: Tenant ID for auth school_type: Optional filter by school type academic_year: Optional filter by academic year Returns: Dict with calendars list and total count """ params = {} if school_type: params["school_type"] = school_type if academic_year: params["academic_year"] = academic_year logger.info( "Fetching school calendars for city", city_id=city_id, tenant_id=tenant_id, filters=params ) result = await self._make_request( "GET", f"external/operations/cities/{city_id}/school-calendars", tenant_id=tenant_id, params=params if params else None, timeout=5.0 ) if result: logger.info( "Successfully fetched school calendars", city_id=city_id, total=result.get("total", 0) ) return result else: logger.warning("No school calendars found for city", city_id=city_id) return None # ================================================================ # POI (POINT OF INTEREST) DATA # ================================================================ async def get_poi_context( self, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Get POI context for a tenant including ML features for forecasting. With the new tenant-based architecture: - Gateway receives at: /api/v1/tenants/{tenant_id}/external/poi-context - Gateway proxies to external service at: /api/v1/tenants/{tenant_id}/poi-context - This client calls: /tenants/{tenant_id}/poi-context This retrieves stored POI detection results and calculated ML features that should be included in demand forecasting predictions. Args: tenant_id: Tenant ID Returns: Dict with POI context including: - ml_features: Dict of POI features for ML models (e.g., poi_retail_total_count) - poi_detection_results: Full detection results - location: Latitude/longitude - total_pois_detected: Count of POIs """ logger.info("Fetching POI context for forecasting", tenant_id=tenant_id) # Updated endpoint path to follow tenant-based pattern: /tenants/{tenant_id}/poi-context result = await self._make_request( "GET", f"tenants/{tenant_id}/poi-context", # Updated path: /tenants/{tenant_id}/poi-context tenant_id=tenant_id, # Pass tenant_id to include in headers for authentication timeout=5.0 ) if result: logger.info( "Successfully fetched POI context", tenant_id=tenant_id, total_pois=result.get("total_pois_detected", 0), ml_features_count=len(result.get("ml_features", {})) ) return result else: logger.info("No POI context found for tenant", tenant_id=tenant_id) return None