"""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.", }