702650376e
- Add AppSetting model for key-value settings storage in PostgreSQL - Create ConfigService with DB-first, env-var-fallback resolution - Add admin-only /api/v1/settings CRUD endpoints - Update tasks.py to use ConfigService for SMTP config - Seed default settings on application startup - Add 24 unit tests for ConfigService (all passing) Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/pop_puller_to_gmail/sessions/ce88d4a8-d8c2-49b3-95a1-30592105a769
149 lines
5.1 KiB
Python
149 lines
5.1 KiB
Python
"""Admin API endpoints for managing database-backed application settings."""
|
|
|
|
from typing import List, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, ConfigDict
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.deps import get_current_superuser
|
|
from app.models.database_models import User
|
|
from app.services.config_service import BOOTSTRAP_KEYS, ConfigService
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Schemas ────────────────────────────────────────────────────────
|
|
|
|
|
|
class AppSettingResponse(BaseModel):
|
|
"""Public representation of a stored setting."""
|
|
|
|
id: int
|
|
key: str
|
|
value: Optional[str] = None
|
|
value_type: str
|
|
description: Optional[str] = None
|
|
is_secret: bool
|
|
category: Optional[str] = None
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class AppSettingCreate(BaseModel):
|
|
"""Payload for creating / updating a setting."""
|
|
|
|
key: str
|
|
value: str
|
|
value_type: str = "string"
|
|
description: Optional[str] = None
|
|
is_secret: bool = False
|
|
category: Optional[str] = None
|
|
|
|
|
|
# ── Endpoints ──────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("", response_model=List[AppSettingResponse])
|
|
async def list_settings(
|
|
category: Optional[str] = Query(None, description="Filter by category"),
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""List all application settings (admin only).
|
|
|
|
Secrets are returned with their values masked.
|
|
"""
|
|
settings = await ConfigService.list_all(db, category=category)
|
|
results: List[AppSettingResponse] = []
|
|
for s in settings:
|
|
val = s.value if not s.is_secret else "********" # type: ignore[arg-type]
|
|
results.append(
|
|
AppSettingResponse(
|
|
id=s.id, # type: ignore[arg-type]
|
|
key=s.key, # type: ignore[arg-type]
|
|
value=val, # type: ignore[arg-type]
|
|
value_type=s.value_type or "string", # type: ignore[arg-type]
|
|
description=s.description, # type: ignore[arg-type]
|
|
is_secret=s.is_secret, # type: ignore[arg-type]
|
|
category=s.category, # type: ignore[arg-type]
|
|
)
|
|
)
|
|
return results
|
|
|
|
|
|
@router.put("/{key}", response_model=AppSettingResponse)
|
|
async def upsert_setting(
|
|
key: str,
|
|
payload: AppSettingCreate,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Create or update a setting (admin only).
|
|
|
|
Bootstrap settings (DATABASE_URL, SECRET_KEY, ENCRYPTION_KEY) cannot
|
|
be stored in the database — they must be set via environment variables.
|
|
"""
|
|
if key in BOOTSTRAP_KEYS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"'{key}' is a bootstrap setting and cannot be stored in the "
|
|
"database. Set it via environment variables."
|
|
),
|
|
)
|
|
|
|
setting = await ConfigService.set(
|
|
key=key,
|
|
value=payload.value,
|
|
db=db,
|
|
value_type=payload.value_type,
|
|
description=payload.description,
|
|
is_secret=payload.is_secret,
|
|
category=payload.category,
|
|
)
|
|
|
|
return AppSettingResponse(
|
|
id=setting.id, # type: ignore[arg-type]
|
|
key=setting.key, # type: ignore[arg-type]
|
|
value="********" if setting.is_secret else setting.value, # type: ignore[arg-type]
|
|
value_type=setting.value_type or "string", # type: ignore[arg-type]
|
|
description=setting.description, # type: ignore[arg-type]
|
|
is_secret=setting.is_secret, # type: ignore[arg-type]
|
|
category=setting.category, # type: ignore[arg-type]
|
|
)
|
|
|
|
|
|
@router.delete("/{key}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_setting(
|
|
key: str,
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Delete a setting from the database (admin only)."""
|
|
if key in BOOTSTRAP_KEYS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(f"'{key}' is a bootstrap setting and cannot be deleted."),
|
|
)
|
|
|
|
deleted = await ConfigService.delete(key, db)
|
|
if not deleted:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Setting '{key}' not found",
|
|
)
|
|
|
|
|
|
@router.post("/seed-defaults", status_code=status.HTTP_200_OK)
|
|
async def seed_default_settings(
|
|
current_user: User = Depends(get_current_superuser),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Seed default settings into the database (admin only).
|
|
|
|
Only creates settings that do not already exist.
|
|
"""
|
|
count = await ConfigService.seed_defaults(db)
|
|
return {"message": f"Seeded {count} default settings", "created": count}
|