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,
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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."""
|
"""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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
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