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
|
data/sage_dataven.db
|
||||||
|
|
||||||
cleaner.py
|
tools/
|
||||||
289
api.py
289
api.py
|
|
@ -528,72 +528,275 @@ 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")
|
code_ean: Optional[str] = Field(
|
||||||
code_barre: Optional[str] = Field(None, description="Code-barres (alias)")
|
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(..., description="Prix de vente HT")
|
prix_vente: float = Field(
|
||||||
prix_achat: Optional[float] = Field(None, description="Prix d'achat HT")
|
..., ge=0, description="Prix de vente HT unitaire (AR_PrixVen)"
|
||||||
prix_revient: Optional[float] = Field(None, description="Prix de revient")
|
)
|
||||||
|
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(..., description="Stock réel")
|
stock_reel: float = Field(
|
||||||
stock_mini: Optional[float] = Field(None, description="Stock minimum")
|
default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)"
|
||||||
stock_maxi: Optional[float] = Field(None, description="Stock maximum")
|
)
|
||||||
|
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)"
|
||||||
|
)
|
||||||
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(
|
||||||
|
None, description="Suivi de stock activé (AR_SuiviStock)"
|
||||||
)
|
)
|
||||||
|
|
||||||
description: Optional[str] = Field(
|
unite_vente: Optional[str] = Field(
|
||||||
None, description="Description détaillée / Commentaire"
|
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 d'article"
|
||||||
)
|
)
|
||||||
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(
|
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)"
|
||||||
)
|
)
|
||||||
|
|
||||||
unite_vente: Optional[str] = Field(None, description="Unité de vente")
|
est_actif: bool = Field(
|
||||||
unite_achat: Optional[str] = Field(None, description="Unité d'achat")
|
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)"
|
||||||
|
)
|
||||||
|
|
||||||
poids: Optional[float] = Field(None, description="Poids (kg)")
|
code_fiscal: Optional[str] = Field(
|
||||||
volume: Optional[float] = Field(None, description="Volume (m³)")
|
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)"
|
||||||
|
)
|
||||||
|
|
||||||
est_actif: bool = Field(True, description="Article actif")
|
stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)")
|
||||||
en_sommeil: bool = Field(False, description="Article en sommeil")
|
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)")
|
||||||
|
|
||||||
tva_code: Optional[str] = Field(None, description="Code TVA")
|
categorie_1: Optional[int] = Field(
|
||||||
tva_taux: Optional[float] = Field(None, description="Taux de TVA (%)")
|
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")
|
date_creation: Optional[str] = Field(
|
||||||
|
None, description="Date de création (AR_DateCre)"
|
||||||
|
)
|
||||||
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):
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue