diff --git a/.gitignore b/.gitignore index b88f070..fe762fa 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,4 @@ dist/ data/sage_dataven.db -cleaner.py \ No newline at end of file +tools/ \ No newline at end of file diff --git a/api.py b/api.py index 98cb6d7..6995bb5 100644 --- a/api.py +++ b/api.py @@ -528,73 +528,276 @@ class FournisseurDetails(BaseModel): class ArticleResponse(BaseModel): - """ - Modèle de réponse pour un article Sage - - ENRICHI avec tous les champs disponibles - """ - + """Modèle de réponse pour un article avec toutes ses informations""" + reference: str = Field(..., description="Référence article (AR_Ref)") designation: str = Field(..., description="Désignation principale (AR_Design)") designation_complementaire: Optional[str] = Field( - None, description="Désignation complémentaire" + None, description="Désignation complémentaire (AR_Design2)" + ) + description: Optional[str] = Field( + None, description="Description détaillée / Commentaire (AR_Commentaire, AR_Info)" + ) + + code_ean: Optional[str] = Field( + None, description="Code EAN / Code-barres principal (AR_CodeBarre)" + ) + code_barre: Optional[str] = Field( + None, description="Code-barres (alias de code_ean)" + ) + raccourci: Optional[str] = Field( + None, description="Code raccourci (AR_Raccourci)" + ) + racine: Optional[str] = Field( + None, description="Racine de référence (AR_Racine)" + ) + + prix_vente: float = Field( + ..., ge=0, description="Prix de vente HT unitaire (AR_PrixVen)" + ) + prix_achat: Optional[float] = Field( + None, ge=0, description="Prix d'achat HT (AR_PrixAch)" + ) + prix_revient: Optional[float] = Field( + None, ge=0, description="Prix de revient (AR_PrixRev)" + ) + prix_ttc: Optional[float] = Field( + None, ge=0, description="Prix de vente TTC (AR_PrixTTC)" + ) + coef: Optional[float] = Field( + None, ge=0, description="Coefficient multiplicateur (AR_Coef)" + ) + + stock_reel: float = Field( + default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)" + ) + stock_mini: Optional[float] = Field( + None, ge=0, description="Stock minimum (F_ARTSTOCK.AS_QteMini)" + ) + stock_maxi: Optional[float] = Field( + None, ge=0, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)" ) - - code_ean: Optional[str] = Field(None, description="Code EAN / Code-barres") - code_barre: Optional[str] = Field(None, description="Code-barres (alias)") - - prix_vente: float = Field(..., description="Prix de vente HT") - prix_achat: Optional[float] = Field(None, description="Prix d'achat HT") - prix_revient: Optional[float] = Field(None, description="Prix de revient") - - stock_reel: float = Field(..., description="Stock réel") - stock_mini: Optional[float] = Field(None, description="Stock minimum") - stock_maxi: Optional[float] = Field(None, description="Stock maximum") stock_reserve: Optional[float] = Field( - None, description="Stock réservé (en commande)" + None, ge=0, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)" ) stock_commande: Optional[float] = Field( - None, description="Stock en commande fournisseur" + None, ge=0, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)" ) stock_disponible: Optional[float] = Field( - None, description="Stock disponible (réel - réservé)" + None, description="Stock disponible = réel - réservé" ) - - description: Optional[str] = Field( - None, description="Description détaillée / Commentaire" + suivi_stock: Optional[bool] = Field( + None, description="Suivi de stock activé (AR_SuiviStock)" ) - + + unite_vente: Optional[str] = Field( + None, max_length=10, description="Unité de vente (AR_UniteVen)" + ) + unite_achat: Optional[str] = Field( + None, max_length=10, description="Unité d'achat/stock (AR_Unite)" + ) + unite_poids: Optional[str] = Field( + None, max_length=10, description="Unité de poids (AR_UnitePoids)" + ) + + poids: Optional[float] = Field( + None, ge=0, description="Poids net unitaire (AR_Poids, AR_PoidsNet)" + ) + poids_brut: Optional[float] = Field( + None, ge=0, description="Poids brut unitaire (AR_PoidsBrut)" + ) + volume: Optional[float] = Field( + None, ge=0, description="Volume unitaire en m³ (AR_Volume)" + ) + type_article: Optional[int] = Field( - None, description="Type d'article (0=Article, 1=Prestation, 2=Divers)" + None, + ge=0, + le=3, + description="Type d'article : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)" ) - type_article_libelle: Optional[str] = Field(None, description="Libellé du type") - famille_code: Optional[str] = Field(None, description="Code famille") - famille_libelle: Optional[str] = Field(None, description="Libellé famille") - - fournisseur_principal: Optional[str] = Field( - None, description="Code fournisseur principal" + type_article_libelle: Optional[str] = Field( + None, description="Libellé du type d'article" + ) + + famille_code: Optional[str] = Field( + None, max_length=20, description="Code famille (FA_CodeFamille)" + ) + famille_libelle: Optional[str] = Field( + None, description="Libellé de la famille (F_FAMILLE.FA_Intitule)" + ) + famille_type: Optional[int] = Field( + None, description="Type de famille : 0=Détail, 1=Total (F_FAMILLE.FA_Type)" + ) + famille_unite_vente: Optional[str] = Field( + None, description="Unité de vente héritée de la famille (F_FAMILLE.FA_UniteVen)" + ) + famille_coef: Optional[float] = Field( + None, description="Coefficient hérité de la famille (F_FAMILLE.FA_Coef)" + ) + famille_suivi_stock: Optional[bool] = Field( + None, description="Suivi stock hérité de la famille (F_FAMILLE.FA_SuiviStock)" + ) + famille_compte_vente: Optional[str] = Field( + None, description="Compte comptable de vente de la famille (F_FAMILLE.CG_NumVte)" + ) + famille_compte_achat: Optional[str] = Field( + None, description="Compte comptable d'achat de la famille (F_FAMILLE.CG_NumAch)" + ) + + nature: Optional[int] = Field( + None, description="Nature de l'article (AR_Nature)" + ) + garantie: Optional[int] = Field( + None, ge=0, description="Durée de garantie en mois (AR_Garantie)" + ) + + fournisseur_principal: Optional[int] = Field( + None, description="N° compte du fournisseur principal (CO_No)" ) fournisseur_nom: Optional[str] = Field( - None, description="Nom fournisseur principal" + None, description="Nom du fournisseur principal (F_COMPTET.CT_Intitule)" + ) + conditionnement: Optional[str] = Field( + None, description="Conditionnement d'achat (AR_Condition)" + ) + nb_colis: Optional[int] = Field( + None, ge=0, description="Nombre de colis par unité (AR_NbColis)" + ) + article_substitut: Optional[str] = Field( + None, description="Référence article de substitution (AR_Substitut)" + ) + + est_actif: bool = Field( + default=True, description="Article actif (AR_Sommeil = 0)" + ) + en_sommeil: bool = Field( + default=False, description="Article en sommeil (AR_Sommeil = 1)" + ) + soumis_escompte: Optional[bool] = Field( + None, description="Soumis à escompte (AR_Escompte)" + ) + publie: Optional[bool] = Field( + None, description="Publié sur web/catalogue (AR_Publie)" + ) + hors_statistique: Optional[bool] = Field( + None, description="Exclus des statistiques (AR_HorsStat)" + ) + vente_debit: Optional[bool] = Field( + None, description="Vente au débit (AR_VteDebit)" + ) + non_imprimable: Optional[bool] = Field( + None, description="Non imprimable sur documents (AR_NotImp)" + ) + fictif: Optional[bool] = Field( + None, description="Article fictif (AR_Fictif)" + ) + sous_traitance: Optional[bool] = Field( + None, description="Article en sous-traitance (AR_SousTraitance)" + ) + criticite: Optional[int] = Field( + None, ge=0, description="Niveau de criticité (AR_Criticite)" + ) + + code_fiscal: Optional[str] = Field( + None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" + ) + tva_code: Optional[str] = Field( + None, description="Code TVA (F_TAXE.TA_Code)" + ) + tva_taux: Optional[float] = Field( + None, ge=0, le=100, description="Taux de TVA en % (F_TAXE.TA_Taux)" + ) + + stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)") + stat_02: Optional[str] = Field(None, description="Statistique 2 (AR_Stat02)") + stat_03: Optional[str] = Field(None, description="Statistique 3 (AR_Stat03)") + stat_04: Optional[str] = Field(None, description="Statistique 4 (AR_Stat04)") + stat_05: Optional[str] = Field(None, description="Statistique 5 (AR_Stat05)") + + categorie_1: Optional[int] = Field( + None, description="Catégorie comptable 1 (CL_No1)" + ) + categorie_2: Optional[int] = Field( + None, description="Catégorie comptable 2 (CL_No2)" + ) + categorie_3: Optional[int] = Field( + None, description="Catégorie comptable 3 (CL_No3)" + ) + categorie_4: Optional[int] = Field( + None, description="Catégorie comptable 4 (CL_No4)" + ) + + date_creation: Optional[str] = Field( + None, description="Date de création (AR_DateCre)" ) - - unite_vente: Optional[str] = Field(None, description="Unité de vente") - unite_achat: Optional[str] = Field(None, description="Unité d'achat") - - poids: Optional[float] = Field(None, description="Poids (kg)") - volume: Optional[float] = Field(None, description="Volume (m³)") - - est_actif: bool = Field(True, description="Article actif") - en_sommeil: bool = Field(False, description="Article en sommeil") - - tva_code: Optional[str] = Field(None, description="Code TVA") - tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)") - - date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field( - None, description="Date de dernière modification" + None, description="Date de dernière modification (AR_DateModif)" ) - + date_sommeil: Optional[str] = Field( + None, description="Date de mise en sommeil (AR_DateSommeil)" + ) + + class Config: + json_schema_extra = { + "example": { + "reference": "ART-001", + "designation": "Ordinateur portable 15 pouces", + "designation_complementaire": "Intel i7, 16GB RAM, 512GB SSD", + "code_ean": "3760123456789", + "prix_vente": 899.00, + "prix_achat": 650.00, + "prix_revient": 675.50, + "stock_reel": 25.0, + "stock_mini": 5.0, + "stock_maxi": 50.0, + "stock_disponible": 22.0, + "unite_vente": "PCE", + "poids": 2.1, + "type_article": 0, + "type_article_libelle": "Article", + "famille_code": "INFO", + "famille_libelle": "Informatique", + "fournisseur_principal": 101, + "fournisseur_nom": "TechSupply SAS", + "est_actif": True, + "en_sommeil": False, + "tva_code": "T20", + "tva_taux": 20.0, + "date_creation": "2023-01-15", + "date_modification": "2024-11-20" + } + } + + +class ArticleListResponse(BaseModel): + """Réponse pour une liste d'articles""" + + total: int = Field(..., description="Nombre total d'articles") + articles: list[ArticleResponse] = Field(..., description="Liste des articles") + filtre_applique: Optional[str] = Field( + None, description="Filtre de recherche appliqué" + ) + avec_stock: bool = Field( + True, description="Indique si les stocks ont été chargés" + ) + avec_famille: bool = Field( + True, description="Indique si les familles ont été enrichies" + ) + + class Config: + json_schema_extra = { + "example": { + "total": 1250, + "filtre_applique": "ordinateur", + "avec_stock": True, + "avec_famille": True, + "articles": [ + # Exemple d'article (voir ArticleResponse) + ] + } + } + class LigneDevis(BaseModel): article_code: str @@ -1765,7 +1968,6 @@ class FamilleCreateRequest(BaseModel): class FamilleResponse(BaseModel): """Modèle de réponse pour une famille d'articles - aligné sur lister_toutes_familles""" - # === Identification === code: str = Field(..., description="Code famille") intitule: str = Field(..., description="Intitulé") type: int = Field(..., description="Type (0=Détail, 1=Total)") @@ -1773,35 +1975,29 @@ class FamilleResponse(BaseModel): est_total: bool = Field(..., description="True si type Total") est_detail: Optional[bool] = Field(None, description="True si type Détail") - # === Vente et unités === unite_vente: Optional[str] = Field(None, description="Unité de vente") unite_poids: Optional[str] = Field(None, description="Unité de poids") coef: Optional[float] = Field(None, description="Coefficient") - # === Stock et logistique === suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)") nb_colis: Optional[int] = Field(None, description="Nombre de colis") - # === Comptabilité === compte_achat: Optional[str] = Field(None, description="Compte général achat") compte_vente: Optional[str] = Field(None, description="Compte général vente") code_fiscal: Optional[str] = Field(None, description="Code fiscal") escompte: Optional[bool] = Field(None, description="Escompte autorisé") - # === Organisation === est_centrale: Optional[bool] = Field(None, description="Famille centrale") nature: Optional[int] = Field(None, description="Nature de la famille") pays: Optional[str] = Field(None, description="Pays d'origine") - # === Classifications === categorie_1: Optional[int] = Field(None, description="Catégorie 1") categorie_2: Optional[int] = Field(None, description="Catégorie 2") categorie_3: Optional[int] = Field(None, description="Catégorie 3") categorie_4: Optional[int] = Field(None, description="Catégorie 4") - # === Statistiques === stat_01: Optional[str] = Field(None, description="Statistique 1") stat_02: Optional[str] = Field(None, description="Statistique 2") stat_03: Optional[str] = Field(None, description="Statistique 3") @@ -1810,7 +2006,6 @@ class FamilleResponse(BaseModel): hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques") est_statistique: Optional[bool] = Field(None, description="Incluse dans les statistiques (legacy)") - # === Paramètres commerciaux === vente_debit: Optional[bool] = Field(None, description="Vente au débit") non_imprimable: Optional[bool] = Field(None, description="Non imprimable") contremarque: Optional[bool] = Field(None, description="Contremarque") @@ -1818,26 +2013,20 @@ class FamilleResponse(BaseModel): fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") publie: Optional[bool] = Field(None, description="Publié") - # === Références === racine_reference: Optional[str] = Field(None, description="Racine référence article") racine_code_barre: Optional[str] = Field(None, description="Racine code-barres") raccourci: Optional[str] = Field(None, description="Raccourci clavier") - # === Gestion === sous_traitance: Optional[bool] = Field(None, description="Sous-traitance") fictif: Optional[bool] = Field(None, description="Famille fictive") criticite: Optional[int] = Field(None, description="Niveau de criticité") - # === Métadonnées (spécifiques à lire_famille) === avertissement: Optional[str] = Field(None, description="Avertissement si famille Total") index_com: Optional[int] = Field(None, description="Index COM (lire_famille uniquement)") date_creation: Optional[str] = Field(None, description="Date de création") date_modification: Optional[str] = Field(None, description="Date de modification") nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") - - # === Champs bruts SQL (optionnels) === - # Permet de conserver tous les champs SQL si besoin - champs_sql: Optional[Dict[str, Any]] = Field(None, description="Champs SQL bruts (si demandés)") + class Config: json_schema_extra = { @@ -5387,4 +5576,4 @@ if __name__ == "__main__": host=settings.api_host, port=settings.api_port, reload=settings.api_reload, - ) + ) \ No newline at end of file diff --git a/database/__init__.py b/database/__init__.py index 579c644..829aa19 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -15,30 +15,25 @@ from database.models import ( AuditLog, StatutEmail, StatutSignature, - # Nouveaux modèles auth User, RefreshToken, LoginAttempt, ) __all__ = [ - # Config "engine", "async_session_factory", "init_db", "get_session", "close_db", - # Models existants "Base", "EmailLog", "SignatureLog", "WorkflowLog", "CacheMetadata", "AuditLog", - # Enums "StatutEmail", "StatutSignature", - # Modèles auth "User", "RefreshToken", "LoginAttempt", diff --git a/database/models.py b/database/models.py index da8c7b2..77c1b30 100644 --- a/database/models.py +++ b/database/models.py @@ -14,9 +14,6 @@ import enum Base = declarative_base() -# ============================================================================ -# Enums -# ============================================================================ class StatutEmail(str, enum.Enum): @@ -40,9 +37,6 @@ class StatutSignature(str, enum.Enum): EXPIRE = "EXPIRE" -# ============================================================================ -# Tables -# ============================================================================ class EmailLog(Base): @@ -53,36 +47,28 @@ class EmailLog(Base): __tablename__ = "email_logs" - # Identifiant id = Column(String(36), primary_key=True) - # Destinataires destinataire = Column(String(255), nullable=False, index=True) cc = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié - # Contenu sujet = Column(String(500), nullable=False) corps_html = Column(Text, nullable=False) - # Documents attachés document_ids = Column(Text, nullable=True) # Séparés par virgules type_document = Column(Integer, nullable=True) - # Statut statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - # Tracking temporel date_creation = Column(DateTime, default=datetime.now, nullable=False) date_envoi = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True) - # Retry automatique nb_tentatives = Column(Integer, default=0) derniere_erreur = Column(Text, nullable=True) prochain_retry = Column(DateTime, nullable=True) - # Métadonnées ip_envoi = Column(String(45), nullable=True) user_agent = Column(String(500), nullable=True) @@ -98,22 +84,17 @@ class SignatureLog(Base): __tablename__ = "signature_logs" - # Identifiant id = Column(String(36), primary_key=True) - # Document Sage associé document_id = Column(String(100), nullable=False, index=True) type_document = Column(Integer, nullable=False) - # Universign transaction_id = Column(String(100), unique=True, index=True, nullable=True) signer_url = Column(String(500), nullable=True) - # Signataire email_signataire = Column(String(255), nullable=False, index=True) nom_signataire = Column(String(255), nullable=False) - # Statut statut = Column( SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True ) @@ -121,12 +102,10 @@ class SignatureLog(Base): date_signature = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True) - # Relances est_relance = Column(Boolean, default=False) nb_relances = Column(Integer, default=0) derniere_relance = Column(DateTime, nullable=True) - # Métadonnées raison_refus = Column(Text, nullable=True) ip_signature = Column(String(45), nullable=True) @@ -142,26 +121,21 @@ class WorkflowLog(Base): __tablename__ = "workflow_logs" - # Identifiant id = Column(String(36), primary_key=True) - # Documents document_source = Column(String(100), nullable=False, index=True) type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. document_cible = Column(String(100), nullable=False, index=True) type_cible = Column(Integer, nullable=False) - # Métadonnées de transformation nb_lignes = Column(Integer, nullable=True) montant_ht = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True) - # Tracking date_transformation = Column(DateTime, default=datetime.now, nullable=False) utilisateur = Column(String(100), nullable=True) - # Résultat succes = Column(Boolean, default=True) erreur = Column(Text, nullable=True) duree_ms = Column(Integer, nullable=True) # Durée en millisecondes @@ -180,17 +154,14 @@ class CacheMetadata(Base): id = Column(Integer, primary_key=True, autoincrement=True) - # Type de cache cache_type = Column( String(50), unique=True, nullable=False ) # 'clients' ou 'articles' - # Statistiques last_refresh = Column(DateTime, default=datetime.now) item_count = Column(Integer, default=0) refresh_duration_ms = Column(Float, nullable=True) - # Santé last_error = Column(Text, nullable=True) error_count = Column(Integer, default=0) @@ -208,30 +179,25 @@ class AuditLog(Base): id = Column(Integer, primary_key=True, autoincrement=True) - # Action action = Column( String(100), nullable=False, index=True ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_id = Column(String(100), nullable=True, index=True) - # Utilisateur (si authentification ajoutée plus tard) utilisateur = Column(String(100), nullable=True) ip_address = Column(String(45), nullable=True) - # Résultat succes = Column(Boolean, default=True) details = Column(Text, nullable=True) # JSON stringifié erreur = Column(Text, nullable=True) - # Timestamp date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) def __repr__(self): return f"" -# Ajouter ces modèles à la fin de database/models.py class User(Base): @@ -245,26 +211,21 @@ class User(Base): email = Column(String(255), unique=True, nullable=False, index=True) hashed_password = Column(String(255), nullable=False) - # Profil nom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False) role = Column(String(50), default="user") # user, admin, commercial - # Validation email is_verified = Column(Boolean, default=False) verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token_expires = Column(DateTime, nullable=True) - # Sécurité is_active = Column(Boolean, default=True) failed_login_attempts = Column(Integer, default=0) locked_until = Column(DateTime, nullable=True) - # Mot de passe oublié reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token_expires = Column(DateTime, nullable=True) - # Timestamps created_at = Column(DateTime, default=datetime.now, nullable=False) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) last_login = Column(DateTime, nullable=True) @@ -284,15 +245,12 @@ class RefreshToken(Base): user_id = Column(String(36), nullable=False, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True) - # Métadonnées device_info = Column(String(500), nullable=True) ip_address = Column(String(45), nullable=True) - # Expiration expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False) - # Révocation is_revoked = Column(Boolean, default=False) revoked_at = Column(DateTime, nullable=True) diff --git a/email_queue.py b/email_queue.py index 05bd286..344f986 100644 --- a/email_queue.py +++ b/email_queue.py @@ -52,7 +52,6 @@ class EmailQueue: logger.info("🛑 Arrêt de la queue email...") self.running = False - # Attendre que la queue soit vide (max 30s) try: self.queue.join() logger.info("✅ Queue email arrêtée proprement") @@ -66,20 +65,16 @@ class EmailQueue: def _worker(self): """Worker qui traite les emails dans un thread""" - # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: while self.running: try: - # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - # Marquer comme traité self.queue.task_done() except queue.Empty: @@ -103,7 +98,6 @@ class EmailQueue: return async with self.session_factory() as session: - # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) @@ -113,34 +107,28 @@ class EmailQueue: logger.error(f"❌ Email log {email_log_id} introuvable") return - # Marquer comme en cours email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() try: - # Envoi avec retry automatique await self._send_with_retry(email_log) - # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None logger.info(f"✅ Email envoyé: {email_log.destinataire}") except Exception as e: - # Échec email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - # Programmer un retry si < max attempts if email_log.nb_tentatives < settings.max_retry_attempts: delay = settings.retry_delay_seconds * ( 2 ** (email_log.nb_tentatives - 1) ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - # Programmer le retry timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() @@ -158,16 +146,13 @@ class EmailQueue: ) async def _send_with_retry(self, email_log): """Envoi SMTP avec retry Tenacity + génération PDF""" - # Préparer le message msg = MIMEMultipart() msg["From"] = settings.smtp_from msg["To"] = email_log.destinataire msg["Subject"] = email_log.sujet - # Corps HTML msg.attach(MIMEText(email_log.corps_html, "html")) - # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs if email_log.document_ids: document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document @@ -178,13 +163,11 @@ class EmailQueue: continue try: - # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( self._generate_pdf, doc_id, type_doc ) if pdf_bytes: - # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") part["Content-Disposition"] = ( f'attachment; filename="{doc_id}.pdf"' @@ -194,9 +177,7 @@ class EmailQueue: except Exception as e: logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") - # Continuer avec les autres PDFs - # Envoi SMTP (bloquant mais dans thread séparé) await asyncio.to_thread(self._send_smtp, msg) def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: @@ -205,7 +186,6 @@ class EmailQueue: logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - # 📡 Récupérer document depuis gateway Windows via HTTP try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: @@ -215,16 +195,13 @@ class EmailQueue: if not doc: raise Exception(f"Document {doc_id} introuvable") - # 📄 Créer PDF avec ReportLab buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - # === EN-TÊTE === pdf.setFont("Helvetica-Bold", 20) pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") - # Type de document type_labels = { 0: "DEVIS", 1: "BON DE LIVRAISON", @@ -238,7 +215,6 @@ class EmailQueue: pdf.setFont("Helvetica", 12) pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") - # === INFORMATIONS CLIENT === y = height - 5 * cm pdf.setFont("Helvetica-Bold", 14) pdf.drawString(2 * cm, y, "CLIENT") @@ -251,7 +227,6 @@ class EmailQueue: y -= 0.6 * cm pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") - # === LIGNES === y -= 1.5 * cm pdf.setFont("Helvetica-Bold", 14) pdf.drawString(2 * cm, y, "ARTICLES") @@ -270,7 +245,6 @@ class EmailQueue: pdf.setFont("Helvetica", 9) for ligne in doc.get("lignes", []): - # Nouvelle page si nécessaire if y < 3 * cm: pdf.showPage() y = height - 3 * cm @@ -283,7 +257,6 @@ class EmailQueue: pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}€") y -= 0.6 * cm - # === TOTAUX === y -= 1 * cm pdf.line(12 * cm, y, width - 2 * cm, y) @@ -302,14 +275,12 @@ class EmailQueue: pdf.drawString(12 * cm, y, "Total TTC:") pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}€") - # === PIED DE PAGE === pdf.setFont("Helvetica", 8) pdf.drawString( 2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" ) pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven") - # Finaliser pdf.save() buffer.seek(0) @@ -336,5 +307,4 @@ class EmailQueue: raise Exception(f"Erreur envoi: {str(e)}") -# Instance globale email_queue = EmailQueue() diff --git a/routes/auth.py b/routes/auth.py index 3d682e0..54406c1 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -27,7 +27,6 @@ import logging logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["Authentication"]) -# === MODÈLES PYDANTIC === class RegisterRequest(BaseModel): @@ -70,7 +69,6 @@ class ResendVerificationRequest(BaseModel): email: EmailStr -# === UTILITAIRES === async def log_login_attempt( @@ -103,7 +101,6 @@ async def check_rate_limit( Returns: (is_allowed, error_message) """ - # Vérifier les tentatives échouées des 15 dernières minutes time_window = datetime.now() - timedelta(minutes=15) result = await session.execute( @@ -121,7 +118,6 @@ async def check_rate_limit( return True, "" -# === ENDPOINTS === @router.post("/register", status_code=status.HTTP_201_CREATED) @@ -137,7 +133,6 @@ async def register( - Crée le compte (non vérifié) - Envoie email de vérification """ - # Vérifier si l'email existe déjà result = await session.execute(select(User).where(User.email == data.email)) existing_user = result.scalar_one_or_none() @@ -146,15 +141,12 @@ async def register( status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" ) - # Valider le mot de passe is_valid, error_msg = validate_password_strength(data.password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) - # Générer token de vérification verification_token = generate_verification_token() - # Créer l'utilisateur new_user = User( id=str(uuid.uuid4()), email=data.email.lower(), @@ -170,7 +162,6 @@ async def register( session.add(new_user) await session.commit() - # Envoyer email de vérification base_url = str(request.base_url).rstrip("/") email_sent = AuthEmailService.send_verification_email( data.email, verification_token, base_url @@ -204,7 +195,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi "message": "Token de vérification invalide ou déjà utilisé.", } - # Vérifier l'expiration if user.verification_token_expires < datetime.now(): return { "success": False, @@ -212,7 +202,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi "expired": True, } - # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None @@ -246,14 +235,12 @@ async def verify_email_post( detail="Token de vérification invalide", ) - # Vérifier l'expiration if user.verification_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token expiré. Demandez un nouvel email de vérification.", ) - # Activer le compte user.is_verified = True user.verification_token = None user.verification_token_expires = None @@ -280,7 +267,6 @@ async def resend_verification( user = result.scalar_one_or_none() if not user: - # Ne pas révéler si l'utilisateur existe return { "success": True, "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", @@ -291,13 +277,11 @@ async def resend_verification( status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" ) - # Générer nouveau token verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() - # Envoyer email base_url = str(request.base_url).rstrip("/") AuthEmailService.send_verification_email(user.email, verification_token, base_url) @@ -316,18 +300,15 @@ async def login( ip = request.client.host if request.client else "unknown" user_agent = request.headers.get("user-agent", "unknown") - # Rate limiting is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) if not is_allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) - # Charger l'utilisateur result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - # Vérifications if not user or not verify_password(data.password, user.hashed_password): await log_login_attempt( session, @@ -338,11 +319,9 @@ async def login( "Identifiants incorrects", ) - # Incrémenter compteur échecs if user: user.failed_login_attempts += 1 - # Verrouiller après 5 échecs if user.failed_login_attempts >= 5: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() @@ -358,7 +337,6 @@ async def login( detail="Email ou mot de passe incorrect", ) - # Vérifier statut compte if not user.is_active: await log_login_attempt( session, data.email.lower(), ip, user_agent, False, "Compte désactivé" @@ -376,7 +354,6 @@ async def login( detail="Email non vérifié. Consultez votre boîte de réception.", ) - # Vérifier verrouillage if user.locked_until and user.locked_until > datetime.now(): await log_login_attempt( session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" @@ -386,20 +363,16 @@ async def login( detail="Compte temporairement verrouillé", ) - # ✅ CONNEXION RÉUSSIE - # Réinitialiser compteur échecs user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() - # Créer tokens access_token = create_access_token( {"sub": user.id, "email": user.email, "role": user.role} ) refresh_token_jwt = create_refresh_token(user.id) - # Stocker refresh token en DB (hashé) refresh_token_record = RefreshToken( id=str(uuid.uuid4()), user_id=user.id, @@ -413,7 +386,6 @@ async def login( session.add(refresh_token_record) await session.commit() - # Logger succès await log_login_attempt(session, data.email.lower(), ip, user_agent, True) logger.info(f"✅ Connexion réussie: {user.email}") @@ -432,7 +404,6 @@ async def refresh_access_token( """ 🔄 Renouvellement du access_token via refresh_token """ - # Décoder le refresh token payload = decode_token(data.refresh_token) if not payload or payload.get("type") != "refresh": raise HTTPException( @@ -442,7 +413,6 @@ async def refresh_access_token( user_id = payload.get("sub") token_hash = hash_token(data.refresh_token) - # Vérifier en DB result = await session.execute( select(RefreshToken).where( RefreshToken.user_id == user_id, @@ -458,13 +428,11 @@ async def refresh_access_token( detail="Refresh token révoqué ou introuvable", ) - # Vérifier expiration if token_record.expires_at < datetime.now(): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" ) - # Charger utilisateur result = await session.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() @@ -474,7 +442,6 @@ async def refresh_access_token( detail="Utilisateur introuvable ou désactivé", ) - # Générer nouveau access token new_access_token = create_access_token( {"sub": user.id, "email": user.email, "role": user.role} ) @@ -500,20 +467,17 @@ async def forgot_password( result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() - # Ne pas révéler si l'utilisateur existe if not user: return { "success": True, "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", } - # Générer token de reset reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() - # Envoyer email frontend_url = ( settings.frontend_url if hasattr(settings, "frontend_url") @@ -545,19 +509,16 @@ async def reset_password( detail="Token de réinitialisation invalide", ) - # Vérifier expiration if user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token expiré. Demandez un nouveau lien de réinitialisation.", ) - # Valider nouveau mot de passe is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) - # Mettre à jour user.hashed_password = hash_password(data.new_password) user.reset_token = None user.reset_token_expires = None @@ -565,7 +526,6 @@ async def reset_password( user.locked_until = None await session.commit() - # Envoyer notification AuthEmailService.send_password_changed_notification(user.email) logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") diff --git a/security/auth.py b/security/auth.py index 7fc182c..05b8d8a 100644 --- a/security/auth.py +++ b/security/auth.py @@ -5,7 +5,6 @@ import jwt import secrets import hashlib -# Configuration SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret ALGORITHM = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES = 30 @@ -14,7 +13,6 @@ REFRESH_TOKEN_EXPIRE_DAYS = 7 pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -# === Hachage de mots de passe === def hash_password(password: str) -> str: """Hash un mot de passe avec bcrypt""" return pwd_context.hash(password) @@ -25,7 +23,6 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) -# === Génération de tokens aléatoires === def generate_verification_token() -> str: """Génère un token de vérification email sécurisé""" return secrets.token_urlsafe(32) @@ -41,7 +38,6 @@ def hash_token(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() -# === JWT Access Token === def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: """ Crée un JWT access token @@ -100,7 +96,6 @@ def decode_token(token: str) -> Optional[Dict]: return None -# === Validation mot de passe === def validate_password_strength(password: str) -> tuple[bool, str]: """ Valide la robustesse d'un mot de passe