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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
134
src/plesna_gerance/api/routes/config.py
Normal file
134
src/plesna_gerance/api/routes/config.py
Normal 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}
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
103
src/plesna_gerance/services/settings_service.py
Normal file
103
src/plesna_gerance/services/settings_service.py
Normal 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
|
||||
Reference in New Issue
Block a user