refactor: supprime le code mort et unifie les routes de tags

Code sans aucun appelant, retiré : DatabaseService.get_revenus_summary
(annoté list[dict] alors qu'il retournait un dict), get_depenses_summary,
get_tag_by_id et storage.file_exists.

Les tags étaient gérés à deux adresses : /api/tags (lecture, appelée par
trois composants) et /api/config/tags (lecture, création, renommage,
appelée par la seule page de configuration). Tout est regroupé sur
/api/tags, dans le module qui leur est dédié ; config.py ne garde que les
settings et ConfigPage est recâblée. Ces routes n'avaient aucun test :
sept en couvrent maintenant la création, le renommage, l'unicité et les
noms vides.

/api/stats disparaît également : sous-ensemble de /api/dashboard/stats,
il n'était appelé par personne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:44:28 +02:00
parent ea5bdac18a
commit 49e060e6e4
7 changed files with 122 additions and 177 deletions

View File

@@ -220,7 +220,7 @@ const editInput = ref(null)
async function loadTags() { async function loadTags() {
try { try {
const resp = await fetch(`${API}/api/config/tags`) const resp = await fetch(`${API}/api/tags`)
tags.value = await resp.json() tags.value = await resp.json()
} catch (e) { } catch (e) {
console.error('Failed to load tags', e) console.error('Failed to load tags', e)
@@ -248,7 +248,7 @@ async function confirmRenameTag(tagId) {
if (!nom) return if (!nom) return
tagMessage.value = '' tagMessage.value = ''
try { try {
const resp = await fetch(`${API}/api/config/tags/${tagId}`, { const resp = await fetch(`${API}/api/tags/${tagId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nom }), body: JSON.stringify({ nom }),
@@ -274,7 +274,7 @@ async function createTag() {
if (!nom) return if (!nom) return
tagMessage.value = '' tagMessage.value = ''
try { try {
const resp = await fetch(`${API}/api/config/tags`, { const resp = await fetch(`${API}/api/tags`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nom }), body: JSON.stringify({ nom }),

View File

@@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...database import DatabaseService, get_session from ...database import get_session
from ...database.models import Tag
from ...services.settings_service import ( from ...services.settings_service import (
SETTINGS_REGISTRY, SETTINGS_REGISTRY,
delete_setting, delete_setting,
@@ -25,14 +24,6 @@ class SettingUpdate(BaseModel):
value: str value: str
class TagCreate(BaseModel):
nom: str
class TagUpdate(BaseModel):
nom: str
# ============================================================ # ============================================================
# Settings endpoints # Settings endpoints
# ============================================================ # ============================================================
@@ -71,64 +62,3 @@ async def reset_setting(
# Return the resolved value after deletion # Return the resolved value after deletion
all_settings = get_all_settings(session) all_settings = get_all_settings(session)
return all_settings[key] 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

@@ -6,11 +6,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response from fastapi.responses import JSONResponse, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...database import DatabaseService, get_session, storage from ...database import DatabaseService, get_session, storage
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
from ...database.service import DuplicateDocumentError from ...database.service import DuplicateDocumentError
from ...extractor import extract_compte_rendu from ...extractor import extract_compte_rendu
from ...utils.canonical import canonical_copy from ...utils.canonical import canonical_copy
@@ -149,24 +147,6 @@ async def save_document_with_pdf(
) )
@router.get("/stats")
async def get_stats(
session: Session = Depends(get_session),
) -> dict:
"""Retourne les statistiques globales de la base de donnees.
Compteurs pour chaque table principale.
"""
return {
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
}
@router.get("/documents", response_model=list[DocumentSummary]) @router.get("/documents", response_model=list[DocumentSummary])
async def list_documents( async def list_documents(
limit: int = 100, limit: int = 100,

View File

@@ -1,15 +1,34 @@
"""Tags routes - Tag management and prediction.""" """Tags routes - Tag management and prediction."""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ...database import DatabaseService, get_session from ...database import DatabaseService, get_session
from ...database.models import Tag
from ...services.tag_predictor import TagPredictor from ...services.tag_predictor import TagPredictor
from ..schemas import PredictTagsRequest from ..schemas import PredictTagsRequest
router = APIRouter(prefix="/api", tags=["tags"]) router = APIRouter(prefix="/api", tags=["tags"])
class TagBody(BaseModel):
"""Corps de requete pour creer ou renommer un tag."""
nom: str
def _tag_dict(tag: Tag) -> dict:
return {"id": tag.id, "nom": tag.nom}
def _nom_valide(nom: str) -> str:
nom = nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
return nom
@router.get("/tags") @router.get("/tags")
async def list_tags( async def list_tags(
session: Session = Depends(get_session), session: Session = Depends(get_session),
@@ -21,7 +40,47 @@ async def list_tags(
db_service = DatabaseService(session) db_service = DatabaseService(session)
tags = db_service.list_tags() tags = db_service.list_tags()
return [{"id": tag.id, "nom": tag.nom} for tag in tags] return [_tag_dict(tag) for tag in tags]
@router.post("/tags", status_code=201)
async def create_tag(
body: TagBody,
session: Session = Depends(get_session),
) -> dict:
"""Cree un nouveau tag (validation unicite)."""
nom = _nom_valide(body.nom)
if session.query(Tag).filter(Tag.nom == nom).first():
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 _tag_dict(tag)
@router.put("/tags/{tag_id}")
async def rename_tag(
tag_id: int,
body: TagBody,
session: Session = Depends(get_session),
) -> dict:
"""Renomme un tag (validation unicite)."""
nom = _nom_valide(body.nom)
tag = session.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag introuvable.")
if session.query(Tag).filter(Tag.nom == nom, Tag.id != tag_id).first():
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag.nom = nom
session.commit()
session.refresh(tag)
return _tag_dict(tag)
@router.post("/predict-tags") @router.post("/predict-tags")

View File

@@ -403,77 +403,8 @@ class DatabaseService:
if depense.tag_id is not None if depense.tag_id is not None
] ]
def get_revenus_summary(
self, immeuble_id: int = None, year: int = None
) -> list[dict]:
"""Get revenue summary grouped by period."""
stmt = select(Revenu)
if immeuble_id:
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
if year:
stmt = stmt.where(
Revenu.periode_debut >= date(year, 1, 1),
Revenu.periode_debut <= date(year, 12, 31),
)
result = self.session.execute(stmt)
revenus = result.scalars().all()
# Aggregate
total_loyers = sum(r.loyers for r in revenus)
total_regles = sum(r.regles for r in revenus)
total_impayes = sum(r.impayes for r in revenus)
return {
"total_loyers": total_loyers,
"total_regles": total_regles,
"total_impayes": total_impayes,
"count": len(revenus),
}
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
"""Get expenses summary grouped by category."""
stmt = select(Depense)
if immeuble_id:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if year:
stmt = stmt.join(Document).where(
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
)
result = self.session.execute(stmt)
depenses = result.scalars().all()
# Aggregate by category
by_category = {}
for d in depenses:
cat = d.categorie or "AUTRE"
if cat not in by_category:
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
by_category[cat]["debit"] += d.debit
by_category[cat]["credit"] += d.credit
by_category[cat]["count"] += 1
total_debit = sum(d.debit for d in depenses)
total_credit = sum(d.credit for d in depenses)
return {
"by_category": by_category,
"total_debit": total_debit,
"total_credit": total_credit,
"count": len(depenses),
}
def list_tags(self) -> list[Tag]: def list_tags(self) -> list[Tag]:
"""List all available tags.""" """List all available tags."""
stmt = select(Tag).order_by(Tag.nom) stmt = select(Tag).order_by(Tag.nom)
result = self.session.execute(stmt) result = self.session.execute(stmt)
return list(result.scalars().all()) return list(result.scalars().all())
def get_tag_by_id(self, tag_id: int) -> Tag | None:
"""Get a tag by ID."""
return self.session.get(Tag, tag_id)

View File

@@ -189,19 +189,6 @@ def get_absolute_path(relative_path: str, storage_root: Path | None = None) -> P
return storage_root / relative_path return storage_root / relative_path
def file_exists(relative_path: str, storage_root: Path | None = None) -> bool:
"""Check if a file exists in storage.
Args:
relative_path: Relative path stored in database.
storage_root: Optional custom storage root.
Returns:
True if file exists.
"""
return get_absolute_path(relative_path, storage_root).exists()
def read_pdf(relative_path: str, storage_root: Path | None = None) -> bytes: def read_pdf(relative_path: str, storage_root: Path | None = None) -> bytes:
"""Read PDF content from storage. """Read PDF content from storage.

58
tests/test_tags_api.py Normal file
View File

@@ -0,0 +1,58 @@
"""Tests des endpoints de gestion des tags.
Lecture, creation et renommage vivaient a deux adresses differentes
(`/api/tags` et `/api/config/tags`) : ces tests verrouillent l'adresse unique
retenue, celle que consomment desormais toutes les pages.
"""
def test_liste_les_tags_predefinis(api_client):
"""La base initialisee est deja pourvue de ses tags de depart."""
response = api_client.get("/api/tags")
assert response.status_code == 200
tags = response.json()
assert tags, "la base devrait etre amorcee avec des tags predefinis"
assert {"id", "nom"} == set(tags[0])
def test_cree_un_tag(api_client):
response = api_client.post("/api/tags", json={"nom": "Ravalement"})
assert response.status_code == 201
assert response.json()["nom"] == "Ravalement"
assert "Ravalement" in [tag["nom"] for tag in api_client.get("/api/tags").json()]
def test_refuse_un_tag_en_double(api_client):
api_client.post("/api/tags", json={"nom": "Ravalement"})
response = api_client.post("/api/tags", json={"nom": "Ravalement"})
assert response.status_code == 409
def test_refuse_un_nom_vide(api_client):
assert api_client.post("/api/tags", json={"nom": " "}).status_code == 400
def test_renomme_un_tag(api_client):
tag_id = api_client.post("/api/tags", json={"nom": "Ravalement"}).json()["id"]
response = api_client.put(f"/api/tags/{tag_id}", json={"nom": "Facade"})
assert response.status_code == 200
assert response.json() == {"id": tag_id, "nom": "Facade"}
def test_renommer_un_tag_inconnu_donne_404(api_client):
assert api_client.put("/api/tags/99999", json={"nom": "Facade"}).status_code == 404
def test_refuse_un_renommage_vers_un_nom_pris(api_client):
api_client.post("/api/tags", json={"nom": "Ravalement"})
autre_id = api_client.post("/api/tags", json={"nom": "Toiture"}).json()["id"]
response = api_client.put(f"/api/tags/{autre_id}", json={"nom": "Ravalement"})
assert response.status_code == 409