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, dashboard_router,
revenus_router, revenus_router,
ia_router, ia_router,
config_router,
) )
app = FastAPI( app = FastAPI(
@@ -43,6 +44,7 @@ app.include_router(analytics_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(revenus_router) app.include_router(revenus_router)
app.include_router(ia_router) app.include_router(ia_router)
app.include_router(config_router)
# Health check endpoints (keep in main app) # 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 .dashboard import router as dashboard_router
from .revenus import router as revenus_router from .revenus import router as revenus_router
from .ia import router as ia_router from .ia import router as ia_router
from .config import router as config_router
__all__ = [ __all__ = [
"extraction_router", "extraction_router",
@@ -16,4 +17,5 @@ __all__ = [
"dashboard_router", "dashboard_router",
"revenus_router", "revenus_router",
"ia_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.""" """Database module for plesna-gerance."""
from .connection import get_engine, get_session, get_session_factory, init_db 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 .service import DatabaseService, DuplicateDocumentError
from . import storage from . import storage
@@ -17,6 +17,7 @@ __all__ = [
"Locataire", "Locataire",
"Revenu", "Revenu",
"Depense", "Depense",
"Setting",
"DatabaseService", "DatabaseService",
"DuplicateDocumentError", "DuplicateDocumentError",
"storage", "storage",

View File

@@ -40,6 +40,20 @@ class Tag(Base):
return f"<Tag(nom={self.nom})>" 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): class Immeuble(Base):
"""Table des immeubles gérés.""" """Table des immeubles gérés."""

View File

@@ -2,21 +2,31 @@
import json import json
import logging import logging
import os
import httpx import httpx
from .sql_executor import execute_readonly_sql, get_schema_description 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__) 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 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 # Définition des tools pour l'API Ollama
TOOLS = [ TOOLS = [
{ {
@@ -185,17 +195,18 @@ async def check_ollama_health() -> dict:
Returns: Returns:
dict avec ollama_available (bool) et model (str). dict avec ollama_available (bool) et model (str).
""" """
config = _get_config()
try: try:
async with httpx.AsyncClient(timeout=5) as client: 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: if resp.status_code == 200:
data = resp.json() data = resp.json()
models = [m.get("name", "") for m in data.get("models", [])] models = [m.get("name", "") for m in data.get("models", [])]
available = any(OLLAMA_MODEL in m for m in models) available = any(config["model"] in m for m in models)
return {"ollama_available": available, "model": OLLAMA_MODEL} return {"ollama_available": available, "model": config["model"]}
except Exception: except Exception:
pass 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: 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": if chart_type != "auto":
user_content += f"\n\nIMPORTANT : Utilise obligatoirement un graphique de type '{chart_type}' (ne choisis pas un autre type)." user_content += f"\n\nIMPORTANT : Utilise obligatoirement un graphique de type '{chart_type}' (ne choisis pas un autre type)."
config = _get_config()
messages = [ messages = [
{"role": "system", "content": SYSTEM_PROMPT}, {"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content}, {"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): for iteration in range(MAX_TOOL_ITERATIONS):
payload = { payload = {
"model": OLLAMA_MODEL, "model": config["model"],
"messages": messages, "messages": messages,
"tools": TOOLS, "tools": TOOLS,
"stream": False, "stream": False,
} }
logger.info(f"Ollama iteration {iteration + 1}/{MAX_TOOL_ITERATIONS}") 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() resp.raise_for_status()
data = resp.json() 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