# services/external/app/api/city_operations.py """ City Operations API - New endpoints for city-based data access """ from fastapi import APIRouter, Depends, HTTPException, Query, Path from typing import List from datetime import datetime from uuid import UUID import structlog from app.schemas.city_data import CityInfoResponse, DataAvailabilityResponse from app.schemas.weather import WeatherDataResponse, WeatherForecastResponse, WeatherForecastAPIResponse from app.schemas.traffic import TrafficDataResponse from app.registry.city_registry import CityRegistry from app.registry.geolocation_mapper import GeolocationMapper from app.repositories.city_data_repository import CityDataRepository from app.cache.redis_wrapper import ExternalDataCache from app.services.weather_service import WeatherService from app.services.traffic_service import TrafficService from app.services.tenant_deletion_service import ExternalTenantDeletionService from shared.routing.route_builder import RouteBuilder from shared.auth.decorators import get_current_user_dep from shared.auth.access_control import service_only_access from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db route_builder = RouteBuilder('external') router = APIRouter(tags=["city-operations"]) logger = structlog.get_logger() @router.get( route_builder.build_base_route("cities"), response_model=List[CityInfoResponse] ) async def list_supported_cities(): """List all enabled cities with data availability""" registry = CityRegistry() cities = registry.get_enabled_cities() return [ CityInfoResponse( city_id=city.city_id, name=city.name, country=city.country.value, latitude=city.latitude, longitude=city.longitude, radius_km=city.radius_km, weather_provider=city.weather_provider.value, traffic_provider=city.traffic_provider.value, enabled=city.enabled ) for city in cities ] @router.get( route_builder.build_operations_route("cities/{city_id}/availability"), response_model=DataAvailabilityResponse ) async def get_city_data_availability( city_id: str = Path(..., description="City ID"), db: AsyncSession = Depends(get_db) ): """Get data availability for a specific city""" registry = CityRegistry() city = registry.get_city(city_id) if not city: raise HTTPException(status_code=404, detail="City not found") from sqlalchemy import text weather_stmt = text( "SELECT MIN(date), MAX(date), COUNT(*) FROM city_weather_data WHERE city_id = :city_id" ) weather_result = await db.execute(weather_stmt, {"city_id": city_id}) weather_row = weather_result.fetchone() weather_min, weather_max, weather_count = weather_row if weather_row else (None, None, 0) traffic_stmt = text( "SELECT MIN(date), MAX(date), COUNT(*) FROM city_traffic_data WHERE city_id = :city_id" ) traffic_result = await db.execute(traffic_stmt, {"city_id": city_id}) traffic_row = traffic_result.fetchone() traffic_min, traffic_max, traffic_count = traffic_row if traffic_row else (None, None, 0) return DataAvailabilityResponse( city_id=city_id, city_name=city.name, weather_available=weather_count > 0, weather_start_date=weather_min.isoformat() if weather_min else None, weather_end_date=weather_max.isoformat() if weather_max else None, weather_record_count=weather_count or 0, traffic_available=traffic_count > 0, traffic_start_date=traffic_min.isoformat() if traffic_min else None, traffic_end_date=traffic_max.isoformat() if traffic_max else None, traffic_record_count=traffic_count or 0 ) @router.get( route_builder.build_operations_route("historical-weather-optimized"), response_model=List[WeatherDataResponse] ) async def get_historical_weather_optimized( tenant_id: UUID = Path(..., description="Tenant ID"), latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), start_date: datetime = Query(..., description="Start date"), end_date: datetime = Query(..., description="End date"), db: AsyncSession = Depends(get_db) ): """ Get historical weather data using city-based cached data This is the FAST endpoint for training service """ try: mapper = GeolocationMapper() mapping = mapper.map_tenant_to_city(latitude, longitude) if not mapping: raise HTTPException( status_code=404, detail="No supported city found for this location" ) city, distance = mapping logger.info( "Fetching historical weather from cache", tenant_id=tenant_id, city=city.name, distance_km=round(distance, 2) ) cache = ExternalDataCache() cached_data = await cache.get_cached_weather( city.city_id, start_date, end_date ) if cached_data: logger.info("Weather cache hit", records=len(cached_data)) return cached_data repo = CityDataRepository(db) db_records = await repo.get_weather_by_city_and_range( city.city_id, start_date, end_date ) response_data = [ WeatherDataResponse( id=str(record.id), location_id=f"{city.city_id}_{record.date.date()}", date=record.date, temperature=record.temperature, precipitation=record.precipitation, humidity=record.humidity, wind_speed=record.wind_speed, pressure=record.pressure, description=record.description, source=record.source, raw_data=None, created_at=record.created_at, updated_at=record.updated_at ) for record in db_records ] await cache.set_cached_weather( city.city_id, start_date, end_date, response_data ) logger.info( "Historical weather data retrieved", records=len(response_data), source="database" ) return response_data except HTTPException: raise except Exception as e: logger.error("Error fetching historical weather", error=str(e)) raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_operations_route("historical-traffic-optimized"), response_model=List[TrafficDataResponse] ) async def get_historical_traffic_optimized( tenant_id: UUID = Path(..., description="Tenant ID"), latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), start_date: datetime = Query(..., description="Start date"), end_date: datetime = Query(..., description="End date"), db: AsyncSession = Depends(get_db) ): """ Get historical traffic data using city-based cached data This is the FAST endpoint for training service """ try: mapper = GeolocationMapper() mapping = mapper.map_tenant_to_city(latitude, longitude) if not mapping: raise HTTPException( status_code=404, detail="No supported city found for this location" ) city, distance = mapping logger.info( "Fetching historical traffic from cache", tenant_id=tenant_id, city=city.name, distance_km=round(distance, 2) ) cache = ExternalDataCache() cached_data = await cache.get_cached_traffic( city.city_id, start_date, end_date ) if cached_data: logger.info("Traffic cache hit", records=len(cached_data)) return cached_data logger.debug("Starting DB query for traffic", city_id=city.city_id) repo = CityDataRepository(db) db_records = await repo.get_traffic_by_city_and_range( city.city_id, start_date, end_date ) logger.debug("DB query completed", records=len(db_records)) logger.debug("Creating response objects") response_data = [ TrafficDataResponse( date=record.date, traffic_volume=record.traffic_volume, pedestrian_count=record.pedestrian_count, congestion_level=record.congestion_level, average_speed=record.average_speed, source=record.source ) for record in db_records ] logger.debug("Response objects created", count=len(response_data)) logger.debug("Caching traffic data") await cache.set_cached_traffic( city.city_id, start_date, end_date, response_data ) logger.debug("Caching completed") logger.info( "Historical traffic data retrieved", records=len(response_data), source="database" ) return response_data except HTTPException: raise except Exception as e: logger.error("Error fetching historical traffic", error=str(e)) raise HTTPException(status_code=500, detail="Internal server error") # ================================================================ # REAL-TIME & FORECAST ENDPOINTS # ================================================================ @router.get( route_builder.build_operations_route("weather/current"), response_model=WeatherDataResponse ) async def get_current_weather( tenant_id: UUID = Path(..., description="Tenant ID"), latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude") ): """ Get current weather for a location (real-time data from AEMET) """ try: weather_service = WeatherService() weather_data = await weather_service.get_current_weather(latitude, longitude) if not weather_data: raise HTTPException( status_code=404, detail="No weather data available for this location" ) logger.info( "Current weather retrieved", tenant_id=tenant_id, latitude=latitude, longitude=longitude ) return weather_data except HTTPException: raise except Exception as e: logger.error("Error fetching current weather", error=str(e)) raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_operations_route("weather/forecast") ) async def get_weather_forecast( tenant_id: UUID = Path(..., description="Tenant ID"), latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude"), days: int = Query(7, ge=1, le=14, description="Number of days to forecast") ): """ Get weather forecast for a location (from AEMET) Returns list of forecast objects with: forecast_date, generated_at, temperature, precipitation, humidity, wind_speed, description, source """ try: weather_service = WeatherService() forecast_data = await weather_service.get_weather_forecast(latitude, longitude, days) if not forecast_data: raise HTTPException( status_code=404, detail="No forecast data available for this location" ) logger.info( "Weather forecast retrieved", tenant_id=tenant_id, latitude=latitude, longitude=longitude, days=days, count=len(forecast_data) ) return forecast_data except HTTPException: raise except Exception as e: logger.error("Error fetching weather forecast", error=str(e)) raise HTTPException(status_code=500, detail="Internal server error") @router.get( route_builder.build_operations_route("traffic/current"), response_model=TrafficDataResponse ) async def get_current_traffic( tenant_id: UUID = Path(..., description="Tenant ID"), latitude: float = Query(..., description="Latitude"), longitude: float = Query(..., description="Longitude") ): """ Get current traffic conditions for a location (real-time data from Madrid OpenData) """ try: traffic_service = TrafficService() traffic_data = await traffic_service.get_current_traffic(latitude, longitude) if not traffic_data: raise HTTPException( status_code=404, detail="No traffic data available for this location" ) logger.info( "Current traffic retrieved", tenant_id=tenant_id, latitude=latitude, longitude=longitude ) return traffic_data except HTTPException: raise except Exception as e: logger.error("Error fetching current traffic", error=str(e)) raise HTTPException(status_code=500, detail="Internal server error") # ============================================================================ # Tenant Data Deletion Operations (Internal Service Only) # ============================================================================ @router.delete( route_builder.build_base_route("tenant/{tenant_id}", include_tenant_prefix=False), response_model=dict ) @service_only_access async def delete_tenant_data( tenant_id: str = Path(..., description="Tenant ID to delete data for"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Delete tenant-specific external data (Internal service only) IMPORTANT NOTE: The External service primarily stores SHARED city-wide data that is used by ALL tenants. This endpoint only deletes tenant-specific data: - Tenant-specific audit logs - Tenant-specific weather data (if any) City-wide data (CityWeatherData, CityTrafficData, TrafficData, etc.) is intentionally PRESERVED as it's shared across all tenants. **WARNING**: This operation is irreversible! Returns: Deletion summary with counts of deleted records and note about preserved data """ try: logger.info("external.tenant_deletion.api_called", tenant_id=tenant_id) deletion_service = ExternalTenantDeletionService(db) result = await deletion_service.safe_delete_tenant_data(tenant_id) if not result.success: raise HTTPException( status_code=500, detail=f"Tenant data deletion failed: {', '.join(result.errors)}" ) return { "message": "Tenant-specific data deletion completed successfully", "note": "City-wide shared data (weather, traffic) has been preserved", "summary": result.to_dict() } except HTTPException: raise except Exception as e: logger.error("external.tenant_deletion.api_error", tenant_id=tenant_id, error=str(e), exc_info=True) raise HTTPException( status_code=500, detail=f"Failed to delete tenant data: {str(e)}" ) @router.get( route_builder.build_base_route("tenant/{tenant_id}/deletion-preview", include_tenant_prefix=False), response_model=dict ) @service_only_access async def preview_tenant_data_deletion( tenant_id: str = Path(..., description="Tenant ID to preview deletion for"), current_user: dict = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """ Preview what tenant-specific data would be deleted (dry-run) This shows counts of tenant-specific data only. City-wide shared data (CityWeatherData, CityTrafficData, TrafficData, etc.) will NOT be deleted. Returns: Dictionary with entity names and their counts """ try: logger.info("external.tenant_deletion.preview_called", tenant_id=tenant_id) deletion_service = ExternalTenantDeletionService(db) preview = await deletion_service.get_tenant_data_preview(tenant_id) total_records = sum(v for k, v in preview.items() if not k.startswith("_")) return { "tenant_id": tenant_id, "service": "external", "preview": preview, "total_records": total_records, "note": "City-wide data (weather, traffic) is shared and will NOT be deleted", "preserved_data": [ "CityWeatherData (city-wide)", "CityTrafficData (city-wide)", "TrafficData (city-wide)", "TrafficMeasurementPoint (reference data)", "WeatherForecast (city-wide)" ], "warning": "Only tenant-specific records will be permanently deleted" } except Exception as e: logger.error("external.tenant_deletion.preview_error", tenant_id=tenant_id, error=str(e), exc_info=True) raise HTTPException( status_code=500, detail=f"Failed to preview tenant data deletion: {str(e)}" )