""" Routes API Universign améliorées Intègre la logique métier complète de gestion des signatures """ from fastapi import APIRouter, Depends, HTTPException, Path, Request from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from typing import Optional from datetime import datetime from pydantic import BaseModel, EmailStr import logging import uuid from database import ( UniversignTransaction, UniversignSigner, LocalDocumentStatus, SageDocumentType, UniversignTransactionStatus, UniversignSignerStatus, get_session, EmailLog, StatutEmail, ) from services.universign_sync import UniversignSyncService from services.signed_documents import signed_documents from config.config import settings from email_queue import email_queue from sage_client import sage_client from data.data import templates_signature_email from utils.generic_functions import normaliser_type_doc logger = logging.getLogger(__name__) router = APIRouter(prefix="/universign", tags=["Universign Enhanced"]) # Service de synchronisation amélioré universign_sync = UniversignSyncService( api_url=settings.universign_api_url, api_key=settings.universign_api_key ) universign_sync.configure( sage_client=sage_client, email_queue=email_queue, settings=settings ) class CreateSignatureRequest(BaseModel): """Demande de création d'une signature""" sage_document_id: str sage_document_type: SageDocumentType signer_email: EmailStr signer_name: str document_name: Optional[str] = None @router.post("/signatures/create-enhanced") async def create_signature_enhanced( request: CreateSignatureRequest, session: AsyncSession = Depends(get_session) ): """ Création de signature avec logique métier stricte: - Vérifie le statut Sage actuel - Ne met à jour à 1 QUE si statut = 0 - Crée la transaction Universign - Envoie l'email de demande """ try: # === VÉRIFICATION STATUT SAGE ACTUEL === doc = sage_client.lire_document( request.sage_document_id, request.sage_document_type.value ) if not doc: raise HTTPException(404, f"Document {request.sage_document_id} introuvable") statut_actuel = doc.get("statut", 0) logger.info(f"📊 Statut Sage actuel: {statut_actuel}") # === VÉRIFICATION DOUBLON === existing_query = select(UniversignTransaction).where( UniversignTransaction.sage_document_id == request.sage_document_id, UniversignTransaction.sage_document_type == request.sage_document_type, ~UniversignTransaction.local_status.in_( [ LocalDocumentStatus.SIGNED, LocalDocumentStatus.REJECTED, LocalDocumentStatus.EXPIRED, LocalDocumentStatus.ERROR, ] ), ) existing_result = await session.execute(existing_query) existing_tx = existing_result.scalar_one_or_none() if existing_tx: raise HTTPException( 400, f"Une demande de signature est déjà en cours pour {request.sage_document_id}", ) # === GÉNÉRATION PDF === pdf_bytes = email_queue._generate_pdf( request.sage_document_id, normaliser_type_doc(request.sage_document_type) ) if not pdf_bytes: raise HTTPException(400, "Échec génération PDF") # === CRÉATION TRANSACTION UNIVERSIGN === import requests auth = (settings.universign_api_key, "") # 1. Créer transaction resp = requests.post( f"{settings.universign_api_url}/transactions", auth=auth, json={ "name": request.document_name or f"{request.sage_document_type.name} {request.sage_document_id}", "language": "fr", }, timeout=30, ) if resp.status_code != 200: raise HTTPException(500, f"Erreur Universign: {resp.status_code}") universign_tx_id = resp.json().get("id") # 2. Upload PDF files = { "file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf") } resp = requests.post( f"{settings.universign_api_url}/files", auth=auth, files=files, timeout=60 ) if resp.status_code not in [200, 201]: raise HTTPException(500, "Erreur upload PDF") file_id = resp.json().get("id") # 3. Attacher document resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents", auth=auth, data={"document": file_id}, timeout=30, ) if resp.status_code not in [200, 201]: raise HTTPException(500, "Erreur attachement document") document_id = resp.json().get("id") # 4. Créer champ signature resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields", auth=auth, data={"type": "signature"}, timeout=30, ) if resp.status_code not in [200, 201]: raise HTTPException(500, "Erreur création champ signature") field_id = resp.json().get("id") # 5. Lier signataire resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures", auth=auth, data={"signer": request.signer_email, "field": field_id}, timeout=30, ) if resp.status_code not in [200, 201]: raise HTTPException(500, "Erreur liaison signataire") # 6. Démarrer transaction resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/start", auth=auth, timeout=30, ) if resp.status_code not in [200, 201]: raise HTTPException(500, "Erreur démarrage transaction") final_data = resp.json() # 7. Extraire URL de signature signer_url = "" if final_data.get("actions"): for action in final_data["actions"]: if action.get("url"): signer_url = action["url"] break if not signer_url: raise HTTPException(500, "URL de signature non retournée") # === ENREGISTREMENT LOCAL === local_id = str(uuid.uuid4()) transaction = UniversignTransaction( id=local_id, transaction_id=universign_tx_id, sage_document_id=request.sage_document_id, sage_document_type=request.sage_document_type, universign_status=UniversignTransactionStatus.STARTED, local_status=LocalDocumentStatus.IN_PROGRESS, signer_url=signer_url, requester_email=request.signer_email, requester_name=request.signer_name, document_name=request.document_name, created_at=datetime.now(), sent_at=datetime.now(), is_test=True, needs_sync=True, ) session.add(transaction) signer = UniversignSigner( id=f"{local_id}_signer_0", transaction_id=local_id, email=request.signer_email, name=request.signer_name, status=UniversignSignerStatus.WAITING, order_index=0, ) session.add(signer) await session.commit() # === ENVOI EMAIL === template = templates_signature_email["demande_signature"] type_labels = { 0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir", } doc_info = sage_client.lire_document( request.sage_document_id, request.sage_document_type.value ) montant_ttc = f"{doc_info.get('total_ttc', 0):.2f}" if doc_info else "0.00" date_doc = ( doc_info.get("date", datetime.now().strftime("%d/%m/%Y")) if doc_info else datetime.now().strftime("%d/%m/%Y") ) variables = { "NOM_SIGNATAIRE": request.signer_name, "TYPE_DOC": type_labels.get(request.sage_document_type.value, "Document"), "NUMERO": request.sage_document_id, "DATE": date_doc, "MONTANT_TTC": montant_ttc, "SIGNER_URL": signer_url, "CONTACT_EMAIL": settings.smtp_from, } sujet = template["sujet"] corps = template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) email_log = EmailLog( id=str(uuid.uuid4()), destinataire=request.signer_email, sujet=sujet, corps_html=corps, document_ids=request.sage_document_id, type_document=request.sage_document_type.value, statut=StatutEmail.EN_ATTENTE, date_creation=datetime.now(), nb_tentatives=0, ) session.add(email_log) await session.commit() email_queue.enqueue(email_log.id) # === MISE À JOUR STATUT SAGE (LOGIQUE STRICTE) === statut_sage_updated = False if statut_actuel == 0: try: sage_client.changer_statut_document( document_type_code=request.sage_document_type.value, numero=request.sage_document_id, nouveau_statut=1, # Confirmé ) logger.info(f"✅ Statut Sage mis à jour: 0 → 1") statut_sage_updated = True except Exception as e: logger.warning(f"⚠️ Impossible de mettre à jour le statut Sage: {e}") else: logger.info(f"ℹ️ Statut Sage non modifié (était {statut_actuel}, ≠ 0)") # === RÉPONSE === return { "success": True, "transaction_id": transaction.transaction_id, "sage_document_id": transaction.sage_document_id, "signer_url": transaction.signer_url, "statut_sage_initial": statut_actuel, "statut_sage_updated": statut_sage_updated, "nouveau_statut_sage": 1 if statut_sage_updated else statut_actuel, "message": ( f"Signature créée. Statut Sage: {statut_actuel} → " f"{1 if statut_sage_updated else statut_actuel}" ), } except HTTPException: raise except Exception as e: logger.error(f"Erreur création signature: {e}", exc_info=True) raise HTTPException(500, str(e)) @router.post("/webhook-enhanced") @router.post("/webhook-enhanced/") async def webhook_universign_enhanced( request: Request, session: AsyncSession = Depends(get_session) ): """ Webhook Universign amélioré: - Détecte l'événement 'closed' (signature complétée) - Télécharge automatiquement le document signé - Met à jour le statut Sage à 2 - Envoie la notification avec lien de téléchargement """ try: payload = await request.json() event_type = payload.get("event") transaction_id = payload.get("transaction_id") or payload.get("id") logger.info(f"📨 Webhook reçu: event={event_type}, tx={transaction_id}") if not transaction_id: return {"status": "error", "message": "Pas de transaction_id"}, 400 # Récupérer la transaction locale query = ( select(UniversignTransaction) .options(selectinload(UniversignTransaction.signers)) .where(UniversignTransaction.transaction_id == transaction_id) ) result = await session.execute(query) transaction = result.scalar_one_or_none() if not transaction: logger.warning(f"Transaction {transaction_id} inconnue") return {"status": "error", "message": "Transaction inconnue"}, 404 transaction.webhook_received = True # Récupérer l'état complet depuis Universign import requests resp = requests.get( f"{settings.universign_api_url}/transactions/{transaction_id}", auth=(settings.universign_api_key, ""), timeout=30, ) if resp.status_code != 200: logger.error(f"Erreur récupération transaction: {resp.status_code}") return {"status": "error", "message": "Erreur API Universign"}, 500 universign_data = resp.json() universign_status_raw = universign_data.get("state", "") previous_status = transaction.local_status.value # Déterminer le nouveau statut from utils.universign_status_mapping import map_universign_to_local new_status = map_universign_to_local(universign_status_raw) # Mettre à jour la transaction transaction.universign_status = ( UniversignTransactionStatus(universign_status_raw) if universign_status_raw in [s.value for s in UniversignTransactionStatus] else transaction.universign_status ) transaction.local_status = LocalDocumentStatus(new_status) transaction.universign_status_updated_at = datetime.now() transaction.last_synced_at = datetime.now() if new_status == "SIGNE" and not transaction.signed_at: transaction.signed_at = datetime.now() await session.commit() # Si statut = SIGNE (completed/closed), gérer la complétion if new_status == "SIGNE" and previous_status != "SIGNE": logger.info(f"🎯 Signature complétée détectée via webhook") success, error = await universign_sync.handle_signature_completed( session=session, transaction=transaction, universign_data=universign_data, ) if not success: logger.error(f"Erreur handle_signature_completed: {error}") return { "status": "partial_success", "message": "Webhook traité mais erreur téléchargement", "error": error, }, 200 logger.info(f"✅ Webhook traité: {previous_status} → {new_status}") return { "status": "success", "event": event_type, "transaction_id": transaction_id, "previous_status": previous_status, "new_status": new_status, } except Exception as e: logger.error(f"Erreur webhook: {e}", exc_info=True) return {"status": "error", "message": str(e)}, 500 @router.get("/documents/{transaction_local_id}/download") async def download_signed_document( transaction_local_id: str = Path(..., description="ID local de la transaction"), session: AsyncSession = Depends(get_session), ): """ Téléchargement sécurisé du document signé **Sécurité**: - Vérifier que le document existe - Vérifier l'intégrité du fichier - Retourner 404 si non trouvé """ try: # Récupérer la transaction query = select(UniversignTransaction).where( UniversignTransaction.id == transaction_local_id ) result = await session.execute(query) transaction = result.scalar_one_or_none() if not transaction: raise HTTPException(404, "Transaction introuvable") if transaction.local_status != LocalDocumentStatus.SIGNED: raise HTTPException( 400, f"Document non signé (statut: {transaction.local_status.value})" ) # Récupérer le chemin du document file_path = signed_documents.get_document_path(transaction) if not file_path: raise HTTPException(404, "Document signé non disponible") # Vérifier l'intégrité if not signed_documents.verify_document_integrity(file_path): logger.error(f"Document corrompu: {file_path}") raise HTTPException(500, "Document signé corrompu") # Nom du fichier à télécharger filename = f"{transaction.sage_document_id}_signe.pdf" logger.info( f"📥 Téléchargement: {filename} par transaction {transaction_local_id}" ) return FileResponse( path=file_path, media_type="application/pdf", filename=filename, headers={ "Content-Disposition": f'attachment; filename="{filename}"', "X-Transaction-ID": transaction.transaction_id, "X-Signed-At": transaction.signed_at.isoformat() if transaction.signed_at else "", }, ) except HTTPException: raise except Exception as e: logger.error(f"Erreur téléchargement document: {e}", exc_info=True) raise HTTPException(500, str(e)) @router.get("/documents/{transaction_local_id}/info") async def get_signed_document_info( transaction_local_id: str, session: AsyncSession = Depends(get_session) ): """ Informations sur le document signé (sans le télécharger) """ try: query = select(UniversignTransaction).where( UniversignTransaction.id == transaction_local_id ) result = await session.execute(query) transaction = result.scalar_one_or_none() if not transaction: raise HTTPException(404, "Transaction introuvable") file_path = signed_documents.get_document_path(transaction) file_info = None if file_path: file_stat = file_path.stat() file_info = { "exists": True, "size_bytes": file_stat.st_size, "size_mb": round(file_stat.st_size / 1024 / 1024, 2), "created_at": datetime.fromtimestamp(file_stat.st_ctime).isoformat(), "integrity_ok": signed_documents.verify_document_integrity(file_path), } return { "transaction_id": transaction.transaction_id, "sage_document_id": transaction.sage_document_id, "sage_document_type": transaction.sage_document_type.name, "local_status": transaction.local_status.value, "signed_at": transaction.signed_at.isoformat() if transaction.signed_at else None, "downloaded_at": ( transaction.signed_document_downloaded_at.isoformat() if transaction.signed_document_downloaded_at else None ), "file_info": file_info, "download_url": f"/universign/documents/{transaction_local_id}/download", } except HTTPException: raise except Exception as e: logger.error(f"Erreur récupération info document: {e}") raise HTTPException(500, str(e))