Enriched article response

This commit is contained in:
Fanilo-Nantenaina 2025-12-27 05:29:18 +03:00
parent e55ff75624
commit be7a8baddd
7 changed files with 255 additions and 188 deletions

2
.gitignore vendored
View file

@ -35,4 +35,4 @@ dist/
data/sage_dataven.db data/sage_dataven.db
cleaner.py tools/

319
api.py
View file

@ -528,73 +528,276 @@ class FournisseurDetails(BaseModel):
class ArticleResponse(BaseModel): class ArticleResponse(BaseModel):
""" """Modèle de réponse pour un article avec toutes ses informations"""
Modèle de réponse pour un article Sage
ENRICHI avec tous les champs disponibles
"""
reference: str = Field(..., description="Référence article (AR_Ref)") reference: str = Field(..., description="Référence article (AR_Ref)")
designation: str = Field(..., description="Désignation principale (AR_Design)") designation: str = Field(..., description="Désignation principale (AR_Design)")
designation_complementaire: Optional[str] = Field( 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( 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( 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( stock_disponible: Optional[float] = Field(
None, description="Stock disponible (réel - réservé)" None, description="Stock disponible = réel - réservé"
) )
suivi_stock: Optional[bool] = Field(
description: Optional[str] = Field( None, description="Suivi de stock activé (AR_SuiviStock)"
None, description="Description détaillée / Commentaire"
) )
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( 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") type_article_libelle: Optional[str] = Field(
famille_code: Optional[str] = Field(None, description="Code famille") None, description="Libellé du type d'article"
famille_libelle: Optional[str] = Field(None, description="Libellé famille") )
fournisseur_principal: Optional[str] = Field( famille_code: Optional[str] = Field(
None, description="Code fournisseur principal" 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( 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( 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): class LigneDevis(BaseModel):
article_code: str article_code: str
@ -1765,7 +1968,6 @@ class FamilleCreateRequest(BaseModel):
class FamilleResponse(BaseModel): class FamilleResponse(BaseModel):
"""Modèle de réponse pour une famille d'articles - aligné sur lister_toutes_familles""" """Modèle de réponse pour une famille d'articles - aligné sur lister_toutes_familles"""
# === Identification ===
code: str = Field(..., description="Code famille") code: str = Field(..., description="Code famille")
intitule: str = Field(..., description="Intitulé") intitule: str = Field(..., description="Intitulé")
type: int = Field(..., description="Type (0=Détail, 1=Total)") 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_total: bool = Field(..., description="True si type Total")
est_detail: Optional[bool] = Field(None, description="True si type Détail") 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_vente: Optional[str] = Field(None, description="Unité de vente")
unite_poids: Optional[str] = Field(None, description="Unité de poids") unite_poids: Optional[str] = Field(None, description="Unité de poids")
coef: Optional[float] = Field(None, description="Coefficient") coef: Optional[float] = Field(None, description="Coefficient")
# === Stock et logistique ===
suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé")
garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)")
delai: Optional[int] = Field(None, description="Délai de livraison (jours)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)")
nb_colis: Optional[int] = Field(None, description="Nombre de colis") nb_colis: Optional[int] = Field(None, description="Nombre de colis")
# === Comptabilité ===
compte_achat: Optional[str] = Field(None, description="Compte général achat") compte_achat: Optional[str] = Field(None, description="Compte général achat")
compte_vente: Optional[str] = Field(None, description="Compte général vente") compte_vente: Optional[str] = Field(None, description="Compte général vente")
code_fiscal: Optional[str] = Field(None, description="Code fiscal") code_fiscal: Optional[str] = Field(None, description="Code fiscal")
escompte: Optional[bool] = Field(None, description="Escompte autorisé") escompte: Optional[bool] = Field(None, description="Escompte autorisé")
# === Organisation ===
est_centrale: Optional[bool] = Field(None, description="Famille centrale") est_centrale: Optional[bool] = Field(None, description="Famille centrale")
nature: Optional[int] = Field(None, description="Nature de la famille") nature: Optional[int] = Field(None, description="Nature de la famille")
pays: Optional[str] = Field(None, description="Pays d'origine") pays: Optional[str] = Field(None, description="Pays d'origine")
# === Classifications ===
categorie_1: Optional[int] = Field(None, description="Catégorie 1") categorie_1: Optional[int] = Field(None, description="Catégorie 1")
categorie_2: Optional[int] = Field(None, description="Catégorie 2") categorie_2: Optional[int] = Field(None, description="Catégorie 2")
categorie_3: Optional[int] = Field(None, description="Catégorie 3") categorie_3: Optional[int] = Field(None, description="Catégorie 3")
categorie_4: Optional[int] = Field(None, description="Catégorie 4") categorie_4: Optional[int] = Field(None, description="Catégorie 4")
# === Statistiques ===
stat_01: Optional[str] = Field(None, description="Statistique 1") stat_01: Optional[str] = Field(None, description="Statistique 1")
stat_02: Optional[str] = Field(None, description="Statistique 2") stat_02: Optional[str] = Field(None, description="Statistique 2")
stat_03: Optional[str] = Field(None, description="Statistique 3") 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") hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques")
est_statistique: Optional[bool] = Field(None, description="Incluse dans les statistiques (legacy)") 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") vente_debit: Optional[bool] = Field(None, description="Vente au débit")
non_imprimable: Optional[bool] = Field(None, description="Non imprimable") non_imprimable: Optional[bool] = Field(None, description="Non imprimable")
contremarque: Optional[bool] = Field(None, description="Contremarque") contremarque: Optional[bool] = Field(None, description="Contremarque")
@ -1818,26 +2013,20 @@ class FamilleResponse(BaseModel):
fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire")
publie: Optional[bool] = Field(None, description="Publié") publie: Optional[bool] = Field(None, description="Publié")
# === Références ===
racine_reference: Optional[str] = Field(None, description="Racine référence article") racine_reference: Optional[str] = Field(None, description="Racine référence article")
racine_code_barre: Optional[str] = Field(None, description="Racine code-barres") racine_code_barre: Optional[str] = Field(None, description="Racine code-barres")
raccourci: Optional[str] = Field(None, description="Raccourci clavier") raccourci: Optional[str] = Field(None, description="Raccourci clavier")
# === Gestion ===
sous_traitance: Optional[bool] = Field(None, description="Sous-traitance") sous_traitance: Optional[bool] = Field(None, description="Sous-traitance")
fictif: Optional[bool] = Field(None, description="Famille fictive") fictif: Optional[bool] = Field(None, description="Famille fictive")
criticite: Optional[int] = Field(None, description="Niveau de criticité") 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") avertissement: Optional[str] = Field(None, description="Avertissement si famille Total")
index_com: Optional[int] = Field(None, description="Index COM (lire_famille uniquement)") index_com: Optional[int] = Field(None, description="Index COM (lire_famille uniquement)")
date_creation: Optional[str] = Field(None, description="Date de création") date_creation: Optional[str] = Field(None, description="Date de création")
date_modification: Optional[str] = Field(None, description="Date de modification") date_modification: Optional[str] = Field(None, description="Date de modification")
nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") 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: class Config:
json_schema_extra = { json_schema_extra = {
@ -5387,4 +5576,4 @@ if __name__ == "__main__":
host=settings.api_host, host=settings.api_host,
port=settings.api_port, port=settings.api_port,
reload=settings.api_reload, reload=settings.api_reload,
) )

View file

@ -15,30 +15,25 @@ from database.models import (
AuditLog, AuditLog,
StatutEmail, StatutEmail,
StatutSignature, StatutSignature,
# Nouveaux modèles auth
User, User,
RefreshToken, RefreshToken,
LoginAttempt, LoginAttempt,
) )
__all__ = [ __all__ = [
# Config
"engine", "engine",
"async_session_factory", "async_session_factory",
"init_db", "init_db",
"get_session", "get_session",
"close_db", "close_db",
# Models existants
"Base", "Base",
"EmailLog", "EmailLog",
"SignatureLog", "SignatureLog",
"WorkflowLog", "WorkflowLog",
"CacheMetadata", "CacheMetadata",
"AuditLog", "AuditLog",
# Enums
"StatutEmail", "StatutEmail",
"StatutSignature", "StatutSignature",
# Modèles auth
"User", "User",
"RefreshToken", "RefreshToken",
"LoginAttempt", "LoginAttempt",

View file

@ -14,9 +14,6 @@ import enum
Base = declarative_base() Base = declarative_base()
# ============================================================================
# Enums
# ============================================================================
class StatutEmail(str, enum.Enum): class StatutEmail(str, enum.Enum):
@ -40,9 +37,6 @@ class StatutSignature(str, enum.Enum):
EXPIRE = "EXPIRE" EXPIRE = "EXPIRE"
# ============================================================================
# Tables
# ============================================================================
class EmailLog(Base): class EmailLog(Base):
@ -53,36 +47,28 @@ class EmailLog(Base):
__tablename__ = "email_logs" __tablename__ = "email_logs"
# Identifiant
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
# Destinataires
destinataire = Column(String(255), nullable=False, index=True) destinataire = Column(String(255), nullable=False, index=True)
cc = Column(Text, nullable=True) # JSON stringifié cc = Column(Text, nullable=True) # JSON stringifié
cci = Column(Text, nullable=True) # JSON stringifié cci = Column(Text, nullable=True) # JSON stringifié
# Contenu
sujet = Column(String(500), nullable=False) sujet = Column(String(500), nullable=False)
corps_html = Column(Text, nullable=False) corps_html = Column(Text, nullable=False)
# Documents attachés
document_ids = Column(Text, nullable=True) # Séparés par virgules document_ids = Column(Text, nullable=True) # Séparés par virgules
type_document = Column(Integer, nullable=True) type_document = Column(Integer, nullable=True)
# Statut
statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True)
# Tracking temporel
date_creation = Column(DateTime, default=datetime.now, nullable=False) date_creation = Column(DateTime, default=datetime.now, nullable=False)
date_envoi = Column(DateTime, nullable=True) date_envoi = Column(DateTime, nullable=True)
date_ouverture = Column(DateTime, nullable=True) date_ouverture = Column(DateTime, nullable=True)
# Retry automatique
nb_tentatives = Column(Integer, default=0) nb_tentatives = Column(Integer, default=0)
derniere_erreur = Column(Text, nullable=True) derniere_erreur = Column(Text, nullable=True)
prochain_retry = Column(DateTime, nullable=True) prochain_retry = Column(DateTime, nullable=True)
# Métadonnées
ip_envoi = Column(String(45), nullable=True) ip_envoi = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True) user_agent = Column(String(500), nullable=True)
@ -98,22 +84,17 @@ class SignatureLog(Base):
__tablename__ = "signature_logs" __tablename__ = "signature_logs"
# Identifiant
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
# Document Sage associé
document_id = Column(String(100), nullable=False, index=True) document_id = Column(String(100), nullable=False, index=True)
type_document = Column(Integer, nullable=False) type_document = Column(Integer, nullable=False)
# Universign
transaction_id = Column(String(100), unique=True, index=True, nullable=True) transaction_id = Column(String(100), unique=True, index=True, nullable=True)
signer_url = Column(String(500), nullable=True) signer_url = Column(String(500), nullable=True)
# Signataire
email_signataire = Column(String(255), nullable=False, index=True) email_signataire = Column(String(255), nullable=False, index=True)
nom_signataire = Column(String(255), nullable=False) nom_signataire = Column(String(255), nullable=False)
# Statut
statut = Column( statut = Column(
SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True
) )
@ -121,12 +102,10 @@ class SignatureLog(Base):
date_signature = Column(DateTime, nullable=True) date_signature = Column(DateTime, nullable=True)
date_refus = Column(DateTime, nullable=True) date_refus = Column(DateTime, nullable=True)
# Relances
est_relance = Column(Boolean, default=False) est_relance = Column(Boolean, default=False)
nb_relances = Column(Integer, default=0) nb_relances = Column(Integer, default=0)
derniere_relance = Column(DateTime, nullable=True) derniere_relance = Column(DateTime, nullable=True)
# Métadonnées
raison_refus = Column(Text, nullable=True) raison_refus = Column(Text, nullable=True)
ip_signature = Column(String(45), nullable=True) ip_signature = Column(String(45), nullable=True)
@ -142,26 +121,21 @@ class WorkflowLog(Base):
__tablename__ = "workflow_logs" __tablename__ = "workflow_logs"
# Identifiant
id = Column(String(36), primary_key=True) id = Column(String(36), primary_key=True)
# Documents
document_source = Column(String(100), nullable=False, index=True) document_source = Column(String(100), nullable=False, index=True)
type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc.
document_cible = Column(String(100), nullable=False, index=True) document_cible = Column(String(100), nullable=False, index=True)
type_cible = Column(Integer, nullable=False) type_cible = Column(Integer, nullable=False)
# Métadonnées de transformation
nb_lignes = Column(Integer, nullable=True) nb_lignes = Column(Integer, nullable=True)
montant_ht = Column(Float, nullable=True) montant_ht = Column(Float, nullable=True)
montant_ttc = Column(Float, nullable=True) montant_ttc = Column(Float, nullable=True)
# Tracking
date_transformation = Column(DateTime, default=datetime.now, nullable=False) date_transformation = Column(DateTime, default=datetime.now, nullable=False)
utilisateur = Column(String(100), nullable=True) utilisateur = Column(String(100), nullable=True)
# Résultat
succes = Column(Boolean, default=True) succes = Column(Boolean, default=True)
erreur = Column(Text, nullable=True) erreur = Column(Text, nullable=True)
duree_ms = Column(Integer, nullable=True) # Durée en millisecondes 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) id = Column(Integer, primary_key=True, autoincrement=True)
# Type de cache
cache_type = Column( cache_type = Column(
String(50), unique=True, nullable=False String(50), unique=True, nullable=False
) # 'clients' ou 'articles' ) # 'clients' ou 'articles'
# Statistiques
last_refresh = Column(DateTime, default=datetime.now) last_refresh = Column(DateTime, default=datetime.now)
item_count = Column(Integer, default=0) item_count = Column(Integer, default=0)
refresh_duration_ms = Column(Float, nullable=True) refresh_duration_ms = Column(Float, nullable=True)
# Santé
last_error = Column(Text, nullable=True) last_error = Column(Text, nullable=True)
error_count = Column(Integer, default=0) error_count = Column(Integer, default=0)
@ -208,30 +179,25 @@ class AuditLog(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
# Action
action = Column( action = Column(
String(100), nullable=False, index=True String(100), nullable=False, index=True
) # 'CREATE_DEVIS', 'SEND_EMAIL', etc. ) # 'CREATE_DEVIS', 'SEND_EMAIL', etc.
ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc. ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc.
ressource_id = Column(String(100), nullable=True, index=True) ressource_id = Column(String(100), nullable=True, index=True)
# Utilisateur (si authentification ajoutée plus tard)
utilisateur = Column(String(100), nullable=True) utilisateur = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True) ip_address = Column(String(45), nullable=True)
# Résultat
succes = Column(Boolean, default=True) succes = Column(Boolean, default=True)
details = Column(Text, nullable=True) # JSON stringifié details = Column(Text, nullable=True) # JSON stringifié
erreur = Column(Text, nullable=True) erreur = Column(Text, nullable=True)
# Timestamp
date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) date_action = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self): def __repr__(self):
return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>" return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
# Ajouter ces modèles à la fin de database/models.py
class User(Base): class User(Base):
@ -245,26 +211,21 @@ class User(Base):
email = Column(String(255), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False) hashed_password = Column(String(255), nullable=False)
# Profil
nom = Column(String(100), nullable=False) nom = Column(String(100), nullable=False)
prenom = Column(String(100), nullable=False) prenom = Column(String(100), nullable=False)
role = Column(String(50), default="user") # user, admin, commercial role = Column(String(50), default="user") # user, admin, commercial
# Validation email
is_verified = Column(Boolean, default=False) is_verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True, unique=True, index=True) verification_token = Column(String(255), nullable=True, unique=True, index=True)
verification_token_expires = Column(DateTime, nullable=True) verification_token_expires = Column(DateTime, nullable=True)
# Sécurité
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
failed_login_attempts = Column(Integer, default=0) failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True) locked_until = Column(DateTime, nullable=True)
# Mot de passe oublié
reset_token = Column(String(255), nullable=True, unique=True, index=True) reset_token = Column(String(255), nullable=True, unique=True, index=True)
reset_token_expires = Column(DateTime, nullable=True) reset_token_expires = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.now, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
@ -284,15 +245,12 @@ class RefreshToken(Base):
user_id = Column(String(36), nullable=False, index=True) user_id = Column(String(36), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True, index=True) token_hash = Column(String(255), nullable=False, unique=True, index=True)
# Métadonnées
device_info = Column(String(500), nullable=True) device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True) ip_address = Column(String(45), nullable=True)
# Expiration
expires_at = Column(DateTime, nullable=False) expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False) created_at = Column(DateTime, default=datetime.now, nullable=False)
# Révocation
is_revoked = Column(Boolean, default=False) is_revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True)

