Compare commits
+1
-4
@@ -39,12 +39,9 @@ HOMEASSISTANT_URL=http://localhost:8123
|
||||
HOMEASSISTANT_TOKEN=your-long-lived-access-token
|
||||
|
||||
# =============================================================================
|
||||
# AI Services
|
||||
# Search
|
||||
# =============================================================================
|
||||
|
||||
# Ollama API
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# SearXNG (self-hosted search)
|
||||
SEARXNG_URL=http://localhost:8080
|
||||
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"tag_name": "${{ github.ref_name }}", "name": "Release ${{ github.ref_name }}", "body": "Automated release for ${{ github.ref_name }}"}' \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases"
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ When changes are ready for deployment:
|
||||
5. **CI/CD triggers automatically**:
|
||||
- Gitea CI builds Docker image on new tag
|
||||
- Watchtower pulls and deploys to production
|
||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
||||
- Verify deployment: `curl http://192.168.86.149:8083/health`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+157
@@ -5,6 +5,163 @@ 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.7] - 2026-01-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-issuer OIDC support** - Accept tokens from multiple OAuth providers
|
||||
- Changed `oidc_issuer` (string) to `oidc_issuers` (list)
|
||||
- Each issuer has its own JWKS endpoint, now cached per-issuer
|
||||
- Validates token issuer against allowed list before fetching JWKS
|
||||
- Supports tokens from: `core-api`, `tatlock-ui`, `tatlock` OAuth applications
|
||||
- Completes fix for environment endpoint user resolution
|
||||
|
||||
### Removed
|
||||
|
||||
- Deprecated `src/config.py` - consolidated to `src/shared/config.py`
|
||||
- Deprecated `src/security.py` - consolidated to `src/shared/security.py`
|
||||
|
||||
## [1.10.6] - 2026-01-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC audience mismatch** - Accept tokens from multiple clients
|
||||
- Changed `oidc_audience` (string) to `oidc_audiences` (list)
|
||||
- Now accepts tokens with audience: `core-api`, `tatlock-ui`, or `tatlock`
|
||||
- Fixes environment endpoint returning "default" user instead of authenticated username
|
||||
- Fixed main.py to use `oidc_audiences[0]` for Swagger UI OAuth client
|
||||
|
||||
### Changed
|
||||
|
||||
- Documentation cleanup in README
|
||||
|
||||
## [1.10.4] - 2026-01-07
|
||||
|
||||
### Removed
|
||||
|
||||
- **Ollama integration removed** - AI inference is no longer handled by this API
|
||||
- Removed `src/models/ollama_client.py` and all Ollama-related configuration
|
||||
- Removed `src/models/embeddings.py` and `src/models/embeddings_ollama.py`
|
||||
- Removed model aliases and AI configuration from settings
|
||||
- Health endpoints no longer check Ollama status
|
||||
- Tests updated to reflect database-only health checks
|
||||
|
||||
### Changed
|
||||
|
||||
- Health check `/health/full` now only checks database connectivity
|
||||
- Diagnostics endpoint simplified (removed Ollama component info)
|
||||
|
||||
## [1.10.3] - 2026-01-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OIDC config not applied to domains module** - Both `src.auth.oidc` and `src.domains.auth.oidc` configs are now initialized
|
||||
- Previously only `src.auth.oidc` was configured, leaving domains tools using hardcoded "local" user
|
||||
- Environment endpoint now correctly uses authenticated user from OIDC token
|
||||
|
||||
## [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
|
||||
|
||||
### Fixed
|
||||
|
||||
- `/auth/users/me` endpoint now supports both NPM forward auth headers AND JWT Bearer tokens
|
||||
- Added `get_current_user_or_forward_auth()` combined auth dependency
|
||||
- Fixes web authentication where NPM passes `X-authentik-*` headers instead of JWT
|
||||
- Mobile/native clients continue to use JWT Bearer tokens as before
|
||||
|
||||
## [1.9.1] - 2026-01-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- CORS configuration now uses explicit origins instead of `"*"`
|
||||
- When `allow_credentials=True`, wildcard origins are rejected by browsers
|
||||
- Added `home.schweitz.net`, `tatlock.schweitz.net`, and localhost origins
|
||||
|
||||
## [1.9.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
||||
- **NPM Forward Auth Support** - Web authentication via Nginx Proxy Manager forward auth
|
||||
- `GET /auth/me` - Get current user from NPM forward auth headers (X-authentik-uid, X-authentik-email, etc.)
|
||||
- Auto-creates user on first web login if not in database
|
||||
- Syncs roles from NPM forward auth groups header
|
||||
- `get_user_by_email` and `get_user_by_authentik_id` methods in AuthService
|
||||
- Comprehensive tests for `/auth/me` endpoint
|
||||
|
||||
## [1.8.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Group-Role Mapping & Permissions** (Phase 3)
|
||||
- Decoupled group-role architecture (groups from Authentik, roles admin-managed)
|
||||
- Permission format: `domain.category:action` with action hierarchy
|
||||
- `require_permission` and `require_any_permission` dependency factories
|
||||
- Global admin override (`admin.general:admin`)
|
||||
- **User Profile & API Keys** (Phase 4)
|
||||
- `GET /auth/users/me` - Full user profile with roles and preferences
|
||||
- `GET/PATCH /auth/users/me/preferences` - User preferences management
|
||||
- `GET/POST/DELETE /auth/users/me/api-keys` - API key lifecycle
|
||||
- API keys with `tak_` prefix, SHA-256 hashing, shown only once on creation
|
||||
|
||||
## [1.7.0] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
||||
- **System Stats API** - Host system resource monitoring for dashboard widgets
|
||||
- `GET /tools/system/stats` - Real-time host system statistics
|
||||
- CPU: usage percentage, core count, load averages
|
||||
- Memory: usage percentage, total/used/available bytes
|
||||
- Disks: all mounted filesystems with usage stats (auto-discovers mounts)
|
||||
- Network: total bytes sent/received
|
||||
- GPU/VRAM: NVIDIA GPU memory usage (via nvidia-smi if available)
|
||||
- `psutil` dependency for cross-platform system metrics
|
||||
|
||||
## [1.6.1] - 2026-01-03
|
||||
|
||||
### Added
|
||||
|
||||
@@ -19,8 +19,7 @@ Central API service providing infrastructure management, home automation, and ut
|
||||
|
||||
### Utilities
|
||||
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
|
||||
- **Health Checks**: Comprehensive service health monitoring
|
||||
- **AI Metrics Proxy**: Forward metrics requests to Core-AI service
|
||||
- **Health Checks**: Service health monitoring with database connectivity status
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -35,10 +34,8 @@ src/
|
||||
├── clients/
|
||||
│ ├── homeassistant_client.py # Home Assistant REST client
|
||||
│ ├── npm_client.py # Nginx Proxy Manager client
|
||||
│ ├── ollama_client.py # Ollama LLM client
|
||||
│ └── portainer_client.py # Portainer API client
|
||||
├── controllers/
|
||||
│ ├── ai_controller.py # AI metrics proxy
|
||||
│ ├── health_controller.py # Health endpoints
|
||||
│ ├── housekeeping_controller.py # Home automation endpoints
|
||||
│ ├── infrastructure_controller.py # Infrastructure management
|
||||
@@ -88,9 +85,6 @@ src/
|
||||
### Tools (`/tools`)
|
||||
- `POST /tools/dns/lookup` - DNS record lookup
|
||||
|
||||
### AI (`/ai`)
|
||||
- `GET /ai/metrics` - Proxy to Core-AI metrics
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
@@ -103,10 +97,6 @@ src/
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy credentials template
|
||||
cp src/credentials.example.py src/credentials.py
|
||||
# Edit src/credentials.py with your values
|
||||
|
||||
# Run locally
|
||||
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
|
||||
```
|
||||
@@ -169,10 +159,9 @@ docker run -p 8083:8083 core-code:latest
|
||||
| `NPM_PASSWORD` | NPM admin password | - |
|
||||
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
|
||||
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
|
||||
| `OLLAMA_URL` | Ollama API URL | `http://localhost:11434` |
|
||||
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
|
||||
| `OIDC_ISSUER` | OIDC issuer URL | - |
|
||||
| `OIDC_AUDIENCE` | OIDC audience | - |
|
||||
| `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
|
||||
| `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
|
||||
|
||||
## API Documentation
|
||||
|
||||
@@ -183,9 +172,9 @@ Once deployed, access documentation at:
|
||||
|
||||
## Health Checks
|
||||
|
||||
- **Basic**: `GET /health` - Returns status and Ollama connection
|
||||
- **Full**: `GET /health/full` - Returns all component statuses (503 if unhealthy)
|
||||
- **Diagnostics**: `GET /health/diagnostics` - Detailed service information
|
||||
- **Basic**: `GET /health` - Fast liveness check for container orchestration
|
||||
- **Full**: `GET /health/full` - Returns database status (503 if unhealthy)
|
||||
- **Diagnostics**: `GET /health/diagnostics` - Service info and configuration
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Add group_roles mapping and update role schema
|
||||
|
||||
Revision ID: 004
|
||||
Revises: f0349c95aa5d
|
||||
Create Date: 2026-01-03
|
||||
|
||||
Changes:
|
||||
- Add category column to roles (default 'general')
|
||||
- Drop authentik_group column from roles (decoupled architecture)
|
||||
- Create user_groups association table
|
||||
- Create group_roles association table
|
||||
- Update role names from domain:action to domain.general:action
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "004"
|
||||
down_revision: Union[str, None] = "f0349c95aa5d"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add category column to roles
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column(
|
||||
"category",
|
||||
sa.String(50),
|
||||
nullable=False,
|
||||
server_default="general",
|
||||
comment="Permission category within domain (general for full access, or specific tool)",
|
||||
),
|
||||
)
|
||||
|
||||
# Update role names from domain:action to domain.general:action
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE roles
|
||||
SET name = REPLACE(name, ':', '.general:')
|
||||
WHERE name NOT LIKE '%.%:%'
|
||||
"""
|
||||
)
|
||||
|
||||
# Update the comment on the name column
|
||||
op.alter_column(
|
||||
"roles",
|
||||
"name",
|
||||
comment="Role name in format domain.category:action (e.g., control-room.general:admin)",
|
||||
)
|
||||
|
||||
# Drop the authentik_group unique index first
|
||||
op.drop_index("ix_roles_authentik_group", table_name="roles")
|
||||
|
||||
# Drop authentik_group column (no longer needed with group_roles mapping)
|
||||
op.drop_column("roles", "authentik_group")
|
||||
|
||||
# Create user_groups association table
|
||||
op.create_table(
|
||||
"user_groups",
|
||||
sa.Column(
|
||||
"user_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"group_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Create group_roles association table
|
||||
op.create_table(
|
||||
"group_roles",
|
||||
sa.Column(
|
||||
"group_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"role_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop association tables
|
||||
op.drop_table("group_roles")
|
||||
op.drop_table("user_groups")
|
||||
|
||||
# Add back authentik_group column
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column(
|
||||
"authentik_group",
|
||||
sa.String(255),
|
||||
nullable=True,
|
||||
comment="Corresponding Authentik group name",
|
||||
),
|
||||
)
|
||||
|
||||
# Restore authentik_group values from role names
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE roles
|
||||
SET authentik_group = 'tatlock-' || REPLACE(REPLACE(name, '.general:', '-'), ':', '-')
|
||||
"""
|
||||
)
|
||||
|
||||
# Recreate the unique index
|
||||
op.create_index("ix_roles_authentik_group", "roles", ["authentik_group"], unique=True)
|
||||
|
||||
# Revert role names from domain.general:action to domain:action
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE roles
|
||||
SET name = REPLACE(name, '.general:', ':')
|
||||
WHERE name LIKE '%.general:%'
|
||||
"""
|
||||
)
|
||||
|
||||
# Update the comment on the name column
|
||||
op.alter_column(
|
||||
"roles",
|
||||
"name",
|
||||
comment="Role name in format domain:action",
|
||||
)
|
||||
|
||||
# Drop category column
|
||||
op.drop_column("roles", "category")
|
||||
@@ -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]
|
||||
name = "core-api"
|
||||
version = "1.6.1"
|
||||
version = "1.10.7"
|
||||
description = "Core Code API - Infrastructure management and tools API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -21,6 +21,7 @@ python-dotenv~=1.0.0
|
||||
python-json-logger~=2.0.0
|
||||
pytz~=2024.1
|
||||
dnspython~=2.7.0
|
||||
psutil~=6.1.0
|
||||
|
||||
# Authentication & Security
|
||||
PyJWT[crypto]>=2.9.0
|
||||
@@ -31,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
|
||||
|
||||
+70
-7
@@ -13,6 +13,7 @@ from src.logging_config import get_logger
|
||||
from src.db import get_async_session
|
||||
from src.auth.schemas import AuthSyncRequest, AuthSyncResponse, UsersListResponse, BulkSyncResultSchema, GroupsListResponse
|
||||
from src.auth.service import AuthService
|
||||
from src.auth.oidc import get_forward_auth_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -222,21 +223,83 @@ class AuthController(BaseController):
|
||||
responses={
|
||||
200: {"description": "User profile"},
|
||||
401: {"description": "Not authenticated"},
|
||||
404: {"description": "User not found in database"},
|
||||
},
|
||||
)
|
||||
async def get_me(
|
||||
forward_auth_user: Optional[dict] = Depends(get_forward_auth_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
) -> AuthSyncResponse:
|
||||
"""
|
||||
Get the current authenticated user's profile
|
||||
|
||||
Note: This endpoint requires a valid session or API key.
|
||||
For now, returns 501 Not Implemented until session management is added.
|
||||
Authentication is handled by NPM forward auth with Authentik.
|
||||
The proxy sets X-authentik-* headers which this endpoint reads.
|
||||
|
||||
For internal/LAN access (no forward auth headers), returns 401.
|
||||
Use POST /auth/sync with an OIDC token for mobile app authentication.
|
||||
"""
|
||||
# TODO: Implement with get_current_user dependency
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Not implemented - use /auth/sync with access token",
|
||||
# Require forward auth for this endpoint
|
||||
if forward_auth_user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required - access via authenticated proxy or use /auth/sync",
|
||||
)
|
||||
|
||||
service = AuthService(session)
|
||||
|
||||
# Try to find user by Authentik UID first, then by email
|
||||
user = None
|
||||
uid = forward_auth_user.get("uid")
|
||||
if uid:
|
||||
try:
|
||||
import uuid
|
||||
authentik_id = uuid.UUID(uid)
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
except (ValueError, TypeError):
|
||||
pass # Invalid UUID, try email
|
||||
|
||||
if user is None:
|
||||
email = forward_auth_user.get("email")
|
||||
if email:
|
||||
user = await service.get_user_by_email(email)
|
||||
|
||||
if user is None:
|
||||
# User authenticated with Authentik but not synced to database yet
|
||||
# This can happen on first login via web
|
||||
logger.info(f"User {forward_auth_user.get('email')} not found, creating from forward auth")
|
||||
|
||||
# Create user from forward auth headers
|
||||
from src.auth.schemas import TokenInfoSchema
|
||||
token_info = TokenInfoSchema(
|
||||
sub=forward_auth_user.get("uid", ""),
|
||||
email=forward_auth_user.get("email", ""),
|
||||
name=forward_auth_user.get("name"),
|
||||
groups=forward_auth_user.get("groups", []),
|
||||
)
|
||||
|
||||
try:
|
||||
user, _ = await service.sync_user(token_info)
|
||||
await service.sync_roles(user, token_info.groups)
|
||||
await session.commit()
|
||||
await session.refresh(user, ["preferences", "roles"])
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create user from forward auth: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to create user profile",
|
||||
)
|
||||
|
||||
# Sync roles from current groups (in case they changed)
|
||||
groups = forward_auth_user.get("groups", [])
|
||||
roles = await service.sync_roles(user, groups)
|
||||
await session.commit()
|
||||
|
||||
return AuthSyncResponse(
|
||||
user=service.user_to_schema(user),
|
||||
roles=service.roles_to_schema(roles),
|
||||
preferences=service.preferences_to_schema(user.preferences),
|
||||
is_new_user=False,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
+54
-22
@@ -22,29 +22,42 @@ class OIDCConfig:
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.audience = ""
|
||||
self.jwks_uri = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.audiences = audiences
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||
|
||||
def get_jwks_uri(self, issuer: str) -> str:
|
||||
"""Get JWKS URI for a specific issuer"""
|
||||
return f"{issuer.rstrip('/')}/jwks/"
|
||||
|
||||
def is_valid_issuer(self, issuer: str) -> bool:
|
||||
"""Check if issuer is in the allowed list"""
|
||||
normalized = issuer.rstrip('/')
|
||||
return normalized in self.issuers
|
||||
|
||||
|
||||
# Global OIDC config instance
|
||||
oidc_config = OIDCConfig()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
||||
# Per-issuer JWKS cache
|
||||
_jwks_cache: Dict[str, Dict] = {}
|
||||
|
||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||
|
||||
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
Args:
|
||||
issuer: The token issuer URL
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys for token verification
|
||||
@@ -55,15 +68,24 @@ def get_jwks() -> Dict:
|
||||
if not oidc_config.enabled:
|
||||
return {}
|
||||
|
||||
normalized_issuer = issuer.rstrip('/')
|
||||
|
||||
# Return cached JWKS if available
|
||||
if normalized_issuer in _jwks_cache:
|
||||
return _jwks_cache[normalized_issuer]
|
||||
|
||||
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
||||
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||
response = httpx.get(jwks_uri, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
||||
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||
_jwks_cache[normalized_issuer] = jwks
|
||||
return jwks
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable"
|
||||
@@ -109,6 +131,15 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# First, extract issuer from unverified claims to know which JWKS to use
|
||||
unverified_claims = jwt.get_unverified_claims(token)
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
|
||||
# Validate issuer is in our allowed list
|
||||
if not oidc_config.is_valid_issuer(token_issuer):
|
||||
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
|
||||
# Decode token header to get key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
kid = unverified_header.get("kid")
|
||||
@@ -116,8 +147,8 @@ async def get_current_user(
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
# Find matching key in JWKS for this specific issuer
|
||||
jwks = get_jwks_for_issuer(token_issuer)
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
@@ -129,13 +160,14 @@ async def get_current_user(
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
# Verify and decode token (accepts any of the configured audiences)
|
||||
# Use the token's issuer for validation (already verified it's in our allowed list)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
audience=oidc_config.audiences,
|
||||
issuer=token_issuer,
|
||||
)
|
||||
|
||||
user_email = payload.get("email", "unknown")
|
||||
|
||||
+37
-1
@@ -13,7 +13,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.db.models import User, Role, UserPreferences, Group
|
||||
from src.auth.schemas import TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema, UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
||||
@@ -86,6 +86,42 @@ class AuthService:
|
||||
logger.error(f"Authentik userinfo request error: {e}")
|
||||
raise ValueError("Authentication service unavailable")
|
||||
|
||||
async def get_user_by_email(self, email: str) -> Optional[User]:
|
||||
"""
|
||||
Get user by email address
|
||||
|
||||
Args:
|
||||
email: User email address
|
||||
|
||||
Returns:
|
||||
User if found, None otherwise
|
||||
"""
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||
.where(User.email == email)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]:
|
||||
"""
|
||||
Get user by Authentik UUID
|
||||
|
||||
Args:
|
||||
authentik_id: Authentik user UUID
|
||||
|
||||
Returns:
|
||||
User if found, None otherwise
|
||||
"""
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||
.where(User.authentik_id == authentik_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def sync_user(self, token_info: TokenInfoSchema) -> tuple[User, bool]:
|
||||
"""
|
||||
Create or update user from OIDC token info
|
||||
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -7,7 +7,7 @@ import httpx
|
||||
from typing import Optional, Dict, List, Any
|
||||
from datetime import datetime, timedelta
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -8,7 +8,7 @@ import httpx
|
||||
import json
|
||||
from typing import Optional, Dict, List, Any
|
||||
from src.logging_config import get_logger
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
|
||||
logger = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Global configuration for Core Code API
|
||||
|
||||
All configuration is loaded from environment variables or .env file.
|
||||
See .env.example for available settings.
|
||||
"""
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
def _get_version_from_pyproject() -> str:
|
||||
"""Load version from pyproject.toml"""
|
||||
pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
|
||||
try:
|
||||
with open(pyproject_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
return data.get("project", {}).get("version", "0.0.0")
|
||||
except FileNotFoundError:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
__version__ = _get_version_from_pyproject()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Global application settings"""
|
||||
|
||||
# Application
|
||||
app_name: str = "Core Code API"
|
||||
app_version: str = __version__
|
||||
debug: bool = False
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8083
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
|
||||
# Logging
|
||||
log_level: str = "DEBUG"
|
||||
|
||||
# Ollama Configuration (for AI orchestration)
|
||||
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
|
||||
ollama_timeout: int = 300 # 5 minutes
|
||||
|
||||
# Model Configuration
|
||||
default_model: str = "mistral-nemo-large:latest"
|
||||
agent_model: str = "mistral-nemo-large:latest" # Must support tool calling with ADK (~4GB VRAM)
|
||||
code_models: str = "mistral-nemo-large:latest"
|
||||
# Previous config (gemma3:12b used ~10GB VRAM)
|
||||
# default_model: str = "gemma3:12b"
|
||||
# agent_model: str = "gemma3:12b"
|
||||
|
||||
# System Prompt Variant (for A/B testing)
|
||||
# Options: v1_verbose, v2_concise, v3_imperative, v4_minimal, v4_gemini_suggestion, v5_adk_optimized, v7_adk_best_practice, v8_holistic
|
||||
system_prompt_variant: str = "v8_holistic"
|
||||
|
||||
# Agent Configuration
|
||||
agent_fallback_enabled: bool = True
|
||||
|
||||
# Model Aliases (OpenAI → Local)
|
||||
alias_gpt35: str = "gemma:7b"
|
||||
alias_gpt4: str = "mistral:7b"
|
||||
alias_gpt4_turbo: str = "mixtral:8x7b"
|
||||
alias_gpt4_code: str = "codestral:latest"
|
||||
|
||||
# Memory Configuration
|
||||
memory_tier1_max_turns: int = 10
|
||||
memory_consolidation_threshold: int = 10
|
||||
|
||||
# Qdrant Configuration
|
||||
qdrant_host: str = "qdrant"
|
||||
qdrant_port: int = 6333
|
||||
qdrant_collection_conversations: str = "core_api_conversations"
|
||||
qdrant_collection_documents: str = "core_api_documents"
|
||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
||||
|
||||
# Embeddings (using Ollama - no local models needed)
|
||||
embedding_model: str = "nomic-embed-text" # Ollama embedding model
|
||||
embedding_dimension: int = 768 # nomic-embed-text dimension
|
||||
embedding_batch_size: int = 32
|
||||
|
||||
# Search Configuration
|
||||
search_provider: str = "searxng"
|
||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
||||
|
||||
# Infrastructure Management (Portainer)
|
||||
portainer_url: str # Required - set PORTAINER_URL in .env
|
||||
portainer_api_key: str # Required - set PORTAINER_API_KEY in .env
|
||||
|
||||
# Infrastructure Management (Nginx Proxy Manager)
|
||||
npm_url: str # Required - set NPM_URL in .env
|
||||
npm_email: str # Required - set NPM_EMAIL in .env
|
||||
npm_password: str # Required - set NPM_PASSWORD in .env
|
||||
|
||||
# Home Assistant Configuration
|
||||
homeassistant_url: str # Required - set HOMEASSISTANT_URL in .env
|
||||
homeassistant_token: str # Required - set HOMEASSISTANT_TOKEN in .env
|
||||
homeassistant_timeout: int = 30
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres_host: str # Required - set POSTGRES_HOST in .env (e.g., localhost:5432)
|
||||
postgres_user: str = "core_api"
|
||||
postgres_password: str # Required - set POSTGRES_PASSWORD in .env
|
||||
postgres_database: str = "core_api"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Construct database URL from components"""
|
||||
return f"postgresql://{self.postgres_user}:{self.postgres_password}@{self.postgres_host}/{self.postgres_database}"
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False # Set to True to require authentication
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
# Must use domain name (not IP) when AUTHENTIK_COOKIE_DOMAIN is set
|
||||
authentik_url: str = "https://auth.schweitz.net" # Authentik base URL
|
||||
authentik_username: str = "" # Admin username for API access (AUTHENTIK_USERNAME env var)
|
||||
authentik_password: str = "" # Admin password for API access (AUTHENTIK_PASSWORD env var)
|
||||
|
||||
@property
|
||||
def model_aliases(self) -> dict:
|
||||
"""Computed property for model aliases"""
|
||||
return {
|
||||
"gpt-3.5-turbo": self.alias_gpt35,
|
||||
"gpt-4": self.alias_gpt4,
|
||||
"gpt-4-turbo": self.alias_gpt4_turbo,
|
||||
"gpt-4-code": self.alias_gpt4_code,
|
||||
}
|
||||
|
||||
def get_lightweight_models(self) -> list[str]:
|
||||
"""Parse comma-separated lightweight models"""
|
||||
return [m.strip().strip('"').strip("'") for m in self.lightweight_models.split(",") if m.strip()]
|
||||
|
||||
def get_heavy_models(self) -> list[str]:
|
||||
"""Parse comma-separated heavy models"""
|
||||
return [m.strip().strip('"').strip("'") for m in self.heavy_models.split(",") if m.strip()]
|
||||
|
||||
def get_code_models(self) -> list[str]:
|
||||
"""Parse comma-separated code models"""
|
||||
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
extra = "ignore" # Ignore extra env vars not defined in Settings
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance"""
|
||||
return Settings()
|
||||
@@ -7,13 +7,11 @@ from fastapi import APIRouter, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from src.controllers.base import BaseController
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
from src.models.ollama_client import get_ollama_client
|
||||
from src.db import get_database
|
||||
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -73,63 +71,18 @@ class HealthController(BaseController):
|
||||
|
||||
@router.get(
|
||||
"/health/full",
|
||||
summary="Fast health check for Docker",
|
||||
summary="Full health check with database",
|
||||
)
|
||||
async def full_health_check(response: Response):
|
||||
"""
|
||||
Fast health check for container orchestration (Docker/K8s).
|
||||
Health check including database connectivity.
|
||||
|
||||
Checks component availability WITHOUT running expensive operations.
|
||||
Returns 200 OK if all components are available, otherwise 503.
|
||||
|
||||
For detailed diagnostics, use /health/diagnostics instead.
|
||||
Returns 200 OK if database is available, otherwise 503.
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# Check 1: Ollama connection + verify agent model is available
|
||||
ollama_client = get_ollama_client()
|
||||
ollama_healthy = False
|
||||
ollama_error = None
|
||||
model_available = False
|
||||
|
||||
try:
|
||||
# Ping Ollama
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
|
||||
# Verify the agent model is pulled and check what's currently loaded
|
||||
models_info = {}
|
||||
if ollama_healthy:
|
||||
try:
|
||||
models_response = await ollama_client.list_models()
|
||||
available_models = [m.get('name', '') for m in models_response.get('models', [])]
|
||||
model_available = settings.agent_model in available_models
|
||||
|
||||
# Get info about currently loaded models (those with size in memory)
|
||||
loaded_models = [
|
||||
m.get('name', '') for m in models_response.get('models', [])
|
||||
if m.get('size', 0) > 0
|
||||
]
|
||||
|
||||
models_info = {
|
||||
"configured": settings.agent_model,
|
||||
"available": model_available,
|
||||
"total_in_ollama": len(available_models),
|
||||
"currently_loaded": loaded_models if loaded_models else ["none"]
|
||||
}
|
||||
|
||||
if not model_available:
|
||||
ollama_error = f"Model '{settings.agent_model}' not found in Ollama. Available: {', '.join(available_models[:3])}"
|
||||
ollama_healthy = False
|
||||
except Exception as e:
|
||||
ollama_error = f"Could not list Ollama models: {str(e)}"
|
||||
ollama_healthy = False
|
||||
|
||||
except Exception as e:
|
||||
ollama_error = str(e)
|
||||
logger.warning(f"Ollama health check failed: {ollama_error}")
|
||||
|
||||
# Check 2: Database connection
|
||||
# Check database connection
|
||||
database = get_database()
|
||||
db_healthy = False
|
||||
db_error = None
|
||||
@@ -140,27 +93,17 @@ class HealthController(BaseController):
|
||||
db_error = str(e)
|
||||
logger.warning(f"Database health check failed: {db_error}")
|
||||
|
||||
is_healthy = ollama_healthy and db_healthy
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
status_code = 200 if is_healthy else 503
|
||||
status_code = 200 if db_healthy else 503
|
||||
response.status_code = status_code
|
||||
|
||||
return {
|
||||
"status": "healthy" if is_healthy else "unhealthy",
|
||||
"status": "healthy" if db_healthy else "unhealthy",
|
||||
"status_code": status_code,
|
||||
"response_time_ms": elapsed_ms,
|
||||
"components": {
|
||||
"ollama": {
|
||||
"status": "✅ healthy" if ollama_healthy else "❌ unhealthy",
|
||||
"models": models_info if models_info else {
|
||||
"configured": settings.agent_model,
|
||||
"available": False
|
||||
},
|
||||
"error": ollama_error
|
||||
},
|
||||
"database": {
|
||||
"status": "✅ healthy" if db_healthy else "❌ unhealthy",
|
||||
"status": "healthy" if db_healthy else "unhealthy",
|
||||
"error": db_error
|
||||
}
|
||||
}
|
||||
@@ -170,18 +113,13 @@ class HealthController(BaseController):
|
||||
"/health/diagnostics",
|
||||
summary="Detailed system diagnostics",
|
||||
)
|
||||
async def diagnostics(deep_test: bool = False):
|
||||
async def diagnostics():
|
||||
"""
|
||||
Comprehensive system diagnostics with detailed component information.
|
||||
|
||||
Query Parameters:
|
||||
- deep_test: Set to true to actually test agent generation (slow, ~5-10s)
|
||||
|
||||
Returns detailed information about all system components.
|
||||
System diagnostics with service information.
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
diagnostics = {
|
||||
"timestamp": time.time(),
|
||||
"service": {
|
||||
@@ -189,30 +127,9 @@ class HealthController(BaseController):
|
||||
"version": settings.app_version,
|
||||
"purpose": "Infrastructure management and tools API"
|
||||
},
|
||||
"components": {}
|
||||
}
|
||||
|
||||
# 1. Ollama Connection
|
||||
ollama_client = get_ollama_client()
|
||||
try:
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
diagnostics["components"]["ollama"] = {
|
||||
"status": "✅ connected",
|
||||
"url": settings.ollama_base_url,
|
||||
"timeout": settings.ollama_timeout,
|
||||
"default_model": settings.default_model
|
||||
"configuration": {
|
||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||
}
|
||||
except Exception as e:
|
||||
diagnostics["components"]["ollama"] = {
|
||||
"status": "❌ error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# 2. Configuration
|
||||
diagnostics["configuration"] = {
|
||||
"agent_fallback_enabled": settings.agent_fallback_enabled,
|
||||
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
|
||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||
}
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
@@ -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.auth.oidc 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,60 @@ 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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.config import get_settings
|
||||
from src.shared.config import get_settings
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -11,6 +11,12 @@ from src.domains.auth.oidc import (
|
||||
get_forward_auth_user,
|
||||
get_forward_auth_admin,
|
||||
oidc_config,
|
||||
# Permission system
|
||||
require_permission,
|
||||
require_any_permission,
|
||||
ACTION_HIERARCHY,
|
||||
VALID_DOMAINS,
|
||||
DEFAULT_CATEGORY,
|
||||
)
|
||||
from src.domains.auth.service import AuthService, get_auth_service
|
||||
from src.domains.auth.controller import auth_controller
|
||||
@@ -22,6 +28,7 @@ from src.domains.auth.models import (
|
||||
UserPreferences,
|
||||
ApiKey,
|
||||
user_groups,
|
||||
group_roles,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -32,6 +39,12 @@ __all__ = [
|
||||
"get_forward_auth_user",
|
||||
"get_forward_auth_admin",
|
||||
"oidc_config",
|
||||
# Permission system
|
||||
"require_permission",
|
||||
"require_any_permission",
|
||||
"ACTION_HIERARCHY",
|
||||
"VALID_DOMAINS",
|
||||
"DEFAULT_CATEGORY",
|
||||
# Service
|
||||
"AuthService",
|
||||
"get_auth_service",
|
||||
@@ -45,4 +58,5 @@ __all__ = [
|
||||
"UserPreferences",
|
||||
"ApiKey",
|
||||
"user_groups",
|
||||
"group_roles",
|
||||
]
|
||||
|
||||
+377
-14
@@ -3,8 +3,9 @@ Authentication Controller
|
||||
|
||||
Provides authentication endpoints for OIDC token sync and user management.
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -13,9 +14,13 @@ from src.shared.logging import get_logger
|
||||
from src.shared.database import get_async_session
|
||||
from src.domains.auth.schemas import (
|
||||
AuthSyncRequest, AuthSyncResponse, UsersListResponse,
|
||||
BulkSyncResultSchema, GroupsListResponse
|
||||
BulkSyncResultSchema, GroupsListResponse, RolesListResponse,
|
||||
GroupRoleAssignmentResponse, UserProfileResponse, PreferencesUpdateRequest,
|
||||
UserPreferencesSchema, ApiKeyCreateRequest, ApiKeyCreateResponse,
|
||||
ApiKeysListResponse,
|
||||
)
|
||||
from src.domains.auth.service import AuthService
|
||||
from src.domains.auth.oidc import get_current_user, get_current_user_or_forward_auth
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -219,29 +224,387 @@ class AuthController(BaseController):
|
||||
raise HTTPException(status_code=401, detail=str(e))
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
summary="Get current user profile",
|
||||
response_model=AuthSyncResponse,
|
||||
"/roles",
|
||||
summary="List all roles",
|
||||
response_model=RolesListResponse,
|
||||
responses={
|
||||
200: {"description": "User profile"},
|
||||
200: {"description": "List of all available roles"},
|
||||
},
|
||||
)
|
||||
async def list_roles(
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> RolesListResponse:
|
||||
"""
|
||||
List all available roles in the system
|
||||
|
||||
Returns all domain.category:action role combinations.
|
||||
Use these when assigning roles to groups.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
roles = await service.list_roles()
|
||||
return RolesListResponse(
|
||||
items=service.roles_to_schema(roles),
|
||||
total=len(roles),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/groups/{group_id}/roles/{role_id}",
|
||||
summary="Assign role to group",
|
||||
response_model=GroupRoleAssignmentResponse,
|
||||
responses={
|
||||
200: {"description": "Role assigned successfully"},
|
||||
404: {"description": "Group or role not found"},
|
||||
},
|
||||
)
|
||||
async def assign_role_to_group(
|
||||
group_id: uuid.UUID = Path(..., description="Group ID"),
|
||||
role_id: uuid.UUID = Path(..., description="Role ID to assign"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> GroupRoleAssignmentResponse:
|
||||
"""
|
||||
Assign a role to a group
|
||||
|
||||
All users in this group will inherit this role's permissions.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
group = await service.assign_role_to_group(group_id, role_id)
|
||||
await session.commit()
|
||||
return GroupRoleAssignmentResponse(
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
roles=[role.name for role in group.roles],
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@router.delete(
|
||||
"/groups/{group_id}/roles/{role_id}",
|
||||
summary="Remove role from group",
|
||||
response_model=GroupRoleAssignmentResponse,
|
||||
responses={
|
||||
200: {"description": "Role removed successfully"},
|
||||
404: {"description": "Group or role not found"},
|
||||
},
|
||||
)
|
||||
async def remove_role_from_group(
|
||||
group_id: uuid.UUID = Path(..., description="Group ID"),
|
||||
role_id: uuid.UUID = Path(..., description="Role ID to remove"),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> GroupRoleAssignmentResponse:
|
||||
"""
|
||||
Remove a role from a group
|
||||
|
||||
Users in this group will no longer inherit this role's permissions.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
try:
|
||||
group = await service.remove_role_from_group(group_id, role_id)
|
||||
await session.commit()
|
||||
return GroupRoleAssignmentResponse(
|
||||
group_id=group.id,
|
||||
group_name=group.name,
|
||||
roles=[role.name for role in group.roles],
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# =====================================================================
|
||||
# Phase 4: User Profile & Settings
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/users/me",
|
||||
summary="Get current user profile",
|
||||
response_model=UserProfileResponse,
|
||||
responses={
|
||||
200: {"description": "User profile with roles and preferences"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def get_me(
|
||||
async def get_current_user_profile(
|
||||
user_claims: dict = Depends(get_current_user_or_forward_auth),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
) -> UserProfileResponse:
|
||||
"""
|
||||
Get the current authenticated user's profile
|
||||
|
||||
Note: This endpoint requires a valid session or API key.
|
||||
For now, returns 501 Not Implemented until session management is added.
|
||||
Returns the user's profile, roles, and preferences.
|
||||
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).
|
||||
"""
|
||||
# TODO: Implement with get_current_user dependency
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Not implemented - use /auth/sync with access token",
|
||||
service = AuthService(session)
|
||||
|
||||
# Get authentik_id from claims (JWT 'sub' field or forward auth 'uid')
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
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:
|
||||
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(
|
||||
status_code=404,
|
||||
detail="User not found - please sync via /auth/sync first",
|
||||
)
|
||||
|
||||
return UserProfileResponse(
|
||||
user=service.user_to_schema(user),
|
||||
roles=service.roles_to_schema(user.roles),
|
||||
preferences=service.preferences_to_schema(user.preferences),
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/users/me/preferences",
|
||||
summary="Get user preferences",
|
||||
response_model=UserPreferencesSchema,
|
||||
responses={
|
||||
200: {"description": "User preferences"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def get_preferences(
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UserPreferencesSchema:
|
||||
"""
|
||||
Get the current user's preferences
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return service.preferences_to_schema(user.preferences)
|
||||
|
||||
@router.patch(
|
||||
"/users/me/preferences",
|
||||
summary="Update user preferences",
|
||||
response_model=UserPreferencesSchema,
|
||||
responses={
|
||||
200: {"description": "Updated preferences"},
|
||||
401: {"description": "Not authenticated"},
|
||||
422: {"description": "Invalid preference value"},
|
||||
},
|
||||
)
|
||||
async def update_preferences(
|
||||
request: PreferencesUpdateRequest,
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UserPreferencesSchema:
|
||||
"""
|
||||
Update the current user's preferences
|
||||
|
||||
Only provided fields are updated. preferences_json is merged
|
||||
with existing values (not replaced).
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
prefs = await service.update_preferences(
|
||||
user_id=user.id,
|
||||
theme=request.theme,
|
||||
default_room=request.default_room,
|
||||
preferences_json=request.preferences_json,
|
||||
)
|
||||
await session.commit()
|
||||
return service.preferences_to_schema(prefs)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
# =====================================================================
|
||||
# Phase 4: API Keys
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/users/me/api-keys",
|
||||
summary="List user's API keys",
|
||||
response_model=ApiKeysListResponse,
|
||||
responses={
|
||||
200: {"description": "List of API keys"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def list_api_keys(
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ApiKeysListResponse:
|
||||
"""
|
||||
List all API keys for the current user
|
||||
|
||||
Returns key metadata only - the actual key values are never
|
||||
retrievable after creation.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
keys = await service.list_user_api_keys(user.id)
|
||||
return ApiKeysListResponse(
|
||||
items=[service.api_key_to_schema(k) for k in keys],
|
||||
total=len(keys),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/users/me/api-keys",
|
||||
summary="Create a new API key",
|
||||
response_model=ApiKeyCreateResponse,
|
||||
responses={
|
||||
201: {"description": "API key created"},
|
||||
401: {"description": "Not authenticated"},
|
||||
403: {"description": "API keys disabled for user"},
|
||||
},
|
||||
)
|
||||
async def create_api_key(
|
||||
request: ApiKeyCreateRequest,
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ApiKeyCreateResponse:
|
||||
"""
|
||||
Create a new API key for the current user
|
||||
|
||||
**IMPORTANT**: The full API key is only returned once in this response!
|
||||
Store it securely - it cannot be retrieved again.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
api_key, full_key = await service.create_api_key(
|
||||
user_id=user.id,
|
||||
name=request.name,
|
||||
scopes=request.scopes,
|
||||
expires_in_days=request.expires_in_days,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return ApiKeyCreateResponse(
|
||||
id=api_key.id,
|
||||
name=api_key.name,
|
||||
key=full_key, # Only time this is returned!
|
||||
key_prefix=api_key.key_prefix,
|
||||
scopes=api_key.scopes,
|
||||
expires_at=api_key.expires_at,
|
||||
created_at=api_key.created_at,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
@router.delete(
|
||||
"/users/me/api-keys/{key_id}",
|
||||
summary="Delete an API key",
|
||||
responses={
|
||||
204: {"description": "API key deleted"},
|
||||
401: {"description": "Not authenticated"},
|
||||
404: {"description": "API key not found"},
|
||||
},
|
||||
)
|
||||
async def delete_api_key(
|
||||
key_id: uuid.UUID = Path(..., description="API key ID to delete"),
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Delete an API key
|
||||
|
||||
The key will be immediately invalidated.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
deleted = await service.delete_api_key(user.id, key_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
await session.commit()
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ user_groups = Table(
|
||||
Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
group_roles = Table(
|
||||
"group_roles",
|
||||
Base.metadata,
|
||||
Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("role_id", UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Model
|
||||
@@ -114,8 +121,13 @@ class Role(Base):
|
||||
"""
|
||||
Role model for domain-scoped permissions
|
||||
|
||||
Permission format: domain.category:action
|
||||
- domain: Main area (control-room, library, media, ai, etc.)
|
||||
- category: Sub-area within domain (general for full access, or specific tools)
|
||||
- action: Permission level (viewer, user, editor, admin)
|
||||
|
||||
Roles are seeded from configuration, not user-editable.
|
||||
Each role maps to an Authentik group (e.g., tatlock-control-room-admin).
|
||||
Groups are assigned roles via the group_roles mapping table.
|
||||
|
||||
Domains: control-room, library, media, ai, housekeeper, developer, documents, gaming, admin
|
||||
Actions: viewer, user, editor, admin (hierarchical)
|
||||
@@ -133,7 +145,7 @@ class Role(Base):
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
comment="Role name in format domain:action (e.g., control-room:admin)",
|
||||
comment="Role name in format domain.category:action (e.g., control-room.general:admin)",
|
||||
)
|
||||
domain: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
@@ -141,17 +153,17 @@ class Role(Base):
|
||||
index=True,
|
||||
comment="Permission domain (e.g., control-room, media, ai)",
|
||||
)
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="general",
|
||||
comment="Permission category within domain (general for full access, or specific tool)",
|
||||
)
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
comment="Permission action (viewer, user, editor, admin)",
|
||||
)
|
||||
authentik_group: Mapped[str | None] = mapped_column(
|
||||
String(255),
|
||||
nullable=True,
|
||||
unique=True,
|
||||
comment="Corresponding Authentik group name (e.g., tatlock-control-room-admin)",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
users: Mapped[List["User"]] = relationship(
|
||||
@@ -160,6 +172,12 @@ class Role(Base):
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
groups: Mapped[List["Group"]] = relationship(
|
||||
"Group",
|
||||
secondary="group_roles",
|
||||
back_populates="roles",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Role {self.name}>"
|
||||
@@ -247,6 +265,14 @@ class Group(Base):
|
||||
comment="Last sync from Authentik",
|
||||
)
|
||||
|
||||
# Relationships
|
||||
roles: Mapped[List["Role"]] = relationship(
|
||||
"Role",
|
||||
secondary="group_roles",
|
||||
back_populates="groups",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Group {self.name}>"
|
||||
|
||||
|
||||
+438
-25
@@ -3,15 +3,56 @@ OIDC Authentication Module
|
||||
|
||||
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
|
||||
Implements bearer token authentication with JWT verification.
|
||||
|
||||
Permission Format: domain.category:action
|
||||
- domain: Main area (control-room, library, media, ai, etc.)
|
||||
- category: Sub-area within domain (general for full domain, or specific tools)
|
||||
- action: Permission level (viewer, user, editor, admin)
|
||||
|
||||
Examples:
|
||||
- control-room.general:admin - Full access to Control Room
|
||||
- media.general:viewer - View-only access to Media area
|
||||
|
||||
Action Hierarchy (higher implies lower):
|
||||
- admin > editor > user > viewer
|
||||
"""
|
||||
from fastapi import Depends, HTTPException, Security, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
import httpx
|
||||
from functools import lru_cache
|
||||
from typing import Dict, Optional
|
||||
from typing import Callable, Dict, List, Optional
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Permission System
|
||||
# =============================================================================
|
||||
|
||||
# Action hierarchy: higher actions imply lower ones
|
||||
ACTION_HIERARCHY: Dict[str, int] = {
|
||||
"viewer": 1,
|
||||
"user": 2,
|
||||
"editor": 3,
|
||||
"admin": 4,
|
||||
}
|
||||
|
||||
# Valid domains (main areas)
|
||||
VALID_DOMAINS = {
|
||||
"control-room",
|
||||
"library",
|
||||
"media",
|
||||
"ai",
|
||||
"housekeeper",
|
||||
"developer",
|
||||
"documents",
|
||||
"gaming",
|
||||
"admin", # Global admin domain
|
||||
}
|
||||
|
||||
# Default category for general domain access
|
||||
DEFAULT_CATEGORY = "general"
|
||||
|
||||
logger = get_logger(__name__)
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
@@ -22,29 +63,42 @@ class OIDCConfig:
|
||||
def __init__(self):
|
||||
# These will be set from environment variables in config.py
|
||||
self.enabled = False
|
||||
self.issuer = ""
|
||||
self.audience = ""
|
||||
self.jwks_uri = ""
|
||||
self.issuers: list[str] = []
|
||||
self.audiences: list[str] = []
|
||||
|
||||
def configure(self, enabled: bool, issuer: str, audience: str):
|
||||
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
|
||||
"""Configure OIDC settings"""
|
||||
self.enabled = enabled
|
||||
self.issuer = issuer
|
||||
self.audience = audience
|
||||
self.jwks_uri = f"{issuer.rstrip('/')}/jwks/"
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuer={issuer}")
|
||||
self.issuers = [iss.rstrip('/') for iss in issuers] # Normalize without trailing slash
|
||||
self.audiences = audiences
|
||||
logger.info(f"OIDC configured: enabled={enabled}, issuers={self.issuers}, audiences={audiences}")
|
||||
|
||||
def get_jwks_uri(self, issuer: str) -> str:
|
||||
"""Get JWKS URI for a specific issuer"""
|
||||
return f"{issuer.rstrip('/')}/jwks/"
|
||||
|
||||
def is_valid_issuer(self, issuer: str) -> bool:
|
||||
"""Check if issuer is in the allowed list"""
|
||||
normalized = issuer.rstrip('/')
|
||||
return normalized in self.issuers
|
||||
|
||||
|
||||
# Global OIDC config instance
|
||||
oidc_config = OIDCConfig()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks() -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) from Authentik
|
||||
# Per-issuer JWKS cache
|
||||
_jwks_cache: Dict[str, Dict] = {}
|
||||
|
||||
Cached to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
def get_jwks_for_issuer(issuer: str) -> Dict:
|
||||
"""
|
||||
Fetch JSON Web Key Set (JWKS) for a specific issuer.
|
||||
|
||||
Cached per-issuer to avoid repeated requests. Cache is cleared on server restart.
|
||||
|
||||
Args:
|
||||
issuer: The token issuer URL
|
||||
|
||||
Returns:
|
||||
JWKS dictionary containing public keys for token verification
|
||||
@@ -55,15 +109,24 @@ def get_jwks() -> Dict:
|
||||
if not oidc_config.enabled:
|
||||
return {}
|
||||
|
||||
normalized_issuer = issuer.rstrip('/')
|
||||
|
||||
# Return cached JWKS if available
|
||||
if normalized_issuer in _jwks_cache:
|
||||
return _jwks_cache[normalized_issuer]
|
||||
|
||||
jwks_uri = oidc_config.get_jwks_uri(normalized_issuer)
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
|
||||
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
|
||||
logger.debug(f"Fetching JWKS from {jwks_uri}")
|
||||
response = httpx.get(jwks_uri, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
jwks = response.json()
|
||||
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
|
||||
logger.info(f"JWKS fetched successfully for {normalized_issuer} ({len(jwks.get('keys', []))} keys)")
|
||||
_jwks_cache[normalized_issuer] = jwks
|
||||
return jwks
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch JWKS: {e}")
|
||||
logger.error(f"Failed to fetch JWKS from {jwks_uri}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Authentication service unavailable"
|
||||
@@ -116,15 +179,23 @@ async def get_current_user(
|
||||
token = credentials.credentials
|
||||
|
||||
try:
|
||||
# Decode token header to get key ID
|
||||
# First, decode token without verification to get issuer and key ID
|
||||
unverified_header = jwt.get_unverified_header(token)
|
||||
unverified_claims = jwt.get_unverified_claims(token)
|
||||
|
||||
kid = unverified_header.get("kid")
|
||||
token_issuer = unverified_claims.get("iss", "")
|
||||
|
||||
if not kid:
|
||||
raise HTTPException(status_code=401, detail="Invalid token format")
|
||||
|
||||
# Find matching key in JWKS
|
||||
jwks = get_jwks()
|
||||
# Validate issuer is in allowed list
|
||||
if not oidc_config.is_valid_issuer(token_issuer):
|
||||
logger.warning(f"Invalid token issuer: {token_issuer}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token issuer")
|
||||
|
||||
# Get JWKS for this specific issuer
|
||||
jwks = get_jwks_for_issuer(token_issuer)
|
||||
rsa_key = None
|
||||
|
||||
for key in jwks.get("keys", []):
|
||||
@@ -136,17 +207,17 @@ async def get_current_user(
|
||||
logger.warning(f"No matching key found for kid: {kid}")
|
||||
raise HTTPException(status_code=401, detail="Invalid token key")
|
||||
|
||||
# Verify and decode token
|
||||
# Verify and decode token using the token's actual issuer
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
rsa_key,
|
||||
algorithms=["RS256"],
|
||||
audience=oidc_config.audience,
|
||||
issuer=oidc_config.issuer,
|
||||
audience=oidc_config.audiences,
|
||||
issuer=token_issuer, # Use the token's issuer for validation
|
||||
)
|
||||
|
||||
user_email = payload.get("email", "unknown")
|
||||
logger.info(f"Authenticated user: {user_email}")
|
||||
logger.info(f"Authenticated user: {user_email} (issuer: {token_issuer})")
|
||||
|
||||
return payload
|
||||
|
||||
@@ -352,3 +423,345 @@ async def get_forward_auth_admin(
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_or_forward_auth(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
|
||||
) -> Dict:
|
||||
"""
|
||||
Combined auth: Try forward auth headers first, then JWT Bearer token.
|
||||
|
||||
Supports both:
|
||||
- Web clients via NPM forward auth (X-authentik-* headers from proxy)
|
||||
- Mobile/native clients via OIDC JWT Bearer tokens
|
||||
|
||||
This is the preferred dependency for /auth/users/me and similar endpoints
|
||||
that need to work with both web (cookie-based via NPM) and mobile (token-based).
|
||||
|
||||
Args:
|
||||
request: FastAPI request object containing headers
|
||||
credentials: HTTP Bearer token from Authorization header
|
||||
|
||||
Returns:
|
||||
User claims dictionary with at minimum: sub, email, name, groups, auth_method
|
||||
|
||||
Raises:
|
||||
HTTPException 401: If neither forward auth headers nor valid JWT provided
|
||||
"""
|
||||
# 1. Try forward auth headers first (web via NPM)
|
||||
username = request.headers.get("x-authentik-username")
|
||||
email = request.headers.get("x-authentik-email")
|
||||
|
||||
if username and email:
|
||||
# Forward auth headers present - use them
|
||||
groups = request.headers.get("x-authentik-groups", "")
|
||||
name = request.headers.get("x-authentik-name", username)
|
||||
uid = request.headers.get("x-authentik-uid")
|
||||
|
||||
user_info = {
|
||||
"sub": uid, # Use authentik UID as subject (for user lookup)
|
||||
"email": email,
|
||||
"preferred_username": username,
|
||||
"name": name,
|
||||
"groups": [g.strip() for g in groups.split(",")] if groups else [],
|
||||
"auth_method": "forward_auth"
|
||||
}
|
||||
|
||||
logger.info(f"Authenticated via forward auth: {email}")
|
||||
return user_info
|
||||
|
||||
# 2. Fall back to JWT Bearer token (mobile/native)
|
||||
return await get_current_user(credentials)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Permission-Based Access Control
|
||||
# =============================================================================
|
||||
|
||||
def _parse_permission(permission: str) -> tuple[str, str, str]:
|
||||
"""
|
||||
Parse a permission string into (domain, category, action)
|
||||
|
||||
Supports formats:
|
||||
- domain.category:action (full): "control-room.general:admin"
|
||||
- domain:action (shorthand): "control-room:admin" -> ("control-room", "general", "admin")
|
||||
|
||||
Returns:
|
||||
Tuple of (domain, category, action)
|
||||
|
||||
Raises:
|
||||
ValueError: If permission format is invalid
|
||||
"""
|
||||
# Split on colon first to get action
|
||||
if ":" not in permission:
|
||||
raise ValueError(f"Invalid permission format (missing ':'): {permission}")
|
||||
|
||||
location, action = permission.rsplit(":", 1)
|
||||
|
||||
# Split location on dot to get domain and category
|
||||
if "." in location:
|
||||
domain, category = location.split(".", 1)
|
||||
else:
|
||||
# Shorthand: domain:action -> domain.general:action
|
||||
domain = location
|
||||
category = DEFAULT_CATEGORY
|
||||
|
||||
return domain, category, action
|
||||
|
||||
|
||||
def _action_satisfies(user_action: str, required_action: str) -> bool:
|
||||
"""
|
||||
Check if user's action level satisfies the required action
|
||||
|
||||
Due to hierarchy, admin satisfies editor, editor satisfies user, etc.
|
||||
|
||||
Args:
|
||||
user_action: The action the user has
|
||||
required_action: The action required for access
|
||||
|
||||
Returns:
|
||||
True if user's action is >= required action
|
||||
"""
|
||||
user_level = ACTION_HIERARCHY.get(user_action, 0)
|
||||
required_level = ACTION_HIERARCHY.get(required_action, 0)
|
||||
return user_level >= required_level
|
||||
|
||||
|
||||
def _user_has_permission(
|
||||
user_permissions: List[str],
|
||||
required_domain: str,
|
||||
required_category: str,
|
||||
required_action: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if user has a permission that satisfies the requirement
|
||||
|
||||
Checks:
|
||||
1. Exact match: domain.category:action
|
||||
2. Domain-wide: domain.general:action (if category != general)
|
||||
3. Global admin: admin.general:admin (superuser)
|
||||
|
||||
Args:
|
||||
user_permissions: List of user's permission strings
|
||||
required_domain: Required domain
|
||||
required_category: Required category
|
||||
required_action: Required action
|
||||
|
||||
Returns:
|
||||
True if user has sufficient permission
|
||||
"""
|
||||
for perm in user_permissions:
|
||||
try:
|
||||
dom, cat, act = _parse_permission(perm)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Global admin (admin.general:admin) grants all permissions
|
||||
if dom == "admin" and cat == "general" and act == "admin":
|
||||
return True
|
||||
|
||||
# Check if this permission covers the requirement
|
||||
if dom == required_domain:
|
||||
# Exact category match
|
||||
if cat == required_category and _action_satisfies(act, required_action):
|
||||
return True
|
||||
# Domain-wide permission (general category) covers all categories in domain
|
||||
if cat == "general" and _action_satisfies(act, required_action):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _extract_permissions_from_groups(groups: List[str]) -> List[str]:
|
||||
"""
|
||||
Extract permission strings from Authentik group names
|
||||
|
||||
Authentik groups follow naming: tatlock-{domain}-{category}-{action}
|
||||
or shorthand: tatlock-{domain}-{action} (implies category=general)
|
||||
|
||||
Examples:
|
||||
- tatlock-control-room-general-admin -> control-room.general:admin
|
||||
- tatlock-media-viewer -> media.general:viewer (shorthand)
|
||||
- tatlock-tools-dns-user -> tools.dns:user
|
||||
|
||||
Args:
|
||||
groups: List of Authentik group names
|
||||
|
||||
Returns:
|
||||
List of permission strings
|
||||
"""
|
||||
permissions = []
|
||||
|
||||
for group in groups:
|
||||
if not group.startswith("tatlock-"):
|
||||
continue
|
||||
|
||||
# Remove prefix
|
||||
parts = group[8:].split("-") # Remove "tatlock-"
|
||||
|
||||
if len(parts) >= 3:
|
||||
# Could be domain-category-action or domain-with-hyphen-action
|
||||
# Try to find a valid action at the end
|
||||
action = parts[-1]
|
||||
if action in ACTION_HIERARCHY:
|
||||
# Check if domain-category or single domain with hyphen
|
||||
remaining = parts[:-1]
|
||||
|
||||
# Try to find known domain (greedy match from start)
|
||||
for i in range(len(remaining), 0, -1):
|
||||
potential_domain = "-".join(remaining[:i])
|
||||
if potential_domain in VALID_DOMAINS:
|
||||
category_parts = remaining[i:]
|
||||
category = "-".join(category_parts) if category_parts else DEFAULT_CATEGORY
|
||||
permissions.append(f"{potential_domain}.{category}:{action}")
|
||||
break
|
||||
elif len(parts) == 2:
|
||||
# Shorthand: domain-action (domain might have hyphen)
|
||||
action = parts[-1]
|
||||
if action in ACTION_HIERARCHY:
|
||||
domain = parts[0]
|
||||
if domain in VALID_DOMAINS:
|
||||
permissions.append(f"{domain}.{DEFAULT_CATEGORY}:{action}")
|
||||
|
||||
return permissions
|
||||
|
||||
|
||||
def require_permission(
|
||||
domain: str,
|
||||
action: str,
|
||||
category: str = DEFAULT_CATEGORY,
|
||||
) -> Callable:
|
||||
"""
|
||||
Dependency factory for permission-based access control
|
||||
|
||||
Creates a FastAPI dependency that checks if the current user has
|
||||
the required permission. Considers action hierarchy and global admin.
|
||||
|
||||
Usage:
|
||||
@router.get("/containers")
|
||||
async def list_containers(
|
||||
user: Dict = Depends(require_permission("control-room", "viewer"))
|
||||
):
|
||||
...
|
||||
|
||||
@router.delete("/container/{id}")
|
||||
async def delete_container(
|
||||
user: Dict = Depends(require_permission("control-room", "admin"))
|
||||
):
|
||||
...
|
||||
|
||||
Args:
|
||||
domain: Permission domain (e.g., "control-room", "media")
|
||||
action: Required action level (viewer, user, editor, admin)
|
||||
category: Permission category within domain, defaults to "general"
|
||||
|
||||
Returns:
|
||||
FastAPI dependency function
|
||||
"""
|
||||
perm_str = f"{domain}.{category}:{action}"
|
||||
|
||||
async def permission_checker(
|
||||
user: Optional[Dict] = Depends(get_current_user)
|
||||
) -> Dict:
|
||||
"""Check if user has required permission"""
|
||||
|
||||
# If OIDC disabled, allow all (local dev mode)
|
||||
if not oidc_config.enabled:
|
||||
logger.debug(f"OIDC disabled - allowing {perm_str}")
|
||||
return user or {"email": "local", "groups": ["admin"]}
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extract permissions from user's groups
|
||||
groups = user.get("groups", [])
|
||||
permissions = _extract_permissions_from_groups(groups)
|
||||
|
||||
# Check if user has required permission
|
||||
if _user_has_permission(permissions, domain, category, action):
|
||||
logger.debug(f"User {user.get('email')} granted {perm_str}")
|
||||
return user
|
||||
|
||||
# Permission denied
|
||||
user_email = user.get("email", "unknown")
|
||||
logger.warning(
|
||||
f"User {user_email} denied {perm_str} "
|
||||
f"(groups: {groups}, permissions: {permissions})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Permission required: {perm_str}",
|
||||
)
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
def require_any_permission(*required_permissions: str) -> Callable:
|
||||
"""
|
||||
Dependency factory requiring any one of multiple permissions
|
||||
|
||||
Useful for endpoints accessible to multiple roles.
|
||||
|
||||
Usage:
|
||||
@router.get("/shared-resource")
|
||||
async def get_shared(
|
||||
user: Dict = Depends(require_any_permission(
|
||||
"control-room:viewer",
|
||||
"media:viewer",
|
||||
))
|
||||
):
|
||||
...
|
||||
|
||||
Args:
|
||||
*required_permissions: Permission strings (domain.category:action or domain:action)
|
||||
|
||||
Returns:
|
||||
FastAPI dependency function
|
||||
"""
|
||||
|
||||
async def permission_checker(
|
||||
user: Optional[Dict] = Depends(get_current_user)
|
||||
) -> Dict:
|
||||
"""Check if user has any of the required permissions"""
|
||||
|
||||
# If OIDC disabled, allow all
|
||||
if not oidc_config.enabled:
|
||||
return user or {"email": "local", "groups": ["admin"]}
|
||||
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
groups = user.get("groups", [])
|
||||
permissions = _extract_permissions_from_groups(groups)
|
||||
|
||||
# Check each required permission
|
||||
for perm in required_permissions:
|
||||
try:
|
||||
dom, cat, act = _parse_permission(perm)
|
||||
if _user_has_permission(permissions, dom, cat, act):
|
||||
logger.debug(f"User {user.get('email')} granted via {perm}")
|
||||
return user
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid permission format: {perm}")
|
||||
continue
|
||||
|
||||
# None matched
|
||||
user_email = user.get("email", "unknown")
|
||||
logger.warning(
|
||||
f"User {user_email} denied (required any of: {required_permissions})"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"One of these permissions required: {', '.join(required_permissions)}",
|
||||
)
|
||||
|
||||
return permission_checker
|
||||
|
||||
@@ -26,10 +26,12 @@ class AuthSyncRequest(BaseSchema):
|
||||
|
||||
|
||||
class RoleSchema(BaseSchema):
|
||||
"""Role information in domain:action format"""
|
||||
"""Role information in domain.category:action format"""
|
||||
|
||||
name: str = Field(..., description="Role name (e.g., 'control-room:admin')")
|
||||
id: uuid.UUID = Field(..., description="Role ID")
|
||||
name: str = Field(..., description="Role name (e.g., 'control-room.general:admin')")
|
||||
domain: str = Field(..., description="Permission domain (e.g., 'control-room')")
|
||||
category: str = Field(default="general", description="Permission category (e.g., 'general')")
|
||||
action: str = Field(..., description="Permission action (e.g., 'admin')")
|
||||
|
||||
|
||||
@@ -120,6 +122,7 @@ class GroupListItemSchema(BaseSchema):
|
||||
parent_name: Optional[str] = Field(None, description="Parent group name")
|
||||
member_count: int = Field(default=0, description="Number of users in this group")
|
||||
synced_at: datetime = Field(..., description="Last sync timestamp")
|
||||
roles: list[str] = Field(default_factory=list, description="Assigned role names")
|
||||
|
||||
|
||||
class GroupsListResponse(BaseSchema):
|
||||
@@ -127,3 +130,82 @@ class GroupsListResponse(BaseSchema):
|
||||
|
||||
items: list[GroupListItemSchema] = Field(..., description="List of groups")
|
||||
total: int = Field(..., description="Total count of groups")
|
||||
|
||||
|
||||
class RolesListResponse(BaseSchema):
|
||||
"""Response from GET /auth/roles"""
|
||||
|
||||
items: list[RoleSchema] = Field(..., description="List of all roles")
|
||||
total: int = Field(..., description="Total count of roles")
|
||||
|
||||
|
||||
class GroupRoleAssignmentResponse(BaseSchema):
|
||||
"""Response from group role assignment operations"""
|
||||
|
||||
group_id: uuid.UUID = Field(..., description="Group ID")
|
||||
group_name: str = Field(..., description="Group name")
|
||||
roles: list[str] = Field(..., description="Currently assigned role names")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Profile (Phase 4)
|
||||
# =============================================================================
|
||||
|
||||
class UserProfileResponse(BaseSchema):
|
||||
"""Response from GET /users/me - full user profile"""
|
||||
|
||||
user: UserSchema = Field(..., description="User profile")
|
||||
roles: list[RoleSchema] = Field(..., description="User's permission roles")
|
||||
preferences: UserPreferencesSchema = Field(..., description="User preferences")
|
||||
|
||||
|
||||
class PreferencesUpdateRequest(BaseSchema):
|
||||
"""Request for PATCH /users/me/preferences"""
|
||||
|
||||
theme: Optional[str] = Field(None, description="Theme preference: system, light, dark")
|
||||
default_room: Optional[str] = Field(None, description="Default room for housekeeping")
|
||||
preferences_json: Optional[dict] = Field(None, description="Extended preferences (merged)")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# API Keys (Phase 4)
|
||||
# =============================================================================
|
||||
|
||||
class ApiKeyCreateRequest(BaseSchema):
|
||||
"""Request for POST /users/me/api-keys"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="Human-readable key name")
|
||||
scopes: Optional[list[str]] = Field(None, description="Optional scope restriction (role names)")
|
||||
expires_in_days: Optional[int] = Field(None, ge=1, le=365, description="Days until expiration (optional)")
|
||||
|
||||
|
||||
class ApiKeyCreateResponse(BaseSchema):
|
||||
"""Response from POST /users/me/api-keys - includes the key (shown only once)"""
|
||||
|
||||
id: uuid.UUID = Field(..., description="API key ID")
|
||||
name: str = Field(..., description="Key name")
|
||||
key: str = Field(..., description="The API key (shown only once!)")
|
||||
key_prefix: str = Field(..., description="Key prefix for identification")
|
||||
scopes: Optional[list[str]] = Field(None, description="Scope restriction")
|
||||
expires_at: Optional[datetime] = Field(None, description="Expiration timestamp")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
|
||||
|
||||
class ApiKeySchema(BaseSchema):
|
||||
"""API key information (without the actual key)"""
|
||||
|
||||
id: uuid.UUID = Field(..., description="API key ID")
|
||||
name: str = Field(..., description="Key name")
|
||||
key_prefix: str = Field(..., description="Key prefix for identification (e.g., 'tak_abc1')")
|
||||
scopes: Optional[list[str]] = Field(None, description="Scope restriction")
|
||||
expires_at: Optional[datetime] = Field(None, description="Expiration timestamp")
|
||||
last_used_at: Optional[datetime] = Field(None, description="Last usage timestamp")
|
||||
created_at: datetime = Field(..., description="Creation timestamp")
|
||||
is_expired: bool = Field(..., description="Whether the key has expired")
|
||||
|
||||
|
||||
class ApiKeysListResponse(BaseSchema):
|
||||
"""Response from GET /users/me/api-keys"""
|
||||
|
||||
items: list[ApiKeySchema] = Field(..., description="List of API keys")
|
||||
total: int = Field(..., description="Total count of keys")
|
||||
|
||||
+466
-17
@@ -3,9 +3,11 @@ Authentication Service
|
||||
|
||||
Business logic for user synchronization from Authentik.
|
||||
"""
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
@@ -15,10 +17,11 @@ from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.shared.config import get_settings
|
||||
from src.shared.logging import get_logger
|
||||
from src.domains.auth.models import User, Role, UserPreferences, Group
|
||||
from src.domains.auth.models import User, Role, UserPreferences, Group, ApiKey
|
||||
from src.domains.auth.schemas import (
|
||||
TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema,
|
||||
UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
|
||||
UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema,
|
||||
ApiKeySchema, ApiKeyCreateResponse,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -99,7 +102,13 @@ class AuthService:
|
||||
Returns:
|
||||
Tuple of (User, is_new_user)
|
||||
"""
|
||||
authentik_id = uuid.UUID(token_info.sub)
|
||||
# Parse authentik_id - may be UUID or other format
|
||||
try:
|
||||
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
|
||||
stmt = (
|
||||
@@ -121,11 +130,14 @@ class AuthService:
|
||||
avatar_url=token_info.picture,
|
||||
last_login=datetime.now(timezone.utc),
|
||||
)
|
||||
# Initialize relationships to avoid lazy loading issues in async
|
||||
user.roles = []
|
||||
self.session.add(user)
|
||||
await self.session.flush() # Get the user ID
|
||||
|
||||
# Create default preferences
|
||||
# Create default preferences and attach to user
|
||||
preferences = UserPreferences(user_id=user.id)
|
||||
user.preferences = preferences
|
||||
self.session.add(preferences)
|
||||
|
||||
logger.info(f"Created new user: {token_info.email}")
|
||||
@@ -141,30 +153,111 @@ class AuthService:
|
||||
await self.session.flush()
|
||||
return user, is_new
|
||||
|
||||
async def sync_roles(self, user: User, groups: list[str]) -> list[Role]:
|
||||
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]:
|
||||
"""
|
||||
Synchronize user roles from Authentik groups
|
||||
Create or update user from forward auth claims (NPM X-authentik-* headers)
|
||||
|
||||
Maps Authentik groups (e.g., 'tatlock-control-room-admin')
|
||||
to application roles (e.g., 'control-room:admin').
|
||||
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]:
|
||||
"""
|
||||
Synchronize user roles from Authentik groups via group_roles mapping
|
||||
|
||||
Looks up the user's groups in the database, then retrieves all roles
|
||||
assigned to those groups via the group_roles mapping table.
|
||||
|
||||
Args:
|
||||
user: User to sync roles for
|
||||
groups: List of Authentik group names
|
||||
group_names: List of Authentik group names
|
||||
|
||||
Returns:
|
||||
List of synced Role objects
|
||||
"""
|
||||
# Get all roles that match the user's Authentik groups
|
||||
stmt = select(Role).where(Role.authentik_group.in_(groups))
|
||||
# Find local Group records matching the Authentik group names
|
||||
stmt = (
|
||||
select(Group)
|
||||
.options(selectinload(Group.roles))
|
||||
.where(Group.name.in_(group_names))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
matching_roles = list(result.scalars().all())
|
||||
matching_groups = list(result.scalars().all())
|
||||
|
||||
# Collect all unique roles from all matching groups
|
||||
roles_set: dict[uuid.UUID, Role] = {}
|
||||
for group in matching_groups:
|
||||
for role in group.roles:
|
||||
roles_set[role.id] = role
|
||||
|
||||
matching_roles = list(roles_set.values())
|
||||
|
||||
# Clear existing roles and set new ones
|
||||
user.roles = matching_roles
|
||||
|
||||
role_names = [r.name for r in matching_roles]
|
||||
logger.info(f"Synced roles for {user.email}: {role_names}")
|
||||
group_names_found = [g.name for g in matching_groups]
|
||||
logger.info(f"Synced roles for {user.email} via groups {group_names_found}: {role_names}")
|
||||
|
||||
return matching_roles
|
||||
|
||||
@@ -183,7 +276,7 @@ class AuthService:
|
||||
def roles_to_schema(self, roles: list[Role]) -> list[RoleSchema]:
|
||||
"""Convert Role models to schemas"""
|
||||
return [
|
||||
RoleSchema(name=r.name, domain=r.domain, action=r.action)
|
||||
RoleSchema(id=r.id, name=r.name, domain=r.domain, category=r.category, action=r.action)
|
||||
for r in roles
|
||||
]
|
||||
|
||||
@@ -492,8 +585,8 @@ class AuthService:
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Base query
|
||||
base_query = select(Group)
|
||||
# Base query with roles loaded
|
||||
base_query = select(Group).options(selectinload(Group.roles))
|
||||
|
||||
# Apply search filter if provided
|
||||
if search:
|
||||
@@ -520,12 +613,106 @@ class AuthService:
|
||||
parent_name=group.parent_name,
|
||||
member_count=group.member_count,
|
||||
synced_at=group.synced_at,
|
||||
roles=[role.name for role in group.roles],
|
||||
)
|
||||
for group in groups
|
||||
]
|
||||
|
||||
return items, total
|
||||
|
||||
async def list_roles(self) -> list[Role]:
|
||||
"""
|
||||
List all available roles
|
||||
|
||||
Returns:
|
||||
List of all Role objects
|
||||
"""
|
||||
stmt = select(Role).order_by(Role.domain, Role.category, Role.action)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_group_by_id(self, group_id: uuid.UUID) -> Optional[Group]:
|
||||
"""
|
||||
Get a group by its ID with roles loaded
|
||||
|
||||
Args:
|
||||
group_id: The group's UUID
|
||||
|
||||
Returns:
|
||||
Group object or None if not found
|
||||
"""
|
||||
stmt = (
|
||||
select(Group)
|
||||
.options(selectinload(Group.roles))
|
||||
.where(Group.id == group_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def assign_role_to_group(self, group_id: uuid.UUID, role_id: uuid.UUID) -> Group:
|
||||
"""
|
||||
Assign a role to a group
|
||||
|
||||
Args:
|
||||
group_id: The group's UUID
|
||||
role_id: The role's UUID to assign
|
||||
|
||||
Returns:
|
||||
Updated Group object
|
||||
|
||||
Raises:
|
||||
ValueError: If group or role not found
|
||||
"""
|
||||
group = await self.get_group_by_id(group_id)
|
||||
if not group:
|
||||
raise ValueError(f"Group not found: {group_id}")
|
||||
|
||||
stmt = select(Role).where(Role.id == role_id)
|
||||
result = await self.session.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
if not role:
|
||||
raise ValueError(f"Role not found: {role_id}")
|
||||
|
||||
# Add role if not already assigned
|
||||
if role not in group.roles:
|
||||
group.roles.append(role)
|
||||
await self.session.flush()
|
||||
logger.info(f"Assigned role {role.name} to group {group.name}")
|
||||
|
||||
return group
|
||||
|
||||
async def remove_role_from_group(self, group_id: uuid.UUID, role_id: uuid.UUID) -> Group:
|
||||
"""
|
||||
Remove a role from a group
|
||||
|
||||
Args:
|
||||
group_id: The group's UUID
|
||||
role_id: The role's UUID to remove
|
||||
|
||||
Returns:
|
||||
Updated Group object
|
||||
|
||||
Raises:
|
||||
ValueError: If group or role not found
|
||||
"""
|
||||
group = await self.get_group_by_id(group_id)
|
||||
if not group:
|
||||
raise ValueError(f"Group not found: {group_id}")
|
||||
|
||||
stmt = select(Role).where(Role.id == role_id)
|
||||
result = await self.session.execute(stmt)
|
||||
role = result.scalar_one_or_none()
|
||||
if not role:
|
||||
raise ValueError(f"Role not found: {role_id}")
|
||||
|
||||
# Remove role if assigned
|
||||
if role in group.roles:
|
||||
group.roles.remove(role)
|
||||
await self.session.flush()
|
||||
logger.info(f"Removed role {role.name} from group {group.name}")
|
||||
|
||||
return group
|
||||
|
||||
async def bulk_sync_groups_from_authentik(self) -> BulkSyncResultSchema:
|
||||
"""
|
||||
Fetch all groups from Authentik admin API and sync to local database
|
||||
@@ -627,6 +814,268 @@ class AuthService:
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Phase 4: User Profile & Settings
|
||||
# =========================================================================
|
||||
|
||||
async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]:
|
||||
"""
|
||||
Get user by Authentik UUID with roles and preferences loaded
|
||||
|
||||
Args:
|
||||
authentik_id: The Authentik user UUID (from JWT 'sub' claim)
|
||||
|
||||
Returns:
|
||||
User object or None if not found
|
||||
"""
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||
.where(User.authentik_id == authentik_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_user_by_id(self, user_id: uuid.UUID) -> Optional[User]:
|
||||
"""
|
||||
Get user by internal UUID with roles and preferences loaded
|
||||
|
||||
Args:
|
||||
user_id: The internal user UUID
|
||||
|
||||
Returns:
|
||||
User object or None if not found
|
||||
"""
|
||||
stmt = (
|
||||
select(User)
|
||||
.options(selectinload(User.roles), selectinload(User.preferences))
|
||||
.where(User.id == user_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def update_preferences(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
theme: Optional[str] = None,
|
||||
default_room: Optional[str] = None,
|
||||
preferences_json: Optional[dict] = None,
|
||||
) -> UserPreferences:
|
||||
"""
|
||||
Update user preferences
|
||||
|
||||
Args:
|
||||
user_id: User's UUID
|
||||
theme: New theme value (or None to keep existing)
|
||||
default_room: New default room (or None to keep existing)
|
||||
preferences_json: JSON to merge with existing (or None to keep existing)
|
||||
|
||||
Returns:
|
||||
Updated UserPreferences object
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found
|
||||
"""
|
||||
stmt = select(UserPreferences).where(UserPreferences.user_id == user_id)
|
||||
result = await self.session.execute(stmt)
|
||||
prefs = result.scalar_one_or_none()
|
||||
|
||||
if prefs is None:
|
||||
# Create preferences if they don't exist
|
||||
prefs = UserPreferences(user_id=user_id)
|
||||
self.session.add(prefs)
|
||||
|
||||
if theme is not None:
|
||||
if theme not in ("system", "light", "dark"):
|
||||
raise ValueError(f"Invalid theme: {theme}")
|
||||
prefs.theme = theme
|
||||
|
||||
if default_room is not None:
|
||||
prefs.default_room = default_room
|
||||
|
||||
if preferences_json is not None:
|
||||
# Merge with existing preferences
|
||||
existing = prefs.preferences_json or {}
|
||||
existing.update(preferences_json)
|
||||
prefs.preferences_json = existing
|
||||
|
||||
await self.session.flush()
|
||||
logger.info(f"Updated preferences for user {user_id}")
|
||||
return prefs
|
||||
|
||||
# =========================================================================
|
||||
# Phase 4: API Keys
|
||||
# =========================================================================
|
||||
|
||||
def _generate_api_key(self) -> tuple[str, str, str]:
|
||||
"""
|
||||
Generate a new API key
|
||||
|
||||
Returns:
|
||||
Tuple of (full_key, key_hash, key_prefix)
|
||||
"""
|
||||
# Generate 32 random bytes = 256 bits of entropy
|
||||
random_bytes = secrets.token_bytes(32)
|
||||
# Encode as base64-like string (URL-safe)
|
||||
key_body = secrets.token_urlsafe(32)
|
||||
# Prefix with 'tak_' (tatlock api key)
|
||||
full_key = f"tak_{key_body}"
|
||||
# Hash for storage
|
||||
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
|
||||
# Prefix for identification (first 8 chars after 'tak_')
|
||||
key_prefix = f"tak_{key_body[:4]}"
|
||||
|
||||
return full_key, key_hash, key_prefix
|
||||
|
||||
async def create_api_key(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
scopes: Optional[list[str]] = None,
|
||||
expires_in_days: Optional[int] = None,
|
||||
) -> tuple[ApiKey, str]:
|
||||
"""
|
||||
Create a new API key for a user
|
||||
|
||||
Args:
|
||||
user_id: User's UUID
|
||||
name: Human-readable key name
|
||||
scopes: Optional list of scope restrictions
|
||||
expires_in_days: Optional expiration in days
|
||||
|
||||
Returns:
|
||||
Tuple of (ApiKey object, full key string)
|
||||
The full key is only returned once at creation!
|
||||
|
||||
Raises:
|
||||
ValueError: If user not found or API keys disabled
|
||||
"""
|
||||
# Check user exists and has API keys enabled
|
||||
user = await self.get_user_by_id(user_id)
|
||||
if user is None:
|
||||
raise ValueError(f"User not found: {user_id}")
|
||||
if not user.api_keys_enabled:
|
||||
raise ValueError("API keys are disabled for this user")
|
||||
|
||||
# Generate the key
|
||||
full_key, key_hash, key_prefix = self._generate_api_key()
|
||||
|
||||
# Calculate expiration
|
||||
expires_at = None
|
||||
if expires_in_days:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
|
||||
|
||||
# Create the key record
|
||||
api_key = ApiKey(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
key_hash=key_hash,
|
||||
key_prefix=key_prefix,
|
||||
scopes=scopes,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
self.session.add(api_key)
|
||||
await self.session.flush()
|
||||
|
||||
logger.info(f"Created API key '{name}' for user {user_id}")
|
||||
return api_key, full_key
|
||||
|
||||
async def list_user_api_keys(self, user_id: uuid.UUID) -> list[ApiKey]:
|
||||
"""
|
||||
List all API keys for a user
|
||||
|
||||
Args:
|
||||
user_id: User's UUID
|
||||
|
||||
Returns:
|
||||
List of ApiKey objects (without the actual keys)
|
||||
"""
|
||||
stmt = (
|
||||
select(ApiKey)
|
||||
.where(ApiKey.user_id == user_id)
|
||||
.order_by(ApiKey.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete_api_key(self, user_id: uuid.UUID, key_id: uuid.UUID) -> bool:
|
||||
"""
|
||||
Delete an API key
|
||||
|
||||
Args:
|
||||
user_id: User's UUID (for authorization)
|
||||
key_id: API key UUID
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
|
||||
Raises:
|
||||
ValueError: If key belongs to different user
|
||||
"""
|
||||
stmt = select(ApiKey).where(ApiKey.id == key_id)
|
||||
result = await self.session.execute(stmt)
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
if api_key is None:
|
||||
return False
|
||||
|
||||
if api_key.user_id != user_id:
|
||||
raise ValueError("API key belongs to different user")
|
||||
|
||||
await self.session.delete(api_key)
|
||||
await self.session.flush()
|
||||
logger.info(f"Deleted API key {key_id} for user {user_id}")
|
||||
return True
|
||||
|
||||
async def validate_api_key(self, key: str) -> Optional[User]:
|
||||
"""
|
||||
Validate an API key and return the associated user
|
||||
|
||||
Args:
|
||||
key: The full API key string
|
||||
|
||||
Returns:
|
||||
User object if valid, None if invalid/expired
|
||||
"""
|
||||
# Hash the provided key
|
||||
key_hash = hashlib.sha256(key.encode()).hexdigest()
|
||||
|
||||
# Look up by hash
|
||||
stmt = (
|
||||
select(ApiKey)
|
||||
.options(selectinload(ApiKey.user).selectinload(User.roles))
|
||||
.where(ApiKey.key_hash == key_hash)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
api_key = result.scalar_one_or_none()
|
||||
|
||||
if api_key is None:
|
||||
return None
|
||||
|
||||
# Check expiration
|
||||
if api_key.is_expired:
|
||||
logger.warning(f"Expired API key used: {api_key.key_prefix}...")
|
||||
return None
|
||||
|
||||
# Update last used timestamp
|
||||
api_key.last_used_at = datetime.now(timezone.utc)
|
||||
|
||||
logger.debug(f"API key authenticated: {api_key.key_prefix}... for user {api_key.user.email}")
|
||||
return api_key.user
|
||||
|
||||
def api_key_to_schema(self, api_key: ApiKey) -> ApiKeySchema:
|
||||
"""Convert ApiKey model to schema"""
|
||||
return ApiKeySchema(
|
||||
id=api_key.id,
|
||||
name=api_key.name,
|
||||
key_prefix=api_key.key_prefix,
|
||||
scopes=api_key.scopes,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
is_expired=api_key.is_expired,
|
||||
)
|
||||
|
||||
|
||||
# Factory function for dependency injection
|
||||
def get_auth_service(session: AsyncSession) -> AuthService:
|
||||
|
||||
@@ -70,66 +70,18 @@ class HealthController(BaseController):
|
||||
|
||||
@router.get(
|
||||
"/health/full",
|
||||
summary="Fast health check for Docker",
|
||||
summary="Full health check with database",
|
||||
)
|
||||
async def full_health_check(response: Response):
|
||||
"""
|
||||
Fast health check for container orchestration (Docker/K8s).
|
||||
Health check including database connectivity.
|
||||
|
||||
Checks component availability WITHOUT running expensive operations.
|
||||
Returns 200 OK if all components are available, otherwise 503.
|
||||
|
||||
For detailed diagnostics, use /health/diagnostics instead.
|
||||
Returns 200 OK if database is available, otherwise 503.
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from src.models.ollama_client import get_ollama_client
|
||||
|
||||
# Check 1: Ollama connection + verify agent model is available
|
||||
ollama_client = get_ollama_client()
|
||||
ollama_healthy = False
|
||||
ollama_error = None
|
||||
model_available = False
|
||||
|
||||
try:
|
||||
# Ping Ollama
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
|
||||
# Verify the agent model is pulled and check what's currently loaded
|
||||
models_info = {}
|
||||
if ollama_healthy:
|
||||
try:
|
||||
models_response = await ollama_client.list_models()
|
||||
available_models = [m.get('name', '') for m in models_response.get('models', [])]
|
||||
model_available = settings.agent_model in available_models
|
||||
|
||||
# Get info about currently loaded models (those with size in memory)
|
||||
loaded_models = [
|
||||
m.get('name', '') for m in models_response.get('models', [])
|
||||
if m.get('size', 0) > 0
|
||||
]
|
||||
|
||||
models_info = {
|
||||
"configured": settings.agent_model,
|
||||
"available": model_available,
|
||||
"total_in_ollama": len(available_models),
|
||||
"currently_loaded": loaded_models if loaded_models else ["none"]
|
||||
}
|
||||
|
||||
if not model_available:
|
||||
ollama_error = f"Model '{settings.agent_model}' not found in Ollama. Available: {', '.join(available_models[:3])}"
|
||||
ollama_healthy = False
|
||||
except Exception as e:
|
||||
ollama_error = f"Could not list Ollama models: {str(e)}"
|
||||
ollama_healthy = False
|
||||
|
||||
except Exception as e:
|
||||
ollama_error = str(e)
|
||||
logger.warning(f"Ollama health check failed: {ollama_error}")
|
||||
|
||||
# Check 2: Database connection
|
||||
# Check database connection
|
||||
database = get_database()
|
||||
db_healthy = False
|
||||
db_error = None
|
||||
@@ -140,25 +92,15 @@ class HealthController(BaseController):
|
||||
db_error = str(e)
|
||||
logger.warning(f"Database health check failed: {db_error}")
|
||||
|
||||
is_healthy = ollama_healthy and db_healthy
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
status_code = 200 if is_healthy else 503
|
||||
status_code = 200 if db_healthy else 503
|
||||
response.status_code = status_code
|
||||
|
||||
return {
|
||||
"status": "healthy" if is_healthy else "unhealthy",
|
||||
"status": "healthy" if db_healthy else "unhealthy",
|
||||
"status_code": status_code,
|
||||
"response_time_ms": elapsed_ms,
|
||||
"components": {
|
||||
"ollama": {
|
||||
"status": "healthy" if ollama_healthy else "unhealthy",
|
||||
"models": models_info if models_info else {
|
||||
"configured": settings.agent_model,
|
||||
"available": False
|
||||
},
|
||||
"error": ollama_error
|
||||
},
|
||||
"database": {
|
||||
"status": "healthy" if db_healthy else "unhealthy",
|
||||
"error": db_error
|
||||
@@ -170,19 +112,13 @@ class HealthController(BaseController):
|
||||
"/health/diagnostics",
|
||||
summary="Detailed system diagnostics",
|
||||
)
|
||||
async def diagnostics(deep_test: bool = False):
|
||||
async def diagnostics():
|
||||
"""
|
||||
Comprehensive system diagnostics with detailed component information.
|
||||
|
||||
Query Parameters:
|
||||
- deep_test: Set to true to actually test agent generation (slow, ~5-10s)
|
||||
|
||||
Returns detailed information about all system components.
|
||||
System diagnostics with service information.
|
||||
"""
|
||||
import time
|
||||
from src.models.ollama_client import get_ollama_client
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
diagnostics = {
|
||||
"timestamp": time.time(),
|
||||
"service": {
|
||||
@@ -190,30 +126,9 @@ class HealthController(BaseController):
|
||||
"version": settings.app_version,
|
||||
"purpose": "Infrastructure management and tools API"
|
||||
},
|
||||
"components": {}
|
||||
}
|
||||
|
||||
# 1. Ollama Connection
|
||||
ollama_client = get_ollama_client()
|
||||
try:
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
diagnostics["components"]["ollama"] = {
|
||||
"status": "connected",
|
||||
"url": settings.ollama_base_url,
|
||||
"timeout": settings.ollama_timeout,
|
||||
"default_model": settings.default_model
|
||||
"configuration": {
|
||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||
}
|
||||
except Exception as e:
|
||||
diagnostics["components"]["ollama"] = {
|
||||
"status": "error",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
# 2. Configuration
|
||||
diagnostics["configuration"] = {
|
||||
"agent_fallback_enabled": settings.agent_fallback_enabled,
|
||||
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
|
||||
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
|
||||
}
|
||||
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"""
|
||||
Tools Domain
|
||||
|
||||
Provides utility tool endpoints including DNS lookups.
|
||||
Provides utility tool endpoints including DNS lookups and system stats.
|
||||
"""
|
||||
from src.domains.tools.controller import tools_controller
|
||||
from src.domains.tools.dns import DNSService, DNSQueryError
|
||||
from src.domains.tools.system import SystemStatsService, SystemStatsResponse
|
||||
|
||||
__all__ = ["tools_controller", "DNSService", "DNSQueryError"]
|
||||
__all__ = [
|
||||
"tools_controller",
|
||||
"DNSService",
|
||||
"DNSQueryError",
|
||||
"SystemStatsService",
|
||||
"SystemStatsResponse",
|
||||
]
|
||||
|
||||
@@ -3,14 +3,22 @@ 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
|
||||
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse
|
||||
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__)
|
||||
|
||||
@@ -21,11 +29,15 @@ 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"""
|
||||
@@ -95,6 +107,109 @@ class ToolsController(BaseController):
|
||||
detail="An unexpected error occurred during DNS lookup"
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/system/stats",
|
||||
response_model=SystemStatsResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Get host system statistics",
|
||||
description="""
|
||||
Get real-time host system resource statistics.
|
||||
|
||||
Returns CPU, memory, disk, network, and GPU/VRAM usage for the host machine
|
||||
(not Docker container metrics).
|
||||
|
||||
**Metrics Returned:**
|
||||
- **CPU:** Usage percentage, core count, load averages
|
||||
- **Memory:** Usage percentage, total/used/available bytes
|
||||
- **Disk:** Usage percentage, total/used/free bytes (root partition)
|
||||
- **Network:** Total bytes sent/received
|
||||
- **GPU:** VRAM usage (if NVIDIA GPU available via nvidia-smi)
|
||||
|
||||
**Use Cases:**
|
||||
- Dashboard system monitoring widgets
|
||||
- Health checks and alerting
|
||||
- Capacity planning
|
||||
"""
|
||||
)
|
||||
async def get_system_stats() -> SystemStatsResponse:
|
||||
"""
|
||||
Get current host system statistics
|
||||
|
||||
Returns:
|
||||
System statistics including CPU, memory, disk, network, and GPU
|
||||
|
||||
Raises:
|
||||
HTTPException: 500 for processing errors
|
||||
"""
|
||||
try:
|
||||
logger.info("Fetching system stats")
|
||||
result = await self.system_stats_service.get_stats()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get system stats: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
"""System stats module for host system resource monitoring."""
|
||||
|
||||
from src.domains.tools.system.service import SystemStatsService
|
||||
from src.domains.tools.system.schemas import SystemStatsResponse
|
||||
|
||||
__all__ = ["SystemStatsService", "SystemStatsResponse"]
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Pydantic schemas for system stats module
|
||||
"""
|
||||
from pydantic import Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class CpuStats(BaseSchema):
|
||||
"""CPU usage statistics"""
|
||||
|
||||
usage_percent: float = Field(
|
||||
...,
|
||||
description="CPU usage percentage (0-100)",
|
||||
ge=0,
|
||||
le=100
|
||||
)
|
||||
|
||||
cores: int = Field(
|
||||
...,
|
||||
description="Number of CPU cores"
|
||||
)
|
||||
|
||||
load_1m: Optional[float] = Field(
|
||||
default=None,
|
||||
description="1-minute load average"
|
||||
)
|
||||
|
||||
load_5m: Optional[float] = Field(
|
||||
default=None,
|
||||
description="5-minute load average"
|
||||
)
|
||||
|
||||
load_15m: Optional[float] = Field(
|
||||
default=None,
|
||||
description="15-minute load average"
|
||||
)
|
||||
|
||||
|
||||
class MemoryStats(BaseSchema):
|
||||
"""Memory usage statistics"""
|
||||
|
||||
usage_percent: float = Field(
|
||||
...,
|
||||
description="Memory usage percentage (0-100)",
|
||||
ge=0,
|
||||
le=100
|
||||
)
|
||||
|
||||
total_bytes: int = Field(
|
||||
...,
|
||||
description="Total memory in bytes"
|
||||
)
|
||||
|
||||
used_bytes: int = Field(
|
||||
...,
|
||||
description="Used memory in bytes"
|
||||
)
|
||||
|
||||
available_bytes: int = Field(
|
||||
...,
|
||||
description="Available memory in bytes"
|
||||
)
|
||||
|
||||
|
||||
class DiskStats(BaseSchema):
|
||||
"""Disk usage statistics for a single mount point"""
|
||||
|
||||
mount_point: str = Field(
|
||||
...,
|
||||
description="Mount point path"
|
||||
)
|
||||
|
||||
device: str = Field(
|
||||
...,
|
||||
description="Device name (e.g., /dev/sda1)"
|
||||
)
|
||||
|
||||
fstype: str = Field(
|
||||
...,
|
||||
description="Filesystem type (e.g., ext4, xfs)"
|
||||
)
|
||||
|
||||
usage_percent: float = Field(
|
||||
...,
|
||||
description="Disk usage percentage (0-100)",
|
||||
ge=0,
|
||||
le=100
|
||||
)
|
||||
|
||||
total_bytes: int = Field(
|
||||
...,
|
||||
description="Total disk space in bytes"
|
||||
)
|
||||
|
||||
used_bytes: int = Field(
|
||||
...,
|
||||
description="Used disk space in bytes"
|
||||
)
|
||||
|
||||
free_bytes: int = Field(
|
||||
...,
|
||||
description="Free disk space in bytes"
|
||||
)
|
||||
|
||||
|
||||
class NetworkStats(BaseSchema):
|
||||
"""Network I/O statistics"""
|
||||
|
||||
bytes_sent: int = Field(
|
||||
...,
|
||||
description="Total bytes sent"
|
||||
)
|
||||
|
||||
bytes_recv: int = Field(
|
||||
...,
|
||||
description="Total bytes received"
|
||||
)
|
||||
|
||||
bytes_total: int = Field(
|
||||
...,
|
||||
description="Total bytes (sent + received)"
|
||||
)
|
||||
|
||||
|
||||
class GpuStats(BaseSchema):
|
||||
"""GPU/VRAM statistics (if available)"""
|
||||
|
||||
available: bool = Field(
|
||||
...,
|
||||
description="Whether GPU stats are available"
|
||||
)
|
||||
|
||||
name: Optional[str] = Field(
|
||||
default=None,
|
||||
description="GPU name"
|
||||
)
|
||||
|
||||
usage_percent: Optional[float] = Field(
|
||||
default=None,
|
||||
description="VRAM usage percentage (0-100)"
|
||||
)
|
||||
|
||||
total_bytes: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Total VRAM in bytes"
|
||||
)
|
||||
|
||||
used_bytes: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Used VRAM in bytes"
|
||||
)
|
||||
|
||||
free_bytes: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Free VRAM in bytes"
|
||||
)
|
||||
|
||||
|
||||
class SystemStatsResponse(BaseSchema):
|
||||
"""Response model for system stats"""
|
||||
|
||||
cpu: CpuStats = Field(
|
||||
...,
|
||||
description="CPU statistics"
|
||||
)
|
||||
|
||||
memory: MemoryStats = Field(
|
||||
...,
|
||||
description="Memory statistics"
|
||||
)
|
||||
|
||||
disks: List[DiskStats] = Field(
|
||||
...,
|
||||
description="Disk statistics for all mounted filesystems"
|
||||
)
|
||||
|
||||
network: NetworkStats = Field(
|
||||
...,
|
||||
description="Network I/O statistics"
|
||||
)
|
||||
|
||||
gpu: GpuStats = Field(
|
||||
...,
|
||||
description="GPU/VRAM statistics"
|
||||
)
|
||||
|
||||
hostname: str = Field(
|
||||
...,
|
||||
description="System hostname"
|
||||
)
|
||||
|
||||
queried_at: datetime = Field(
|
||||
...,
|
||||
description="UTC timestamp when stats were collected"
|
||||
)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
System stats service for collecting host system metrics
|
||||
"""
|
||||
import subprocess
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psutil
|
||||
|
||||
from src.shared.logging import get_logger
|
||||
from typing import List
|
||||
|
||||
from src.domains.tools.system.schemas import (
|
||||
SystemStatsResponse,
|
||||
CpuStats,
|
||||
MemoryStats,
|
||||
DiskStats,
|
||||
NetworkStats,
|
||||
GpuStats,
|
||||
)
|
||||
|
||||
# Filesystem types to exclude (virtual/system filesystems)
|
||||
EXCLUDED_FSTYPES = {
|
||||
"tmpfs", "devtmpfs", "devfs", "squashfs", "overlay",
|
||||
"aufs", "proc", "sysfs", "cgroup", "cgroup2",
|
||||
"debugfs", "tracefs", "securityfs", "pstore",
|
||||
"hugetlbfs", "mqueue", "binfmt_misc", "autofs",
|
||||
"fuse.lxcfs", "nsfs", "efivarfs",
|
||||
}
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SystemStatsService:
|
||||
"""Service for collecting host system statistics"""
|
||||
|
||||
async def get_stats(self) -> SystemStatsResponse:
|
||||
"""
|
||||
Collect current system statistics.
|
||||
|
||||
Returns:
|
||||
SystemStatsResponse with CPU, memory, disks, network, and GPU stats
|
||||
"""
|
||||
cpu = self._get_cpu_stats()
|
||||
memory = self._get_memory_stats()
|
||||
disks = self._get_all_disk_stats()
|
||||
network = self._get_network_stats()
|
||||
gpu = self._get_gpu_stats()
|
||||
|
||||
return SystemStatsResponse(
|
||||
cpu=cpu,
|
||||
memory=memory,
|
||||
disks=disks,
|
||||
network=network,
|
||||
gpu=gpu,
|
||||
hostname=socket.gethostname(),
|
||||
queried_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
def _get_cpu_stats(self) -> CpuStats:
|
||||
"""Get CPU usage statistics"""
|
||||
# Get CPU percentage (blocking call with interval for accuracy)
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
cpu_count = psutil.cpu_count()
|
||||
|
||||
# Get load averages (Unix only)
|
||||
try:
|
||||
load_1, load_5, load_15 = psutil.getloadavg()
|
||||
except (AttributeError, OSError):
|
||||
load_1 = load_5 = load_15 = None
|
||||
|
||||
return CpuStats(
|
||||
usage_percent=cpu_percent,
|
||||
cores=cpu_count or 1,
|
||||
load_1m=load_1,
|
||||
load_5m=load_5,
|
||||
load_15m=load_15,
|
||||
)
|
||||
|
||||
def _get_memory_stats(self) -> MemoryStats:
|
||||
"""Get memory usage statistics"""
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
return MemoryStats(
|
||||
usage_percent=mem.percent,
|
||||
total_bytes=mem.total,
|
||||
used_bytes=mem.used,
|
||||
available_bytes=mem.available,
|
||||
)
|
||||
|
||||
def _get_all_disk_stats(self) -> List[DiskStats]:
|
||||
"""Get disk usage statistics for all mounted real filesystems"""
|
||||
disks = []
|
||||
seen_devices = set()
|
||||
|
||||
for partition in psutil.disk_partitions(all=False):
|
||||
# Skip excluded filesystem types
|
||||
if partition.fstype.lower() in EXCLUDED_FSTYPES:
|
||||
continue
|
||||
|
||||
# Skip duplicate devices (same device mounted multiple times)
|
||||
if partition.device in seen_devices:
|
||||
continue
|
||||
seen_devices.add(partition.device)
|
||||
|
||||
# Skip Docker/container overlays
|
||||
if partition.mountpoint.startswith("/var/lib/docker"):
|
||||
continue
|
||||
|
||||
try:
|
||||
usage = psutil.disk_usage(partition.mountpoint)
|
||||
disks.append(DiskStats(
|
||||
mount_point=partition.mountpoint,
|
||||
device=partition.device,
|
||||
fstype=partition.fstype,
|
||||
usage_percent=usage.percent,
|
||||
total_bytes=usage.total,
|
||||
used_bytes=usage.used,
|
||||
free_bytes=usage.free,
|
||||
))
|
||||
except (PermissionError, OSError) as e:
|
||||
logger.debug(f"Skipping {partition.mountpoint}: {e}")
|
||||
continue
|
||||
|
||||
# Sort by mount point for consistent ordering
|
||||
disks.sort(key=lambda d: d.mount_point)
|
||||
|
||||
return disks
|
||||
|
||||
def _get_network_stats(self) -> NetworkStats:
|
||||
"""Get network I/O statistics"""
|
||||
net_io = psutil.net_io_counters()
|
||||
|
||||
return NetworkStats(
|
||||
bytes_sent=net_io.bytes_sent,
|
||||
bytes_recv=net_io.bytes_recv,
|
||||
bytes_total=net_io.bytes_sent + net_io.bytes_recv,
|
||||
)
|
||||
|
||||
def _get_gpu_stats(self) -> GpuStats:
|
||||
"""Get GPU/VRAM statistics using nvidia-smi"""
|
||||
try:
|
||||
# Query nvidia-smi for GPU memory info
|
||||
result = subprocess.run(
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=name,memory.total,memory.used,memory.free",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.debug("nvidia-smi not available or failed")
|
||||
return GpuStats(available=False)
|
||||
|
||||
# Parse output: "NVIDIA GeForce RTX 3080, 10240, 2048, 8192"
|
||||
line = result.stdout.strip().split("\n")[0] # First GPU
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
|
||||
if len(parts) >= 4:
|
||||
name = parts[0]
|
||||
total_mb = int(parts[1])
|
||||
used_mb = int(parts[2])
|
||||
free_mb = int(parts[3])
|
||||
|
||||
total_bytes = total_mb * 1024 * 1024
|
||||
used_bytes = used_mb * 1024 * 1024
|
||||
free_bytes = free_mb * 1024 * 1024
|
||||
usage_percent = (used_mb / total_mb * 100) if total_mb > 0 else 0
|
||||
|
||||
return GpuStats(
|
||||
available=True,
|
||||
name=name,
|
||||
usage_percent=round(usage_percent, 1),
|
||||
total_bytes=total_bytes,
|
||||
used_bytes=used_bytes,
|
||||
free_bytes=free_bytes,
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
logger.debug("nvidia-smi not found - no NVIDIA GPU available")
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("nvidia-smi timed out")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get GPU stats: {e}")
|
||||
|
||||
return GpuStats(available=False)
|
||||
+1
-12
@@ -10,7 +10,6 @@ from src.shared.config import get_settings
|
||||
from src.shared.logging import setup_logging, get_logger
|
||||
from src.shared.database import get_database
|
||||
from src.shared.security import initialize_oidc
|
||||
from src.models.ollama_client import get_ollama_client, close_ollama_client
|
||||
|
||||
# Import domain controllers
|
||||
from src.domains.health import health_controller
|
||||
@@ -42,17 +41,8 @@ async def lifespan(app: FastAPI):
|
||||
logger.info(f"Starting {settings.app_name} v{settings.app_version}")
|
||||
logger.info(f"Debug mode: {settings.debug}")
|
||||
logger.info(f"Log level: {settings.log_level}")
|
||||
logger.info(f"Ollama URL: {settings.ollama_base_url}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Check Ollama connectivity
|
||||
ollama_client = get_ollama_client()
|
||||
ollama_healthy = await ollama_client.health_check()
|
||||
if ollama_healthy:
|
||||
logger.info("Ollama connection successful")
|
||||
else:
|
||||
logger.warning("Ollama connection failed - AI features may not work")
|
||||
|
||||
# Check database connectivity
|
||||
database = get_database()
|
||||
db_healthy = await database.health_check()
|
||||
@@ -68,7 +58,6 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down application")
|
||||
await close_ollama_client()
|
||||
await database.close()
|
||||
|
||||
|
||||
@@ -94,7 +83,7 @@ See `/docs` for the full API reference.
|
||||
lifespan=lifespan,
|
||||
debug=settings.debug,
|
||||
swagger_ui_init_oauth={
|
||||
"clientId": settings.oidc_audience,
|
||||
"clientId": settings.oidc_audiences[0] if settings.oidc_audiences else "core-api",
|
||||
"usePkceWithAuthorizationCodeGrant": True,
|
||||
} if settings.oidc_enabled else None
|
||||
)
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
Embedding model client for text vectorization
|
||||
|
||||
Uses sentence-transformers for generating embeddings.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EmbeddingClient:
|
||||
"""Client for generating text embeddings"""
|
||||
|
||||
def __init__(self, model_name: Optional[str] = None):
|
||||
"""
|
||||
Initialize embedding client
|
||||
|
||||
Args:
|
||||
model_name: Optional model name, defaults to config
|
||||
"""
|
||||
self.model_name = model_name or settings.embedding_model
|
||||
self.dimension = settings.embedding_dimension
|
||||
self._model: Optional[SentenceTransformer] = None
|
||||
logger.info(f"Initializing EmbeddingClient with model: {self.model_name}")
|
||||
|
||||
def _load_model(self) -> SentenceTransformer:
|
||||
"""
|
||||
Lazy load the embedding model
|
||||
|
||||
Returns:
|
||||
Loaded SentenceTransformer model
|
||||
"""
|
||||
if self._model is None:
|
||||
logger.info(f"Loading embedding model: {self.model_name}")
|
||||
self._model = SentenceTransformer(self.model_name)
|
||||
logger.info(f"Model loaded successfully. Embedding dimension: {self.dimension}")
|
||||
return self._model
|
||||
|
||||
def embed_text(self, text: str) -> List[float]:
|
||||
"""
|
||||
Generate embedding for a single text
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
List of floats representing the embedding vector
|
||||
"""
|
||||
model = self._load_model()
|
||||
embedding = model.encode(text, convert_to_numpy=True)
|
||||
return embedding.tolist()
|
||||
|
||||
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts
|
||||
|
||||
Args:
|
||||
texts: List of input texts
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
model = self._load_model()
|
||||
embeddings = model.encode(
|
||||
texts,
|
||||
batch_size=settings.embedding_batch_size,
|
||||
convert_to_numpy=True,
|
||||
show_progress_bar=False
|
||||
)
|
||||
return embeddings.tolist()
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension
|
||||
|
||||
Returns:
|
||||
Embedding vector dimension
|
||||
"""
|
||||
return self.dimension
|
||||
|
||||
|
||||
# Global instance
|
||||
_embedding_client: Optional[EmbeddingClient] = None
|
||||
|
||||
|
||||
def get_embedding_client() -> EmbeddingClient:
|
||||
"""
|
||||
Get or create global embedding client instance
|
||||
|
||||
Returns:
|
||||
EmbeddingClient instance
|
||||
"""
|
||||
global _embedding_client
|
||||
if _embedding_client is None:
|
||||
_embedding_client = EmbeddingClient()
|
||||
return _embedding_client
|
||||
|
||||
|
||||
async def embed_text_async(text: str) -> List[float]:
|
||||
"""
|
||||
Async wrapper for embedding text
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Embedding vector
|
||||
"""
|
||||
client = get_embedding_client()
|
||||
return client.embed_text(text)
|
||||
|
||||
|
||||
async def embed_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
"""
|
||||
Async wrapper for batch embedding
|
||||
|
||||
Args:
|
||||
texts: List of input texts
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
client = get_embedding_client()
|
||||
return client.embed_batch(texts)
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Ollama-based embedding client for text vectorization
|
||||
|
||||
Uses Ollama's embedding API instead of local sentence-transformers.
|
||||
This eliminates the need for PyTorch and heavy ML dependencies.
|
||||
"""
|
||||
import logging
|
||||
import httpx
|
||||
from typing import List, Optional
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class OllamaEmbeddingClient:
|
||||
"""Client for generating text embeddings using Ollama"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: int = 30
|
||||
):
|
||||
"""
|
||||
Initialize Ollama embedding client
|
||||
|
||||
Args:
|
||||
model_name: Embedding model name (default: nomic-embed-text)
|
||||
base_url: Ollama base URL (default from settings)
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
self.model_name = model_name or settings.embedding_model
|
||||
self.base_url = (base_url or settings.ollama_base_url).rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.dimension = settings.embedding_dimension
|
||||
|
||||
logger.info(f"Initializing OllamaEmbeddingClient with model: {self.model_name}")
|
||||
logger.info(f"Ollama URL: {self.base_url}")
|
||||
|
||||
async def embed_text(self, text: str) -> List[float]:
|
||||
"""
|
||||
Generate embedding for a single text using Ollama
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
List of floats representing the embedding vector
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/embeddings",
|
||||
json={
|
||||
"model": self.model_name,
|
||||
"prompt": text
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
return result["embedding"]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating embedding via Ollama: {e}")
|
||||
raise
|
||||
|
||||
async def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts
|
||||
|
||||
Args:
|
||||
texts: List of input texts
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
embedding = await self.embed_text(text)
|
||||
embeddings.append(embedding)
|
||||
return embeddings
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension
|
||||
|
||||
Returns:
|
||||
Embedding vector dimension
|
||||
"""
|
||||
return self.dimension
|
||||
|
||||
|
||||
# Global instance
|
||||
_embedding_client: Optional[OllamaEmbeddingClient] = None
|
||||
|
||||
|
||||
def get_embedding_client() -> OllamaEmbeddingClient:
|
||||
"""
|
||||
Get or create global Ollama embedding client instance
|
||||
|
||||
Returns:
|
||||
OllamaEmbeddingClient instance
|
||||
"""
|
||||
global _embedding_client
|
||||
if _embedding_client is None:
|
||||
_embedding_client = OllamaEmbeddingClient()
|
||||
return _embedding_client
|
||||
|
||||
|
||||
async def embed_text_async(text: str) -> List[float]:
|
||||
"""
|
||||
Async wrapper for embedding text
|
||||
|
||||
Args:
|
||||
text: Input text
|
||||
|
||||
Returns:
|
||||
Embedding vector
|
||||
"""
|
||||
client = get_embedding_client()
|
||||
return await client.embed_text(text)
|
||||
|
||||
|
||||
async def embed_batch_async(texts: List[str]) -> List[List[float]]:
|
||||
"""
|
||||
Async wrapper for batch embedding
|
||||
|
||||
Args:
|
||||
texts: List of input texts
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
client = get_embedding_client()
|
||||
return await client.embed_batch(texts)
|
||||
@@ -1,223 +0,0 @@
|
||||
"""
|
||||
Ollama client for model inference.
|
||||
Handles both streaming and non-streaming requests.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
from typing import AsyncIterator, Dict, Any, Optional
|
||||
from src.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class OllamaClient:
|
||||
"""Client for interacting with Ollama API."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.ollama_base_url
|
||||
self.timeout = settings.ollama_timeout
|
||||
self.client = httpx.AsyncClient(timeout=self.timeout)
|
||||
logger.info(f"Initialized Ollama client: {self.base_url}")
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client."""
|
||||
await self.client.aclose()
|
||||
|
||||
def resolve_model(self, model_name: str) -> str:
|
||||
"""
|
||||
Resolve model alias to actual Ollama model.
|
||||
|
||||
Args:
|
||||
model_name: Requested model name (e.g., "gpt-3.5-turbo")
|
||||
|
||||
Returns:
|
||||
Actual Ollama model name (e.g., "gemma:7b")
|
||||
"""
|
||||
resolved = settings.model_aliases.get(model_name, model_name)
|
||||
if resolved != model_name:
|
||||
logger.info(f"Model resolution: {model_name} → {resolved}")
|
||||
return resolved
|
||||
|
||||
async def generate_non_streaming(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate non-streaming response from Ollama using chat endpoint.
|
||||
|
||||
Args:
|
||||
model: Model name
|
||||
prompt: User prompt
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Maximum tokens to generate
|
||||
|
||||
Returns:
|
||||
Dict with 'response' and 'tokens' keys
|
||||
"""
|
||||
actual_model = self.resolve_model(model)
|
||||
|
||||
payload = {
|
||||
"model": actual_model,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
}
|
||||
}
|
||||
|
||||
if max_tokens:
|
||||
payload["options"]["num_predict"] = max_tokens
|
||||
|
||||
logger.debug(f"Ollama request to {actual_model}")
|
||||
|
||||
try:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json=payload
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
return {
|
||||
"response": result.get("message", {}).get("content", ""),
|
||||
"tokens": {
|
||||
"prompt": result.get("prompt_eval_count", 0),
|
||||
"completion": result.get("eval_count", 0),
|
||||
"total": result.get("prompt_eval_count", 0) + result.get("eval_count", 0)
|
||||
}
|
||||
}
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Ollama request failed: {e}")
|
||||
raise
|
||||
|
||||
async def generate_streaming(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None
|
||||
) -> AsyncIterator[str]:
|
||||
"""
|
||||
Generate streaming response from Ollama using chat endpoint.
|
||||
|
||||
Args:
|
||||
model: Model name
|
||||
prompt: User prompt
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Maximum tokens to generate
|
||||
|
||||
Yields:
|
||||
Token strings
|
||||
"""
|
||||
actual_model = self.resolve_model(model)
|
||||
|
||||
payload = {
|
||||
"model": actual_model,
|
||||
"messages": [
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"stream": True,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
}
|
||||
}
|
||||
|
||||
if max_tokens:
|
||||
payload["options"]["num_predict"] = max_tokens
|
||||
|
||||
logger.debug(f"Ollama streaming request to {actual_model}")
|
||||
|
||||
try:
|
||||
async with self.client.stream(
|
||||
"POST",
|
||||
f"{self.base_url}/api/chat",
|
||||
json=payload
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
if "message" in chunk:
|
||||
content = chunk["message"].get("content", "")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
# Check if done
|
||||
if chunk.get("done", False):
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse JSON: {line}")
|
||||
continue
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"Ollama streaming request failed: {e}")
|
||||
raise
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if Ollama is healthy.
|
||||
|
||||
Returns:
|
||||
True if healthy, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/tags",
|
||||
timeout=5.0
|
||||
)
|
||||
return response.status_code == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama health check failed: {e}")
|
||||
return False
|
||||
|
||||
async def list_models(self) -> Dict[str, Any]:
|
||||
"""
|
||||
List all available models in Ollama.
|
||||
|
||||
Returns:
|
||||
Dict with 'models' key containing list of model info
|
||||
"""
|
||||
try:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url}/api/tags",
|
||||
timeout=5.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list Ollama models: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Global client instance
|
||||
_ollama_client: Optional[OllamaClient] = None
|
||||
|
||||
|
||||
def get_ollama_client() -> OllamaClient:
|
||||
"""Get or create the global Ollama client instance."""
|
||||
global _ollama_client
|
||||
if _ollama_client is None:
|
||||
_ollama_client = OllamaClient()
|
||||
return _ollama_client
|
||||
|
||||
|
||||
async def close_ollama_client():
|
||||
"""Close the global Ollama client."""
|
||||
global _ollama_client
|
||||
if _ollama_client is not None:
|
||||
await _ollama_client.close()
|
||||
_ollama_client = None
|
||||
@@ -1,32 +0,0 @@
|
||||
"""
|
||||
Security initialization module
|
||||
|
||||
Handles OIDC configuration and authentication setup
|
||||
"""
|
||||
from src.config import Settings
|
||||
from src.auth.oidc import oidc_config
|
||||
from src.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def initialize_oidc(settings: Settings) -> None:
|
||||
"""
|
||||
Initialize OIDC authentication configuration
|
||||
|
||||
Configures the global oidc_config instance with settings from environment.
|
||||
If OIDC is enabled, logs the issuer URL for verification.
|
||||
|
||||
Args:
|
||||
settings: Application settings containing OIDC configuration
|
||||
"""
|
||||
oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
)
|
||||
|
||||
if settings.oidc_enabled:
|
||||
logger.info(f"✓ OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
||||
else:
|
||||
logger.info("○ OIDC authentication disabled - API is publicly accessible")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
+17
-48
@@ -36,8 +36,15 @@ class Settings(BaseSettings):
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8083
|
||||
|
||||
# CORS
|
||||
cors_origins: list[str] = ["*"]
|
||||
# CORS - Note: When cors_credentials is True, cannot use "*" for origins
|
||||
# Set CORS_ORIGINS env var to override (comma-separated list)
|
||||
cors_origins: list[str] = [
|
||||
"https://home.schweitz.net",
|
||||
"https://tatlock.schweitz.net",
|
||||
"http://localhost:8080",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:8080",
|
||||
]
|
||||
cors_credentials: bool = True
|
||||
cors_methods: list[str] = ["*"]
|
||||
cors_headers: list[str] = ["*"]
|
||||
@@ -45,31 +52,6 @@ class Settings(BaseSettings):
|
||||
# Logging
|
||||
log_level: str = "DEBUG"
|
||||
|
||||
# Ollama Configuration (for AI orchestration)
|
||||
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
|
||||
ollama_timeout: int = 300 # 5 minutes
|
||||
|
||||
# Model Configuration
|
||||
default_model: str = "mistral-nemo-large:latest"
|
||||
agent_model: str = "mistral-nemo-large:latest"
|
||||
code_models: str = "mistral-nemo-large:latest"
|
||||
|
||||
# System Prompt Variant (for A/B testing)
|
||||
system_prompt_variant: str = "v8_holistic"
|
||||
|
||||
# Agent Configuration
|
||||
agent_fallback_enabled: bool = True
|
||||
|
||||
# Model Aliases (OpenAI → Local)
|
||||
alias_gpt35: str = "gemma:7b"
|
||||
alias_gpt4: str = "mistral:7b"
|
||||
alias_gpt4_turbo: str = "mixtral:8x7b"
|
||||
alias_gpt4_code: str = "codestral:latest"
|
||||
|
||||
# Memory Configuration
|
||||
memory_tier1_max_turns: int = 10
|
||||
memory_consolidation_threshold: int = 10
|
||||
|
||||
# Qdrant Configuration
|
||||
qdrant_host: str = "qdrant"
|
||||
qdrant_port: int = 6333
|
||||
@@ -77,11 +59,6 @@ class Settings(BaseSettings):
|
||||
qdrant_collection_documents: str = "core_api_documents"
|
||||
qdrant_collection_user_facts: str = "core_api_user_facts"
|
||||
|
||||
# Embeddings (using Ollama)
|
||||
embedding_model: str = "nomic-embed-text"
|
||||
embedding_dimension: int = 768
|
||||
embedding_batch_size: int = 32
|
||||
|
||||
# Search Configuration
|
||||
search_provider: str = "searxng"
|
||||
searxng_url: str # Required - set SEARXNG_URL in .env
|
||||
@@ -113,28 +90,20 @@ class Settings(BaseSettings):
|
||||
|
||||
# OIDC Authentication (Authentik)
|
||||
oidc_enabled: bool = False
|
||||
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
|
||||
oidc_audience: str = "core-api"
|
||||
# Accept tokens from multiple OAuth providers (each has its own issuer/JWKS)
|
||||
oidc_issuers: list[str] = [
|
||||
"https://auth.schweitz.net/application/o/core-api/",
|
||||
"https://auth.schweitz.net/application/o/tatlock-ui/",
|
||||
"https://auth.schweitz.net/application/o/tatlock/",
|
||||
]
|
||||
# Accept tokens from multiple clients
|
||||
oidc_audiences: list[str] = ["core-api", "tatlock-ui", "tatlock"]
|
||||
|
||||
# Authentik API (for token validation and user management)
|
||||
authentik_url: str = "https://auth.schweitz.net"
|
||||
authentik_username: str = ""
|
||||
authentik_password: str = ""
|
||||
|
||||
@property
|
||||
def model_aliases(self) -> dict:
|
||||
"""Computed property for model aliases"""
|
||||
return {
|
||||
"gpt-3.5-turbo": self.alias_gpt35,
|
||||
"gpt-4": self.alias_gpt4,
|
||||
"gpt-4-turbo": self.alias_gpt4_turbo,
|
||||
"gpt-4-code": self.alias_gpt4_code,
|
||||
}
|
||||
|
||||
def get_code_models(self) -> list[str]:
|
||||
"""Parse comma-separated code models"""
|
||||
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = False
|
||||
|
||||
+13
-5
@@ -17,15 +17,23 @@ def initialize_oidc(settings: Settings) -> None:
|
||||
settings: Application settings containing OIDC configuration
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from src.auth.oidc import oidc_config
|
||||
# Configure BOTH oidc modules (src.auth and src.domains.auth)
|
||||
from src.auth.oidc import oidc_config as auth_oidc_config
|
||||
from src.domains.auth.oidc import oidc_config as domains_oidc_config
|
||||
|
||||
oidc_config.configure(
|
||||
auth_oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuer=settings.oidc_issuer,
|
||||
audience=settings.oidc_audience
|
||||
issuers=settings.oidc_issuers,
|
||||
audiences=settings.oidc_audiences
|
||||
)
|
||||
|
||||
domains_oidc_config.configure(
|
||||
enabled=settings.oidc_enabled,
|
||||
issuers=settings.oidc_issuers,
|
||||
audiences=settings.oidc_audiences
|
||||
)
|
||||
|
||||
if settings.oidc_enabled:
|
||||
logger.info(f"OIDC authentication enabled (issuer: {settings.oidc_issuer})")
|
||||
logger.info(f"OIDC authentication enabled (issuers: {settings.oidc_issuers})")
|
||||
else:
|
||||
logger.info("OIDC authentication disabled - API is publicly accessible")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,627 @@
|
||||
"""Tests for authentication service."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.models import User, Role, Group, UserPreferences
|
||||
from src.domains.auth.schemas import TokenInfoSchema, RoleSchema
|
||||
from src.domains.auth.service import AuthService, get_auth_service
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session():
|
||||
"""Create a mock async database session."""
|
||||
session = AsyncMock(spec=AsyncSession)
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
session.flush = AsyncMock()
|
||||
session.refresh = AsyncMock()
|
||||
session.add = MagicMock()
|
||||
return session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_service(mock_session):
|
||||
"""Create an AuthService instance with mock session."""
|
||||
return AuthService(mock_session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user():
|
||||
"""Create a sample user for testing."""
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
avatar_url="https://example.com/avatar.jpg",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_login=datetime.now(timezone.utc),
|
||||
)
|
||||
user.roles = []
|
||||
user.preferences = None
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_role():
|
||||
"""Create a sample role for testing."""
|
||||
return Role(
|
||||
id=uuid.uuid4(),
|
||||
name="control-room.general:admin",
|
||||
domain="control-room",
|
||||
category="general",
|
||||
action="admin",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_group(sample_role):
|
||||
"""Create a sample group for testing."""
|
||||
group = Group(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Administrators",
|
||||
is_superuser=True,
|
||||
parent_name=None,
|
||||
member_count=5,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group.roles = [sample_role]
|
||||
return group
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_token_info():
|
||||
"""Create sample token info from Authentik."""
|
||||
return TokenInfoSchema(
|
||||
sub=str(uuid.uuid4()),
|
||||
email="test@example.com",
|
||||
name="Test User",
|
||||
preferred_username="testuser",
|
||||
groups=["Administrators", "Developers"],
|
||||
picture="https://example.com/avatar.jpg",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_preferences():
|
||||
"""Create sample user preferences."""
|
||||
return UserPreferences(
|
||||
user_id=uuid.uuid4(),
|
||||
theme="dark",
|
||||
default_room="control-room",
|
||||
preferences_json={"notifications": True},
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AuthService Initialization Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAuthServiceInit:
|
||||
"""Test AuthService initialization."""
|
||||
|
||||
def test_init_with_session(self, mock_session):
|
||||
"""AuthService should initialize with session."""
|
||||
service = AuthService(mock_session)
|
||||
assert service.session is mock_session
|
||||
|
||||
def test_init_sets_userinfo_url(self, mock_session):
|
||||
"""AuthService should set userinfo URL from settings."""
|
||||
service = AuthService(mock_session)
|
||||
assert "userinfo" in service.userinfo_url
|
||||
|
||||
def test_get_auth_service_factory(self, mock_session):
|
||||
"""get_auth_service should return AuthService instance."""
|
||||
service = get_auth_service(mock_session)
|
||||
assert isinstance(service, AuthService)
|
||||
assert service.session is mock_session
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Token Validation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestValidateToken:
|
||||
"""Test token validation via Authentik userinfo endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_token_success(self, auth_service):
|
||||
"""validate_token should return token info on success."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"sub": str(uuid.uuid4()),
|
||||
"email": "test@example.com",
|
||||
"name": "Test User",
|
||||
"preferred_username": "testuser",
|
||||
"groups": ["Administrators"],
|
||||
"picture": "https://example.com/avatar.jpg",
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch("src.domains.auth.service.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
result = await auth_service.validate_token("valid_token")
|
||||
|
||||
assert result.email == "test@example.com"
|
||||
assert result.name == "Test User"
|
||||
assert "Administrators" in result.groups
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_token_invalid(self, auth_service):
|
||||
"""validate_token should raise ValueError for invalid token."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("src.domains.auth.service.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
return_value=mock_response
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid or expired token"):
|
||||
await auth_service.validate_token("invalid_token")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_token_service_unavailable(self, auth_service):
|
||||
"""validate_token should raise ValueError when service unavailable."""
|
||||
import httpx
|
||||
|
||||
with patch("src.domains.auth.service.httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
|
||||
side_effect=httpx.RequestError("Connection failed")
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Authentication service unavailable"):
|
||||
await auth_service.validate_token("token")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# User Sync Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSyncUser:
|
||||
"""Test user synchronization from OIDC token."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_creates_new_user(self, auth_service, sample_token_info, mock_session):
|
||||
"""sync_user should create new user when not found."""
|
||||
# Mock no existing user found
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
user, is_new = await auth_service.sync_user(sample_token_info)
|
||||
|
||||
assert is_new is True
|
||||
assert mock_session.add.call_count == 2 # User and Preferences
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_updates_existing_user(
|
||||
self, auth_service, sample_token_info, sample_user, mock_session
|
||||
):
|
||||
"""sync_user should update existing user when found."""
|
||||
# Mock existing user found
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = sample_user
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
# Update token info with matching authentik_id
|
||||
sample_token_info.sub = str(sample_user.authentik_id)
|
||||
|
||||
user, is_new = await auth_service.sync_user(sample_token_info)
|
||||
|
||||
assert is_new is False
|
||||
assert user.email == sample_token_info.email
|
||||
assert user.name == sample_token_info.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_updates_last_login(
|
||||
self, auth_service, sample_token_info, sample_user, mock_session
|
||||
):
|
||||
"""sync_user should update last_login timestamp."""
|
||||
old_login = sample_user.last_login
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = sample_user
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
sample_token_info.sub = str(sample_user.authentik_id)
|
||||
|
||||
user, _ = await auth_service.sync_user(sample_token_info)
|
||||
|
||||
assert user.last_login is not None
|
||||
# last_login should be updated (or same if happened in same second)
|
||||
assert user.last_login >= old_login or user.last_login is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Role Sync Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSyncRoles:
|
||||
"""Test role synchronization from Authentik groups via group_roles."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_roles_from_groups(
|
||||
self, auth_service, sample_user, sample_group, mock_session
|
||||
):
|
||||
"""sync_roles should get roles from matching groups."""
|
||||
# Mock finding groups with roles
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [sample_group]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.sync_roles(sample_user, ["Administrators"])
|
||||
|
||||
assert len(roles) == 1
|
||||
assert roles[0].name == "control-room.general:admin"
|
||||
assert sample_user.roles == roles
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_roles_no_matching_groups(
|
||||
self, auth_service, sample_user, mock_session
|
||||
):
|
||||
"""sync_roles should return empty list when no groups match."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = []
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.sync_roles(sample_user, ["NonExistentGroup"])
|
||||
|
||||
assert len(roles) == 0
|
||||
assert sample_user.roles == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_roles_deduplicates_roles(
|
||||
self, auth_service, sample_user, sample_role, mock_session
|
||||
):
|
||||
"""sync_roles should deduplicate roles from multiple groups."""
|
||||
# Create two groups with the same role
|
||||
group1 = Group(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Group1",
|
||||
is_superuser=False,
|
||||
member_count=1,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group1.roles = [sample_role]
|
||||
|
||||
group2 = Group(
|
||||
id=uuid.uuid4(),
|
||||
authentik_id=uuid.uuid4(),
|
||||
name="Group2",
|
||||
is_superuser=False,
|
||||
member_count=1,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
synced_at=datetime.now(timezone.utc),
|
||||
)
|
||||
group2.roles = [sample_role] # Same role
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [group1, group2]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.sync_roles(sample_user, ["Group1", "Group2"])
|
||||
|
||||
# Should only have one role despite appearing in two groups
|
||||
assert len(roles) == 1
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Schema Conversion Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSchemaConversions:
|
||||
"""Test model to schema conversions."""
|
||||
|
||||
def test_user_to_schema(self, auth_service, sample_user):
|
||||
"""user_to_schema should convert User model to UserSchema."""
|
||||
schema = auth_service.user_to_schema(sample_user)
|
||||
|
||||
assert schema.id == sample_user.id
|
||||
assert schema.authentik_id == sample_user.authentik_id
|
||||
assert schema.email == sample_user.email
|
||||
assert schema.name == sample_user.name
|
||||
assert schema.avatar_url == sample_user.avatar_url
|
||||
|
||||
def test_roles_to_schema(self, auth_service, sample_role):
|
||||
"""roles_to_schema should convert Role models to RoleSchemas."""
|
||||
schemas = auth_service.roles_to_schema([sample_role])
|
||||
|
||||
assert len(schemas) == 1
|
||||
assert schemas[0].id == sample_role.id
|
||||
assert schemas[0].name == sample_role.name
|
||||
assert schemas[0].domain == sample_role.domain
|
||||
assert schemas[0].category == sample_role.category
|
||||
assert schemas[0].action == sample_role.action
|
||||
|
||||
def test_roles_to_schema_empty_list(self, auth_service):
|
||||
"""roles_to_schema should handle empty list."""
|
||||
schemas = auth_service.roles_to_schema([])
|
||||
assert schemas == []
|
||||
|
||||
def test_preferences_to_schema(self, auth_service, sample_preferences):
|
||||
"""preferences_to_schema should convert UserPreferences to schema."""
|
||||
schema = auth_service.preferences_to_schema(sample_preferences)
|
||||
|
||||
assert schema.theme == sample_preferences.theme
|
||||
assert schema.default_room == sample_preferences.default_room
|
||||
assert schema.preferences_json == sample_preferences.preferences_json
|
||||
|
||||
def test_preferences_to_schema_none(self, auth_service):
|
||||
"""preferences_to_schema should return defaults for None."""
|
||||
schema = auth_service.preferences_to_schema(None)
|
||||
|
||||
assert schema.theme == "system"
|
||||
assert schema.default_room == "front-hall"
|
||||
assert schema.preferences_json == {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# List Operations Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestListOperations:
|
||||
"""Test list operations for users, groups, and roles."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users(self, auth_service, sample_user, mock_session):
|
||||
"""list_users should return paginated user list."""
|
||||
sample_user.roles = []
|
||||
|
||||
# Mock count query
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
# Mock users query
|
||||
users_result = MagicMock()
|
||||
users_result.scalars.return_value.all.return_value = [sample_user]
|
||||
|
||||
mock_session.execute.side_effect = [count_result, users_result]
|
||||
|
||||
items, total = await auth_service.list_users()
|
||||
|
||||
assert total == 1
|
||||
assert len(items) == 1
|
||||
assert items[0].email == sample_user.email
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_users_with_search(self, auth_service, mock_session):
|
||||
"""list_users should filter by search query."""
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 0
|
||||
|
||||
users_result = MagicMock()
|
||||
users_result.scalars.return_value.all.return_value = []
|
||||
|
||||
mock_session.execute.side_effect = [count_result, users_result]
|
||||
|
||||
items, total = await auth_service.list_users(search="nonexistent")
|
||||
|
||||
assert total == 0
|
||||
assert len(items) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_groups(self, auth_service, sample_group, mock_session):
|
||||
"""list_groups should return paginated group list with roles."""
|
||||
count_result = MagicMock()
|
||||
count_result.scalar.return_value = 1
|
||||
|
||||
groups_result = MagicMock()
|
||||
groups_result.scalars.return_value.all.return_value = [sample_group]
|
||||
|
||||
mock_session.execute.side_effect = [count_result, groups_result]
|
||||
|
||||
items, total = await auth_service.list_groups()
|
||||
|
||||
assert total == 1
|
||||
assert len(items) == 1
|
||||
assert items[0].name == sample_group.name
|
||||
assert len(items[0].roles) == 1 # Should include role names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_roles(self, auth_service, sample_role, mock_session):
|
||||
"""list_roles should return all roles ordered by domain."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.all.return_value = [sample_role]
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
roles = await auth_service.list_roles()
|
||||
|
||||
assert len(roles) == 1
|
||||
assert roles[0].name == sample_role.name
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Group-Role Management Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGroupRoleManagement:
|
||||
"""Test group-role assignment and removal."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_by_id(self, auth_service, sample_group, mock_session):
|
||||
"""get_group_by_id should return group with roles."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = sample_group
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
group = await auth_service.get_group_by_id(sample_group.id)
|
||||
|
||||
assert group is not None
|
||||
assert group.id == sample_group.id
|
||||
assert len(group.roles) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_group_by_id_not_found(self, auth_service, mock_session):
|
||||
"""get_group_by_id should return None when not found."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
group = await auth_service.get_group_by_id(uuid.uuid4())
|
||||
|
||||
assert group is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""assign_role_to_group should add role to group."""
|
||||
# Clear existing roles for this test
|
||||
sample_group.roles = []
|
||||
|
||||
# Mock group lookup
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
# Mock role lookup
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.assign_role_to_group(sample_group.id, sample_role.id)
|
||||
|
||||
assert sample_role in group.roles
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group_already_assigned(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""assign_role_to_group should not duplicate if already assigned."""
|
||||
# Group already has this role
|
||||
sample_group.roles = [sample_role]
|
||||
original_count = len(sample_group.roles)
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.assign_role_to_group(sample_group.id, sample_role.id)
|
||||
|
||||
assert len(group.roles) == original_count # No duplicate
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group_group_not_found(self, auth_service, mock_session):
|
||||
"""assign_role_to_group should raise ValueError when group not found."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
with pytest.raises(ValueError, match="Group not found"):
|
||||
await auth_service.assign_role_to_group(uuid.uuid4(), uuid.uuid4())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assign_role_to_group_role_not_found(
|
||||
self, auth_service, sample_group, mock_session
|
||||
):
|
||||
"""assign_role_to_group should raise ValueError when role not found."""
|
||||
sample_group.roles = []
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = None
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
with pytest.raises(ValueError, match="Role not found"):
|
||||
await auth_service.assign_role_to_group(sample_group.id, uuid.uuid4())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_role_from_group(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""remove_role_from_group should remove role from group."""
|
||||
# Group has this role
|
||||
sample_group.roles = [sample_role]
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.remove_role_from_group(sample_group.id, sample_role.id)
|
||||
|
||||
assert sample_role not in group.roles
|
||||
mock_session.flush.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_role_from_group_not_assigned(
|
||||
self, auth_service, sample_group, sample_role, mock_session
|
||||
):
|
||||
"""remove_role_from_group should handle role not assigned gracefully."""
|
||||
# Group does not have this role
|
||||
sample_group.roles = []
|
||||
|
||||
group_result = MagicMock()
|
||||
group_result.scalar_one_or_none.return_value = sample_group
|
||||
|
||||
role_result = MagicMock()
|
||||
role_result.scalar_one_or_none.return_value = sample_role
|
||||
|
||||
mock_session.execute.side_effect = [group_result, role_result]
|
||||
|
||||
group = await auth_service.remove_role_from_group(sample_group.id, sample_role.id)
|
||||
|
||||
# Should complete without error
|
||||
assert len(group.roles) == 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Role Schema Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestRoleSchema:
|
||||
"""Test RoleSchema validation."""
|
||||
|
||||
def test_role_schema_creation(self):
|
||||
"""RoleSchema should be creatable with valid data."""
|
||||
schema = RoleSchema(
|
||||
id=uuid.uuid4(),
|
||||
name="control-room.general:admin",
|
||||
domain="control-room",
|
||||
category="general",
|
||||
action="admin",
|
||||
)
|
||||
|
||||
assert schema.name == "control-room.general:admin"
|
||||
assert schema.domain == "control-room"
|
||||
assert schema.category == "general"
|
||||
assert schema.action == "admin"
|
||||
|
||||
def test_role_schema_category_default(self):
|
||||
"""RoleSchema should default category to 'general'."""
|
||||
schema = RoleSchema(
|
||||
id=uuid.uuid4(),
|
||||
name="media.general:viewer",
|
||||
domain="media",
|
||||
action="viewer",
|
||||
)
|
||||
|
||||
assert schema.category == "general"
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for config module."""
|
||||
import pytest
|
||||
from src.config import (
|
||||
from src.shared.config import (
|
||||
__version__,
|
||||
Settings,
|
||||
get_settings,
|
||||
|
||||
@@ -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
|
||||
+19
-87
@@ -80,24 +80,24 @@ class TestFullHealthCheck:
|
||||
"""Test /health/full endpoint."""
|
||||
|
||||
@patch("src.shared.database.Database.health_check")
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_full_health_returns_503_when_unhealthy(self, mock_get_ollama, mock_db_health, client):
|
||||
"""Full health should return 503 when Ollama unhealthy."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
def test_full_health_returns_200_when_healthy(self, mock_db_health, client):
|
||||
"""Full health should return 200 when database is healthy."""
|
||||
mock_db_health.return_value = True
|
||||
|
||||
response = client.get("/health/full")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.shared.database.Database.health_check")
|
||||
def test_full_health_returns_503_when_unhealthy(self, mock_db_health, client):
|
||||
"""Full health should return 503 when database is unhealthy."""
|
||||
mock_db_health.return_value = False
|
||||
|
||||
response = client.get("/health/full")
|
||||
assert response.status_code == 503
|
||||
|
||||
@patch("src.shared.database.Database.health_check")
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_full_health_returns_components_status(self, mock_get_ollama, mock_db_health, client):
|
||||
def test_full_health_returns_components_status(self, mock_db_health, client):
|
||||
"""Full health should return component status."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
mock_db_health.return_value = True
|
||||
|
||||
response = client.get("/health/full")
|
||||
@@ -105,63 +105,32 @@ class TestFullHealthCheck:
|
||||
|
||||
assert "status" in data
|
||||
assert "components" in data
|
||||
assert "ollama" in data["components"]
|
||||
assert "database" in data["components"]
|
||||
assert "response_time_ms" in data
|
||||
|
||||
@patch("src.shared.database.Database.health_check")
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_full_health_handles_list_models_error(self, mock_get_ollama, mock_db_health, client):
|
||||
"""Full health should handle list_models errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_client.list_models.side_effect = Exception("Connection error")
|
||||
mock_get_ollama.return_value = mock_client
|
||||
mock_db_health.return_value = True
|
||||
def test_full_health_handles_database_error(self, mock_db_health, client):
|
||||
"""Full health should handle database errors gracefully."""
|
||||
mock_db_health.side_effect = Exception("Connection error")
|
||||
|
||||
response = client.get("/health/full")
|
||||
data = response.json()
|
||||
|
||||
# Should report error in component status
|
||||
assert "ollama" in data["components"]
|
||||
|
||||
@patch("src.shared.database.Database.health_check")
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_full_health_handles_health_check_exception(self, mock_get_ollama, mock_db_health, client):
|
||||
"""Full health should handle health check exceptions gracefully."""
|
||||
mock_client = AsyncMock()
|
||||
# Return False instead of raising exception to test unhealthy path
|
||||
mock_client.health_check.return_value = False
|
||||
mock_get_ollama.return_value = mock_client
|
||||
mock_db_health.return_value = True
|
||||
|
||||
response = client.get("/health/full")
|
||||
# Should return 503 for unhealthy
|
||||
assert response.status_code == 503
|
||||
data = response.json()
|
||||
assert data["status"] == "unhealthy"
|
||||
assert "error" in data["components"]["database"]
|
||||
|
||||
|
||||
class TestDiagnosticsEndpoint:
|
||||
"""Test /health/diagnostics endpoint."""
|
||||
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_diagnostics_returns_200(self, mock_get_ollama, client):
|
||||
def test_diagnostics_returns_200(self, client):
|
||||
"""Diagnostics should return 200."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
assert response.status_code == 200
|
||||
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_diagnostics_returns_service_info(self, mock_get_ollama, client):
|
||||
def test_diagnostics_returns_service_info(self, client):
|
||||
"""Diagnostics should return service information."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
@@ -169,54 +138,17 @@ class TestDiagnosticsEndpoint:
|
||||
assert "name" in data["service"]
|
||||
assert "version" in data["service"]
|
||||
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_diagnostics_returns_components(self, mock_get_ollama, client):
|
||||
"""Diagnostics should return component details."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "components" in data
|
||||
assert "ollama" in data["components"]
|
||||
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_diagnostics_returns_configuration(self, mock_get_ollama, client):
|
||||
def test_diagnostics_returns_configuration(self, client):
|
||||
"""Diagnostics should return configuration info."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "configuration" in data
|
||||
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_diagnostics_returns_response_time(self, mock_get_ollama, client):
|
||||
def test_diagnostics_returns_response_time(self, client):
|
||||
"""Diagnostics should return response time."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.return_value = True
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
assert "response_time_ms" in data
|
||||
assert isinstance(data["response_time_ms"], int)
|
||||
|
||||
@patch("src.models.ollama_client.get_ollama_client")
|
||||
def test_diagnostics_handles_ollama_error(self, mock_get_ollama, client):
|
||||
"""Diagnostics should handle Ollama connection errors."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.health_check.side_effect = Exception("Connection refused")
|
||||
mock_get_ollama.return_value = mock_client
|
||||
|
||||
response = client.get("/health/diagnostics")
|
||||
data = response.json()
|
||||
|
||||
# Should still return 200 with error info
|
||||
assert response.status_code == 200
|
||||
assert "error" in data["components"]["ollama"]
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"""Tests for Ollama client."""
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
import json
|
||||
|
||||
from src.models.ollama_client import OllamaClient, get_ollama_client, close_ollama_client
|
||||
|
||||
|
||||
class TestOllamaClientInit:
|
||||
"""Test OllamaClient initialization."""
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_uses_settings_defaults(self, mock_settings):
|
||||
"""Client should use settings for defaults."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 60
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
assert client.base_url == "http://ollama:11434"
|
||||
assert client.timeout == 60
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_creates_http_client(self, mock_settings):
|
||||
"""Client should create httpx AsyncClient."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
assert client.client is not None
|
||||
|
||||
|
||||
class TestOllamaClientClose:
|
||||
"""Test client close functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_close_closes_client(self, mock_settings):
|
||||
"""close should close the HTTP client."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock) as mock_close:
|
||||
await client.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestOllamaClientResolveModel:
|
||||
"""Test model resolution."""
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_resolves_aliased_model(self, mock_settings):
|
||||
"""resolve_model should map alias to actual model."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {"gpt-3.5-turbo": "gemma:7b"}
|
||||
|
||||
client = OllamaClient()
|
||||
result = client.resolve_model("gpt-3.5-turbo")
|
||||
|
||||
assert result == "gemma:7b"
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_returns_original_if_no_alias(self, mock_settings):
|
||||
"""resolve_model should return original if no alias found."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
result = client.resolve_model("llama2")
|
||||
|
||||
assert result == "llama2"
|
||||
|
||||
|
||||
class TestOllamaClientHealthCheck:
|
||||
"""Test health check functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_health_check_returns_true_on_200(self, mock_settings):
|
||||
"""Health check should return True when Ollama responds 200."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is True
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_health_check_returns_false_on_error(self, mock_settings):
|
||||
"""Health check should return False on connection error."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_health_check_returns_false_on_non_200(self, mock_settings):
|
||||
"""Health check should return False on non-200 status."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.health_check()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestOllamaClientListModels:
|
||||
"""Test list models functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_list_models_returns_dict(self, mock_settings):
|
||||
"""list_models should return dict with models."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
models_data = {
|
||||
"models": [
|
||||
{"name": "llama2", "size": 1000000},
|
||||
{"name": "gemma:7b", "size": 2000000}
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = models_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.return_value = mock_response
|
||||
result = await client.list_models()
|
||||
|
||||
assert result == models_data
|
||||
assert len(result["models"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_list_models_raises_on_error(self, mock_settings):
|
||||
"""list_models should raise on error."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
with patch.object(client.client, "get", new_callable=AsyncMock) as mock_get:
|
||||
mock_get.side_effect = Exception("Connection error")
|
||||
|
||||
with pytest.raises(Exception):
|
||||
await client.list_models()
|
||||
|
||||
|
||||
class TestOllamaClientGenerateNonStreaming:
|
||||
"""Test non-streaming generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_generate_non_streaming_returns_response(self, mock_settings):
|
||||
"""generate_non_streaming should return response dict."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
response_data = {
|
||||
"message": {"content": "Hello! How can I help?"},
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 20
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = response_data
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
result = await client.generate_non_streaming("llama2", "Hello")
|
||||
|
||||
assert result["response"] == "Hello! How can I help?"
|
||||
assert result["tokens"]["prompt"] == 10
|
||||
assert result["tokens"]["completion"] == 20
|
||||
assert result["tokens"]["total"] == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_generate_non_streaming_includes_max_tokens(self, mock_settings):
|
||||
"""generate_non_streaming should include max_tokens in payload."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"message": {"content": "Hi"}}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client.client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
await client.generate_non_streaming("llama2", "Hello", max_tokens=100)
|
||||
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[1]["json"]["options"]["num_predict"] == 100
|
||||
|
||||
|
||||
class TestOllamaClientGenerateStreaming:
|
||||
"""Test streaming generation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_generate_streaming_yields_content(self, mock_settings):
|
||||
"""generate_streaming should yield content chunks."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
mock_settings.model_aliases = {}
|
||||
|
||||
client = OllamaClient()
|
||||
|
||||
# Create mock streaming response
|
||||
async def mock_aiter_lines():
|
||||
yield json.dumps({"message": {"content": "Hello"}})
|
||||
yield json.dumps({"message": {"content": " world"}})
|
||||
yield json.dumps({"done": True})
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.aiter_lines = mock_aiter_lines
|
||||
|
||||
mock_stream_context = AsyncMock()
|
||||
mock_stream_context.__aenter__.return_value = mock_response
|
||||
mock_stream_context.__aexit__.return_value = None
|
||||
|
||||
with patch.object(client.client, "stream", return_value=mock_stream_context):
|
||||
chunks = []
|
||||
async for chunk in client.generate_streaming("llama2", "Hi"):
|
||||
chunks.append(chunk)
|
||||
|
||||
assert "Hello" in chunks
|
||||
assert " world" in chunks
|
||||
|
||||
|
||||
class TestOllamaClientSingleton:
|
||||
"""Test singleton pattern."""
|
||||
|
||||
@patch("src.models.ollama_client.settings")
|
||||
def test_get_ollama_client_returns_same_instance(self, mock_settings):
|
||||
"""get_ollama_client should return singleton."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
import src.models.ollama_client as module
|
||||
module._ollama_client = None
|
||||
|
||||
client1 = get_ollama_client()
|
||||
client2 = get_ollama_client()
|
||||
|
||||
assert client1 is client2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("src.models.ollama_client.settings")
|
||||
async def test_close_ollama_client_clears_singleton(self, mock_settings):
|
||||
"""close_ollama_client should clear the singleton."""
|
||||
mock_settings.ollama_base_url = "http://ollama:11434"
|
||||
mock_settings.ollama_timeout = 30
|
||||
|
||||
import src.models.ollama_client as module
|
||||
module._ollama_client = None
|
||||
|
||||
client = get_ollama_client()
|
||||
|
||||
with patch.object(client.client, "aclose", new_callable=AsyncMock):
|
||||
await close_ollama_client()
|
||||
|
||||
assert module._ollama_client is None
|
||||
Reference in New Issue
Block a user