Merge pull request 'feat/new_features' (#2) from feat/new_features into develop

Reviewed-on: fanilo/backend_vps#2
This commit is contained in:
fanilo 2025-12-20 06:54:40 +00:00
commit 33fe6cd0fa
17 changed files with 3992 additions and 774 deletions

View file

@ -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

2
.gitignore vendored
View file

@ -36,5 +36,3 @@ htmlcov/
*~ *~
.build/ .build/
dist/ dist/
*.db

3438
api.py

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -5,13 +5,14 @@ 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
@ -50,9 +51,7 @@ async def get_current_user(
) )
# 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:
@ -65,21 +64,20 @@ async def get_current_user(
# 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
@ -87,7 +85,7 @@ async def get_current_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
@ -111,11 +109,12 @@ def require_role(*allowed_roles: str):
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

View file

@ -26,13 +26,13 @@ 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
@ -45,7 +45,9 @@ async def create_admin():
# 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:
@ -61,9 +63,7 @@ async def create_admin():
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:
@ -80,7 +80,7 @@ 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)

BIN
data/sage_dataven.db Normal file

Binary file not shown.

View file

@ -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",
] ]

View file

@ -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,

View file

@ -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,23 +29,28 @@ 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
@ -79,6 +95,7 @@ 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
@ -97,7 +114,9 @@ class SignatureLog(Base):
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)
@ -105,6 +124,7 @@ class SignatureLog(Base):
# 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)
@ -119,6 +139,7 @@ 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
@ -154,12 +175,15 @@ 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)
@ -179,12 +203,15 @@ 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)
@ -203,12 +230,15 @@ class AuditLog(Base):
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)
@ -247,6 +277,7 @@ 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)
@ -273,6 +304,7 @@ 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)

View file

@ -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"

View file

@ -31,7 +31,7 @@ class EmailQueue:
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"""
@ -42,9 +42,7 @@ class EmailQueue:
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)
@ -139,7 +137,9 @@ class EmailQueue:
# 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
@ -147,30 +147,31 @@ class EmailQueue:
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:
@ -181,15 +182,15 @@ class EmailQueue:
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")
@ -232,7 +233,7 @@ class EmailQueue:
# === 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 = {
@ -241,81 +242,83 @@ 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()
@ -327,7 +330,9 @@ class EmailQueue:
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()

View file

@ -24,9 +24,9 @@ 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
@ -49,7 +49,7 @@ async def main():
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:

View file

@ -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,13 +88,15 @@ 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é
@ -103,11 +107,10 @@ async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[
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()
@ -120,11 +123,12 @@ async def check_rate_limit(session: AsyncSession, email: str, ip: str) -> tuple[
# === 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
@ -134,24 +138,18 @@ async def register(
- 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()
@ -166,18 +164,16 @@ 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:
@ -189,28 +185,23 @@ async def register(
"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
@ -218,7 +209,7 @@ async def verify_email_get(
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
@ -232,14 +223,13 @@ async def verify_email_get(
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)
@ -253,14 +243,14 @@ async def verify_email_post(
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
@ -273,7 +263,7 @@ async def verify_email_post(
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,27 +271,24 @@ 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
@ -311,24 +298,15 @@ async def resend_verification(
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,
base_url
)
return { return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."}
"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
@ -342,19 +320,23 @@ async def login(
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:
@ -366,37 +348,42 @@ async def login(
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(
session, data.email.lower(), ip, user_agent, False, "Compte désactivé"
)
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:
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
@ -407,7 +394,9 @@ async def login(
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é)
@ -418,7 +407,7 @@ 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)
@ -432,14 +421,13 @@ async def login(
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,8 +436,7 @@ 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")
@ -460,7 +447,7 @@ async def refresh_access_token(
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()
@ -468,41 +455,36 @@ async def refresh_access_token(
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,21 +492,19 @@ 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
@ -534,54 +514,48 @@ async def forgot_password(
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)
@ -598,7 +572,7 @@ async def reset_password(
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,7 +580,7 @@ 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)
@ -615,8 +589,7 @@ async def logout(
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()
@ -628,10 +601,7 @@ async def logout(
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,
} }

View file

@ -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", {})
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
# Instance globale
sage_client = SageGatewayClient() sage_client = SageGatewayClient()

View file

@ -57,11 +57,7 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
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
@ -81,7 +77,7 @@ def create_refresh_token(user_id: str) -> str:
"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)

View file

@ -15,13 +15,15 @@ class AuthEmailService:
"""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()
@ -105,9 +107,7 @@ class AuthEmailService:
""" """
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
@ -178,9 +178,7 @@ class AuthEmailService:
""" """
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
@ -220,7 +218,5 @@ class AuthEmailService:
""" """
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
) )