REFACTOR ALL APIs

This commit is contained in:
Urtzi Alfaro
2025-10-06 15:27:01 +02:00
parent dc8221bd2f
commit 38fb98bc27
166 changed files with 18454 additions and 13605 deletions

View File

@@ -0,0 +1,407 @@
# services/external/app/api/external_operations.py
"""
External Operations API - Business operations for fetching external data
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Dict, Any
from datetime import datetime
from uuid import UUID
import structlog
from app.schemas.weather import (
WeatherDataResponse,
WeatherForecastResponse,
WeatherForecastRequest,
HistoricalWeatherRequest,
HourlyForecastRequest,
HourlyForecastResponse
)
from app.schemas.traffic import (
TrafficDataResponse,
TrafficForecastRequest,
HistoricalTrafficRequest
)
from app.services.weather_service import WeatherService
from app.services.traffic_service import TrafficService
from app.services.messaging import publish_weather_updated, publish_traffic_updated
from shared.auth.decorators import get_current_user_dep
from shared.auth.access_control import require_user_role
from shared.routing.route_builder import RouteBuilder
route_builder = RouteBuilder('external')
router = APIRouter(tags=["external-operations"])
logger = structlog.get_logger()
def get_weather_service():
"""Dependency injection for WeatherService"""
return WeatherService()
def get_traffic_service():
"""Dependency injection for TrafficService"""
return TrafficService()
# Weather Operations
@router.get(
route_builder.build_operations_route("weather/current"),
response_model=WeatherDataResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_current_weather(
latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
weather_service: WeatherService = Depends(get_weather_service)
):
"""Get current weather data for location from external API"""
try:
logger.debug("Getting current weather",
lat=latitude,
lon=longitude,
tenant_id=tenant_id,
user_id=current_user["user_id"])
weather = await weather_service.get_current_weather(latitude, longitude)
if not weather:
raise HTTPException(status_code=503, detail="Weather service temporarily unavailable")
try:
await publish_weather_updated({
"type": "current_weather_requested",
"tenant_id": str(tenant_id),
"latitude": latitude,
"longitude": longitude,
"requested_by": current_user["user_id"],
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish weather event", error=str(e))
return weather
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get current weather", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post(
route_builder.build_operations_route("weather/historical"),
response_model=List[WeatherDataResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_historical_weather(
request: HistoricalWeatherRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
weather_service: WeatherService = Depends(get_weather_service)
):
"""Get historical weather data with date range"""
try:
if request.end_date <= request.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
if (request.end_date - request.start_date).days > 1000:
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
historical_data = await weather_service.get_historical_weather(
request.latitude, request.longitude, request.start_date, request.end_date)
try:
await publish_weather_updated({
"type": "historical_requested",
"latitude": request.latitude,
"longitude": request.longitude,
"start_date": request.start_date.isoformat(),
"end_date": request.end_date.isoformat(),
"records_count": len(historical_data),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as pub_error:
logger.warning("Failed to publish historical weather event", error=str(pub_error))
return historical_data
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error in historical weather API", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post(
route_builder.build_operations_route("weather/forecast"),
response_model=List[WeatherForecastResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_weather_forecast(
request: WeatherForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
weather_service: WeatherService = Depends(get_weather_service)
):
"""Get weather forecast for location"""
try:
logger.debug("Getting weather forecast",
lat=request.latitude,
lon=request.longitude,
days=request.days,
tenant_id=tenant_id)
forecast = await weather_service.get_weather_forecast(request.latitude, request.longitude, request.days)
if not forecast:
logger.info("Weather forecast unavailable - returning empty list")
return []
try:
await publish_weather_updated({
"type": "forecast_requested",
"tenant_id": str(tenant_id),
"latitude": request.latitude,
"longitude": request.longitude,
"days": request.days,
"requested_by": current_user["user_id"],
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish forecast event", error=str(e))
return forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post(
route_builder.build_operations_route("weather/hourly-forecast"),
response_model=List[HourlyForecastResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_hourly_weather_forecast(
request: HourlyForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
weather_service: WeatherService = Depends(get_weather_service)
):
"""Get hourly weather forecast for location"""
try:
logger.debug("Getting hourly weather forecast",
lat=request.latitude,
lon=request.longitude,
hours=request.hours,
tenant_id=tenant_id)
hourly_forecast = await weather_service.get_hourly_forecast(
request.latitude, request.longitude, request.hours
)
if not hourly_forecast:
logger.info("Hourly weather forecast unavailable - returning empty list")
return []
try:
await publish_weather_updated({
"type": "hourly_forecast_requested",
"tenant_id": str(tenant_id),
"latitude": request.latitude,
"longitude": request.longitude,
"hours": request.hours,
"requested_by": current_user["user_id"],
"forecast_count": len(hourly_forecast),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish hourly forecast event", error=str(e))
return hourly_forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get hourly weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.get(
route_builder.build_operations_route("weather-status"),
response_model=dict
)
async def get_weather_status(
weather_service: WeatherService = Depends(get_weather_service)
):
"""Get weather API status and diagnostics"""
try:
aemet_status = "unknown"
aemet_message = "Not tested"
try:
test_weather = await weather_service.get_current_weather(40.4168, -3.7038)
if test_weather and hasattr(test_weather, 'source') and test_weather.source == "aemet":
aemet_status = "healthy"
aemet_message = "AEMET API responding correctly"
elif test_weather and hasattr(test_weather, 'source') and test_weather.source == "synthetic":
aemet_status = "degraded"
aemet_message = "Using synthetic weather data (AEMET API unavailable)"
else:
aemet_status = "unknown"
aemet_message = "Weather source unknown"
except Exception as test_error:
aemet_status = "unhealthy"
aemet_message = f"AEMET API test failed: {str(test_error)}"
return {
"status": aemet_status,
"message": aemet_message,
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
logger.error("Weather status check failed", error=str(e))
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")
# Traffic Operations
@router.get(
route_builder.build_operations_route("traffic/current"),
response_model=TrafficDataResponse
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_current_traffic(
latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
traffic_service: TrafficService = Depends(get_traffic_service)
):
"""Get current traffic data for location from external API"""
try:
logger.debug("Getting current traffic",
lat=latitude,
lon=longitude,
tenant_id=tenant_id,
user_id=current_user["user_id"])
traffic = await traffic_service.get_current_traffic(latitude, longitude)
if not traffic:
raise HTTPException(status_code=503, detail="Traffic service temporarily unavailable")
try:
await publish_traffic_updated({
"type": "current_traffic_requested",
"tenant_id": str(tenant_id),
"latitude": latitude,
"longitude": longitude,
"requested_by": current_user["user_id"],
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish traffic event", error=str(e))
return traffic
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get current traffic", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post(
route_builder.build_operations_route("traffic/historical"),
response_model=List[TrafficDataResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_historical_traffic(
request: HistoricalTrafficRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
traffic_service: TrafficService = Depends(get_traffic_service)
):
"""Get historical traffic data with date range"""
try:
if request.end_date <= request.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
historical_data = await traffic_service.get_historical_traffic(
request.latitude, request.longitude, request.start_date, request.end_date)
try:
await publish_traffic_updated({
"type": "historical_requested",
"latitude": request.latitude,
"longitude": request.longitude,
"start_date": request.start_date.isoformat(),
"end_date": request.end_date.isoformat(),
"records_count": len(historical_data),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as pub_error:
logger.warning("Failed to publish historical traffic event", error=str(pub_error))
return historical_data
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error in historical traffic API", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post(
route_builder.build_operations_route("traffic/forecast"),
response_model=List[TrafficDataResponse]
)
@require_user_role(['viewer', 'member', 'admin', 'owner'])
async def get_traffic_forecast(
request: TrafficForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
traffic_service: TrafficService = Depends(get_traffic_service)
):
"""Get traffic forecast for location"""
try:
logger.debug("Getting traffic forecast",
lat=request.latitude,
lon=request.longitude,
hours=request.hours,
tenant_id=tenant_id)
forecast = await traffic_service.get_traffic_forecast(request.latitude, request.longitude, request.hours)
if not forecast:
logger.info("Traffic forecast unavailable - returning empty list")
return []
try:
await publish_traffic_updated({
"type": "forecast_requested",
"tenant_id": str(tenant_id),
"latitude": request.latitude,
"longitude": request.longitude,
"hours": request.hours,
"requested_by": current_user["user_id"],
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish traffic forecast event", error=str(e))
return forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get traffic forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")

View File

@@ -1,184 +0,0 @@
# services/external/app/api/traffic.py
"""Traffic data API endpoints with improved error handling"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Dict, Any
from datetime import datetime, timedelta
import structlog
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.traffic_service import TrafficService
from app.services.messaging import publish_traffic_updated
from app.schemas.traffic import (
TrafficDataResponse,
HistoricalTrafficRequest,
TrafficForecastRequest
)
from shared.auth.decorators import (
get_current_user_dep
)
router = APIRouter(tags=["traffic"])
traffic_service = TrafficService()
logger = structlog.get_logger()
@router.get("/tenants/{tenant_id}/traffic/current", response_model=TrafficDataResponse)
async def get_current_traffic(
latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get current traffic data for location"""
try:
logger.debug("API: Getting current traffic", lat=latitude, lon=longitude)
traffic = await traffic_service.get_current_traffic(latitude, longitude)
if not traffic:
logger.warning("No traffic data available", lat=latitude, lon=longitude)
raise HTTPException(status_code=404, detail="Traffic data not available")
# Publish event (with error handling)
try:
await publish_traffic_updated({
"type": "current_requested",
"latitude": latitude,
"longitude": longitude,
"timestamp": datetime.utcnow().isoformat()
})
except Exception as pub_error:
logger.warning("Failed to publish traffic event", error=str(pub_error))
# Continue processing - event publishing failure shouldn't break the API
logger.debug("Successfully returning traffic data",
volume=traffic.get('traffic_volume') if isinstance(traffic, dict) else getattr(traffic, 'traffic_volume', None),
congestion=traffic.get('congestion_level') if isinstance(traffic, dict) else getattr(traffic, 'congestion_level', None))
return traffic
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error("Unexpected error in traffic API", error=str(e))
import traceback
logger.error("Traffic API traceback", traceback=traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/tenants/{tenant_id}/traffic/historical")
async def get_historical_traffic(
request: HistoricalTrafficRequest,
db: AsyncSession = Depends(get_db),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get historical traffic data with date range in payload"""
try:
# Validate date range
if request.end_date <= request.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
if (request.end_date - request.start_date).days > 1000:
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
historical_data = await traffic_service.get_historical_traffic(
request.latitude, request.longitude, request.start_date, request.end_date, str(tenant_id)
)
# Publish event (with error handling)
try:
await publish_traffic_updated({
"type": "historical_requested",
"latitude": request.latitude,
"longitude": request.longitude,
"start_date": request.start_date.isoformat(),
"end_date": request.end_date.isoformat(),
"records_count": len(historical_data),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as pub_error:
logger.warning("Failed to publish historical traffic event", error=str(pub_error))
# Continue processing
return historical_data
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error in historical traffic API", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/tenants/{tenant_id}/traffic/forecast")
async def get_traffic_forecast(
request: TrafficForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get traffic forecast for location"""
try:
logger.debug("API: Getting traffic forecast",
lat=request.latitude, lon=request.longitude, hours=request.hours)
# For now, return mock forecast data since we don't have a real traffic forecast service
# In a real implementation, this would call a traffic forecasting service
# Generate mock forecast data for the requested hours
forecast_data = []
from datetime import datetime, timedelta
base_time = datetime.utcnow()
for hour in range(request.hours):
forecast_time = base_time + timedelta(hours=hour)
# Mock traffic pattern (higher during rush hours)
hour_of_day = forecast_time.hour
if 7 <= hour_of_day <= 9 or 17 <= hour_of_day <= 19: # Rush hours
traffic_volume = 120
pedestrian_count = 80
congestion_level = "high"
average_speed = 15
elif 22 <= hour_of_day or hour_of_day <= 6: # Night hours
traffic_volume = 20
pedestrian_count = 10
congestion_level = "low"
average_speed = 50
else: # Regular hours
traffic_volume = 60
pedestrian_count = 40
congestion_level = "medium"
average_speed = 35
# Use consistent TrafficDataResponse format
forecast_data.append({
"date": forecast_time.isoformat(),
"traffic_volume": traffic_volume,
"pedestrian_count": pedestrian_count,
"congestion_level": congestion_level,
"average_speed": average_speed,
"source": "madrid_opendata_forecast"
})
# Publish event (with error handling)
try:
await publish_traffic_updated({
"type": "forecast_requested",
"latitude": request.latitude,
"longitude": request.longitude,
"hours": request.hours,
"timestamp": datetime.utcnow().isoformat()
})
except Exception as pub_error:
logger.warning("Failed to publish traffic forecast event", error=str(pub_error))
# Continue processing
logger.debug("Successfully returning traffic forecast", records=len(forecast_data))
return forecast_data
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error in traffic forecast API", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")

View File

@@ -0,0 +1,123 @@
# services/external/app/api/traffic_data.py
"""
Traffic Data API - Atomic CRUD operations on TrafficData model
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional
from datetime import date
from uuid import UUID
import structlog
from app.schemas.traffic import TrafficDataResponse
from app.services.traffic_service import TrafficService
from shared.routing.route_builder import RouteBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
route_builder = RouteBuilder('external')
router = APIRouter(tags=["traffic-data"])
logger = structlog.get_logger()
def get_traffic_service():
"""Dependency injection for TrafficService"""
return TrafficService()
@router.get(
route_builder.build_base_route("traffic-data"),
response_model=List[TrafficDataResponse]
)
async def list_traffic_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
latitude: Optional[float] = Query(None),
longitude: Optional[float] = Query(None),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
traffic_service: TrafficService = Depends(get_traffic_service)
):
"""List stored traffic data records"""
try:
logger.info("Listing traffic data", tenant_id=tenant_id)
traffic_records = await traffic_service.get_stored_traffic_data(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date,
latitude=latitude,
longitude=longitude,
limit=limit,
db=db
)
return traffic_records
except Exception as e:
logger.error("Failed to list traffic data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to retrieve traffic data")
@router.get(
route_builder.build_resource_detail_route("traffic-data", "traffic_id"),
response_model=TrafficDataResponse
)
async def get_traffic_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
traffic_id: UUID = Path(..., description="Traffic data ID"),
db: AsyncSession = Depends(get_db),
traffic_service: TrafficService = Depends(get_traffic_service)
):
"""Get a specific traffic data record"""
try:
logger.info("Getting traffic data", tenant_id=tenant_id, traffic_id=traffic_id)
traffic_record = await traffic_service.get_traffic_data_by_id(
tenant_id=tenant_id,
traffic_id=traffic_id,
db=db
)
if not traffic_record:
raise HTTPException(status_code=404, detail="Traffic data not found")
return traffic_record
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get traffic data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to retrieve traffic data")
@router.delete(
route_builder.build_resource_detail_route("traffic-data", "traffic_id")
)
async def delete_traffic_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
traffic_id: UUID = Path(..., description="Traffic data ID"),
db: AsyncSession = Depends(get_db),
traffic_service: TrafficService = Depends(get_traffic_service)
):
"""Delete a traffic data record"""
try:
logger.info("Deleting traffic data", tenant_id=tenant_id, traffic_id=traffic_id)
success = await traffic_service.delete_traffic_data(
tenant_id=tenant_id,
traffic_id=traffic_id,
db=db
)
if not success:
raise HTTPException(status_code=404, detail="Traffic data not found")
return {"message": "Traffic data deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete traffic data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to delete traffic data")

