feat(01-03): API routers (calendar, generate, export, settings) e wiring main.py

- schemas/settings.py: Settings pydantic model con api_key, llm_model, nicchie_attive, tono
- routers/calendar.py: POST /api/calendar/generate, GET /api/calendar/formats
- routers/generate.py: POST /api/generate/bulk (202 + job_id), GET /job/{job_id}/status (polling), GET /job/{job_id}, POST /single
- routers/export.py: GET /api/export/{job_id}/csv (originale), POST /api/export/{job_id}/csv (modifiche inline)
- routers/settings.py: GET /api/settings/status (api_key_configured), GET /api/settings, PUT /api/settings
- main.py: include_router x4 PRIMA di SPAStaticFiles, copia prompt default al primo avvio
This commit is contained in:
Michele
2026-03-08 02:14:17 +01:00
parent 083621afd3
commit e06edde4ef
6 changed files with 694 additions and 1 deletions

163
backend/routers/settings.py Normal file
View File

@@ -0,0 +1,163 @@
"""Router per la gestione della configurazione applicativa.
Endpoint:
- GET /api/settings — ritorna la configurazione corrente (api_key mascherata)
- PUT /api/settings — salva la configurazione aggiornata
- GET /api/settings/status — ritorna se l'api_key è configurata (per abilitare/disabilitare UI)
"""
from __future__ import annotations
import json
import logging
from typing import Optional
from fastapi import APIRouter
from pydantic import BaseModel
from backend.config import CONFIG_PATH
from backend.schemas.settings import Settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"])
# Percorso al file di configurazione
_SETTINGS_FILE = CONFIG_PATH / "settings.json"
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class SettingsStatusResponse(BaseModel):
"""Risposta per GET /status — usata dal frontend per abilitare/disabilitare il pulsante genera."""
api_key_configured: bool
llm_model: str
class SettingsResponse(BaseModel):
"""Risposta per GET / — api_key mascherata per sicurezza."""
api_key_masked: Optional[str] # Solo ultimi 4 caratteri o None
llm_model: str
nicchie_attive: list[str]
lingua: str
frequenza_post: int
brand_name: Optional[str]
tono: Optional[str]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _load_settings() -> Settings:
"""Carica le impostazioni da disco. Ritorna i default se il file non esiste."""
if not _SETTINGS_FILE.exists():
return Settings()
try:
data = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
return Settings.model_validate(data)
except Exception as e:
logger.warning("Errore caricamento settings: %s — uso default", str(e))
return Settings()
def _save_settings(settings: Settings) -> None:
"""Salva le impostazioni su disco."""
_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
_SETTINGS_FILE.write_text(
settings.model_dump_json(indent=2),
encoding="utf-8",
)
def _mask_api_key(api_key: Optional[str]) -> Optional[str]:
"""Maschera la chiave API mostrando solo gli ultimi 4 caratteri.
Es: "sk-ant-api03-abc...xyz1234" -> "...1234"
"""
if not api_key:
return None
if len(api_key) <= 4:
return "****"
return f"...{api_key[-4:]}"
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.get("/status", response_model=SettingsStatusResponse)
async def get_settings_status() -> SettingsStatusResponse:
"""Ritorna lo stato della configurazione essenziale.
Usato dal frontend per abilitare/disabilitare il pulsante "Genera".
Returns:
SettingsStatusResponse con api_key_configured e llm_model.
"""
settings = _load_settings()
return SettingsStatusResponse(
api_key_configured=bool(settings.api_key),
llm_model=settings.llm_model,
)
@router.get("/", response_model=SettingsResponse)
async def get_settings() -> SettingsResponse:
"""Ritorna la configurazione corrente con api_key mascherata.
La chiave API non viene mai inviata al frontend in chiaro —
solo gli ultimi 4 caratteri vengono mostrati per conferma.
Returns:
SettingsResponse con tutti i parametri configurabili.
"""
settings = _load_settings()
return SettingsResponse(
api_key_masked=_mask_api_key(settings.api_key),
llm_model=settings.llm_model,
nicchie_attive=settings.nicchie_attive,
lingua=settings.lingua,
frequenza_post=settings.frequenza_post,
brand_name=settings.brand_name,
tono=settings.tono,
)
@router.put("/", response_model=SettingsResponse)
async def update_settings(new_settings: Settings) -> SettingsResponse:
"""Aggiorna e salva la configurazione.
Nota: se api_key è None nel body, la chiave esistente viene mantenuta
(evita di sovrascrivere accidentalmente la chiave quando si aggiornano
altri parametri senza inviare la chiave).
Args:
new_settings: Nuova configurazione da salvare.
Returns:
SettingsResponse aggiornata con api_key mascherata.
"""
# Carica settings esistenti per merger (non sovrascrive api_key con None)
existing = _load_settings()
# Se la nuova api_key è None, mantieni quella esistente
if new_settings.api_key is None:
new_settings = new_settings.model_copy(update={"api_key": existing.api_key})
_save_settings(new_settings)
logger.info("Settings aggiornate | model=%s | brand=%s", new_settings.llm_model, new_settings.brand_name)
return SettingsResponse(
api_key_masked=_mask_api_key(new_settings.api_key),
llm_model=new_settings.llm_model,
nicchie_attive=new_settings.nicchie_attive,
lingua=new_settings.lingua,
frequenza_post=new_settings.frequenza_post,
brand_name=new_settings.brand_name,
tono=new_settings.tono,
)