Files
gh-christianlouis-inboxconv…/backend/app/api/v1/endpoints/app_settings.py
T
copilot-swe-agent[bot] 702650376e Add database-backed configuration (AppSetting model, ConfigService, settings API)
- 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
2026-03-23 13:35:52 +00:00

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}