Files
pdf_oralia_vibe/src/plesna_gerance/api/routes/documents.py
Bertrand Benjamin 92cf21309f feat: affiche la date de derniere extraction et revient aux documents
La liste des documents montrait la date d'import, figee au premier
enregistrement : c'est la date de la derniere extraction ayant produit les
donnees affichees qui renseigne, surtout apres un balayage de re-extraction. La
colonne `extracted_at` la porte, mise a jour a chaque enregistrement, et la date
d'import reste consultable en infobulle.

Le projet n'ayant pas d'outil de migration (`create_all` laisse intactes les
tables existantes), `init_db` rattrape les colonnes ajoutees apres coup : sans
cela une base deja installee cesserait de fonctionner. Les documents deja
presents recoivent leur date d'import, qui est bien celle de leur extraction.

Une fois la selection appliquee sans echec, le balayage renvoie vers la liste des
documents ; en cas d'echec on reste sur place, les lignes concernees portant leur
message.

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

490 lines
17 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.canonical import canonical_copy
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,
extracted_at=doc.extracted_at.isoformat() if doc.extracted_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,
"extracted_at": document.extracted_at.isoformat()
if document.extracted_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.
`previous_canonical` donne les donnees actuelles telles qu'elles seraient
reecrites a l'enregistrement (cf. utils.canonical). L'appelant compare avec
`previous_data` pour savoir quels champs le serveur imposera de toute facon,
et le signaler avant que l'utilisateur ne se prononce — sans avoir a
connaitre les regles de reecriture.
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)}",
)
previous_data = json.loads(document.json_data) if document.json_data else None
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": previous_data,
"previous_canonical": canonical_copy(previous_data),
"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.",
}