Files
pdf_oralia_vibe/src/plesna_gerance/api/routes/documents.py
Bertrand Benjamin 9cf0a89aab feat: expose la re-extraction verifiable d'un document
POST /api/documents/{id}/re-extract retourne desormais, en plus des donnees
re-extraites, les donnees actuelles et les tags des depenses : un seul appel
suffit pour comparer avant/apres et reporter les tags. L'endpoint devient
synchrone pour que FastAPI l'execute dans un thread, l'extraction bloquant
plusieurs secondes par PDF.

PUT /api/documents/{id} enregistre la nouvelle extraction en visant le
document par son ID (save_document(replace_document_id=...)) : une extraction
qui corrige la reference ou la date met a jour le bon document au lieu d'en
creer un second, et refuse la collision avec un document voisin. Le PDF
stocke est conserve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 06:56:02 +02:00

476 lines
16 KiB
Python

"""Documents routes - CRUD operations for documents."""
import json
import logging
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.uploads import UploadTooLargeError, read_upload_limited
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
router = APIRouter(prefix="/api", tags=["documents"])
logger = logging.getLogger(__name__)
@router.post("/save", response_model=SaveResponse)
async def save_document(
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Sauvegarde les donnees extraites en base de donnees.
- **source_file**: Nom du fichier PDF original (optionnel)
- **data**: Donnees extraites (format identique a la reponse de /api/extract)
- **depenses_tags**: Liste des tags par index de depense (optionnel)
- **overwrite**: Si True, ecrase le document existant (optionnel)
Retourne un message de succes avec l'ID du document cree,
ou une erreur si le document existe deja (doublon).
"""
try:
db_service = DatabaseService(session)
document = db_service.save_document(
data=request.data,
source_file=request.source_file,
depenses_tags=request.depenses_tags,
overwrite=request.overwrite,
)
return SaveResponse(
success=True,
message="Document sauvegarde avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=f"Document deja existant: reference={e.reference}, date={e.date}",
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
logger.exception("Erreur lors de la sauvegarde du document")
raise HTTPException(
status_code=500, detail="Erreur lors de la sauvegarde du document."
)
@router.post("/save-with-pdf", response_model=SaveResponse)
async def save_document_with_pdf(
pdf_file: UploadFile = File(..., description="Fichier PDF original"),
data: str = Form(..., description="Donnees JSON extraites"),
depenses_tags: str | None = Form(None, description="Tags JSON pour les depenses"),
overwrite: bool = Form(False, description="Ecraser si existant"),
session: Session = Depends(get_session),
) -> SaveResponse:
"""Sauvegarde les donnees extraites avec le fichier PDF original.
Cette version stocke le PDF et le JSON sur le disque pour tracabilite.
- **pdf_file**: Fichier PDF original (multipart)
- **data**: Donnees JSON extraites (string JSON)
- **depenses_tags**: Tags JSON pour les depenses (optionnel)
- **overwrite**: Si True, ecrase le document existant
"""
# Parse JSON data
try:
parsed_data = json.loads(data)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"JSON invalide pour data: {e}")
# Parse depenses_tags if provided
parsed_tags = None
if depenses_tags:
try:
parsed_tags = json.loads(depenses_tags)
except json.JSONDecodeError as e:
raise HTTPException(
status_code=400, detail=f"JSON invalide pour depenses_tags: {e}"
)
# Read PDF content (taille bornée)
try:
pdf_content = await read_upload_limited(pdf_file)
except UploadTooLargeError as e:
raise HTTPException(status_code=413, detail=str(e))
except Exception as e:
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")
try:
db_service = DatabaseService(session)
document = db_service.save_document(
data=parsed_data,
source_file=pdf_file.filename,
pdf_content=pdf_content,
depenses_tags=parsed_tags,
overwrite=overwrite,
)
return SaveResponse(
success=True,
message="Document sauvegarde avec succes (PDF et JSON stockes)",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=f"Document deja existant: reference={e.reference}, date={e.date}",
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
logger.exception("Erreur lors de la sauvegarde du document avec PDF")
raise HTTPException(
status_code=500, detail="Erreur lors de la sauvegarde du document."
)
@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,
offset: int = 0,
session: Session = Depends(get_session),
) -> list[DocumentSummary]:
"""Liste les documents importes en base.
- **limit**: Nombre maximum de documents a retourner (defaut: 100)
- **offset**: Decalage pour la pagination (defaut: 0)
Retourne une liste de documents avec leurs metadonnees principales.
"""
db_service = DatabaseService(session)
documents = db_service.list_documents(limit=limit, offset=offset)
return [
DocumentSummary(
id=doc.id,
reference=doc.reference,
date=str(doc.date),
type=doc.type,
source_file=doc.source_file,
immeuble_code=doc.immeuble.code if doc.immeuble else None,
immeuble_adresse=doc.immeuble.adresse if doc.immeuble else None,
solde_montant=doc.solde_montant,
solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None,
has_pdf=doc.pdf_path is not None,
has_json=doc.json_path is not None,
)
for doc in documents
]
@router.get("/documents/{document_id}")
async def get_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Recupere un document par son ID avec toutes ses donnees.
Retourne le document complet incluant le JSON original.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
return {
"id": document.id,
"reference": document.reference,
"date": str(document.date),
"type": document.type,
"source_file": document.source_file,
"immeuble": {
"code": document.immeuble.code,
"adresse": document.immeuble.adresse,
"ville": document.immeuble.ville,
"code_postal": document.immeuble.code_postal,
}
if document.immeuble
else None,
"solde": {
"montant": document.solde_montant,
"type": document.solde_type,
"date_arrete": str(document.solde_date_arrete)
if document.solde_date_arrete
else None,
},
"editeur": {
"nom": document.editeur_nom,
"siret": document.editeur_siret,
},
"json_data": json.loads(document.json_data) if document.json_data else None,
"depenses_tags": db_service.get_depenses_tags(document_id),
"created_at": document.created_at.isoformat() if document.created_at else None,
"has_pdf": document.pdf_path is not None,
"has_json": document.json_path is not None,
}
@router.get("/check-duplicate")
async def check_duplicate(
reference: str,
date: str,
session: Session = Depends(get_session),
) -> dict:
"""Verifie si un document existe deja en base.
- **reference**: Reference du document
- **date**: Date du document (format YYYY-MM-DD)
Retourne {exists: true/false, document_id: ...}
"""
try:
doc_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail="Format de date invalide. Utiliser YYYY-MM-DD"
)
db_service = DatabaseService(session)
existing = db_service.check_duplicate(reference, doc_date)
return {
"exists": existing is not None,
"document_id": existing.id if existing else None,
"reference": reference,
"date": date,
}
@router.get("/documents/{document_id}/pdf")
async def download_document_pdf(
document_id: int,
session: Session = Depends(get_session),
) -> Response:
"""Telecharge le fichier PDF original d'un document.
Retourne le fichier PDF si disponible, sinon erreur 404.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
if not document.pdf_path:
raise HTTPException(
status_code=404,
detail="Fichier PDF non disponible pour ce document",
)
try:
pdf_content = storage.read_pdf(document.pdf_path)
filename = document.source_file or f"{document.reference}.pdf"
return Response(
content=pdf_content,
media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="{filename}"'},
)
except FileNotFoundError:
raise HTTPException(
status_code=404,
detail="Fichier PDF introuvable sur le disque",
)
@router.get("/documents/{document_id}/json")
async def download_document_json(
document_id: int,
session: Session = Depends(get_session),
) -> Response:
"""Telecharge le fichier JSON extrait d'un document.
Retourne le fichier JSON si disponible sur disque,
sinon retourne le json_data de la base de donnees.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
filename = f"{document.reference}_{document.date}.json"
# Try to read from storage first
if document.json_path:
try:
json_data = storage.read_json(document.json_path)
return JSONResponse(
content=json_data,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
except FileNotFoundError:
pass # Fall back to database
# Fall back to json_data in database
if document.json_data:
json_data = json.loads(document.json_data)
return JSONResponse(
content=json_data,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
raise HTTPException(
status_code=404,
detail="Donnees JSON non disponibles pour ce document",
)
@router.put("/documents/{document_id}", response_model=SaveResponse)
async def update_document(
document_id: int,
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Remplace les donnees d'un document existant, identifie par son ID.
A la difference de `POST /api/save` avec `overwrite`, qui retrouve le
document par (reference, date), le document vise est ici designe par son ID :
une nouvelle extraction qui corrige la reference ou la date met a jour le bon
document au lieu d'en creer un second. Le PDF stocke est conserve.
- **data**: Nouvelles donnees extraites
- **depenses_tags**: Tags a appliquer aux depenses (par index d'operation)
- **source_file**: Nom du fichier source (optionnel, conserve si absent)
"""
db_service = DatabaseService(session)
existing = db_service.get_document_by_id(document_id)
if not existing:
raise HTTPException(status_code=404, detail="Document non trouve")
try:
document = db_service.save_document(
data=request.data,
source_file=request.source_file or existing.source_file,
depenses_tags=request.depenses_tags,
replace_document_id=document_id,
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=(
f"Un autre document porte deja reference={e.reference}, date={e.date}"
),
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception:
logger.exception("Erreur lors de la mise a jour du document %s", document_id)
raise HTTPException(
status_code=500, detail="Erreur lors de la mise a jour du document."
)
return SaveResponse(
success=True,
message="Document mis a jour avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
@router.post("/documents/{document_id}/re-extract")
def re_extract_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Re-extrait les donnees depuis le PDF stocke.
Utile pour corriger l'extraction apres amelioration des parsers, document par
document ou lors d'un balayage de toute la base. Ne modifie pas la base :
retourne cote a cote les donnees actuelles et les donnees re-extraites, pour
que l'appelant compare et decide s'il enregistre (PUT /api/documents/{id}).
`depenses_tags` porte les tags actuels du document, pour pouvoir etre
reportes sur la nouvelle extraction plutot que perdus au reenregistrement.
Endpoint synchrone (`def`) : l'extraction est bloquante et prend plusieurs
secondes, FastAPI l'execute donc dans un thread pour ne pas figer le serveur.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
if not document.pdf_path:
raise HTTPException(
status_code=404,
detail="Fichier PDF non disponible pour ce document - re-extraction impossible",
)
# Get absolute path
pdf_full_path = storage.get_absolute_path(document.pdf_path)
if not pdf_full_path.exists():
raise HTTPException(
status_code=404,
detail="Fichier PDF introuvable sur le disque",
)
# Re-extract
try:
new_data = extract_compte_rendu(str(pdf_full_path))
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Erreur lors de la re-extraction: {str(e)}",
)
return {
"document_id": document_id,
"reference": document.reference,
"date": str(document.date),
"source_file": document.source_file,
"original_json_path": document.json_path,
"previous_data": json.loads(document.json_data) if document.json_data else None,
"re_extracted_data": new_data,
"depenses_tags": db_service.get_depenses_tags(document_id),
"message": "Donnees re-extraites. Utilisez PUT /api/documents/{id} pour mettre a jour.",
}