From 6045c6ac6a44798d2bb2b6144a70f0945021f210 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 6 Jan 2026 23:04:59 +0100 Subject: [PATCH] feat: add environment endpoint for weather, forecast, and sun data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GET /tools/environment endpoint that fetches weather, forecast, sun times, and air quality data from user's volatile Qdrant collection. - Add qdrant-client dependency - Create QdrantReadClient wrapper for read-only queries - Add environment schemas and service in tools domain - Parse weather, forecast, sun times, and air quality from Qdrant payloads - Support user-specific collections via preferred_username from OIDC - Add comprehensive service tests (13 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 16 ++ pyproject.toml | 2 +- requirements.txt | 3 + src/controllers/tools_controller.py | 61 ++++- src/domains/tools/controller.py | 66 +++++- src/domains/tools/environment/__init__.py | 23 ++ src/domains/tools/environment/schemas.py | 185 ++++++++++++++++ src/domains/tools/environment/service.py | 246 +++++++++++++++++++++ src/shared/clients/__init__.py | 3 + src/shared/clients/qdrant_client.py | 258 ++++++++++++++++++++++ tests/test_environment.py | 159 +++++++++++++ 11 files changed, 1019 insertions(+), 3 deletions(-) create mode 100644 src/domains/tools/environment/__init__.py create mode 100644 src/domains/tools/environment/schemas.py create mode 100644 src/domains/tools/environment/service.py create mode 100644 src/shared/clients/qdrant_client.py create mode 100644 tests/test_environment.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d5cf4..400f5bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.10.0] - 2026-01-06 + +### Added + +- **Environment Data API** - Qdrant-backed endpoint for weather, forecast, and sun position data + - `GET /tools/environment` - Fetch environment data from user's volatile collection + - Weather: current temperature, conditions, humidity, wind speed + - Forecast: multi-day outlook with high/low temperatures + - Sun times: sunrise, sunset, daylight duration + - Air quality: AQI and quality level (when available) + - Data sourced from `volatile_{user}` Qdrant collection + - Uses `preferred_username` from OIDC, falls back to `default` +- `qdrant-client` dependency for vector database access +- `QdrantReadClient` wrapper for read-only collection queries +- Comprehensive test suite for environment service parsing + ## [1.9.4] - 2026-01-04 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 94db214..29d40f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "core-api" -version = "1.9.4" +version = "1.10.0" description = "Core Code API - Infrastructure management and tools API" readme = "README.md" requires-python = ">=3.12" diff --git a/requirements.txt b/requirements.txt index 15cbf28..0c2ea61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -32,3 +32,6 @@ cryptography>=44.0.1 # CVE-2024-12797 sqlalchemy[asyncio]~=2.0.0 asyncpg>=0.30.0 alembic~=1.13.0 + +# Vector Database +qdrant-client>=1.9.0 diff --git a/src/controllers/tools_controller.py b/src/controllers/tools_controller.py index d908880..3b6acdb 100644 --- a/src/controllers/tools_controller.py +++ b/src/controllers/tools_controller.py @@ -3,14 +3,20 @@ Tools Controller Provides utility tool endpoints including: - DNS lookups +- Environment data (weather, forecast, sun times, air quality) """ -from fastapi import APIRouter, HTTPException, status +from typing import Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, status from src.controllers.base import BaseController from src.logging_config import get_logger from src.dns.schemas import DNSLookupRequest, DNSLookupResponse from src.dns.service import DNSService from src.dns.exceptions import DNSQueryError +from src.domains.tools.environment.schemas import EnvironmentResponse +from src.domains.tools.environment.service import get_environment_service +from src.oidc.dependencies import get_optional_user logger = get_logger(__name__) @@ -26,6 +32,7 @@ class ToolsController(BaseController): def __init__(self): super().__init__(prefix="/tools", tags=["Tools"]) self.dns_service = DNSService() + self.environment_service = get_environment_service() def create_router(self) -> APIRouter: """Create and configure the router""" @@ -95,6 +102,58 @@ class ToolsController(BaseController): detail="An unexpected error occurred during DNS lookup" ) + @router.get( + "/environment", + response_model=EnvironmentResponse, + status_code=status.HTTP_200_OK, + summary="Get environment data", + description=""" + Fetch current environment data including weather, forecast, sun times, + and optionally air quality. + + Data is retrieved from the user's volatile Qdrant collection which is + populated by background data collectors. + + **Data Sources:** + - Weather: Current temperature, conditions, humidity, wind + - Forecast: Multi-day weather outlook + - Sun Times: Sunrise, sunset, daylight duration + - Air Quality: AQI and pollutant levels (when available) + + **Authentication:** + - Uses authenticated user's `preferred_username` if available + - Falls back to 'default' for unauthenticated requests + """ + ) + async def get_environment( + user: Optional[Dict] = Depends(get_optional_user), + ) -> EnvironmentResponse: + """ + Get current environment data. + + Args: + user: Optional authenticated user info + + Returns: + Environment data with weather, forecast, sun times, and air quality + """ + try: + # Determine user identifier + user_id = "default" + if user: + user_id = user.get("preferred_username") or user.get("sub", "default") + + logger.info(f"Fetching environment data for user: {user_id}") + result = await self.environment_service.get_current(user_id) + return result + + except Exception as e: + logger.error(f"Error fetching environment data: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to fetch environment data" + ) + return router diff --git a/src/domains/tools/controller.py b/src/domains/tools/controller.py index beb55ab..b0d00e3 100644 --- a/src/domains/tools/controller.py +++ b/src/domains/tools/controller.py @@ -4,8 +4,10 @@ Tools Controller Provides utility tool endpoints including: - DNS lookups - System stats +- Environment data (weather, forecast, sun times) """ -from fastapi import APIRouter, HTTPException, status +from typing import Dict, Optional +from fastapi import APIRouter, HTTPException, status, Depends from src.shared.base import BaseController from src.shared.logging import get_logger @@ -14,6 +16,9 @@ from src.domains.tools.dns.service import DNSService from src.domains.tools.dns.exceptions import DNSQueryError from src.domains.tools.system.schemas import SystemStatsResponse from src.domains.tools.system.service import SystemStatsService +from src.domains.tools.environment.schemas import EnvironmentResponse +from src.domains.tools.environment.service import EnvironmentService +from src.domains.auth.oidc import get_optional_user logger = get_logger(__name__) @@ -25,12 +30,14 @@ class ToolsController(BaseController): Provides endpoints for: - DNS lookups - System stats + - Environment data (weather, forecast, sun times) """ def __init__(self): super().__init__(prefix="/tools", tags=["Tools"]) self.dns_service = DNSService() self.system_stats_service = SystemStatsService() + self.environment_service = EnvironmentService() def create_router(self) -> APIRouter: """Create and configure the router""" @@ -146,6 +153,63 @@ class ToolsController(BaseController): detail=f"Failed to collect system stats: {str(e)}" ) + @router.get( + "/environment", + response_model=EnvironmentResponse, + status_code=status.HTTP_200_OK, + summary="Get environment data", + description=""" + Get current environment data including weather, forecast, and sun times. + + Fetches data from the Qdrant volatile collection for the authenticated user. + Falls back to 'default' user if not authenticated. + + **Data Returned:** + - **Weather:** Current temperature, conditions, humidity, wind + - **Forecast:** Multi-day weather outlook + - **Sun Times:** Sunrise, sunset, daylight duration + - **Air Quality:** AQI and pollutant levels (if available) + + **Data Source:** Qdrant volatile_{user} collection + + **Use Cases:** + - Dashboard environment widgets + - Home automation context + - Weather-based automations + """ + ) + async def get_environment( + user: Optional[Dict] = Depends(get_optional_user), + ) -> EnvironmentResponse: + """ + Get current environment data + + Args: + user: Optional authenticated user from OIDC + + Returns: + Environment data including weather, forecast, sun times + + Raises: + HTTPException: 500 for processing errors + """ + try: + # Get user identifier from OIDC claims, fallback to 'default' + user_id = "default" + if user: + user_id = user.get("preferred_username") or user.get("sub", "default") + + logger.info(f"Fetching environment data for user: {user_id}") + result = await self.environment_service.get_current(user_id) + return result + + except Exception as e: + logger.error(f"Failed to get environment data: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to fetch environment data: {str(e)}" + ) + return router diff --git a/src/domains/tools/environment/__init__.py b/src/domains/tools/environment/__init__.py new file mode 100644 index 0000000..300612a --- /dev/null +++ b/src/domains/tools/environment/__init__.py @@ -0,0 +1,23 @@ +""" +Environment data module for Tools domain. + +Provides access to weather, forecast, sun times, and air quality data +from the Qdrant volatile collection. +""" +from src.domains.tools.environment.schemas import ( + WeatherData, + ForecastDay, + SunTimesData, + AirQualityData, + EnvironmentResponse, +) +from src.domains.tools.environment.service import EnvironmentService + +__all__ = [ + "WeatherData", + "ForecastDay", + "SunTimesData", + "AirQualityData", + "EnvironmentResponse", + "EnvironmentService", +] diff --git a/src/domains/tools/environment/schemas.py b/src/domains/tools/environment/schemas.py new file mode 100644 index 0000000..9ad9018 --- /dev/null +++ b/src/domains/tools/environment/schemas.py @@ -0,0 +1,185 @@ +""" +Environment data schemas for Tools domain. + +Provides Pydantic models for weather, forecast, sun times, and air quality data +retrieved from the Qdrant volatile collection. +""" +from datetime import datetime +from typing import Optional, List, Any +from pydantic import Field + +from src.shared.base import BaseSchema + + +class WeatherData(BaseSchema): + """Current weather conditions.""" + + temperature: Optional[float] = Field( + None, + description="Current temperature in Celsius" + ) + feels_like: Optional[float] = Field( + None, + description="Feels-like temperature in Celsius" + ) + conditions: Optional[str] = Field( + None, + description="Weather conditions description (e.g., 'Partly Cloudy')" + ) + humidity: Optional[int] = Field( + None, + ge=0, + le=100, + description="Humidity percentage" + ) + wind_speed: Optional[float] = Field( + None, + description="Wind speed in km/h" + ) + wind_direction: Optional[str] = Field( + None, + description="Wind direction (e.g., 'NW')" + ) + pressure: Optional[float] = Field( + None, + description="Atmospheric pressure in hPa" + ) + visibility: Optional[float] = Field( + None, + description="Visibility in km" + ) + uv_index: Optional[float] = Field( + None, + description="UV index" + ) + location: Optional[str] = Field( + None, + description="Location name" + ) + icon: Optional[str] = Field( + None, + description="Weather icon code or URL" + ) + + +class ForecastDay(BaseSchema): + """Single day forecast data.""" + + date: str = Field( + ..., + description="Date string (e.g., '2025-01-07')" + ) + high: Optional[float] = Field( + None, + description="High temperature in Celsius" + ) + low: Optional[float] = Field( + None, + description="Low temperature in Celsius" + ) + conditions: Optional[str] = Field( + None, + description="Weather conditions description" + ) + precipitation_chance: Optional[int] = Field( + None, + ge=0, + le=100, + description="Chance of precipitation percentage" + ) + icon: Optional[str] = Field( + None, + description="Weather icon code or URL" + ) + + +class SunTimesData(BaseSchema): + """Sunrise and sunset times.""" + + sunrise: Optional[datetime] = Field( + None, + description="Sunrise time" + ) + sunset: Optional[datetime] = Field( + None, + description="Sunset time" + ) + daylight_minutes: Optional[int] = Field( + None, + description="Total daylight duration in minutes" + ) + solar_noon: Optional[datetime] = Field( + None, + description="Solar noon time" + ) + dawn: Optional[datetime] = Field( + None, + description="Civil dawn time" + ) + dusk: Optional[datetime] = Field( + None, + description="Civil dusk time" + ) + + +class AirQualityData(BaseSchema): + """Air quality information.""" + + aqi: Optional[int] = Field( + None, + ge=0, + description="Air Quality Index" + ) + quality: Optional[str] = Field( + None, + description="Quality category (Good, Moderate, Unhealthy, etc.)" + ) + pm25: Optional[float] = Field( + None, + description="PM2.5 concentration in microg/m3" + ) + pm10: Optional[float] = Field( + None, + description="PM10 concentration in microg/m3" + ) + o3: Optional[float] = Field( + None, + description="Ozone concentration in ppb" + ) + no2: Optional[float] = Field( + None, + description="Nitrogen dioxide concentration in ppb" + ) + location: Optional[str] = Field( + None, + description="Location name" + ) + + +class EnvironmentResponse(BaseSchema): + """Combined environment data response.""" + + weather: Optional[WeatherData] = Field( + None, + description="Current weather conditions" + ) + forecast: Optional[List[ForecastDay]] = Field( + None, + description="Multi-day weather forecast" + ) + sun_times: Optional[SunTimesData] = Field( + None, + description="Sunrise/sunset times" + ) + air_quality: Optional[AirQualityData] = Field( + None, + description="Air quality data (None if not available)" + ) + updated_at: datetime = Field( + default_factory=datetime.utcnow, + description="Timestamp when data was fetched" + ) + user: Optional[str] = Field( + None, + description="User identifier used for data lookup" + ) diff --git a/src/domains/tools/environment/service.py b/src/domains/tools/environment/service.py new file mode 100644 index 0000000..0e23a78 --- /dev/null +++ b/src/domains/tools/environment/service.py @@ -0,0 +1,246 @@ +""" +Environment data service for Tools domain. + +Fetches weather, forecast, sun times, and air quality data from +the Qdrant volatile collection. +""" +from datetime import datetime +from typing import Optional, Dict, Any, List + +from src.shared.logging import get_logger +from src.shared.clients.qdrant_client import get_qdrant_client +from src.domains.tools.environment.schemas import ( + WeatherData, + ForecastDay, + SunTimesData, + AirQualityData, + EnvironmentResponse, +) + +logger = get_logger(__name__) + + +class EnvironmentService: + """ + Service for fetching environment data from Qdrant volatile collection. + + Retrieves weather, forecast, sun times, and optionally air quality + data for a specific user. + """ + + def __init__(self): + """Initialize environment service with Qdrant client.""" + self.qdrant = get_qdrant_client() + + def _parse_weather(self, raw_data: Optional[Dict[str, Any]]) -> Optional[WeatherData]: + """ + Parse raw weather data into WeatherData schema. + + Handles various field naming conventions that might come from + different weather APIs. + """ + if not raw_data: + return None + + try: + return WeatherData( + temperature=raw_data.get("temperature") or raw_data.get("temp"), + feels_like=raw_data.get("feels_like") or raw_data.get("feelslike"), + conditions=raw_data.get("conditions") or raw_data.get("weather") or raw_data.get("description"), + humidity=raw_data.get("humidity"), + wind_speed=raw_data.get("wind_speed") or raw_data.get("windspeed") or raw_data.get("wind"), + wind_direction=raw_data.get("wind_direction") or raw_data.get("wind_dir"), + pressure=raw_data.get("pressure"), + visibility=raw_data.get("visibility"), + uv_index=raw_data.get("uv_index") or raw_data.get("uv"), + location=raw_data.get("location") or raw_data.get("city"), + icon=raw_data.get("icon") or raw_data.get("icon_url"), + ) + except Exception as e: + logger.warning(f"Failed to parse weather data: {e}") + return None + + def _parse_forecast(self, raw_data: Any) -> Optional[List[ForecastDay]]: + """ + Parse raw forecast data into list of ForecastDay schemas. + + Handles both list format and dict with nested list. + """ + if not raw_data: + return None + + try: + # Normalize to list + forecast_list = raw_data + if isinstance(raw_data, dict): + forecast_list = raw_data.get("days") or raw_data.get("forecast") or [] + + if not isinstance(forecast_list, list): + return None + + days = [] + for day in forecast_list: + if isinstance(day, dict): + days.append(ForecastDay( + date=day.get("date", ""), + high=day.get("high") or day.get("maxtemp") or day.get("temp_max"), + low=day.get("low") or day.get("mintemp") or day.get("temp_min"), + conditions=day.get("conditions") or day.get("weather") or day.get("description"), + precipitation_chance=day.get("precipitation_chance") or day.get("pop") or day.get("precip"), + icon=day.get("icon"), + )) + + return days if days else None + + except Exception as e: + logger.warning(f"Failed to parse forecast data: {e}") + return None + + def _parse_sun_times(self, raw_data: Optional[Dict[str, Any]]) -> Optional[SunTimesData]: + """ + Parse raw sun times data into SunTimesData schema. + + Handles datetime strings and calculates daylight minutes if not provided. + """ + if not raw_data: + return None + + try: + sunrise = raw_data.get("sunrise") + sunset = raw_data.get("sunset") + + # Parse datetime strings if needed + if isinstance(sunrise, str): + sunrise = datetime.fromisoformat(sunrise.replace("Z", "+00:00")) + if isinstance(sunset, str): + sunset = datetime.fromisoformat(sunset.replace("Z", "+00:00")) + + # Calculate daylight minutes if not provided + daylight_minutes = raw_data.get("daylight_minutes") or raw_data.get("daylight") + if daylight_minutes is None and sunrise and sunset: + daylight_minutes = int((sunset - sunrise).total_seconds() / 60) + + # Parse optional fields + solar_noon = raw_data.get("solar_noon") + if isinstance(solar_noon, str): + solar_noon = datetime.fromisoformat(solar_noon.replace("Z", "+00:00")) + + dawn = raw_data.get("dawn") or raw_data.get("civil_dawn") + if isinstance(dawn, str): + dawn = datetime.fromisoformat(dawn.replace("Z", "+00:00")) + + dusk = raw_data.get("dusk") or raw_data.get("civil_dusk") + if isinstance(dusk, str): + dusk = datetime.fromisoformat(dusk.replace("Z", "+00:00")) + + return SunTimesData( + sunrise=sunrise, + sunset=sunset, + daylight_minutes=daylight_minutes, + solar_noon=solar_noon, + dawn=dawn, + dusk=dusk, + ) + + except Exception as e: + logger.warning(f"Failed to parse sun times data: {e}") + return None + + def _parse_air_quality(self, raw_data: Any) -> Optional[AirQualityData]: + """ + Parse raw air quality data into AirQualityData schema. + + Handles both dict format and simple integer AQI value. + """ + if raw_data is None: + return None + + try: + # Handle simple integer AQI + if isinstance(raw_data, (int, float)): + aqi = int(raw_data) + return AirQualityData( + aqi=aqi, + quality=self._aqi_to_quality(aqi), + ) + + if not isinstance(raw_data, dict): + return None + + aqi = raw_data.get("aqi") or raw_data.get("index") + if isinstance(aqi, (int, float)): + aqi = int(aqi) + + return AirQualityData( + aqi=aqi, + quality=raw_data.get("quality") or (self._aqi_to_quality(aqi) if aqi else None), + pm25=raw_data.get("pm25") or raw_data.get("pm2_5"), + pm10=raw_data.get("pm10"), + o3=raw_data.get("o3") or raw_data.get("ozone"), + no2=raw_data.get("no2"), + location=raw_data.get("location"), + ) + + except Exception as e: + logger.warning(f"Failed to parse air quality data: {e}") + return None + + def _aqi_to_quality(self, aqi: int) -> str: + """Convert AQI value to quality category string.""" + if aqi <= 50: + return "Good" + elif aqi <= 100: + return "Moderate" + elif aqi <= 150: + return "Unhealthy for Sensitive Groups" + elif aqi <= 200: + return "Unhealthy" + elif aqi <= 300: + return "Very Unhealthy" + else: + return "Hazardous" + + async def get_current(self, user: str = "default") -> EnvironmentResponse: + """ + Get current environment data for a user. + + Fetches weather, forecast, sun times, and air quality from + the user's volatile collection. + + Args: + user: User identifier (default: 'default') + + Returns: + EnvironmentResponse with all available data + """ + logger.info(f"Fetching environment data for user: {user}") + + # Get raw data from Qdrant + raw_data = await self.qdrant.get_environment_data(user) + + # Parse each data type + weather = self._parse_weather(raw_data.get("weather")) + forecast = self._parse_forecast(raw_data.get("forecast")) + sun_times = self._parse_sun_times(raw_data.get("sun_times")) + air_quality = self._parse_air_quality(raw_data.get("air_quality")) + + return EnvironmentResponse( + weather=weather, + forecast=forecast, + sun_times=sun_times, + air_quality=air_quality, + updated_at=datetime.utcnow(), + user=user, + ) + + +# Singleton instance +_environment_service: Optional[EnvironmentService] = None + + +def get_environment_service() -> EnvironmentService: + """Get or create singleton environment service instance.""" + global _environment_service + if _environment_service is None: + _environment_service = EnvironmentService() + return _environment_service diff --git a/src/shared/clients/__init__.py b/src/shared/clients/__init__.py index 22da133..eae0e05 100644 --- a/src/shared/clients/__init__.py +++ b/src/shared/clients/__init__.py @@ -7,6 +7,7 @@ from src.shared.clients.portainer_client import PortainerClient, get_portainer_c from src.shared.clients.npm_client import NPMClient, get_npm_client from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client from src.shared.clients.authentik_client import AuthentikClient, get_authentik_client +from src.shared.clients.qdrant_client import QdrantReadClient, get_qdrant_client __all__ = [ "PortainerClient", @@ -17,4 +18,6 @@ __all__ = [ "get_homeassistant_client", "AuthentikClient", "get_authentik_client", + "QdrantReadClient", + "get_qdrant_client", ] diff --git a/src/shared/clients/qdrant_client.py b/src/shared/clients/qdrant_client.py new file mode 100644 index 0000000..a52a1c0 --- /dev/null +++ b/src/shared/clients/qdrant_client.py @@ -0,0 +1,258 @@ +""" +Qdrant Vector Database Client (Read-Only) + +Provides read-only access to Qdrant collections for querying volatile data. +Used to fetch weather, forecast, and sun times from the volatile_{user} collection. +""" +import time +from typing import List, Dict, Any, Optional +from qdrant_client import QdrantClient +from qdrant_client.models import Filter, FieldCondition, MatchValue, Range + +from src.shared.logging import get_logger +from src.shared.config import get_settings + +logger = get_logger(__name__) +settings = get_settings() + + +class QdrantReadClient: + """ + Read-only Qdrant client for accessing volatile data. + + Connects to Qdrant and provides methods to query collections + with filtering by namespace and TTL expiry. + """ + + VOLATILE_COLLECTION_PREFIX = "volatile_" + + def __init__( + self, + host: Optional[str] = None, + port: Optional[int] = None, + ): + """ + Initialize Qdrant read client. + + Args: + host: Qdrant server host (default from settings) + port: Qdrant server port (default from settings) + """ + self.host = host or settings.qdrant_host + self.port = port or settings.qdrant_port + self._client: Optional[QdrantClient] = None + + logger.info(f"Initialized QdrantReadClient: {self.host}:{self.port}") + + @property + def client(self) -> QdrantClient: + """Lazy-load Qdrant client connection.""" + if self._client is None: + self._client = QdrantClient( + host=self.host, + port=self.port, + ) + return self._client + + def _get_volatile_collection(self, user: str) -> str: + """Get volatile collection name for user.""" + return f"{self.VOLATILE_COLLECTION_PREFIX}{user}" + + def _current_timestamp_ms(self) -> int: + """Get current timestamp in milliseconds.""" + return int(time.time() * 1000) + + async def collection_exists(self, collection_name: str) -> bool: + """ + Check if a collection exists. + + Args: + collection_name: Name of collection to check + + Returns: + True if collection exists + """ + try: + collections = self.client.get_collections() + existing = [c.name for c in collections.collections] + return collection_name in existing + except Exception as e: + logger.error(f"Error checking collection existence: {e}") + return False + + async def get_by_namespace( + self, + user: str, + namespace: str, + include_expired: bool = False + ) -> List[Dict[str, Any]]: + """ + Get all records for a specific namespace from user's volatile collection. + + Args: + user: User identifier (e.g., 'jpmschweitzer' or 'default') + namespace: Namespace to filter (e.g., 'weather', 'forecast', 'sun') + include_expired: Whether to include expired records (default False) + + Returns: + List of records with payload data + """ + collection_name = self._get_volatile_collection(user) + + if not await self.collection_exists(collection_name): + logger.debug(f"Collection {collection_name} does not exist") + return [] + + # Build filter conditions + conditions = [ + FieldCondition( + key="namespace", + match=MatchValue(value=namespace) + ) + ] + + # Add TTL expiry filter unless including expired + if not include_expired: + now_ms = self._current_timestamp_ms() + conditions.append( + FieldCondition( + key="ttl_expiry", + range=Range(gt=now_ms) + ) + ) + + query_filter = Filter(must=conditions) + + try: + # Scroll through matching records + points, _ = self.client.scroll( + collection_name=collection_name, + scroll_filter=query_filter, + limit=100, + with_payload=True, + with_vectors=False + ) + + results = [] + for point in points: + payload = dict(point.payload) if point.payload else {} + results.append({ + "id": str(point.id), + "namespace": payload.get("namespace"), + "key": payload.get("key"), + "raw_data": payload.get("raw_data", {}), + "source": payload.get("source"), + "ttl_expiry": payload.get("ttl_expiry"), + "updated_at": payload.get("updated_at"), + }) + + logger.debug( + f"Found {len(results)} records in {collection_name}/{namespace}" + ) + return results + + except Exception as e: + logger.error(f"Error fetching from {collection_name}/{namespace}: {e}") + return [] + + async def get_environment_data( + self, + user: str + ) -> Dict[str, Any]: + """ + Get all environment data (weather, forecast, sun times) for a user. + + Convenience method that fetches all environment-related namespaces + in a single call. + + Args: + user: User identifier + + Returns: + Dict with 'weather', 'forecast', 'sun_times', 'air_quality' keys + (each may be None if no data found) + """ + result = { + "weather": None, + "forecast": None, + "sun_times": None, + "air_quality": None, + } + + # Fetch weather data + weather_records = await self.get_by_namespace(user, "weather") + if weather_records: + # Get the first/most recent weather record + result["weather"] = weather_records[0].get("raw_data") + # Check if air quality is embedded in weather data + if result["weather"]: + aqi = result["weather"].get("aqi") or result["weather"].get("air_quality") + if aqi: + result["air_quality"] = aqi if isinstance(aqi, dict) else {"aqi": aqi} + + # Fetch forecast data + forecast_records = await self.get_by_namespace(user, "forecast") + if forecast_records: + # Forecast might be a single record with list or multiple records + first_record = forecast_records[0].get("raw_data") + if isinstance(first_record, list): + result["forecast"] = first_record + elif isinstance(first_record, dict): + # Could be a dict with 'days' or 'forecast' key + result["forecast"] = first_record.get( + "days", + first_record.get("forecast", [first_record]) + ) + + # Fetch sun times data + sun_records = await self.get_by_namespace(user, "sun") + if sun_records: + result["sun_times"] = sun_records[0].get("raw_data") + + # Check for separate air quality namespace if not embedded + if result["air_quality"] is None: + aq_records = await self.get_by_namespace(user, "air_quality") + if aq_records: + result["air_quality"] = aq_records[0].get("raw_data") + + return result + + async def health_check(self) -> Dict[str, Any]: + """ + Check Qdrant connectivity. + + Returns: + Dict with connection status and info + """ + try: + collections = self.client.get_collections() + volatile_collections = [ + c.name for c in collections.collections + if c.name.startswith(self.VOLATILE_COLLECTION_PREFIX) + ] + return { + "status": "healthy", + "connected": True, + "host": f"{self.host}:{self.port}", + "volatile_collections": volatile_collections, + } + except Exception as e: + logger.error(f"Qdrant health check failed: {e}") + return { + "status": "unhealthy", + "connected": False, + "host": f"{self.host}:{self.port}", + "error": str(e), + } + + +# Singleton instance for reuse +_qdrant_client: Optional[QdrantReadClient] = None + + +def get_qdrant_client() -> QdrantReadClient: + """Get or create singleton Qdrant client instance.""" + global _qdrant_client + if _qdrant_client is None: + _qdrant_client = QdrantReadClient() + return _qdrant_client diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..120a7ab --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,159 @@ +"""Tests for environment service and schemas.""" +import pytest +from datetime import datetime + + +class TestEnvironmentService: + """Test EnvironmentService methods.""" + + def test_aqi_to_quality_good(self): + """AQI 0-50 should return Good.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + assert service._aqi_to_quality(0) == "Good" + assert service._aqi_to_quality(25) == "Good" + assert service._aqi_to_quality(50) == "Good" + + def test_aqi_to_quality_moderate(self): + """AQI 51-100 should return Moderate.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + assert service._aqi_to_quality(51) == "Moderate" + assert service._aqi_to_quality(75) == "Moderate" + assert service._aqi_to_quality(100) == "Moderate" + + def test_aqi_to_quality_unhealthy_sensitive(self): + """AQI 101-150 should return Unhealthy for Sensitive Groups.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + assert service._aqi_to_quality(101) == "Unhealthy for Sensitive Groups" + assert service._aqi_to_quality(150) == "Unhealthy for Sensitive Groups" + + def test_aqi_to_quality_unhealthy(self): + """AQI 151-200 should return Unhealthy.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + assert service._aqi_to_quality(151) == "Unhealthy" + assert service._aqi_to_quality(200) == "Unhealthy" + + def test_aqi_to_quality_very_unhealthy(self): + """AQI 201-300 should return Very Unhealthy.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + assert service._aqi_to_quality(201) == "Very Unhealthy" + assert service._aqi_to_quality(300) == "Very Unhealthy" + + def test_aqi_to_quality_hazardous(self): + """AQI >300 should return Hazardous.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + assert service._aqi_to_quality(301) == "Hazardous" + assert service._aqi_to_quality(500) == "Hazardous" + + def test_parse_weather_with_valid_data(self): + """Parse weather should return WeatherData for valid input.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + raw = { + "temperature": 15.5, + "conditions": "Cloudy", + "humidity": 72, + "location": "Rotterdam", + } + result = service._parse_weather(raw) + + assert result is not None + assert result.temperature == 15.5 + assert result.conditions == "Cloudy" + assert result.humidity == 72 + + def test_parse_weather_with_none(self): + """Parse weather should return None for None input.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + result = service._parse_weather(None) + assert result is None + + def test_parse_forecast_with_list(self): + """Parse forecast should handle list format.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + raw = [ + {"date": "2026-01-06", "high": 12, "low": 5, "conditions": "Cloudy"}, + {"date": "2026-01-07", "high": 14, "low": 6, "conditions": "Sunny"}, + ] + result = service._parse_forecast(raw) + + assert result is not None + assert len(result) == 2 + assert result[0].date == "2026-01-06" + assert result[0].high == 12 + + def test_parse_forecast_with_dict(self): + """Parse forecast should handle dict with days key.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + raw = { + "days": [ + {"date": "2026-01-06", "high": 12, "low": 5, "conditions": "Cloudy"}, + ] + } + result = service._parse_forecast(raw) + + assert result is not None + assert len(result) == 1 + + def test_parse_sun_times_with_strings(self): + """Parse sun times should handle ISO datetime strings.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + raw = { + "sunrise": "2026-01-06T08:45:00", + "sunset": "2026-01-06T16:50:00", + } + result = service._parse_sun_times(raw) + + assert result is not None + assert result.sunrise.hour == 8 + assert result.sunrise.minute == 45 + assert result.sunset.hour == 16 + assert result.daylight_minutes == 485 + + def test_parse_air_quality_with_int(self): + """Parse air quality should handle simple integer AQI.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + result = service._parse_air_quality(42) + + assert result is not None + assert result.aqi == 42 + assert result.quality == "Good" + + def test_parse_air_quality_with_dict(self): + """Parse air quality should handle dict format.""" + from src.domains.tools.environment.service import EnvironmentService + service = EnvironmentService.__new__(EnvironmentService) + + raw = { + "aqi": 75, + "pm25": 8.5, + "pm10": 15, + } + result = service._parse_air_quality(raw) + + assert result is not None + assert result.aqi == 75 + assert result.quality == "Moderate" + assert result.pm25 == 8.5