Sage100-vps/routes/universign.py

561 lines
19 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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))