Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a16688cc8 | ||
|
|
a7535fe8ea | ||
|
|
6045c6ac6a | ||
|
|
b8fca7060f | ||
|
|
e3a49c800a | ||
|
|
7ab9f73a1d | ||
|
|
dd5b794de4 |
@@ -5,6 +5,53 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.10.2] - 2026-01-07
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Enhanced environment endpoint logging** - Added detailed user claim logging for debugging
|
||||||
|
- Logs both `preferred_username` and `sub` claims when resolving user
|
||||||
|
- Distinguishes between authenticated and unauthenticated requests
|
||||||
|
|
||||||
|
## [1.10.1] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix OIDC import path in tools controller (`src.oidc.dependencies` → `src.auth.oidc`)
|
||||||
|
- Environment endpoint was returning `user: "local"` instead of authenticated username
|
||||||
|
- Caused queries to wrong Qdrant collection (`volatile_local` vs `volatile_{username}`)
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- Fix SQLAlchemy async lazy loading error for new users in `/auth/sync`
|
||||||
|
- Initialize `user.roles = []` and `user.preferences` to avoid greenlet error
|
||||||
|
- Was causing "MissingGreenlet: greenlet_spawn has not been called" on new user creation
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-01-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Handle non-UUID `sub` claim in `/auth/sync` - Authentik JWT may return non-UUID subject identifiers
|
||||||
|
- Now derives deterministic UUID from sub string if direct parsing fails
|
||||||
|
|
||||||
## [1.9.2] - 2026-01-04
|
## [1.9.2] - 2026-01-04
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
proxy_buffers 8 16k;
|
||||||
|
proxy_buffer_size 32k;
|
||||||
|
|
||||||
|
# CORS headers for Flutter web
|
||||||
|
add_header Access-Control-Allow-Origin "https://home.schweitz.net" always;
|
||||||
|
add_header Access-Control-Allow-Credentials true always;
|
||||||
|
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
|
||||||
|
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||||
|
|
||||||
|
if ($request_method = OPTIONS) {
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "core-api"
|
name = "core-api"
|
||||||
version = "1.9.2"
|
version = "1.10.2"
|
||||||
description = "Core Code API - Infrastructure management and tools API"
|
description = "Core Code API - Infrastructure management and tools API"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -32,3 +32,6 @@ cryptography>=44.0.1 # CVE-2024-12797
|
|||||||
sqlalchemy[asyncio]~=2.0.0
|
sqlalchemy[asyncio]~=2.0.0
|
||||||
asyncpg>=0.30.0
|
asyncpg>=0.30.0
|
||||||
alembic~=1.13.0
|
alembic~=1.13.0
|
||||||
|
|
||||||
|
# Vector Database
|
||||||
|
qdrant-client>=1.9.0
|
||||||
|
|||||||
@@ -3,14 +3,20 @@ Tools Controller
|
|||||||
|
|
||||||
Provides utility tool endpoints including:
|
Provides utility tool endpoints including:
|
||||||
- DNS lookups
|
- 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.controllers.base import BaseController
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
from src.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||||
from src.dns.service import DNSService
|
from src.dns.service import DNSService
|
||||||
from src.dns.exceptions import DNSQueryError
|
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.auth.oidc import get_optional_user
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -26,6 +32,7 @@ class ToolsController(BaseController):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(prefix="/tools", tags=["Tools"])
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
|
self.environment_service = get_environment_service()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -95,6 +102,60 @@ class ToolsController(BaseController):
|
|||||||
detail="An unexpected error occurred during DNS lookup"
|
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:
|
||||||
|
logger.debug(f"User claims: {user}")
|
||||||
|
user_id = user.get("preferred_username") or user.get("sub", "default")
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id} (preferred_username={user.get('preferred_username')}, sub={user.get('sub')})")
|
||||||
|
else:
|
||||||
|
logger.info(f"Fetching environment data for user: {user_id} (no auth)")
|
||||||
|
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
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -322,7 +322,6 @@ class AuthController(BaseController):
|
|||||||
responses={
|
responses={
|
||||||
200: {"description": "User profile with roles and preferences"},
|
200: {"description": "User profile with roles and preferences"},
|
||||||
401: {"description": "Not authenticated"},
|
401: {"description": "Not authenticated"},
|
||||||
404: {"description": "User not found in database"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
async def get_current_user_profile(
|
async def get_current_user_profile(
|
||||||
@@ -333,11 +332,16 @@ class AuthController(BaseController):
|
|||||||
Get the current authenticated user's profile
|
Get the current authenticated user's profile
|
||||||
|
|
||||||
Returns the user's profile, roles, and preferences.
|
Returns the user's profile, roles, and preferences.
|
||||||
Requires authentication via Bearer token or API key.
|
Supports both:
|
||||||
|
- Bearer token (mobile/native clients)
|
||||||
|
- NPM forward auth headers (web clients via proxy)
|
||||||
|
|
||||||
|
For forward auth users, auto-creates the user in the database
|
||||||
|
if they don't exist yet (first login via web).
|
||||||
"""
|
"""
|
||||||
service = AuthService(session)
|
service = AuthService(session)
|
||||||
|
|
||||||
# Get authentik_id from claims (JWT 'sub' field)
|
# Get authentik_id from claims (JWT 'sub' field or forward auth 'uid')
|
||||||
authentik_id_str = user_claims.get("sub")
|
authentik_id_str = user_claims.get("sub")
|
||||||
if not authentik_id_str or authentik_id_str == "local-user":
|
if not authentik_id_str or authentik_id_str == "local-user":
|
||||||
raise HTTPException(status_code=401, detail="Authentication required")
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
@@ -348,7 +352,23 @@ class AuthController(BaseController):
|
|||||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||||
|
|
||||||
user = await service.get_user_by_authentik_id(authentik_id)
|
user = await service.get_user_by_authentik_id(authentik_id)
|
||||||
|
|
||||||
|
# If user not found and using forward auth, auto-create them
|
||||||
if user is None:
|
if user is None:
|
||||||
|
auth_method = user_claims.get("auth_method")
|
||||||
|
if auth_method == "forward_auth":
|
||||||
|
# Auto-sync user from forward auth headers
|
||||||
|
logger.info(f"Auto-creating user from forward auth: {user_claims.get('email')}")
|
||||||
|
user, is_new = await service.sync_user_from_claims(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=user_claims.get("email", ""),
|
||||||
|
name=user_claims.get("name", user_claims.get("preferred_username", "")),
|
||||||
|
groups=user_claims.get("groups", []),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user, ["preferences", "roles"])
|
||||||
|
else:
|
||||||
|
# JWT auth but user not in DB - they need to sync first
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="User not found - please sync via /auth/sync first",
|
detail="User not found - please sync via /auth/sync first",
|
||||||
|
|||||||
@@ -102,7 +102,13 @@ class AuthService:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (User, is_new_user)
|
Tuple of (User, is_new_user)
|
||||||
"""
|
"""
|
||||||
|
# Parse authentik_id - may be UUID or other format
|
||||||
|
try:
|
||||||
authentik_id = uuid.UUID(token_info.sub)
|
authentik_id = uuid.UUID(token_info.sub)
|
||||||
|
except ValueError:
|
||||||
|
# If sub is not a valid UUID, derive one deterministically
|
||||||
|
logger.warning(f"sub claim is not a UUID: {token_info.sub}, deriving UUID")
|
||||||
|
authentik_id = uuid.uuid5(uuid.NAMESPACE_OID, token_info.sub)
|
||||||
|
|
||||||
# Try to find existing user
|
# Try to find existing user
|
||||||
stmt = (
|
stmt = (
|
||||||
@@ -124,11 +130,14 @@ class AuthService:
|
|||||||
avatar_url=token_info.picture,
|
avatar_url=token_info.picture,
|
||||||
last_login=datetime.now(timezone.utc),
|
last_login=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
# Initialize relationships to avoid lazy loading issues in async
|
||||||
|
user.roles = []
|
||||||
self.session.add(user)
|
self.session.add(user)
|
||||||
await self.session.flush() # Get the user ID
|
await self.session.flush() # Get the user ID
|
||||||
|
|
||||||
# Create default preferences
|
# Create default preferences and attach to user
|
||||||
preferences = UserPreferences(user_id=user.id)
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
user.preferences = preferences
|
||||||
self.session.add(preferences)
|
self.session.add(preferences)
|
||||||
|
|
||||||
logger.info(f"Created new user: {token_info.email}")
|
logger.info(f"Created new user: {token_info.email}")
|
||||||
@@ -144,6 +153,74 @@ class AuthService:
|
|||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
return user, is_new
|
return user, is_new
|
||||||
|
|
||||||
|
async def sync_user_from_claims(
|
||||||
|
self,
|
||||||
|
authentik_id: uuid.UUID,
|
||||||
|
email: str,
|
||||||
|
name: str,
|
||||||
|
groups: list[str],
|
||||||
|
avatar_url: Optional[str] = None,
|
||||||
|
) -> tuple[User, bool]:
|
||||||
|
"""
|
||||||
|
Create or update user from forward auth claims (NPM X-authentik-* headers)
|
||||||
|
|
||||||
|
This is similar to sync_user() but works with raw claims instead of
|
||||||
|
TokenInfoSchema. Used for auto-syncing users on first web login via NPM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authentik_id: The Authentik user UUID (from X-authentik-uid)
|
||||||
|
email: User email (from X-authentik-email)
|
||||||
|
name: User display name (from X-authentik-name)
|
||||||
|
groups: List of group names (from X-authentik-groups)
|
||||||
|
avatar_url: Optional avatar URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (User, is_new_user)
|
||||||
|
"""
|
||||||
|
# Try to find existing user
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||||
|
.where(User.authentik_id == authentik_id)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
is_new = user is None
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
# Create new user
|
||||||
|
user = User(
|
||||||
|
authentik_id=authentik_id,
|
||||||
|
email=email,
|
||||||
|
name=name or email,
|
||||||
|
avatar_url=avatar_url,
|
||||||
|
last_login=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
self.session.add(user)
|
||||||
|
await self.session.flush() # Get the user ID
|
||||||
|
|
||||||
|
# Create default preferences
|
||||||
|
preferences = UserPreferences(user_id=user.id)
|
||||||
|
self.session.add(preferences)
|
||||||
|
|
||||||
|
logger.info(f"Created new user from forward auth: {email}")
|
||||||
|
else:
|
||||||
|
# Update existing user
|
||||||
|
user.email = email
|
||||||
|
user.name = name or email
|
||||||
|
if avatar_url:
|
||||||
|
user.avatar_url = avatar_url
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
logger.info(f"Updated existing user from forward auth: {email}")
|
||||||
|
|
||||||
|
# Sync roles from groups
|
||||||
|
await self.sync_roles(user, groups)
|
||||||
|
|
||||||
|
await self.session.flush()
|
||||||
|
return user, is_new
|
||||||
|
|
||||||
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
async def sync_roles(self, user: User, group_names: list[str]) -> list[Role]:
|
||||||
"""
|
"""
|
||||||
Synchronize user roles from Authentik groups via group_roles mapping
|
Synchronize user roles from Authentik groups via group_roles mapping
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ Tools Controller
|
|||||||
Provides utility tool endpoints including:
|
Provides utility tool endpoints including:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
- System stats
|
- 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.base import BaseController
|
||||||
from src.shared.logging import get_logger
|
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.dns.exceptions import DNSQueryError
|
||||||
from src.domains.tools.system.schemas import SystemStatsResponse
|
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||||
from src.domains.tools.system.service import SystemStatsService
|
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__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -25,12 +30,14 @@ class ToolsController(BaseController):
|
|||||||
Provides endpoints for:
|
Provides endpoints for:
|
||||||
- DNS lookups
|
- DNS lookups
|
||||||
- System stats
|
- System stats
|
||||||
|
- Environment data (weather, forecast, sun times)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(prefix="/tools", tags=["Tools"])
|
super().__init__(prefix="/tools", tags=["Tools"])
|
||||||
self.dns_service = DNSService()
|
self.dns_service = DNSService()
|
||||||
self.system_stats_service = SystemStatsService()
|
self.system_stats_service = SystemStatsService()
|
||||||
|
self.environment_service = EnvironmentService()
|
||||||
|
|
||||||
def create_router(self) -> APIRouter:
|
def create_router(self) -> APIRouter:
|
||||||
"""Create and configure the router"""
|
"""Create and configure the router"""
|
||||||
@@ -146,6 +153,63 @@ class ToolsController(BaseController):
|
|||||||
detail=f"Failed to collect system stats: {str(e)}"
|
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
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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.npm_client import NPMClient, get_npm_client
|
||||||
from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_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.authentik_client import AuthentikClient, get_authentik_client
|
||||||
|
from src.shared.clients.qdrant_client import QdrantReadClient, get_qdrant_client
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"PortainerClient",
|
"PortainerClient",
|
||||||
@@ -17,4 +18,6 @@ __all__ = [
|
|||||||
"get_homeassistant_client",
|
"get_homeassistant_client",
|
||||||
"AuthentikClient",
|
"AuthentikClient",
|
||||||
"get_authentik_client",
|
"get_authentik_client",
|
||||||
|
"QdrantReadClient",
|
||||||
|
"get_qdrant_client",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user