378 lines
14 KiB
Python
378 lines
14 KiB
Python
import os
|
|
import logging
|
|
import requests
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Optional, Tuple, Dict, List
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents"))
|
|
SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
class UniversignDocumentService:
|
|
"""Service de gestion des documents signés Universign - VERSION CORRIGÉE"""
|
|
|
|
def __init__(self, api_url: str, api_key: str, timeout: int = 60):
|
|
self.api_url = api_url.rstrip("/")
|
|
self.api_key = api_key
|
|
self.timeout = timeout
|
|
self.auth = (api_key, "")
|
|
|
|
def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]:
|
|
try:
|
|
logger.info(f"📋 Récupération documents pour transaction: {transaction_id}")
|
|
|
|
response = requests.get(
|
|
f"{self.api_url}/transactions/{transaction_id}",
|
|
auth=self.auth,
|
|
timeout=self.timeout,
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
documents = data.get("documents", [])
|
|
|
|
logger.info(f"✅ {len(documents)} document(s) trouvé(s)")
|
|
|
|
# Log détaillé de chaque document
|
|
for idx, doc in enumerate(documents):
|
|
logger.debug(
|
|
f" Document {idx}: id={doc.get('id')}, "
|
|
f"name={doc.get('name')}, status={doc.get('status')}"
|
|
)
|
|
|
|
return documents
|
|
|
|
elif response.status_code == 404:
|
|
logger.warning(
|
|
f"Transaction {transaction_id} introuvable sur Universign"
|
|
)
|
|
return None
|
|
|
|
else:
|
|
logger.error(
|
|
f"Erreur HTTP {response.status_code} pour {transaction_id}: "
|
|
f"{response.text[:500]}"
|
|
)
|
|
return None
|
|
|
|
except requests.exceptions.Timeout:
|
|
logger.error(f"⏱️ Timeout récupération transaction {transaction_id}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur fetch documents: {e}", exc_info=True)
|
|
return None
|
|
|
|
def download_signed_document(
|
|
self, transaction_id: str, document_id: str
|
|
) -> Optional[bytes]:
|
|
try:
|
|
download_url = (
|
|
f"{self.api_url}/transactions/{transaction_id}"
|
|
f"/documents/{document_id}/download"
|
|
)
|
|
|
|
logger.info(f"Téléchargement depuis: {download_url}")
|
|
|
|
response = requests.get(
|
|
download_url,
|
|
auth=self.auth,
|
|
timeout=self.timeout,
|
|
stream=True,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
content_type = response.headers.get("Content-Type", "")
|
|
content_length = response.headers.get("Content-Length", "unknown")
|
|
|
|
logger.info(
|
|
f"✅ Téléchargement réussi: "
|
|
f"Content-Type={content_type}, Size={content_length}"
|
|
)
|
|
|
|
# Vérification du type de contenu
|
|
if (
|
|
"pdf" not in content_type.lower()
|
|
and "octet-stream" not in content_type.lower()
|
|
):
|
|
logger.warning(
|
|
f"⚠️ Type de contenu inattendu: {content_type}. "
|
|
f"Tentative de lecture quand même..."
|
|
)
|
|
|
|
# Lecture du contenu
|
|
content = response.content
|
|
|
|
if len(content) < 1024:
|
|
logger.error(f"❌ Document trop petit: {len(content)} octets")
|
|
return None
|
|
|
|
return content
|
|
|
|
elif response.status_code == 404:
|
|
logger.error(
|
|
f"❌ Document {document_id} introuvable pour transaction {transaction_id}"
|
|
)
|
|
return None
|
|
|
|
elif response.status_code == 403:
|
|
logger.error(
|
|
f"❌ Accès refusé au document {document_id}. "
|
|
f"Vérifiez que la transaction est bien signée."
|
|
)
|
|
return None
|
|
|
|
else:
|
|
logger.error(
|
|
f"❌ Erreur HTTP {response.status_code}: {response.text[:500]}"
|
|
)
|
|
return None
|
|
|
|
except requests.exceptions.Timeout:
|
|
logger.error(f"⏱️ Timeout téléchargement document {document_id}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"❌ Erreur téléchargement: {e}", exc_info=True)
|
|
return None
|
|
|
|
async def download_and_store_signed_document(
|
|
self, session: AsyncSession, transaction, force: bool = False
|
|
) -> Tuple[bool, Optional[str]]:
|
|
# Vérification si déjà téléchargé
|
|
if not force and transaction.signed_document_path:
|
|
if os.path.exists(transaction.signed_document_path):
|
|
logger.debug(
|
|
f"✅ Document déjà téléchargé: {transaction.transaction_id}"
|
|
)
|
|
return True, None
|
|
|
|
transaction.download_attempts += 1
|
|
|
|
try:
|
|
# ÉTAPE 1: Récupérer les documents de la transaction
|
|
logger.info(
|
|
f"Récupération document signé pour: {transaction.transaction_id}"
|
|
)
|
|
|
|
documents = self.fetch_transaction_documents(transaction.transaction_id)
|
|
|
|
if not documents:
|
|
error = "Aucun document trouvé dans la transaction Universign"
|
|
logger.warning(f"⚠️ {error}")
|
|
transaction.download_error = error
|
|
await session.commit()
|
|
return False, error
|
|
|
|
# ÉTAPE 2: Récupérer le premier document (ou chercher celui qui est signé)
|
|
document_id = None
|
|
for doc in documents:
|
|
doc_id = doc.get("id")
|
|
doc_status = doc.get("status", "").lower()
|
|
|
|
# Priorité aux documents marqués comme signés/complétés
|
|
if doc_status in ["signed", "completed", "closed"]:
|
|
document_id = doc_id
|
|
logger.info(
|
|
f"Document signé trouvé: {doc_id} (status: {doc_status})"
|
|
)
|
|
break
|
|
|
|
# Fallback sur le premier document si aucun n'est explicitement signé
|
|
if document_id is None:
|
|
document_id = doc_id
|
|
|
|
if not document_id:
|
|
error = "Impossible de déterminer l'ID du document à télécharger"
|
|
logger.error(f"❌ {error}")
|
|
transaction.download_error = error
|
|
await session.commit()
|
|
return False, error
|
|
|
|
# Stocker le document_id pour référence future
|
|
if hasattr(transaction, "universign_document_id"):
|
|
transaction.universign_document_id = document_id
|
|
|
|
# ÉTAPE 3: Télécharger le document signé
|
|
pdf_content = self.download_signed_document(
|
|
transaction_id=transaction.transaction_id, document_id=document_id
|
|
)
|
|
|
|
if not pdf_content:
|
|
error = f"Échec téléchargement document {document_id}"
|
|
logger.error(f"❌ {error}")
|
|
transaction.download_error = error
|
|
await session.commit()
|
|
return False, error
|
|
|
|
# ÉTAPE 4: Stocker le fichier localement
|
|
filename = self._generate_filename(transaction)
|
|
file_path = SIGNED_DOCS_DIR / filename
|
|
|
|
with open(file_path, "wb") as f:
|
|
f.write(pdf_content)
|
|
|
|
file_size = os.path.getsize(file_path)
|
|
|
|
# Mise à jour de la transaction
|
|
transaction.signed_document_path = str(file_path)
|
|
transaction.signed_document_downloaded_at = datetime.now()
|
|
transaction.signed_document_size_bytes = file_size
|
|
transaction.download_error = None
|
|
|
|
# Stocker aussi l'URL de téléchargement pour référence
|
|
transaction.document_url = (
|
|
f"{self.api_url}/transactions/{transaction.transaction_id}"
|
|
f"/documents/{document_id}/download"
|
|
)
|
|
|
|
await session.commit()
|
|
|
|
logger.info(
|
|
f"✅ Document signé téléchargé: {filename} ({file_size / 1024:.1f} KB)"
|
|
)
|
|
|
|
return True, None
|
|
|
|
except OSError as e:
|
|
error = f"Erreur filesystem: {str(e)}"
|
|
logger.error(f"❌ {error}")
|
|
transaction.download_error = error
|
|
await session.commit()
|
|
return False, error
|
|
|
|
except Exception as e:
|
|
error = f"Erreur inattendue: {str(e)}"
|
|
logger.error(f"❌ {error}", exc_info=True)
|
|
transaction.download_error = error
|
|
await session.commit()
|
|
return False, error
|
|
|
|
def _generate_filename(self, transaction) -> str:
|
|
"""Génère un nom de fichier unique pour le document signé"""
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
tx_id = transaction.transaction_id.replace("tr_", "")
|
|
filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}_signed.pdf"
|
|
return filename
|
|
|
|
def get_document_path(self, transaction) -> Optional[Path]:
|
|
"""Retourne le chemin du document signé s'il existe"""
|
|
if not transaction.signed_document_path:
|
|
return None
|
|
path = Path(transaction.signed_document_path)
|
|
if path.exists():
|
|
return path
|
|
return None
|
|
|
|
async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]:
|
|
"""Supprime les anciens documents signés"""
|
|
from datetime import timedelta
|
|
|
|
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
|
|
deleted = 0
|
|
size_freed = 0
|
|
|
|
for file_path in SIGNED_DOCS_DIR.glob("*.pdf"):
|
|
try:
|
|
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
|
|
if file_time < cutoff_date:
|
|
size_freed += os.path.getsize(file_path)
|
|
os.remove(file_path)
|
|
deleted += 1
|
|
logger.info(f"🗑️ Supprimé: {file_path.name}")
|
|
except Exception as e:
|
|
logger.error(f"Erreur suppression {file_path}: {e}")
|
|
|
|
size_freed_mb = size_freed / (1024 * 1024)
|
|
logger.info(
|
|
f"Nettoyage terminé: {deleted} fichiers supprimés "
|
|
f"({size_freed_mb:.2f} MB libérés)"
|
|
)
|
|
|
|
return deleted, int(size_freed_mb)
|
|
|
|
# === MÉTHODES DE DIAGNOSTIC ===
|
|
|
|
def diagnose_transaction(self, transaction_id: str) -> Dict:
|
|
"""
|
|
Diagnostic complet d'une transaction pour debug
|
|
"""
|
|
result = {
|
|
"transaction_id": transaction_id,
|
|
"api_url": self.api_url,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"checks": {},
|
|
}
|
|
|
|
try:
|
|
# Test 1: Récupération de la transaction
|
|
logger.info(f"Diagnostic transaction: {transaction_id}")
|
|
|
|
response = requests.get(
|
|
f"{self.api_url}/transactions/{transaction_id}",
|
|
auth=self.auth,
|
|
timeout=self.timeout,
|
|
)
|
|
|
|
result["checks"]["transaction_fetch"] = {
|
|
"status_code": response.status_code,
|
|
"success": response.status_code == 200,
|
|
}
|
|
|
|
if response.status_code != 200:
|
|
result["checks"]["transaction_fetch"]["error"] = response.text[:500]
|
|
return result
|
|
|
|
data = response.json()
|
|
|
|
result["checks"]["transaction_data"] = {
|
|
"state": data.get("state"),
|
|
"documents_count": len(data.get("documents", [])),
|
|
"participants_count": len(data.get("participants", [])),
|
|
}
|
|
|
|
# Test 2: Documents disponibles
|
|
documents = data.get("documents", [])
|
|
result["checks"]["documents"] = []
|
|
|
|
for doc in documents:
|
|
doc_info = {
|
|
"id": doc.get("id"),
|
|
"name": doc.get("name"),
|
|
"status": doc.get("status"),
|
|
}
|
|
|
|
# Test téléchargement
|
|
if doc.get("id"):
|
|
download_url = (
|
|
f"{self.api_url}/transactions/{transaction_id}"
|
|
f"/documents/{doc['id']}/download"
|
|
)
|
|
|
|
try:
|
|
dl_response = requests.head(
|
|
download_url,
|
|
auth=self.auth,
|
|
timeout=10,
|
|
)
|
|
doc_info["download_check"] = {
|
|
"url": download_url,
|
|
"status_code": dl_response.status_code,
|
|
"accessible": dl_response.status_code in [200, 302],
|
|
"content_type": dl_response.headers.get("Content-Type"),
|
|
}
|
|
except Exception as e:
|
|
doc_info["download_check"] = {"error": str(e)}
|
|
|
|
result["checks"]["documents"].append(doc_info)
|
|
|
|
result["success"] = True
|
|
|
|
except Exception as e:
|
|
result["success"] = False
|
|
result["error"] = str(e)
|
|
|
|
return result
|