561 lines
19 KiB
Python
561 lines
19 KiB
Python
"""
|
||
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))
|