View file

@ -52,7 +52,6 @@ class EmailQueue:
logger.info("🛑 Arrêt de la queue email...") logger.info("🛑 Arrêt de la queue email...")
self.running = False self.running = False
# Attendre que la queue soit vide (max 30s)
try: try:
self.queue.join() self.queue.join()
logger.info("✅ Queue email arrêtée proprement") logger.info("✅ Queue email arrêtée proprement")
@ -66,20 +65,16 @@ class EmailQueue:
def _worker(self): def _worker(self):
"""Worker qui traite les emails dans un thread""" """Worker qui traite les emails dans un thread"""
# Créer une event loop pour ce thread
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
while self.running: while self.running:
try: try:
# Récupérer un email de la queue (timeout 1s)
email_log_id = self.queue.get(timeout=1) email_log_id = self.queue.get(timeout=1)
# Traiter l'email
loop.run_until_complete(self._process_email(email_log_id)) loop.run_until_complete(self._process_email(email_log_id))
# Marquer comme traité
self.queue.task_done() self.queue.task_done()
except queue.Empty: except queue.Empty:
@ -103,7 +98,6 @@ class EmailQueue:
return return
async with self.session_factory() as session: async with self.session_factory() as session:
# Charger l'email log
result = await session.execute( result = await session.execute(
select(EmailLog).where(EmailLog.id == email_log_id) select(EmailLog).where(EmailLog.id == email_log_id)
) )
@ -113,34 +107,28 @@ class EmailQueue:
logger.error(f"❌ Email log {email_log_id} introuvable") logger.error(f"❌ Email log {email_log_id} introuvable")
return return
# Marquer comme en cours
email_log.statut = StatutEmail.EN_COURS email_log.statut = StatutEmail.EN_COURS
email_log.nb_tentatives += 1 email_log.nb_tentatives += 1
await session.commit() await session.commit()
try: try:
# Envoi avec retry automatique
await self._send_with_retry(email_log) await self._send_with_retry(email_log)
# Succès
email_log.statut = StatutEmail.ENVOYE email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now() email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None email_log.derniere_erreur = None
logger.info(f"✅ Email envoyé: {email_log.destinataire}") logger.info(f"✅ Email envoyé: {email_log.destinataire}")
except Exception as e: except Exception as e:
# Échec
email_log.statut = StatutEmail.ERREUR email_log.statut = StatutEmail.ERREUR
email_log.derniere_erreur = str(e)[:1000] # Limiter la taille 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: if email_log.nb_tentatives < settings.max_retry_attempts:
delay = settings.retry_delay_seconds * ( delay = settings.retry_delay_seconds * (
2 ** (email_log.nb_tentatives - 1) 2 ** (email_log.nb_tentatives - 1)
) )
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
# Programmer le retry
timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
timer.daemon = True timer.daemon = True
timer.start() timer.start()
@ -158,16 +146,13 @@ class EmailQueue:
) )
async def _send_with_retry(self, email_log): async def _send_with_retry(self, email_log):
"""Envoi SMTP avec retry Tenacity + génération PDF""" """Envoi SMTP avec retry Tenacity + génération PDF"""
# Préparer le message
msg = MIMEMultipart() msg = MIMEMultipart()
msg["From"] = settings.smtp_from msg["From"] = settings.smtp_from
msg["To"] = email_log.destinataire msg["To"] = email_log.destinataire
msg["Subject"] = email_log.sujet msg["Subject"] = email_log.sujet
# Corps HTML
msg.attach(MIMEText(email_log.corps_html, "html")) msg.attach(MIMEText(email_log.corps_html, "html"))
# 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs
if email_log.document_ids: if email_log.document_ids:
document_ids = email_log.document_ids.split(",") document_ids = email_log.document_ids.split(",")
type_doc = email_log.type_document type_doc = email_log.type_document
@ -178,13 +163,11 @@ class EmailQueue:
continue continue
try: try:
# Générer PDF (appel bloquant dans thread séparé)
pdf_bytes = await asyncio.to_thread( pdf_bytes = await asyncio.to_thread(
self._generate_pdf, doc_id, type_doc self._generate_pdf, doc_id, type_doc
) )
if pdf_bytes: if pdf_bytes:
# Attacher PDF
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
part["Content-Disposition"] = ( part["Content-Disposition"] = (
f'attachment; filename="{doc_id}.pdf"' f'attachment; filename="{doc_id}.pdf"'
@ -194,9 +177,7 @@ class EmailQueue:
except Exception as e: except Exception as e:
logger.error(f"❌ Erreur génération PDF {doc_id}: {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) await asyncio.to_thread(self._send_smtp, msg)
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
@ -205,7 +186,6 @@ class EmailQueue:
logger.error("❌ sage_client non configuré") logger.error("❌ sage_client non configuré")
raise Exception("sage_client non disponible") raise Exception("sage_client non disponible")
# 📡 Récupérer document depuis gateway Windows via HTTP
try: try:
doc = self.sage_client.lire_document(doc_id, type_doc) doc = self.sage_client.lire_document(doc_id, type_doc)
except Exception as e: except Exception as e:
@ -215,16 +195,13 @@ class EmailQueue:
if not doc: if not doc:
raise Exception(f"Document {doc_id} introuvable") raise Exception(f"Document {doc_id} introuvable")
# 📄 Créer PDF avec ReportLab
buffer = BytesIO() buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4) pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4 width, height = A4
# === EN-TÊTE ===
pdf.setFont("Helvetica-Bold", 20) pdf.setFont("Helvetica-Bold", 20)
pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}") pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}")
# Type de document
type_labels = { type_labels = {
0: "DEVIS", 0: "DEVIS",
1: "BON DE LIVRAISON", 1: "BON DE LIVRAISON",
@ -238,7 +215,6 @@ class EmailQueue:
pdf.setFont("Helvetica", 12) pdf.setFont("Helvetica", 12)
pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}") pdf.drawString(2 * cm, height - 4 * cm, f"Type: {type_label}")
# === INFORMATIONS CLIENT ===
y = height - 5 * cm y = height - 5 * cm
pdf.setFont("Helvetica-Bold", 14) pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(2 * cm, y, "CLIENT") pdf.drawString(2 * cm, y, "CLIENT")
@ -251,7 +227,6 @@ class EmailQueue:
y -= 0.6 * cm y -= 0.6 * cm
pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}") pdf.drawString(2 * cm, y, f"Date: {doc.get('date', '')}")
# === LIGNES ===
y -= 1.5 * cm y -= 1.5 * cm
pdf.setFont("Helvetica-Bold", 14) pdf.setFont("Helvetica-Bold", 14)
pdf.drawString(2 * cm, y, "ARTICLES") pdf.drawString(2 * cm, y, "ARTICLES")
@ -270,7 +245,6 @@ class EmailQueue:
pdf.setFont("Helvetica", 9) pdf.setFont("Helvetica", 9)
for ligne in doc.get("lignes", []): for ligne in doc.get("lignes", []):
# Nouvelle page si nécessaire
if y < 3 * cm: if y < 3 * cm:
pdf.showPage() pdf.showPage()
y = height - 3 * cm y = height - 3 * cm
@ -283,7 +257,6 @@ class EmailQueue:
pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}") pdf.drawString(15 * cm, y, f"{ligne.get('montant_ht', 0):.2f}")
y -= 0.6 * cm y -= 0.6 * cm
# === TOTAUX ===
y -= 1 * cm y -= 1 * cm
pdf.line(12 * cm, y, width - 2 * cm, y) pdf.line(12 * cm, y, width - 2 * cm, y)
@ -302,14 +275,12 @@ class EmailQueue:
pdf.drawString(12 * cm, y, "Total TTC:") pdf.drawString(12 * cm, y, "Total TTC:")
pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}") pdf.drawString(15 * cm, y, f"{doc.get('total_ttc', 0):.2f}")
# === PIED DE PAGE ===
pdf.setFont("Helvetica", 8) pdf.setFont("Helvetica", 8)
pdf.drawString( pdf.drawString(
2 * cm, 2 * cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}" 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") pdf.drawString(2 * cm, 1.5 * cm, "Sage 100c - API Dataven")
# Finaliser
pdf.save() pdf.save()
buffer.seek(0) buffer.seek(0)
@ -336,5 +307,4 @@ class EmailQueue:
raise Exception(f"Erreur envoi: {str(e)}") raise Exception(f"Erreur envoi: {str(e)}")
# Instance globale
email_queue = EmailQueue() email_queue = EmailQueue()

