diff --git a/.gitignore b/.gitignore index 7d75fa8..ed8f8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ tools/ .env.staging .env.production -.trunk \ No newline at end of file +.trunk + +*clean*.py \ No newline at end of file diff --git a/api.py b/api.py index e2dc23d..bfb0e8d 100644 --- a/api.py +++ b/api.py @@ -133,7 +133,6 @@ async def lifespan(app: FastAPI): api_url=settings.universign_api_url, api_key=settings.universign_api_key ) - # Configuration du service avec les dépendances sync_service.configure( sage_client=sage_client, email_queue=email_queue, settings=settings ) @@ -2666,7 +2665,6 @@ async def get_current_sage_config( } -# Routes Collaborateurs @app.get( "/collaborateurs", response_model=List[CollaborateurDetails], diff --git a/config/config.py b/config/config.py index 63bf99b..d60c4d8 100644 --- a/config/config.py +++ b/config/config.py @@ -7,7 +7,6 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int @@ -21,15 +20,12 @@ class Settings(BaseSettings): SAGE_TYPE_BON_AVOIR: int = 50 SAGE_TYPE_FACTURE: int = 60 - # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str frontend_url: str - # === Base de données === database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" - # === SMTP === smtp_host: str smtp_port: int = 587 smtp_user: str @@ -37,21 +33,17 @@ class Settings(BaseSettings): smtp_from: str smtp_use_tls: bool = True - # === Universign === universign_api_key: str universign_api_url: str - # === API === api_host: str api_port: int api_reload: bool = False - # === Email Queue === max_email_workers: int = 3 max_retry_attempts: int = 3 retry_delay_seconds: int = 3 - # === CORS === cors_origins: List[str] = ["*"] diff --git a/core/dependencies.py b/core/dependencies.py index 039081c..ff443a6 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -6,89 +6,161 @@ from database import get_session, User from security.auth import decode_token from typing import Optional from datetime import datetime +import logging -security = HTTPBearer() +logger = logging.getLogger(__name__) + +security = HTTPBearer(auto_error=False) -async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), +async def get_current_user_hybrid( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - token = credentials.credentials + if credentials and credentials.credentials: + token = credentials.credentials - payload = decode_token(token) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token invalide ou expiré", - headers={"WWW-Authenticate": "Bearer"}, + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Type de token incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token malformé", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception.", + ) + + if user.locked_until and user.locked_until > datetime.now(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", + ) + + logger.debug(f" Authentifié via JWT: {user.email}") + return user + + api_key_obj = getattr(request.state, "api_key", None) + + if api_key_obj: + if api_key_obj.user_id: + result = await session.execute( + select(User).where(User.id == api_key_obj.user_id) + ) + user = result.scalar_one_or_none() + + if user: + logger.debug( + f" Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" + ) + return user + + from database import User as UserModel + + virtual_user = UserModel( + id=f"api_key_{api_key_obj.id}", + email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", + nom="API Key", + prenom=api_key_obj.name, + role="api_client", + is_verified=True, + is_active=True, ) - if payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Type de token incorrect", - headers={"WWW-Authenticate": "Bearer"}, - ) + virtual_user._is_api_key_user = True + virtual_user._api_key_obj = api_key_obj - user_id: str = payload.get("sub") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token malformé", - headers={"WWW-Authenticate": "Bearer"}, - ) + logger.debug(f" Authentifié via API Key: {api_key_obj.name} (user virtuel)") + return virtual_user - result = await session.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" - ) - - if not user.is_verified: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception.", - ) - - if user.locked_until and user.locked_until > datetime.now(): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées", - ) - - return user + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise (JWT ou API Key)", + headers={"WWW-Authenticate": "Bearer"}, + ) -async def get_current_user_optional( +async def get_current_user_optional_hybrid( + request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> Optional[User]: - if not credentials: - return None - + """Version optionnelle de get_current_user_hybrid (ne lève pas d'erreur)""" try: - return await get_current_user(credentials, session) + return await get_current_user_hybrid(request, credentials, session) except HTTPException: return None -def require_role(*allowed_roles: str): - async def role_checker(user: User = Depends(get_current_user)) -> User: - if user.role not in allowed_roles: +def require_role_hybrid(*allowed_roles: str): + async def role_checker( + request: Request, user: User = Depends(get_current_user_hybrid) + ) -> User: + is_api_key_user = getattr(user, "_is_api_key_user", False) + + if is_api_key_user: + if "api_client" not in allowed_roles and "*" not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", + ) + logger.debug(" API Key autorisée pour cette route") + return user + + if user.role not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) + return user return role_checker + + +def is_api_key_user(user: User) -> bool: + """Vérifie si l'utilisateur est authentifié via API Key""" + return getattr(user, "_is_api_key_user", False) + + +def get_api_key_from_user(user: User): + """Récupère l'objet API Key depuis un user virtuel""" + return getattr(user, "_api_key_obj", None) + + +get_current_user = get_current_user_hybrid +get_current_user_optional = get_current_user_optional_hybrid diff --git a/create_admin.py b/create_admin.py index d3cb786..96197ec 100644 --- a/create_admin.py +++ b/create_admin.py @@ -19,7 +19,6 @@ async def create_admin(): print(" Création d'un compte administrateur") print("=" * 60 + "\n") - # Saisie des informations email = input("Email de l'admin: ").strip().lower() if not email or "@" not in email: print(" Email invalide") @@ -32,7 +31,6 @@ async def create_admin(): print(" Prénom et nom requis") return False - # Mot de passe avec validation while True: password = input( "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " @@ -58,7 +56,6 @@ async def create_admin(): print(f"\n Un utilisateur avec l'email {email} existe déjà") return False - # Créer l'admin admin = User( id=str(uuid.uuid4()), email=email, diff --git a/routes/enterprise.py b/routes/enterprise.py index 2ed18d1..2de1e5f 100644 --- a/routes/enterprise.py +++ b/routes/enterprise.py @@ -22,7 +22,6 @@ async def rechercher_entreprise( try: logger.info(f" Recherche entreprise: '{q}'") - # Appel API api_response = await rechercher_entreprise_api(q, per_page) resultats_api = api_response.get("results", []) diff --git a/routes/universign.py b/routes/universign.py index b8a16c3..2bf0e7a 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -512,7 +512,6 @@ async def webhook_universign( transaction_id = None if payload.get("type", "").startswith("transaction.") and "payload" in payload: - # Le transaction_id est dans payload.object.id nested_object = payload.get("payload", {}).get("object", {}) if nested_object.get("object") == "transaction": transaction_id = nested_object.get("id") diff --git a/sage_client.py b/sage_client.py index 0137512..9ad7b50 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,4 +1,3 @@ -# sage_client.py import requests from typing import Dict, List, Optional from config.config import settings @@ -468,7 +467,6 @@ class SageGatewayClient: "tva_encaissement": tva_encaissement, } - # Champs optionnels if date_reglement: payload["date_reglement"] = date_reglement if code_journal: diff --git a/schemas/articles/articles.py b/schemas/articles/articles.py index 79b2d62..26996a7 100644 --- a/schemas/articles/articles.py +++ b/schemas/articles/articles.py @@ -76,7 +76,6 @@ class Article(BaseModel): ) nb_emplacements: int = Field(0, description="Nombre d'emplacements") - # Champs énumérés normalisés suivi_stock: Optional[int] = Field( None, description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé", diff --git a/schemas/documents/reglements.py b/schemas/documents/reglements.py index bf6d178..5cc5e2c 100644 --- a/schemas/documents/reglements.py +++ b/schemas/documents/reglements.py @@ -10,12 +10,10 @@ logger = logging.getLogger(__name__) class ReglementFactureCreate(BaseModel): """Requête de règlement d'une facture côté VPS""" - # Montant et devise montant: Decimal = Field(..., gt=0, description="Montant à régler") devise_code: Optional[int] = Field(0, description="Code devise (0=EUR par défaut)") cours_devise: Optional[Decimal] = Field(1.0, description="Cours de la devise") - # Mode et journal mode_reglement: int = Field( ..., ge=0, description="Code mode règlement depuis /reglements/modes" ) @@ -23,13 +21,11 @@ class ReglementFactureCreate(BaseModel): ..., min_length=1, description="Code journal depuis /journaux/tresorerie" ) - # Dates date_reglement: Optional[date] = Field( None, description="Date du règlement (défaut: aujourd'hui)" ) date_echeance: Optional[date] = Field(None, description="Date d'échéance") - # Références reference: Optional[str] = Field( "", max_length=17, description="Référence pièce règlement" ) @@ -37,7 +33,6 @@ class ReglementFactureCreate(BaseModel): "", max_length=35, description="Libellé du règlement" ) - # TVA sur encaissement tva_encaissement: Optional[bool] = Field( False, description="Appliquer TVA sur encaissement" ) @@ -81,7 +76,6 @@ class ReglementMultipleCreate(BaseModel): libelle: Optional[str] = Field("") tva_encaissement: Optional[bool] = Field(False) - # Factures spécifiques (optionnel) numeros_factures: Optional[List[str]] = Field( None, description="Si vide, règle les plus anciennes en premier" ) diff --git a/schemas/sage/sage_gateway.py b/schemas/sage/sage_gateway.py index e503641..9501129 100644 --- a/schemas/sage/sage_gateway.py +++ b/schemas/sage/sage_gateway.py @@ -10,7 +10,6 @@ class GatewayHealthStatus(str, Enum): UNKNOWN = "unknown" -# === CREATE === class SageGatewayCreate(BaseModel): name: str = Field( @@ -71,7 +70,6 @@ class SageGatewayUpdate(BaseModel): return v.rstrip("/") if v else v -# === RESPONSE === class SageGatewayResponse(BaseModel): id: str diff --git a/schemas/tiers/commercial.py b/schemas/tiers/commercial.py index 5a4685b..de74165 100644 --- a/schemas/tiers/commercial.py +++ b/schemas/tiers/commercial.py @@ -9,7 +9,6 @@ class CollaborateurBase(BaseModel): prenom: Optional[str] = Field(None, max_length=50) fonction: Optional[str] = Field(None, max_length=50) - # Adresse adresse: Optional[str] = Field(None, max_length=100) complement: Optional[str] = Field(None, max_length=100) code_postal: Optional[str] = Field(None, max_length=10) @@ -17,7 +16,6 @@ class CollaborateurBase(BaseModel): code_region: Optional[str] = Field(None, max_length=50) pays: Optional[str] = Field(None, max_length=50) - # Services service: Optional[str] = Field(None, max_length=50) vendeur: bool = Field(default=False) caissier: bool = Field(default=False) @@ -25,18 +23,15 @@ class CollaborateurBase(BaseModel): chef_ventes: bool = Field(default=False) numero_chef_ventes: Optional[int] = None - # Contact telephone: Optional[str] = Field(None, max_length=20) telecopie: Optional[str] = Field(None, max_length=20) email: Optional[EmailStr] = None tel_portable: Optional[str] = Field(None, max_length=20) - # Réseaux sociaux facebook: Optional[str] = Field(None, max_length=100) linkedin: Optional[str] = Field(None, max_length=100) skype: Optional[str] = Field(None, max_length=100) - # Autres matricule: Optional[str] = Field(None, max_length=20) sommeil: bool = Field(default=False) diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py index 58166a1..7a46ef5 100644 --- a/schemas/tiers/tiers.py +++ b/schemas/tiers/tiers.py @@ -14,7 +14,6 @@ class TypeTiersInt(IntEnum): class TiersDetails(BaseModel): - # IDENTIFICATION numero: Optional[str] = Field(None, description="Code tiers (CT_Num)") intitule: Optional[str] = Field( None, description="Raison sociale ou Nom complet (CT_Intitule)" @@ -37,7 +36,6 @@ class TiersDetails(BaseModel): ) code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") - # ADRESSE contact: Optional[str] = Field( None, description="Nom du contact principal (CT_Contact)" ) @@ -50,7 +48,6 @@ class TiersDetails(BaseModel): region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") - # TELECOM telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") @@ -58,13 +55,11 @@ class TiersDetails(BaseModel): facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") - # TAUX taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") - # STATISTIQUES statistique01: Optional[str] = Field( None, description="Statistique 1 (CT_Statistique01)" ) @@ -96,7 +91,6 @@ class TiersDetails(BaseModel): None, description="Statistique 10 (CT_Statistique10)" ) - # COMMERCIAL encours_autorise: Optional[float] = Field( None, description="Encours maximum autorisé (CT_Encours)" ) @@ -113,7 +107,6 @@ class TiersDetails(BaseModel): None, description="Détails du commercial/collaborateur" ) - # FACTURATION lettrage_auto: Optional[bool] = Field( None, description="Lettrage automatique (CT_Lettrage)" ) @@ -146,7 +139,6 @@ class TiersDetails(BaseModel): None, description="Bon à payer obligatoire (CT_BonAPayer)" ) - # LOGISTIQUE priorite_livraison: Optional[int] = Field( None, description="Priorité livraison (CT_PrioriteLivr)" ) @@ -160,17 +152,14 @@ class TiersDetails(BaseModel): None, description="Délai appro jours (CT_DelaiAppro)" ) - # COMMENTAIRE commentaire: Optional[str] = Field( None, description="Commentaire libre (CT_Commentaire)" ) - # ANALYTIQUE section_analytique: Optional[str] = Field( None, description="Section analytique (CA_Num)" ) - # ORGANISATION / SURVEILLANCE mode_reglement_code: Optional[int] = Field( None, description="Code mode règlement (MR_No)" ) @@ -200,7 +189,6 @@ class TiersDetails(BaseModel): None, description="Résultat financier (CT_SvResultat)" ) - # COMPTE GENERAL ET CATEGORIES compte_general: Optional[str] = Field( None, description="Compte général principal (CG_NumPrinc)" ) @@ -211,7 +199,6 @@ class TiersDetails(BaseModel): None, description="Catégorie comptable (N_CatCompta)" ) - # CONTACTS contacts: Optional[List[Contact]] = Field( default_factory=list, description="Liste des contacts du tiers" ) diff --git a/services/universign_document.py b/services/universign_document.py index fa899a9..394c3ce 100644 --- a/services/universign_document.py +++ b/services/universign_document.py @@ -23,7 +23,7 @@ class UniversignDocumentService: def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]: try: - logger.info(f"📋 Récupération documents pour transaction: {transaction_id}") + logger.info(f" Récupération documents pour transaction: {transaction_id}") response = requests.get( f"{self.api_url}/transactions/{transaction_id}", @@ -38,7 +38,6 @@ class UniversignDocumentService: 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')}, " @@ -64,7 +63,7 @@ class UniversignDocumentService: 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) + logger.error(f" Erreur fetch documents: {e}", exc_info=True) return None def download_signed_document( @@ -94,7 +93,6 @@ class UniversignDocumentService: 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() @@ -104,31 +102,30 @@ class UniversignDocumentService: 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") + 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}" + 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" 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]}" + f" Erreur HTTP {response.status_code}: {response.text[:500]}" ) return None @@ -136,13 +133,12 @@ class UniversignDocumentService: 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) + 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( @@ -153,7 +149,6 @@ class UniversignDocumentService: 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}" ) @@ -167,13 +162,11 @@ class UniversignDocumentService: 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( @@ -181,34 +174,30 @@ class UniversignDocumentService: ) 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}") + 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}") + 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 @@ -217,13 +206,11 @@ class UniversignDocumentService: 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" @@ -239,14 +226,14 @@ class UniversignDocumentService: except OSError as e: error = f"Erreur filesystem: {str(e)}" - logger.error(f"❌ {error}") + 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) + logger.error(f" {error}", exc_info=True) transaction.download_error = error await session.commit() return False, error @@ -294,7 +281,6 @@ class UniversignDocumentService: return deleted, int(size_freed_mb) - # === MÉTHODES DE DIAGNOSTIC === def diagnose_transaction(self, transaction_id: str) -> Dict: """ @@ -308,7 +294,6 @@ class UniversignDocumentService: } try: - # Test 1: Récupération de la transaction logger.info(f"Diagnostic transaction: {transaction_id}") response = requests.get( @@ -334,7 +319,6 @@ class UniversignDocumentService: "participants_count": len(data.get("participants", [])), } - # Test 2: Documents disponibles documents = data.get("documents", []) result["checks"]["documents"] = [] @@ -345,7 +329,6 @@ class UniversignDocumentService: "status": doc.get("status"), } - # Test téléchargement if doc.get("id"): download_url = ( f"{self.api_url}/transactions/{transaction_id}" diff --git a/services/universign_sync.py b/services/universign_sync.py index 28e633c..da634f2 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -159,7 +159,6 @@ class UniversignSyncService: return stats - # CORRECTION 1 : process_webhook dans universign_sync.py async def process_webhook( self, session: AsyncSession, payload: Dict, transaction_id: str = None ) -> Tuple[bool, Optional[str]]: @@ -167,9 +166,7 @@ class UniversignSyncService: Traite un webhook Universign - CORRECTION : meilleure gestion des payloads """ try: - # Si transaction_id n'est pas fourni, essayer de l'extraire if not transaction_id: - # Même logique que dans universign.py if ( payload.get("type", "").startswith("transaction.") and "payload" in payload @@ -195,7 +192,6 @@ class UniversignSyncService: f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}" ) - # Récupérer la transaction locale query = ( select(UniversignTransaction) .options(selectinload(UniversignTransaction.signers)) @@ -208,25 +204,20 @@ class UniversignSyncService: logger.warning(f"Transaction {transaction_id} inconnue localement") return False, "Transaction inconnue" - # Marquer comme webhook reçu transaction.webhook_received = True - # Stocker l'ancien statut pour comparaison old_status = transaction.local_status.value - # Force la synchronisation complète success, error = await self.sync_transaction( session, transaction, force=True ) - # Log du changement de statut if success and transaction.local_status.value != old_status: logger.info( f"Webhook traité: {transaction_id} | " f"{old_status} → {transaction.local_status.value}" ) - # Enregistrer le log du webhook await self._log_sync_attempt( session=session, transaction=transaction, @@ -248,7 +239,6 @@ class UniversignSyncService: logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True) return False, str(e) - # CORRECTION 2 : _sync_signers - Ne pas écraser les signers existants async def _sync_signers( self, session: AsyncSession, @@ -271,7 +261,6 @@ class UniversignSyncService: logger.warning(f"Signataire sans email à l'index {idx}, ignoré") continue - # PROTECTION : gérer les statuts inconnus raw_status = signer_data.get("status") or signer_data.get( "state", "waiting" ) @@ -302,7 +291,6 @@ class UniversignSyncService: if signer_data.get("name") and not signer.name: signer.name = signer_data.get("name") else: - # Nouveau signer avec gestion d'erreur intégrée try: signer = UniversignSigner( id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", @@ -330,7 +318,6 @@ class UniversignSyncService: ): import json - # Si statut final et pas de force, skip if is_final_status(transaction.local_status.value) and not force: logger.debug( f"⏭️ Skip {transaction.transaction_id}: statut final " @@ -340,14 +327,13 @@ class UniversignSyncService: await session.commit() return True, None - # Récupération du statut distant logger.info(f"Synchronisation: {transaction.transaction_id}") result = self.fetch_transaction_status(transaction.transaction_id) if not result: error = "Échec récupération données Universign" - logger.error(f"❌ {error}: {transaction.transaction_id}") + logger.error(f" {error}: {transaction.transaction_id}") transaction.sync_attempts += 1 transaction.sync_error = error await self._log_sync_attempt(session, transaction, "polling", False, error) @@ -358,9 +344,8 @@ class UniversignSyncService: universign_data = result["transaction"] universign_status_raw = universign_data.get("state", "draft") - logger.info(f"📊 Statut Universign brut: {universign_status_raw}") + logger.info(f" Statut Universign brut: {universign_status_raw}") - # Convertir le statut new_local_status = map_universign_to_local(universign_status_raw) previous_local_status = transaction.local_status.value @@ -369,7 +354,6 @@ class UniversignSyncService: f"{new_local_status} (Local) | Actuel: {previous_local_status}" ) - # Vérifier la transition if not is_transition_allowed(previous_local_status, new_local_status): logger.warning( f"Transition refusée: {previous_local_status} → {new_local_status}" @@ -383,10 +367,9 @@ class UniversignSyncService: if status_changed: logger.info( - f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" + f"CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" ) - # Mise à jour du statut Universign brut try: transaction.universign_status = UniversignTransactionStatus( universign_status_raw @@ -404,14 +387,12 @@ class UniversignSyncService: else: transaction.universign_status = UniversignTransactionStatus.STARTED - # Mise à jour du statut local transaction.local_status = LocalDocumentStatus(new_local_status) transaction.universign_status_updated_at = datetime.now() - # Mise à jour des dates if new_local_status == "EN_COURS" and not transaction.sent_at: transaction.sent_at = datetime.now() - logger.info("📅 Date d'envoi mise à jour") + logger.info("Date d'envoi mise à jour") if new_local_status == "SIGNE" and not transaction.signed_at: transaction.signed_at = datetime.now() @@ -419,15 +400,11 @@ class UniversignSyncService: if new_local_status == "REFUSE" and not transaction.refused_at: transaction.refused_at = datetime.now() - logger.info("❌ Date de refus mise à jour") + logger.info(" Date de refus mise à jour") if new_local_status == "EXPIRE" and not transaction.expired_at: transaction.expired_at = datetime.now() - logger.info("⏰ Date d'expiration mise à jour") - - # === SECTION CORRIGÉE: Gestion des documents === - # Ne plus chercher document_url dans la réponse (elle n'existe pas!) - # Le téléchargement se fait via le service document qui utilise le bon endpoint + logger.info("Date d'expiration mise à jour") documents = universign_data.get("documents", []) if documents: @@ -437,7 +414,6 @@ class UniversignSyncService: f"status={first_doc.get('status')}" ) - # Téléchargement automatique du document signé if new_local_status == "SIGNE" and not transaction.signed_document_path: logger.info("Déclenchement téléchargement document signé...") @@ -455,21 +431,15 @@ class UniversignSyncService: logger.warning(f"Échec téléchargement: {download_error}") except Exception as e: - logger.error( - f"❌ Erreur téléchargement document: {e}", exc_info=True - ) - # === FIN SECTION CORRIGÉE === + logger.error(f" Erreur téléchargement document: {e}", exc_info=True) - # Synchroniser les signataires await self._sync_signers(session, transaction, universign_data) - # Mise à jour des métadonnées de sync transaction.last_synced_at = datetime.now() transaction.sync_attempts += 1 transaction.needs_sync = not is_final_status(new_local_status) transaction.sync_error = None - # Log de la tentative await self._log_sync_attempt( session=session, transaction=transaction, @@ -491,7 +461,6 @@ class UniversignSyncService: await session.commit() - # Exécuter les actions post-changement if status_changed: logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") await self._execute_status_actions( @@ -507,7 +476,7 @@ class UniversignSyncService: except Exception as e: error_msg = f"Erreur lors de la synchronisation: {str(e)}" - logger.error(f"❌ {error_msg}", exc_info=True) + logger.error(f" {error_msg}", exc_info=True) transaction.sync_error = error_msg[:1000] transaction.sync_attempts += 1 @@ -519,20 +488,16 @@ class UniversignSyncService: return False, error_msg - # CORRECTION 3 : Amélioration du logging dans sync_transaction async def _sync_transaction_documents_corrected( self, session, transaction, universign_data: dict, new_local_status: str ): - # Récupérer et stocker les infos documents documents = universign_data.get("documents", []) if documents: - # Stocker le premier document_id pour référence first_doc = documents[0] first_doc_id = first_doc.get("id") if first_doc_id: - # Stocker l'ID du document (si le champ existe dans le modèle) if hasattr(transaction, "universign_document_id"): transaction.universign_document_id = first_doc_id @@ -543,7 +508,6 @@ class UniversignSyncService: else: logger.debug("Aucun document dans la réponse Universign") - # Téléchargement automatique si signé if new_local_status == "SIGNE": if not transaction.signed_document_path: logger.info("Déclenchement téléchargement document signé...") @@ -562,9 +526,7 @@ class UniversignSyncService: logger.warning(f"Échec téléchargement: {download_error}") except Exception as e: - logger.error( - f"❌ Erreur téléchargement document: {e}", exc_info=True - ) + logger.error(f" Erreur téléchargement document: {e}", exc_info=True) else: logger.debug( f"Document déjà téléchargé: {transaction.signed_document_path}" diff --git a/utils/generic_functions.py b/utils/generic_functions.py index 41b734b..29a361e 100644 --- a/utils/generic_functions.py +++ b/utils/generic_functions.py @@ -290,15 +290,11 @@ def _preparer_lignes_document(lignes: List) -> List[Dict]: UNIVERSIGN_TO_LOCAL: Dict[str, str] = { - # États initiaux "draft": "EN_ATTENTE", "ready": "EN_ATTENTE", - # En cours "started": "EN_COURS", - # États finaux (succès) "completed": "SIGNE", "closed": "SIGNE", - # États finaux (échec) "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", @@ -429,7 +425,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", - "icon": "❌", + "icon": "", "color": "red", }, "EXPIRE": { diff --git a/utils/universign_status_mapping.py b/utils/universign_status_mapping.py index 50e29cc..8391698 100644 --- a/utils/universign_status_mapping.py +++ b/utils/universign_status_mapping.py @@ -96,7 +96,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", - "icon": "❌", + "icon": "", "color": "red", }, "EXPIRE": {