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:
@@ -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 }),
|
||||||
|
|||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
58
tests/test_tags_api.py
Normal 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
|
||||||
Reference in New Issue
Block a user