View File

@@ -1,255 +0,0 @@
# services/external/app/api/weather.py
"""
Weather API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, Path
from typing import List, Optional, Dict, Any
from datetime import datetime, date
import structlog
from uuid import UUID
from app.schemas.weather import (
WeatherDataResponse,
WeatherForecastResponse,
WeatherForecastRequest,
HistoricalWeatherRequest,
HourlyForecastRequest,
HourlyForecastResponse
)
from app.services.weather_service import WeatherService
from app.services.messaging import publish_weather_updated
# Import unified authentication from shared library
from shared.auth.decorators import (
get_current_user_dep
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
router = APIRouter(tags=["weather"])
logger = structlog.get_logger()
weather_service = WeatherService()
@router.get("/tenants/{tenant_id}/weather/current", response_model=WeatherDataResponse)
async def get_current_weather(
latitude: float = Query(..., description="Latitude"),
longitude: float = Query(..., description="Longitude"),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get current weather data for location"""
try:
logger.debug("Getting current weather",
lat=latitude,
lon=longitude,
tenant_id=tenant_id,
user_id=current_user["user_id"])
weather = await weather_service.get_current_weather(latitude, longitude)
if not weather:
raise HTTPException(status_code=503, detail="Weather service temporarily unavailable")
# Publish event
try:
await publish_weather_updated({
"type": "current_weather_requested",
"tenant_id": tenant_id,
"latitude": latitude,
"longitude": longitude,
"requested_by": current_user["user_id"],
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish weather event", error=str(e))
return weather
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get current weather", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/tenants/{tenant_id}/weather/historical")
async def get_historical_weather(
request: HistoricalWeatherRequest,
db: AsyncSession = Depends(get_db),
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get historical weather data with date range in payload"""
try:
# Validate date range
if request.end_date <= request.start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
if (request.end_date - request.start_date).days > 1000:
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
historical_data = await weather_service.get_historical_weather(
request.latitude, request.longitude, request.start_date, request.end_date)
# Publish event (with error handling)
try:
await publish_weather_updated({
"type": "historical_requested",
"latitude": request.latitude,
"longitude": request.longitude,
"start_date": request.start_date.isoformat(),
"end_date": request.end_date.isoformat(),
"records_count": len(historical_data),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as pub_error:
logger.warning("Failed to publish historical weather event", error=str(pub_error))
# Continue processing
return historical_data
except HTTPException:
raise
except Exception as e:
logger.error("Unexpected error in historical weather API", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/tenants/{tenant_id}/weather/forecast", response_model=List[WeatherForecastResponse])
async def get_weather_forecast(
request: WeatherForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get weather forecast for location"""
try:
logger.debug("Getting weather forecast",
lat=request.latitude,
lon=request.longitude,
days=request.days,
tenant_id=tenant_id)
forecast = await weather_service.get_weather_forecast(request.latitude, request.longitude, request.days)
# Don't return 404 for empty forecast - return empty list with 200 status
if not forecast:
logger.info("Weather forecast unavailable - returning empty list")
return []
# Publish event
try:
await publish_weather_updated({
"type": "forecast_requested",
"tenant_id": tenant_id,
"latitude": request.latitude,
"longitude": request.longitude,
"days": request.days,
"requested_by": current_user["user_id"],
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish forecast event", error=str(e))
return forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.post("/tenants/{tenant_id}/weather/hourly-forecast", response_model=List[HourlyForecastResponse])
async def get_hourly_weather_forecast(
request: HourlyForecastRequest,
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
):
"""Get hourly weather forecast for location using AEMET API
This endpoint provides hourly weather predictions for up to 48 hours,
perfect for detailed bakery operations planning and weather-based recommendations.
"""
try:
logger.debug("Getting hourly weather forecast",
lat=request.latitude,
lon=request.longitude,
hours=request.hours,
tenant_id=tenant_id)
hourly_forecast = await weather_service.get_hourly_forecast(
request.latitude, request.longitude, request.hours
)
# Don't return 404 for empty hourly forecast - return empty list with 200 status
if not hourly_forecast:
logger.info("Hourly weather forecast unavailable - returning empty list")
return []
# Publish event
try:
await publish_weather_updated({
"type": "hourly_forecast_requested",
"tenant_id": tenant_id,
"latitude": request.latitude,
"longitude": request.longitude,
"hours": request.hours,
"requested_by": current_user["user_id"],
"forecast_count": len(hourly_forecast),
"timestamp": datetime.utcnow().isoformat()
})
except Exception as e:
logger.warning("Failed to publish hourly forecast event", error=str(e))
return hourly_forecast
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get hourly weather forecast", error=str(e))
raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}")
@router.get("/weather/status")
async def get_weather_status():
"""Get AEMET API status and diagnostics"""
try:
from app.core.config import settings
# Test AEMET API connectivity
aemet_status = "unknown"
aemet_message = "Not tested"
try:
# Quick test of AEMET API
test_weather = await weather_service.get_current_weather(40.4168, -3.7038)
if test_weather and hasattr(test_weather, 'source') and test_weather.source == "aemet":
aemet_status = "healthy"
aemet_message = "AEMET API responding correctly"
elif test_weather and hasattr(test_weather, 'source') and test_weather.source == "synthetic":
aemet_status = "degraded"
aemet_message = "AEMET API unavailable - using synthetic data"
else:
aemet_status = "degraded"
aemet_message = "Weather service returned unexpected data format"
except Exception as e:
aemet_status = "unhealthy"
aemet_message = f"AEMET API error: {str(e)}"
return {
"status": "ok",
"aemet": {
"status": aemet_status,
"message": aemet_message,
"api_key_configured": bool(settings.AEMET_API_KEY),
"enabled": getattr(settings, 'AEMET_ENABLED', True),
"base_url": settings.AEMET_BASE_URL,
"timeout": settings.AEMET_TIMEOUT, # Now correctly shows 60 from config
"retry_attempts": settings.AEMET_RETRY_ATTEMPTS
},
"timestamp": datetime.utcnow().isoformat(),
"service": "external-weather-service"
}
except Exception as e:
logger.error("Failed to get weather status", error=str(e))
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")

View File

@@ -0,0 +1,123 @@
# services/external/app/api/weather_data.py
"""
Weather Data API - Atomic CRUD operations on WeatherData model
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Path
from typing import List, Optional
from datetime import date
from uuid import UUID
import structlog
from app.schemas.weather import WeatherDataResponse
from app.services.weather_service import WeatherService
from shared.routing.route_builder import RouteBuilder
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
route_builder = RouteBuilder('external')
router = APIRouter(tags=["weather-data"])
logger = structlog.get_logger()
def get_weather_service():
"""Dependency injection for WeatherService"""
return WeatherService()
@router.get(
route_builder.build_base_route("weather-data"),
response_model=List[WeatherDataResponse]
)
async def list_weather_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
start_date: Optional[date] = Query(None),
end_date: Optional[date] = Query(None),
latitude: Optional[float] = Query(None),
longitude: Optional[float] = Query(None),
limit: int = Query(100, ge=1, le=1000),
db: AsyncSession = Depends(get_db),
weather_service: WeatherService = Depends(get_weather_service)
):
"""List stored weather data records"""
try:
logger.info("Listing weather data", tenant_id=tenant_id)
weather_records = await weather_service.get_stored_weather_data(
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date,
latitude=latitude,
longitude=longitude,
limit=limit,
db=db
)
return weather_records
except Exception as e:
logger.error("Failed to list weather data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to retrieve weather data")
@router.get(
route_builder.build_resource_detail_route("weather-data", "weather_id"),
response_model=WeatherDataResponse
)
async def get_weather_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
weather_id: UUID = Path(..., description="Weather data ID"),
db: AsyncSession = Depends(get_db),
weather_service: WeatherService = Depends(get_weather_service)
):
"""Get a specific weather data record"""
try:
logger.info("Getting weather data", tenant_id=tenant_id, weather_id=weather_id)
weather_record = await weather_service.get_weather_data_by_id(
tenant_id=tenant_id,
weather_id=weather_id,
db=db
)
if not weather_record:
raise HTTPException(status_code=404, detail="Weather data not found")
return weather_record
except HTTPException:
raise
except Exception as e:
logger.error("Failed to get weather data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to retrieve weather data")
@router.delete(
route_builder.build_resource_detail_route("weather-data", "weather_id")
)
async def delete_weather_data(
tenant_id: UUID = Path(..., description="Tenant ID"),
weather_id: UUID = Path(..., description="Weather data ID"),
db: AsyncSession = Depends(get_db),
weather_service: WeatherService = Depends(get_weather_service)
):
"""Delete a weather data record"""
try:
logger.info("Deleting weather data", tenant_id=tenant_id, weather_id=weather_id)
success = await weather_service.delete_weather_data(
tenant_id=tenant_id,
weather_id=weather_id,
db=db
)
if not success:
raise HTTPException(status_code=404, detail="Weather data not found")
return {"message": "Weather data deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error("Failed to delete weather data", error=str(e), tenant_id=tenant_id)
raise HTTPException(status_code=500, detail="Failed to delete weather data")

View File

@@ -10,8 +10,7 @@ from app.core.database import database_manager
from app.services.messaging import setup_messaging, cleanup_messaging
from shared.service_base import StandardFastAPIService
# Include routers
from app.api.weather import router as weather_router
from app.api.traffic import router as traffic_router
from app.api import weather_data, traffic_data, external_operations
class ExternalService(StandardFastAPIService):
@@ -125,7 +124,7 @@ class ExternalService(StandardFastAPIService):
version="1.0.0",
log_level=settings.LOG_LEVEL,
cors_origins=settings.CORS_ORIGINS,
api_prefix="/api/v1",
api_prefix="", # Empty because RouteBuilder already includes /api/v1
database_manager=database_manager,
expected_tables=external_expected_tables,
custom_health_checks={
@@ -178,5 +177,6 @@ app = service.create_app()
service.setup_standard_endpoints()
# Include routers
service.add_router(weather_router, tags=["weather"])
service.add_router(traffic_router, tags=["traffic"])
service.add_router(weather_data.router)
service.add_router(traffic_data.router)
service.add_router(external_operations.router)

View File

@@ -1,8 +1,8 @@
"""initial_schema_20251001_1119
"""initial_schema_20251006_1517
Revision ID: 374752db316e
Revision ID: 44983b9ad55b
Revises:
Create Date: 2025-10-01 11:19:50.472480+02:00
Create Date: 2025-10-06 15:17:13.717978+02:00
"""
from typing import Sequence, Union
@@ -12,7 +12,7 @@ import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '374752db316e'
revision: str = '44983b9ad55b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None