Compare commits

..
55 Commits
Author SHA1 Message Date
Jeroen SchweitzerandClaude Opus 4.5 6243f29aae feat: multi-issuer OIDC support with config consolidation
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 1m15s
- Support multiple OAuth providers (core-api, tatlock-ui, tatlock)
- Changed oidc_issuer (string) to oidc_issuers (list)
- Per-issuer JWKS caching
- Validates token issuer against allowed list
- Consolidated config files (removed deprecated src/config.py, src/security.py)
- Updated imports to use src/shared/config and src/shared/security

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 13:09:29 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c4d32952db fix: OIDC multi-audience support and Swagger UI fix
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m13s
- Accept tokens from multiple clients (core-api, tatlock-ui, tatlock)
- Fixed main.py to use oidc_audiences[0] for Swagger UI OAuth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 12:17:16 +01:00
Jeroen Schweitzer 4c45f139d9 correct documentation 2026-01-08 12:11:01 +01:00
Jeroen Schweitzer 67b33314fe fix: update main.py to use oidc_audiences list
Build and Push / release (push) Failing after 3s
Build and Push / build (push) Has been skipped
2026-01-07 19:34:05 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6ce34cc016 fix: accept multiple OIDC audiences for cross-client auth
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m15s
- Changed oidc_audience (string) to oidc_audiences (list)
- Now accepts tokens with audience: core-api, tatlock-ui, tatlock
- Fixes environment endpoint returning "default" user when using
  tatlock-ui token (audience mismatch was causing JWT claims error)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 19:01:39 +01:00
Jeroen SchweitzerandClaude Opus 4.5 c1f16d44e5 refactor: remove Ollama integration and unused AI configuration
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m19s
- Remove src/models/ollama_client.py, embeddings.py, embeddings_ollama.py
- Remove model aliases and AI config from settings (both config.py files)
- Update health endpoints to only check database connectivity
- Update tests to reflect database-only health checks
- Update README, .env.example, and OIDC docstrings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 17:56:48 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4df5cfc106 fix: initialize OIDC config for both auth modules
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m30s
The domains.auth.oidc module had its own oidc_config instance that
wasn't being configured, causing environment endpoint to always use
hardcoded "local" user instead of authenticated user.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 16:00:14 +01:00
Jeroen SchweitzerandClaude Opus 4.5 0a16688cc8 chore: release v1.10.2
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m7s
Enhanced environment endpoint logging for debugging user resolution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 15:32:53 +01:00
Jeroen SchweitzerandClaude Opus 4.5 a7535fe8ea fix: correct OIDC import path in tools controller
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m14s
The environment endpoint was importing from non-existent path
`src.oidc.dependencies` instead of `src.auth.oidc`, causing
authentication to fail and queries to go to wrong Qdrant collection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:36:24 +01:00
Jeroen SchweitzerandClaude Opus 4.5 6045c6ac6a feat: add environment endpoint for weather, forecast, and sun data
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 2m14s
Add GET /tools/environment endpoint that fetches weather, forecast, sun times,
and air quality data from user's volatile Qdrant collection.

- Add qdrant-client dependency
- Create QdrantReadClient wrapper for read-only queries
- Add environment schemas and service in tools domain
- Parse weather, forecast, sun times, and air quality from Qdrant payloads
- Support user-specific collections via preferred_username from OIDC
- Add comprehensive service tests (13 tests)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 23:04:59 +01:00
Jeroen Schweitzer b8fca7060f npm config 2026-01-04 21:51:21 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e3a49c800a fix: SQLAlchemy async lazy loading for new users
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
Initialize user.roles=[] and user.preferences on new user creation
to avoid MissingGreenlet error in async context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:05:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7ab9f73a1d chore: release v1.9.3
Build and Push / release (push) Successful in 2s
Build and Push / build (push) Successful in 1m10s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:49:26 +01:00
Jeroen SchweitzerandClaude Opus 4.5 dd5b794de4 fix: handle non-UUID sub claim in auth sync
Authentik JWT sub claim may not be a valid UUID.
Now derives a deterministic UUID from the sub string if parsing fails.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:45:05 +01:00
Jeroen SchweitzerandClaude Opus 4.5 dd0997679f fix: /auth/users/me now supports NPM forward auth headers
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
Added get_current_user_or_forward_auth() combined dependency that:
- First checks for X-authentik-* headers from NPM forward auth (web)
- Falls back to JWT Bearer token validation (mobile/native)

This fixes web authentication where browsers don't send Bearer tokens
but rely on NPM's forward auth proxy to pass user info via headers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 00:24:41 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e2226cd923 fix(build): add production API URLs to Docker build
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m8s
Dockerfile now passes --dart-define flags for CORE_API_URL and
TATLOCK_API_URL pointing to schweitz.net domains. This enables
requiresAuth=true, fixing auth being completely skipped in production.

Also added service port reference table to AGENTS.md.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:18:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7bf3c76a1b fix(cors): use explicit origins instead of wildcard
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m11s
When allow_credentials=True, browsers reject wildcard (*) origins.
Added specific allowed origins for Tatlock domains.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 23:09:47 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4ae8cfbc0b chore: release v1.9.0
Build and Push / release (push) Successful in 3s
Build and Push / build (push) Successful in 1m10s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:24:36 +01:00
Jeroen SchweitzerandClaude Opus 4.5 69045a78a7 test(auth): add tests for GET /auth/me endpoint
Add comprehensive tests for NPM forward auth endpoint:
- Forward auth header parsing
- User lookup methods (get_user_by_email, get_user_by_authentik_id)
- Admin gate authorization logic
- OpenAPI spec validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:14:13 +01:00
Jeroen SchweitzerandClaude Opus 4.5 884996fd83 feat(auth): implement GET /auth/me for NPM forward auth
Add endpoint to get current user profile from NPM forward auth headers.
Enables web authentication flow where NPM handles Authentik login.

- Read X-authentik-* headers set by NPM forward auth
- Auto-create user if not in database (first login via web)
- Sync roles from current Authentik groups
- Add get_user_by_email and get_user_by_authentik_id helpers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:54:44 +01:00
Jeroen SchweitzerandClaude Opus 4.5 8bd13eab09 ci: trigger build on version tag push with auto-release
Changed workflow to:
- Trigger on push of v* tags instead of release publish
- Auto-create Gitea release via API
- Then build and push Docker image

This simplifies deployment: just push a version tag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:40:44 +01:00
Jeroen SchweitzerandClaude Opus 4.5 397a47c8fc feat(auth): implement Phase 4 user profile and API key endpoints
Build and Push / build (release) Successful in 1m10s
Add user profile, preferences, and API key management endpoints:
- 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 use tak_ prefix, SHA-256 hashing, and are shown only once on creation.
Preferences support partial updates with JSON merge behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 20:24:46 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7752cd9d23 feat(auth): implement group-role mapping and permission system
Architecture changes:
- Permission format: domain.category:action (e.g., control-room.general:admin)
- Decoupled groups from roles via group_roles mapping table
- Groups are organizational (synced from Authentik)
- Roles are permissions (admin-managed via API)

New features:
- require_permission() and require_any_permission() dependency factories
- Action hierarchy: admin > editor > user > viewer
- Global admin override (admin.general:admin grants all)
- Group-role management endpoints (assign/remove roles)
- GET /auth/roles endpoint to list all roles

Database changes:
- Added category column to roles table (default: general)
- Removed authentik_group column (decoupled)
- Added group_roles association table
- Added user_groups association table
- Migration updates role names to domain.general:action format