View file

@ -27,7 +27,6 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"]) router = APIRouter(prefix="/auth", tags=["Authentication"])
# === MODÈLES PYDANTIC ===
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
@ -70,7 +69,6 @@ class ResendVerificationRequest(BaseModel):
email: EmailStr email: EmailStr
# === UTILITAIRES ===
async def log_login_attempt( async def log_login_attempt(
@ -103,7 +101,6 @@ async def check_rate_limit(
Returns: Returns:
(is_allowed, error_message) (is_allowed, error_message)
""" """
# Vérifier les tentatives échouées des 15 dernières minutes
time_window = datetime.now() - timedelta(minutes=15) time_window = datetime.now() - timedelta(minutes=15)
result = await session.execute( result = await session.execute(
@ -121,7 +118,6 @@ async def check_rate_limit(
return True, "" return True, ""
# === ENDPOINTS ===
@router.post("/register", status_code=status.HTTP_201_CREATED) @router.post("/register", status_code=status.HTTP_201_CREATED)
@ -137,7 +133,6 @@ async def register(
- Crée le compte (non vérifié) - Crée le compte (non vérifié)
- Envoie email de vérification - Envoie email de vérification
""" """
# Vérifier si l'email existe déjà
result = await session.execute(select(User).where(User.email == data.email)) result = await session.execute(select(User).where(User.email == data.email))
existing_user = result.scalar_one_or_none() 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é" 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) is_valid, error_msg = validate_password_strength(data.password)
if not is_valid: if not is_valid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
# Générer token de vérification
verification_token = generate_verification_token() verification_token = generate_verification_token()
# Créer l'utilisateur
new_user = User( new_user = User(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
email=data.email.lower(), email=data.email.lower(),
@ -170,7 +162,6 @@ async def register(
session.add(new_user) session.add(new_user)
await session.commit() await session.commit()
# Envoyer email de vérification
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
email_sent = AuthEmailService.send_verification_email( email_sent = AuthEmailService.send_verification_email(
data.email, verification_token, base_url 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é.", "message": "Token de vérification invalide ou déjà utilisé.",
} }
# Vérifier l'expiration
if user.verification_token_expires < datetime.now(): if user.verification_token_expires < datetime.now():
return { return {
"success": False, "success": False,
@ -212,7 +202,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
"expired": True, "expired": True,
} }
# Activer le compte
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
user.verification_token_expires = None user.verification_token_expires = None
@ -246,14 +235,12 @@ async def verify_email_post(
detail="Token de vérification invalide", detail="Token de vérification invalide",
) )
# Vérifier l'expiration
if user.verification_token_expires < datetime.now(): if user.verification_token_expires < datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Token expiré. Demandez un nouvel email de vérification.", detail="Token expiré. Demandez un nouvel email de vérification.",
) )
# Activer le compte
user.is_verified = True user.is_verified = True
user.verification_token = None user.verification_token = None
user.verification_token_expires = None user.verification_token_expires = None
@ -280,7 +267,6 @@ async def resend_verification(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if not user: if not user:
# Ne pas révéler si l'utilisateur existe
return { return {
"success": True, "success": True,
"message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", "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é" 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() verification_token = generate_verification_token()
user.verification_token = verification_token user.verification_token = verification_token
user.verification_token_expires = datetime.now() + timedelta(hours=24) user.verification_token_expires = datetime.now() + timedelta(hours=24)
await session.commit() await session.commit()
# Envoyer email
base_url = str(request.base_url).rstrip("/") base_url = str(request.base_url).rstrip("/")
AuthEmailService.send_verification_email(user.email, verification_token, base_url) 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" ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "unknown") user_agent = request.headers.get("user-agent", "unknown")
# Rate limiting
is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip)
if not is_allowed: if not is_allowed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg 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())) result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
# Vérifications
if not user or not verify_password(data.password, user.hashed_password): if not user or not verify_password(data.password, user.hashed_password):
await log_login_attempt( await log_login_attempt(
session, session,
@ -338,11 +319,9 @@ async def login(
"Identifiants incorrects", "Identifiants incorrects",
) )
# Incrémenter compteur échecs
if user: if user:
user.failed_login_attempts += 1 user.failed_login_attempts += 1
# Verrouiller après 5 échecs
if user.failed_login_attempts >= 5: if user.failed_login_attempts >= 5:
user.locked_until = datetime.now() + timedelta(minutes=15) user.locked_until = datetime.now() + timedelta(minutes=15)
await session.commit() await session.commit()
@ -358,7 +337,6 @@ async def login(
detail="Email ou mot de passe incorrect", detail="Email ou mot de passe incorrect",
) )
# Vérifier statut compte
if not user.is_active: if not user.is_active:
await log_login_attempt( await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Compte désactivé" 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.", 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(): if user.locked_until and user.locked_until > datetime.now():
await log_login_attempt( await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" session, data.email.lower(), ip, user_agent, False, "Compte verrouillé"
@ -386,20 +363,16 @@ async def login(
detail="Compte temporairement verrouillé", detail="Compte temporairement verrouillé",
) )
# ✅ CONNEXION RÉUSSIE
# Réinitialiser compteur échecs
user.failed_login_attempts = 0 user.failed_login_attempts = 0
user.locked_until = None user.locked_until = None
user.last_login = datetime.now() user.last_login = datetime.now()
# Créer tokens
access_token = create_access_token( access_token = create_access_token(
{"sub": user.id, "email": user.email, "role": user.role} {"sub": user.id, "email": user.email, "role": user.role}
) )
refresh_token_jwt = create_refresh_token(user.id) refresh_token_jwt = create_refresh_token(user.id)
# Stocker refresh token en DB (hashé)
refresh_token_record = RefreshToken( refresh_token_record = RefreshToken(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
user_id=user.id, user_id=user.id,
@ -413,7 +386,6 @@ async def login(
session.add(refresh_token_record) session.add(refresh_token_record)
await session.commit() await session.commit()
# Logger succès
await log_login_attempt(session, data.email.lower(), ip, user_agent, True) await log_login_attempt(session, data.email.lower(), ip, user_agent, True)
logger.info(f"✅ Connexion réussie: {user.email}") logger.info(f"✅ Connexion réussie: {user.email}")
@ -432,7 +404,6 @@ async def refresh_access_token(
""" """
🔄 Renouvellement du access_token via refresh_token 🔄 Renouvellement du access_token via refresh_token
""" """
# Décoder le refresh token
payload = decode_token(data.refresh_token) payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh": if not payload or payload.get("type") != "refresh":
raise HTTPException( raise HTTPException(
@ -442,7 +413,6 @@ async def refresh_access_token(
user_id = payload.get("sub") user_id = payload.get("sub")
token_hash = hash_token(data.refresh_token) token_hash = hash_token(data.refresh_token)
# Vérifier en DB
result = await session.execute( result = await session.execute(
select(RefreshToken).where( select(RefreshToken).where(
RefreshToken.user_id == user_id, RefreshToken.user_id == user_id,
@ -458,13 +428,11 @@ async def refresh_access_token(
detail="Refresh token révoqué ou introuvable", detail="Refresh token révoqué ou introuvable",
) )
# Vérifier expiration
if token_record.expires_at < datetime.now(): if token_record.expires_at < datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré"
) )
# Charger utilisateur
result = await session.execute(select(User).where(User.id == user_id)) result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -474,7 +442,6 @@ async def refresh_access_token(
detail="Utilisateur introuvable ou désactivé", detail="Utilisateur introuvable ou désactivé",
) )
# Générer nouveau access token
new_access_token = create_access_token( new_access_token = create_access_token(
{"sub": user.id, "email": user.email, "role": user.role} {"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())) result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
# Ne pas révéler si l'utilisateur existe
if not user: if not user:
return { return {
"success": True, "success": True,
"message": "Si cet email existe, un lien de réinitialisation a été envoyé.", "message": "Si cet email existe, un lien de réinitialisation a été envoyé.",
} }
# Générer token de reset
reset_token = generate_reset_token() reset_token = generate_reset_token()
user.reset_token = reset_token user.reset_token = reset_token
user.reset_token_expires = datetime.now() + timedelta(hours=1) user.reset_token_expires = datetime.now() + timedelta(hours=1)
await session.commit() await session.commit()
# Envoyer email
frontend_url = ( frontend_url = (
settings.frontend_url settings.frontend_url
if hasattr(settings, "frontend_url") if hasattr(settings, "frontend_url")
@ -545,19 +509,16 @@ async def reset_password(
detail="Token de réinitialisation invalide", detail="Token de réinitialisation invalide",
) )
# Vérifier expiration
if user.reset_token_expires < datetime.now(): if user.reset_token_expires < datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Token expiré. Demandez un nouveau lien de réinitialisation.", 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) is_valid, error_msg = validate_password_strength(data.new_password)
if not is_valid: if not is_valid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
# Mettre à jour
user.hashed_password = hash_password(data.new_password) user.hashed_password = hash_password(data.new_password)
user.reset_token = None user.reset_token = None
user.reset_token_expires = None user.reset_token_expires = None
@ -565,7 +526,6 @@ async def reset_password(
user.locked_until = None user.locked_until = None
await session.commit() await session.commit()
# Envoyer notification
AuthEmailService.send_password_changed_notification(user.email) AuthEmailService.send_password_changed_notification(user.email)
logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") logger.info(f"🔐 Mot de passe réinitialisé: {user.email}")

View file

@ -5,7 +5,6 @@ import jwt
import secrets import secrets
import hashlib import hashlib
# Configuration
SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 30
@ -14,7 +13,6 @@ REFRESH_TOKEN_EXPIRE_DAYS = 7
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# === Hachage de mots de passe ===
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
"""Hash un mot de passe avec bcrypt""" """Hash un mot de passe avec bcrypt"""
return pwd_context.hash(password) 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) return pwd_context.verify(plain_password, hashed_password)
# === Génération de tokens aléatoires ===
def generate_verification_token() -> str: def generate_verification_token() -> str:
"""Génère un token de vérification email sécurisé""" """Génère un token de vérification email sécurisé"""
return secrets.token_urlsafe(32) return secrets.token_urlsafe(32)
@ -41,7 +38,6 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
# === JWT Access Token ===
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
""" """
Crée un JWT access token Crée un JWT access token
@ -100,7 +96,6 @@ def decode_token(token: str) -> Optional[Dict]:
return None return None
# === Validation mot de passe ===
def validate_password_strength(password: str) -> tuple[bool, str]: def validate_password_strength(password: str) -> tuple[bool, str]:
""" """
Valide la robustesse d'un mot de passe Valide la robustesse d'un mot de passe