feat: add settings service and config API routes

Add a key-value settings table (Setting model) with DB > env > default
resolution chain. Replace hardcoded Ollama constants in ollama_service
with dynamic _get_config() lookups. Add /api/config/ endpoints for
settings CRUD and tag management (create, rename).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 16:09:02 +01:00
parent 4fe6a4b2c9
commit a1d708d083
7 changed files with 283 additions and 14 deletions

View File

@@ -16,6 +16,7 @@ from .routes import (
dashboard_router,
revenus_router,
ia_router,
config_router,
)
app = FastAPI(
@@ -43,6 +44,7 @@ app.include_router(analytics_router)
app.include_router(dashboard_router)
app.include_router(revenus_router)
app.include_router(ia_router)
app.include_router(config_router)
# Health check endpoints (keep in main app)

View File

@@ -7,6 +7,7 @@ from .analytics import router as analytics_router
from .dashboard import router as dashboard_router
from .revenus import router as revenus_router
from .ia import router as ia_router
from .config import router as config_router
__all__ = [
"extraction_router",
@@ -16,4 +17,5 @@ __all__ = [
"dashboard_router",
"revenus_router",
"ia_router",
"config_router",
]

View File

@@ -0,0 +1,134 @@
"""Routes de configuration — Settings LLM et gestion des tags."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ...database import get_session, DatabaseService
from ...database.models import Tag
from ...services.settings_service import (
get_all_settings,
set_setting,
delete_setting,
SETTINGS_REGISTRY,
)
router = APIRouter(prefix="/api/config", tags=["config"])
# ============================================================
# Schemas
# ============================================================
class SettingUpdate(BaseModel):
value: str
class TagCreate(BaseModel):
nom: str
class TagUpdate(BaseModel):
nom: str
# ============================================================
# Settings endpoints
# ============================================================
@router.get("/settings")
async def list_settings(
session: Session = Depends(get_session),
) -> dict:
"""Liste tous les settings avec valeur et source."""
return get_all_settings(session)
@router.put("/settings/{key}")
async def update_setting(
key: str,
body: SettingUpdate,
session: Session = Depends(get_session),
) -> dict:
"""Met à jour un setting (persiste en DB)."""
if key not in SETTINGS_REGISTRY:
raise HTTPException(status_code=404, detail=f"Setting inconnu : {key}")
set_setting(session, key, body.value)
return {"key": key, "value": body.value, "source": "database"}
@router.delete("/settings/{key}")
async def reset_setting(
key: str,
session: Session = Depends(get_session),
) -> dict:
"""Reset un setting au défaut (supprime l'override DB)."""
if key not in SETTINGS_REGISTRY:
raise HTTPException(status_code=404, detail=f"Setting inconnu : {key}")
deleted = delete_setting(session, key)
# Return the resolved value after deletion
all_settings = get_all_settings(session)
return all_settings[key]
# ============================================================
# Tags endpoints
# ============================================================
@router.get("/tags")
async def list_tags(
session: Session = Depends(get_session),
) -> list[dict]:
"""Liste tous les tags."""
db_service = DatabaseService(session)
tags = db_service.list_tags()
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
@router.post("/tags", status_code=201)
async def create_tag(
body: TagCreate,
session: Session = Depends(get_session),
) -> dict:
"""Crée un nouveau tag (validation unicité)."""
nom = body.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
existing = session.query(Tag).filter(Tag.nom == nom).first()
if existing:
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag = Tag(nom=nom)
session.add(tag)
session.commit()
session.refresh(tag)
return {"id": tag.id, "nom": tag.nom}
@router.put("/tags/{tag_id}")
async def rename_tag(
tag_id: int,
body: TagUpdate,
session: Session = Depends(get_session),
) -> dict:
"""Renomme un tag (validation unicité)."""
nom = body.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
tag = session.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag introuvable.")
existing = session.query(Tag).filter(Tag.nom == nom, Tag.id != tag_id).first()
if existing:
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag.nom = nom
session.commit()
session.refresh(tag)
return {"id": tag.id, "nom": tag.nom}

View File

@@ -1,7 +1,7 @@
"""Database module for plesna-gerance."""
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense, Setting
from .service import DatabaseService, DuplicateDocumentError
from . import storage
@@ -17,6 +17,7 @@ __all__ = [
"Locataire",
"Revenu",
"Depense",
"Setting",
"DatabaseService",
"DuplicateDocumentError",
"storage",

View File

@@ -40,6 +40,20 @@ class Tag(Base):
return f"<Tag(nom={self.nom})>"
class Setting(Base):
"""Table des paramètres de configuration (key-value)."""
__tablename__ = "settings"
id = Column(Integer, primary_key=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self) -> str:
return f"<Setting(key={self.key}, value={self.value})>"
class Immeuble(Base):
"""Table des immeubles gérés."""

View File

@@ -2,21 +2,31 @@
import json
import logging
import os
import httpx
from .sql_executor import execute_readonly_sql, get_schema_description
from .settings_service import get_setting
from ..database.connection import get_session_factory
logger = logging.getLogger(__name__)
# Configuration via variables d'environnement
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5")
OLLAMA_TIMEOUT = int(os.environ.get("OLLAMA_TIMEOUT", "120"))
MAX_TOOL_ITERATIONS = 10
def _get_config() -> dict:
"""Récupère la config Ollama via le service settings (DB > env > default)."""
SessionLocal = get_session_factory()
session = SessionLocal()
try:
return {
"url": get_setting(session, "ollama_url"),
"model": get_setting(session, "ollama_model"),
"timeout": int(get_setting(session, "ollama_timeout")),
}
finally:
session.close()
# Définition des tools pour l'API Ollama
TOOLS = [
{
@@ -185,17 +195,18 @@ async def check_ollama_health() -> dict:
Returns:
dict avec ollama_available (bool) et model (str).
"""
config = _get_config()
try:
async with httpx.AsyncClient(timeout=5) as client:
resp = await client.get(f"{OLLAMA_URL}/api/tags")
resp = await client.get(f"{config['url']}/api/tags")
if resp.status_code == 200:
data = resp.json()
models = [m.get("name", "") for m in data.get("models", [])]
available = any(OLLAMA_MODEL in m for m in models)
return {"ollama_available": available, "model": OLLAMA_MODEL}
available = any(config["model"] in m for m in models)
return {"ollama_available": available, "model": config["model"]}
except Exception:
pass
return {"ollama_available": False, "model": OLLAMA_MODEL}
return {"ollama_available": False, "model": config["model"]}
async def ask_ollama(question: str, chart_type: str = "auto") -> dict:
@@ -216,22 +227,24 @@ async def ask_ollama(question: str, chart_type: str = "auto") -> dict:
if chart_type != "auto":
user_content += f"\n\nIMPORTANT : Utilise obligatoirement un graphique de type '{chart_type}' (ne choisis pas un autre type)."
config = _get_config()
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
]
async with httpx.AsyncClient(timeout=OLLAMA_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
for iteration in range(MAX_TOOL_ITERATIONS):
payload = {
"model": OLLAMA_MODEL,
"model": config["model"],
"messages": messages,
"tools": TOOLS,
"stream": False,
}
logger.info(f"Ollama iteration {iteration + 1}/{MAX_TOOL_ITERATIONS}")
resp = await client.post(f"{OLLAMA_URL}/api/chat", json=payload)
resp = await client.post(f"{config['url']}/api/chat", json=payload)
resp.raise_for_status()
data = resp.json()

View File

@@ -0,0 +1,103 @@
"""Service de gestion des paramètres de configuration.
Chaîne de résolution : valeur DB > variable d'environnement > valeur par défaut.
"""
import os
from sqlalchemy.orm import Session
from ..database.models import Setting
# Registry: key -> (env_var, default_value)
SETTINGS_REGISTRY: dict[str, tuple[str, str]] = {
"ollama_url": ("OLLAMA_URL", "http://localhost:11434"),
"ollama_model": ("OLLAMA_MODEL", "qwen2.5"),
"ollama_timeout": ("OLLAMA_TIMEOUT", "120"),
}
def get_setting(session: Session, key: str) -> str:
"""Résout la valeur d'un setting : DB > env > default.
Raises:
KeyError: si la clé n'est pas dans le registry.
"""
if key not in SETTINGS_REGISTRY:
raise KeyError(f"Setting inconnu : {key}")
env_var, default = SETTINGS_REGISTRY[key]
# 1. Check DB
row = session.query(Setting).filter(Setting.key == key).first()
if row is not None:
return row.value
# 2. Check env var
env_value = os.environ.get(env_var)
if env_value is not None:
return env_value
# 3. Default
return default
def get_all_settings(session: Session) -> dict[str, dict]:
"""Retourne tous les settings avec valeur et source.
Returns:
dict de la forme {key: {"value": str, "source": "database"|"env"|"default"}}
"""
db_rows = {row.key: row.value for row in session.query(Setting).all()}
result = {}
for key, (env_var, default) in SETTINGS_REGISTRY.items():
if key in db_rows:
result[key] = {"value": db_rows[key], "source": "database"}
elif os.environ.get(env_var) is not None:
result[key] = {"value": os.environ[env_var], "source": "env"}
else:
result[key] = {"value": default, "source": "default"}
return result
def set_setting(session: Session, key: str, value: str) -> Setting:
"""Upsert un setting en base.
Raises:
KeyError: si la clé n'est pas dans le registry.
"""
if key not in SETTINGS_REGISTRY:
raise KeyError(f"Setting inconnu : {key}")
row = session.query(Setting).filter(Setting.key == key).first()
if row is not None:
row.value = value
else:
row = Setting(key=key, value=value)
session.add(row)
session.commit()
session.refresh(row)
return row
def delete_setting(session: Session, key: str) -> bool:
"""Supprime l'override DB pour un setting (retour au défaut).
Returns:
True si un override existait, False sinon.
Raises:
KeyError: si la clé n'est pas dans le registry.
"""
if key not in SETTINGS_REGISTRY:
raise KeyError(f"Setting inconnu : {key}")
row = session.query(Setting).filter(Setting.key == key).first()
if row is not None:
session.delete(row)
session.commit()
return True
return False