Tests:
- 67 new tests for auth service and controller
- Covers token validation, user sync, role sync
- Covers group-role assignment/removal
- Covers schema conversions and permission system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 19:52:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 075b0ec297 feat: add system stats API for dashboard monitoring
Build and Push / build (release) Successful in 1m44s
- GET /tools/system/stats - Real-time host system statistics
- CPU usage, memory, all mounted disks, network I/O
- GPU/VRAM stats via nvidia-smi (if available)
- Uses psutil for cross-platform host metrics
- Auto-discovers and filters real filesystems

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:30:45 +01:00
Jeroen SchweitzerandClaude Opus 4.5 49be935b5d feat: add link_type field to quick links
Build and Push / build (release) Successful in 1m10s
Adds link_type column to quick_links table for iframe vs new_tab behavior.
Includes Alembic migration and schema updates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 14:38:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 381d43b60b feat: add dashboard API with quick links and widgets
Build and Push / build (release) Successful in 1m28s
- Dashboard domain with Quick Links CRUD + reorder endpoints
- Dashboard widgets management endpoints
- Database migrations for quick_links and dashboard_widgets tables
- Static file controller for Organizr widgets
- Default local user when OIDC is disabled
- Domain-based architecture refactor (src/domains/, src/shared/)
- Test suite updated for new structure (285 tests passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 12:27:57 +01:00
Jeroen SchweitzerandClaude Opus 4.5 e85c9a123d feat: add groups management API
Build and Push / build (release) Successful in 50s
- GET /auth/groups - list groups with search/pagination
- POST /auth/groups/sync-from-authentik - bulk sync from Authentik
- Group model and database migration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 22:28:06 +01:00
Jeroen SchweitzerandClaude Opus 4.5 3516376d92 chore: cleanup auth code after debugging session
Build and Push / build (release) Successful in 50s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:41:10 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ffa984e271 fix: separate httpx and SQLAlchemy async contexts in bulk sync
Build and Push / build (release) Successful in 50s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:34:34 +01:00
Jeroen SchweitzerandClaude Opus 4.5 7d13be6052 fix: use uuid field instead of pk for Authentik user sync
Build and Push / build (release) Successful in 50s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:27:21 +01:00
Jeroen SchweitzerandClaude Opus 4.5 faff45db90 fix: manually handle session cookies for Authentik API authentication
Build and Push / build (release) Successful in 1m14s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:01:29 +01:00
Jeroen SchweitzerandClaude Opus 4.5 892015fa59 fix: use Authentik domain URL for cookie domain compatibility
Build and Push / build (release) Successful in 1m17s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:52:14 +01:00
Jeroen SchweitzerandClaude Opus 4.5 ee93a73160 fix: use AUTHENTIK_USERNAME/PASSWORD env vars to match production
Build and Push / build (release) Successful in 28s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:37:12 +01:00
Jeroen SchweitzerandClaude Opus 4.5 4f45f9bf37 feat: add authentication and user management with Authentik integration
Build and Push / build (release) Successful in 1m16s
- Add PostgreSQL database with async SQLAlchemy
- Add Alembic migrations for schema management
- Add User, Role, UserPreferences, ApiKey models
- Add auth endpoints: /auth/me, /auth/users, /auth/users/sync-from-authentik
- Add token validation via Authentik userinfo endpoint
- Add bulk user sync from Authentik admin API
- Add database health check to diagnostics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 20:25:03 +01:00
jpmschweitzerandClaude Opus 4.5 cdb6344013 feat: add PostgreSQL database configuration
- Add POSTGRES_HOST, POSTGRES_USER, POSTGRES_PASSWORD settings
- Add database_url property for connection string construction
- Database: core_api on shared postgres instance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:43:44 +01:00
jpmschweitzerandClaude Opus 4.5 0d2926dc6b fix: remove hardcoded service URLs, require ENV config
Build and Push / build (release) Successful in 29s
- Remove hardcoded default URLs (portainer, npm, ollama, etc.)
- All external service URLs now required via ENV vars
- Remove obsolete ai_client and ai_controller (core-ai proxy)
- Clean up health_controller obsolete core-ai references
- App fails fast at startup if required config missing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:59:46 +01:00
jpmschweitzerandClaude Opus 4.5 31370f053c fix: clean up root endpoint and disable redoc
Build and Push / build (release) Successful in 28s
- Simplify root endpoint to return minimal status info
- Remove obsolete endpoint listings (moved to /docs)
- Clean up verbose app description
- Disable redoc UI

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:46:26 +01:00
jpmschweitzerandClaude Opus 4.5 9370c70311 fix: correct Portainer default port to 8001
Port 9000 is Authentik, Portainer runs on 8001

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:29:18 +01:00
jpmschweitzerandClaude Opus 4.5 68909e6576 feat: add development server startup script
Build and Push / build (release) Successful in 29s
Add wakeup.sh for local development with:
- Port conflict detection
- Virtual environment activation
- Log file management
- Colored output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:15:17 +01:00
jpmschweitzerandClaude Opus 4.5 435a3faeed chore: clean up .gitignore
Remove duplicate credentials.py entry

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:15:01 +01:00
jpmschweitzerandClaude Opus 4.5 06cb4c17c8 chore: bump version to 1.3.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:14:27 +01:00
jpmschweitzerandClaude Opus 4.5 ffa075528b test: update tests for Portainer client without socket fallback
- Remove TestPortainerClientDockerSocketFallback class
- Update wrapper method tests to expect RuntimeError on missing endpoints
- Remove all socket fallback related test cases

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:14:04 +01:00
jpmschweitzerandClaude Opus 4.5 dbbb92d292 refactor: remove Docker socket fallback from Portainer client
- Remove _list_containers_via_socket method
- Remove _inspect_container_via_socket method
- Simplify list_containers and inspect_container to use Portainer API only
- Raise RuntimeError when no Portainer endpoints available

BREAKING: Portainer API configuration is now required for all container
and stack operations. Set PORTAINER_URL and PORTAINER_API_KEY env vars.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:13:46 +01:00
jpmschweitzerandClaude Opus 4.5 3ecfb91cc2 refactor: simplify configuration to use only environment variables
- Remove credentials.py import, use pydantic-settings .env support
- Remove unused search API keys (Brave, Google)
- Update default search provider to SearXNG
- Add extra="ignore" to allow flexible env var usage
- Update .env.example with organized sections

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 11:13:26 +01:00
jpmschweitzerandClaude Opus 4.5 167a3a43a8 feat: add stack management and container delete endpoints
Build and Push / build (release) Successful in 29s
- Add stack compose YAML get/update endpoints
- Add stack environment variable get/update endpoints
- Add stack deploy and rebuild endpoints
- Add container delete endpoint with force option
- Add Portainer client methods for new operations
- Add comprehensive test coverage (30 new tests)
- Remove REQUESTED_SERVICES.md (now implemented)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 10:19:50 +01:00
jpmschweitzerandClaude Opus 4.5 b5bdff3d54 Bump version to 1.2.1
Build and Push / build (release) Successful in 28s
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:51:00 +01:00
jpmschweitzerandClaude Opus 4.5 5c4ac70450 Add AGENTS.md with project guidelines
Documents coding standards, git discipline, release flow,
and FastAPI architecture best practices.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:46:15 +01:00
jpmschweitzerandClaude Opus 4.5 ad1b55009a Remove obsolete web scraper config from .env.example
Remove APP_VERSION (now from pyproject.toml) and web scraper settings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:46:02 +01:00
jpmschweitzerandClaude Opus 4.5 fd8aee3227 Fix device control response returning stale state
Add 300ms delay after executing device action before fetching new state,
allowing Home Assistant time to update the entity state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 18:40:19 +01:00
jpmschweitzerandClaude Opus 4.5 afb47c48d4 Bump version to 1.2.0 and update documentation
Build and Push / build (release) Successful in 39s
Update README.md:
- Reflect current architecture (housekeeping, infrastructure, tools)
- Document all API endpoints
- Add environment variables reference
- Update test instructions

Update CHANGELOG.md:
- Document housekeeping API addition
- Document web scraper removal
- Document test coverage improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:52:25 +01:00
jpmschweitzerandClaude Opus 4.5 a22e168666 Improve test coverage to 65%
Add comprehensive test suites for:
- NPM client (27 tests)
- Ollama client (16 tests)
- AI client and controller (34 tests)
- Static controller (8 tests)
- Tools controller DNS lookup (9 tests)
- OIDC authentication (10 tests)
- Housekeeping endpoints (28 tests)
- Infrastructure endpoints (15 tests)
- Health endpoints (12 tests)
- Portainer client (12 tests)
- Home Assistant client (24 tests)

Total: 285 tests passing with 65% code coverage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:32:39 +01:00
jpmschweitzerandClaude Opus 4.5 33f31b09a3 Remove obsolete web scraper module
Web scraping functionality is no longer needed. Removes:
- src/web_scraper/ directory (6 files)
- /web-scraper/scrape endpoint from tools controller
- References from health controller endpoints list

Tools controller now only contains DNS lookup functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:28:10 +01:00
jpmschweitzerandClaude Opus 4.5 c45fdcb528 Add housekeeping API for Home Assistant integration
Introduces home automation endpoints for the Tatlock Housekeeper agent:
- Device discovery and control (turn_on, turn_off, toggle, set_brightness)
- Scene activation and script execution
- Automation management (enable/disable)
- State history queries and area discovery

Includes Home Assistant REST client and configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 16:15:24 +01:00
jpmschweitzerandClaude Opus 4.5 a06fc59e8f Add version to /health endpoint
Build and Push / build (release) Successful in 27s
- Include version in /health endpoint response
- Update test to verify version field
- Bump version to 1.1.2

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 12:53:04 +01:00
jpmschweitzer 095ce15f0e Update .gitea/workflows/build.yml
Build and Push / build (release) Successful in 27s
fix port
2025-12-14 12:49:51 +01:00
115 changed files with 20983 additions and 2624 deletions
+51 -12
View File
@@ -1,23 +1,62 @@
# Core Code API Configuration
# Copy to .env and fill in real values
# Application settings
# =============================================================================
# Application
# =============================================================================
APP_NAME="Core Code API"
APP_VERSION="1.0.0"
DEBUG=false
LOG_LEVEL=INFO
# Server settings
# =============================================================================
# Server
# =============================================================================
HOST=0.0.0.0
PORT=8083
# CORS settings (default allows all origins for internal use)
# CORS (default allows all origins for internal use)
# CORS_ORIGINS=["http://192.168.86.149:82"]
# Logging
LOG_LEVEL=INFO
# =============================================================================
# Infrastructure Services
# =============================================================================
# Web Scraper Module
WEB_SCRAPER_REQUEST_TIMEOUT=30
WEB_SCRAPER_MAX_REDIRECTS=5
WEB_SCRAPER_USER_AGENT="Mozilla/5.0 (compatible; CoreCode/1.0)"
WEB_SCRAPER_DEFAULT_MAX_LENGTH=10000
WEB_SCRAPER_MAX_LINKS_TO_EXTRACT=50
# Portainer API (required for container/stack management)
PORTAINER_URL=http://localhost:8001
PORTAINER_API_KEY=ptr_your-api-key-here
# Nginx Proxy Manager API
NPM_URL=http://localhost:81
NPM_EMAIL=admin@example.com
NPM_PASSWORD=your-npm-password
# =============================================================================
# Home Automation
# =============================================================================
# Home Assistant API
HOMEASSISTANT_URL=http://localhost:8123
HOMEASSISTANT_TOKEN=your-long-lived-access-token
# =============================================================================
# Search
# =============================================================================
# SearXNG (self-hosted search)
SEARXNG_URL=http://localhost:8080
# =============================================================================
# Database (PostgreSQL)
# =============================================================================
POSTGRES_HOST=localhost:5432
POSTGRES_USER=core_api
POSTGRES_PASSWORD=your-password
# =============================================================================
# Vector Database
# =============================================================================
# Qdrant
QDRANT_HOST=qdrant
QDRANT_PORT=6333
+16 -3
View File
@@ -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
@@ -30,4 +43,4 @@ jobs:
if: success()
run: |
curl -sf -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
http://watchtower:8070/v1/update
http://watchtower:8080/v1/update
-1
View File
@@ -105,7 +105,6 @@ data/
# Credentials and secrets
src/credentials.py
credentials.py
*.pem
*.key
secrets/
+72
View File
@@ -0,0 +1,72 @@
# AGENTS.md
> **Start every session by reading this file.**
> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project.
## 1. Agent Operational Protocols
### 🧠 Work Patterns (Plan-Act-Reflect)
* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be.
* **Act:** Execute the changes in small, atomic steps.
* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests?
### 🛡️ Git Discipline
* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`.
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
* `feat: add user login endpoint`
* `fix: resolve database connection timeout`
* `refactor: split monolith dependency file`
* **Atomic Commits:** Keep commits small. One logical change = one commit.
### 📝 Changelog Maintenance
* **Update `CHANGELOG.md`** with every user-facing change.
* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`.
### 🚀 Release Flow
When changes are ready for deployment:
1. **Ask user if deploy cycle is desired **
2. **Update version** in `pyproject.toml`:
- Bug fixes: bump patch version (1.8.3 → 1.8.4)
- New features: bump minor version (1.8.4 → 1.9.0)
3. **Update CHANGELOG.md**:
- Move items from `[Unreleased]` to new version section
- Add release date: `## [1.8.4] - 2025-12-16`
4. **Commit and tag**:
```bash
git add -A
git commit -m "fix: description of changes"
git tag v1.8.4
git push origin main --tags
```
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:8083/health`
---
## 2. FastAPI Architecture & Best Practices
*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)*
### 📂 Project Structure (Directory-based, NOT File-type based)
Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory.
**Correct Structure:**
```text
src/
├── auth/
│ ├── router.py # Endpoints
│ ├── schemas.py # Pydantic models
│ ├── service.py # Business logic (CRUD, etc.)
│ ├── dependencies.py# Module-specific dependencies
│ └── config.py # Module-specific settings
├── posts/
│ ├── router.py
│ └── ...
└── main.py # App entry point
+344
View File
@@ -5,6 +5,350 @@ 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
- `link_type` field to quick links for iframe vs new tab behavior
## [1.6.0] - 2026-01-03
### Added
- **Dashboard API** - Quick links and widgets management for Organizr-style dashboard
- `GET /dashboard/quick-links` - List quick links with category/visibility filtering
- `GET /dashboard/quick-links/{id}` - Get single quick link
- `POST /dashboard/quick-links` - Create quick link
- `PUT /dashboard/quick-links/{id}` - Update quick link
- `DELETE /dashboard/quick-links/{id}` - Delete quick link
- `POST /dashboard/quick-links/reorder` - Reorder quick links by position
- `GET /dashboard/widgets` - List dashboard widgets
- `GET /dashboard/widgets/{id}` - Get single widget
- `POST /dashboard/widgets` - Create widget
- `PUT /dashboard/widgets/{id}` - Update widget
- `DELETE /dashboard/widgets/{id}` - Delete widget
- Database migrations for `quick_links` and `dashboard_widgets` tables
- Static file controller for serving Organizr widgets (`/static/widgets`)
- Default local user authentication when OIDC is disabled
### Changed
- **Domain-based architecture** - Refactored codebase to domain-driven structure
- `src/domains/` - Domain modules (auth, dashboard, health, housekeeping, infrastructure, tools)
- `src/shared/` - Shared utilities (base, config, database, logging, security, clients)
- Test suite updated for new domain structure (285 tests passing)
## [1.5.0] - 2026-01-01
### Added
- **Groups Management** - Authentik group synchronization
- `GET /auth/groups` - List all groups with search and pagination
- `POST /auth/groups/sync-from-authentik` - Bulk sync groups from Authentik admin API
- Database model and migration for groups table
## [1.4.6] - 2026-01-01
### Changed
- Code cleanup: move inline `re` import to top of auth/service.py
## [1.4.5] - 2026-01-01
### Fixed
- Separate httpx and SQLAlchemy async contexts in bulk sync (fixes greenlet error)
## [1.4.4] - 2026-01-01
### Fixed
- Use `uuid` field instead of `pk` for Authentik user sync (pk is integer, uuid is proper UUID)
- Skip internal_service_account type users during bulk sync
## [1.4.3] - 2026-01-01
### Fixed
- Manually extract and send session cookies for Authentik flow auth (fixes cross-domain cookie handling)
## [1.4.2] - 2026-01-01
### Fixed
- Use Authentik domain URL (auth.schweitz.net) instead of IP to fix cookie domain matching
## [1.4.1] - 2026-01-01
### Fixed
- Authentik config now uses AUTHENTIK_USERNAME/PASSWORD to match production env vars
## [1.4.0] - 2026-01-01
### Added
- **Authentication & User Management** - Authentik integration for user synchronization
- `GET /auth/me` - Get current authenticated user info
- `GET /auth/users` - List all users with search and pagination
- `POST /auth/users/sync-from-authentik` - Bulk sync users from Authentik admin API
- PostgreSQL database integration with async SQLAlchemy
- Alembic database migrations for schema management
- Database models: User, Role, UserPreferences, ApiKey
- Token validation via Authentik userinfo endpoint
- Role synchronization from Authentik groups
- Health check now includes database connectivity status
### Changed
- Authentik configuration now uses username/password for admin API access
- Health endpoint includes database status in diagnostics
## [1.3.1] - 2025-12-31
### Changed
- Simplified configuration: all settings now read from environment variables/.env only
- Removed Docker socket fallback for container operations (Portainer API is now required)
- Updated default search provider to SearXNG
### Removed
- `src/credentials.py` - credentials now managed via environment variables
- Docker socket fallback methods from Portainer client
- Unused search API settings (Brave, Google)
## [1.3.0] - 2025-12-31
### Added
- **Stack Management Endpoints** for Tatlock Control Room integration
- `GET /infrastructure/stacks/{stackId}/compose` - Get stack Docker Compose YAML
- `PUT /infrastructure/stacks/{stackId}/compose` - Update stack Docker Compose YAML
- `GET /infrastructure/stacks/{stackId}/env` - Get stack environment variables
- `PUT /infrastructure/stacks/{stackId}/env` - Update stack environment variables
- `POST /infrastructure/stacks/{stackId}/deploy` - Redeploy stack
- `POST /infrastructure/stacks/{stackId}/rebuild` - Pull images and recreate containers
- `DELETE /infrastructure/containers/{id}` - Delete container with optional force flag
- Portainer client methods: `get_stack_file`, `redeploy_stack`, `update_stack_env`, `delete_container`, `restart_container`
### Removed
- REQUESTED_SERVICES.md - endpoint specifications now implemented
## [1.2.1] - 2025-12-17
### Fixed
- Device control endpoint now returns correct new state after action (added 300ms delay for HA state propagation)
### Added
- AGENTS.md with project coding guidelines and release flow documentation
### Changed
- Removed obsolete web scraper settings from .env.example
## [1.2.0] - 2025-12-17
### Added
- **Housekeeping API** - Home Assistant integration for smart home control
- `GET /housekeeping/health` - HA connection status
- `GET /housekeeping/devices` - List controllable devices with optional domain/area filtering
- `GET /housekeeping/devices/{entity_id}` - Get device details
- `POST /housekeeping/devices/{entity_id}/control` - Control devices (turn_on, turn_off, toggle, set_brightness)
- `GET /housekeeping/scenes` - List available scenes
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate a scene
- `GET /housekeeping/scripts` - List available scripts
- `POST /housekeeping/scripts/{script_id}/run` - Run a script
- `GET /housekeeping/automations` - List automations
- `POST /housekeeping/automations/{automation_id}/toggle` - Enable/disable automation
- `GET /housekeeping/history` - Query state history
- `GET /housekeeping/areas` - List rooms/areas
- Home Assistant REST API client (`src/clients/homeassistant_client.py`)
- Home Assistant configuration in credentials and settings
- Comprehensive test suite with 65% code coverage (285 tests)
- Tests for NPM client, Ollama client, AI client, OIDC authentication
- Tests for infrastructure, health, tools, and housekeeping endpoints
### Removed
- **Web Scraper** - Entire web scraping module removed
- `src/web_scraper/` directory deleted
- `/web-scraper/scrape` endpoint removed
- Trafilatura and BeautifulSoup dependencies removed from scraping use
### Changed
- Updated README.md with current architecture and all endpoints
- Tools controller now only contains DNS lookup functionality
- Health controller endpoints list updated to reflect current features
## [1.1.2] - 2024-12-14
### Added
- Version field to /health endpoint response
## [1.1.1] - 2024-12-14
### Added
+109 -139
View File
@@ -1,33 +1,90 @@
# Core Code API
OpenAPI-compatible functions for Open WebUI, providing web scraping and data processing capabilities.
Central API service providing infrastructure management, home automation, and utility endpoints for the homelab ecosystem.
## Features
### Web Scraper
- Intelligent content extraction using Trafilatura
- BeautifulSoup fallback for complex pages
- Configurable content length limits
- Optional link extraction
- Perfect for feeding webpage content to LLMs
### Infrastructure Management
- **Portainer Integration**: Stack and container management
- **NPM Integration**: Nginx Proxy Manager domain and certificate management
- **Service Control**: Start/stop services with container orchestration
### Home Automation (Housekeeping API)
- **Device Control**: Turn on/off, toggle, and set brightness for smart devices
- **Scene Activation**: Trigger Home Assistant scenes
- **Script Execution**: Run Home Assistant scripts
- **Automation Management**: Enable/disable automations
- **State History**: Query device state changes over time
- **Area Discovery**: List rooms and areas
### Utilities
- **DNS Lookup**: Query DNS records (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR)
- **Health Checks**: Service health monitoring with database connectivity status
## Architecture
```
src/
├── config.py # Global application settings
├── logging_config.py # Logging configuration
├── base_schema.py # Base Pydantic models
├── main.py # FastAPI application entry point
── web_scraper/ # Web scraper module
── __init__.py
├── config.py # Module-specific settings
├── schemas.py # Pydantic request/response models
├── service.py # Business logic
── router.py # API routes
└── exceptions.py # Custom exceptions
├── config.py # Global application settings
├── logging_config.py # Logging configuration
├── base_controller.py # Base controller pattern
├── main.py # FastAPI application entry point
── auth/
── oidc.py # OIDC authentication
├── clients/
├── homeassistant_client.py # Home Assistant REST client
├── npm_client.py # Nginx Proxy Manager client
── portainer_client.py # Portainer API client
├── controllers/
│ ├── health_controller.py # Health endpoints
│ ├── housekeeping_controller.py # Home automation endpoints
│ ├── infrastructure_controller.py # Infrastructure management
│ ├── static_controller.py # Static file serving
│ └── tools_controller.py # DNS and utility tools
└── dns/
├── service.py # DNS lookup service
└── exceptions.py # DNS-specific exceptions
```
## API Endpoints
### Health
- `GET /` - Service info and documentation links
- `GET /health` - Basic health status
- `GET /health/full` - Detailed component health
- `GET /health/diagnostics` - Full diagnostic information
### Infrastructure (`/infrastructure`)
- `GET /infrastructure/health` - Portainer/NPM connection status
- `GET /infrastructure/services` - List all services (stacks)
- `GET /infrastructure/services/{name}` - Get service details
- `GET /infrastructure/services/{name}/status` - Service status
- `POST /infrastructure/services/{name}/start` - Start service
- `POST /infrastructure/services/{name}/stop` - Stop service
- `GET /infrastructure/containers` - List containers
- `GET /infrastructure/containers/{name}` - Container details
- `GET /infrastructure/containers/{name}/logs` - Container logs
- `GET /infrastructure/ports` - List exposed ports
- `GET /infrastructure/domains` - List proxy domains
- `GET /infrastructure/widget-data` - Dashboard widget data
### Housekeeping (`/housekeeping`)
- `GET /housekeeping/health` - Home Assistant connection status
- `GET /housekeeping/devices` - List controllable devices
- `GET /housekeeping/devices/{entity_id}` - Device details
- `POST /housekeeping/devices/{entity_id}/control` - Control device
- `GET /housekeeping/scenes` - List scenes
- `POST /housekeeping/scenes/{scene_id}/activate` - Activate scene
- `GET /housekeeping/scripts` - List scripts
- `POST /housekeeping/scripts/{script_id}/run` - Run script
- `GET /housekeeping/automations` - List automations
- `POST /housekeeping/automations/{automation_id}/toggle` - Toggle automation
- `GET /housekeeping/history` - State history
- `GET /housekeeping/areas` - List areas/rooms
### Tools (`/tools`)
- `POST /tools/dns/lookup` - DNS record lookup
## Development
### Requirements
@@ -44,9 +101,22 @@ pip install -r requirements.txt
uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
```
### Running Tests
```bash
# Run all tests
pytest
# Run with coverage
pytest --cov=src --cov-report=term-missing
# Run specific test file
pytest tests/test_housekeeping.py -v
```
### Adding New Dependencies
**Important**: Dependencies use major version pinning (`~=`) for automatic patch updates while preventing breaking changes.
Dependencies use major version pinning (`~=`) for automatic patch updates while preventing breaking changes.
1. Add package to `requirements.txt` with major version constraint:
```
@@ -58,14 +128,6 @@ uvicorn src.main:app --reload --host 0.0.0.0 --port 8083
docker restart core-api
```
The container automatically runs `pip install -r requirements.txt` on every boot, so new dependencies are installed immediately on restart.
**Version Pinning Best Practices**:
- Use `~=` (compatible release) for most packages: `fastapi~=0.115.0`
- Use `>=X,<Y` for complex constraints: `langchain-core>=0.3.17,<0.4.0`
- Allows automatic security patches without breaking changes
- Documented in PEP 440
### Docker Build
```bash
@@ -88,130 +150,38 @@ docker run -p 8083:8083 core-code:latest
### Environment Variables
See `.env.example` for all available configuration options.
| Variable | Description | Default |
|----------|-------------|---------|
| `PORTAINER_URL` | Portainer API URL | `http://localhost:9000` |
| `PORTAINER_API_KEY` | Portainer API key | - |
| `NPM_URL` | Nginx Proxy Manager URL | `http://localhost:81` |
| `NPM_EMAIL` | NPM admin email | - |
| `NPM_PASSWORD` | NPM admin password | - |
| `HOMEASSISTANT_URL` | Home Assistant URL | `http://localhost:8123` |
| `HOMEASSISTANT_TOKEN` | HA long-lived access token | - |
| `OIDC_ENABLED` | Enable OIDC auth | `false` |
| `OIDC_ISSUERS` | OIDC issuer URLs (comma-separated) | See config.py |
| `OIDC_AUDIENCES` | OIDC audiences (comma-separated) | See config.py |
## API Documentation
Once deployed, access documentation at:
- **Swagger UI**: http://192.168.86.149:8083/docs
- **ReDoc**: http://192.168.86.149:8083/redoc
- **OpenAPI Spec**: http://192.168.86.149:8083/openapi.json
## Integration with Open WebUI
### Method 1: Functions (OpenAPI Import)
1. In Open WebUI, navigate to Functions
2. Import from OpenAPI spec: `http://192.168.86.149:8083/openapi.json`
3. Use functions directly in chat
### Method 2: Pipelines
1. Create a pipeline that calls Core Code API endpoints
2. Use as data source for LLM workflows
### Method 3: Direct API Calls
```python
import httpx
async with httpx.AsyncClient() as client:
response = await client.post(
"http://192.168.86.149:8083/web-scraper/scrape",
json={
"url": "https://example.com",
"extract_main_content": True
}
)
data = response.json()
```
## API Endpoints
### Web Scraper
**POST /web-scraper/scrape**
Scrape and extract content from a website.
Request:
```json
{
"url": "https://example.com/article",
"extract_main_content": true,
"include_links": false,
"max_length": 10000
}
```
Response:
```json
{
"url": "https://example.com/article",
"title": "Article Title",
"content": "Extracted article content...",
"extracted_at": "2025-11-12T19:30:00Z",
"content_length": 5432,
"links": null
}
```
## Logging
Logs are written to:
- **Console**: stdout (captured by Docker)
- **File**: `/app/logs/app.log` (persisted via volume mount)
Log format:
```
2025-11-12 19:30:00 | INFO | src.web_scraper.service:scrape_url:45 | Starting scrape for URL: https://example.com
```
- **Swagger UI**: http://localhost:8083/docs
- **ReDoc**: http://localhost:8083/redoc
- **OpenAPI Spec**: http://localhost:8083/openapi.json
## Health Checks
- **Endpoint**: `GET /health`
- **Docker**: Automatic health checks configured
- **Response**: `{"status": "healthy"}`
- **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
- Runs as non-root user (uid 1000)
- No authentication required (internal network only)
- OIDC authentication support via Authentik
- CORS configured for same-network access
- Rate limiting: Not implemented (internal use only)
## Future Modules
The architecture supports adding new modules:
- Data transformation functions
- API integrations
- File processing
- Database queries
Each module follows the same structure:
```
src/
└── module_name/
├── config.py
├── schemas.py
├── service.py
├── router.py
└── exceptions.py
```
## Troubleshooting
### Container won't start
```bash
docker logs core-code
```
### API not responding
```bash
curl http://192.168.86.149:8083/health
```
### Check OpenAPI spec
```bash
curl http://192.168.86.149:8083/openapi.json | jq
```
- Admin endpoints require authentication when OIDC enabled
## License
-446
View File
@@ -1,446 +0,0 @@
# Requested Core-API Services for Core-AI Infrastructure Tools
This document specifies the API endpoints needed by core-ai infrastructure tools. All requests from core-ai should go through core-api for centralized logging and access control.
## Context
The core-ai service is implementing 9 infrastructure tools in 3 logical clusters:
1. **Container Lifecycle** (4 tools) - containers.py
2. **Service Management** (3 tools) - services.py
3. **Monitoring & Resources** (2 tools) - monitoring.py
These tools need corresponding core-api REST endpoints to perform operations via Portainer.
---
## Cluster 1: Container Lifecycle Management
### 1.1 List Containers
**Endpoint:** `GET /v1/infrastructure/containers`
**Query Parameters:**
- `status` (optional): Filter by status - "all", "running", "stopped", "paused" (default: "running")
**Response:**
```json
[
{
"Id": "abc123...",
"Names": ["/nginx"],
"State": "running",
"Status": "Up 3 days",
"Image": "nginx:latest",
"Ports": [
{"PrivatePort": 80, "PublicPort": 8080, "Type": "tcp"},
{"PrivatePort": 443, "PublicPort": 8443, "Type": "tcp"}
],
"StartedAt": "2024-12-01T10:00:00Z"
}
]
```
**Implementation Notes:**
- Use `PortainerClient.list_containers(all_containers=True)` with Docker socket fallback
- Filter results based on `status` query parameter
- Return standard Docker API container list format
---
### 1.2 Manage Container
**Endpoint:** `POST /v1/infrastructure/containers/{container}/{action}`
**Path Parameters:**
- `container`: Container name or ID (e.g., "nginx", "core-ai")
- `action`: One of: "start", "stop", "restart", "pause", "unpause", "remove"
**Response (Success):**
```json
{
"success": true,
"action": "restart",
"container": "nginx",
"message": "Container restarted successfully"
}
```
**Response (Error):**
```json
{
"success": false,
"error": "Container not found",
"message": "Container 'nginx2' not found. Available containers: nginx, core-ai, ollama"
}
```
**Status Codes:**
- `200` - Success
- `304` - Not Modified (already in target state)
- `404` - Container not found
- `409` - Conflict (e.g., cannot remove running container)
- `500` - Server error
**Implementation Notes:**
- For "restart": call stop then start
- For actions not yet in PortainerClient (pause, unpause, remove):
- Call Portainer API directly: `/api/endpoints/{endpoint_id}/docker/containers/{container_id}/{action}`
- Handle partial name matching (case-insensitive)
- Return helpful error messages suggesting `docker_list_containers()` when not found
---
### 1.3 Inspect Container
**Endpoint:** `GET /v1/infrastructure/containers/{container}`
**Path Parameters:**
- `container`: Container name or ID
**Query Parameters:**
- `details` (optional): Level of detail - "summary" (default), "full", "resources"
**Response:**
```json
{
"Id": "abc123...",
"Name": "/nginx",
"State": {
"Status": "running",
"Running": true,
"StartedAt": "2024-12-01T10:00:00Z",
"FinishedAt": "0001-01-01T00:00:00Z",
"ExitCode": 0
},
"Config": {
"Image": "nginx:latest",
"Env": ["PATH=/usr/local/sbin:...", "NGINX_VERSION=1.25.0"],
"Cmd": ["nginx", "-g", "daemon off;"]
},
"NetworkSettings": {
"Ports": {
"80/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8080"}],
"443/tcp": [{"HostIp": "0.0.0.0", "HostPort": "8443"}]
},
"Networks": {
"bridge": {
"IPAddress": "172.17.0.2",
"Gateway": "172.17.0.1"
}
}
},
"HostConfig": {
"Memory": 536870912,
"NanoCpus": 1000000000,
"RestartPolicy": {"Name": "unless-stopped"}
},
"Mounts": [
{
"Type": "bind",
"Source": "/host/path",
"Destination": "/container/path"
}
]
}
```
**Implementation Notes:**
- Use `PortainerClient.inspect_container(container)` which auto-detects endpoint and falls back to Docker socket
- Return full Docker inspect response
- The core-ai tool will handle formatting based on `details` level
- Return 404 if container not found
---
### 1.4 Container Logs
**Endpoint:** `GET /v1/infrastructure/containers/{container}/logs`
**Path Parameters:**
- `container`: Container name or ID
**Query Parameters:**
- `lines` (optional): Number of log lines (default: 50, max: 500)
- `since` (optional): Time filter - "1h", "30m", or ISO timestamp
**Response:**
```json
{
"container": "nginx",
"lines_requested": 50,
"since": null,
"logs": "2024-12-04T10:00:00.123Z Starting nginx...\n2024-12-04T10:00:01.456Z Ready to accept connections\n..."
}
```
**Implementation Notes:**
- Access Docker API directly: `GET /v1.41/containers/{container}/logs`
- Use Docker socket transport (httpx with uds)
- Parameters: `stdout=true`, `stderr=true`, `tail={lines}`, `timestamps=true`
- If `since` provided: add `since={unix_timestamp}` parameter
- Strip Docker stream headers (8-byte binary prefix per line)
- Return plain text logs with timestamps
- Return 404 if container not found
---
## Cluster 2: Service Management
### 2.1 List Services
**Endpoint:** `GET /v1/infrastructure/services` (already exists, may need enhancement)
**Query Parameters:**
- `stack` (optional): Filter by stack name
**Response:**
```json
[
{
"name": "portainer",
"stack_id": 1,
"status": "active",
"containers_running": 3,
"containers_total": 3,
"ports": [9000, 8000],
"domains": ["portainer.example.com"]
}
]
```
**Implementation Notes:**
- Enhance existing `/infrastructure/services` endpoint if needed
- Ensure it returns stack/service information from Portainer
- Include container counts (running/total)
---
### 2.2 Manage Service
**Endpoint:** `POST /v1/infrastructure/services/{service}/{action}`
**Path Parameters:**
- `service`: Service/stack name
- `action`: One of: "start", "stop", "restart", "scale"
**Request Body (for scale action):**
```json
{
"replicas": 3
}
```
**Response:**
```json
{
"success": true,
"action": "restart",
"service": "web",
"message": "Service restarted successfully"
}
```
**Implementation Notes:**
- For "start"/"stop": Use Portainer stack start/stop API
- For "restart": Stop then start the stack
- For "scale": Update stack with new replica count
- This may require updating stack compose file
---
### 2.3 Service Status
**Endpoint:** `GET /v1/infrastructure/services/{service}/status`
**Path Parameters:**
- `service`: Service/stack name
**Response:**
```json
{
"name": "web",
"status": "active",
"stack_id": 5,
"containers": [
{
"name": "web_app_1",
"status": "running",
"health": "healthy",
"uptime": "2 days"
}
],
"replica_status": "3/3 running",
"resources": {
"memory_total": "1.2 GB",
"cpu_usage": "15%"
},
"recent_events": [
{"time": "2024-12-04T09:00:00Z", "action": "container_start", "container": "web_app_3"}
]
}
```
**Implementation Notes:**
- Get stack details from Portainer
- Get individual container statuses
- Calculate aggregate resource usage
- May require querying Docker events API for recent events
---
## Cluster 3: Monitoring & Resources
### 3.1 System Resources
**Endpoint:** `GET /v1/infrastructure/resources/system`
**Response:**
```json
{
"cpu": {
"cores": 8,
"usage_percent": 45.2,
"load_average": [2.5, 2.3, 2.1]
},
"memory": {
"total_bytes": 16777216000,
"used_bytes": 8388608000,
"available_bytes": 8388608000,
"usage_percent": 50.0
},
"disk": {
"total_bytes": 500000000000,
"used_bytes": 250000000000,
"available_bytes": 250000000000,
"usage_percent": 50.0
},
"network": {
"interfaces": {
"eth0": {
"rx_bytes": 1000000000,
"tx_bytes": 500000000
}
}
}
}
```
**Implementation Notes:**
- Use Docker system info API: `GET /v1.41/system/df`
- May also use `GET /v1.41/info` for system-wide stats
- Calculate percentages and format nicely
- Include load averages from system stats
---
### 3.2 Container Resources
**Endpoint:** `GET /v1/infrastructure/resources/containers`
**Query Parameters:**
- `container` (optional): Specific container name/ID (if omitted, return all)
**Response:**
```json
[
{
"name": "nginx",
"cpu_percent": 5.2,
"memory_usage_bytes": 45000000,
"memory_limit_bytes": 100000000,
"memory_percent": 45.0,
"network_rx_bytes": 50000000,
"network_tx_bytes": 25000000,
"block_read_bytes": 10000000,
"block_write_bytes": 5000000
}
]
```
**Implementation Notes:**
- Use Docker stats API: `GET /v1.41/containers/{id}/stats?stream=false`
- If `container` param provided: return single container stats
- If omitted: return stats for all running containers
- Calculate percentages where applicable
- Stats API returns real-time metrics (one-time snapshot, not streaming)
---
## Implementation Priority
**Phase 1 (Needed immediately for core-ai):**
1. `GET /v1/infrastructure/containers` - List containers
2. `POST /v1/infrastructure/containers/{container}/{action}` - Manage containers
3. `GET /v1/infrastructure/containers/{container}` - Inspect container
4. `GET /v1/infrastructure/containers/{container}/logs` - Container logs
**Phase 2 (Needed for full infrastructure tools):**
5. `POST /v1/infrastructure/services/{service}/{action}` - Manage services
6. `GET /v1/infrastructure/services/{service}/status` - Service status
7. `GET /v1/infrastructure/resources/system` - System resources
8. `GET /v1/infrastructure/resources/containers` - Container resources
---
## Security & Access Control
All endpoints should:
- Log all requests (especially write operations)
- Support OIDC authentication when enabled
- Require admin privileges for destructive operations (remove, scale)
- Rate limit to prevent abuse
- Validate input parameters
- Return sanitized errors (no sensitive data in error messages)
---
## Error Handling
Standard error response format:
```json
{
"error": "ContainerNotFound",
"message": "Container 'nginx2' not found",
"details": {
"container": "nginx2",
"available_containers": ["nginx", "core-ai", "ollama"]
}
}
```
Common error codes:
- `400` - Bad Request (invalid parameters)
- `404` - Not Found (container/service doesn't exist)
- `409` - Conflict (invalid state transition)
- `500` - Internal Server Error (Portainer/Docker API failed)
- `503` - Service Unavailable (Portainer/Docker not accessible)
---
## Testing
Each endpoint should have:
- Unit tests (mock Portainer client)
- Integration tests (real Portainer/Docker)
- Error case tests (not found, permission denied, etc.)
- Performance tests (ensure response times < 2s)
---
## Questions / Decisions Needed
1. **Authentication**: Should container management require admin role, or allow read-only for all users?
2. **Rate Limiting**: What limits should be applied to prevent abuse?
3. **Caching**: Should container lists be cached? (TTL: 5s?)
4. **Async**: Should heavy operations (like logs) be async with job IDs?
5. **Webhooks**: Should operations emit events for monitoring?
---
## Notes
- All endpoints follow RESTful conventions
- Use existing PortainerClient methods where available
- Fall back to Docker socket when Portainer doesn't have data
- Log all operations with timestamps, user, and outcome
- Consider adding `/v1/infrastructure/containers/search` for fuzzy name matching
+68
View File
@@ -0,0 +1,68 @@
# Alembic Configuration for Core-API
#
# Database migrations using async SQLAlchemy
[alembic]
# Path to migration scripts
script_location = alembic
# Template for migration file names
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# Prepend sys.path with the project root
prepend_sys_path = .
# Timezone for revision creation date
timezone = UTC
# Max length of revision identifiers
truncate_slug_length = 40
# Set to 'true' to run in offline mode
revision_environment = false
# Set to 'true' for sqlalchemy.url to be from the environment
# We use env.py to get the URL from config.py instead
sqlalchemy.url =
[post_write_hooks]
# Black formatting on generated migration files
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 100 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+21
View File
@@ -0,0 +1,21 @@
Alembic Migrations for Core-API
This directory contains database migrations managed by Alembic.
Commands:
# Generate a new migration (after changing models)
alembic revision --autogenerate -m "description"
# Apply all pending migrations
alembic upgrade head
# Rollback last migration
alembic downgrade -1
# View migration history
alembic history
# View current revision
alembic current
See https://alembic.sqlalchemy.org for more documentation.
+99
View File
@@ -0,0 +1,99 @@
"""
Alembic Environment Configuration
Async migration environment for SQLAlchemy 2.0 with asyncpg.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import our models and config
from src.shared.config import get_settings
from src.shared.database import Base
# Import all models to ensure they're registered with Base.metadata
from src.domains.auth.models import User, Role, UserRole, UserPreferences, ApiKey # noqa: F401
from src.domains.dashboard.models import QuickLink, DashboardWidget # noqa: F401
# Alembic Config object
config = context.config
# Interpret the config file for Python logging
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Model metadata for autogenerate support
target_metadata = Base.metadata
# Get database URL from our settings
settings = get_settings()
db_url = settings.database_url
if db_url.startswith("postgresql://"):
db_url = db_url.replace("postgresql://", "postgresql+asyncpg://", 1)
def run_migrations_offline() -> None:
"""
Run migrations in 'offline' mode.
Generates SQL script without connecting to the database.
"""
context.configure(
url=db_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""
Run migrations with the given connection.
"""
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""
Run migrations in 'online' mode with async engine.
"""
configuration = config.get_section(config.config_ini_section) or {}
configuration["sqlalchemy.url"] = db_url
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""
Run migrations in 'online' mode.
"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,138 @@
"""Create auth tables
Revision ID: 001
Revises:
Create Date: 2026-01-01
Creates the initial authentication and authorization tables:
- users: User accounts synced from Authentik
- roles: Domain-scoped permission roles
- user_roles: User-Role association table
- user_preferences: User settings and preferences
- api_keys: API key authentication
"""
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 = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Users table
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("avatar_url", sa.String(500), nullable=True),
sa.Column("api_keys_enabled", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("last_login", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_users_authentik_id", "users", ["authentik_id"], unique=True)
op.create_index("ix_users_email", "users", ["email"], unique=True)
# Roles table
op.create_table(
"roles",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(100), nullable=False, comment="Role name in format domain:action"),
sa.Column("domain", sa.String(50), nullable=False, comment="Permission domain"),
sa.Column("action", sa.String(20), nullable=False, comment="Permission action"),
sa.Column("authentik_group", sa.String(255), nullable=True, comment="Corresponding Authentik group name"),
)
op.create_index("ix_roles_name", "roles", ["name"], unique=True)
op.create_index("ix_roles_domain", "roles", ["domain"])
op.create_index("ix_roles_authentik_group", "roles", ["authentik_group"], unique=True)
# User-Role association table
op.create_table(
"user_roles",
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
)
# User preferences table
op.create_table(
"user_preferences",
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
sa.Column("theme", sa.String(20), nullable=False, server_default="system", comment="Theme preference"),
sa.Column("default_room", sa.String(50), nullable=False, server_default="front-hall", comment="Default room"),
sa.Column("preferences_json", postgresql.JSONB(), nullable=False, server_default="{}"),
)
# API keys table
op.create_table(
"api_keys",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.String(100), nullable=False, comment="Human-readable key name"),
sa.Column("key_hash", sa.String(255), nullable=False, comment="SHA-256 hash of the API key"),
sa.Column("key_prefix", sa.String(8), nullable=False, comment="First 8 chars for identification"),
sa.Column("scopes", postgresql.ARRAY(sa.String()), nullable=True, comment="Optional scope restriction"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
# Seed initial roles (domain:action combinations)
roles_table = sa.table(
"roles",
sa.column("id", postgresql.UUID),
sa.column("name", sa.String),
sa.column("domain", sa.String),
sa.column("action", sa.String),
sa.column("authentik_group", sa.String),
)
domains = [
"control-room",
"library",
"media",
"ai",
"housekeeper",
"developer",
"documents",
"gaming",
"admin",
]
actions = ["viewer", "user", "editor", "admin"]
roles_data = []
for domain in domains:
for action in actions:
role_name = f"{domain}:{action}"
authentik_group = f"tatlock-{domain}-{action}"
roles_data.append({
"id": sa.text("gen_random_uuid()"),
"name": role_name,
"domain": domain,
"action": action,
"authentik_group": authentik_group,
})
# Insert roles using raw SQL for UUID generation
for role in roles_data:
op.execute(
f"""
INSERT INTO roles (id, name, domain, action, authentik_group)
VALUES (gen_random_uuid(), '{role["name"]}', '{role["domain"]}', '{role["action"]}', '{role["authentik_group"]}')
"""
)
def downgrade() -> None:
op.drop_table("api_keys")
op.drop_table("user_preferences")
op.drop_table("user_roles")
op.drop_table("roles")
op.drop_table("users")
@@ -0,0 +1,40 @@
"""Create groups table
Revision ID: 002
Revises: 001
Create Date: 2026-01-01
Creates the groups table for syncing Authentik groups.
"""
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 = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Groups table
op.create_table(
"groups",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("authentik_id", postgresql.UUID(as_uuid=True), nullable=False, comment="Authentik group UUID"),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default="false", comment="Whether members have superuser privileges"),
sa.Column("parent_name", sa.String(255), nullable=True, comment="Parent group name for hierarchy"),
sa.Column("member_count", sa.Integer(), nullable=False, server_default="0", comment="Number of users in this group"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("synced_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="Last sync from Authentik"),
)
op.create_index("ix_groups_authentik_id", "groups", ["authentik_id"], unique=True)
op.create_index("ix_groups_name", "groups", ["name"], unique=True)
def downgrade() -> None:
op.drop_table("groups")
@@ -0,0 +1,71 @@
"""Create dashboard tables
Revision ID: 003
Revises: 002
Create Date: 2026-01-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '003'
down_revision: Union[str, None] = '002'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create quick_links and dashboard_widgets tables."""
# Create quick_links table
op.create_table(
'quick_links',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=100), nullable=False),
sa.Column('url', sa.String(length=500), nullable=False),
sa.Column('icon', sa.String(length=100), nullable=True),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('category', sa.String(length=50), nullable=True),
sa.Column('user_id', sa.String(length=255), nullable=True),
sa.Column('position', sa.Integer(), nullable=True, default=0),
sa.Column('is_visible', sa.Boolean(), nullable=True, default=True),
sa.Column('color', sa.String(length=20), nullable=True),
sa.Column('background_color', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quick_links_id'), 'quick_links', ['id'], unique=False)
op.create_index(op.f('ix_quick_links_user_id'), 'quick_links', ['user_id'], unique=False)
# Create dashboard_widgets table
op.create_table(
'dashboard_widgets',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('widget_type', sa.String(length=50), nullable=False),
sa.Column('user_id', sa.String(length=255), nullable=True),
sa.Column('position_x', sa.Integer(), nullable=True, default=0),
sa.Column('position_y', sa.Integer(), nullable=True, default=0),
sa.Column('width', sa.Integer(), nullable=True, default=1),
sa.Column('height', sa.Integer(), nullable=True, default=1),
sa.Column('config', sa.Text(), nullable=True),
sa.Column('is_visible', sa.Boolean(), nullable=True, default=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_dashboard_widgets_id'), 'dashboard_widgets', ['id'], unique=False)
op.create_index(op.f('ix_dashboard_widgets_user_id'), 'dashboard_widgets', ['user_id'], unique=False)
def downgrade() -> None:
"""Drop dashboard tables."""
op.drop_index(op.f('ix_dashboard_widgets_user_id'), table_name='dashboard_widgets')
op.drop_index(op.f('ix_dashboard_widgets_id'), table_name='dashboard_widgets')
op.drop_table('dashboard_widgets')
op.drop_index(op.f('ix_quick_links_user_id'), table_name='quick_links')
op.drop_index(op.f('ix_quick_links_id'), table_name='quick_links')
op.drop_table('quick_links')
@@ -0,0 +1,26 @@
"""add_link_type_to_quick_links
Revision ID: f0349c95aa5d
Revises: 003
Create Date: 2026-01-03 13:14:46.911770+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f0349c95aa5d'
down_revision: Union[str, None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('quick_links', sa.Column('link_type', sa.String(length=20), nullable=True, server_default='iframe'))
# Update existing rows to have the default value
op.execute("UPDATE quick_links SET link_type = 'iframe' WHERE link_type IS NULL")
def downgrade() -> None:
op.drop_column('quick_links', 'link_type')
@@ -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")
+12
View File
@@ -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
View File
@@ -1,6 +1,6 @@
[project]
name = "core-api"
version = "1.1.1"
version = "1.10.7"
description = "Core Code API - Infrastructure management and tools API"
readme = "README.md"
requires-python = ">=3.12"
+18 -8
View File
@@ -1,12 +1,13 @@
# FastAPI and ASGI server
fastapi~=0.115.0
uvicorn[standard]>=0.34.0 # Updated for google-adk compatibility
pydantic>=2.11.1,<3.0.0 # Required for google-cloud-aiplatform[agent-engines]
fastapi>=0.115.0
starlette>=0.49.1 # CVE-2025-54121, CVE-2025-62727
uvicorn[standard]>=0.34.0
pydantic>=2.11.1,<3.0.0
pydantic-settings>=2.10.1
# HTTP client
httpx>=0.28.0 # Required for google-adk
python-socketio[asyncio_client]~=5.11.0
httpx>=0.28.0
python-socketio[asyncio_client]>=5.14.0 # CVE-2025-61765
# Web scraping
beautifulsoup4~=4.12.0
@@ -20,8 +21,17 @@ 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
python-jose[cryptography]~=3.3.0
cryptography~=43.0.0
PyJWT[crypto]>=2.9.0
python-jose[cryptography]>=3.4.0 # CVE PYSEC-2024-232, PYSEC-2024-233
cryptography>=44.0.1 # CVE-2024-12797
# Database
sqlalchemy[asyncio]~=2.0.0
asyncpg>=0.30.0
alembic~=1.13.0
# Vector Database
qdrant-client>=1.9.0
+1 -1
View File
@@ -1,6 +1,6 @@
"""
Core Code API - OpenAPI-compatible functions for Open WebUI
"""
from src.shared.config import __version__
__version__ = "1.0.0"
__author__ = "Core Code Team"
+22
View File
@@ -3,3 +3,25 @@ Authentication module for core-api
Provides OIDC/OAuth2 authentication via Authentik
"""
from src.auth.oidc import (
get_current_user,
get_admin_user,
get_optional_user,
get_forward_auth_user,
get_forward_auth_admin,
oidc_config,
)
from src.auth.service import AuthService, get_auth_service
from src.auth.controller import auth_controller
__all__ = [
"get_current_user",
"get_admin_user",
"get_optional_user",
"get_forward_auth_user",
"get_forward_auth_admin",
"oidc_config",
"AuthService",
"get_auth_service",
"auth_controller",
]
+309
View File
@@ -0,0 +1,309 @@
"""
Authentication Controller
Provides authentication endpoints for OIDC token sync and user management.
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.controllers.base import BaseController
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__)
class AuthController(BaseController):
"""
Controller for authentication operations
Provides endpoints for:
- Token synchronization (login)
- User profile retrieval
"""
def __init__(self):
super().__init__(prefix="/auth", tags=["Authentication"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.post(
"/sync",
summary="Sync user from OIDC token",
response_model=AuthSyncResponse,
responses={
200: {"description": "User synced successfully"},
401: {"description": "Invalid or expired token"},
503: {"description": "Authentication service unavailable"},
},
)
async def sync_user(
request: AuthSyncRequest,
session: AsyncSession = Depends(get_async_session),
) -> AuthSyncResponse:
"""
Synchronize user from OIDC access token
This endpoint should be called after the client obtains an access token
from Authentik. It:
1. Validates the token via Authentik's userinfo endpoint
2. Creates or updates the user in the database
3. Syncs roles from Authentik groups
4. Returns the user profile with roles and preferences
The client should store the returned user info for local use.
"""
service = AuthService(session)
try:
# Validate token with Authentik
token_info = await service.validate_token(request.access_token)
except ValueError as e:
logger.warning(f"Token validation failed: {e}")
raise HTTPException(status_code=401, detail=str(e))
# Sync user to database
user, is_new = await service.sync_user(token_info)
# Sync roles from groups
roles = await service.sync_roles(user, token_info.groups)
# Commit the transaction
await session.commit()
# Refresh to get relationships
await session.refresh(user, ["preferences"])
# Build response
return AuthSyncResponse(
user=service.user_to_schema(user),
roles=service.roles_to_schema(roles),
preferences=service.preferences_to_schema(user.preferences),
is_new_user=is_new,
)
@router.get(
"/users",
summary="List all users",
response_model=UsersListResponse,
responses={
200: {"description": "List of users"},
},
)
async def list_users(
search: Optional[str] = Query(None, description="Search by name or email"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
session: AsyncSession = Depends(get_async_session),
) -> UsersListResponse:
"""
List all users who have logged in via Authentik
Returns paginated list of users with their roles.
Supports search filtering by name or email.
"""
service = AuthService(session)
items, total = await service.list_users(
search=search,
offset=offset,
limit=limit,
)
return UsersListResponse(items=items, total=total)
@router.post(
"/users/sync-from-authentik",
summary="Bulk sync users from Authentik",
response_model=BulkSyncResultSchema,
responses={
200: {"description": "Sync completed"},
401: {"description": "Authentik API token invalid"},
503: {"description": "Authentik service unavailable"},
},
)
async def sync_users_from_authentik(
session: AsyncSession = Depends(get_async_session),
) -> BulkSyncResultSchema:
"""
Fetch all users from Authentik and sync to local database
This endpoint uses the Authentik admin API to fetch all users
and create/update them in the local database. Requires
AUTHENTIK_CORE_API_TOKEN to be configured.
Use this to initially populate users or to re-sync after
changes in Authentik.
"""
service = AuthService(session)
try:
result = await service.bulk_sync_from_authentik()
logger.info(
f"Bulk sync completed: {result.created} created, "
f"{result.updated} updated, {result.failed} failed"
)
return result
except ValueError as e:
logger.error(f"Bulk sync failed: {e}")
raise HTTPException(status_code=401, detail=str(e))
@router.get(
"/groups",
summary="List all groups",
response_model=GroupsListResponse,
responses={
200: {"description": "List of groups"},
},
)
async def list_groups(
search: Optional[str] = Query(None, description="Search by group name"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
session: AsyncSession = Depends(get_async_session),
) -> GroupsListResponse:
"""
List all groups synced from Authentik
Returns paginated list of groups with their details.
Supports search filtering by name.
"""
service = AuthService(session)
items, total = await service.list_groups(
search=search,
offset=offset,
limit=limit,
)
return GroupsListResponse(items=items, total=total)
@router.post(
"/groups/sync-from-authentik",
summary="Bulk sync groups from Authentik",
response_model=BulkSyncResultSchema,
responses={
200: {"description": "Sync completed"},
401: {"description": "Authentik API credentials invalid"},
503: {"description": "Authentik service unavailable"},
},
)
async def sync_groups_from_authentik(
session: AsyncSession = Depends(get_async_session),
) -> BulkSyncResultSchema:
"""
Fetch all groups from Authentik and sync to local database
This endpoint uses the Authentik admin API to fetch all groups
and create/update them in the local database. Requires
AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD to be configured.
Use this to populate groups or to re-sync after changes in Authentik.
"""
service = AuthService(session)
try:
result = await service.bulk_sync_groups_from_authentik()
logger.info(
f"Groups bulk sync completed: {result.created} created, "
f"{result.updated} updated, {result.failed} failed"
)
return result
except ValueError as e:
logger.error(f"Groups bulk sync failed: {e}")
raise HTTPException(status_code=401, detail=str(e))
@router.get(
"/me",
summary="Get current user profile",
response_model=AuthSyncResponse,
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),
) -> AuthSyncResponse:
"""
Get the current authenticated user's profile
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.
"""
# 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
# Create controller instance
auth_controller = AuthController()
+54 -22
View File
@@ -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")
+129
View File
@@ -0,0 +1,129 @@
"""
Authentication Schemas
Pydantic models for auth request/response payloads.
"""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import Field
from src.base_schema import BaseSchema
class AuthSyncRequest(BaseSchema):
"""
Request payload for POST /auth/sync
The client sends this after obtaining an OIDC token from Authentik.
The access_token is validated against Authentik's userinfo endpoint.
"""
access_token: str = Field(
...,
description="OIDC access token from Authentik",
)
class RoleSchema(BaseSchema):
"""Role information in domain:action format"""
name: str = Field(..., description="Role name (e.g., 'control-room:admin')")
domain: str = Field(..., description="Permission domain (e.g., 'control-room')")
action: str = Field(..., description="Permission action (e.g., 'admin')")
class UserPreferencesSchema(BaseSchema):
"""User preferences"""
theme: str = Field(default="system", description="Theme preference: system, light, dark")
default_room: str = Field(default="front-hall", description="Default room for housekeeping")
preferences_json: dict = Field(default_factory=dict, description="Extended preferences")
class UserSchema(BaseSchema):
"""User information returned from sync"""
id: uuid.UUID = Field(..., description="Internal user ID")
authentik_id: uuid.UUID = Field(..., description="Authentik user ID")
email: str = Field(..., description="User email")
name: str = Field(..., description="Display name")
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
created_at: datetime = Field(..., description="Account creation timestamp")
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
class AuthSyncResponse(BaseSchema):
"""
Response from POST /auth/sync
Contains the synced user profile, roles, and preferences.
"""
user: UserSchema = Field(..., description="User profile")
roles: list[RoleSchema] = Field(..., description="User's permission roles")
preferences: UserPreferencesSchema = Field(..., description="User preferences")
is_new_user: bool = Field(..., description="True if user was just created")
class TokenInfoSchema(BaseSchema):
"""
Token information from Authentik userinfo endpoint
This is what Authentik returns when validating an access token.
"""
sub: str = Field(..., description="Subject (Authentik user ID)")
email: str = Field(..., description="User email")
name: Optional[str] = Field(None, description="Display name")
preferred_username: Optional[str] = Field(None, description="Username")
groups: list[str] = Field(default_factory=list, description="Group memberships")
picture: Optional[str] = Field(None, description="Profile picture URL")
class UserListItemSchema(BaseSchema):
"""User item for list display"""
id: uuid.UUID = Field(..., description="Internal user ID")
email: str = Field(..., description="User email")
name: str = Field(..., description="Display name")
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
created_at: datetime = Field(..., description="Account creation timestamp")
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
roles: list[str] = Field(default_factory=list, description="Role names")
class UsersListResponse(BaseSchema):
"""Response from GET /auth/users"""
items: list[UserListItemSchema] = Field(..., description="List of users")
total: int = Field(..., description="Total count of users")
class BulkSyncResultSchema(BaseSchema):
"""Result from bulk sync operation"""
created: int = Field(..., description="Number of users created")
updated: int = Field(..., description="Number of users updated")
failed: int = Field(..., description="Number of users that failed to sync")
total_in_authentik: int = Field(..., description="Total users in Authentik")
errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs")
class GroupListItemSchema(BaseSchema):
"""Group item for list display"""
id: uuid.UUID = Field(..., description="Internal group ID")
authentik_id: uuid.UUID = Field(..., description="Authentik group ID")
name: str = Field(..., description="Group name")
is_superuser: bool = Field(default=False, description="Whether group has superuser privileges")
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")
class GroupsListResponse(BaseSchema):
"""Response from GET /auth/groups"""
items: list[GroupListItemSchema] = Field(..., description="List of groups")
total: int = Field(..., description="Total count of groups")
+667
View File
@@ -0,0 +1,667 @@
"""
Authentication Service
Business logic for user synchronization from Authentik.
"""
import re
import uuid
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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
logger = get_logger(__name__)
settings = get_settings()
class AuthService:
"""
Service for authentication and user synchronization
Handles:
- Token validation via Authentik userinfo endpoint
- User creation/update from OIDC claims
- Role synchronization from Authentik groups
"""
def __init__(self, session: AsyncSession):
"""
Initialize auth service
Args:
session: Async database session
"""
self.session = session
self.userinfo_url = f"{settings.authentik_url}/application/o/userinfo/"
async def validate_token(self, access_token: str) -> TokenInfoSchema:
"""
Validate access token via Authentik userinfo endpoint
Args:
access_token: OIDC access token
Returns:
Token info containing user claims
Raises:
ValueError: If token is invalid or expired
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
self.userinfo_url,
headers={"Authorization": f"Bearer {access_token}"},
)
if response.status_code == 401:
raise ValueError("Invalid or expired token")
response.raise_for_status()
data = response.json()
logger.debug(f"Userinfo response: {data}")
return TokenInfoSchema(
sub=data.get("sub"),
email=data.get("email"),
name=data.get("name") or data.get("preferred_username"),
preferred_username=data.get("preferred_username"),
groups=data.get("groups", []),
picture=data.get("picture"),
)
except httpx.HTTPStatusError as e:
logger.error(f"Authentik userinfo request failed: {e}")
raise ValueError(f"Token validation failed: {e.response.status_code}")
except httpx.RequestError as e:
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
Args:
token_info: Validated token information
Returns:
Tuple of (User, is_new_user)
"""
authentik_id = uuid.UUID(token_info.sub)
# 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=token_info.email,
name=token_info.name or token_info.email,
avatar_url=token_info.picture,
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: {token_info.email}")
else:
# Update existing user
user.email = token_info.email
user.name = token_info.name or token_info.email
user.avatar_url = token_info.picture
user.last_login = datetime.now(timezone.utc)
logger.info(f"Updated existing user: {token_info.email}")
await self.session.flush()
return user, is_new
async def sync_roles(self, user: User, groups: list[str]) -> list[Role]:
"""
Synchronize user roles from Authentik groups
Maps Authentik groups (e.g., 'tatlock-control-room-admin')
to application roles (e.g., 'control-room:admin').
Args:
user: User to sync roles for
groups: 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))
result = await self.session.execute(stmt)
matching_roles = list(result.scalars().all())
# 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}")
return matching_roles
def user_to_schema(self, user: User) -> UserSchema:
"""Convert User model to schema"""
return UserSchema(
id=user.id,
authentik_id=user.authentik_id,
email=user.email,
name=user.name,
avatar_url=user.avatar_url,
created_at=user.created_at,
last_login=user.last_login,
)
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)
for r in roles
]
def preferences_to_schema(self, preferences: Optional[UserPreferences]) -> UserPreferencesSchema:
"""Convert UserPreferences model to schema"""
if preferences is None:
return UserPreferencesSchema()
return UserPreferencesSchema(
theme=preferences.theme,
default_room=preferences.default_room,
preferences_json=preferences.preferences_json or {},
)
async def list_users(
self,
search: Optional[str] = None,
offset: int = 0,
limit: int = 50,
) -> tuple[list[UserListItemSchema], int]:
"""
List all users with optional search and pagination
Args:
search: Optional search query (matches name or email)
offset: Number of records to skip
limit: Maximum number of records to return
Returns:
Tuple of (list of user schemas, total count)
"""
from sqlalchemy import func
# Base query with roles loaded
base_query = select(User).options(selectinload(User.roles))
# Apply search filter if provided
if search:
search_filter = f"%{search}%"
base_query = base_query.where(
(User.name.ilike(search_filter)) | (User.email.ilike(search_filter))
)
# Get total count
count_query = select(func.count()).select_from(base_query.subquery())
total_result = await self.session.execute(count_query)
total = total_result.scalar() or 0
# Apply pagination and ordering
query = base_query.order_by(User.name).offset(offset).limit(limit)
result = await self.session.execute(query)
users = list(result.scalars().all())
# Convert to schemas
items = [
UserListItemSchema(
id=user.id,
email=user.email,
name=user.name,
avatar_url=user.avatar_url,
created_at=user.created_at,
last_login=user.last_login,
roles=[role.name for role in user.roles],
)
for user in users
]
return items, total
def _extract_cookie(self, headers: httpx.Headers, cookie_name: str) -> str:
"""Extract a specific cookie value from Set-Cookie headers"""
for header in headers.get_list('set-cookie'):
if header.startswith(f'{cookie_name}='):
match = re.match(rf'{cookie_name}=([^;]+)', header)
if match:
return match.group(1)
return ""
async def _authentik_session_login(self, client: httpx.AsyncClient) -> str:
"""
Authenticate with Authentik using the flow API to establish a session
Authentik's flow API requires:
1. Cookie persistence between requests (manually handled due to domain restrictions)
2. X-authentik-CSRF header set to the authentik_csrf cookie value
3. Multi-stage flow handling (identification -> password -> done)
Args:
client: httpx client
Returns:
Session cookie value for subsequent API calls
Raises:
ValueError: If authentication fails
"""
flow_url = f"{settings.authentik_url}/api/v3/flows/executor/default-authentication-flow/"
# Step 1: Get the initial flow challenge (this sets the session and csrf cookies)
resp = await client.get(flow_url, headers={"Accept": "application/json"})
resp.raise_for_status()
data = resp.json()
# Extract cookies manually from Set-Cookie headers (bypasses domain restrictions)
session_cookie = self._extract_cookie(resp.headers, "authentik_session")
csrf_cookie = self._extract_cookie(resp.headers, "authentik_csrf")
logger.debug(f"Flow initial: component={data.get('component')}, session={bool(session_cookie)}, csrf={bool(csrf_cookie)}")
# Build headers with manual cookie and CSRF token
def build_headers():
hdrs = {
"Accept": "application/json",
"Content-Type": "application/json",
"Cookie": f"authentik_session={session_cookie}",
}
if csrf_cookie:
hdrs["Cookie"] += f"; authentik_csrf={csrf_cookie}"
hdrs["X-authentik-CSRF"] = csrf_cookie
return hdrs
# Step 2: Handle identification stage - submit username
if data.get("component") == "ak-stage-identification":
resp = await client.post(
flow_url,
json={"uid_field": settings.authentik_username},
headers=build_headers(),
)
resp.raise_for_status()
data = resp.json()
# Update session cookie if new one received
new_session = self._extract_cookie(resp.headers, "authentik_session")
if new_session:
session_cookie = new_session
logger.debug(f"After username: component={data.get('component')}")
# Step 3: Handle password stage if required
if data.get("component") == "ak-stage-password":
resp = await client.post(
flow_url,
json={"password": settings.authentik_password},
headers=build_headers(),
)
resp.raise_for_status()
data = resp.json()
# Update session cookie if new one received
new_session = self._extract_cookie(resp.headers, "authentik_session")
if new_session:
session_cookie = new_session
logger.debug(f"After password: component={data.get('component')}")
# Check for access denied
if data.get("component") == "ak-stage-access-denied":
raise ValueError("Authentik authentication failed: access denied")
# Check for redirect (successful auth)
if data.get("component") == "xak-flow-redirect" or data.get("to"):
logger.info("Successfully authenticated with Authentik via flow")
return session_cookie
# If we're still in identification stage, the username might be wrong
if data.get("component") == "ak-stage-identification":
response_errors = data.get("response_errors", {})
raise ValueError(f"Authentication stuck at identification stage: {response_errors}")
logger.info(f"Authentik flow completed with component: {data.get('component')}")
return session_cookie
async def bulk_sync_from_authentik(self) -> BulkSyncResultSchema:
"""
Fetch all users from Authentik admin API and sync to local database
Returns:
BulkSyncResultSchema with counts of created/updated/failed users
"""
if not settings.authentik_username or not settings.authentik_password:
raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured")
created = 0
updated = 0
failed = 0
errors = []
total_in_authentik = 0
# Step 1: Fetch all user data from Authentik API
authentik_users = []
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
# Authenticate with Authentik to get session cookie
session_cookie = await self._authentik_session_login(client)
# Fetch users from Authentik admin API using session cookie
response = await client.get(
f"{settings.authentik_url}/api/v3/core/users/",
params={"page_size": 500},
headers={
"Accept": "application/json",
"Cookie": f"authentik_session={session_cookie}",
},
)
if response.status_code == 401:
raise ValueError("Authentik API token is invalid or expired")
response.raise_for_status()
data = response.json()
authentik_users = data.get("results", [])
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_users))
except httpx.HTTPStatusError as e:
raise ValueError(f"Authentik API error: {e.response.status_code}")
except httpx.RequestError as e:
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
# Step 2: Sync users to database (outside of httpx context to avoid greenlet issues)
for auth_user in authentik_users:
try:
# Skip service accounts and inactive users
if auth_user.get("type") in ("service_account", "internal_service_account"):
continue
if not auth_user.get("is_active", True):
continue
# Extract user data from Authentik
authentik_id = uuid.UUID(auth_user["uuid"])
email = auth_user.get("email") or f"{auth_user['username']}@local"
name = auth_user.get("name") or auth_user.get("username", "Unknown")
avatar_url = auth_user.get("avatar")
# Get user's groups for role mapping
groups = []
groups_summary = auth_user.get("groups_obj", [])
for group in groups_summary:
groups.append(group.get("name", ""))
# Check if user exists
stmt = select(User).where(User.authentik_id == authentik_id)
result = await self.session.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
# Create new user
user = User(
authentik_id=authentik_id,
email=email,
name=name,
avatar_url=avatar_url,
)
self.session.add(user)
await self.session.flush()
# Create default preferences
preferences = UserPreferences(user_id=user.id)
self.session.add(preferences)
created += 1
logger.info(f"Created user from Authentik: {email}")
else:
# Update existing user
user.email = email
user.name = name
user.avatar_url = avatar_url
updated += 1
logger.info(f"Updated user from Authentik: {email}")
# Sync roles from groups
await self.sync_roles(user, groups)
except Exception as e:
failed += 1
error_msg = f"Failed to sync user {auth_user.get('username', 'unknown')}: {str(e)}"
errors.append(error_msg)
logger.warning(error_msg)
# Commit all changes
await self.session.commit()
return BulkSyncResultSchema(
created=created,
updated=updated,
failed=failed,
total_in_authentik=total_in_authentik,
errors=errors,
)
async def list_groups(
self,
search: Optional[str] = None,
offset: int = 0,
limit: int = 50,
) -> tuple[list[GroupListItemSchema], int]:
"""
List all groups with optional search and pagination
Args:
search: Optional search query (matches name)
offset: Number of records to skip
limit: Maximum number of records to return
Returns:
Tuple of (list of group schemas, total count)
"""
from sqlalchemy import func
# Base query
base_query = select(Group)
# Apply search filter if provided
if search:
search_filter = f"%{search}%"
base_query = base_query.where(Group.name.ilike(search_filter))
# Get total count
count_query = select(func.count()).select_from(base_query.subquery())
total_result = await self.session.execute(count_query)
total = total_result.scalar() or 0
# Apply pagination and ordering
query = base_query.order_by(Group.name).offset(offset).limit(limit)
result = await self.session.execute(query)
groups = list(result.scalars().all())
# Convert to schemas
items = [
GroupListItemSchema(
id=group.id,
authentik_id=group.authentik_id,
name=group.name,
is_superuser=group.is_superuser,
parent_name=group.parent_name,
member_count=group.member_count,
synced_at=group.synced_at,
)
for group in groups
]
return items, total
async def bulk_sync_groups_from_authentik(self) -> BulkSyncResultSchema:
"""
Fetch all groups from Authentik admin API and sync to local database
Returns:
BulkSyncResultSchema with counts of created/updated/failed groups
"""
if not settings.authentik_username or not settings.authentik_password:
raise ValueError("AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD must be configured")
created = 0
updated = 0
failed = 0
errors = []
total_in_authentik = 0
# Step 1: Fetch all group data from Authentik API
authentik_groups = []
try:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
# Authenticate with Authentik to get session cookie
session_cookie = await self._authentik_session_login(client)
# Fetch groups from Authentik admin API using session cookie
response = await client.get(
f"{settings.authentik_url}/api/v3/core/groups/",
params={"page_size": 500},
headers={
"Accept": "application/json",
"Cookie": f"authentik_session={session_cookie}",
},
)
if response.status_code == 401:
raise ValueError("Authentik API token is invalid or expired")
response.raise_for_status()
data = response.json()
authentik_groups = data.get("results", [])
total_in_authentik = data.get("pagination", {}).get("count", len(authentik_groups))
except httpx.HTTPStatusError as e:
raise ValueError(f"Authentik API error: {e.response.status_code}")
except httpx.RequestError as e:
raise ValueError(f"Failed to connect to Authentik: {str(e)}")
# Step 2: Sync groups to database (outside of httpx context to avoid greenlet issues)
for auth_group in authentik_groups:
try:
# Extract group data from Authentik
authentik_id = uuid.UUID(auth_group["pk"])
name = auth_group.get("name", "Unknown")
is_superuser = auth_group.get("is_superuser", False)
parent_name = auth_group.get("parent_name")
# users field contains list of user PKs
member_count = len(auth_group.get("users", []))
# Check if group exists
stmt = select(Group).where(Group.authentik_id == authentik_id)
result = await self.session.execute(stmt)
group = result.scalar_one_or_none()
if group is None:
# Create new group
group = Group(
authentik_id=authentik_id,
name=name,
is_superuser=is_superuser,
parent_name=parent_name,
member_count=member_count,
)
self.session.add(group)
created += 1
logger.info(f"Created group from Authentik: {name}")
else:
# Update existing group
group.name = name
group.is_superuser = is_superuser
group.parent_name = parent_name
group.member_count = member_count
updated += 1
logger.info(f"Updated group from Authentik: {name}")
except Exception as e:
failed += 1
error_msg = f"Failed to sync group {auth_group.get('name', 'unknown')}: {str(e)}"
errors.append(error_msg)
logger.warning(error_msg)
# Commit all changes
await self.session.commit()
return BulkSyncResultSchema(
created=created,
updated=updated,
failed=failed,
total_in_authentik=total_in_authentik,
errors=errors,
)
# Factory function for dependency injection
def get_auth_service(session: AsyncSession) -> AuthService:
"""Create AuthService instance with database session"""
return AuthService(session)
-197
View File
@@ -1,197 +0,0 @@
"""
Core-AI HTTP Client
Provides interface to Core-AI service for AI performance metrics.
"""
import httpx
from typing import Optional, Dict, List, Any
from src.logging_config import get_logger
from src.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class CoreAIClient:
"""
HTTP client for Core-AI service
Provides access to AI performance metrics, tool execution stats,
and memory system monitoring.
"""
def __init__(
self,
base_url: Optional[str] = None,
timeout: int = 10
):
"""
Initialize Core-AI client
Args:
base_url: Core-AI base URL (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or getattr(settings, 'core_ai_base_url', 'http://core-ai:8086')).rstrip("/")
self.timeout = timeout
self.client = httpx.AsyncClient(timeout=self.timeout)
async def close(self):
"""Close the HTTP client"""
await self.client.aclose()
async def health_check(self) -> bool:
"""
Check if Core-AI service is accessible
Returns:
True if accessible, False otherwise
"""
try:
response = await self.client.get(f"{self.base_url}/health")
return response.status_code == 200
except Exception as e:
logger.error(f"Core-AI health check failed: {e}")
return False
async def get_metrics(self) -> Dict[str, Any]:
"""
Get comprehensive AI performance metrics
Returns:
Dict with agent performance, tool execution, memory stats
Example:
{
"uptime_seconds": 3600,
"timestamp": "2025-12-03T20:00:00Z",
"agent": {
"total_requests": 100,
"avg_response_time_ms": 1250.5,
"p95_response_time_ms": 3200.0,
...
},
"tools": {
"total_calls": 250,
"success_rate": 0.98,
"top_tools": {...}
},
"memory": {
"tier1_hit_rate": 0.85,
...
},
...
}
"""
try:
response = await self.client.get(f"{self.base_url}/metrics")
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"Failed to get metrics: HTTP {e.response.status_code}")
raise
except Exception as e:
logger.error(f"Failed to get metrics: {e}")
raise
async def get_recent_errors(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get recent request errors
Args:
limit: Maximum number of errors to return
Returns:
List of error records with timestamps
Example:
[
{
"timestamp": "2025-12-03T19:45:12Z",
"agent_type": "pydantic",
"error": "Connection timeout",
"duration_ms": 5000
},
...
]
"""
try:
response = await self.client.get(
f"{self.base_url}/metrics/errors",
params={"limit": limit}
)
response.raise_for_status()
data = response.json()
return data.get("errors", [])
except Exception as e:
logger.error(f"Failed to get recent errors: {e}")
raise
async def get_tool_failures(self, limit: int = 20) -> List[Dict[str, Any]]:
"""
Get recent tool execution failures
Args:
limit: Maximum number of failures to return
Returns:
List of tool failure records
Example:
[
{
"timestamp": "2025-12-03T19:50:30Z",
"tool_name": "list_containers",
"error": "Connection refused",
"duration_ms": 150
},
...
]
"""
try:
response = await self.client.get(
f"{self.base_url}/metrics/tool-failures",
params={"limit": limit}
)
response.raise_for_status()
data = response.json()
return data.get("failures", [])
except Exception as e:
logger.error(f"Failed to get tool failures: {e}")
raise
async def reset_metrics(self) -> bool:
"""
Reset all metrics (admin operation)
Returns:
True if successful
"""
try:
response = await self.client.post(f"{self.base_url}/metrics/reset")
response.raise_for_status()
logger.info("Successfully reset Core-AI metrics")
return True
except Exception as e:
logger.error(f"Failed to reset metrics: {e}")
raise
async def __aenter__(self):
"""Async context manager entry"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
await self.close()
# Singleton instance
_ai_client: Optional[CoreAIClient] = None
def get_ai_client() -> CoreAIClient:
"""Get singleton Core-AI client instance"""
global _ai_client
if _ai_client is None:
_ai_client = CoreAIClient()
return _ai_client
+409
View File
@@ -0,0 +1,409 @@
"""
Home Assistant REST API Client
Provides interface to Home Assistant REST API for home automation control.
Uses long-lived access token authentication.
API Reference: https://developers.home-assistant.io/docs/api/rest/
"""
import httpx
import json
from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta, timezone
from src.logging_config import get_logger
from src.shared.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class HomeAssistantClient:
"""
HTTP client for Home Assistant REST API
Uses long-lived access token authentication via Bearer token.
"""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
timeout: int = 30
):
"""
Initialize Home Assistant client
Args:
base_url: Home Assistant base URL (default from settings)
token: Long-lived access token (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
self.token = token or settings.homeassistant_token
self.timeout = timeout
if not self.token:
logger.warning("Home Assistant token not configured")
def _get_headers(self) -> Dict[str, str]:
"""Get request headers with Bearer token authentication"""
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
# ========================================================================
# Health & Discovery
# ========================================================================
async def health_check(self) -> Dict[str, Any]:
"""
Check Home Assistant API connectivity and get version info
HA Endpoint: GET /api/
Returns:
Dict with connected status, platform name, and version
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/",
headers=self._get_headers()
)
if response.status_code == 200:
data = response.json()
return {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": data.get("version", "unknown")
}
return {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": f"HTTP {response.status_code}"
}
except Exception as e:
logger.error(f"Home Assistant health check failed: {e}")
return {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": str(e)
}
async def get_states(self) -> List[Dict[str, Any]]:
"""
Get all entity states
HA Endpoint: GET /api/states
Returns:
List of all entity states
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/states",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def get_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
"""
Get state of a specific entity
HA Endpoint: GET /api/states/<entity_id>
Args:
entity_id: Entity ID (e.g., "light.living_room")
Returns:
Entity state dict or None if not found
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/states/{entity_id}",
headers=self._get_headers()
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
async def get_config(self) -> Dict[str, Any]:
"""
Get Home Assistant configuration (includes areas)
HA Endpoint: GET /api/config
Returns:
Configuration dict including components, location, etc.
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/config",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
# ========================================================================
# Device Control
# ========================================================================
async def call_service(
self,
domain: str,
service: str,
entity_id: Optional[str] = None,
service_data: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Call a Home Assistant service
HA Endpoint: POST /api/services/<domain>/<service>
Args:
domain: Service domain (e.g., "light", "switch", "scene")
service: Service name (e.g., "turn_on", "turn_off", "toggle")
entity_id: Target entity ID (optional for some services)
service_data: Additional service data/attributes
Returns:
List of changed states
"""
payload = service_data.copy() if service_data else {}
if entity_id:
payload["entity_id"] = entity_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/services/{domain}/{service}",
headers=self._get_headers(),
json=payload
)
response.raise_for_status()
return response.json()
async def turn_on(
self,
entity_id: str,
**attributes
) -> List[Dict[str, Any]]:
"""
Turn on an entity with optional attributes
Args:
entity_id: Entity ID (e.g., "light.living_room")
**attributes: Additional attributes (brightness, color_temp, etc.)
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="turn_on",
entity_id=entity_id,
service_data=attributes if attributes else None
)
async def turn_off(self, entity_id: str) -> List[Dict[str, Any]]:
"""
Turn off an entity
Args:
entity_id: Entity ID
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="turn_off",
entity_id=entity_id
)
async def toggle(self, entity_id: str) -> List[Dict[str, Any]]:
"""
Toggle an entity
Args:
entity_id: Entity ID
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="toggle",
entity_id=entity_id
)
# ========================================================================
# Scenes
# ========================================================================
async def activate_scene(self, scene_id: str) -> List[Dict[str, Any]]:
"""
Activate a scene
Args:
scene_id: Scene entity ID (e.g., "scene.movie_night")
Returns:
List of changed states
"""
return await self.call_service(
domain="scene",
service="turn_on",
entity_id=scene_id
)
# ========================================================================
# Scripts
# ========================================================================
async def run_script(
self,
script_id: str,
variables: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Execute a script with optional variables
Args:
script_id: Script entity ID (e.g., "script.bedtime_routine")
variables: Script variables
Returns:
List of changed states
"""
service_data = {"variables": variables} if variables else None
return await self.call_service(
domain="script",
service="turn_on",
entity_id=script_id,
service_data=service_data
)
# ========================================================================
# Automations
# ========================================================================
async def enable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
"""
Enable an automation
Args:
automation_id: Automation entity ID
Returns:
List of changed states
"""
return await self.call_service(
domain="automation",
service="turn_on",
entity_id=automation_id
)
async def disable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
"""
Disable an automation
Args:
automation_id: Automation entity ID
Returns:
List of changed states
"""
return await self.call_service(
domain="automation",
service="turn_off",
entity_id=automation_id
)
# ========================================================================
# History
# ========================================================================
async def get_history(
self,
entity_id: str,
hours: int = 24
) -> List[List[Dict[str, Any]]]:
"""
Get state history for an entity
HA Endpoint: GET /api/history/period/<timestamp>
Args:
entity_id: Entity ID to get history for
hours: Number of hours of history (default 24)
Returns:
List of state history entries
"""
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
timestamp = start_time.isoformat()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/history/period/{timestamp}",
headers=self._get_headers(),
params={
"filter_entity_id": entity_id,
"minimal_response": "true"
}
)
response.raise_for_status()
return response.json()
# ========================================================================
# Areas (via template API)
# ========================================================================
async def get_areas(self) -> List[Dict[str, str]]:
"""
Get all areas/rooms
Note: The REST API doesn't have a direct areas endpoint.
This uses the template API to render area data.
HA Endpoint: POST /api/template
Returns:
List of area dicts with id and name
"""
template = """
{% set areas_list = [] %}
{% for area in areas() %}
{% set areas_list = areas_list + [{"id": area, "name": area_name(area)}] %}
{% endfor %}
{{ areas_list | tojson }}
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/template",
headers=self._get_headers(),
json={"template": template}
)
response.raise_for_status()
# Response is rendered template as string
return json.loads(response.text)
# Singleton instance
_homeassistant_client: Optional[HomeAssistantClient] = None
def get_homeassistant_client() -> HomeAssistantClient:
"""Get singleton Home Assistant client instance"""
global _homeassistant_client
if _homeassistant_client is None:
_homeassistant_client = HomeAssistantClient()
return _homeassistant_client
+1 -1
View File
@@ -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()
+169 -113
View File
@@ -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()
@@ -210,6 +210,152 @@ class PortainerClient:
response.raise_for_status()
return True
async def get_stack_file(self, stack_id: int) -> str:
"""
Get the compose file content for a stack
Args:
stack_id: Stack identifier
Returns:
Docker Compose YAML content as string
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/stacks/{stack_id}/file",
headers=self._get_headers()
)
response.raise_for_status()
data = response.json()
return data.get("StackFileContent", "")
async def redeploy_stack(
self,
stack_id: int,
endpoint_id: int,
pull_image: bool = False
) -> Dict[str, Any]:
"""
Redeploy a stack with its current configuration
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
pull_image: Pull latest images before deployment
Returns:
Updated stack details
"""
# Get current stack file content
stack_content = await self.get_stack_file(stack_id)
# Get current stack to preserve env vars
stack = await self.get_stack(stack_id)
env_vars = stack.get("Env", [])
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": pull_image
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def update_stack_env(
self,
stack_id: int,
endpoint_id: int,
env_vars: List[Dict[str, str]]
) -> Dict[str, Any]:
"""
Update stack environment variables
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
env_vars: List of {"name": "VAR_NAME", "value": "var_value"} dicts
Returns:
Updated stack details
"""
# Get current stack file content (required for update)
stack_content = await self.get_stack_file(stack_id)
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": False
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def delete_container(
self,
endpoint_id: int,
container_id: str,
force: bool = False
) -> bool:
"""
Delete a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
force: Force remove running container
Returns:
True if successful
"""
params = {"force": "true" if force else "false"}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
logger.info(f"Deleted container {container_id}")
return True
async def restart_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Restart a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/restart",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Restarted container {container_id}")
return True
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers on a specific endpoint
@@ -292,70 +438,14 @@ class PortainerClient:
return True
# ========================================================================
# Docker Socket Fallback (for containers not managed by Portainer)
# ========================================================================
async def _list_containers_via_socket(self, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
Fallback: List containers directly via Docker socket
Used when Portainer API doesn't return complete data (e.g., containers
started outside Portainer, AMP game servers, etc.)
Args:
all_containers: Include stopped containers
Returns:
List of container details in Docker API format
"""
try:
# Docker socket is mounted at /var/run/docker.sock
# Use httpx with unix socket transport
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=10) as client:
params = {"all": 1 if all_containers else 0}
response = await client.get(
"http://localhost/v1.41/containers/json",
params=params
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Docker socket fallback failed: {e}")
return []
async def _inspect_container_via_socket(self, container_id_or_name: str) -> Optional[Dict[str, Any]]:
"""
Fallback: Inspect container directly via Docker socket
Args:
container_id_or_name: Container ID or name
Returns:
Container details or None
"""
try:
transport = httpx.AsyncHTTPTransport(uds="/var/run/docker.sock")
async with httpx.AsyncClient(transport=transport, timeout=10) as client:
response = await client.get(
f"http://localhost/v1.41/containers/{container_id_or_name}/json"
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.warning(f"Docker socket inspect fallback failed for '{container_id_or_name}': {e}")
return None
# ========================================================================
# Helper methods for agent tools (auto-detect endpoint + fallback)
# Helper methods for agent tools (auto-detect endpoint)
# ========================================================================
async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers using auto-detected endpoint with Docker socket fallback
List containers using auto-detected endpoint
This is a convenience wrapper that automatically uses the first/default endpoint.
If Portainer doesn't have complete data, falls back to Docker socket.
Args:
all_containers: Include stopped containers (default: True)
@@ -363,34 +453,18 @@ class PortainerClient:
Returns:
List of container details
"""
try:
# Try Portainer first
endpoints = await self.get_endpoints()
if endpoints:
endpoint_id = endpoints[0]["Id"]
containers = await self.get_containers(endpoint_id, all_containers)
if containers:
return containers
endpoints = await self.get_endpoints()
if not endpoints:
raise RuntimeError("No Portainer endpoints available")
# Fallback to Docker socket
logger.info("Portainer returned no containers, trying Docker socket fallback...")
return await self._list_containers_via_socket(all_containers)
except Exception as e:
logger.error(f"Error listing containers: {e}")
# Try fallback even on exception
try:
return await self._list_containers_via_socket(all_containers)
except Exception as fallback_error:
logger.error(f"Fallback also failed: {fallback_error}")
return []
endpoint_id = endpoints[0]["Id"]
return await self.get_containers(endpoint_id, all_containers)
async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]:
"""
Inspect a container by name using auto-detected endpoint with Docker socket fallback
Inspect a container by name using auto-detected endpoint
This is a convenience wrapper that automatically uses the first/default endpoint.
If Portainer doesn't find the container, falls back to Docker socket.
Args:
container_name: Container name (e.g., "jellyfin", "ollama")
@@ -398,44 +472,26 @@ class PortainerClient:
Returns:
Container details or None if not found
"""
try:
# Try Portainer first
endpoints = await self.get_endpoints()
if endpoints:
endpoint_id = endpoints[0]["Id"]
endpoints = await self.get_endpoints()
if not endpoints:
raise RuntimeError("No Portainer endpoints available")
# First list all containers to find the one matching the name
all_containers = await self.get_containers(endpoint_id, all_containers=True)
endpoint_id = endpoints[0]["Id"]
matching_container = None
for container in all_containers:
# Container names come as array like ['/jellyfin']
names = container.get('Names', [])
for name in names:
clean_name = name.lstrip('/')
if clean_name == container_name or clean_name.lower() == container_name.lower():
matching_container = container
break
if matching_container:
break
# List all containers to find the one matching the name
all_containers = await self.get_containers(endpoint_id, all_containers=True)
if matching_container:
for container in all_containers:
# Container names come as array like ['/jellyfin']
names = container.get('Names', [])
for name in names:
clean_name = name.lstrip('/')
if clean_name == container_name or clean_name.lower() == container_name.lower():
# Get detailed info using container ID
container_id = matching_container['Id']
container_id = container['Id']
return await self.get_container(endpoint_id, container_id)
# Not found in Portainer, try Docker socket fallback
logger.info(f"Container '{container_name}' not found in Portainer, trying Docker socket fallback...")
return await self._inspect_container_via_socket(container_name)
except Exception as e:
logger.error(f"Error inspecting container '{container_name}': {e}")
# Try fallback even on exception
try:
return await self._inspect_container_via_socket(container_name)
except Exception as fallback_error:
logger.error(f"Fallback also failed: {fallback_error}")
return None
return None
# Singleton instance
-163
View File
@@ -1,163 +0,0 @@
"""
Global configuration for Core Code API
"""
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()
# Import infrastructure credentials from gitignored module
try:
from src.credentials import (
PORTAINER_URL, PORTAINER_API_KEY,
NPM_URL, NPM_EMAIL, NPM_PASSWORD,
BRAVE_SEARCH_API_KEY,
GOOGLE_SEARCH_API_KEY, GOOGLE_SEARCH_ENGINE_ID
)
except ImportError:
# Fallback to empty strings if credentials.py doesn't exist
# (e.g., fresh clone before credentials setup)
PORTAINER_URL = "http://localhost:8001"
PORTAINER_API_KEY = ""
NPM_URL = "http://localhost:81"
NPM_EMAIL = ""
NPM_PASSWORD = ""
BRAVE_SEARCH_API_KEY = ""
GOOGLE_SEARCH_API_KEY = ""
GOOGLE_SEARCH_ENGINE_ID = ""
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 = "http://ollama:11434"
ollama_timeout: int = 300 # 5 minutes
# Model Configuration
default_model: str = "mistral-tools:7b"
agent_model: str = "gemma2:9b-instruct-q5_K_M" # Must support tool calling with ADK (~4GB VRAM)
lightweight_models: str = "gemma3-tools:1b,phi3:mini"
heavy_models: str = "mistral:7b,gemma2:9b,gemma3:12b,mixtral:8x7b"
code_models: str = "codestral:latest,codegemma: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 = "google" # Options: google, brave, searxng, duckduckgo
searxng_url: str = "http://searxng:8080" # For future self-hosted SearxNG
# Search API Keys (from credentials.py)
brave_search_api_key: str = BRAVE_SEARCH_API_KEY # https://brave.com/search/api/
google_search_api_key: str = GOOGLE_SEARCH_API_KEY # https://console.cloud.google.com/
google_search_engine_id: str = GOOGLE_SEARCH_ENGINE_ID # Custom Search Engine ID
# Infrastructure Management (from credentials.py)
portainer_url: str = PORTAINER_URL
portainer_api_key: str = PORTAINER_API_KEY
npm_url: str = NPM_URL
npm_email: str = NPM_EMAIL
npm_password: str = NPM_PASSWORD
# Core-AI Service (AI performance metrics)
core_ai_base_url: str = "http://core-ai:8086"
# 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"
@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
@lru_cache()
def get_settings() -> Settings:
"""Cached settings instance"""
return Settings()
-219
View File
@@ -1,219 +0,0 @@
"""
AI Metrics Proxy Controller
Provides proxy endpoints to Core-AI service metrics.
Allows external access to AI performance stats via core-api.
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, List, Any
from src.clients.ai_client import get_ai_client
from src.logging_config import get_logger
logger = get_logger(__name__)
# Create router
router = APIRouter(
prefix="/ai",
tags=["AI Metrics"]
)
@router.get(
"/health",
summary="Check Core-AI service health",
description="Verify that the Core-AI service is accessible and responding"
)
async def ai_health_check():
"""
Check if Core-AI service is healthy
Returns:
Health status and availability
"""
try:
ai_client = get_ai_client()
is_healthy = await ai_client.health_check()
return {
"service": "core-ai",
"status": "healthy" if is_healthy else "unhealthy",
"accessible": is_healthy
}
except Exception as e:
logger.error(f"AI health check failed: {e}")
return {
"service": "core-ai",
"status": "error",
"accessible": False,
"error": str(e)
}
@router.get(
"/metrics",
response_model=Dict[str, Any],
summary="Get comprehensive AI performance metrics",
description="Returns detailed metrics including agent performance, tool execution stats, memory system metrics, and user activity"
)
async def get_ai_metrics():
"""
Proxy endpoint for Core-AI metrics
Returns comprehensive AI performance data:
- Agent request statistics (total, by type, response times)
- Response time percentiles (p50, p95, p99)
- Tool execution metrics (calls, success rates, durations)
- Memory system statistics (cache hits, consolidations)
- User activity tracking
- Concurrency metrics
Returns:
Dict with all collected metrics
Raises:
HTTPException: If Core-AI is unreachable or returns error
"""
try:
ai_client = get_ai_client()
metrics = await ai_client.get_metrics()
return metrics
except Exception as e:
logger.error(f"Failed to fetch AI metrics: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
@router.get(
"/metrics/errors",
response_model=Dict[str, Any],
summary="Get recent request errors",
description="Returns recent AI agent request errors with timestamps and details"
)
async def get_ai_errors(limit: int = 20):
"""
Get recent AI request errors
Args:
limit: Maximum number of errors to return (default: 20)
Returns:
Dict with error list and total count
Example response:
{
"errors": [
{
"timestamp": "2025-12-03T19:45:12Z",
"agent_type": "pydantic",
"error": "Connection timeout",
"duration_ms": 5000
}
],
"total": 1
}
"""
try:
ai_client = get_ai_client()
errors = await ai_client.get_recent_errors(limit=limit)
return {
"errors": errors,
"total": len(errors)
}
except Exception as e:
logger.error(f"Failed to fetch AI errors: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
@router.get(
"/metrics/tool-failures",
response_model=Dict[str, Any],
summary="Get recent tool execution failures",
description="Returns recent tool execution failures with error details"
)
async def get_ai_tool_failures(limit: int = 20):
"""
Get recent tool execution failures
Args:
limit: Maximum number of failures to return (default: 20)
Returns:
Dict with failure list and total count
Example response:
{
"failures": [
{
"timestamp": "2025-12-03T19:50:30Z",
"tool_name": "list_containers",
"error": "Connection refused",
"duration_ms": 150
}
],
"total": 1
}
"""
try:
ai_client = get_ai_client()
failures = await ai_client.get_tool_failures(limit=limit)
return {
"failures": failures,
"total": len(failures)
}
except Exception as e:
logger.error(f"Failed to fetch tool failures: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
@router.post(
"/metrics/reset",
summary="Reset all AI metrics (admin)",
description="Clear all collected metrics. This is an administrative operation that resets all counters and history."
)
async def reset_ai_metrics():
"""
Reset all AI metrics (admin operation)
Clears all collected metrics including:
- Request history
- Tool execution stats
- Memory system metrics
- Error logs
Returns:
Success confirmation
Note:
This is an administrative operation that should be used carefully.
All historical data will be lost.
"""
try:
ai_client = get_ai_client()
await ai_client.reset_metrics()
logger.info("AI metrics reset successfully")
return {
"success": True,
"message": "AI metrics reset successfully"
}
except Exception as e:
logger.error(f"Failed to reset AI metrics: {e}")
raise HTTPException(
status_code=503,
detail=f"Core-AI service unavailable: {str(e)}"
)
+29 -133
View File
@@ -7,13 +7,9 @@ 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
# Note: Agent functionality moved to separate core-ai service (Dec 2025)
# This service (core-api) only provides infrastructure management and tools
AGENT_AVAILABLE = False
from src.db import get_database
logger = get_logger(__name__)
@@ -52,20 +48,7 @@ class HealthController(BaseController):
"service": settings.app_name,
"version": settings.app_version,
"status": "healthy",
"documentation": {
"swagger_ui": "/docs",
"redoc": "/redoc",
"openapi_spec": "/openapi.json"
},
"endpoints": {
"chat_completions": "/v1/chat/completions",
"models": "/v1/models",
"conversations": "/v1/conversations",
"web_scraper": "/web-scraper/scrape",
"infrastructure": "/infrastructure",
"health": "/health",
"health_full": "/health/full"
}
"docs": "/docs"
}
@router.get(
@@ -75,102 +58,54 @@ class HealthController(BaseController):
)
async def health_check():
"""
Simple health check endpoint for container orchestration
Fast health check endpoint for container orchestration
Returns a 200 OK status when the service is running properly.
Used by Docker, Kubernetes, and load balancers.
Returns a 200 OK immediately if the service is running.
Does NOT check backend connectivity (use /health/full for that).
Used by Docker, Kubernetes, and load balancers for liveness probes.
"""
ollama_client = get_ollama_client()
ollama_healthy = await ollama_client.health_check()
return {
"status": "healthy",
"ollama_connected": ollama_healthy
"version": settings.app_version
}
@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
# Check database connection
database = get_database()
db_healthy = False
db_error = None
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
db_healthy = await database.health_check()
except Exception as e:
ollama_error = str(e)
logger.warning(f"Ollama health check failed: {ollama_error}")
# Note: Agent functionality moved to separate core-ai service
# This service only needs Ollama for embeddings (infrastructure tools)
# Agent health is checked separately in core-ai service
# Determine overall status (only Ollama required for core-api)
is_healthy = ollama_healthy
db_error = str(e)
logger.warning(f"Database health check failed: {db_error}")
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
},
"note": "AI agent functionality available in separate core-ai service (port 8086)"
"database": {
"status": "healthy" if db_healthy else "unhealthy",
"error": db_error
}
}
}
@@ -178,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": {
@@ -197,43 +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. Agent Stack - Moved to separate core-ai service
diagnostics["components"]["agent"] = {
"status": "N/A",
"note": "AI agent functionality moved to separate core-ai service (port 8086)",
"check_url": "http://core-ai:8086/health"
}
# 3. Memory System (Qdrant) - Moved to core-ai service
diagnostics["components"]["qdrant"] = {
"status": "N/A",
"note": "Memory system managed by core-ai service (port 8086)"
}
# 4. 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)
+748
View File
@@ -0,0 +1,748 @@
"""
Housekeeping Controller
Provides API endpoints for home automation via Home Assistant.
Designed for the Tatlock Housekeeper agent and other consumers.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from src.controllers.base import BaseController
from src.clients.homeassistant_client import get_homeassistant_client
from src.logging_config import get_logger
from src.auth.oidc import get_admin_user
logger = get_logger(__name__)
# ========================================================================
# Pydantic Schemas
# ========================================================================
class Device(BaseModel):
"""Device/entity information"""
entity_id: str
name: str
domain: str
area: Optional[str] = None
state: str
attributes: Dict[str, Any] = {}
last_changed: Optional[str] = None
class DeviceListResponse(BaseModel):
"""Response for device listing"""
devices: List[Device]
class DeviceDetailResponse(Device):
"""Detailed device response"""
pass
class Area(BaseModel):
"""Area/room information"""
id: str
name: str
class AreaListResponse(BaseModel):
"""Response for area listing"""
areas: List[Area]
class DeviceControlRequest(BaseModel):
"""Request to control a device"""
action: str = Field(..., description="Action: turn_on, turn_off, or toggle")
brightness: Optional[int] = Field(None, ge=0, le=255)
color_temp: Optional[int] = None
rgb_color: Optional[List[int]] = None
class Config:
extra = "allow" # Allow additional attributes
class DeviceControlResponse(BaseModel):
"""Response from device control"""
success: bool
entity_id: str
new_state: Optional[str] = None
message: str
class Scene(BaseModel):
"""Scene information"""
id: str
name: str
class SceneListResponse(BaseModel):
"""Response for scene listing"""
scenes: List[Scene]
class SceneActivateResponse(BaseModel):
"""Response from scene activation"""
success: bool
scene_id: str
message: str
class Script(BaseModel):
"""Script information"""
id: str
name: str
class ScriptListResponse(BaseModel):
"""Response for script listing"""
scripts: List[Script]
class ScriptRunRequest(BaseModel):
"""Request to run a script"""
variables: Optional[Dict[str, Any]] = None
class ScriptRunResponse(BaseModel):
"""Response from script execution"""
success: bool
script_id: str
message: str
class Automation(BaseModel):
"""Automation information"""
id: str
name: str
enabled: bool
class AutomationListResponse(BaseModel):
"""Response for automation listing"""
automations: List[Automation]
class AutomationToggleRequest(BaseModel):
"""Request to toggle automation"""
enabled: bool
class AutomationToggleResponse(BaseModel):
"""Response from automation toggle"""
success: bool
automation_id: str
enabled: bool
message: str
class HistoryEntry(BaseModel):
"""Single history entry"""
state: str
timestamp: str
attributes: Dict[str, Any] = {}
class HistoryResponse(BaseModel):
"""Response for history query"""
entity_id: str
history: List[HistoryEntry]
class HealthResponse(BaseModel):
"""Health check response"""
status: str
connected: bool
platform: str
version: Optional[str] = None
error: Optional[str] = None
class ErrorResponse(BaseModel):
"""Standard error response"""
error: bool = True
code: str
message: str
# ========================================================================
# Controller
# ========================================================================
class HousekeepingController(BaseController):
"""
Controller for home automation operations
Provides endpoints for:
- Device discovery and control
- Scene activation
- Script execution
- Automation management
- State history
"""
def __init__(self):
super().__init__(prefix="/housekeeping", tags=["Housekeeping"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
# ====================================================================
# Health
# ====================================================================
@router.get(
"/health",
response_model=HealthResponse,
summary="Home automation health check"
)
async def get_health():
"""
Check Home Assistant connection health
Returns connection status and HA version.
"""
ha = get_homeassistant_client()
return await ha.health_check()
# ====================================================================
# Device Discovery
# ====================================================================
@router.get(
"/devices",
response_model=DeviceListResponse,
summary="List available devices"
)
async def list_devices(
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
area: Optional[str] = Query(None, description="Filter by area/room name")
):
"""
List all available devices with optional filtering
Query Parameters:
- domain: Filter by device type (light, switch, climate, media_player, etc.)
- area: Filter by area/room name
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
# Non-controllable domains to filter out
excluded_domains = {
"zone", "person", "device_tracker", "sun", "weather",
"persistent_notification", "update", "binary_sensor", "sensor",
"conversation", "calendar", "button", "number", "select",
"text", "time", "date", "datetime", "image", "tts", "stt"
}
devices = []
for state in states:
entity_id = state.get("entity_id", "")
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
# Skip non-controllable entities
if entity_domain in excluded_domains:
continue
# Apply domain filter
if domain and entity_domain != domain:
continue
# Get area from attributes
device_area = state.get("attributes", {}).get("area_id")
# Apply area filter
if area and device_area and area.lower() not in device_area.lower():
continue
device = Device(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=device_area,
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
devices.append(device)
return DeviceListResponse(devices=devices)
except Exception as e:
logger.error(f"Failed to list devices: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/devices/{entity_id:path}",
response_model=DeviceDetailResponse,
responses={404: {"model": ErrorResponse}}
)
async def get_device(entity_id: str):
"""
Get detailed state of a specific device
Args:
entity_id: Entity ID (e.g., light.living_room)
"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(entity_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
return DeviceDetailResponse(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=state.get("attributes", {}).get("area_id"),
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/areas",
response_model=AreaListResponse,
summary="List areas/rooms"
)
async def list_areas():
"""
List all configured areas/rooms in Home Assistant
"""
ha = get_homeassistant_client()
try:
areas = await ha.get_areas()
return AreaListResponse(
areas=[Area(id=a["id"], name=a["name"]) for a in areas]
)
except Exception as e:
logger.error(f"Failed to list areas: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Device Control
# ====================================================================
@router.post(
"/devices/{entity_id:path}/control",
response_model=DeviceControlResponse,
responses={404: {"model": ErrorResponse}, 400: {"model": ErrorResponse}}
)
async def control_device(
entity_id: str,
request: DeviceControlRequest,
user: Dict = Depends(get_admin_user)
):
"""
Control a device (turn on, turn off, toggle, or set attributes)
Args:
entity_id: Entity ID (e.g., light.living_room)
request: Control request with action and optional attributes
"""
ha = get_homeassistant_client()
# Validate action
valid_actions = ["turn_on", "turn_off", "toggle"]
if request.action not in valid_actions:
raise HTTPException(
status_code=400,
detail={"error": True, "code": "INVALID_ACTION",
"message": f"Invalid action '{request.action}'. Must be one of: {', '.join(valid_actions)}"}
)
try:
# Check device exists first
current_state = await ha.get_state(entity_id)
if not current_state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
# Build attributes dict from request
attributes = {}
if request.brightness is not None:
attributes["brightness"] = request.brightness
if request.color_temp is not None:
attributes["color_temp"] = request.color_temp
if request.rgb_color is not None:
attributes["rgb_color"] = request.rgb_color
# Add any extra attributes from request
extra_fields = request.model_dump(exclude={"action", "brightness", "color_temp", "rgb_color"})
for key, value in extra_fields.items():
if value is not None:
attributes[key] = value
# Execute action
if request.action == "turn_on":
await ha.turn_on(entity_id, **attributes)
elif request.action == "turn_off":
await ha.turn_off(entity_id)
else: # toggle
await ha.toggle(entity_id)
# Wait for HA to update state before fetching
await asyncio.sleep(0.3)
# Get new state
new_state = await ha.get_state(entity_id)
logger.info(f"Device {entity_id} controlled: {request.action} by {user.get('preferred_username', 'unknown')}")
return DeviceControlResponse(
success=True,
entity_id=entity_id,
new_state=new_state.get("state") if new_state else None,
message=f"Device {request.action} successful"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to control device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Scenes
# ====================================================================
@router.get(
"/scenes",
response_model=SceneListResponse,
summary="List available scenes"
)
async def list_scenes():
"""
List all available scenes in Home Assistant
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scenes = [
Scene(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("scene.")
]
return SceneListResponse(scenes=scenes)
except Exception as e:
logger.error(f"Failed to list scenes: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scenes/{scene_id:path}/activate",
response_model=SceneActivateResponse,
responses={404: {"model": ErrorResponse}}
)
async def activate_scene(
scene_id: str,
user: Dict = Depends(get_admin_user)
):
"""
Activate a scene
Args:
scene_id: Scene entity ID (e.g., scene.movie_night)
"""
ha = get_homeassistant_client()
try:
# Verify scene exists
state = await ha.get_state(scene_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCENE_NOT_FOUND",
"message": f"Scene {scene_id} not found"}
)
await ha.activate_scene(scene_id)
logger.info(f"Scene {scene_id} activated by {user.get('preferred_username', 'unknown')}")
return SceneActivateResponse(
success=True,
scene_id=scene_id,
message="Scene activated"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to activate scene {scene_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Scripts
# ====================================================================
@router.get(
"/scripts",
response_model=ScriptListResponse,
summary="List available scripts"
)
async def list_scripts():
"""
List all available scripts/sequences in Home Assistant
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scripts = [
Script(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("script.")
]
return ScriptListResponse(scripts=scripts)
except Exception as e:
logger.error(f"Failed to list scripts: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scripts/{script_id:path}/run",
response_model=ScriptRunResponse,
responses={404: {"model": ErrorResponse}}
)
async def run_script(
script_id: str,
request: Optional[ScriptRunRequest] = None,
user: Dict = Depends(get_admin_user)
):
"""
Execute a script with optional variables
Args:
script_id: Script entity ID (e.g., script.bedtime_routine)
request: Optional variables for the script
"""
ha = get_homeassistant_client()
try:
# Verify script exists
state = await ha.get_state(script_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCRIPT_NOT_FOUND",
"message": f"Script {script_id} not found"}
)
variables = request.variables if request else None
await ha.run_script(script_id, variables)
logger.info(f"Script {script_id} executed by {user.get('preferred_username', 'unknown')}")
return ScriptRunResponse(
success=True,
script_id=script_id,
message="Script executed"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to run script {script_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# Automations
# ====================================================================
@router.get(
"/automations",
response_model=AutomationListResponse,
summary="List automations"
)
async def list_automations():
"""
List all automations with their enabled/disabled status
"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
automations = [
Automation(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"]),
enabled=s.get("state") == "on"
)
for s in states
if s["entity_id"].startswith("automation.")
]
return AutomationListResponse(automations=automations)
except Exception as e:
logger.error(f"Failed to list automations: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/automations/{automation_id:path}/toggle",
response_model=AutomationToggleResponse,
responses={404: {"model": ErrorResponse}}
)
async def toggle_automation(
automation_id: str,
request: AutomationToggleRequest,
user: Dict = Depends(get_admin_user)
):
"""
Enable or disable an automation
Args:
automation_id: Automation entity ID (e.g., automation.motion_lights)
request: Contains enabled boolean
"""
ha = get_homeassistant_client()
try:
# Verify automation exists
state = await ha.get_state(automation_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "AUTOMATION_NOT_FOUND",
"message": f"Automation {automation_id} not found"}
)
if request.enabled:
await ha.enable_automation(automation_id)
else:
await ha.disable_automation(automation_id)
logger.info(f"Automation {automation_id} {'enabled' if request.enabled else 'disabled'} by {user.get('preferred_username', 'unknown')}")
return AutomationToggleResponse(
success=True,
automation_id=automation_id,
enabled=request.enabled,
message=f"Automation {'enabled' if request.enabled else 'disabled'}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to toggle automation {automation_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
# ====================================================================
# History
# ====================================================================
@router.get(
"/history",
response_model=HistoryResponse,
responses={400: {"model": ErrorResponse}}
)
async def get_history(
entity_id: str = Query(..., description="Entity ID to get history for"),
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
):
"""
Get state history for a device
Query Parameters:
- entity_id: Device entity ID (required)
- hours: Number of hours of history (default 24, max 168/1 week)
"""
ha = get_homeassistant_client()
try:
history_data = await ha.get_history(entity_id, hours)
# Transform HA history format to our format
history_entries = []
if history_data and len(history_data) > 0:
for entry in history_data[0]: # First array is our entity
history_entries.append(HistoryEntry(
state=entry.get("state", "unknown"),
timestamp=entry.get("last_changed", ""),
attributes=entry.get("attributes", {})
))
return HistoryResponse(
entity_id=entity_id,
history=history_entries
)
except Exception as e:
logger.error(f"Failed to get history for {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
return router
# Create controller instance
housekeeping_controller = HousekeepingController()
+353 -1
View File
@@ -4,7 +4,8 @@ Infrastructure Management Controller
Provides API endpoints for automated infrastructure management,
including service deployment, configuration, and monitoring setup.
"""
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Request
from fastapi.responses import PlainTextResponse
from typing import List, Dict, Any, Optional, Union
from pydantic import BaseModel, field_validator
@@ -1700,6 +1701,357 @@ class InfrastructureController(BaseController):
logger.error(f"Failed to get container resources: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================================================
# Container Delete Endpoint (for Tatlock Control Room)
# ========================================================================
@router.delete(
"/containers/{container_id}",
status_code=204,
summary="Delete a container"
)
async def delete_container(
container_id: str,
force: bool = False
):
"""
Delete a Docker container.
Args:
container_id: Full container ID
force: Force remove running container (default: false)
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Deleting container '{container_id}' (force={force})")
try:
# Get endpoint
endpoints = await portainer.get_endpoints()
if not endpoints:
raise HTTPException(status_code=500, detail="No Portainer endpoints available")
endpoint_id = endpoints[0]['Id']
# Delete the container
await portainer.delete_container(endpoint_id, container_id, force=force)
logger.info(f"Successfully deleted container '{container_id}'")
return None # 204 No Content
except HTTPException:
raise
except Exception as e:
error_str = str(e).lower()
if "404" in error_str or "no such container" in error_str:
raise HTTPException(status_code=404, detail=f"Container '{container_id}' not found")
elif "409" in error_str or "conflict" in error_str:
raise HTTPException(
status_code=409,
detail=f"Cannot delete container '{container_id}': container is running. Use force=true to force remove."
)
else:
logger.error(f"Failed to delete container '{container_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# ========================================================================
# Stack Management Endpoints (for Tatlock Control Room)
# ========================================================================
@router.get(
"/stacks/{stack_id}/compose",
summary="Get stack compose YAML",
response_class=PlainTextResponse
)
async def get_stack_compose(stack_id: str):
"""
Get the Docker Compose YAML for a stack.
Args:
stack_id: Stack name (compose project name)
Returns:
Docker Compose YAML as text/yaml
"""
portainer = get_portainer_client()
logger.info(f"Getting compose file for stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
# Get compose file content
compose_content = await portainer.get_stack_file(stack["Id"])
return PlainTextResponse(
content=compose_content,
media_type="text/yaml"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get compose for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/stacks/{stack_id}/compose",
status_code=204,
summary="Update stack compose YAML"
)
async def update_stack_compose(stack_id: str, request: Request):
"""
Update the Docker Compose YAML for a stack.
Args:
stack_id: Stack name (compose project name)
request: Raw YAML content in request body
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Updating compose file for stack '{stack_id}'")
try:
# Read raw YAML from request body
compose_content = (await request.body()).decode("utf-8")
if not compose_content.strip():
raise HTTPException(status_code=400, detail="Empty compose content")
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Update stack with new compose content
await portainer.update_stack(
stack_id=stack_int_id,
stack_file_content=compose_content,
endpoint_id=endpoint_id,
prune=False,
pull_image=False
)
logger.info(f"Successfully updated compose for stack '{stack_id}'")
return None # 204 No Content
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update compose for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get(
"/stacks/{stack_id}/env",
response_model=Dict[str, str],
summary="Get stack environment variables"
)
async def get_stack_env(stack_id: str):
"""
Get environment variables for a stack.
Args:
stack_id: Stack name (compose project name)
Returns:
Dictionary of environment variable name-value pairs
"""
portainer = get_portainer_client()
logger.info(f"Getting env vars for stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
# Get full stack details including env vars
stack_details = await portainer.get_stack(stack["Id"])
env_list = stack_details.get("Env", [])
# Convert from [{name, value}] to {name: value}
env_dict = {item["name"]: item["value"] for item in env_list}
return env_dict
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get env for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put(
"/stacks/{stack_id}/env",
status_code=204,
summary="Update stack environment variables"
)
async def update_stack_env(stack_id: str, env_vars: Dict[str, str]):
"""
Update environment variables for a stack.
Args:
stack_id: Stack name (compose project name)
env_vars: Dictionary of environment variable name-value pairs
Returns:
204 No Content on success
"""
portainer = get_portainer_client()
logger.info(f"Updating env vars for stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Convert from {name: value} to [{name, value}]
env_list = [{"name": k, "value": v} for k, v in env_vars.items()]
# Update stack env vars
await portainer.update_stack_env(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
env_vars=env_list
)
logger.info(f"Successfully updated env vars for stack '{stack_id}'")
return None # 204 No Content
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update env for stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/stacks/{stack_id}/deploy",
status_code=202,
summary="Deploy stack"
)
async def deploy_stack(stack_id: str):
"""
Redeploy a stack with current YAML and environment variables.
Args:
stack_id: Stack name (compose project name)
Returns:
202 Accepted
"""
portainer = get_portainer_client()
logger.info(f"Deploying stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Redeploy without pulling new images
await portainer.redeploy_stack(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
pull_image=False
)
logger.info(f"Successfully deployed stack '{stack_id}'")
return None # 202 Accepted
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to deploy stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/stacks/{stack_id}/rebuild",
status_code=202,
summary="Rebuild stack"
)
async def rebuild_stack(stack_id: str):
"""
Pull fresh images and recreate all containers in the stack.
Args:
stack_id: Stack name (compose project name)
Returns:
202 Accepted
"""
portainer = get_portainer_client()
logger.info(f"Rebuilding stack '{stack_id}'")
try:
# Find stack by name
stacks = await portainer.get_stacks()
stack = next(
(s for s in stacks if s.get("Name", "").lower() == stack_id.lower()),
None
)
if not stack:
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
stack_int_id = stack["Id"]
endpoint_id = stack.get("EndpointId")
# Redeploy with image pull
await portainer.redeploy_stack(
stack_id=stack_int_id,
endpoint_id=endpoint_id,
pull_image=True
)
logger.info(f"Successfully rebuilt stack '{stack_id}'")
return None # 202 Accepted
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to rebuild stack '{stack_id}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
return router
+62 -67
View File
@@ -2,19 +2,21 @@
Tools Controller
Provides utility tool endpoints including:
- Web scraping and content extraction
- 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.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
from src.web_scraper.exceptions import FetchError, ScrapingError
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__)
@@ -24,79 +26,18 @@ class ToolsController(BaseController):
Controller for utility tools
Provides endpoints for:
- Web scraping and content extraction
- DNS lookups
"""
def __init__(self):
super().__init__(prefix="/tools", tags=["Tools"])
# Initialize services (could be dependency injected for testing)
self.scraper_service = WebScraperService()
self.dns_service = DNSService()
self.environment_service = get_environment_service()
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.post(
"/scrape",
response_model=WebScraperResponse,
status_code=status.HTTP_200_OK,
summary="Scrape website content",
description="""
Scrape and extract main content from a website.
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
**Features:**
- Intelligent main content extraction
- Removes navigation, ads, footers
- Optional link extraction
- Configurable content length limits
**Rate Limiting:** None (internal network use only)
"""
)
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape a website and extract its main content
Args:
request: Scraping request with URL and options
Returns:
Extracted content with metadata
Raises:
HTTPException: 400 for fetch errors, 500 for processing errors
"""
try:
logger.info(f"Received scrape request for: {request.url}")
result = await self.scraper_service.scrape_url(request)
return result
except FetchError as e:
logger.warning(f"Fetch failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to fetch URL: {str(e)}"
)
except ScrapingError as e:
logger.error(f"Scraping failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract content: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)
@router.post(
"/dns/lookup",
response_model=DNSLookupResponse,
@@ -161,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
-19
View File
@@ -1,19 +0,0 @@
"""
Infrastructure Credentials Template
INSTRUCTIONS:
1. Copy this file to credentials.py
2. Fill in your actual credentials
3. DO NOT commit credentials.py to version control (it's in .gitignore)
This file should be committed to the repository as a template.
"""
# Portainer Configuration
PORTAINER_URL = "http://localhost:8001"
PORTAINER_API_KEY = "ptr_your_api_token_here" # Create in Portainer UI: User menu → My account → Access tokens
# Nginx Proxy Manager Configuration
NPM_URL = "http://localhost:81"
NPM_EMAIL = "admin@example.com"
NPM_PASSWORD = "your_password_here"
+16
View File
@@ -0,0 +1,16 @@
"""
Database package for Core-API
Provides async PostgreSQL database connectivity using SQLAlchemy 2.0.
"""
from src.db.database import (
get_async_session,
get_database,
Database,
)
__all__ = [
"get_async_session",
"get_database",
"Database",
]
+179
View File
@@ -0,0 +1,179 @@
"""
Database Connection Module
Provides async PostgreSQL connectivity using SQLAlchemy 2.0 with asyncpg driver.
Follows the existing singleton pattern used throughout core-api.
"""
from typing import AsyncGenerator, Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncSession,
AsyncEngine,
create_async_engine,
async_sessionmaker,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool
from src.shared.config import get_settings
from src.logging_config import get_logger
logger = get_logger(__name__)
settings = get_settings()
class Base(DeclarativeBase):
"""
SQLAlchemy declarative base for all models
All database models should inherit from this class.
"""
pass
class Database:
"""
Async database connection manager
Provides async engine and session factory for PostgreSQL connections.
Uses asyncpg driver for optimal async performance.
"""
def __init__(self, database_url: Optional[str] = None):
"""
Initialize database connection manager
Args:
database_url: PostgreSQL connection URL (default from settings)
"""
# Convert postgresql:// to postgresql+asyncpg:// for async driver
url = database_url or settings.database_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
self._url = url
self._engine: Optional[AsyncEngine] = None
self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None
@property
def engine(self) -> AsyncEngine:
"""
Get or create the async database engine
Returns:
AsyncEngine instance
"""
if self._engine is None:
self._engine = create_async_engine(
self._url,
echo=settings.debug, # Log SQL in debug mode
poolclass=NullPool, # Disable connection pooling for serverless compatibility
)
logger.info(f"Database engine created for {self._url.split('@')[-1]}")
return self._engine
@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
"""
Get or create the async session factory
Returns:
Session factory for creating database sessions
"""
if self._session_factory is None:
self._session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
return self._session_factory
async def create_tables(self) -> None:
"""
Create all database tables
Should only be used for development/testing.
Use Alembic migrations for production.
"""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created")
async def drop_tables(self) -> None:
"""
Drop all database tables
WARNING: Destroys all data. Use with caution.
"""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
logger.warning("Database tables dropped")
async def health_check(self) -> bool:
"""
Check if database connection is healthy
Returns:
True if connection successful, False otherwise
"""
try:
async with self.session_factory() as session:
await session.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Database health check failed: {e}")
return False
async def close(self) -> None:
"""
Close database connections and dispose of engine
"""
if self._engine is not None:
await self._engine.dispose()
self._engine = None
self._session_factory = None
logger.info("Database connections closed")
# Singleton instance
_database: Optional[Database] = None
def get_database() -> Database:
"""
Get singleton database instance
Returns:
Database instance
"""
global _database
if _database is None:
_database = Database()
return _database
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
"""
FastAPI dependency for database sessions
Yields an async session that is automatically closed after the request.
Usage:
@router.get("/items")
async def get_items(session: AsyncSession = Depends(get_async_session)):
result = await session.execute(select(Item))
return result.scalars().all()
Yields:
AsyncSession instance
"""
database = get_database()
async with database.session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+19
View File
@@ -0,0 +1,19 @@
"""
SQLAlchemy Models for Core-API
Database models for authentication, authorization, and user management.
"""
from src.db.models.user import User
from src.db.models.role import Role, UserRole
from src.db.models.user_preferences import UserPreferences
from src.db.models.api_key import ApiKey
from src.db.models.group import Group
__all__ = [
"User",
"Role",
"UserRole",
"UserPreferences",
"ApiKey",
"Group",
]
+98
View File
@@ -0,0 +1,98 @@
"""
API Key Model
Provides API key authentication as fallback for OIDC.
Keys are tied to user accounts and inherit user permissions.
"""
import uuid
from datetime import datetime
from sqlalchemy import String, ForeignKey, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
from typing import TYPE_CHECKING, List
if TYPE_CHECKING:
from src.db.models.user import User
class ApiKey(Base):
"""
API Key model for programmatic access
API keys provide an alternative to OIDC for:
- Local development without SSO
- Service-to-service communication
- Scripts and automation
Keys inherit the user's roles but can optionally
be restricted to a subset of scopes.
"""
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="Human-readable key name (e.g., 'Dev Laptop', 'CI/CD')",
)
key_hash: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="SHA-256 hash of the API key",
)
key_prefix: Mapped[str] = mapped_column(
String(8),
nullable=False,
comment="First 8 chars of key for identification (e.g., 'cak_abc1')",
)
scopes: Mapped[List[str] | None] = mapped_column(
ARRAY(String),
nullable=True,
comment="Optional scope restriction (subset of user roles)",
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Optional expiration timestamp",
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Last time this key was used",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="api_keys",
)
def __repr__(self) -> str:
return f"<ApiKey {self.key_prefix}... ({self.name})>"
@property
def is_expired(self) -> bool:
"""Check if the API key has expired"""
if self.expires_at is None:
return False
return datetime.now(self.expires_at.tzinfo) > self.expires_at
+85
View File
@@ -0,0 +1,85 @@
"""
Group Model
Represents groups synced from Authentik.
Groups are used for access control and user organization.
"""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import String, Boolean, DateTime, func, Table, Column, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
# Association table for User-Group many-to-many relationship
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("group_id", UUID(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
)
class Group(Base):
"""
Group model synced from Authentik
Groups are fetched from Authentik admin API and cached locally.
They represent organizational units for access control.
"""
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
authentik_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
unique=True,
nullable=False,
index=True,
comment="Authentik group UUID",
)
name: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
is_superuser: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
comment="Whether members of this group have superuser privileges",
)
parent_name: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="Parent group name for hierarchy",
)
member_count: Mapped[int] = mapped_column(
default=0,
nullable=False,
comment="Number of users in this group",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
synced_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
comment="Last sync from Authentik",
)
def __repr__(self) -> str:
return f"<Group {self.name}>"
+93
View File
@@ -0,0 +1,93 @@
"""
Role Models
Defines domain-scoped permissions mapped from Authentik groups.
Format: {domain}:{action} (e.g., control-room:admin, media:viewer)
"""
import uuid
from typing import TYPE_CHECKING, List
from sqlalchemy import String, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
if TYPE_CHECKING:
from src.db.models.user import User
class Role(Base):
"""
Role model for domain-scoped permissions
Roles are seeded from configuration, not user-editable.
Each role maps to an Authentik group (e.g., tatlock-control-room-admin).
Domains: control-room, library, media, ai, housekeeper, developer, documents, gaming, admin
Actions: viewer, user, editor, admin (hierarchical)
"""
__tablename__ = "roles"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
name: Mapped[str] = mapped_column(
String(100),
unique=True,
nullable=False,
index=True,
comment="Role name in format domain:action (e.g., control-room:admin)",
)
domain: Mapped[str] = mapped_column(
String(50),
nullable=False,
index=True,
comment="Permission domain (e.g., control-room, media, ai)",
)
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(
"User",
secondary="user_roles",
back_populates="roles",
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Role {self.name}>"
class UserRole(Base):
"""
Association table for User-Role many-to-many relationship
Synced from Authentik groups during user authentication.
"""
__tablename__ = "user_roles"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
role_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("roles.id", ondelete="CASCADE"),
primary_key=True,
)
+94
View File
@@ -0,0 +1,94 @@
"""
User Model
Represents users synced from Authentik SSO.
"""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import String, Boolean, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
if TYPE_CHECKING:
from src.db.models.role import Role
from src.db.models.user_preferences import UserPreferences
from src.db.models.api_key import ApiKey
class User(Base):
"""
User model synced from Authentik
Users are created/updated when they authenticate via OIDC.
The authentik_id links to the Authentik user record.
"""
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
authentik_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
unique=True,
nullable=False,
index=True,
)
email: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
avatar_url: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
api_keys_enabled: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
last_login: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Relationships
roles: Mapped[List["Role"]] = relationship(
"Role",
secondary="user_roles",
back_populates="users",
lazy="selectin",
)
preferences: Mapped["UserPreferences"] = relationship(
"UserPreferences",
back_populates="user",
uselist=False,
lazy="selectin",
cascade="all, delete-orphan",
)
api_keys: Mapped[List["ApiKey"]] = relationship(
"ApiKey",
back_populates="user",
lazy="selectin",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<User {self.email}>"
+61
View File
@@ -0,0 +1,61 @@
"""
User Preferences Model
Stores user-specific settings like theme and default room.
"""
import uuid
from sqlalchemy import String, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.db.database import Base
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.db.models.user import User
class UserPreferences(Base):
"""
User preferences model
Stores user-specific settings that persist across sessions.
Extended settings stored in preferences_json for flexibility.
"""
__tablename__ = "user_preferences"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
theme: Mapped[str] = mapped_column(
String(20),
default="system",
nullable=False,
comment="Theme preference: system, light, dark",
)
default_room: Mapped[str] = mapped_column(
String(50),
default="front-hall",
nullable=False,
comment="Default room for housekeeping features",
)
preferences_json: Mapped[dict] = mapped_column(
JSONB,
default=dict,
nullable=False,
comment="Extended preferences as JSON",
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="preferences",
)
def __repr__(self) -> str:
return f"<UserPreferences user_id={self.user_id}>"
+5
View File
@@ -0,0 +1,5 @@
"""
Domain modules for Core-API
Each domain contains its own models, schemas, services, and controllers.
"""
+62
View File
@@ -0,0 +1,62 @@
"""
Authentication Domain
Provides OIDC/OAuth2 authentication via Authentik, user management,
roles, groups, and API key authentication.
"""
from src.domains.auth.oidc import (
get_current_user,
get_admin_user,
get_optional_user,
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
from src.domains.auth.models import (
User,
Role,
UserRole,
Group,
UserPreferences,
ApiKey,
user_groups,
group_roles,
)
__all__ = [
# OIDC dependencies
"get_current_user",
"get_admin_user",
"get_optional_user",
"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",
# Controller
"auth_controller",
# Models
"User",
"Role",
"UserRole",
"Group",
"UserPreferences",
"ApiKey",
"user_groups",
"group_roles",
]
+612
View File
@@ -0,0 +1,612 @@
"""
Authentication Controller
Provides authentication endpoints for OIDC token sync and user management.
"""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.shared.base import BaseController
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, 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__)
class AuthController(BaseController):
"""
Controller for authentication operations
Provides endpoints for:
- Token synchronization (login)
- User profile retrieval
"""
def __init__(self):
super().__init__(prefix="/auth", tags=["Authentication"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.post(
"/sync",
summary="Sync user from OIDC token",
response_model=AuthSyncResponse,
responses={
200: {"description": "User synced successfully"},
401: {"description": "Invalid or expired token"},
503: {"description": "Authentication service unavailable"},
},
)
async def sync_user(
request: AuthSyncRequest,
session: AsyncSession = Depends(get_async_session),
) -> AuthSyncResponse:
"""
Synchronize user from OIDC access token
This endpoint should be called after the client obtains an access token
from Authentik. It:
1. Validates the token via Authentik's userinfo endpoint
2. Creates or updates the user in the database
3. Syncs roles from Authentik groups
4. Returns the user profile with roles and preferences
The client should store the returned user info for local use.
"""
service = AuthService(session)
try:
# Validate token with Authentik
token_info = await service.validate_token(request.access_token)
except ValueError as e:
logger.warning(f"Token validation failed: {e}")
raise HTTPException(status_code=401, detail=str(e))
# Sync user to database
user, is_new = await service.sync_user(token_info)
# Sync roles from groups
roles = await service.sync_roles(user, token_info.groups)
# Commit the transaction
await session.commit()
# Refresh to get relationships
await session.refresh(user, ["preferences"])
# Build response
return AuthSyncResponse(
user=service.user_to_schema(user),
roles=service.roles_to_schema(roles),
preferences=service.preferences_to_schema(user.preferences),
is_new_user=is_new,
)
@router.get(
"/users",
summary="List all users",
response_model=UsersListResponse,
responses={
200: {"description": "List of users"},
},
)
async def list_users(
search: Optional[str] = Query(None, description="Search by name or email"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
session: AsyncSession = Depends(get_async_session),
) -> UsersListResponse:
"""
List all users who have logged in via Authentik
Returns paginated list of users with their roles.
Supports search filtering by name or email.
"""
service = AuthService(session)
items, total = await service.list_users(
search=search,
offset=offset,
limit=limit,
)
return UsersListResponse(items=items, total=total)
@router.post(
"/users/sync-from-authentik",
summary="Bulk sync users from Authentik",
response_model=BulkSyncResultSchema,
responses={
200: {"description": "Sync completed"},
401: {"description": "Authentik API token invalid"},
503: {"description": "Authentik service unavailable"},
},
)
async def sync_users_from_authentik(
session: AsyncSession = Depends(get_async_session),
) -> BulkSyncResultSchema:
"""
Fetch all users from Authentik and sync to local database
This endpoint uses the Authentik admin API to fetch all users
and create/update them in the local database. Requires
AUTHENTIK_CORE_API_TOKEN to be configured.
Use this to initially populate users or to re-sync after
changes in Authentik.
"""
service = AuthService(session)
try:
result = await service.bulk_sync_from_authentik()
logger.info(
f"Bulk sync completed: {result.created} created, "
f"{result.updated} updated, {result.failed} failed"
)
return result
except ValueError as e:
logger.error(f"Bulk sync failed: {e}")
raise HTTPException(status_code=401, detail=str(e))
@router.get(
"/groups",
summary="List all groups",
response_model=GroupsListResponse,
responses={
200: {"description": "List of groups"},
},
)
async def list_groups(
search: Optional[str] = Query(None, description="Search by group name"),
offset: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(50, ge=1, le=100, description="Maximum records to return"),
session: AsyncSession = Depends(get_async_session),
) -> GroupsListResponse:
"""
List all groups synced from Authentik
Returns paginated list of groups with their details.
Supports search filtering by name.
"""
service = AuthService(session)
items, total = await service.list_groups(
search=search,
offset=offset,
limit=limit,
)
return GroupsListResponse(items=items, total=total)
@router.post(
"/groups/sync-from-authentik",
summary="Bulk sync groups from Authentik",
response_model=BulkSyncResultSchema,
responses={
200: {"description": "Sync completed"},
401: {"description": "Authentik API credentials invalid"},
503: {"description": "Authentik service unavailable"},
},
)
async def sync_groups_from_authentik(
session: AsyncSession = Depends(get_async_session),
) -> BulkSyncResultSchema:
"""
Fetch all groups from Authentik and sync to local database
This endpoint uses the Authentik admin API to fetch all groups
and create/update them in the local database. Requires
AUTHENTIK_USERNAME and AUTHENTIK_PASSWORD to be configured.
Use this to populate groups or to re-sync after changes in Authentik.
"""
service = AuthService(session)
try:
result = await service.bulk_sync_groups_from_authentik()
logger.info(
f"Groups bulk sync completed: {result.created} created, "
f"{result.updated} updated, {result.failed} failed"
)
return result
except ValueError as e:
logger.error(f"Groups bulk sync failed: {e}")
raise HTTPException(status_code=401, detail=str(e))
@router.get(
"/roles",
summary="List all roles",
response_model=RolesListResponse,
responses={
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_current_user_profile(
user_claims: dict = Depends(get_current_user_or_forward_auth),
session: AsyncSession = Depends(get_async_session),
) -> UserProfileResponse:
"""
Get the current authenticated user's profile
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).
"""
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
# Create controller instance
auth_controller = AuthController()
+408
View File
@@ -0,0 +1,408 @@
"""
Authentication Domain Models
SQLAlchemy models for users, roles, groups, API keys, and preferences.
All authentication-related database models consolidated in one file.
"""
import uuid
from datetime import datetime
from typing import TYPE_CHECKING, List
from sqlalchemy import String, Boolean, DateTime, func, ForeignKey, Table, Column
from sqlalchemy.dialects.postgresql import UUID, JSONB, ARRAY
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.shared.database import Base
# =============================================================================
# Association Tables
# =============================================================================
user_groups = Table(
"user_groups",
Base.metadata,
Column("user_id", UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
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
# =============================================================================
class User(Base):
"""
User model synced from Authentik
Users are created/updated when they authenticate via OIDC.
The authentik_id links to the Authentik user record.
"""
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
authentik_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
unique=True,
nullable=False,
index=True,
)
email: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
avatar_url: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
api_keys_enabled: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
last_login: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
# Relationships
roles: Mapped[List["Role"]] = relationship(
"Role",
secondary="user_roles",
back_populates="users",
lazy="selectin",
)
preferences: Mapped["UserPreferences"] = relationship(
"UserPreferences",
back_populates="user",
uselist=False,
lazy="selectin",
cascade="all, delete-orphan",
)
api_keys: Mapped[List["ApiKey"]] = relationship(
"ApiKey",
back_populates="user",
lazy="selectin",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<User {self.email}>"
# =============================================================================
# Role Models
# =============================================================================
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.
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)
"""
__tablename__ = "roles"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
name: Mapped[str] = mapped_column(
String(100),
unique=True,
nullable=False,
index=True,
comment="Role name in format domain.category:action (e.g., control-room.general:admin)",
)
domain: Mapped[str] = mapped_column(
String(50),
nullable=False,
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)",
)
# Relationships
users: Mapped[List["User"]] = relationship(
"User",
secondary="user_roles",
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}>"
class UserRole(Base):
"""
Association table for User-Role many-to-many relationship
Synced from Authentik groups during user authentication.
"""
__tablename__ = "user_roles"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
role_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("roles.id", ondelete="CASCADE"),
primary_key=True,
)
# =============================================================================
# Group Model
# =============================================================================
class Group(Base):
"""
Group model synced from Authentik
Groups are fetched from Authentik admin API and cached locally.
They represent organizational units for access control.
"""
__tablename__ = "groups"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
authentik_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
unique=True,
nullable=False,
index=True,
comment="Authentik group UUID",
)
name: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
is_superuser: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
comment="Whether members of this group have superuser privileges",
)
parent_name: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
comment="Parent group name for hierarchy",
)
member_count: Mapped[int] = mapped_column(
default=0,
nullable=False,
comment="Number of users in this group",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
synced_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
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}>"
# =============================================================================
# User Preferences Model
# =============================================================================
class UserPreferences(Base):
"""
User preferences model
Stores user-specific settings that persist across sessions.
Extended settings stored in preferences_json for flexibility.
"""
__tablename__ = "user_preferences"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
)
theme: Mapped[str] = mapped_column(
String(20),
default="system",
nullable=False,
comment="Theme preference: system, light, dark",
)
default_room: Mapped[str] = mapped_column(
String(50),
default="front-hall",
nullable=False,
comment="Default room for housekeeping features",
)
preferences_json: Mapped[dict] = mapped_column(
JSONB,
default=dict,
nullable=False,
comment="Extended preferences as JSON",
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="preferences",
)
def __repr__(self) -> str:
return f"<UserPreferences user_id={self.user_id}>"
# =============================================================================
# API Key Model
# =============================================================================
class ApiKey(Base):
"""
API Key model for programmatic access
API keys provide an alternative to OIDC for:
- Local development without SSO
- Service-to-service communication
- Scripts and automation
Keys inherit the user's roles but can optionally
be restricted to a subset of scopes.
"""
__tablename__ = "api_keys"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(100),
nullable=False,
comment="Human-readable key name (e.g., 'Dev Laptop', 'CI/CD')",
)
key_hash: Mapped[str] = mapped_column(
String(255),
nullable=False,
comment="SHA-256 hash of the API key",
)
key_prefix: Mapped[str] = mapped_column(
String(8),
nullable=False,
comment="First 8 chars of key for identification (e.g., 'cak_abc1')",
)
scopes: Mapped[List[str] | None] = mapped_column(
ARRAY(String),
nullable=True,
comment="Optional scope restriction (subset of user roles)",
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Optional expiration timestamp",
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Last time this key was used",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
# Relationships
user: Mapped["User"] = relationship(
"User",
back_populates="api_keys",
)
def __repr__(self) -> str:
return f"<ApiKey {self.key_prefix}... ({self.name})>"
@property
def is_expired(self) -> bool:
"""Check if the API key has expired"""
if self.expires_at is None:
return False
return datetime.now(self.expires_at.tzinfo) > self.expires_at
+767
View File
@@ -0,0 +1,767 @@
"""
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 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)
class OIDCConfig:
"""OIDC configuration from environment"""
def __init__(self):
# These will be set from environment variables in config.py
self.enabled = False
self.issuers: list[str] = []
self.audiences: list[str] = []
def configure(self, enabled: bool, issuers: list[str], audiences: list[str]):
"""Configure OIDC settings"""
self.enabled = enabled
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()
# Per-issuer JWKS cache
_jwks_cache: Dict[str, Dict] = {}
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
Raises:
HTTPException: If JWKS fetch fails
"""
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 {jwks_uri}")
response = httpx.get(jwks_uri, timeout=10.0)
response.raise_for_status()
jwks = response.json()
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 from {jwks_uri}: {e}")
raise HTTPException(
status_code=503,
detail="Authentication service unavailable"
)
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
) -> Optional[Dict]:
"""
Validate OIDC token from Authorization: Bearer header
Extracts and validates JWT token from request header. Verifies:
- Token signature using JWKS
- Token expiration
- Issuer matches Authentik
- Audience matches core-api
Args:
credentials: HTTP Bearer token from Authorization header
Returns:
User claims dictionary containing email, name, groups, etc.
Returns None if OIDC is disabled (allows unauthenticated access)
Raises:
HTTPException 401: If token is invalid, expired, or missing when OIDC enabled
"""
# If OIDC is disabled, return a default local user
if not oidc_config.enabled:
logger.debug("OIDC disabled - using local user")
return {
"sub": "local-user",
"email": "local@localhost",
"preferred_username": "local",
"name": "Local User",
"groups": ["admin"],
"auth_method": "local"
}
# OIDC enabled - token required
if not credentials:
logger.warning("Authentication required but no token provided")
raise HTTPException(
status_code=401,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
try:
# 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")
# 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", []):
if key.get("kid") == kid:
rsa_key = key
break
if not rsa_key:
logger.warning(f"No matching key found for kid: {kid}")
raise HTTPException(status_code=401, detail="Invalid token key")
# Verify and decode token using the token's actual issuer
payload = jwt.decode(
token,
rsa_key,
algorithms=["RS256"],
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} (issuer: {token_issuer})")
return payload
except jwt.ExpiredSignatureError:
logger.warning("Token expired")
raise HTTPException(
status_code=401,
detail="Token expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.JWTClaimsError as e:
logger.warning(f"Invalid token claims: {e}")
raise HTTPException(
status_code=401,
detail="Invalid token claims",
headers={"WWW-Authenticate": "Bearer"},
)
except JWTError as e:
logger.error(f"JWT validation error: {e}")
raise HTTPException(
status_code=401,
detail="Invalid authentication token",
headers={"WWW-Authenticate": "Bearer"},
)
except Exception as e:
logger.error(f"Unexpected authentication error: {e}")
raise HTTPException(
status_code=500,
detail="Authentication error",
)
async def get_admin_user(
user: Optional[Dict] = Depends(get_current_user)
) -> Dict:
"""
Require admin group membership
Use this dependency for endpoints that require admin access.
Checks if user is member of 'admin' group in Authentik.
Args:
user: User claims from get_current_user
Returns:
User claims dictionary if user is admin
Raises:
HTTPException 403: If user is not in admin group
HTTPException 401: If OIDC enabled but user not authenticated
"""
# If OIDC disabled, allow all (backward compatibility)
if not oidc_config.enabled or user is None:
logger.debug("OIDC disabled - allowing admin access")
return {"email": "unauthenticated", "groups": ["admin"]}
# Check admin group membership
groups = user.get("groups", [])
if "admin" not in groups and "authentik Admins" not in groups:
user_email = user.get("email", "unknown")
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
raise HTTPException(
status_code=403,
detail="Admin access required"
)
return user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Security(security)
) -> Optional[Dict]:
"""
Optional authentication - allows both authenticated and unauthenticated access
Use for endpoints that should be accessible to everyone but can provide
enhanced functionality for authenticated users.
Args:
credentials: HTTP Bearer token from Authorization header
Returns:
User claims if valid token provided, local user if OIDC disabled, None otherwise
"""
# If OIDC is disabled, return the local user
if not oidc_config.enabled:
return {
"sub": "local-user",
"email": "local@localhost",
"preferred_username": "local",
"name": "Local User",
"groups": ["admin"],
"auth_method": "local"
}
if not credentials:
return None
try:
return await get_current_user(credentials)
except HTTPException:
# Invalid token - return None instead of raising
return None
async def get_forward_auth_user(
request: Request
) -> Optional[Dict]:
"""
Authentik Forward Auth authentication for external access via NPM
This dependency allows:
- External access through api.schweitz.net (with Authentik forward auth headers) - REQUIRES authentication
- Internal direct access (no forward auth headers) - ALLOWED without authentication
When accessing through NPM with Authentik forward auth enabled, NPM adds headers like:
- X-authentik-username
- X-authentik-email
- X-authentik-groups
- X-authentik-name
- X-authentik-uid
Args:
request: FastAPI request object containing headers
Returns:
User info dict if authenticated via forward auth headers
None if accessed internally (no forward auth headers)
Raises:
HTTPException 401: If forward auth headers present but invalid/incomplete
"""
# Check for Authentik forward auth headers
username = request.headers.get("x-authentik-username")
email = request.headers.get("x-authentik-email")
groups = request.headers.get("x-authentik-groups")
name = request.headers.get("x-authentik-name")
uid = request.headers.get("x-authentik-uid")
# If NO forward auth headers present, this is internal access - allow it
if not username and not email:
logger.debug("No forward auth headers - allowing internal access")
return None
# Forward auth headers present (external access via api.schweitz.net)
# Validate authentication
if not username or not email:
logger.warning("Incomplete forward auth headers detected")
raise HTTPException(
status_code=401,
detail="Authentication required - incomplete forward auth headers"
)
# Parse groups (comma-separated string to list)
groups_list = [g.strip() for g in groups.split(",")] if groups else []
user_info = {
"username": username,
"email": email,
"name": name or username,
"groups": groups_list,
"uid": uid,
"auth_method": "forward_auth"
}
logger.info(f"Authenticated via forward auth: {email} (groups: {groups_list})")
return user_info
async def get_forward_auth_admin(
user: Optional[Dict] = Depends(get_forward_auth_user)
) -> Dict:
"""
Require admin access for external requests, allow all internal requests
Use this dependency for endpoints that require admin access when accessed
externally through api.schweitz.net, but allow unrestricted internal access.
Args:
user: User info from get_forward_auth_user
Returns:
User info dict if user is admin or if accessed internally
Raises:
HTTPException 403: If external user is not in admin/authentik Admins group
"""
# Internal access (no forward auth headers) - allow all
if user is None:
logger.debug("Internal access - allowing without admin check")
return {"email": "internal", "groups": ["admin"], "auth_method": "internal"}
# External access - check admin group membership
groups = user.get("groups", [])
if "admin" not in groups and "authentik Admins" not in groups:
user_email = user.get("email", "unknown")
logger.warning(f"User {user_email} attempted admin access (groups: {groups})")
raise HTTPException(
status_code=403,
detail="Admin access required"
)
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
+211
View File
@@ -0,0 +1,211 @@
"""
Authentication Schemas
Pydantic models for auth request/response payloads.
"""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import Field
from src.shared.base import BaseSchema
class AuthSyncRequest(BaseSchema):
"""
Request payload for POST /auth/sync
The client sends this after obtaining an OIDC token from Authentik.
The access_token is validated against Authentik's userinfo endpoint.
"""
access_token: str = Field(
...,
description="OIDC access token from Authentik",
)
class RoleSchema(BaseSchema):
"""Role information in domain.category:action format"""
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')")
class UserPreferencesSchema(BaseSchema):
"""User preferences"""
theme: str = Field(default="system", description="Theme preference: system, light, dark")
default_room: str = Field(default="front-hall", description="Default room for housekeeping")
preferences_json: dict = Field(default_factory=dict, description="Extended preferences")
class UserSchema(BaseSchema):
"""User information returned from sync"""
id: uuid.UUID = Field(..., description="Internal user ID")
authentik_id: uuid.UUID = Field(..., description="Authentik user ID")
email: str = Field(..., description="User email")
name: str = Field(..., description="Display name")
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
created_at: datetime = Field(..., description="Account creation timestamp")
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
class AuthSyncResponse(BaseSchema):
"""
Response from POST /auth/sync
Contains the synced user profile, roles, and preferences.
"""
user: UserSchema = Field(..., description="User profile")
roles: list[RoleSchema] = Field(..., description="User's permission roles")
preferences: UserPreferencesSchema = Field(..., description="User preferences")
is_new_user: bool = Field(..., description="True if user was just created")
class TokenInfoSchema(BaseSchema):
"""
Token information from Authentik userinfo endpoint
This is what Authentik returns when validating an access token.
"""
sub: str = Field(..., description="Subject (Authentik user ID)")
email: str = Field(..., description="User email")
name: Optional[str] = Field(None, description="Display name")
preferred_username: Optional[str] = Field(None, description="Username")
groups: list[str] = Field(default_factory=list, description="Group memberships")
picture: Optional[str] = Field(None, description="Profile picture URL")
class UserListItemSchema(BaseSchema):
"""User item for list display"""
id: uuid.UUID = Field(..., description="Internal user ID")
email: str = Field(..., description="User email")
name: str = Field(..., description="Display name")
avatar_url: Optional[str] = Field(None, description="Profile picture URL")
created_at: datetime = Field(..., description="Account creation timestamp")
last_login: Optional[datetime] = Field(None, description="Last login timestamp")
roles: list[str] = Field(default_factory=list, description="Role names")
class UsersListResponse(BaseSchema):
"""Response from GET /auth/users"""
items: list[UserListItemSchema] = Field(..., description="List of users")
total: int = Field(..., description="Total count of users")
class BulkSyncResultSchema(BaseSchema):
"""Result from bulk sync operation"""
created: int = Field(..., description="Number of users created")
updated: int = Field(..., description="Number of users updated")
failed: int = Field(..., description="Number of users that failed to sync")
total_in_authentik: int = Field(..., description="Total users in Authentik")
errors: list[str] = Field(default_factory=list, description="Error messages for failed syncs")
class GroupListItemSchema(BaseSchema):
"""Group item for list display"""
id: uuid.UUID = Field(..., description="Internal group ID")
authentik_id: uuid.UUID = Field(..., description="Authentik group ID")
name: str = Field(..., description="Group name")
is_superuser: bool = Field(default=False, description="Whether group has superuser privileges")
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):
"""Response from GET /auth/groups"""
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")
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
"""
Dashboard Domain
Provides dashboard management endpoints including quick links.
"""
from src.domains.dashboard.controller import dashboard_controller
__all__ = ["dashboard_controller"]
+305
View File
@@ -0,0 +1,305 @@
"""
Dashboard Controller
Provides API endpoints for dashboard management including quick links.
"""
from fastapi import APIRouter, HTTPException, Depends, Query
from typing import Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from src.shared.base import BaseController
from src.shared.database import get_async_session
from src.shared.logging import get_logger
from src.domains.auth.oidc import get_current_user, get_optional_user
from src.domains.dashboard.service import get_dashboard_service
from src.domains.dashboard.schemas import (
QuickLinkCreate,
QuickLinkUpdate,
QuickLinkResponse,
QuickLinkListResponse,
QuickLinkReorderRequest,
QuickLinkReorderResponse,
DashboardWidgetCreate,
DashboardWidgetUpdate,
DashboardWidgetResponse,
DashboardWidgetListResponse,
)
logger = get_logger(__name__)
class DashboardController(BaseController):
"""
Controller for dashboard operations
Provides endpoints for:
- Quick links CRUD
- Quick links reordering
- Dashboard widgets management
"""
def __init__(self):
super().__init__(prefix="/dashboard", tags=["Dashboard"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
service = get_dashboard_service()
# =====================================================================
# Quick Links
# =====================================================================
@router.get(
"/quick-links",
response_model=QuickLinkListResponse,
summary="List quick links"
)
async def list_quick_links(
category: Optional[str] = Query(None, description="Filter by category"),
include_global: bool = Query(True, description="Include global links"),
visible_only: bool = Query(True, description="Only visible links"),
session: AsyncSession = Depends(get_async_session),
user: Optional[Dict] = Depends(get_optional_user),
):
"""
List quick links for the current user
Returns user-specific links plus global links (if include_global=True).
"""
user_id = user.get("sub") if user else None
links = await service.get_quick_links(
session=session,
user_id=user_id,
include_global=include_global,
category=category,
visible_only=visible_only,
)
return QuickLinkListResponse(
links=[QuickLinkResponse.model_validate(link, from_attributes=True) for link in links],
total=len(links)
)
@router.get(
"/quick-links/{link_id}",
response_model=QuickLinkResponse,
summary="Get a quick link"
)
async def get_quick_link(
link_id: int,
session: AsyncSession = Depends(get_async_session),
user: Optional[Dict] = Depends(get_optional_user),
):
"""Get a specific quick link by ID"""
user_id = user.get("sub") if user else None
link = await service.get_quick_link(session, link_id, user_id)
if not link:
raise HTTPException(status_code=404, detail="Quick link not found")
return QuickLinkResponse.model_validate(link, from_attributes=True)
@router.post(
"/quick-links",
response_model=QuickLinkResponse,
status_code=201,
summary="Create a quick link"
)
async def create_quick_link(
data: QuickLinkCreate,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""
Create a new quick link for the current user
Links are user-specific by default. Admins can create global links
by setting user_id to null.
"""
user_id = user.get("sub")
link = await service.create_quick_link(session, data, user_id)
logger.info(f"Quick link created: {link.title} by user {user.get('preferred_username')}")
return QuickLinkResponse.model_validate(link, from_attributes=True)
@router.put(
"/quick-links/{link_id}",
response_model=QuickLinkResponse,
summary="Update a quick link"
)
async def update_quick_link(
link_id: int,
data: QuickLinkUpdate,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""Update an existing quick link"""
user_id = user.get("sub")
link = await service.update_quick_link(session, link_id, data, user_id)
if not link:
raise HTTPException(status_code=404, detail="Quick link not found or not authorized")
return QuickLinkResponse.model_validate(link, from_attributes=True)
@router.delete(
"/quick-links/{link_id}",
status_code=204,
summary="Delete a quick link"
)
async def delete_quick_link(
link_id: int,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""Delete a quick link"""
user_id = user.get("sub")
success = await service.delete_quick_link(session, link_id, user_id)
if not success:
raise HTTPException(status_code=404, detail="Quick link not found or not authorized")
return None
@router.post(
"/quick-links/reorder",
response_model=QuickLinkReorderResponse,
summary="Reorder quick links"
)
async def reorder_quick_links(
data: QuickLinkReorderRequest,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""
Reorder quick links by providing link IDs in desired order
The position of each link will be set to its index in the provided list.
"""
user_id = user.get("sub")
reordered = await service.reorder_quick_links(session, data.link_ids, user_id)
return QuickLinkReorderResponse(
success=True,
message=f"Reordered {reordered} links",
reordered_count=reordered
)
# =====================================================================
# Dashboard Widgets
# =====================================================================
@router.get(
"/widgets",
response_model=DashboardWidgetListResponse,
summary="List dashboard widgets"
)
async def list_widgets(
include_defaults: bool = Query(True, description="Include default widgets"),
visible_only: bool = Query(True, description="Only visible widgets"),
session: AsyncSession = Depends(get_async_session),
user: Optional[Dict] = Depends(get_optional_user),
):
"""List dashboard widgets for the current user"""
user_id = user.get("sub") if user else None
widgets = await service.get_widgets(
session=session,
user_id=user_id,
include_defaults=include_defaults,
visible_only=visible_only,
)
return DashboardWidgetListResponse(
widgets=[DashboardWidgetResponse.model_validate(w, from_attributes=True) for w in widgets],
total=len(widgets)
)
@router.get(
"/widgets/{widget_id}",
response_model=DashboardWidgetResponse,
summary="Get a dashboard widget"
)
async def get_widget(
widget_id: int,
session: AsyncSession = Depends(get_async_session),
user: Optional[Dict] = Depends(get_optional_user),
):
"""Get a specific dashboard widget by ID"""
user_id = user.get("sub") if user else None
widget = await service.get_widget(session, widget_id, user_id)
if not widget:
raise HTTPException(status_code=404, detail="Widget not found")
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
@router.post(
"/widgets",
response_model=DashboardWidgetResponse,
status_code=201,
summary="Create a dashboard widget"
)
async def create_widget(
data: DashboardWidgetCreate,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""Create a new dashboard widget"""
user_id = user.get("sub")
widget = await service.create_widget(session, data, user_id)
logger.info(f"Widget created: {widget.widget_type} by user {user.get('preferred_username')}")
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
@router.put(
"/widgets/{widget_id}",
response_model=DashboardWidgetResponse,
summary="Update a dashboard widget"
)
async def update_widget(
widget_id: int,
data: DashboardWidgetUpdate,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""Update an existing dashboard widget"""
user_id = user.get("sub")
widget = await service.update_widget(session, widget_id, data, user_id)
if not widget:
raise HTTPException(status_code=404, detail="Widget not found or not authorized")
return DashboardWidgetResponse.model_validate(widget, from_attributes=True)
@router.delete(
"/widgets/{widget_id}",
status_code=204,
summary="Delete a dashboard widget"
)
async def delete_widget(
widget_id: int,
session: AsyncSession = Depends(get_async_session),
user: Dict = Depends(get_current_user),
):
"""Delete a dashboard widget"""
user_id = user.get("sub")
success = await service.delete_widget(session, widget_id, user_id)
if not success:
raise HTTPException(status_code=404, detail="Widget not found or not authorized")
return None
return router
# Create controller instance
dashboard_controller = DashboardController()
+77
View File
@@ -0,0 +1,77 @@
"""
Dashboard Domain Models
SQLAlchemy models for dashboard-related data.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from src.shared.database import Base
class QuickLink(Base):
"""Quick link for dashboard jump pad"""
__tablename__ = "quick_links"
id = Column(Integer, primary_key=True, index=True)
# Link content
title = Column(String(100), nullable=False)
url = Column(String(500), nullable=False)
icon = Column(String(100), nullable=True) # Icon name or URL
description = Column(String(255), nullable=True)
# Categorization
category = Column(String(50), nullable=True) # e.g., "services", "tools", "docs"
# User association - nullable for global links
user_id = Column(String(255), nullable=True, index=True) # Authentik user ID
# Ordering and display
position = Column(Integer, default=0)
is_visible = Column(Boolean, default=True)
link_type = Column(String(20), default="iframe") # "iframe", "new_tab", etc.
# Styling
color = Column(String(20), nullable=True) # Hex color for the link card
background_color = Column(String(20), nullable=True)
# Metadata
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<QuickLink(id={self.id}, title='{self.title}', user_id='{self.user_id}')>"
class DashboardWidget(Base):
"""Dashboard widget configuration"""
__tablename__ = "dashboard_widgets"
id = Column(Integer, primary_key=True, index=True)
# Widget identification
widget_type = Column(String(50), nullable=False) # e.g., "quick_links", "service_status", "weather"
# User association - nullable for default widgets
user_id = Column(String(255), nullable=True, index=True)
# Position and sizing
position_x = Column(Integer, default=0)
position_y = Column(Integer, default=0)
width = Column(Integer, default=1)
height = Column(Integer, default=1)
# Widget-specific configuration (JSON)
config = Column(Text, nullable=True) # JSON string for widget-specific settings
# Display
is_visible = Column(Boolean, default=True)
# Metadata
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f"<DashboardWidget(id={self.id}, type='{self.widget_type}', user_id='{self.user_id}')>"
+113
View File
@@ -0,0 +1,113 @@
"""
Dashboard Domain Schemas
Pydantic schemas for dashboard endpoints.
"""
from datetime import datetime
from typing import Optional, List
from pydantic import Field
from src.shared.base import BaseSchema
# Quick Link Schemas
class QuickLinkBase(BaseSchema):
"""Base schema for quick links"""
title: str = Field(..., min_length=1, max_length=100, description="Link title")
url: str = Field(..., min_length=1, max_length=500, description="Link URL")
icon: Optional[str] = Field(None, max_length=100, description="Icon name or URL")
description: Optional[str] = Field(None, max_length=255, description="Link description")
category: Optional[str] = Field(None, max_length=50, description="Link category")
color: Optional[str] = Field(None, max_length=20, description="Hex color for link card")
background_color: Optional[str] = Field(None, max_length=20, description="Background hex color")
class QuickLinkCreate(QuickLinkBase):
"""Schema for creating a quick link"""
position: Optional[int] = Field(0, ge=0, description="Display position")
is_visible: Optional[bool] = Field(True, description="Whether link is visible")
link_type: Optional[str] = Field("iframe", max_length=20, description="Link type: iframe, new_tab")
class QuickLinkUpdate(BaseSchema):
"""Schema for updating a quick link"""
title: Optional[str] = Field(None, min_length=1, max_length=100)
url: Optional[str] = Field(None, min_length=1, max_length=500)
icon: Optional[str] = Field(None, max_length=100)
description: Optional[str] = Field(None, max_length=255)
category: Optional[str] = Field(None, max_length=50)
position: Optional[int] = Field(None, ge=0)
is_visible: Optional[bool] = None
link_type: Optional[str] = Field(None, max_length=20)
color: Optional[str] = Field(None, max_length=20)
background_color: Optional[str] = Field(None, max_length=20)
class QuickLinkResponse(QuickLinkBase):
"""Schema for quick link response"""
id: int
user_id: Optional[str] = None
position: int
is_visible: bool
link_type: str = "iframe"
created_at: datetime
updated_at: datetime
class QuickLinkListResponse(BaseSchema):
"""Response for list of quick links"""
links: List[QuickLinkResponse]
total: int
class QuickLinkReorderRequest(BaseSchema):
"""Request to reorder quick links"""
link_ids: List[int] = Field(..., description="List of link IDs in desired order")
class QuickLinkReorderResponse(BaseSchema):
"""Response after reordering"""
success: bool
message: str
reordered_count: int
# Dashboard Widget Schemas
class DashboardWidgetBase(BaseSchema):
"""Base schema for dashboard widgets"""
widget_type: str = Field(..., min_length=1, max_length=50, description="Widget type identifier")
position_x: int = Field(0, ge=0, description="X position on grid")
position_y: int = Field(0, ge=0, description="Y position on grid")
width: int = Field(1, ge=1, le=12, description="Widget width in grid units")
height: int = Field(1, ge=1, le=12, description="Widget height in grid units")
config: Optional[str] = Field(None, description="JSON config for widget")
is_visible: bool = Field(True, description="Whether widget is visible")
class DashboardWidgetCreate(DashboardWidgetBase):
"""Schema for creating a widget"""
pass
class DashboardWidgetUpdate(BaseSchema):
"""Schema for updating a widget"""
position_x: Optional[int] = Field(None, ge=0)
position_y: Optional[int] = Field(None, ge=0)
width: Optional[int] = Field(None, ge=1, le=12)
height: Optional[int] = Field(None, ge=1, le=12)
config: Optional[str] = None
is_visible: Optional[bool] = None
class DashboardWidgetResponse(DashboardWidgetBase):
"""Schema for widget response"""
id: int
user_id: Optional[str] = None
created_at: datetime
updated_at: datetime
class DashboardWidgetListResponse(BaseSchema):
"""Response for list of widgets"""
widgets: List[DashboardWidgetResponse]
total: int
+319
View File
@@ -0,0 +1,319 @@
"""
Dashboard Domain Service
Business logic for dashboard operations.
"""
from typing import Optional, List
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from src.shared.logging import get_logger
from src.domains.dashboard.models import QuickLink, DashboardWidget
from src.domains.dashboard.schemas import (
QuickLinkCreate,
QuickLinkUpdate,
QuickLinkResponse,
DashboardWidgetCreate,
DashboardWidgetUpdate,
DashboardWidgetResponse,
)
logger = get_logger(__name__)
class DashboardService:
"""Service for dashboard operations"""
# =========================================================================
# Quick Links
# =========================================================================
async def get_quick_links(
self,
session: AsyncSession,
user_id: Optional[str] = None,
include_global: bool = True,
category: Optional[str] = None,
visible_only: bool = True,
) -> List[QuickLink]:
"""
Get quick links for a user
Args:
session: Database session
user_id: User ID to filter by (None for global only)
include_global: Whether to include global links (user_id=None)
category: Optional category filter
visible_only: Only return visible links
"""
conditions = []
if user_id:
if include_global:
from sqlalchemy import or_
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
else:
conditions.append(QuickLink.user_id == user_id)
else:
conditions.append(QuickLink.user_id.is_(None))
if category:
conditions.append(QuickLink.category == category)
if visible_only:
conditions.append(QuickLink.is_visible == True)
stmt = select(QuickLink).where(*conditions).order_by(QuickLink.position, QuickLink.id)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_quick_link(
self,
session: AsyncSession,
link_id: int,
user_id: Optional[str] = None,
) -> Optional[QuickLink]:
"""Get a specific quick link by ID"""
conditions = [QuickLink.id == link_id]
if user_id:
from sqlalchemy import or_
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
stmt = select(QuickLink).where(*conditions)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def create_quick_link(
self,
session: AsyncSession,
data: QuickLinkCreate,
user_id: Optional[str] = None,
) -> QuickLink:
"""Create a new quick link"""
# Get max position for this user
stmt = select(QuickLink.position).where(
QuickLink.user_id == user_id if user_id else QuickLink.user_id.is_(None)
).order_by(QuickLink.position.desc()).limit(1)
result = await session.execute(stmt)
max_pos = result.scalar_one_or_none() or -1
link = QuickLink(
title=data.title,
url=data.url,
icon=data.icon,
description=data.description,
category=data.category,
position=data.position if data.position > 0 else max_pos + 1,
is_visible=data.is_visible,
color=data.color,
background_color=data.background_color,
user_id=user_id,
)
session.add(link)
await session.commit()
await session.refresh(link)
logger.info(f"Created quick link: {link.title} (id={link.id}, user={user_id})")
return link
async def update_quick_link(
self,
session: AsyncSession,
link_id: int,
data: QuickLinkUpdate,
user_id: Optional[str] = None,
) -> Optional[QuickLink]:
"""Update a quick link"""
link = await self.get_quick_link(session, link_id, user_id)
if not link:
return None
# Only allow updating own links or global links for admins
if link.user_id and link.user_id != user_id:
return None
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(link, field, value)
await session.commit()
await session.refresh(link)
logger.info(f"Updated quick link: {link.title} (id={link.id})")
return link
async def delete_quick_link(
self,
session: AsyncSession,
link_id: int,
user_id: Optional[str] = None,
) -> bool:
"""Delete a quick link"""
link = await self.get_quick_link(session, link_id, user_id)
if not link:
return False
# Only allow deleting own links
if link.user_id and link.user_id != user_id:
return False
await session.delete(link)
await session.commit()
logger.info(f"Deleted quick link: id={link_id}")
return True
async def reorder_quick_links(
self,
session: AsyncSession,
link_ids: List[int],
user_id: Optional[str] = None,
) -> int:
"""Reorder quick links by updating positions"""
reordered = 0
for position, link_id in enumerate(link_ids):
conditions = [QuickLink.id == link_id]
if user_id:
from sqlalchemy import or_
conditions.append(or_(QuickLink.user_id == user_id, QuickLink.user_id.is_(None)))
stmt = update(QuickLink).where(*conditions).values(position=position)
result = await session.execute(stmt)
reordered += result.rowcount
await session.commit()
logger.info(f"Reordered {reordered} quick links for user {user_id}")
return reordered
# =========================================================================
# Dashboard Widgets
# =========================================================================
async def get_widgets(
self,
session: AsyncSession,
user_id: Optional[str] = None,
include_defaults: bool = True,
visible_only: bool = True,
) -> List[DashboardWidget]:
"""Get dashboard widgets for a user"""
conditions = []
if user_id:
if include_defaults:
from sqlalchemy import or_
conditions.append(or_(DashboardWidget.user_id == user_id, DashboardWidget.user_id.is_(None)))
else:
conditions.append(DashboardWidget.user_id == user_id)
else:
conditions.append(DashboardWidget.user_id.is_(None))
if visible_only:
conditions.append(DashboardWidget.is_visible == True)
stmt = select(DashboardWidget).where(*conditions).order_by(
DashboardWidget.position_y, DashboardWidget.position_x
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_widget(
self,
session: AsyncSession,
widget_id: int,
user_id: Optional[str] = None,
) -> Optional[DashboardWidget]:
"""Get a specific widget by ID"""
conditions = [DashboardWidget.id == widget_id]
if user_id:
from sqlalchemy import or_
conditions.append(or_(DashboardWidget.user_id == user_id, DashboardWidget.user_id.is_(None)))
stmt = select(DashboardWidget).where(*conditions)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def create_widget(
self,
session: AsyncSession,
data: DashboardWidgetCreate,
user_id: Optional[str] = None,
) -> DashboardWidget:
"""Create a new dashboard widget"""
widget = DashboardWidget(
widget_type=data.widget_type,
position_x=data.position_x,
position_y=data.position_y,
width=data.width,
height=data.height,
config=data.config,
is_visible=data.is_visible,
user_id=user_id,
)
session.add(widget)
await session.commit()
await session.refresh(widget)
logger.info(f"Created widget: {widget.widget_type} (id={widget.id}, user={user_id})")
return widget
async def update_widget(
self,
session: AsyncSession,
widget_id: int,
data: DashboardWidgetUpdate,
user_id: Optional[str] = None,
) -> Optional[DashboardWidget]:
"""Update a dashboard widget"""
widget = await self.get_widget(session, widget_id, user_id)
if not widget:
return None
if widget.user_id and widget.user_id != user_id:
return None
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(widget, field, value)
await session.commit()
await session.refresh(widget)
logger.info(f"Updated widget: id={widget.id}")
return widget
async def delete_widget(
self,
session: AsyncSession,
widget_id: int,
user_id: Optional[str] = None,
) -> bool:
"""Delete a dashboard widget"""
widget = await self.get_widget(session, widget_id, user_id)
if not widget:
return False
if widget.user_id and widget.user_id != user_id:
return False
await session.delete(widget)
await session.commit()
logger.info(f"Deleted widget: id={widget_id}")
return True
# Singleton instance
_dashboard_service: Optional[DashboardService] = None
def get_dashboard_service() -> DashboardService:
"""Get singleton dashboard service instance"""
global _dashboard_service
if _dashboard_service is None:
_dashboard_service = DashboardService()
return _dashboard_service
+8
View File
@@ -0,0 +1,8 @@
"""
Health Domain
Provides health check and diagnostics endpoints.
"""
from src.domains.health.controller import health_controller
__all__ = ["health_controller"]
+143
View File
@@ -0,0 +1,143 @@
"""
Health Controller
Provides service health and information endpoints
"""
from fastapi import APIRouter, Response
from fastapi.responses import JSONResponse
from src.shared.base import BaseController
from src.shared.config import get_settings
from src.shared.logging import get_logger
from src.shared.database import get_database
logger = get_logger(__name__)
class HealthController(BaseController):
"""
Controller for service health and information
Provides endpoints for:
- Service information and status
- Health checks
"""
def __init__(self):
super().__init__(prefix="", tags=["Health"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(tags=self.tags)
settings = get_settings()
@router.get(
"/",
summary="Service information",
response_class=JSONResponse
)
async def root():
"""
Get service information and health status
Returns basic information about the API service and available endpoints.
"""
logger.debug("Root endpoint accessed")
return {
"service": settings.app_name,
"version": settings.app_version,
"status": "healthy",
"docs": "/docs"
}
@router.get(
"/health",
summary="Health check",
response_class=JSONResponse
)
async def health_check():
"""
Fast health check endpoint for container orchestration
Returns a 200 OK immediately if the service is running.
Does NOT check backend connectivity (use /health/full for that).
Used by Docker, Kubernetes, and load balancers for liveness probes.
"""
return {
"status": "healthy",
"version": settings.app_version
}
@router.get(
"/health/full",
summary="Full health check with database",
)
async def full_health_check(response: Response):
"""
Health check including database connectivity.
Returns 200 OK if database is available, otherwise 503.
"""
import time
start_time = time.time()
# Check database connection
database = get_database()
db_healthy = False
db_error = None
try:
db_healthy = await database.health_check()
except Exception as e:
db_error = str(e)
logger.warning(f"Database health check failed: {db_error}")
elapsed_ms = int((time.time() - start_time) * 1000)
status_code = 200 if db_healthy else 503
response.status_code = status_code
return {
"status": "healthy" if db_healthy else "unhealthy",
"status_code": status_code,
"response_time_ms": elapsed_ms,
"components": {
"database": {
"status": "healthy" if db_healthy else "unhealthy",
"error": db_error
}
}
}
@router.get(
"/health/diagnostics",
summary="Detailed system diagnostics",
)
async def diagnostics():
"""
System diagnostics with service information.
"""
import time
start_time = time.time()
diagnostics = {
"timestamp": time.time(),
"service": {
"name": settings.app_name,
"version": settings.app_version,
"purpose": "Infrastructure management and tools API"
},
"configuration": {
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
}
}
elapsed_ms = int((time.time() - start_time) * 1000)
diagnostics["response_time_ms"] = elapsed_ms
return diagnostics
return router
# Create controller instance
health_controller = HealthController()
+8
View File
@@ -0,0 +1,8 @@
"""
Housekeeping Domain
Provides home automation endpoints via Home Assistant.
"""
from src.domains.housekeeping.controller import housekeeping_controller
__all__ = ["housekeeping_controller"]
+645
View File
@@ -0,0 +1,645 @@
"""
Housekeeping Controller
Provides API endpoints for home automation via Home Assistant.
Designed for the Tatlock Housekeeper agent and other consumers.
"""
import asyncio
from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from src.shared.base import BaseController
from src.shared.clients import get_homeassistant_client
from src.shared.logging import get_logger
from src.domains.auth.oidc import get_admin_user
logger = get_logger(__name__)
# Pydantic Schemas
class Device(BaseModel):
"""Device/entity information"""
entity_id: str
name: str
domain: str
area: Optional[str] = None
state: str
attributes: Dict[str, Any] = {}
last_changed: Optional[str] = None
class DeviceListResponse(BaseModel):
"""Response for device listing"""
devices: List[Device]
class DeviceDetailResponse(Device):
"""Detailed device response"""
pass
class Area(BaseModel):
"""Area/room information"""
id: str
name: str
class AreaListResponse(BaseModel):
"""Response for area listing"""
areas: List[Area]
class DeviceControlRequest(BaseModel):
"""Request to control a device"""
action: str = Field(..., description="Action: turn_on, turn_off, or toggle")
brightness: Optional[int] = Field(None, ge=0, le=255)
color_temp: Optional[int] = None
rgb_color: Optional[List[int]] = None
class Config:
extra = "allow"
class DeviceControlResponse(BaseModel):
"""Response from device control"""
success: bool
entity_id: str
new_state: Optional[str] = None
message: str
class Scene(BaseModel):
"""Scene information"""
id: str
name: str
class SceneListResponse(BaseModel):
"""Response for scene listing"""
scenes: List[Scene]
class SceneActivateResponse(BaseModel):
"""Response from scene activation"""
success: bool
scene_id: str
message: str
class Script(BaseModel):
"""Script information"""
id: str
name: str
class ScriptListResponse(BaseModel):
"""Response for script listing"""
scripts: List[Script]
class ScriptRunRequest(BaseModel):
"""Request to run a script"""
variables: Optional[Dict[str, Any]] = None
class ScriptRunResponse(BaseModel):
"""Response from script execution"""
success: bool
script_id: str
message: str
class Automation(BaseModel):
"""Automation information"""
id: str
name: str
enabled: bool
class AutomationListResponse(BaseModel):
"""Response for automation listing"""
automations: List[Automation]
class AutomationToggleRequest(BaseModel):
"""Request to toggle automation"""
enabled: bool
class AutomationToggleResponse(BaseModel):
"""Response from automation toggle"""
success: bool
automation_id: str
enabled: bool
message: str
class HistoryEntry(BaseModel):
"""Single history entry"""
state: str
timestamp: str
attributes: Dict[str, Any] = {}
class HistoryResponse(BaseModel):
"""Response for history query"""
entity_id: str
history: List[HistoryEntry]
class HealthResponse(BaseModel):
"""Health check response"""
status: str
connected: bool
platform: str
version: Optional[str] = None
error: Optional[str] = None
class ErrorResponse(BaseModel):
"""Standard error response"""
error: bool = True
code: str
message: str
class HousekeepingController(BaseController):
"""
Controller for home automation operations
Provides endpoints for:
- Device discovery and control
- Scene activation
- Script execution
- Automation management
- State history
"""
def __init__(self):
super().__init__(prefix="/housekeeping", tags=["Housekeeping"])
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.get(
"/health",
response_model=HealthResponse,
summary="Home automation health check"
)
async def get_health():
"""Check Home Assistant connection health"""
ha = get_homeassistant_client()
return await ha.health_check()
@router.get(
"/devices",
response_model=DeviceListResponse,
summary="List available devices"
)
async def list_devices(
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
area: Optional[str] = Query(None, description="Filter by area/room name")
):
"""List all available devices with optional filtering"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
excluded_domains = {
"zone", "person", "device_tracker", "sun", "weather",
"persistent_notification", "update", "binary_sensor", "sensor",
"conversation", "calendar", "button", "number", "select",
"text", "time", "date", "datetime", "image", "tts", "stt"
}
devices = []
for state in states:
entity_id = state.get("entity_id", "")
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
if entity_domain in excluded_domains:
continue
if domain and entity_domain != domain:
continue
device_area = state.get("attributes", {}).get("area_id")
if area and device_area and area.lower() not in device_area.lower():
continue
device = Device(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=device_area,
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
devices.append(device)
return DeviceListResponse(devices=devices)
except Exception as e:
logger.error(f"Failed to list devices: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/devices/{entity_id:path}",
response_model=DeviceDetailResponse,
responses={404: {"model": ErrorResponse}}
)
async def get_device(entity_id: str):
"""Get detailed state of a specific device"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(entity_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
entity_domain = entity_id.split(".")[0] if "." in entity_id else ""
return DeviceDetailResponse(
entity_id=entity_id,
name=state.get("attributes", {}).get("friendly_name", entity_id),
domain=entity_domain,
area=state.get("attributes", {}).get("area_id"),
state=state.get("state", "unknown"),
attributes=state.get("attributes", {}),
last_changed=state.get("last_changed")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/areas",
response_model=AreaListResponse,
summary="List areas/rooms"
)
async def list_areas():
"""List all configured areas/rooms in Home Assistant"""
ha = get_homeassistant_client()
try:
areas = await ha.get_areas()
return AreaListResponse(
areas=[Area(id=a["id"], name=a["name"]) for a in areas]
)
except Exception as e:
logger.error(f"Failed to list areas: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/devices/{entity_id:path}/control",
response_model=DeviceControlResponse,
responses={404: {"model": ErrorResponse}, 400: {"model": ErrorResponse}}
)
async def control_device(
entity_id: str,
request: DeviceControlRequest,
user: Dict = Depends(get_admin_user)
):
"""Control a device (turn on, turn off, toggle, or set attributes)"""
ha = get_homeassistant_client()
valid_actions = ["turn_on", "turn_off", "toggle"]
if request.action not in valid_actions:
raise HTTPException(
status_code=400,
detail={"error": True, "code": "INVALID_ACTION",
"message": f"Invalid action '{request.action}'. Must be one of: {', '.join(valid_actions)}"}
)
try:
current_state = await ha.get_state(entity_id)
if not current_state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "DEVICE_NOT_FOUND",
"message": f"Device {entity_id} not found"}
)
attributes = {}
if request.brightness is not None:
attributes["brightness"] = request.brightness
if request.color_temp is not None:
attributes["color_temp"] = request.color_temp
if request.rgb_color is not None:
attributes["rgb_color"] = request.rgb_color
extra_fields = request.model_dump(exclude={"action", "brightness", "color_temp", "rgb_color"})
for key, value in extra_fields.items():
if value is not None:
attributes[key] = value
if request.action == "turn_on":
await ha.turn_on(entity_id, **attributes)
elif request.action == "turn_off":
await ha.turn_off(entity_id)
else:
await ha.toggle(entity_id)
await asyncio.sleep(0.3)
new_state = await ha.get_state(entity_id)
logger.info(f"Device {entity_id} controlled: {request.action} by {user.get('preferred_username', 'unknown')}")
return DeviceControlResponse(
success=True,
entity_id=entity_id,
new_state=new_state.get("state") if new_state else None,
message=f"Device {request.action} successful"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to control device {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/scenes",
response_model=SceneListResponse,
summary="List available scenes"
)
async def list_scenes():
"""List all available scenes in Home Assistant"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scenes = [
Scene(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("scene.")
]
return SceneListResponse(scenes=scenes)
except Exception as e:
logger.error(f"Failed to list scenes: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scenes/{scene_id:path}/activate",
response_model=SceneActivateResponse,
responses={404: {"model": ErrorResponse}}
)
async def activate_scene(
scene_id: str,
user: Dict = Depends(get_admin_user)
):
"""Activate a scene"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(scene_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCENE_NOT_FOUND",
"message": f"Scene {scene_id} not found"}
)
await ha.activate_scene(scene_id)
logger.info(f"Scene {scene_id} activated by {user.get('preferred_username', 'unknown')}")
return SceneActivateResponse(
success=True,
scene_id=scene_id,
message="Scene activated"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to activate scene {scene_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/scripts",
response_model=ScriptListResponse,
summary="List available scripts"
)
async def list_scripts():
"""List all available scripts/sequences in Home Assistant"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
scripts = [
Script(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"])
)
for s in states
if s["entity_id"].startswith("script.")
]
return ScriptListResponse(scripts=scripts)
except Exception as e:
logger.error(f"Failed to list scripts: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/scripts/{script_id:path}/run",
response_model=ScriptRunResponse,
responses={404: {"model": ErrorResponse}}
)
async def run_script(
script_id: str,
request: Optional[ScriptRunRequest] = None,
user: Dict = Depends(get_admin_user)
):
"""Execute a script with optional variables"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(script_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "SCRIPT_NOT_FOUND",
"message": f"Script {script_id} not found"}
)
variables = request.variables if request else None
await ha.run_script(script_id, variables)
logger.info(f"Script {script_id} executed by {user.get('preferred_username', 'unknown')}")
return ScriptRunResponse(
success=True,
script_id=script_id,
message="Script executed"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to run script {script_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/automations",
response_model=AutomationListResponse,
summary="List automations"
)
async def list_automations():
"""List all automations with their enabled/disabled status"""
ha = get_homeassistant_client()
try:
states = await ha.get_states()
automations = [
Automation(
id=s["entity_id"],
name=s.get("attributes", {}).get("friendly_name", s["entity_id"]),
enabled=s.get("state") == "on"
)
for s in states
if s["entity_id"].startswith("automation.")
]
return AutomationListResponse(automations=automations)
except Exception as e:
logger.error(f"Failed to list automations: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.post(
"/automations/{automation_id:path}/toggle",
response_model=AutomationToggleResponse,
responses={404: {"model": ErrorResponse}}
)
async def toggle_automation(
automation_id: str,
request: AutomationToggleRequest,
user: Dict = Depends(get_admin_user)
):
"""Enable or disable an automation"""
ha = get_homeassistant_client()
try:
state = await ha.get_state(automation_id)
if not state:
raise HTTPException(
status_code=404,
detail={"error": True, "code": "AUTOMATION_NOT_FOUND",
"message": f"Automation {automation_id} not found"}
)
if request.enabled:
await ha.enable_automation(automation_id)
else:
await ha.disable_automation(automation_id)
logger.info(f"Automation {automation_id} {'enabled' if request.enabled else 'disabled'} by {user.get('preferred_username', 'unknown')}")
return AutomationToggleResponse(
success=True,
automation_id=automation_id,
enabled=request.enabled,
message=f"Automation {'enabled' if request.enabled else 'disabled'}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to toggle automation {automation_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
@router.get(
"/history",
response_model=HistoryResponse,
responses={400: {"model": ErrorResponse}}
)
async def get_history(
entity_id: str = Query(..., description="Entity ID to get history for"),
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
):
"""Get state history for a device"""
ha = get_homeassistant_client()
try:
history_data = await ha.get_history(entity_id, hours)
history_entries = []
if history_data and len(history_data) > 0:
for entry in history_data[0]:
history_entries.append(HistoryEntry(
state=entry.get("state", "unknown"),
timestamp=entry.get("last_changed", ""),
attributes=entry.get("attributes", {})
))
return HistoryResponse(
entity_id=entity_id,
history=history_entries
)
except Exception as e:
logger.error(f"Failed to get history for {entity_id}: {e}")
raise HTTPException(
status_code=500,
detail={"error": True, "code": "CONNECTION_ERROR", "message": str(e)}
)
return router
# Create controller instance
housekeeping_controller = HousekeepingController()
+8
View File
@@ -0,0 +1,8 @@
"""
Infrastructure Domain
Provides infrastructure management endpoints for Docker/Portainer and NPM.
"""
from src.domains.infrastructure.controller import infrastructure_controller
__all__ = ["infrastructure_controller"]
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
"""
Static Domain
Serves static files for widgets and other frontend assets.
"""
from src.domains.static.controller import static_controller
__all__ = ["static_controller"]
+116
View File
@@ -0,0 +1,116 @@
"""
Static Files Controller
Serves static files for widgets and other frontend assets.
"""
from fastapi import APIRouter
from fastapi.responses import FileResponse, HTMLResponse
from pathlib import Path
from src.shared.base import BaseController
from src.shared.logging import get_logger
logger = get_logger(__name__)
class StaticController(BaseController):
"""
Controller for serving static files
Provides endpoints for:
- Organizr widgets
- Other static assets
"""
def __init__(self):
super().__init__(prefix="/static", tags=["Static"])
# Static files are at the root of the project
self.static_dir = Path(__file__).parent.parent.parent.parent / "static"
def create_router(self) -> APIRouter:
"""Create and configure the router"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.get(
"/widgets/{filename}",
response_class=HTMLResponse,
summary="Get widget file"
)
async def get_widget(filename: str):
"""
Serve widget HTML files
Args:
filename: Widget filename (e.g., service-control.html)
Returns:
HTML file content
"""
widget_path = self.static_dir / "widgets" / filename
if not widget_path.exists():
return HTMLResponse(
content=f"<h1>404 - Widget not found</h1><p>{filename}</p>",
status_code=404
)
if not widget_path.is_file():
return HTMLResponse(
content=f"<h1>400 - Not a file</h1>",
status_code=400
)
# Security: Ensure the path is within the static directory
try:
widget_path.resolve().relative_to(self.static_dir.resolve())
except ValueError:
return HTMLResponse(
content=f"<h1>403 - Forbidden</h1>",
status_code=403
)
logger.info(f"Serving widget: {filename}")
return FileResponse(
widget_path,
media_type="text/html",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
@router.get(
"/widgets",
summary="List available widgets"
)
async def list_widgets():
"""
List all available widget files
Returns:
List of widget filenames
"""
widgets_dir = self.static_dir / "widgets"
if not widgets_dir.exists():
return {"widgets": [], "message": "Widgets directory not found"}
widgets = []
for file in widgets_dir.glob("*.html"):
widgets.append({
"name": file.name,
"url": f"/static/widgets/{file.name}",
"size": file.stat().st_size
})
return {
"widgets": widgets,
"count": len(widgets)
}
return router
# Create controller instance
static_controller = StaticController()
+16
View File
@@ -0,0 +1,16 @@
"""
Tools Domain
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",
"SystemStatsService",
"SystemStatsResponse",
]
+217
View File
@@ -0,0 +1,217 @@
"""
Tools Controller
Provides utility tool endpoints including:
- DNS lookups
- System stats
- Environment data (weather, forecast, sun times)
"""
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__)
class ToolsController(BaseController):
"""
Controller for utility tools
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"""
router = APIRouter(prefix=self.prefix, tags=self.tags)
@router.post(
"/dns/lookup",
response_model=DNSLookupResponse,
status_code=status.HTTP_200_OK,
summary="Perform DNS lookup",
description="""
Perform DNS lookups for various record types.
Uses dnspython for reliable DNS queries with support for multiple record types
and custom nameservers. Perfect for troubleshooting DNS issues and checking
domain configurations.
**Supported Record Types:**
- A: IPv4 address records
- AAAA: IPv6 address records
- MX: Mail exchange records
- TXT: Text records (SPF, DKIM, etc.)
- CNAME: Canonical name records
- NS: Nameserver records
- SOA: Start of authority records
- PTR: Pointer records (reverse DNS)
- CAA: Certification authority authorization
- SRV: Service records
**Features:**
- Custom nameserver support (e.g., 8.8.8.8, 1.1.1.1)
- Query time measurement
- Detailed error messages
**Rate Limiting:** None (internal network use only)
"""
)
async def dns_lookup(request: DNSLookupRequest) -> DNSLookupResponse:
"""
Perform DNS lookup for a domain
Args:
request: DNS lookup request with domain, record type, and optional nameserver
Returns:
DNS lookup results with records and metadata
Raises:
HTTPException: 400 for invalid queries, 500 for processing errors
"""
try:
logger.info(f"Received DNS lookup request for: {request.domain} ({request.record_type})")
result = await self.dns_service.lookup(request)
return result
except DNSQueryError as e:
logger.warning(f"DNS query error: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"DNS query failed: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error during DNS lookup: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
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
# Create controller instance
tools_controller = ToolsController()
+16
View File
@@ -0,0 +1,16 @@
"""
DNS Tools Module
Provides DNS lookup functionality.
"""
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse, DNSRecord
from src.domains.tools.dns.service import DNSService
from src.domains.tools.dns.exceptions import DNSQueryError
__all__ = [
"DNSLookupRequest",
"DNSLookupResponse",
"DNSRecord",
"DNSService",
"DNSQueryError",
]
+8
View File
@@ -0,0 +1,8 @@
"""
DNS Exceptions
"""
class DNSQueryError(Exception):
"""Raised when a DNS query fails"""
pass
+94
View File
@@ -0,0 +1,94 @@
"""
Pydantic schemas for DNS lookup module
"""
from pydantic import Field
from typing import Optional, List
from datetime import datetime
from src.shared.base import BaseSchema
class DNSLookupRequest(BaseSchema):
"""Request model for DNS lookup"""
domain: str = Field(
...,
description="The domain name to lookup",
examples=["example.com", "google.com"],
min_length=1,
max_length=255
)
record_type: str = Field(
default="A",
description="DNS record type to query (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR, CAA)",
examples=["A", "AAAA", "MX", "TXT", "CNAME"]
)
nameserver: Optional[str] = Field(
default=None,
description="Optional nameserver to use for the query (e.g., 8.8.8.8, 1.1.1.1)",
examples=["8.8.8.8", "1.1.1.1", "9.9.9.9"]
)
class DNSRecord(BaseSchema):
"""Single DNS record result"""
value: str = Field(
...,
description="The DNS record value"
)
ttl: Optional[int] = Field(
default=None,
description="Time to live in seconds"
)
priority: Optional[int] = Field(
default=None,
description="Priority (for MX records)"
)
class DNSLookupResponse(BaseSchema):
"""Response model for DNS lookup"""
domain: str = Field(
...,
description="The queried domain name"
)
record_type: str = Field(
...,
description="DNS record type queried"
)
records: List[DNSRecord] = Field(
...,
description="List of DNS records found"
)
nameserver_used: Optional[str] = Field(
default=None,
description="Nameserver used for the query"
)
query_time_ms: float = Field(
...,
description="Query execution time in milliseconds"
)
queried_at: datetime = Field(
...,
description="UTC timestamp when query was executed"
)
success: bool = Field(
...,
description="Whether the query was successful"
)
error_message: Optional[str] = Field(
default=None,
description="Error message if query failed"
)
+188
View File
@@ -0,0 +1,188 @@
"""
DNS Lookup Service
Provides DNS query functionality using dnspython library.
"""
import time
from datetime import datetime, timezone
from typing import Optional
import dns.resolver
import dns.exception
from src.shared.logging import get_logger
from src.domains.tools.dns.schemas import DNSLookupRequest, DNSLookupResponse, DNSRecord
from src.domains.tools.dns.exceptions import DNSQueryError
logger = get_logger(__name__)
class DNSService:
"""
Service for performing DNS lookups
Uses dnspython for reliable DNS queries with support for
various record types and custom nameservers.
"""
SUPPORTED_RECORD_TYPES = [
"A", "AAAA", "MX", "TXT", "CNAME", "NS", "SOA", "PTR", "CAA", "SRV"
]
def __init__(self):
"""Initialize DNS service"""
self.resolver = dns.resolver.Resolver()
self.resolver.timeout = 5.0
self.resolver.lifetime = 10.0
async def lookup(self, request: DNSLookupRequest) -> DNSLookupResponse:
"""
Perform DNS lookup for the specified domain and record type
Args:
request: DNS lookup request with domain, record type, and optional nameserver
Returns:
DNSLookupResponse with query results
Raises:
DNSQueryError: If the DNS query fails
"""
start_time = time.time()
record_type = request.record_type.upper()
if record_type not in self.SUPPORTED_RECORD_TYPES:
raise DNSQueryError(
f"Unsupported record type: {record_type}. "
f"Supported types: {', '.join(self.SUPPORTED_RECORD_TYPES)}"
)
resolver = dns.resolver.Resolver()
resolver.timeout = 5.0
resolver.lifetime = 10.0
nameserver_used = None
if request.nameserver:
resolver.nameservers = [request.nameserver]
nameserver_used = request.nameserver
logger.info(f"Using custom nameserver: {request.nameserver}")
else:
nameserver_used = resolver.nameservers[0] if resolver.nameservers else "system"
try:
logger.info(f"Performing DNS lookup: {request.domain} ({record_type})")
answers = resolver.resolve(request.domain, record_type)
records = []
for rdata in answers:
record = self._parse_record(rdata, record_type)
if record:
records.append(record)
query_time_ms = (time.time() - start_time) * 1000
logger.info(
f"DNS lookup successful: {request.domain} ({record_type}) - "
f"Found {len(records)} records in {query_time_ms:.2f}ms"
)
return DNSLookupResponse(
domain=request.domain,
record_type=record_type,
records=records,
nameserver_used=nameserver_used,
query_time_ms=round(query_time_ms, 2),
queried_at=datetime.now(timezone.utc),
success=True,
error_message=None
)
except dns.resolver.NXDOMAIN:
error_msg = f"Domain not found: {request.domain}"
logger.warning(error_msg)
return self._error_response(request, nameserver_used, start_time, error_msg)
except dns.resolver.NoAnswer:
error_msg = f"No {record_type} records found for {request.domain}"
logger.warning(error_msg)
return self._error_response(request, nameserver_used, start_time, error_msg)
except dns.resolver.Timeout:
error_msg = f"DNS query timeout for {request.domain}"
logger.error(error_msg)
return self._error_response(request, nameserver_used, start_time, error_msg)
except dns.exception.DNSException as e:
error_msg = f"DNS error: {str(e)}"
logger.error(f"DNS query failed for {request.domain}: {e}")
return self._error_response(request, nameserver_used, start_time, error_msg)
except Exception as e:
error_msg = f"Unexpected error: {str(e)}"
logger.error(f"Unexpected error during DNS lookup: {e}", exc_info=True)
return self._error_response(request, nameserver_used, start_time, error_msg)
def _parse_record(self, rdata, record_type: str) -> Optional[DNSRecord]:
"""Parse DNS record data into DNSRecord schema"""
try:
if record_type == "A" or record_type == "AAAA":
return DNSRecord(value=str(rdata), ttl=None)
elif record_type == "MX":
return DNSRecord(
value=str(rdata.exchange),
priority=rdata.preference,
ttl=None
)
elif record_type == "TXT":
txt_value = " ".join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings])
return DNSRecord(value=txt_value, ttl=None)
elif record_type in ["CNAME", "NS", "PTR"]:
return DNSRecord(value=str(rdata.target), ttl=None)
elif record_type == "SOA":
soa_value = f"mname={rdata.mname} rname={rdata.rname} serial={rdata.serial}"
return DNSRecord(value=soa_value, ttl=None)
elif record_type == "CAA":
caa_value = f"{rdata.flags} {rdata.tag.decode() if isinstance(rdata.tag, bytes) else rdata.tag} {rdata.value.decode() if isinstance(rdata.value, bytes) else rdata.value}"
return DNSRecord(value=caa_value, ttl=None)
elif record_type == "SRV":
srv_value = f"{rdata.target} port={rdata.port} priority={rdata.priority} weight={rdata.weight}"
return DNSRecord(
value=srv_value,
priority=rdata.priority,
ttl=None
)
else:
return DNSRecord(value=str(rdata), ttl=None)
except Exception as e:
logger.error(f"Failed to parse {record_type} record: {e}")
return None
def _error_response(
self,
request: DNSLookupRequest,
nameserver_used: Optional[str],
start_time: float,
error_message: str
) -> DNSLookupResponse:
"""Create an error response for failed DNS queries"""
query_time_ms = (time.time() - start_time) * 1000
return DNSLookupResponse(
domain=request.domain,
record_type=request.record_type.upper(),
records=[],
nameserver_used=nameserver_used,
query_time_ms=round(query_time_ms, 2),
queried_at=datetime.now(timezone.utc),
success=False,
error_message=error_message
)
+23
View File
@@ -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",
]
+185
View File
@@ -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"
)
+246
View File
@@ -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
+6
View File
@@ -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"]
+197
View File
@@ -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"
)
+190
View File
@@ -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)
+34 -80
View File
@@ -6,15 +6,19 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
from src.config import get_settings
from src.logging_config import setup_logging, get_logger
from src.models.ollama_client import get_ollama_client, close_ollama_client
from src.controllers.infrastructure_controller import infrastructure_controller
from src.controllers.tools_controller import tools_controller
from src.controllers.health_controller import health_controller
from src.controllers.static_controller import static_controller
from src.controllers.ai_controller import router as ai_router
from src.security import initialize_oidc
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
# Import domain controllers
from src.domains.health import health_controller
from src.domains.auth import auth_controller
from src.domains.tools import tools_controller
from src.domains.infrastructure import infrastructure_controller
from src.domains.housekeeping import housekeeping_controller
from src.domains.static import static_controller
from src.domains.dashboard import dashboard_controller
# Initialize settings
settings = get_settings()
@@ -37,16 +41,15 @@ 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")
# Check database connectivity
database = get_database()
db_healthy = await database.health_check()
if db_healthy:
logger.info("Database connection successful")
else:
logger.warning("✗ Ollama connection failed - AI features may not work")
logger.warning("Database connection failed - auth features may not work")
# Initialize security (OIDC authentication)
initialize_oidc(settings)
@@ -55,7 +58,7 @@ async def lifespan(app: FastAPI):
# Shutdown
logger.info("Shutting down application")
await close_ollama_client()
await database.close()
# Create FastAPI application
@@ -63,75 +66,24 @@ app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="""
Core Code API provides OpenAPI-compatible functions and AI orchestration for Open WebUI.
Core Code API - Infrastructure management and home automation API.
## Features
## Features
### OpenAI-Compatible API (v1)
- `/v1/chat/completions` - Chat completions with streaming support
- `/v1/models` - List available models
Compatible with OpenAI client libraries and Open WebUI.
- **Infrastructure Management** - Container and stack management via Portainer
- **Home Automation** - Device control via Home Assistant
- **Dashboard** - Quick links and widget management
- **Tools** - DNS lookup and utilities
### Conversation Memory (Phase 2)
- `/v1/conversations/{id}` - Get conversation history
- `/v1/conversations/{id}/search` - Semantic search within conversation
- `/v1/conversations/search` - Search across all conversations
- `/v1/conversations/{id}/stats` - Get conversation statistics
- `/v1/conversations/{id}/consolidate` - Manual consolidation
- `DELETE /v1/conversations/{id}` - Delete conversation
Multi-tier memory system:
- **Tier 1**: Fast in-memory buffer (last 10 turns)
- **Tier 2/3**: Unified Qdrant storage (persistent + semantic search)
### Infrastructure Management
**Read Endpoints:**
- `GET /infrastructure/health` - Check Portainer & NPM connectivity
- `GET /infrastructure/services` - List all deployed services
- `GET /infrastructure/services/{name}` - Get service details
- `GET /infrastructure/ports` - List allocated ports
- `GET /infrastructure/domains` - List configured domains
**Write Endpoints (Admin Only):**
- `POST /infrastructure/services` - Deploy new service from compose YAML
- `PUT /infrastructure/services/{name}` - Update existing service
- `DELETE /infrastructure/services/{name}` - Remove service and stack
- `POST /infrastructure/proxy` - Create proxy host with optional SSL
Automates infrastructure operations via Portainer and Nginx Proxy Manager APIs.
### Web Scraper
Intelligent web scraping with main content extraction.
Perfect for extracting articles, documentation, and blog posts for LLM consumption.
## Authentication
When OIDC authentication is enabled (oidc_enabled=true in config):
- Infrastructure write endpoints require authentication
- Use OAuth2/OIDC bearer token from Authentik
- Admin group membership required for infrastructure operations
## Integration
This API is designed to integrate with:
- **Open WebUI**: Direct OpenAI API compatibility
- **Open WebUI Functions**: Import via OpenAPI spec
- **Open WebUI Pipelines**: Use as data source
- **LangChain**: Compatible with standard HTTP tools
## Documentation
- **OpenAPI Spec**: `/openapi.json`
- **Swagger UI**: `/docs`
- **ReDoc**: `/redoc`
See `/docs` for the full API reference.
""",
docs_url="/docs",
redoc_url="/redoc",
redoc_url=None,
openapi_url="/openapi.json",
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
)
@@ -146,12 +98,14 @@ app.add_middleware(
)
# Include controller routers
# Include domain routers
app.include_router(health_controller.router) # / and /health
app.include_router(tools_controller.router) # /web-scraper/scrape
app.include_router(auth_controller.router) # /auth/*
app.include_router(tools_controller.router) # /tools/*
app.include_router(infrastructure_controller.router) # /infrastructure/*
app.include_router(housekeeping_controller.router) # /housekeeping/*
app.include_router(static_controller.router) # /static/*
app.include_router(ai_router) # /ai/*
app.include_router(dashboard_controller.router) # /dashboard/*
# Global exception handler
-128
View File
@@ -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)
-136
View File
@@ -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)
-223
View File
@@ -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
-32
View File
@@ -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")
+50
View File
@@ -0,0 +1,50 @@
"""
Shared utilities for Core-API
Contains common base classes, configuration, database, logging utilities,
and API clients used across all domains.
"""
from src.shared.config import get_settings, Settings
from src.shared.database import Base, get_database, get_async_session
from src.shared.logging import get_logger, setup_logging
from src.shared.base import BaseController, BaseSchema
from src.shared.security import initialize_oidc
# Re-export clients for convenience
from src.shared.clients import (
PortainerClient,
get_portainer_client,
NPMClient,
get_npm_client,
HomeAssistantClient,
get_homeassistant_client,
AuthentikClient,
get_authentik_client,
)
__all__ = [
# Config
"get_settings",
"Settings",
# Database
"Base",
"get_database",
"get_async_session",
# Logging
"get_logger",
"setup_logging",
# Base classes
"BaseController",
"BaseSchema",
# Security
"initialize_oidc",
# Clients
"PortainerClient",
"get_portainer_client",
"NPMClient",
"get_npm_client",
"HomeAssistantClient",
"get_homeassistant_client",
"AuthentikClient",
"get_authentik_client",
]
+65
View File
@@ -0,0 +1,65 @@
"""
Base classes for Core-API
Provides common base classes for controllers and schemas.
"""
from datetime import datetime
from typing import Any
from abc import ABC, abstractmethod
from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict
class BaseController(ABC):
"""
Base controller class with common functionality
All controllers should inherit from this class and implement
the create_router() method to define their endpoints.
"""
def __init__(self, prefix: str, tags: list[str]):
"""
Initialize base controller
Args:
prefix: URL prefix for this controller's routes
tags: OpenAPI tags for documentation grouping
"""
self.prefix = prefix
self.tags = tags
self._router = None
@abstractmethod
def create_router(self) -> APIRouter:
"""Create and configure the FastAPI router for this controller"""
pass
@property
def router(self) -> APIRouter:
"""Get the router instance, creating it if needed"""
if self._router is None:
self._router = self.create_router()
return self._router
class BaseSchema(BaseModel):
"""
Base Pydantic model with standardized configuration
All schemas should inherit from this to ensure consistent behavior.
"""
model_config = ConfigDict(
strict=False,
populate_by_name=True,
use_enum_values=True,
validate_assignment=True,
json_encoders={
datetime: lambda v: v.isoformat() if v else None
}
)
def dict_without_none(self) -> dict[str, Any]:
"""Return model as dict, excluding None values"""
return {k: v for k, v in self.model_dump().items() if v is not None}
+23
View File
@@ -0,0 +1,23 @@
"""
API Clients for Core-API
Provides HTTP/WebSocket clients for external infrastructure services.
"""
from src.shared.clients.portainer_client import PortainerClient, get_portainer_client
from src.shared.clients.npm_client import NPMClient, get_npm_client
from src.shared.clients.homeassistant_client import HomeAssistantClient, get_homeassistant_client
from src.shared.clients.authentik_client import AuthentikClient, get_authentik_client
from src.shared.clients.qdrant_client import QdrantReadClient, get_qdrant_client
__all__ = [
"PortainerClient",
"get_portainer_client",
"NPMClient",
"get_npm_client",
"HomeAssistantClient",
"get_homeassistant_client",
"AuthentikClient",
"get_authentik_client",
"QdrantReadClient",
"get_qdrant_client",
]
+302
View File
@@ -0,0 +1,302 @@
"""
Authentik API Client
Provides methods for interacting with Authentik Identity Provider API.
Used for managing applications, providers, and authentication flows.
"""
import httpx
from typing import Dict, List, Any, Optional
from functools import lru_cache
from src.shared.logging import get_logger
logger = get_logger(__name__)
class AuthentikClient:
"""Client for Authentik API operations"""
def __init__(self, base_url: str, api_token: str):
"""
Initialize Authentik client
Args:
base_url: Authentik base URL (e.g., http://authentik-server:9000)
api_token: API token for authentication
"""
self.base_url = base_url.rstrip('/')
self.api_token = api_token
self.client = httpx.AsyncClient(timeout=30.0)
async def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
"""Make authenticated API request using token auth"""
headers = kwargs.pop("headers", {})
headers["Authorization"] = f"Bearer {self.api_token}"
response = await self.client.request(
method,
f"{self.base_url}/api/v3/{endpoint.lstrip('/')}",
headers=headers,
**kwargs
)
if not response.is_success:
logger.error(f"API request failed: {response.status_code}")
logger.error(f"Response body: {response.text}")
response.raise_for_status()
return response.json()
async def health_check(self) -> bool:
"""Check if Authentik is accessible"""
try:
response = await self.client.get(f"{self.base_url}/-/health/live/")
return response.status_code == 200
except Exception as e:
logger.error(f"Authentik health check failed: {e}")
return False
async def create_oauth2_provider(
self,
name: str,
client_id: str,
redirect_uris: List[str],
authorization_flow_slug: str = "default-provider-authorization-implicit-consent",
signing_key: Optional[str] = None
) -> Dict:
"""
Create an OAuth2/OIDC provider
Args:
name: Provider name
client_id: OAuth2 client ID
redirect_uris: List of allowed redirect URIs
authorization_flow_slug: Authorization flow slug (will be resolved to UUID)
signing_key: Signing key UUID (defaults to auto-selected)
Returns:
Created provider data including client_secret
"""
# Get authorization flow UUID from slug
flows = await self.list_flows()
auth_flow_uuid = None
invalidation_flow_uuid = None
for flow in flows:
if flow.get("slug") == authorization_flow_slug:
auth_flow_uuid = flow.get("pk")
if flow.get("slug") == "default-provider-invalidation-flow":
invalidation_flow_uuid = flow.get("pk")
if not auth_flow_uuid:
raise ValueError(f"Authorization flow '{authorization_flow_slug}' not found")
if not invalidation_flow_uuid:
raise ValueError("Invalidation flow not found")
# Get signing key if not provided
if not signing_key:
keys = await self._request("GET", "crypto/certificatekeypairs/")
# Find the self-signed cert
for key in keys.get("results", []):
if "authentik" in key.get("name", "").lower():
signing_key = key.get("pk")
break
if not signing_key and keys.get("results"):
signing_key = keys["results"][0]["pk"]
# Format redirect URIs as objects with matching_mode
formatted_redirect_uris = [
{"url": uri, "matching_mode": "strict"}
for uri in redirect_uris
]
provider_data = {
"name": name,
"authorization_flow": auth_flow_uuid,
"invalidation_flow": invalidation_flow_uuid,
"client_type": "confidential",
"client_id": client_id,
"redirect_uris": formatted_redirect_uris,
"signing_key": signing_key,
"sub_mode": "hashed_user_id",
"include_claims_in_id_token": True,
"issuer_mode": "per_provider",
"access_token_validity": "minutes=60",
"refresh_token_validity": "days=30",
"property_mappings": [] # Will use default mappings
}
result = await self._request("POST", "providers/oauth2/", json=provider_data)
logger.info(f"Created OAuth2 provider: {name} (ID: {result.get('pk')})")
return result
async def create_application(
self,
name: str,
slug: str,
provider_pk: int,
launch_url: Optional[str] = None,
icon_url: Optional[str] = None
) -> Dict:
"""
Create an application
Args:
name: Application display name
slug: Application slug (URL-safe identifier)
provider_pk: Primary key of the provider to use
launch_url: Optional launch URL
icon_url: Optional icon URL
Returns:
Created application data
"""
app_data = {
"name": name,
"slug": slug,
"provider": provider_pk,
"meta_launch_url": launch_url or "",
"meta_icon": icon_url or "",
"policy_engine_mode": "any",
"open_in_new_tab": False
}
result = await self._request("POST", "core/applications/", json=app_data)
logger.info(f"Created application: {name} (slug: {slug})")
return result
async def get_provider_by_name(self, name: str) -> Optional[Dict]:
"""Get OAuth2 provider by name"""
providers = await self._request("GET", "providers/oauth2/", params={"name": name})
results = providers.get("results", [])
return results[0] if results else None
async def get_application_by_slug(self, slug: str) -> Optional[Dict]:
"""Get application by slug"""
apps = await self._request("GET", "core/applications/", params={"slug": slug})
results = apps.get("results", [])
return results[0] if results else None
async def list_flows(self) -> List[Dict]:
"""List all authentication flows"""
result = await self._request("GET", "flows/instances/")
return result.get("results", [])
async def create_proxy_provider(
self,
name: str,
external_host: str,
authorization_flow_slug: str = "default-provider-authorization-implicit-consent",
mode: str = "forward_single",
token_validity: int = 480 # 8 hours in minutes
) -> Dict:
"""
Create a Proxy Provider for forward authentication
Args:
name: Provider name
external_host: External URL (e.g., https://auth.schweitz.net)
authorization_flow_slug: Authorization flow slug
mode: Proxy mode (forward_single for forward auth)
token_validity: Token validity in minutes (default: 480 = 8 hours)
Returns:
Created provider data
"""
# Get authorization flow UUID from slug
flows = await self.list_flows()
auth_flow_uuid = None
invalidation_flow_uuid = None
for flow in flows:
if flow.get("slug") == authorization_flow_slug:
auth_flow_uuid = flow.get("pk")
if flow.get("slug") == "default-provider-invalidation-flow":
invalidation_flow_uuid = flow.get("pk")
if not auth_flow_uuid:
raise ValueError(f"Authorization flow '{authorization_flow_slug}' not found")
if not invalidation_flow_uuid:
raise ValueError("Invalidation flow not found")
provider_data = {
"name": name,
"authorization_flow": auth_flow_uuid,
"invalidation_flow": invalidation_flow_uuid,
"mode": mode,
"external_host": external_host,
"access_token_validity": f"minutes={token_validity}",
"refresh_token_validity": f"minutes={token_validity}",
"session_duration": f"seconds={token_validity * 60}",
"cookie_domain": "", # Will use the domain of each proxied site
"property_mappings": []
}
result = await self._request("POST", "providers/proxy/", json=provider_data)
logger.info(f"Created Proxy provider: {name} (ID: {result.get('pk')})")
return result
async def get_provider_by_name_proxy(self, name: str) -> Optional[Dict]:
"""Get Proxy provider by name"""
providers = await self._request("GET", "providers/proxy/", params={"name": name})
results = providers.get("results", [])
return results[0] if results else None
async def create_outpost(
self,
name: str,
type: str,
providers: List[int],
config: Optional[Dict] = None
) -> Dict:
"""
Create an Authentik Outpost
Args:
name: Outpost name
type: Outpost type (e.g., "proxy")
providers: List of provider PKs
config: Optional configuration overrides
Returns:
Created outpost data
"""
outpost_data = {
"name": name,
"type": type,
"providers": providers,
"config": config or {},
"service_connection": None # Will use local Docker
}
result = await self._request("POST", "outposts/instances/", json=outpost_data)
logger.info(f"Created outpost: {name} (ID: {result.get('pk')})")
return result
async def get_outpost_by_name(self, name: str) -> Optional[Dict]:
"""Get outpost by name"""
outposts = await self._request("GET", "outposts/instances/", params={"name": name})
results = outposts.get("results", [])
return results[0] if results else None
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
@lru_cache()
def get_authentik_client() -> AuthentikClient:
"""Get cached Authentik client instance"""
# Import credentials from gitignored module
try:
from src.credentials import AUTHENTIK_URL, AUTHENTIK_CORE_API_TOKEN
except ImportError:
# Fallback to environment variables if credentials.py doesn't exist
import os
AUTHENTIK_URL = os.getenv("AUTHENTIK_URL", "http://authentik-server:9000")
AUTHENTIK_CORE_API_TOKEN = os.getenv("AUTHENTIK_API_TOKEN", "")
return AuthentikClient(
base_url=AUTHENTIK_URL,
api_token=AUTHENTIK_CORE_API_TOKEN
)
+409
View File
@@ -0,0 +1,409 @@
"""
Home Assistant REST API Client
Provides interface to Home Assistant REST API for home automation control.
Uses long-lived access token authentication.
API Reference: https://developers.home-assistant.io/docs/api/rest/
"""
import httpx
import json
from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta, timezone
from src.shared.logging import get_logger
from src.shared.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class HomeAssistantClient:
"""
HTTP client for Home Assistant REST API
Uses long-lived access token authentication via Bearer token.
"""
def __init__(
self,
base_url: Optional[str] = None,
token: Optional[str] = None,
timeout: int = 30
):
"""
Initialize Home Assistant client
Args:
base_url: Home Assistant base URL (default from settings)
token: Long-lived access token (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.homeassistant_url).rstrip("/")
self.token = token or settings.homeassistant_token
self.timeout = timeout
if not self.token:
logger.warning("Home Assistant token not configured")
def _get_headers(self) -> Dict[str, str]:
"""Get request headers with Bearer token authentication"""
return {
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json"
}
# ========================================================================
# Health & Discovery
# ========================================================================
async def health_check(self) -> Dict[str, Any]:
"""
Check Home Assistant API connectivity and get version info
HA Endpoint: GET /api/
Returns:
Dict with connected status, platform name, and version
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/",
headers=self._get_headers()
)
if response.status_code == 200:
data = response.json()
return {
"status": "healthy",
"connected": True,
"platform": "home_assistant",
"version": data.get("version", "unknown")
}
return {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": f"HTTP {response.status_code}"
}
except Exception as e:
logger.error(f"Home Assistant health check failed: {e}")
return {
"status": "unhealthy",
"connected": False,
"platform": "home_assistant",
"error": str(e)
}
async def get_states(self) -> List[Dict[str, Any]]:
"""
Get all entity states
HA Endpoint: GET /api/states
Returns:
List of all entity states
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/states",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def get_state(self, entity_id: str) -> Optional[Dict[str, Any]]:
"""
Get state of a specific entity
HA Endpoint: GET /api/states/<entity_id>
Args:
entity_id: Entity ID (e.g., "light.living_room")
Returns:
Entity state dict or None if not found
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/states/{entity_id}",
headers=self._get_headers()
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
async def get_config(self) -> Dict[str, Any]:
"""
Get Home Assistant configuration (includes areas)
HA Endpoint: GET /api/config
Returns:
Configuration dict including components, location, etc.
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/config",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
# ========================================================================
# Device Control
# ========================================================================
async def call_service(
self,
domain: str,
service: str,
entity_id: Optional[str] = None,
service_data: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Call a Home Assistant service
HA Endpoint: POST /api/services/<domain>/<service>
Args:
domain: Service domain (e.g., "light", "switch", "scene")
service: Service name (e.g., "turn_on", "turn_off", "toggle")
entity_id: Target entity ID (optional for some services)
service_data: Additional service data/attributes
Returns:
List of changed states
"""
payload = service_data.copy() if service_data else {}
if entity_id:
payload["entity_id"] = entity_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/services/{domain}/{service}",
headers=self._get_headers(),
json=payload
)
response.raise_for_status()
return response.json()
async def turn_on(
self,
entity_id: str,
**attributes
) -> List[Dict[str, Any]]:
"""
Turn on an entity with optional attributes
Args:
entity_id: Entity ID (e.g., "light.living_room")
**attributes: Additional attributes (brightness, color_temp, etc.)
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="turn_on",
entity_id=entity_id,
service_data=attributes if attributes else None
)
async def turn_off(self, entity_id: str) -> List[Dict[str, Any]]:
"""
Turn off an entity
Args:
entity_id: Entity ID
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="turn_off",
entity_id=entity_id
)
async def toggle(self, entity_id: str) -> List[Dict[str, Any]]:
"""
Toggle an entity
Args:
entity_id: Entity ID
Returns:
List of changed states
"""
domain = entity_id.split(".")[0]
return await self.call_service(
domain=domain,
service="toggle",
entity_id=entity_id
)
# ========================================================================
# Scenes
# ========================================================================
async def activate_scene(self, scene_id: str) -> List[Dict[str, Any]]:
"""
Activate a scene
Args:
scene_id: Scene entity ID (e.g., "scene.movie_night")
Returns:
List of changed states
"""
return await self.call_service(
domain="scene",
service="turn_on",
entity_id=scene_id
)
# ========================================================================
# Scripts
# ========================================================================
async def run_script(
self,
script_id: str,
variables: Optional[Dict[str, Any]] = None
) -> List[Dict[str, Any]]:
"""
Execute a script with optional variables
Args:
script_id: Script entity ID (e.g., "script.bedtime_routine")
variables: Script variables
Returns:
List of changed states
"""
service_data = {"variables": variables} if variables else None
return await self.call_service(
domain="script",
service="turn_on",
entity_id=script_id,
service_data=service_data
)
# ========================================================================
# Automations
# ========================================================================
async def enable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
"""
Enable an automation
Args:
automation_id: Automation entity ID
Returns:
List of changed states
"""
return await self.call_service(
domain="automation",
service="turn_on",
entity_id=automation_id
)
async def disable_automation(self, automation_id: str) -> List[Dict[str, Any]]:
"""
Disable an automation
Args:
automation_id: Automation entity ID
Returns:
List of changed states
"""
return await self.call_service(
domain="automation",
service="turn_off",
entity_id=automation_id
)
# ========================================================================
# History
# ========================================================================
async def get_history(
self,
entity_id: str,
hours: int = 24
) -> List[List[Dict[str, Any]]]:
"""
Get state history for an entity
HA Endpoint: GET /api/history/period/<timestamp>
Args:
entity_id: Entity ID to get history for
hours: Number of hours of history (default 24)
Returns:
List of state history entries
"""
start_time = datetime.now(timezone.utc) - timedelta(hours=hours)
timestamp = start_time.isoformat()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/history/period/{timestamp}",
headers=self._get_headers(),
params={
"filter_entity_id": entity_id,
"minimal_response": "true"
}
)
response.raise_for_status()
return response.json()
# ========================================================================
# Areas (via template API)
# ========================================================================
async def get_areas(self) -> List[Dict[str, str]]:
"""
Get all areas/rooms
Note: The REST API doesn't have a direct areas endpoint.
This uses the template API to render area data.
HA Endpoint: POST /api/template
Returns:
List of area dicts with id and name
"""
template = """
{% set areas_list = [] %}
{% for area in areas() %}
{% set areas_list = areas_list + [{"id": area, "name": area_name(area)}] %}
{% endfor %}
{{ areas_list | tojson }}
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/template",
headers=self._get_headers(),
json={"template": template}
)
response.raise_for_status()
# Response is rendered template as string
return json.loads(response.text)
# Singleton instance
_homeassistant_client: Optional[HomeAssistantClient] = None
def get_homeassistant_client() -> HomeAssistantClient:
"""Get singleton Home Assistant client instance"""
global _homeassistant_client
if _homeassistant_client is None:
_homeassistant_client = HomeAssistantClient()
return _homeassistant_client
+383
View File
@@ -0,0 +1,383 @@
"""
Nginx Proxy Manager API Client
Provides interface to NPM REST API for proxy host and SSL certificate management.
"""
import httpx
from typing import Optional, Dict, List, Any
from datetime import datetime, timedelta
from src.shared.logging import get_logger
from src.shared.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class NPMClient:
"""
HTTP client for Nginx Proxy Manager API
Uses JWT Bearer token authentication with automatic token refresh.
Tokens expire after ~24 hours.
"""
def __init__(
self,
base_url: Optional[str] = None,
email: Optional[str] = None,
password: Optional[str] = None,
timeout: int = 30
):
"""
Initialize NPM client
Args:
base_url: NPM base URL (default from settings)
email: NPM admin email (default from settings)
password: NPM admin password (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.npm_url).rstrip("/")
self.email = email or settings.npm_email
self.password = password or settings.npm_password
self.timeout = timeout
self._token: Optional[str] = None
self._token_expires: Optional[datetime] = None
if not self.email or not self.password:
logger.warning("NPM credentials not configured")
async def _ensure_token(self):
"""Ensure we have a valid token, refresh if needed"""
if self._token and self._token_expires:
# If token expires in less than 1 hour, refresh it
if datetime.now() + timedelta(hours=1) < self._token_expires:
return
# Get new token
await self._refresh_token()
async def _refresh_token(self):
"""Get a new authentication token"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/tokens",
json={
"identity": self.email,
"secret": self.password
}
)
response.raise_for_status()
data = response.json()
self._token = data.get("token")
# Assume 23-hour expiration to be safe
self._token_expires = datetime.now() + timedelta(hours=23)
logger.info("NPM token refreshed successfully")
except Exception as e:
logger.error(f"Failed to refresh NPM token: {e}")
raise
def _get_headers(self) -> Dict[str, str]:
"""Get request headers with authentication"""
if not self._token:
raise RuntimeError("No NPM token available. Call _ensure_token() first.")
return {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json"
}
async def health_check(self) -> bool:
"""
Check if NPM API is accessible
Returns:
True if accessible, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client:
response = await client.get(f"{self.base_url}/api")
# Accept any successful response (2xx) or redirect (3xx) as healthy
# A redirect indicates the service is up and responding
return 200 <= response.status_code < 400
except Exception as e:
logger.error(f"NPM health check failed: {e}")
return False
async def get_proxy_hosts(self) -> List[Dict[str, Any]]:
"""
List all proxy hosts
Returns:
List of proxy host configurations
"""
await self._ensure_token()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/nginx/proxy-hosts",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def get_proxy_host(self, host_id: int) -> Dict[str, Any]:
"""
Get details of a specific proxy host
Args:
host_id: Proxy host identifier
Returns:
Proxy host configuration
"""
await self._ensure_token()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/nginx/proxy-hosts/{host_id}",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def create_proxy_host(
self,
domain_names: List[str],
forward_host: str,
forward_port: int,
forward_scheme: str = "http",
certificate_id: int = 0,
ssl_forced: bool = False,
block_exploits: bool = True,
caching_enabled: bool = True,
websocket_upgrade: bool = True,
http2_support: bool = True,
hsts_enabled: bool = True,
advanced_config: str = ""
) -> Dict[str, Any]:
"""
Create a new proxy host
Args:
domain_names: List of domain names for this proxy
forward_host: Target host to proxy to
forward_port: Target port to proxy to
forward_scheme: http or https
certificate_id: SSL certificate ID (0 for none)
ssl_forced: Force HTTPS redirect
block_exploits: Enable exploit blocking
caching_enabled: Enable response caching
websocket_upgrade: Allow WebSocket upgrades
http2_support: Enable HTTP/2
hsts_enabled: Enable HSTS headers
advanced_config: Custom nginx configuration
Returns:
Created proxy host details
"""
await self._ensure_token()
payload = {
"domain_names": domain_names,
"forward_scheme": forward_scheme,
"forward_host": forward_host,
"forward_port": forward_port,
"certificate_id": certificate_id,
"ssl_forced": ssl_forced,
"block_exploits": block_exploits,
"caching_enabled": caching_enabled,
"allow_websocket_upgrade": websocket_upgrade,
"http2_support": http2_support,
"hsts_enabled": hsts_enabled,
"hsts_subdomains": False,
"advanced_config": advanced_config,
"access_list_id": 0,
"meta": {}
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/nginx/proxy-hosts",
headers=self._get_headers(),
json=payload
)
response.raise_for_status()
return response.json()
async def update_proxy_host(
self,
proxy_id: int,
config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Update an existing proxy host configuration
Args:
proxy_id: Proxy host ID to update
config: Full proxy host configuration (get from get_proxy_host, modify, then update)
Returns:
Updated proxy host details
"""
await self._ensure_token()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/nginx/proxy-hosts/{proxy_id}",
headers=self._get_headers(),
json=config
)
if not response.is_success:
logger.error(f"Update failed: {response.status_code}")
logger.error(f"Response: {response.text}")
response.raise_for_status()
return response.json()
async def enable_authentik_forward_auth(
self,
proxy_id: int,
authentik_url: str = "http://authentik-server:9000"
) -> Dict[str, Any]:
"""
Enable Authentik forward authentication on a proxy host
Args:
proxy_id: Proxy host ID to update
authentik_url: Authentik server URL (default: http://authentik-server:9000)
Returns:
Updated proxy host details
"""
# Get current config
proxy_host = await self.get_proxy_host(proxy_id)
# Authentik forward auth configuration
auth_config = f"""# Authentik Forward Authentication
# Send authentication requests to Authentik
auth_request /outpost.goauthentik.io/auth/nginx;
# Preserve authentication cookies
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
# Get user information from Authentik
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
# Pass user info to backend
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;
# On authentication failure, redirect to Authentik login
error_page 401 = @authentik_proxy_signin;
location @authentik_proxy_signin {{
internal;
add_header Set-Cookie $auth_cookie;
return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}}
# Authentik authentication endpoint
location /outpost.goauthentik.io {{
proxy_pass {authentik_url}/outpost.goauthentik.io;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
}}
"""
# Update the advanced config
proxy_host["advanced_config"] = auth_config
# Remove read-only fields that NPM doesn't accept in updates
readonly_fields = [
"id", "created_on", "modified_on", "owner", "owner_user_id",
"certificate", "use_default_location", "ipv6", "meta", "nginx_online",
"nginx_err", "access_list", "certificate_id"
]
clean_config = {k: v for k, v in proxy_host.items() if k not in readonly_fields}
# Ensure locations is an array (required field)
if "locations" not in clean_config or clean_config["locations"] is None:
clean_config["locations"] = []
# Update the proxy host
return await self.update_proxy_host(proxy_id, clean_config)
async def get_certificates(self) -> List[Dict[str, Any]]:
"""
List all SSL certificates
Returns:
List of certificate details
"""
await self._ensure_token()
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/nginx/certificates",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def create_certificate(
self,
domain_names: List[str],
provider: str = "letsencrypt"
) -> Dict[str, Any]:
"""
Request a new SSL certificate from Let's Encrypt
Args:
domain_names: List of domains for the certificate
provider: Certificate provider (default: letsencrypt)
Returns:
Certificate details
"""
await self._ensure_token()
payload = {
"provider": provider,
"domain_names": domain_names,
"meta": {
"dns_challenge": False
}
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/nginx/certificates",
headers=self._get_headers(),
json=payload
)
response.raise_for_status()
return response.json()
# Singleton instance
_npm_client: Optional[NPMClient] = None
def get_npm_client() -> NPMClient:
"""Get singleton NPM client instance"""
global _npm_client
if _npm_client is None:
_npm_client = NPMClient()
return _npm_client
+505
View File
@@ -0,0 +1,505 @@
"""
Portainer API Client
Provides interface to Portainer REST API for stack and container management.
Includes fallback to Docker socket for containers not managed by Portainer.
"""
import httpx
from typing import Optional, Dict, List, Any
from src.shared.logging import get_logger
from src.shared.config import get_settings
logger = get_logger(__name__)
settings = get_settings()
class PortainerClient:
"""
HTTP client for Portainer API
Uses access token authentication (X-API-Key header)
for long-lived API access without session management.
"""
def __init__(
self,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
timeout: int = 30
):
"""
Initialize Portainer client
Args:
base_url: Portainer base URL (default from settings)
api_key: Portainer API access token (default from settings)
timeout: Request timeout in seconds
"""
self.base_url = (base_url or settings.portainer_url).rstrip("/")
self.api_key = api_key or settings.portainer_api_key
self.timeout = timeout
if not self.api_key:
logger.warning("Portainer API key not configured")
def _get_headers(self) -> Dict[str, str]:
"""Get request headers with authentication"""
return {
"X-API-Key": self.api_key,
"Content-Type": "application/json"
}
async def health_check(self) -> bool:
"""
Check if Portainer API is accessible
Returns:
True if accessible, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/api/status")
return response.status_code == 200
except Exception as e:
logger.error(f"Portainer health check failed: {e}")
return False
async def get_endpoints(self) -> List[Dict[str, Any]]:
"""
List all Portainer endpoints (Docker environments)
Returns:
List of endpoint configurations
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/endpoints",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def get_stacks(self, endpoint_id: Optional[int] = None) -> List[Dict[str, Any]]:
"""
List all stacks
Args:
endpoint_id: Filter by specific endpoint (optional)
Returns:
List of stack configurations
"""
params = {}
if endpoint_id:
params["endpointId"] = endpoint_id
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/stacks",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
return response.json()
async def get_stack(self, stack_id: int) -> Dict[str, Any]:
"""
Get details of a specific stack
Args:
stack_id: Stack identifier
Returns:
Stack configuration details
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def create_stack(
self,
name: str,
stack_file_content: str,
endpoint_id: int
) -> Dict[str, Any]:
"""
Create a new stack from compose file content
Args:
name: Stack name
stack_file_content: Docker Compose YAML content
endpoint_id: Portainer endpoint to deploy to
Returns:
Created stack details
"""
payload = {
"name": name,
"stackFileContent": stack_file_content
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/stacks/create/standalone/string",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def update_stack(
self,
stack_id: int,
stack_file_content: str,
endpoint_id: int,
prune: bool = False,
pull_image: bool = False
) -> Dict[str, Any]:
"""
Update an existing stack
Args:
stack_id: Stack identifier
stack_file_content: New Docker Compose YAML content
endpoint_id: Portainer endpoint
prune: Remove services no longer defined
pull_image: Pull latest images before deployment
Returns:
Updated stack details
"""
payload = {
"stackFileContent": stack_file_content,
"prune": prune,
"pullImage": pull_image
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def delete_stack(self, stack_id: int, endpoint_id: int) -> bool:
"""
Delete a stack
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id}
)
response.raise_for_status()
return True
async def get_stack_file(self, stack_id: int) -> str:
"""
Get the compose file content for a stack
Args:
stack_id: Stack identifier
Returns:
Docker Compose YAML content as string
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/stacks/{stack_id}/file",
headers=self._get_headers()
)
response.raise_for_status()
data = response.json()
return data.get("StackFileContent", "")
async def redeploy_stack(
self,
stack_id: int,
endpoint_id: int,
pull_image: bool = False
) -> Dict[str, Any]:
"""
Redeploy a stack with its current configuration
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
pull_image: Pull latest images before deployment
Returns:
Updated stack details
"""
# Get current stack file content
stack_content = await self.get_stack_file(stack_id)
# Get current stack to preserve env vars
stack = await self.get_stack(stack_id)
env_vars = stack.get("Env", [])
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": pull_image
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def update_stack_env(
self,
stack_id: int,
endpoint_id: int,
env_vars: List[Dict[str, str]]
) -> Dict[str, Any]:
"""
Update stack environment variables
Args:
stack_id: Stack identifier
endpoint_id: Portainer endpoint
env_vars: List of {"name": "VAR_NAME", "value": "var_value"} dicts
Returns:
Updated stack details
"""
# Get current stack file content (required for update)
stack_content = await self.get_stack_file(stack_id)
payload = {
"stackFileContent": stack_content,
"env": env_vars,
"prune": False,
"pullImage": False
}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.put(
f"{self.base_url}/api/stacks/{stack_id}",
headers=self._get_headers(),
params={"endpointId": endpoint_id},
json=payload
)
response.raise_for_status()
return response.json()
async def delete_container(
self,
endpoint_id: int,
container_id: str,
force: bool = False
) -> bool:
"""
Delete a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
force: Force remove running container
Returns:
True if successful
"""
params = {"force": "true" if force else "false"}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.delete(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
logger.info(f"Deleted container {container_id}")
return True
async def restart_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Restart a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/restart",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Restarted container {container_id}")
return True
async def get_containers(self, endpoint_id: int, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers on a specific endpoint
Args:
endpoint_id: Portainer endpoint identifier
all_containers: Include stopped containers (default: True)
Returns:
List of container details
"""
params = {"all": 1 if all_containers else 0}
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/json",
headers=self._get_headers(),
params=params
)
response.raise_for_status()
return response.json()
async def get_container(self, endpoint_id: int, container_id: str) -> Dict[str, Any]:
"""
Get detailed information about a specific container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
Container details including network and port information
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/json",
headers=self._get_headers()
)
response.raise_for_status()
return response.json()
async def stop_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Stop a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/stop",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Stopped container {container_id}")
return True
async def start_container(self, endpoint_id: int, container_id: str) -> bool:
"""
Start a container
Args:
endpoint_id: Portainer endpoint identifier
container_id: Container ID or name
Returns:
True if successful
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/endpoints/{endpoint_id}/docker/containers/{container_id}/start",
headers=self._get_headers()
)
response.raise_for_status()
logger.info(f"Started container {container_id}")
return True
# ========================================================================
# Helper methods for agent tools (auto-detect endpoint)
# ========================================================================
async def list_containers(self, all_containers: bool = True) -> List[Dict[str, Any]]:
"""
List containers using auto-detected endpoint
This is a convenience wrapper that automatically uses the first/default endpoint.
Args:
all_containers: Include stopped containers (default: True)
Returns:
List of container details
"""
endpoints = await self.get_endpoints()
if not endpoints:
raise RuntimeError("No Portainer endpoints available")
endpoint_id = endpoints[0]["Id"]
return await self.get_containers(endpoint_id, all_containers)
async def inspect_container(self, container_name: str) -> Optional[Dict[str, Any]]:
"""
Inspect a container by name using auto-detected endpoint
This is a convenience wrapper that automatically uses the first/default endpoint.
Args:
container_name: Container name (e.g., "jellyfin", "ollama")
Returns:
Container details or None if not found
"""
endpoints = await self.get_endpoints()
if not endpoints:
raise RuntimeError("No Portainer endpoints available")
endpoint_id = endpoints[0]["Id"]
# List all containers to find the one matching the name
all_containers = await self.get_containers(endpoint_id, all_containers=True)
for container in all_containers:
# Container names come as array like ['/jellyfin']
names = container.get('Names', [])
for name in names:
clean_name = name.lstrip('/')
if clean_name == container_name or clean_name.lower() == container_name.lower():
# Get detailed info using container ID
container_id = container['Id']
return await self.get_container(endpoint_id, container_id)
return None
# Singleton instance
_portainer_client: Optional[PortainerClient] = None
def get_portainer_client() -> PortainerClient:
"""Get singleton Portainer client instance"""
global _portainer_client
if _portainer_client is None:
_portainer_client = PortainerClient()
return _portainer_client
+258
View File
@@ -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
+116
View File
@@ -0,0 +1,116 @@
"""
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.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 - 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] = ["*"]
# Logging
log_level: str = "DEBUG"
# 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"
# Search Configuration
search_provider: str = "searxng"
searxng_url: str # Required - set SEARXNG_URL in .env
# Infrastructure Management (Portainer)
portainer_url: str # Required
portainer_api_key: str # Required
# Infrastructure Management (Nginx Proxy Manager)
npm_url: str # Required
npm_email: str # Required
npm_password: str # Required
# Home Assistant Configuration
homeassistant_url: str # Required
homeassistant_token: str # Required
homeassistant_timeout: int = 30
# PostgreSQL Database
postgres_host: str # Required
postgres_user: str = "core_api"
postgres_password: str # Required
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
# 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 = ""
class Config:
env_file = ".env"
case_sensitive = False
extra = "ignore"
@lru_cache()
def get_settings() -> Settings:
"""Cached settings instance"""
return Settings()
+135
View File
@@ -0,0 +1,135 @@
"""
Database Connection Module
Provides async PostgreSQL connectivity using SQLAlchemy 2.0 with asyncpg driver.
"""
from typing import AsyncGenerator, Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncSession,
AsyncEngine,
create_async_engine,
async_sessionmaker,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import NullPool
from src.shared.config import get_settings
from src.shared.logging import get_logger
logger = get_logger(__name__)
settings = get_settings()
class Base(DeclarativeBase):
"""
SQLAlchemy declarative base for all models
All database models should inherit from this class.
"""
pass
class Database:
"""
Async database connection manager
Provides async engine and session factory for PostgreSQL connections.
"""
def __init__(self, database_url: Optional[str] = None):
"""Initialize database connection manager"""
url = database_url or settings.database_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
self._url = url
self._engine: Optional[AsyncEngine] = None
self._session_factory: Optional[async_sessionmaker[AsyncSession]] = None
@property
def engine(self) -> AsyncEngine:
"""Get or create the async database engine"""
if self._engine is None:
self._engine = create_async_engine(
self._url,
echo=settings.debug,
poolclass=NullPool,
)
logger.info(f"Database engine created for {self._url.split('@')[-1]}")
return self._engine
@property
def session_factory(self) -> async_sessionmaker[AsyncSession]:
"""Get or create the async session factory"""
if self._session_factory is None:
self._session_factory = async_sessionmaker(
bind=self.engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
return self._session_factory
async def create_tables(self) -> None:
"""Create all database tables (dev/testing only)"""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables created")
async def drop_tables(self) -> None:
"""Drop all database tables (WARNING: destroys data)"""
async with self.engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
logger.warning("Database tables dropped")
async def health_check(self) -> bool:
"""Check if database connection is healthy"""
try:
async with self.session_factory() as session:
await session.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"Database health check failed: {e}")
return False
async def close(self) -> None:
"""Close database connections"""
if self._engine is not None:
await self._engine.dispose()
self._engine = None
self._session_factory = None
logger.info("Database connections closed")
# Singleton instance
_database: Optional[Database] = None
def get_database() -> Database:
"""Get singleton database instance"""
global _database
if _database is None:
_database = Database()
return _database
async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
"""
FastAPI dependency for database sessions
Usage:
@router.get("/items")
async def get_items(session: AsyncSession = Depends(get_async_session)):
result = await session.execute(select(Item))
return result.scalars().all()
"""
database = get_database()
async with database.session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+37
View File
@@ -0,0 +1,37 @@
"""
Logging configuration for Core Code API
"""
import logging
import sys
from pathlib import Path
def setup_logging(log_level: str = "INFO") -> None:
"""
Configure logging for the application
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
"""
log_dir = Path("logs")
log_dir.mkdir(exist_ok=True)
logging.basicConfig(
level=getattr(logging, log_level.upper()),
format="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(log_dir / "app.log", encoding="utf-8")
]
)
# Set specific log levels for third-party libraries
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
def get_logger(name: str) -> logging.Logger:
"""Get a logger instance"""
return logging.getLogger(name)
+39
View File
@@ -0,0 +1,39 @@
"""
Security initialization module
Handles OIDC configuration and authentication setup
"""
from src.shared.config import Settings
from src.shared.logging import get_logger
logger = get_logger(__name__)
def initialize_oidc(settings: Settings) -> None:
"""
Initialize OIDC authentication configuration
Args:
settings: Application settings containing OIDC configuration
"""
# Import here to avoid circular imports
# 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
auth_oidc_config.configure(
enabled=settings.oidc_enabled,
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 (issuers: {settings.oidc_issuers})")
else:
logger.info("OIDC authentication disabled - API is publicly accessible")
-13
View File
@@ -1,13 +0,0 @@
"""
Web scraper module for extracting content from websites
"""
from src.web_scraper.router import router
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
__all__ = [
"router",
"WebScraperRequest",
"WebScraperResponse",
"WebScraperService",
]
-32
View File
@@ -1,32 +0,0 @@
"""
Configuration for web scraper module
"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class WebScraperSettings(BaseSettings):
"""Web scraper specific settings"""
# HTTP client configuration
request_timeout: int = 30
max_redirects: int = 5
user_agent: str = "Mozilla/5.0 (compatible; CoreCode/1.0)"
# Content extraction
default_max_length: int = 10000
max_links_to_extract: int = 50
# Rate limiting (future use)
rate_limit_enabled: bool = False
requests_per_minute: int = 60
class Config:
env_prefix = "WEB_SCRAPER_"
case_sensitive = False
@lru_cache()
def get_web_scraper_settings() -> WebScraperSettings:
"""Cached web scraper settings instance"""
return WebScraperSettings()
-18
View File
@@ -1,18 +0,0 @@
"""
Custom exceptions for web scraper module
"""
class WebScraperException(Exception):
"""Base exception for web scraper module"""
pass
class FetchError(WebScraperException):
"""Raised when URL fetch fails"""
pass
class ScrapingError(WebScraperException):
"""Raised when content extraction fails"""
pass
-79
View File
@@ -1,79 +0,0 @@
"""
API routes for web scraper module
"""
from fastapi import APIRouter, HTTPException, status
from src.logging_config import get_logger
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.service import WebScraperService
from src.web_scraper.exceptions import FetchError, ScrapingError
logger = get_logger(__name__)
router = APIRouter(
prefix="/web-scraper",
tags=["Web Scraper"]
)
# Initialize service (could be dependency injected for testing)
scraper_service = WebScraperService()
@router.post(
"/scrape",
response_model=WebScraperResponse,
status_code=status.HTTP_200_OK,
summary="Scrape website content",
description="""
Scrape and extract main content from a website.
Uses trafilatura for intelligent content extraction (articles, blog posts, documentation),
with BeautifulSoup as fallback. Perfect for feeding webpage content to LLMs.
**Features:**
- Intelligent main content extraction
- Removes navigation, ads, footers
- Optional link extraction
- Configurable content length limits
**Rate Limiting:** None (internal network use only)
"""
)
async def scrape_website(request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape a website and extract its main content
Args:
request: Scraping request with URL and options
Returns:
Extracted content with metadata
Raises:
HTTPException: 400 for fetch errors, 500 for processing errors
"""
try:
logger.info(f"Received scrape request for: {request.url}")
result = await scraper_service.scrape_url(request)
return result
except FetchError as e:
logger.warning(f"Fetch failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to fetch URL: {str(e)}"
)
except ScrapingError as e:
logger.error(f"Scraping failed: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to extract content: {str(e)}"
)
except Exception as e:
logger.error(f"Unexpected error: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred"
)
-69
View File
@@ -1,69 +0,0 @@
"""
Pydantic schemas for web scraper module
"""
from pydantic import HttpUrl, Field
from typing import Optional
from datetime import datetime
from src.base_schema import BaseSchema
class WebScraperRequest(BaseSchema):
"""Request model for web scraping"""
url: HttpUrl = Field(
...,
description="The URL to scrape",
examples=["https://example.com/article"]
)
extract_main_content: bool = Field(
default=True,
description="Use intelligent content extraction (trafilatura) vs raw HTML parsing"
)
include_links: bool = Field(
default=False,
description="Include list of links found on the page"
)
max_length: Optional[int] = Field(
default=10000,
ge=100,
le=100000,
description="Maximum content length to return (100-100000 chars)"
)
class WebScraperResponse(BaseSchema):
"""Response model for web scraping"""
url: str = Field(
...,
description="The scraped URL"
)
title: Optional[str] = Field(
default=None,
description="Page title extracted from <title> tag"
)
content: str = Field(
...,
description="Extracted page content"
)
extracted_at: datetime = Field(
...,
description="UTC timestamp when content was extracted"
)
content_length: int = Field(
...,
ge=0,
description="Length of extracted content in characters"
)
links: Optional[list[str]] = Field(
default=None,
description="List of HTTP(S) links found on the page (max 50)"
)
-213
View File
@@ -1,213 +0,0 @@
"""
Business logic for web scraper module
"""
import httpx
from bs4 import BeautifulSoup
import trafilatura
from datetime import datetime, timezone
from typing import Optional
from src.logging_config import get_logger
from src.web_scraper.config import get_web_scraper_settings
from src.web_scraper.schemas import WebScraperRequest, WebScraperResponse
from src.web_scraper.exceptions import ScrapingError, FetchError
logger = get_logger(__name__)
class WebScraperService:
"""Service class for web scraping operations"""
def __init__(self):
self.settings = get_web_scraper_settings()
async def scrape_url(self, request: WebScraperRequest) -> WebScraperResponse:
"""
Scrape and extract content from a URL
Args:
request: Scraping request parameters
Returns:
Extracted content with metadata
Raises:
FetchError: If URL cannot be fetched
ScrapingError: If content extraction fails
"""
url_str = str(request.url)
logger.info(f"Starting scrape for URL: {url_str}")
try:
# Fetch the webpage
html_content = await self._fetch_url(url_str)
# Extract content based on settings
if request.extract_main_content:
content = self._extract_main_content(html_content, request.include_links)
else:
content = self._extract_basic_content(html_content)
# Extract metadata
title = self._extract_title(html_content)
links = self._extract_links(html_content) if request.include_links else None
# Clean and truncate content
content = self._clean_content(content)
if request.max_length and len(content) > request.max_length:
content = content[:request.max_length] + "\n\n[Content truncated...]"
logger.debug(f"Content truncated to {request.max_length} characters")
logger.info(f"Successfully scraped {len(content)} characters from {url_str}")
return WebScraperResponse(
url=url_str,
title=title,
content=content,
extracted_at=datetime.now(timezone.utc),
content_length=len(content),
links=links
)
except FetchError:
raise
except Exception as e:
logger.error(f"Scraping failed for {url_str}: {str(e)}", exc_info=True)
raise ScrapingError(f"Failed to scrape content: {str(e)}")
async def _fetch_url(self, url: str) -> str:
"""
Fetch HTML content from URL
Args:
url: URL to fetch
Returns:
HTML content as string
Raises:
FetchError: If fetch fails
"""
try:
async with httpx.AsyncClient(
timeout=self.settings.request_timeout,
follow_redirects=True,
max_redirects=self.settings.max_redirects
) as client:
logger.debug(f"Fetching URL: {url}")
response = await client.get(
url,
headers={"User-Agent": self.settings.user_agent}
)
response.raise_for_status()
logger.debug(f"Fetched {len(response.text)} bytes from {url}")
return response.text
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error {e.response.status_code} for {url}")
raise FetchError(f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
except httpx.RequestError as e:
logger.error(f"Request error for {url}: {str(e)}")
raise FetchError(f"Failed to fetch URL: {str(e)}")
def _extract_main_content(self, html: str, include_links: bool = False) -> str:
"""
Extract main content using trafilatura (intelligent extraction)
Args:
html: Raw HTML content
include_links: Whether to preserve links in output
Returns:
Extracted content
"""
logger.debug("Extracting main content with trafilatura")
content = trafilatura.extract(
html,
include_links=include_links,
include_images=False,
output_format='txt',
no_fallback=False
)
# Fallback to BeautifulSoup if trafilatura fails
if not content:
logger.debug("Trafilatura extraction failed, falling back to BeautifulSoup")
content = self._extract_basic_content(html)
return content
def _extract_basic_content(self, html: str) -> str:
"""
Extract content using basic BeautifulSoup parsing
Args:
html: Raw HTML content
Returns:
Extracted text content
"""
logger.debug("Extracting content with BeautifulSoup")
soup = BeautifulSoup(html, 'html.parser')
# Remove unwanted elements
for element in soup(["script", "style", "nav", "footer", "header", "aside"]):
element.decompose()
# Extract text
text = soup.get_text(separator='\n', strip=True)
return text
def _extract_title(self, html: str) -> Optional[str]:
"""
Extract page title from HTML
Args:
html: Raw HTML content
Returns:
Page title or None
"""
soup = BeautifulSoup(html, 'html.parser')
title = soup.title.string if soup.title else None
if title:
title = title.strip()
logger.debug(f"Extracted title: {title}")
return title
def _extract_links(self, html: str) -> list[str]:
"""
Extract HTTP(S) links from HTML
Args:
html: Raw HTML content
Returns:
List of absolute HTTP(S) URLs
"""
soup = BeautifulSoup(html, 'html.parser')
links = [
a.get('href')
for a in soup.find_all('a', href=True)
if a.get('href', '').startswith('http')
]
# Limit number of links
links = links[:self.settings.max_links_to_extract]
logger.debug(f"Extracted {len(links)} links")
return links
def _clean_content(self, content: str) -> str:
"""
Clean and normalize extracted content
Args:
content: Raw extracted content
Returns:
Cleaned content
"""
# Remove empty lines and normalize whitespace
lines = [line.strip() for line in content.split('\n') if line.strip()]
cleaned = '\n'.join(lines)
return cleaned
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More