diff --git a/frontend/src/pages/ConfigPage.vue b/frontend/src/pages/ConfigPage.vue index 2f03e4a..8bbc820 100644 --- a/frontend/src/pages/ConfigPage.vue +++ b/frontend/src/pages/ConfigPage.vue @@ -220,7 +220,7 @@ const editInput = ref(null) async function loadTags() { try { - const resp = await fetch(`${API}/api/config/tags`) + const resp = await fetch(`${API}/api/tags`) tags.value = await resp.json() } catch (e) { console.error('Failed to load tags', e) @@ -248,7 +248,7 @@ async function confirmRenameTag(tagId) { if (!nom) return tagMessage.value = '' try { - const resp = await fetch(`${API}/api/config/tags/${tagId}`, { + const resp = await fetch(`${API}/api/tags/${tagId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nom }), @@ -274,7 +274,7 @@ async function createTag() { if (!nom) return tagMessage.value = '' try { - const resp = await fetch(`${API}/api/config/tags`, { + const resp = await fetch(`${API}/api/tags`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ nom }), diff --git a/src/plesna_gerance/api/routes/config.py b/src/plesna_gerance/api/routes/config.py index 1e8e8d1..638f849 100644 --- a/src/plesna_gerance/api/routes/config.py +++ b/src/plesna_gerance/api/routes/config.py @@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session -from ...database import DatabaseService, get_session -from ...database.models import Tag +from ...database import get_session from ...services.settings_service import ( SETTINGS_REGISTRY, delete_setting, @@ -25,14 +24,6 @@ class SettingUpdate(BaseModel): value: str -class TagCreate(BaseModel): - nom: str - - -class TagUpdate(BaseModel): - nom: str - - # ============================================================ # Settings endpoints # ============================================================ @@ -71,64 +62,3 @@ async def reset_setting( # 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} diff --git a/src/plesna_gerance/api/routes/documents.py b/src/plesna_gerance/api/routes/documents.py index 85ef075..29b004a 100644 --- a/src/plesna_gerance/api/routes/documents.py +++ b/src/plesna_gerance/api/routes/documents.py @@ -6,11 +6,9 @@ from datetime import datetime from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import JSONResponse, Response -from sqlalchemy import func, select from sqlalchemy.orm import Session from ...database import DatabaseService, get_session, storage -from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu from ...database.service import DuplicateDocumentError from ...extractor import extract_compte_rendu 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]) async def list_documents( limit: int = 100, diff --git a/src/plesna_gerance/api/routes/tags.py b/src/plesna_gerance/api/routes/tags.py index ecacdd1..9fbea92 100644 --- a/src/plesna_gerance/api/routes/tags.py +++ b/src/plesna_gerance/api/routes/tags.py @@ -1,15 +1,34 @@ """Tags routes - Tag management and prediction.""" from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy.orm import Session from ...database import DatabaseService, get_session +from ...database.models import Tag from ...services.tag_predictor import TagPredictor from ..schemas import PredictTagsRequest 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") async def list_tags( session: Session = Depends(get_session), @@ -21,7 +40,47 @@ async def list_tags( db_service = DatabaseService(session) 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") diff --git a/src/plesna_gerance/database/service.py b/src/plesna_gerance/database/service.py index 0499a31..33c830b 100644 --- a/src/plesna_gerance/database/service.py +++ b/src/plesna_gerance/database/service.py @@ -403,77 +403,8 @@ class DatabaseService: 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]: """List all available tags.""" stmt = select(Tag).order_by(Tag.nom) result = self.session.execute(stmt) 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) diff --git a/src/plesna_gerance/database/storage.py b/src/plesna_gerance/database/storage.py index c802ff3..63c363c 100644 --- a/src/plesna_gerance/database/storage.py +++ b/src/plesna_gerance/database/storage.py @@ -189,19 +189,6 @@ def get_absolute_path(relative_path: str, storage_root: Path | None = None) -> P 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: """Read PDF content from storage. diff --git a/tests/test_tags_api.py b/tests/test_tags_api.py new file mode 100644 index 0000000..cf1978e --- /dev/null +++ b/tests/test_tags_api.py @@ -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