Merge pull request 'feat/new_features' (#2) from feat/new_features into develop
Reviewed-on: fanilo/backend_vps#2
This commit is contained in:
commit
33fe6cd0fa
17 changed files with 3992 additions and 774 deletions
|
|
@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100
|
||||||
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
|
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
|
||||||
|
|
||||||
# === Base de données ===
|
# === Base de données ===
|
||||||
DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db
|
DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db
|
||||||
|
|
||||||
# === SMTP ===
|
# === SMTP ===
|
||||||
SMTP_HOST=smtp.office365.com
|
SMTP_HOST=smtp.office365.com
|
||||||
|
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -35,6 +35,4 @@ htmlcov/
|
||||||
# Docker
|
# Docker
|
||||||
*~
|
*~
|
||||||
.build/
|
.build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
*.db
|
|
||||||
|
|
@ -6,8 +6,8 @@ class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
|
||||||
)
|
)
|
||||||
|
|
||||||
# === JWT & Auth ===
|
# === JWT & Auth ===
|
||||||
jwt_secret: str
|
jwt_secret: str
|
||||||
jwt_algorithm: str
|
jwt_algorithm: str
|
||||||
access_token_expire_minutes: int
|
access_token_expire_minutes: int
|
||||||
|
|
@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
||||||
frontend_url: str
|
frontend_url: str
|
||||||
|
|
||||||
# === Base de données ===
|
# === Base de données ===
|
||||||
database_url: str = "sqlite+aiosqlite:///./sage_dataven.db"
|
database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db"
|
||||||
|
|
||||||
# === SMTP ===
|
# === SMTP ===
|
||||||
smtp_host: str
|
smtp_host: str
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,25 @@ from sqlalchemy import select
|
||||||
from database import get_session, User
|
from database import get_session, User
|
||||||
from security.auth import decode_token
|
from security.auth import decode_token
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from datetime import datetime # ✅ AJOUT MANQUANT - C'ÉTAIT LE BUG !
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||||
session: AsyncSession = Depends(get_session)
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Dépendance FastAPI pour extraire l'utilisateur du JWT
|
Dépendance FastAPI pour extraire l'utilisateur du JWT
|
||||||
|
|
||||||
Usage dans un endpoint:
|
Usage dans un endpoint:
|
||||||
@app.get("/protected")
|
@app.get("/protected")
|
||||||
async def protected_route(user: User = Depends(get_current_user)):
|
async def protected_route(user: User = Depends(get_current_user)):
|
||||||
return {"user_id": user.id}
|
return {"user_id": user.id}
|
||||||
"""
|
"""
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
|
|
||||||
# Décoder le token
|
# Décoder le token
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload:
|
if not payload:
|
||||||
|
|
@ -31,7 +32,7 @@ async def get_current_user(
|
||||||
detail="Token invalide ou expiré",
|
detail="Token invalide ou expiré",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérifier le type
|
# Vérifier le type
|
||||||
if payload.get("type") != "access":
|
if payload.get("type") != "access":
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -39,7 +40,7 @@ async def get_current_user(
|
||||||
detail="Type de token incorrect",
|
detail="Type de token incorrect",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extraire user_id
|
# Extraire user_id
|
||||||
user_id: str = payload.get("sub")
|
user_id: str = payload.get("sub")
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|
@ -48,46 +49,43 @@ async def get_current_user(
|
||||||
detail="Token malformé",
|
detail="Token malformé",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Charger l'utilisateur
|
# Charger l'utilisateur
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.id == user_id))
|
||||||
select(User).where(User.id == user_id)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Utilisateur introuvable",
|
detail="Utilisateur introuvable",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérifications de sécurité
|
# Vérifications de sécurité
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
|
||||||
detail="Compte désactivé"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not user.is_verified:
|
if not user.is_verified:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
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 si le compte est verrouillé
|
# ✅ FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!)
|
||||||
if user.locked_until and user.locked_until > datetime.now():
|
if user.locked_until and user.locked_until > datetime.now():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Compte temporairement verrouillé suite à trop de tentatives échouées"
|
detail="Compte temporairement verrouillé suite à trop de tentatives échouées",
|
||||||
)
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user_optional(
|
async def get_current_user_optional(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
session: AsyncSession = Depends(get_session)
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
"""
|
"""
|
||||||
Version optionnelle - ne lève pas d'erreur si pas de token
|
Version optionnelle - ne lève pas d'erreur si pas de token
|
||||||
|
|
@ -95,7 +93,7 @@ async def get_current_user_optional(
|
||||||
"""
|
"""
|
||||||
if not credentials:
|
if not credentials:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return await get_current_user(credentials, session)
|
return await get_current_user(credentials, session)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -105,18 +103,19 @@ async def get_current_user_optional(
|
||||||
def require_role(*allowed_roles: str):
|
def require_role(*allowed_roles: str):
|
||||||
"""
|
"""
|
||||||
Décorateur pour restreindre l'accès par rôle
|
Décorateur pour restreindre l'accès par rôle
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@app.get("/admin/users")
|
@app.get("/admin/users")
|
||||||
async def list_users(user: User = Depends(require_role("admin"))):
|
async def list_users(user: User = Depends(require_role("admin"))):
|
||||||
...
|
...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
||||||
if user.role not in allowed_roles:
|
if user.role not in allowed_roles:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}"
|
detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}",
|
||||||
)
|
)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
return role_checker
|
return role_checker
|
||||||
|
|
|
||||||
|
|
@ -25,29 +25,31 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def create_admin():
|
async def create_admin():
|
||||||
"""Crée un utilisateur admin"""
|
"""Crée un utilisateur admin"""
|
||||||
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("🔐 Création d'un compte administrateur")
|
print("🔐 Création d'un compte administrateur")
|
||||||
print("="*60 + "\n")
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
# Saisie des informations
|
# Saisie des informations
|
||||||
email = input("Email de l'admin: ").strip().lower()
|
email = input("Email de l'admin: ").strip().lower()
|
||||||
if not email or '@' not in email:
|
if not email or "@" not in email:
|
||||||
print("❌ Email invalide")
|
print("❌ Email invalide")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
prenom = input("Prénom: ").strip()
|
prenom = input("Prénom: ").strip()
|
||||||
nom = input("Nom: ").strip()
|
nom = input("Nom: ").strip()
|
||||||
|
|
||||||
if not prenom or not nom:
|
if not prenom or not nom:
|
||||||
print("❌ Prénom et nom requis")
|
print("❌ Prénom et nom requis")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Mot de passe avec validation
|
# Mot de passe avec validation
|
||||||
while True:
|
while True:
|
||||||
password = input("Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): ")
|
password = input(
|
||||||
|
"Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): "
|
||||||
|
)
|
||||||
is_valid, error_msg = validate_password_strength(password)
|
is_valid, error_msg = validate_password_strength(password)
|
||||||
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
confirm = input("Confirmez le mot de passe: ")
|
confirm = input("Confirmez le mot de passe: ")
|
||||||
if password == confirm:
|
if password == confirm:
|
||||||
|
|
@ -56,20 +58,18 @@ async def create_admin():
|
||||||
print("❌ Les mots de passe ne correspondent pas\n")
|
print("❌ Les mots de passe ne correspondent pas\n")
|
||||||
else:
|
else:
|
||||||
print(f"❌ {error_msg}\n")
|
print(f"❌ {error_msg}\n")
|
||||||
|
|
||||||
# Vérifier si l'email existe déjà
|
# Vérifier si l'email existe déjà
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.email == email))
|
||||||
select(User).where(User.email == email)
|
|
||||||
)
|
|
||||||
existing = result.scalar_one_or_none()
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
print(f"\n❌ Un utilisateur avec l'email {email} existe déjà")
|
print(f"\n❌ Un utilisateur avec l'email {email} existe déjà")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Créer l'admin
|
# Créer l'admin
|
||||||
admin = User(
|
admin = User(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|
@ -80,19 +80,19 @@ async def create_admin():
|
||||||
role="admin",
|
role="admin",
|
||||||
is_verified=True, # Admin vérifié par défaut
|
is_verified=True, # Admin vérifié par défaut
|
||||||
is_active=True,
|
is_active=True,
|
||||||
created_at=datetime.now()
|
created_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(admin)
|
session.add(admin)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
print("\n✅ Administrateur créé avec succès!")
|
print("\n✅ Administrateur créé avec succès!")
|
||||||
print(f"📧 Email: {email}")
|
print(f"📧 Email: {email}")
|
||||||
print(f"👤 Nom: {prenom} {nom}")
|
print(f"👤 Nom: {prenom} {nom}")
|
||||||
print(f"🔑 Rôle: admin")
|
print(f"🔑 Rôle: admin")
|
||||||
print(f"🆔 ID: {admin.id}")
|
print(f"🆔 ID: {admin.id}")
|
||||||
print("\n💡 Vous pouvez maintenant vous connecter à l'API\n")
|
print("\n💡 Vous pouvez maintenant vous connecter à l'API\n")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -106,4 +106,4 @@ if __name__ == "__main__":
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ Erreur: {e}")
|
print(f"\n❌ Erreur: {e}")
|
||||||
logger.exception("Détails:")
|
logger.exception("Détails:")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
|
||||||
BIN
data/sage_dataven.db
Normal file
BIN
data/sage_dataven.db
Normal file
Binary file not shown.
|
|
@ -3,7 +3,7 @@ from database.db_config import (
|
||||||
async_session_factory,
|
async_session_factory,
|
||||||
init_db,
|
init_db,
|
||||||
get_session,
|
get_session,
|
||||||
close_db
|
close_db,
|
||||||
)
|
)
|
||||||
|
|
||||||
from database.models import (
|
from database.models import (
|
||||||
|
|
@ -23,26 +23,23 @@ from database.models import (
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Config
|
# Config
|
||||||
'engine',
|
"engine",
|
||||||
'async_session_factory',
|
"async_session_factory",
|
||||||
'init_db',
|
"init_db",
|
||||||
'get_session',
|
"get_session",
|
||||||
'close_db',
|
"close_db",
|
||||||
|
|
||||||
# Models existants
|
# Models existants
|
||||||
'Base',
|
"Base",
|
||||||
'EmailLog',
|
"EmailLog",
|
||||||
'SignatureLog',
|
"SignatureLog",
|
||||||
'WorkflowLog',
|
"WorkflowLog",
|
||||||
'CacheMetadata',
|
"CacheMetadata",
|
||||||
'AuditLog',
|
"AuditLog",
|
||||||
|
|
||||||
# Enums
|
# Enums
|
||||||
'StatutEmail',
|
"StatutEmail",
|
||||||
'StatutSignature',
|
"StatutSignature",
|
||||||
|
|
||||||
# Modèles auth
|
# Modèles auth
|
||||||
'User',
|
"User",
|
||||||
'RefreshToken',
|
"RefreshToken",
|
||||||
'LoginAttempt',
|
"LoginAttempt",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db")
|
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db")
|
||||||
|
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
|
|
@ -32,10 +32,10 @@ async def init_db():
|
||||||
try:
|
try:
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
logger.info("✅ Base de données initialisée avec succès")
|
logger.info("✅ Base de données initialisée avec succès")
|
||||||
logger.info(f"📍 Fichier DB: {DATABASE_URL}")
|
logger.info(f"📍 Fichier DB: {DATABASE_URL}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur initialisation DB: {e}")
|
logger.error(f"❌ Erreur initialisation DB: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
@ -53,4 +53,4 @@ async def get_session() -> AsyncSession:
|
||||||
async def close_db():
|
async def close_db():
|
||||||
"""Ferme proprement toutes les connexions"""
|
"""Ferme proprement toutes les connexions"""
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
logger.info("🔌 Connexions DB fermées")
|
logger.info("🔌 Connexions DB fermées")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
from sqlalchemy import Column, Integer, String, DateTime, Float, Text, Boolean, Enum as SQLEnum
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
Text,
|
||||||
|
Boolean,
|
||||||
|
Enum as SQLEnum,
|
||||||
|
)
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import enum
|
import enum
|
||||||
|
|
@ -9,8 +18,10 @@ Base = declarative_base()
|
||||||
# Enums
|
# Enums
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class StatutEmail(str, enum.Enum):
|
class StatutEmail(str, enum.Enum):
|
||||||
"""Statuts possibles d'un email"""
|
"""Statuts possibles d'un email"""
|
||||||
|
|
||||||
EN_ATTENTE = "EN_ATTENTE"
|
EN_ATTENTE = "EN_ATTENTE"
|
||||||
EN_COURS = "EN_COURS"
|
EN_COURS = "EN_COURS"
|
||||||
ENVOYE = "ENVOYE"
|
ENVOYE = "ENVOYE"
|
||||||
|
|
@ -18,54 +29,59 @@ class StatutEmail(str, enum.Enum):
|
||||||
ERREUR = "ERREUR"
|
ERREUR = "ERREUR"
|
||||||
BOUNCE = "BOUNCE"
|
BOUNCE = "BOUNCE"
|
||||||
|
|
||||||
|
|
||||||
class StatutSignature(str, enum.Enum):
|
class StatutSignature(str, enum.Enum):
|
||||||
"""Statuts possibles d'une signature électronique"""
|
"""Statuts possibles d'une signature électronique"""
|
||||||
|
|
||||||
EN_ATTENTE = "EN_ATTENTE"
|
EN_ATTENTE = "EN_ATTENTE"
|
||||||
ENVOYE = "ENVOYE"
|
ENVOYE = "ENVOYE"
|
||||||
SIGNE = "SIGNE"
|
SIGNE = "SIGNE"
|
||||||
REFUSE = "REFUSE"
|
REFUSE = "REFUSE"
|
||||||
EXPIRE = "EXPIRE"
|
EXPIRE = "EXPIRE"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Tables
|
# Tables
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
class EmailLog(Base):
|
class EmailLog(Base):
|
||||||
"""
|
"""
|
||||||
Journal des emails envoyés via l'API
|
Journal des emails envoyés via l'API
|
||||||
Permet le suivi et le retry automatique
|
Permet le suivi et le retry automatique
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "email_logs"
|
__tablename__ = "email_logs"
|
||||||
|
|
||||||
# Identifiant
|
# Identifiant
|
||||||
id = Column(String(36), primary_key=True)
|
id = Column(String(36), primary_key=True)
|
||||||
|
|
||||||
# Destinataires
|
# 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
|
# 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
|
# 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
|
||||||
statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True)
|
statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True)
|
||||||
|
|
||||||
# Tracking temporel
|
# 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
|
# 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
|
# 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)
|
||||||
|
|
@ -79,33 +95,37 @@ class SignatureLog(Base):
|
||||||
Journal des demandes de signature Universign
|
Journal des demandes de signature Universign
|
||||||
Permet le suivi du workflow de signature
|
Permet le suivi du workflow de signature
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "signature_logs"
|
__tablename__ = "signature_logs"
|
||||||
|
|
||||||
# Identifiant
|
# Identifiant
|
||||||
id = Column(String(36), primary_key=True)
|
id = Column(String(36), primary_key=True)
|
||||||
|
|
||||||
# Document Sage associé
|
# 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
|
# 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
|
# 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
|
||||||
statut = Column(SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True)
|
statut = Column(
|
||||||
|
SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True
|
||||||
|
)
|
||||||
date_envoi = Column(DateTime, default=datetime.now)
|
date_envoi = Column(DateTime, default=datetime.now)
|
||||||
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
|
# 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)
|
||||||
|
|
||||||
# Métadonnées
|
# 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)
|
||||||
|
|
@ -119,27 +139,28 @@ class WorkflowLog(Base):
|
||||||
Journal des transformations de documents (Devis → Commande → Facture)
|
Journal des transformations de documents (Devis → Commande → Facture)
|
||||||
Permet la traçabilité du workflow commercial
|
Permet la traçabilité du workflow commercial
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "workflow_logs"
|
__tablename__ = "workflow_logs"
|
||||||
|
|
||||||
# Identifiant
|
# Identifiant
|
||||||
id = Column(String(36), primary_key=True)
|
id = Column(String(36), primary_key=True)
|
||||||
|
|
||||||
# Documents
|
# 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
|
# 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
|
# 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
|
# Résultat
|
||||||
succes = Column(Boolean, default=True)
|
succes = Column(Boolean, default=True)
|
||||||
erreur = Column(Text, nullable=True)
|
erreur = Column(Text, nullable=True)
|
||||||
|
|
@ -154,18 +175,21 @@ class CacheMetadata(Base):
|
||||||
Métadonnées sur le cache Sage (clients, articles)
|
Métadonnées sur le cache Sage (clients, articles)
|
||||||
Permet le monitoring du cache géré par la gateway Windows
|
Permet le monitoring du cache géré par la gateway Windows
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "cache_metadata"
|
__tablename__ = "cache_metadata"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
# Type de cache
|
# Type de cache
|
||||||
cache_type = Column(String(50), unique=True, nullable=False) # 'clients' ou 'articles'
|
cache_type = Column(
|
||||||
|
String(50), unique=True, nullable=False
|
||||||
|
) # 'clients' ou 'articles'
|
||||||
|
|
||||||
# Statistiques
|
# 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é
|
# 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)
|
||||||
|
|
@ -179,66 +203,72 @@ class AuditLog(Base):
|
||||||
Journal d'audit pour la sécurité et la conformité
|
Journal d'audit pour la sécurité et la conformité
|
||||||
Trace toutes les actions importantes dans l'API
|
Trace toutes les actions importantes dans l'API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
# Action
|
# Action
|
||||||
action = Column(String(100), nullable=False, index=True) # 'CREATE_DEVIS', 'SEND_EMAIL', etc.
|
action = Column(
|
||||||
|
String(100), nullable=False, index=True
|
||||||
|
) # '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 (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
|
# 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
|
# 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
|
# Ajouter ces modèles à la fin de database/models.py
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
"""
|
"""
|
||||||
Utilisateurs de l'API avec validation email
|
Utilisateurs de l'API avec validation email
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True)
|
id = Column(String(36), primary_key=True)
|
||||||
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
|
# 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
|
# 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é
|
# 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é
|
# 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
|
# 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)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User {self.email} verified={self.is_verified}>"
|
return f"<User {self.email} verified={self.is_verified}>"
|
||||||
|
|
||||||
|
|
@ -247,24 +277,25 @@ class RefreshToken(Base):
|
||||||
"""
|
"""
|
||||||
Tokens de rafraîchissement JWT
|
Tokens de rafraîchissement JWT
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "refresh_tokens"
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True)
|
id = Column(String(36), primary_key=True)
|
||||||
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
|
# 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
|
# 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
|
# 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)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
|
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
|
||||||
|
|
||||||
|
|
@ -273,18 +304,19 @@ class LoginAttempt(Base):
|
||||||
"""
|
"""
|
||||||
Journal des tentatives de connexion (détection bruteforce)
|
Journal des tentatives de connexion (détection bruteforce)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "login_attempts"
|
__tablename__ = "login_attempts"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
email = Column(String(255), nullable=False, index=True)
|
email = Column(String(255), nullable=False, index=True)
|
||||||
ip_address = Column(String(45), nullable=False, index=True)
|
ip_address = Column(String(45), nullable=False, index=True)
|
||||||
user_agent = Column(String(500), nullable=True)
|
user_agent = Column(String(500), nullable=True)
|
||||||
|
|
||||||
success = Column(Boolean, default=False)
|
success = Column(Boolean, default=False)
|
||||||
failure_reason = Column(String(255), nullable=True)
|
failure_reason = Column(String(255), nullable=True)
|
||||||
|
|
||||||
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<LoginAttempt {self.email} success={self.success}>"
|
return f"<LoginAttempt {self.email} success={self.success}>"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ services:
|
||||||
container_name: vps-sage-api
|
container_name: vps-sage-api
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
# ✅ Monter un DOSSIER entier au lieu d'un fichier
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
|
||||||
251
email_queue.py
251
email_queue.py
|
|
@ -25,67 +25,65 @@ class EmailQueue:
|
||||||
"""
|
"""
|
||||||
Queue d'emails avec workers threadés et retry automatique
|
Queue d'emails avec workers threadés et retry automatique
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue = queue.Queue()
|
self.queue = queue.Queue()
|
||||||
self.workers = []
|
self.workers = []
|
||||||
self.running = False
|
self.running = False
|
||||||
self.session_factory = None
|
self.session_factory = None
|
||||||
self.sage_client = None # Sera injecté depuis api.py
|
self.sage_client = None
|
||||||
|
|
||||||
def start(self, num_workers: int = 3):
|
def start(self, num_workers: int = 3):
|
||||||
"""Démarre les workers"""
|
"""Démarre les workers"""
|
||||||
if self.running:
|
if self.running:
|
||||||
logger.warning("Queue déjà démarrée")
|
logger.warning("Queue déjà démarrée")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.running = True
|
self.running = True
|
||||||
for i in range(num_workers):
|
for i in range(num_workers):
|
||||||
worker = threading.Thread(
|
worker = threading.Thread(
|
||||||
target=self._worker,
|
target=self._worker, name=f"EmailWorker-{i}", daemon=True
|
||||||
name=f"EmailWorker-{i}",
|
|
||||||
daemon=True
|
|
||||||
)
|
)
|
||||||
worker.start()
|
worker.start()
|
||||||
self.workers.append(worker)
|
self.workers.append(worker)
|
||||||
|
|
||||||
logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)")
|
logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)")
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""Arrête les workers proprement"""
|
"""Arrête les workers proprement"""
|
||||||
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)
|
# 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")
|
||||||
except:
|
except:
|
||||||
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
|
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
|
||||||
|
|
||||||
def enqueue(self, email_log_id: str):
|
def enqueue(self, email_log_id: str):
|
||||||
"""Ajoute un email dans la queue"""
|
"""Ajoute un email dans la queue"""
|
||||||
self.queue.put(email_log_id)
|
self.queue.put(email_log_id)
|
||||||
logger.debug(f"📨 Email {email_log_id} ajouté à la queue")
|
logger.debug(f"📨 Email {email_log_id} ajouté à la queue")
|
||||||
|
|
||||||
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
|
# 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)
|
# 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
|
# 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é
|
# Marquer comme traité
|
||||||
self.queue.task_done()
|
self.queue.task_done()
|
||||||
|
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -96,144 +94,147 @@ class EmailQueue:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
loop.close()
|
loop.close()
|
||||||
|
|
||||||
async def _process_email(self, email_log_id: str):
|
async def _process_email(self, email_log_id: str):
|
||||||
"""Traite un email avec retry automatique"""
|
"""Traite un email avec retry automatique"""
|
||||||
from database import EmailLog, StatutEmail
|
from database import EmailLog, StatutEmail
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
if not self.session_factory:
|
if not self.session_factory:
|
||||||
logger.error("❌ session_factory non configuré")
|
logger.error("❌ session_factory non configuré")
|
||||||
return
|
return
|
||||||
|
|
||||||
async with self.session_factory() as session:
|
async with self.session_factory() as session:
|
||||||
# Charger l'email log
|
# 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)
|
||||||
)
|
)
|
||||||
email_log = result.scalar_one_or_none()
|
email_log = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not email_log:
|
if not email_log:
|
||||||
logger.error(f"❌ Email log {email_log_id} introuvable")
|
logger.error(f"❌ Email log {email_log_id} introuvable")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Marquer comme en cours
|
# 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
|
# Envoi avec retry automatique
|
||||||
await self._send_with_retry(email_log)
|
await self._send_with_retry(email_log)
|
||||||
|
|
||||||
# Succès
|
# 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
|
# É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
|
# 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 * (2 ** (email_log.nb_tentatives - 1))
|
delay = settings.retry_delay_seconds * (
|
||||||
|
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
|
# 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()
|
||||||
|
|
||||||
logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}")
|
logger.warning(
|
||||||
|
f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}")
|
logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}")
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@retry(
|
@retry(
|
||||||
stop=stop_after_attempt(3),
|
stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10)
|
||||||
wait=wait_exponential(multiplier=1, min=4, max=10)
|
|
||||||
)
|
)
|
||||||
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
|
# 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
|
# 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
|
# 📎 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
|
||||||
|
|
||||||
for doc_id in document_ids:
|
for doc_id in document_ids:
|
||||||
doc_id = doc_id.strip()
|
doc_id = doc_id.strip()
|
||||||
if not doc_id:
|
if not doc_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Générer PDF (appel bloquant dans thread séparé)
|
# 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,
|
self._generate_pdf, doc_id, type_doc
|
||||||
doc_id,
|
|
||||||
type_doc
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if pdf_bytes:
|
if pdf_bytes:
|
||||||
# Attacher PDF
|
# Attacher PDF
|
||||||
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
|
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
|
||||||
part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"'
|
part["Content-Disposition"] = (
|
||||||
|
f'attachment; filename="{doc_id}.pdf"'
|
||||||
|
)
|
||||||
msg.attach(part)
|
msg.attach(part)
|
||||||
logger.info(f"📎 PDF attaché: {doc_id}.pdf")
|
logger.info(f"📎 PDF attaché: {doc_id}.pdf")
|
||||||
|
|
||||||
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
|
# Continuer avec les autres PDFs
|
||||||
|
|
||||||
# Envoi SMTP (bloquant mais dans thread séparé)
|
# 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:
|
||||||
"""
|
"""
|
||||||
Génération PDF via ReportLab + sage_client
|
Génération PDF via ReportLab + sage_client
|
||||||
|
|
||||||
⚠️ Cette méthode est appelée depuis un thread worker
|
⚠️ Cette méthode est appelée depuis un thread worker
|
||||||
"""
|
"""
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.pdfgen import canvas
|
from reportlab.pdfgen import canvas
|
||||||
from reportlab.lib.units import cm
|
from reportlab.lib.units import cm
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
if not self.sage_client:
|
if not self.sage_client:
|
||||||
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
|
# 📡 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:
|
||||||
logger.error(f"❌ Erreur récupération document {doc_id}: {e}")
|
logger.error(f"❌ Erreur récupération document {doc_id}: {e}")
|
||||||
raise Exception(f"Document {doc_id} inaccessible")
|
raise Exception(f"Document {doc_id} inaccessible")
|
||||||
|
|
||||||
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
|
# 📄 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 ===
|
# === 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 de document
|
||||||
type_labels = {
|
type_labels = {
|
||||||
0: "DEVIS",
|
0: "DEVIS",
|
||||||
|
|
@ -241,101 +242,105 @@ class EmailQueue:
|
||||||
2: "BON DE RETOUR",
|
2: "BON DE RETOUR",
|
||||||
3: "COMMANDE",
|
3: "COMMANDE",
|
||||||
4: "PRÉPARATION",
|
4: "PRÉPARATION",
|
||||||
5: "FACTURE"
|
5: "FACTURE",
|
||||||
}
|
}
|
||||||
type_label = type_labels.get(type_doc, "DOCUMENT")
|
type_label = type_labels.get(type_doc, "DOCUMENT")
|
||||||
|
|
||||||
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 ===
|
# === 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")
|
||||||
|
|
||||||
y -= 0.8*cm
|
y -= 0.8 * cm
|
||||||
pdf.setFont("Helvetica", 11)
|
pdf.setFont("Helvetica", 11)
|
||||||
pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}")
|
pdf.drawString(2 * cm, y, f"Code: {doc.get('client_code', '')}")
|
||||||
y -= 0.6*cm
|
y -= 0.6 * cm
|
||||||
pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}")
|
pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule', '')}")
|
||||||
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 ===
|
# === 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")
|
||||||
|
|
||||||
y -= 1*cm
|
y -= 1 * cm
|
||||||
pdf.setFont("Helvetica-Bold", 10)
|
pdf.setFont("Helvetica-Bold", 10)
|
||||||
pdf.drawString(2*cm, y, "Désignation")
|
pdf.drawString(2 * cm, y, "Désignation")
|
||||||
pdf.drawString(10*cm, y, "Qté")
|
pdf.drawString(10 * cm, y, "Qté")
|
||||||
pdf.drawString(12*cm, y, "Prix Unit.")
|
pdf.drawString(12 * cm, y, "Prix Unit.")
|
||||||
pdf.drawString(15*cm, y, "Total HT")
|
pdf.drawString(15 * cm, y, "Total HT")
|
||||||
|
|
||||||
y -= 0.5*cm
|
y -= 0.5 * cm
|
||||||
pdf.line(2*cm, y, width - 2*cm, y)
|
pdf.line(2 * cm, y, width - 2 * cm, y)
|
||||||
|
|
||||||
y -= 0.7*cm
|
y -= 0.7 * cm
|
||||||
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
|
# 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
|
||||||
pdf.setFont("Helvetica", 9)
|
pdf.setFont("Helvetica", 9)
|
||||||
|
|
||||||
designation = ligne.get('designation', '')[:50]
|
designation = ligne.get("designation", "")[:50]
|
||||||
pdf.drawString(2*cm, y, designation)
|
pdf.drawString(2 * cm, y, designation)
|
||||||
pdf.drawString(10*cm, y, str(ligne.get('quantite', 0)))
|
pdf.drawString(10 * cm, y, str(ligne.get("quantite", 0)))
|
||||||
pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€")
|
pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€")
|
||||||
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 ===
|
# === 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)
|
||||||
|
|
||||||
y -= 0.8*cm
|
y -= 0.8 * cm
|
||||||
pdf.setFont("Helvetica-Bold", 11)
|
pdf.setFont("Helvetica-Bold", 11)
|
||||||
pdf.drawString(12*cm, y, "Total HT:")
|
pdf.drawString(12 * cm, y, "Total HT:")
|
||||||
pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€")
|
pdf.drawString(15 * cm, y, f"{doc.get('total_ht', 0):.2f}€")
|
||||||
|
|
||||||
y -= 0.6*cm
|
y -= 0.6 * cm
|
||||||
pdf.drawString(12*cm, y, "TVA (20%):")
|
pdf.drawString(12 * cm, y, "TVA (20%):")
|
||||||
tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0)
|
tva = doc.get("total_ttc", 0) - doc.get("total_ht", 0)
|
||||||
pdf.drawString(15*cm, y, f"{tva:.2f}€")
|
pdf.drawString(15 * cm, y, f"{tva:.2f}€")
|
||||||
|
|
||||||
y -= 0.6*cm
|
y -= 0.6 * cm
|
||||||
pdf.setFont("Helvetica-Bold", 14)
|
pdf.setFont("Helvetica-Bold", 14)
|
||||||
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 ===
|
# === PIED DE PAGE ===
|
||||||
pdf.setFont("Helvetica", 8)
|
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(
|
||||||
pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven")
|
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
|
# Finaliser
|
||||||
pdf.save()
|
pdf.save()
|
||||||
buffer.seek(0)
|
buffer.seek(0)
|
||||||
|
|
||||||
logger.info(f"✅ PDF généré: {doc_id}.pdf")
|
logger.info(f"✅ PDF généré: {doc_id}.pdf")
|
||||||
return buffer.read()
|
return buffer.read()
|
||||||
|
|
||||||
def _send_smtp(self, msg):
|
def _send_smtp(self, msg):
|
||||||
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
|
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
|
with smtplib.SMTP(
|
||||||
|
settings.smtp_host, settings.smtp_port, timeout=30
|
||||||
|
) as server:
|
||||||
if settings.smtp_use_tls:
|
if settings.smtp_use_tls:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
|
|
||||||
if settings.smtp_user and settings.smtp_password:
|
if settings.smtp_user and settings.smtp_password:
|
||||||
server.login(settings.smtp_user, settings.smtp_password)
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
|
||||||
server.send_message(msg)
|
server.send_message(msg)
|
||||||
|
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
raise Exception(f"Erreur SMTP: {str(e)}")
|
raise Exception(f"Erreur SMTP: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -343,4 +348,4 @@ class EmailQueue:
|
||||||
|
|
||||||
|
|
||||||
# Instance globale
|
# Instance globale
|
||||||
email_queue = EmailQueue()
|
email_queue = EmailQueue()
|
||||||
|
|
|
||||||
22
init_db.py
22
init_db.py
|
|
@ -23,35 +23,35 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
"""Crée toutes les tables dans sage_dataven.db"""
|
"""Crée toutes les tables dans sage_dataven.db"""
|
||||||
|
|
||||||
print("\n" + "="*60)
|
print("\n" + "=" * 60)
|
||||||
print("🚀 Initialisation de la base de données Sage Dataven")
|
print("🚀 Initialisation de la base de données Sage Dataven")
|
||||||
print("="*60 + "\n")
|
print("=" * 60 + "\n")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Créer les tables
|
# Créer les tables
|
||||||
await init_db()
|
await init_db()
|
||||||
|
|
||||||
print("\n✅ Base de données créée avec succès!")
|
print("\n✅ Base de données créée avec succès!")
|
||||||
print(f"📍 Fichier: sage_dataven.db")
|
print(f"📍 Fichier: sage_dataven.db")
|
||||||
|
|
||||||
print("\n📊 Tables créées:")
|
print("\n📊 Tables créées:")
|
||||||
print(" ├─ email_logs (Journalisation emails)")
|
print(" ├─ email_logs (Journalisation emails)")
|
||||||
print(" ├─ signature_logs (Suivi signatures Universign)")
|
print(" ├─ signature_logs (Suivi signatures Universign)")
|
||||||
print(" ├─ workflow_logs (Transformations documents)")
|
print(" ├─ workflow_logs (Transformations documents)")
|
||||||
print(" ├─ cache_metadata (Métadonnées cache)")
|
print(" ├─ cache_metadata (Métadonnées cache)")
|
||||||
print(" └─ audit_logs (Journal d'audit)")
|
print(" └─ audit_logs (Journal d'audit)")
|
||||||
|
|
||||||
print("\n📝 Prochaines étapes:")
|
print("\n📝 Prochaines étapes:")
|
||||||
print(" 1. Configurer le fichier .env avec vos credentials")
|
print(" 1. Configurer le fichier .env avec vos credentials")
|
||||||
print(" 2. Lancer la gateway Windows sur la machine Sage")
|
print(" 2. Lancer la gateway Windows sur la machine Sage")
|
||||||
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
|
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
|
||||||
print(" 4. Ou avec Docker: docker-compose up -d")
|
print(" 4. Ou avec Docker: docker-compose up -d")
|
||||||
print(" 5. Tester: http://votre-vps:8000/docs")
|
print(" 5. Tester: http://votre-vps:8000/docs")
|
||||||
|
|
||||||
print("\n" + "="*60 + "\n")
|
print("\n" + "=" * 60 + "\n")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\n❌ Erreur lors de l'initialisation: {e}")
|
print(f"\n❌ Erreur lors de l'initialisation: {e}")
|
||||||
logger.exception("Détails de l'erreur:")
|
logger.exception("Détails de l'erreur:")
|
||||||
|
|
@ -60,4 +60,4 @@ async def main():
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
result = asyncio.run(main())
|
result = asyncio.run(main())
|
||||||
sys.exit(0 if result else 1)
|
sys.exit(0 if result else 1)
|
||||||
|
|
|
||||||
372
routes/auth.py
372
routes/auth.py
|
|
@ -16,7 +16,7 @@ from security.auth import (
|
||||||
decode_token,
|
decode_token,
|
||||||
generate_verification_token,
|
generate_verification_token,
|
||||||
generate_reset_token,
|
generate_reset_token,
|
||||||
hash_token
|
hash_token,
|
||||||
)
|
)
|
||||||
from services.email_service import AuthEmailService
|
from services.email_service import AuthEmailService
|
||||||
from core.dependencies import get_current_user
|
from core.dependencies import get_current_user
|
||||||
|
|
@ -29,6 +29,7 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||||
|
|
||||||
# === MODÈLES PYDANTIC ===
|
# === MODÈLES PYDANTIC ===
|
||||||
|
|
||||||
|
|
||||||
class RegisterRequest(BaseModel):
|
class RegisterRequest(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
password: str = Field(..., min_length=8)
|
password: str = Field(..., min_length=8)
|
||||||
|
|
@ -45,7 +46,7 @@ class TokenResponse(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
expires_in: int = 1800 # 30 minutes en secondes
|
expires_in: int = 86400 # 30 minutes en secondes
|
||||||
|
|
||||||
|
|
||||||
class RefreshTokenRequest(BaseModel):
|
class RefreshTokenRequest(BaseModel):
|
||||||
|
|
@ -71,13 +72,14 @@ class ResendVerificationRequest(BaseModel):
|
||||||
|
|
||||||
# === UTILITAIRES ===
|
# === UTILITAIRES ===
|
||||||
|
|
||||||
|
|
||||||
async def log_login_attempt(
|
async def log_login_attempt(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
email: str,
|
email: str,
|
||||||
ip: str,
|
ip: str,
|
||||||
user_agent: str,
|
user_agent: str,
|
||||||
success: bool,
|
success: bool,
|
||||||
failure_reason: Optional[str] = None
|
failure_reason: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Enregistre une tentative de connexion"""
|
"""Enregistre une tentative de connexion"""
|
||||||
attempt = LoginAttempt(
|
attempt = LoginAttempt(
|
||||||
|
|
@ -86,76 +88,72 @@ async def log_login_attempt(
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
success=success,
|
success=success,
|
||||||
failure_reason=failure_reason,
|
failure_reason=failure_reason,
|
||||||
timestamp=datetime.now()
|
timestamp=datetime.now(),
|
||||||
)
|
)
|
||||||
session.add(attempt)
|
session.add(attempt)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[bool, str]:
|
async def check_rate_limit(
|
||||||
|
session: AsyncSession, email: str, ip: str
|
||||||
|
) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Vérifie si l'utilisateur/IP est rate limité
|
Vérifie si l'utilisateur/IP est rate limité
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_allowed, error_message)
|
(is_allowed, error_message)
|
||||||
"""
|
"""
|
||||||
# Vérifier les tentatives échouées des 15 dernières minutes
|
# 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(
|
||||||
select(LoginAttempt)
|
select(LoginAttempt).where(
|
||||||
.where(
|
|
||||||
LoginAttempt.email == email,
|
LoginAttempt.email == email,
|
||||||
LoginAttempt.success == False,
|
LoginAttempt.success == False,
|
||||||
LoginAttempt.timestamp >= time_window
|
LoginAttempt.timestamp >= time_window,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
failed_attempts = result.scalars().all()
|
failed_attempts = result.scalars().all()
|
||||||
|
|
||||||
if len(failed_attempts) >= 5:
|
if len(failed_attempts) >= 5:
|
||||||
return False, "Trop de tentatives échouées. Réessayez dans 15 minutes."
|
return False, "Trop de tentatives échouées. Réessayez dans 15 minutes."
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
# === ENDPOINTS ===
|
# === ENDPOINTS ===
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||||
async def register(
|
async def register(
|
||||||
data: RegisterRequest,
|
data: RegisterRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session)
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
📝 Inscription d'un nouvel utilisateur
|
📝 Inscription d'un nouvel utilisateur
|
||||||
|
|
||||||
- Valide le mot de passe
|
- Valide le mot de passe
|
||||||
- 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à
|
# Vérifier si l'email existe déjà
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.email == data.email))
|
||||||
select(User).where(User.email == data.email)
|
|
||||||
)
|
|
||||||
existing_user = result.scalar_one_or_none()
|
existing_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if existing_user:
|
if existing_user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé"
|
||||||
detail="Cet email est déjà utilisé"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Valider le mot de passe
|
# 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(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=error_msg
|
|
||||||
)
|
|
||||||
|
|
||||||
# Générer token de vérification
|
# Générer token de vérification
|
||||||
verification_token = generate_verification_token()
|
verification_token = generate_verification_token()
|
||||||
|
|
||||||
# Créer l'utilisateur
|
# Créer l'utilisateur
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|
@ -166,80 +164,72 @@ async def register(
|
||||||
is_verified=False,
|
is_verified=False,
|
||||||
verification_token=verification_token,
|
verification_token=verification_token,
|
||||||
verification_token_expires=datetime.now() + timedelta(hours=24),
|
verification_token_expires=datetime.now() + timedelta(hours=24),
|
||||||
created_at=datetime.now()
|
created_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(new_user)
|
session.add(new_user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Envoyer email de vérification
|
# 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,
|
data.email, verification_token, base_url
|
||||||
verification_token,
|
|
||||||
base_url
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not email_sent:
|
if not email_sent:
|
||||||
logger.warning(f"Échec envoi email vérification pour {data.email}")
|
logger.warning(f"Échec envoi email vérification pour {data.email}")
|
||||||
|
|
||||||
logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})")
|
logger.info(f"✅ Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.",
|
"message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.",
|
||||||
"user_id": new_user.id,
|
"user_id": new_user.id,
|
||||||
"email": data.email
|
"email": data.email,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/verify-email")
|
@router.get("/verify-email")
|
||||||
async def verify_email_get(
|
async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)):
|
||||||
token: str,
|
|
||||||
session: AsyncSession = Depends(get_session)
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
✅ Vérification de l'email via lien cliquable (GET)
|
✅ Vérification de l'email via lien cliquable (GET)
|
||||||
Utilisé quand l'utilisateur clique sur le lien dans l'email
|
Utilisé quand l'utilisateur clique sur le lien dans l'email
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.verification_token == token))
|
||||||
select(User).where(User.verification_token == token)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Token de vérification invalide ou déjà utilisé."
|
"message": "Token de vérification invalide ou déjà utilisé.",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Vérifier l'expiration
|
# Vérifier l'expiration
|
||||||
if user.verification_token_expires < datetime.now():
|
if user.verification_token_expires < datetime.now():
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"message": "Token expiré. Veuillez demander un nouvel email de vérification.",
|
"message": "Token expiré. Veuillez demander un nouvel email de vérification.",
|
||||||
"expired": True
|
"expired": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Activer le compte
|
# 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
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(f"✅ Email vérifié: {user.email}")
|
logger.info(f"✅ Email vérifié: {user.email}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.",
|
"message": "✅ Email vérifié avec succès ! Vous pouvez maintenant vous connecter.",
|
||||||
"email": user.email
|
"email": user.email,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-email")
|
@router.post("/verify-email")
|
||||||
async def verify_email_post(
|
async def verify_email_post(
|
||||||
data: VerifyEmailRequest,
|
data: VerifyEmailRequest, session: AsyncSession = Depends(get_session)
|
||||||
session: AsyncSession = Depends(get_session)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
✅ Vérification de l'email via API (POST)
|
✅ Vérification de l'email via API (POST)
|
||||||
|
|
@ -249,31 +239,31 @@ async def verify_email_post(
|
||||||
select(User).where(User.verification_token == data.token)
|
select(User).where(User.verification_token == data.token)
|
||||||
)
|
)
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Token de vérification invalide"
|
detail="Token de vérification invalide",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérifier l'expiration
|
# 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
|
# 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
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(f"✅ Email vérifié: {user.email}")
|
logger.info(f"✅ Email vérifié: {user.email}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter."
|
"message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -281,135 +271,134 @@ async def verify_email_post(
|
||||||
async def resend_verification(
|
async def resend_verification(
|
||||||
data: ResendVerificationRequest,
|
data: ResendVerificationRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session)
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
🔄 Renvoyer l'email de vérification
|
🔄 Renvoyer l'email de vérification
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.email == data.email.lower()))
|
||||||
select(User).where(User.email == data.email.lower())
|
|
||||||
)
|
|
||||||
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
|
# 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é.",
|
||||||
}
|
}
|
||||||
|
|
||||||
if user.is_verified:
|
if user.is_verified:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié"
|
||||||
detail="Ce compte est déjà vérifié"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Générer nouveau token
|
# 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
|
# Envoyer email
|
||||||
base_url = str(request.base_url).rstrip('/')
|
base_url = str(request.base_url).rstrip("/")
|
||||||
AuthEmailService.send_verification_email(
|
AuthEmailService.send_verification_email(user.email, verification_token, base_url)
|
||||||
user.email,
|
|
||||||
verification_token,
|
return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."}
|
||||||
base_url
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "Un nouveau lien de vérification a été envoyé."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(
|
async def login(
|
||||||
data: LoginRequest,
|
data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
|
||||||
request: Request,
|
|
||||||
session: AsyncSession = Depends(get_session)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
🔐 Connexion utilisateur
|
🔐 Connexion utilisateur
|
||||||
|
|
||||||
Retourne access_token (30min) et refresh_token (7 jours)
|
Retourne access_token (30min) et refresh_token (7 jours)
|
||||||
"""
|
"""
|
||||||
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
|
# 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,
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
|
||||||
detail=error_msg
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Charger l'utilisateur
|
# Charger l'utilisateur
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.email == data.email.lower()))
|
||||||
select(User).where(User.email == data.email.lower())
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
# Vérifications
|
# 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(session, data.email.lower(), ip, user_agent, False, "Identifiants incorrects")
|
await log_login_attempt(
|
||||||
|
session,
|
||||||
|
data.email.lower(),
|
||||||
|
ip,
|
||||||
|
user_agent,
|
||||||
|
False,
|
||||||
|
"Identifiants incorrects",
|
||||||
|
)
|
||||||
|
|
||||||
# Incrémenter compteur échecs
|
# Incrémenter compteur échecs
|
||||||
if user:
|
if user:
|
||||||
user.failed_login_attempts += 1
|
user.failed_login_attempts += 1
|
||||||
|
|
||||||
# Verrouiller après 5 échecs
|
# 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()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes."
|
detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.",
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Email ou mot de passe incorrect"
|
detail="Email ou mot de passe incorrect",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérifier statut compte
|
# Vérifier statut compte
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Compte désactivé")
|
await log_login_attempt(
|
||||||
raise HTTPException(
|
session, data.email.lower(), ip, user_agent, False, "Compte désactivé"
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Compte désactivé"
|
|
||||||
)
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
|
||||||
|
)
|
||||||
|
|
||||||
if not user.is_verified:
|
if not user.is_verified:
|
||||||
await log_login_attempt(session, data.email.lower(), ip, user_agent, False, "Email non vérifié")
|
await log_login_attempt(
|
||||||
|
session, data.email.lower(), ip, user_agent, False, "Email non vérifié"
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
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
|
# 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(session, data.email.lower(), ip, user_agent, False, "Compte verrouillé")
|
await log_login_attempt(
|
||||||
|
session, data.email.lower(), ip, user_agent, False, "Compte verrouillé"
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Compte temporairement verrouillé"
|
detail="Compte temporairement verrouillé",
|
||||||
)
|
)
|
||||||
|
|
||||||
# ✅ CONNEXION RÉUSSIE
|
# ✅ CONNEXION RÉUSSIE
|
||||||
|
|
||||||
# Réinitialiser compteur échecs
|
# 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
|
# Créer tokens
|
||||||
access_token = create_access_token({"sub": user.id, "email": user.email, "role": user.role})
|
access_token = create_access_token(
|
||||||
|
{"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é)
|
# Stocker refresh token en DB (hashé)
|
||||||
refresh_token_record = RefreshToken(
|
refresh_token_record = RefreshToken(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
|
@ -418,28 +407,27 @@ async def login(
|
||||||
device_info=user_agent[:500],
|
device_info=user_agent[:500],
|
||||||
ip_address=ip,
|
ip_address=ip,
|
||||||
expires_at=datetime.now() + timedelta(days=7),
|
expires_at=datetime.now() + timedelta(days=7),
|
||||||
created_at=datetime.now()
|
created_at=datetime.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
session.add(refresh_token_record)
|
session.add(refresh_token_record)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Logger succès
|
# 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}")
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
refresh_token=refresh_token_jwt,
|
refresh_token=refresh_token_jwt,
|
||||||
expires_in=1800 # 30 minutes
|
expires_in=86400, # 30 minutes
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=TokenResponse)
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
async def refresh_access_token(
|
async def refresh_access_token(
|
||||||
data: RefreshTokenRequest,
|
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
|
||||||
session: AsyncSession = Depends(get_session)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
🔄 Renouvellement du access_token via refresh_token
|
🔄 Renouvellement du access_token via refresh_token
|
||||||
|
|
@ -448,61 +436,55 @@ async def refresh_access_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(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide"
|
||||||
detail="Refresh token invalide"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
# 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,
|
||||||
RefreshToken.token_hash == token_hash,
|
RefreshToken.token_hash == token_hash,
|
||||||
RefreshToken.is_revoked == False
|
RefreshToken.is_revoked == False,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
token_record = result.scalar_one_or_none()
|
token_record = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not token_record:
|
if not token_record:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Refresh token révoqué ou introuvable"
|
detail="Refresh token révoqué ou introuvable",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérifier expiration
|
# 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,
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré"
|
||||||
detail="Refresh token expiré"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Charger utilisateur
|
# Charger utilisateur
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.id == user_id))
|
||||||
select(User).where(User.id == user_id)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user or not user.is_active:
|
if not user or not user.is_active:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Utilisateur introuvable ou désactivé"
|
detail="Utilisateur introuvable ou désactivé",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Générer nouveau access token
|
# Générer nouveau access token
|
||||||
new_access_token = create_access_token({
|
new_access_token = create_access_token(
|
||||||
"sub": user.id,
|
{"sub": user.id, "email": user.email, "role": user.role}
|
||||||
"email": user.email,
|
)
|
||||||
"role": user.role
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(f"🔄 Token rafraîchi: {user.email}")
|
logger.info(f"🔄 Token rafraîchi: {user.email}")
|
||||||
|
|
||||||
return TokenResponse(
|
return TokenResponse(
|
||||||
access_token=new_access_token,
|
access_token=new_access_token,
|
||||||
refresh_token=data.refresh_token, # Refresh token reste le même
|
refresh_token=data.refresh_token, # Refresh token reste le même
|
||||||
expires_in=1800
|
expires_in=86400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -510,79 +492,71 @@ async def refresh_access_token(
|
||||||
async def forgot_password(
|
async def forgot_password(
|
||||||
data: ForgotPasswordRequest,
|
data: ForgotPasswordRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
session: AsyncSession = Depends(get_session)
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
🔑 Demande de réinitialisation de mot de passe
|
🔑 Demande de réinitialisation de mot de passe
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.email == data.email.lower()))
|
||||||
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
|
# 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
|
# 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
|
# Envoyer email
|
||||||
frontend_url = settings.frontend_url if hasattr(settings, 'frontend_url') else str(request.base_url).rstrip('/')
|
frontend_url = (
|
||||||
AuthEmailService.send_password_reset_email(
|
settings.frontend_url
|
||||||
user.email,
|
if hasattr(settings, "frontend_url")
|
||||||
reset_token,
|
else str(request.base_url).rstrip("/")
|
||||||
frontend_url
|
|
||||||
)
|
)
|
||||||
|
AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url)
|
||||||
|
|
||||||
logger.info(f"📧 Reset password demandé: {user.email}")
|
logger.info(f"📧 Reset password demandé: {user.email}")
|
||||||
|
|
||||||
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é.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset-password")
|
@router.post("/reset-password")
|
||||||
async def reset_password(
|
async def reset_password(
|
||||||
data: ResetPasswordRequest,
|
data: ResetPasswordRequest, session: AsyncSession = Depends(get_session)
|
||||||
session: AsyncSession = Depends(get_session)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
🔐 Réinitialisation du mot de passe avec token
|
🔐 Réinitialisation du mot de passe avec token
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(select(User).where(User.reset_token == data.token))
|
||||||
select(User).where(User.reset_token == data.token)
|
|
||||||
)
|
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Token de réinitialisation invalide"
|
detail="Token de réinitialisation invalide",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Vérifier expiration
|
# 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
|
# 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(
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg)
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=error_msg
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mettre à jour
|
# 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
|
||||||
|
|
@ -590,15 +564,15 @@ async def reset_password(
|
||||||
user.failed_login_attempts = 0
|
user.failed_login_attempts = 0
|
||||||
user.locked_until = None
|
user.locked_until = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# Envoyer notification
|
# 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}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter."
|
"message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -606,32 +580,28 @@ async def reset_password(
|
||||||
async def logout(
|
async def logout(
|
||||||
data: RefreshTokenRequest,
|
data: RefreshTokenRequest,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
user: User = Depends(get_current_user)
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
🚪 Déconnexion (révocation du refresh token)
|
🚪 Déconnexion (révocation du refresh token)
|
||||||
"""
|
"""
|
||||||
token_hash = hash_token(data.refresh_token)
|
token_hash = hash_token(data.refresh_token)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(RefreshToken).where(
|
select(RefreshToken).where(
|
||||||
RefreshToken.user_id == user.id,
|
RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash
|
||||||
RefreshToken.token_hash == token_hash
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
token_record = result.scalar_one_or_none()
|
token_record = result.scalar_one_or_none()
|
||||||
|
|
||||||
if token_record:
|
if token_record:
|
||||||
token_record.is_revoked = True
|
token_record.is_revoked = True
|
||||||
token_record.revoked_at = datetime.now()
|
token_record.revoked_at = datetime.now()
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
logger.info(f"👋 Déconnexion: {user.email}")
|
logger.info(f"👋 Déconnexion: {user.email}")
|
||||||
|
|
||||||
return {
|
return {"success": True, "message": "Déconnexion réussie"}
|
||||||
"success": True,
|
|
||||||
"message": "Déconnexion réussie"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me")
|
@router.get("/me")
|
||||||
|
|
@ -647,5 +617,5 @@ async def get_current_user_info(user: User = Depends(get_current_user)):
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
"is_verified": user.is_verified,
|
"is_verified": user.is_verified,
|
||||||
"created_at": user.created_at.isoformat(),
|
"created_at": user.created_at.isoformat(),
|
||||||
"last_login": user.last_login.isoformat() if user.last_login else None
|
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
294
sage_client.py
294
sage_client.py
|
|
@ -1,5 +1,5 @@
|
||||||
import requests
|
import requests
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional, Union
|
||||||
from config import settings
|
from config import settings
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
@ -63,9 +63,6 @@ class SageGatewayClient:
|
||||||
raise
|
raise
|
||||||
time.sleep(2**attempt)
|
time.sleep(2**attempt)
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# CLIENTS
|
|
||||||
# =====================================================
|
|
||||||
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
||||||
"""Liste tous les clients avec filtre optionnel"""
|
"""Liste tous les clients avec filtre optionnel"""
|
||||||
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
@ -74,9 +71,6 @@ class SageGatewayClient:
|
||||||
"""Lecture d'un client par code"""
|
"""Lecture d'un client par code"""
|
||||||
return self._post("/sage/clients/get", {"code": code}).get("data")
|
return self._post("/sage/clients/get", {"code": code}).get("data")
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# ARTICLES
|
|
||||||
# =====================================================
|
|
||||||
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
||||||
"""Liste tous les articles avec filtre optionnel"""
|
"""Liste tous les articles avec filtre optionnel"""
|
||||||
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
@ -85,9 +79,6 @@ class SageGatewayClient:
|
||||||
"""Lecture d'un article par référence"""
|
"""Lecture d'un article par référence"""
|
||||||
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# DEVIS (US-A1)
|
|
||||||
# =====================================================
|
|
||||||
def creer_devis(self, devis_data: Dict) -> Dict:
|
def creer_devis(self, devis_data: Dict) -> Dict:
|
||||||
"""Création d'un devis"""
|
"""Création d'un devis"""
|
||||||
return self._post("/sage/devis/create", devis_data).get("data", {})
|
return self._post("/sage/devis/create", devis_data).get("data", {})
|
||||||
|
|
@ -102,18 +93,12 @@ class SageGatewayClient:
|
||||||
statut: Optional[int] = None,
|
statut: Optional[int] = None,
|
||||||
inclure_lignes: bool = True,
|
inclure_lignes: bool = True,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
|
||||||
✅ Liste tous les devis avec filtres
|
|
||||||
"""
|
|
||||||
payload = {"limit": limit, "inclure_lignes": inclure_lignes}
|
payload = {"limit": limit, "inclure_lignes": inclure_lignes}
|
||||||
if statut is not None:
|
if statut is not None:
|
||||||
payload["statut"] = statut
|
payload["statut"] = statut
|
||||||
return self._post("/sage/devis/list", payload).get("data", [])
|
return self._post("/sage/devis/list", payload).get("data", [])
|
||||||
|
|
||||||
def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict:
|
def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict:
|
||||||
"""
|
|
||||||
✅ CORRECTION: Utilise query params au lieu du body
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{self.url}/sage/devis/statut",
|
f"{self.url}/sage/devis/statut",
|
||||||
|
|
@ -130,9 +115,6 @@ class SageGatewayClient:
|
||||||
logger.error(f"❌ Erreur changement statut: {e}")
|
logger.error(f"❌ Erreur changement statut: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# DOCUMENTS GÉNÉRIQUES
|
|
||||||
# =====================================================
|
|
||||||
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||||
"""Lecture d'un document générique"""
|
"""Lecture d'un document générique"""
|
||||||
return self._post(
|
return self._post(
|
||||||
|
|
@ -142,9 +124,6 @@ class SageGatewayClient:
|
||||||
def transformer_document(
|
def transformer_document(
|
||||||
self, numero_source: str, type_source: int, type_cible: int
|
self, numero_source: str, type_source: int, type_cible: int
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
|
||||||
✅ CORRECTION: Utilise query params pour la transformation
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
r = requests.post(
|
r = requests.post(
|
||||||
f"{self.url}/sage/documents/transform",
|
f"{self.url}/sage/documents/transform",
|
||||||
|
|
@ -177,30 +156,17 @@ class SageGatewayClient:
|
||||||
)
|
)
|
||||||
return resp.get("success", False)
|
return resp.get("success", False)
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# COMMANDES (US-A2)
|
|
||||||
# =====================================================
|
|
||||||
def lister_commandes(
|
def lister_commandes(
|
||||||
self, limit: int = 100, statut: Optional[int] = None
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
|
||||||
Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10
|
|
||||||
"""
|
|
||||||
payload = {"limit": limit}
|
payload = {"limit": limit}
|
||||||
if statut is not None:
|
if statut is not None:
|
||||||
payload["statut"] = statut
|
payload["statut"] = statut
|
||||||
return self._post("/sage/commandes/list", payload).get("data", [])
|
return self._post("/sage/commandes/list", payload).get("data", [])
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# FACTURES (US-A7)
|
|
||||||
# =====================================================
|
|
||||||
def lister_factures(
|
def lister_factures(
|
||||||
self, limit: int = 100, statut: Optional[int] = None
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""
|
|
||||||
✅ Liste toutes les factures
|
|
||||||
Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60
|
|
||||||
"""
|
|
||||||
payload = {"limit": limit}
|
payload = {"limit": limit}
|
||||||
if statut is not None:
|
if statut is not None:
|
||||||
payload["statut"] = statut
|
payload["statut"] = statut
|
||||||
|
|
@ -213,24 +179,15 @@ class SageGatewayClient:
|
||||||
)
|
)
|
||||||
return resp.get("success", False)
|
return resp.get("success", False)
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# CONTACTS (US-A6)
|
|
||||||
# =====================================================
|
|
||||||
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
||||||
"""Lecture du contact principal d'un client"""
|
"""Lecture du contact principal d'un client"""
|
||||||
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# REMISES (US-A5)
|
|
||||||
# =====================================================
|
|
||||||
def lire_remise_max_client(self, code_client: str) -> float:
|
def lire_remise_max_client(self, code_client: str) -> float:
|
||||||
"""Récupère la remise max autorisée pour un client"""
|
"""Récupère la remise max autorisée pour un client"""
|
||||||
result = self._post("/sage/client/remise-max", {"code": code_client})
|
result = self._post("/sage/client/remise-max", {"code": code_client})
|
||||||
return result.get("data", {}).get("remise_max", 10.0)
|
return result.get("data", {}).get("remise_max", 10.0)
|
||||||
|
|
||||||
# =====================================================
|
|
||||||
# GÉNÉRATION PDF (pour email_queue)
|
|
||||||
# =====================================================
|
|
||||||
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
|
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
|
||||||
"""Génère le PDF d'un document via la gateway Windows"""
|
"""Génère le PDF d'un document via la gateway Windows"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -256,6 +213,57 @@ class SageGatewayClient:
|
||||||
logger.error(f"Erreur génération PDF: {e}")
|
logger.error(f"Erreur génération PDF: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def lister_prospects(self, filtre: str = "") -> List[Dict]:
|
||||||
|
"""Liste tous les prospects avec filtre optionnel"""
|
||||||
|
return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_prospect(self, code: str) -> Optional[Dict]:
|
||||||
|
"""Lecture d'un prospect par code"""
|
||||||
|
return self._post("/sage/prospects/get", {"code": code}).get("data")
|
||||||
|
|
||||||
|
def lister_fournisseurs(self, filtre: str = "") -> List[Dict]:
|
||||||
|
"""Liste tous les fournisseurs avec filtre optionnel"""
|
||||||
|
return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_fournisseur(self, code: str) -> Optional[Dict]:
|
||||||
|
"""Lecture d'un fournisseur par code"""
|
||||||
|
return self._post("/sage/fournisseurs/get", {"code": code}).get("data")
|
||||||
|
|
||||||
|
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/fournisseurs/update",
|
||||||
|
{"code": code, "fournisseur_data": fournisseur_data},
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_avoirs(
|
||||||
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Liste tous les avoirs"""
|
||||||
|
payload = {"limit": limit}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/avoirs/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def lire_avoir(self, numero: str) -> Optional[Dict]:
|
||||||
|
"""Lecture d'un avoir avec ses lignes"""
|
||||||
|
return self._post("/sage/avoirs/get", {"code": numero}).get("data")
|
||||||
|
|
||||||
|
def lister_livraisons(
|
||||||
|
self, limit: int = 100, statut: Optional[int] = None
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""Liste tous les bons de livraison"""
|
||||||
|
payload = {"limit": limit}
|
||||||
|
if statut is not None:
|
||||||
|
payload["statut"] = statut
|
||||||
|
return self._post("/sage/livraisons/list", payload).get("data", [])
|
||||||
|
|
||||||
|
def lire_livraison(self, numero: str) -> Optional[Dict]:
|
||||||
|
"""Lecture d'une livraison avec ses lignes"""
|
||||||
|
return self._post("/sage/livraisons/get", {"code": numero}).get("data")
|
||||||
|
|
||||||
# =====================================================
|
# =====================================================
|
||||||
# CACHE (ADMIN)
|
# CACHE (ADMIN)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|
@ -278,6 +286,202 @@ class SageGatewayClient:
|
||||||
except:
|
except:
|
||||||
return {"status": "down"}
|
return {"status": "down"}
|
||||||
|
|
||||||
|
def creer_client(self, client_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/clients/create", client_data).get("data", {})
|
||||||
|
|
||||||
# Instance globale
|
def modifier_client(self, code: str, client_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/clients/update", {"code": code, "client_data": client_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def modifier_devis(self, numero: str, devis_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/devis/update", {"numero": numero, "devis_data": devis_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def creer_commande(self, commande_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/commandes/create", commande_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_commande(self, numero: str, commande_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/commandes/update", {"numero": numero, "commande_data": commande_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def creer_livraison(self, livraison_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/livraisons/create", livraison_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/livraisons/update",
|
||||||
|
{"numero": numero, "livraison_data": livraison_data},
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def creer_avoir(self, avoir_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/avoirs/create", avoir_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def creer_facture(self, facture_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/factures/create", facture_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_facture(self, numero: str, facture_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/factures/update", {"numero": numero, "facture_data": facture_data}
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
|
||||||
|
try:
|
||||||
|
logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}")
|
||||||
|
|
||||||
|
# Appel HTTP vers la gateway Windows
|
||||||
|
r = requests.post(
|
||||||
|
f"{self.url}/sage/documents/generate-pdf",
|
||||||
|
json={"doc_id": doc_id, "type_doc": type_doc},
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=60, # Timeout élevé pour génération PDF
|
||||||
|
)
|
||||||
|
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
response_data = r.json()
|
||||||
|
|
||||||
|
# Vérifier que la réponse contient bien le PDF
|
||||||
|
if not response_data.get("success"):
|
||||||
|
error_msg = response_data.get("error", "Erreur inconnue")
|
||||||
|
raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}")
|
||||||
|
|
||||||
|
pdf_base64 = response_data.get("data", {}).get("pdf_base64", "")
|
||||||
|
|
||||||
|
if not pdf_base64:
|
||||||
|
raise ValueError(
|
||||||
|
f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Décoder le base64
|
||||||
|
pdf_bytes = base64.b64decode(pdf_base64)
|
||||||
|
|
||||||
|
logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets")
|
||||||
|
|
||||||
|
return pdf_bytes
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.error(f"⏱️ Timeout génération PDF pour {doc_id}")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Timeout lors de la génération du PDF (>60s). "
|
||||||
|
f"Le document {doc_id} est peut-être trop volumineux."
|
||||||
|
)
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"❌ Erreur HTTP génération PDF: {e}")
|
||||||
|
raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def creer_article(self, article_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/articles/create", article_data).get("data", {})
|
||||||
|
|
||||||
|
def modifier_article(self, reference: str, article_data: Dict) -> Dict:
|
||||||
|
return self._post(
|
||||||
|
"/sage/articles/update",
|
||||||
|
{"reference": reference, "article_data": article_data},
|
||||||
|
).get("data", {})
|
||||||
|
|
||||||
|
def lister_familles(self, filtre: str = "") -> List[Dict]:
|
||||||
|
return self._get("/sage/familles", params={"filtre": filtre}).get("data", [])
|
||||||
|
|
||||||
|
def lire_famille(self, code: str) -> Optional[Dict]:
|
||||||
|
try:
|
||||||
|
response = self._get(f"/sage/familles/{code}")
|
||||||
|
return response.get("data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture famille {code}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def creer_famille(self, famille_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/familles/create", famille_data).get("data", {})
|
||||||
|
|
||||||
|
def get_stats_familles(self) -> Dict:
|
||||||
|
return self._get("/sage/familles/stats").get("data", {})
|
||||||
|
|
||||||
|
|
||||||
|
def creer_entree_stock(self, entree_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/stock/entree", entree_data).get("data", {})
|
||||||
|
|
||||||
|
|
||||||
|
def creer_sortie_stock(self, sortie_data: Dict) -> Dict:
|
||||||
|
return self._post("/sage/stock/sortie", sortie_data).get("data", {})
|
||||||
|
|
||||||
|
|
||||||
|
def lire_mouvement_stock(self, numero: str) -> Optional[Dict]:
|
||||||
|
try:
|
||||||
|
response = self._get(f"/sage/stock/mouvement/{numero}")
|
||||||
|
return response.get("data")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lecture mouvement {numero}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lister_modeles_disponibles(self) -> Dict:
|
||||||
|
"""Liste les modèles Crystal Reports disponibles"""
|
||||||
|
try:
|
||||||
|
r = requests.get(
|
||||||
|
f"{self.url}/sage/modeles/list",
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json().get("data", {})
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"❌ Erreur listage modèles: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def generer_pdf_document(
|
||||||
|
self,
|
||||||
|
numero: str,
|
||||||
|
type_doc: int,
|
||||||
|
modele: str = None,
|
||||||
|
base64_encode: bool = True
|
||||||
|
) -> Union[bytes, str, Dict]:
|
||||||
|
"""
|
||||||
|
Génère un PDF d'un document Sage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict: Avec pdf_base64 si base64_encode=True
|
||||||
|
bytes: Contenu PDF brut si base64_encode=False
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"type_doc": type_doc,
|
||||||
|
"base64_encode": base64_encode
|
||||||
|
}
|
||||||
|
|
||||||
|
if modele:
|
||||||
|
params["modele"] = modele
|
||||||
|
|
||||||
|
r = requests.get(
|
||||||
|
f"{self.url}/sage/documents/{numero}/pdf",
|
||||||
|
params=params,
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=60 # PDF peut prendre du temps
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
|
||||||
|
if base64_encode:
|
||||||
|
return r.json().get("data", {})
|
||||||
|
else:
|
||||||
|
return r.content
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"❌ Erreur génération PDF: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
sage_client = SageGatewayClient()
|
sage_client = SageGatewayClient()
|
||||||
|
|
|
||||||
|
|
@ -45,24 +45,20 @@ def hash_token(token: str) -> str:
|
||||||
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
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Payload (doit contenir 'sub' = user_id)
|
data: Payload (doit contenir 'sub' = user_id)
|
||||||
expires_delta: Durée de validité personnalisée
|
expires_delta: Durée de validité personnalisée
|
||||||
"""
|
"""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
|
|
||||||
if expires_delta:
|
if expires_delta:
|
||||||
expire = datetime.utcnow() + expires_delta
|
expire = datetime.utcnow() + expires_delta
|
||||||
else:
|
else:
|
||||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
to_encode.update({
|
to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"})
|
||||||
"exp": expire,
|
|
||||||
"iat": datetime.utcnow(),
|
|
||||||
"type": "access"
|
|
||||||
})
|
|
||||||
|
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
@ -70,20 +66,20 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
|
||||||
def create_refresh_token(user_id: str) -> str:
|
def create_refresh_token(user_id: str) -> str:
|
||||||
"""
|
"""
|
||||||
Crée un refresh token (JWT long terme)
|
Crée un refresh token (JWT long terme)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Token JWT non hashé (à hasher avant stockage DB)
|
Token JWT non hashé (à hasher avant stockage DB)
|
||||||
"""
|
"""
|
||||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||||
|
|
||||||
to_encode = {
|
to_encode = {
|
||||||
"sub": user_id,
|
"sub": user_id,
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"iat": datetime.utcnow(),
|
"iat": datetime.utcnow(),
|
||||||
"type": "refresh",
|
"type": "refresh",
|
||||||
"jti": secrets.token_urlsafe(16) # Unique ID
|
"jti": secrets.token_urlsafe(16), # Unique ID
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
return encoded_jwt
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
@ -91,7 +87,7 @@ def create_refresh_token(user_id: str) -> str:
|
||||||
def decode_token(token: str) -> Optional[Dict]:
|
def decode_token(token: str) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Décode et valide un JWT
|
Décode et valide un JWT
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Payload si valide, None sinon
|
Payload si valide, None sinon
|
||||||
"""
|
"""
|
||||||
|
|
@ -108,24 +104,24 @@ def decode_token(token: str) -> Optional[Dict]:
|
||||||
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
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(is_valid, error_message)
|
(is_valid, error_message)
|
||||||
"""
|
"""
|
||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
return False, "Le mot de passe doit contenir au moins 8 caractères"
|
return False, "Le mot de passe doit contenir au moins 8 caractères"
|
||||||
|
|
||||||
if not any(c.isupper() for c in password):
|
if not any(c.isupper() for c in password):
|
||||||
return False, "Le mot de passe doit contenir au moins une majuscule"
|
return False, "Le mot de passe doit contenir au moins une majuscule"
|
||||||
|
|
||||||
if not any(c.islower() for c in password):
|
if not any(c.islower() for c in password):
|
||||||
return False, "Le mot de passe doit contenir au moins une minuscule"
|
return False, "Le mot de passe doit contenir au moins une minuscule"
|
||||||
|
|
||||||
if not any(c.isdigit() for c in password):
|
if not any(c.isdigit() for c in password):
|
||||||
return False, "Le mot de passe doit contenir au moins un chiffre"
|
return False, "Le mot de passe doit contenir au moins un chiffre"
|
||||||
|
|
||||||
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||||
if not any(c in special_chars for c in password):
|
if not any(c in special_chars for c in password):
|
||||||
return False, "Le mot de passe doit contenir au moins un caractère spécial"
|
return False, "Le mot de passe doit contenir au moins un caractère spécial"
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
|
||||||
|
|
@ -9,46 +9,48 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class AuthEmailService:
|
class AuthEmailService:
|
||||||
"""Service d'envoi d'emails pour l'authentification"""
|
"""Service d'envoi d'emails pour l'authentification"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _send_email(to: str, subject: str, html_body: str) -> bool:
|
def _send_email(to: str, subject: str, html_body: str) -> bool:
|
||||||
"""Envoi SMTP générique"""
|
"""Envoi SMTP générique"""
|
||||||
try:
|
try:
|
||||||
msg = MIMEMultipart()
|
msg = MIMEMultipart()
|
||||||
msg['From'] = settings.smtp_from
|
msg["From"] = settings.smtp_from
|
||||||
msg['To'] = to
|
msg["To"] = to
|
||||||
msg['Subject'] = subject
|
msg["Subject"] = subject
|
||||||
|
|
||||||
msg.attach(MIMEText(html_body, 'html'))
|
msg.attach(MIMEText(html_body, "html"))
|
||||||
|
|
||||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server:
|
with smtplib.SMTP(
|
||||||
|
settings.smtp_host, settings.smtp_port, timeout=30
|
||||||
|
) as server:
|
||||||
if settings.smtp_use_tls:
|
if settings.smtp_use_tls:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
|
|
||||||
if settings.smtp_user and settings.smtp_password:
|
if settings.smtp_user and settings.smtp_password:
|
||||||
server.login(settings.smtp_user, settings.smtp_password)
|
server.login(settings.smtp_user, settings.smtp_password)
|
||||||
|
|
||||||
server.send_message(msg)
|
server.send_message(msg)
|
||||||
|
|
||||||
logger.info(f"✅ Email envoyé: {subject} → {to}")
|
logger.info(f"✅ Email envoyé: {subject} → {to}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Erreur envoi email: {e}")
|
logger.error(f"❌ Erreur envoi email: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_verification_email(email: str, token: str, base_url: str) -> bool:
|
def send_verification_email(email: str, token: str, base_url: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Envoie l'email de vérification avec lien de confirmation
|
Envoie l'email de vérification avec lien de confirmation
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Email du destinataire
|
email: Email du destinataire
|
||||||
token: Token de vérification
|
token: Token de vérification
|
||||||
base_url: URL de base de l'API (ex: https://api.votredomaine.com)
|
base_url: URL de base de l'API (ex: https://api.votredomaine.com)
|
||||||
"""
|
"""
|
||||||
verification_link = f"{base_url}/auth/verify-email?token={token}"
|
verification_link = f"{base_url}/auth/verify-email?token={token}"
|
||||||
|
|
||||||
html_body = f"""
|
html_body = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -103,25 +105,23 @@ class AuthEmailService:
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return AuthEmailService._send_email(
|
return AuthEmailService._send_email(
|
||||||
email,
|
email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body
|
||||||
"🔐 Vérifiez votre adresse email - Sage Dataven",
|
|
||||||
html_body
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_password_reset_email(email: str, token: str, base_url: str) -> bool:
|
def send_password_reset_email(email: str, token: str, base_url: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Envoie l'email de réinitialisation de mot de passe
|
Envoie l'email de réinitialisation de mot de passe
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Email du destinataire
|
email: Email du destinataire
|
||||||
token: Token de reset
|
token: Token de reset
|
||||||
base_url: URL de base du frontend
|
base_url: URL de base du frontend
|
||||||
"""
|
"""
|
||||||
reset_link = f"{base_url}/reset?token={token}"
|
reset_link = f"{base_url}/reset?token={token}"
|
||||||
|
|
||||||
html_body = f"""
|
html_body = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -176,13 +176,11 @@ class AuthEmailService:
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return AuthEmailService._send_email(
|
return AuthEmailService._send_email(
|
||||||
email,
|
email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body
|
||||||
"🔐 Réinitialisation de votre mot de passe - Sage Dataven",
|
|
||||||
html_body
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def send_password_changed_notification(email: str) -> bool:
|
def send_password_changed_notification(email: str) -> bool:
|
||||||
"""Notification après changement de mot de passe réussi"""
|
"""Notification après changement de mot de passe réussi"""
|
||||||
|
|
@ -218,9 +216,7 @@ class AuthEmailService:
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return AuthEmailService._send_email(
|
return AuthEmailService._send_email(
|
||||||
email,
|
email, "✅ Votre mot de passe a été modifié - Sage Dataven", html_body
|
||||||
"✅ Votre mot de passe a été modifié - Sage Dataven",
|
)
|
||||||
html_body
|
|
||||||
)
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue