Enriched article response
This commit is contained in:
parent
e55ff75624
commit
be7a8baddd
7 changed files with 255 additions and 188 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -35,4 +35,4 @@ dist/
|
|||
|
||||
data/sage_dataven.db
|
||||
|
||||
cleaner.py
|
||||
tools/
|
||||
319
api.py
319
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,
|
||||
)
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
|
||||
|
||||
|
||||
# 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue