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>
This commit is contained in:
Jeroen Schweitzer
2026-01-03 12:27:57 +01:00
co-authored by Claude Opus 4.5
parent e85c9a123d
commit 381d43b60b
51 changed files with 8215 additions and 784 deletions
+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"
+5
View File
@@ -0,0 +1,5 @@
"""
Domain modules for Core-API
Each domain contains its own models, schemas, services, and controllers.
"""
+48
View File
@@ -0,0 +1,48 @@
"""
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,
)
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,
)
__all__ = [
# OIDC dependencies
"get_current_user",
"get_admin_user",
"get_optional_user",
"get_forward_auth_user",
"get_forward_auth_admin",
"oidc_config",
# Service
"AuthService",
"get_auth_service",
# Controller
"auth_controller",
# Models
"User",
"Role",
"UserRole",
"Group",
"UserPreferences",
"ApiKey",
"user_groups",
]
+249
View File
@@ -0,0 +1,249 @@
"""
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.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
)
from src.domains.auth.service import AuthService
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"},
},
)
async def get_me(
session: AsyncSession = Depends(get_async_session),
) -> JSONResponse:
"""
Get the current authenticated user's profile
Note: This endpoint requires a valid session or API key.
For now, returns 501 Not Implemented until session management is added.
"""
# TODO: Implement with get_current_user dependency
raise HTTPException(
status_code=501,
detail="Not implemented - use /auth/sync with access token",
)
return router
# Create controller instance
auth_controller = AuthController()
+382
View File
@@ -0,0 +1,382 @@
"""
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),
)
# =============================================================================
# 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
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,
)
# =============================================================================
# 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",
)
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
+354
View File
@@ -0,0 +1,354 @@
"""
OIDC Authentication Module
Provides OAuth2/OIDC token validation for FastAPI using Authentik as IdP.
Implements bearer token authentication with JWT verification.
"""
from fastapi import Depends, HTTPException, Security, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import httpx
from functools import lru_cache
from typing import Dict, Optional
from src.shared.logging import get_logger
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.issuer = ""
self.audience = ""
self.jwks_uri = ""
def configure(self, enabled: bool, issuer: str, audience: 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}")
# Global OIDC config instance
oidc_config = OIDCConfig()
@lru_cache(maxsize=1)
def get_jwks() -> Dict:
"""
Fetch JSON Web Key Set (JWKS) from Authentik
Cached to avoid repeated requests. Cache is cleared on server restart.
Returns:
JWKS dictionary containing public keys for token verification
Raises:
HTTPException: If JWKS fetch fails
"""
if not oidc_config.enabled:
return {}
try:
logger.debug(f"Fetching JWKS from {oidc_config.jwks_uri}")
response = httpx.get(oidc_config.jwks_uri, timeout=10.0)
response.raise_for_status()
jwks = response.json()
logger.info(f"JWKS fetched successfully ({len(jwks.get('keys', []))} keys)")
return jwks
except Exception as e:
logger.error(f"Failed to fetch JWKS: {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:
# Decode token header to get key ID
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get("kid")
if not kid:
raise HTTPException(status_code=401, detail="Invalid token format")
# Find matching key in JWKS
jwks = get_jwks()
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
payload = jwt.decode(
token,
rsa_key,
algorithms=["RS256"],
audience=oidc_config.audience,
issuer=oidc_config.issuer,
)
user_email = payload.get("email", "unknown")
logger.info(f"Authenticated user: {user_email}")
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
+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.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: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")
+634
View File
@@ -0,0 +1,634 @@
"""
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.shared.logging import get_logger
from src.domains.auth.models import User, Role, UserPreferences, Group
from src.domains.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 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)
+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()
+76
View File
@@ -0,0 +1,76 @@
"""
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)
# 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}')>"
+110
View File
@@ -0,0 +1,110 @@
"""
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")
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
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
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"]
+228
View File
@@ -0,0 +1,228 @@
"""
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="Fast health check for Docker",
)
async def full_health_check(response: Response):
"""
Fast health check for container orchestration (Docker/K8s).
Checks component availability WITHOUT running expensive operations.
Returns 200 OK if all components are available, otherwise 503.
For detailed diagnostics, use /health/diagnostics instead.
"""
import time
start_time = time.time()
# Import here to avoid circular imports
from src.models.ollama_client import get_ollama_client
# Check 1: Ollama connection + verify agent model is available
ollama_client = get_ollama_client()
ollama_healthy = False
ollama_error = None
model_available = False
try:
# Ping Ollama
ollama_healthy = await ollama_client.health_check()
# Verify the agent model is pulled and check what's currently loaded
models_info = {}
if ollama_healthy:
try:
models_response = await ollama_client.list_models()
available_models = [m.get('name', '') for m in models_response.get('models', [])]
model_available = settings.agent_model in available_models
# Get info about currently loaded models (those with size in memory)
loaded_models = [
m.get('name', '') for m in models_response.get('models', [])
if m.get('size', 0) > 0
]
models_info = {
"configured": settings.agent_model,
"available": model_available,
"total_in_ollama": len(available_models),
"currently_loaded": loaded_models if loaded_models else ["none"]
}
if not model_available:
ollama_error = f"Model '{settings.agent_model}' not found in Ollama. Available: {', '.join(available_models[:3])}"
ollama_healthy = False
except Exception as e:
ollama_error = f"Could not list Ollama models: {str(e)}"
ollama_healthy = False
except Exception as e:
ollama_error = str(e)
logger.warning(f"Ollama health check failed: {ollama_error}")
# Check 2: Database connection
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}")
is_healthy = ollama_healthy and db_healthy
elapsed_ms = int((time.time() - start_time) * 1000)
status_code = 200 if is_healthy else 503
response.status_code = status_code
return {
"status": "healthy" if is_healthy else "unhealthy",
"status_code": status_code,
"response_time_ms": elapsed_ms,
"components": {
"ollama": {
"status": "healthy" if ollama_healthy else "unhealthy",
"models": models_info if models_info else {
"configured": settings.agent_model,
"available": False
},
"error": ollama_error
},
"database": {
"status": "healthy" if db_healthy else "unhealthy",
"error": db_error
}
}
}
@router.get(
"/health/diagnostics",
summary="Detailed system diagnostics",
)
async def diagnostics(deep_test: bool = False):
"""
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.
"""
import time
from src.models.ollama_client import get_ollama_client
start_time = time.time()
diagnostics = {
"timestamp": time.time(),
"service": {
"name": settings.app_name,
"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
}
except Exception as e:
diagnostics["components"]["ollama"] = {
"status": "error",
"error": str(e)
}
# 2. Configuration
diagnostics["configuration"] = {
"agent_fallback_enabled": settings.agent_fallback_enabled,
"memory_tier1_max_turns": settings.memory_tier1_max_turns,
"cors_origins": settings.cors_origins[:2] if len(settings.cors_origins) > 2 else settings.cors_origins
}
elapsed_ms = int((time.time() - start_time) * 1000)
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()
+9
View File
@@ -0,0 +1,9 @@
"""
Tools Domain
Provides utility tool endpoints including DNS lookups.
"""
from src.domains.tools.controller import tools_controller
from src.domains.tools.dns import DNSService, DNSQueryError
__all__ = ["tools_controller", "DNSService", "DNSQueryError"]
+102
View File
@@ -0,0 +1,102 @@
"""
Tools Controller
Provides utility tool endpoints including:
- DNS lookups
"""
from fastapi import APIRouter, HTTPException, status
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
logger = get_logger(__name__)
class ToolsController(BaseController):
"""
Controller for utility tools
Provides endpoints for:
- DNS lookups
"""
def __init__(self):
super().__init__(prefix="/tools", tags=["Tools"])
self.dns_service = DNSService()
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"
)
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
)
+20 -15
View File
@@ -6,17 +6,20 @@ 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.shared.config import get_settings
from src.shared.logging import setup_logging, get_logger
from src.shared.database import get_database
from src.shared.security import initialize_oidc
from src.models.ollama_client import get_ollama_client, close_ollama_client
from src.db import get_database
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.housekeeping_controller import housekeeping_controller
from src.auth.controller import auth_controller
from src.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()
@@ -46,17 +49,17 @@ async def lifespan(app: FastAPI):
ollama_client = get_ollama_client()
ollama_healthy = await ollama_client.health_check()
if ollama_healthy:
logger.info("Ollama connection successful")
logger.info("Ollama connection successful")
else:
logger.warning("Ollama connection failed - AI features may not work")
logger.warning("Ollama connection failed - AI features may not work")
# Check database connectivity
database = get_database()
db_healthy = await database.health_check()
if db_healthy:
logger.info("Database connection successful")
logger.info("Database connection successful")
else:
logger.warning("Database connection failed - auth features may not work")
logger.warning("Database connection failed - auth features may not work")
# Initialize security (OIDC authentication)
initialize_oidc(settings)
@@ -80,6 +83,7 @@ Core Code API - Infrastructure management and home automation API.
- **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
See `/docs` for the full API reference.
@@ -105,13 +109,14 @@ app.add_middleware(
)
# Include controller routers
# Include domain routers
app.include_router(health_controller.router) # / and /health
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(dashboard_controller.router) # /dashboard/*
# Global exception handler
+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}
+20
View File
@@ -0,0 +1,20 @@
"""
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
__all__ = [
"PortainerClient",
"get_portainer_client",
"NPMClient",
"get_npm_client",
"HomeAssistantClient",
"get_homeassistant_client",
"AuthentikClient",
"get_authentik_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
+147
View File
@@ -0,0 +1,147 @@
"""
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
cors_origins: list[str] = ["*"]
cors_credentials: bool = True
cors_methods: list[str] = ["*"]
cors_headers: list[str] = ["*"]
# Logging
log_level: str = "DEBUG"
# Ollama Configuration (for AI orchestration)
ollama_base_url: str # Required - set OLLAMA_BASE_URL in .env
ollama_timeout: int = 300 # 5 minutes
# Model Configuration
default_model: str = "mistral-nemo-large:latest"
agent_model: str = "mistral-nemo-large:latest"
code_models: str = "mistral-nemo-large:latest"
# System Prompt Variant (for A/B testing)
system_prompt_variant: str = "v8_holistic"
# Agent Configuration
agent_fallback_enabled: bool = True
# Model Aliases (OpenAI → Local)
alias_gpt35: str = "gemma:7b"
alias_gpt4: str = "mistral:7b"
alias_gpt4_turbo: str = "mixtral:8x7b"
alias_gpt4_code: str = "codestral:latest"
# Memory Configuration
memory_tier1_max_turns: int = 10
memory_consolidation_threshold: int = 10
# Qdrant Configuration
qdrant_host: str = "qdrant"
qdrant_port: int = 6333
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)
embedding_model: str = "nomic-embed-text"
embedding_dimension: int = 768
embedding_batch_size: int = 32
# Search Configuration
search_provider: str = "searxng"
searxng_url: str # Required - set SEARXNG_URL in .env
# 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
oidc_issuer: str = "https://auth.schweitz.net/application/o/core-api/"
oidc_audience: str = "core-api"
# Authentik API (for token validation and user management)
authentik_url: str = "https://auth.schweitz.net"
authentik_username: str = ""
authentik_password: str = ""
@property
def model_aliases(self) -> dict:
"""Computed property for model aliases"""
return {
"gpt-3.5-turbo": self.alias_gpt35,
"gpt-4": self.alias_gpt4,
"gpt-4-turbo": self.alias_gpt4_turbo,
"gpt-4-code": self.alias_gpt4_code,
}
def get_code_models(self) -> list[str]:
"""Parse comma-separated code models"""
return [m.strip().strip('"').strip("'") for m in self.code_models.split(",") if m.strip()]
class Config:
env_file = ".env"
case_sensitive = False
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)
+31
View File
@@ -0,0 +1,31 @@
"""
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
from src.auth.oidc import oidc_config
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")