refactor: reorganize database models and clean up schemas

This commit is contained in:
Fanilo-Nantenaina 2025-12-30 18:35:47 +03:00
parent 4867b114fe
commit 792d771667
38 changed files with 737 additions and 905 deletions

152
api.py
View file

@ -32,12 +32,10 @@ from sage_client import sage_client
from schemas import (
TiersDetails,
TypeTiers,
BaremeRemiseResponse,
UserResponse,
ClientCreateRequest,
ClientDetails,
ClientResponse,
ClientUpdateRequest,
FournisseurCreateAPIRequest,
FournisseurDetails,
@ -60,18 +58,15 @@ from schemas import (
LivraisonUpdateRequest,
SignatureRequest,
StatutSignature,
TypeTiersInt,
ArticleCreateRequest,
ArticleResponse,
ArticleUpdateRequest,
ArticleListResponse,
EntreeStockRequest,
SortieStockRequest,
MouvementStockResponse,
RelanceDevisRequest,
FamilleResponse,
FamilleCreateRequest,
FamilleListResponse,
ContactCreate,
ContactUpdate,
)
@ -125,7 +120,6 @@ app.add_middleware(
app.include_router(auth_router)
async def universign_envoyer(
doc_id: str,
pdf_bytes: bytes,
@ -146,57 +140,67 @@ async def universign_envoyer(
if not pdf_bytes or len(pdf_bytes) == 0:
raise Exception("Le PDF généré est vide")
# ÉTAPE 1: Création transaction
response = requests.post(
f"{api_url}/transactions",
auth=auth,
json={"name": f"{doc_data.get('type_label', 'Document')} {doc_id}", "language": "fr"},
json={
"name": f"{doc_data.get('type_label', 'Document')} {doc_id}",
"language": "fr",
},
timeout=30,
)
if response.status_code != 200:
raise Exception(f"Erreur création transaction: {response.status_code}")
transaction_id = response.json().get("id")
# ÉTAPE 2: Upload PDF
files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")}
files = {
"file": (
f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf",
pdf_bytes,
"application/pdf",
)
}
response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=60)
if response.status_code not in [200, 201]:
raise Exception(f"Erreur upload fichier: {response.status_code}")
file_id = response.json().get("id")
# ÉTAPE 3: Ajout document
response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents",
auth=auth, data={"document": file_id}, timeout=30
auth=auth,
data={"document": file_id},
timeout=30,
)
if response.status_code not in [200, 201]:
raise Exception(f"Erreur ajout document: {response.status_code}")
document_id = response.json().get("id")
# ÉTAPE 4: Création champ signature
response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields",
auth=auth, data={"type": "signature"}, timeout=30
auth=auth,
data={"type": "signature"},
timeout=30,
)
if response.status_code not in [200, 201]:
raise Exception(f"Erreur création champ: {response.status_code}")
field_id = response.json().get("id")
# ÉTAPE 5: Liaison signataire
response = requests.post(
f"{api_url}/transactions/{transaction_id}/signatures",
auth=auth, data={"signer": email, "field": field_id}, timeout=30
auth=auth,
data={"signer": email, "field": field_id},
timeout=30,
)
if response.status_code not in [200, 201]:
raise Exception(f"Erreur liaison signataire: {response.status_code}")
# ÉTAPE 6: Démarrage
response = requests.post(f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30)
response = requests.post(
f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30
)
if response.status_code not in [200, 201]:
raise Exception(f"Erreur démarrage: {response.status_code}")
final_data = response.json()
# Récupération URL
signer_url = ""
if final_data.get("actions"):
for action in final_data["actions"]:
@ -211,9 +215,14 @@ async def universign_envoyer(
if not signer_url:
raise ValueError("URL de signature non retournée par Universign")
# Préparation email
template = templates_signature_email["demande_signature"]
type_labels = {0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir"}
type_labels = {
0: "Devis",
10: "Commande",
30: "Bon de Livraison",
60: "Facture",
50: "Avoir",
}
variables = {
"NOM_SIGNATAIRE": nom,
"TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"),
@ -259,6 +268,7 @@ async def universign_envoyer(
async def universign_statut(transaction_id: str) -> dict:
import requests
try:
response = requests.get(
f"{settings.universign_api_url}/transactions/{transaction_id}",
@ -267,8 +277,18 @@ async def universign_statut(transaction_id: str) -> dict:
)
if response.status_code == 200:
data = response.json()
statut_map = {"draft": "EN_ATTENTE", "started": "EN_ATTENTE", "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE"}
return {"statut": statut_map.get(data.get("state"), "EN_ATTENTE"), "date_signature": data.get("completed_at")}
statut_map = {
"draft": "EN_ATTENTE",
"started": "EN_ATTENTE",
"completed": "SIGNE",
"refused": "REFUSE",
"expired": "EXPIRE",
"canceled": "REFUSE",
}
return {
"statut": statut_map.get(data.get("state"), "EN_ATTENTE"),
"date_signature": data.get("completed_at"),
}
return {"statut": "ERREUR"}
except Exception as e:
logger.error(f"Erreur statut Universign: {e}")
@ -817,6 +837,7 @@ async def envoyer_devis_email(
logger.error(f"Erreur envoi email: {e}")
raise HTTPException(500, str(e))
@app.put("/document/{type_doc}/{numero}/statut", tags=["Documents"])
async def changer_statut_document(
type_doc: int = Path(
@ -887,12 +908,18 @@ async def changer_statut_document(
case 10 | 1 | 20 | 2 | 30 | 3 | 40 | 4 | 50 | 5 | 60 | 6:
if statut_actuel >= 2:
type_names = {
10: "la commande", 1: "la commande",
20: "la préparation", 2: "la préparation",
30: "la livraison", 3: "la livraison",
40: "le retour", 4: "le retour",
50: "l'avoir", 5: "l'avoir",
60: "la facture", 6: "la facture"
10: "la commande",
1: "la commande",
20: "la préparation",
2: "la préparation",
30: "la livraison",
3: "la livraison",
40: "le retour",
4: "le retour",
50: "l'avoir",
5: "l'avoir",
60: "la facture",
6: "la facture",
}
raise HTTPException(
400,
@ -900,15 +927,21 @@ async def changer_statut_document(
f"ne peut plus changer de statut (statut actuel ≥ 2)",
)
document_type_int = document_type_code.value if hasattr(document_type_code, 'value') else type_doc_normalized
document_type_int = (
document_type_code.value
if hasattr(document_type_code, "value")
else type_doc_normalized
)
resultat = sage_client.changer_statut_document(
document_type_code=document_type_int,
numero=numero,
nouveau_statut=nouveau_statut
nouveau_statut=nouveau_statut,
)
logger.info(f"Statut document {numero} changé: {statut_actuel}{nouveau_statut}")
logger.info(
f"Statut document {numero} changé: {statut_actuel}{nouveau_statut}"
)
return {
"success": True,
@ -926,6 +959,7 @@ async def changer_statut_document(
logger.error(f"Erreur changement statut document {numero}: {e}")
raise HTTPException(500, str(e))
@app.get("/commandes/{id}", tags=["Commandes"])
async def lire_commande(id: str):
try:
@ -2950,9 +2984,7 @@ async def lister_utilisateurs_debug(
)
)
logger.info(
f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)"
)
logger.info(f"Liste utilisateurs retournée: {len(users_response)} résultat(s)")
return users_response
@ -2999,56 +3031,14 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)
raise HTTPException(500, str(e))
@app.get("/modeles", tags=["PDF Sage-Like"])
async def get_modeles_disponibles():
"""Liste tous les modèles PDF disponibles"""
try:
modeles = sage_client.lister_modeles_disponibles()
return modeles
except Exception as e:
logger.error(f"Erreur listage modèles: {e}")
raise HTTPException(500, str(e))
@app.get("/documents/{numero}/pdf", tags=["PDF Sage-Like"])
async def get_document_pdf(
numero: str,
type_doc: int = Query(..., description="0=devis, 60=facture, etc."),
modele: str = Query(
None, description="Nom du modèle (ex: 'Facture client logo.bgc')"
),
download: bool = Query(False, description="Télécharger au lieu d'afficher"),
):
try:
pdf_bytes = sage_client.generer_pdf_document(
numero=numero,
type_doc=type_doc,
modele=modele,
base64_encode=False, # On veut les bytes bruts
)
from fastapi.responses import Response
disposition = "attachment" if download else "inline"
filename = f"{numero}.pdf"
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'{disposition}; filename="{filename}"'},
)
except Exception as e:
logger.error(f"Erreur génération PDF: {e}")
raise HTTPException(500, str(e))
@app.post("/tiers/{numero}/contacts", response_model=Contact, tags=["Contacts"])
async def creer_contact(numero: str, contact: ContactCreate):
try:
try:
sage_client.lire_tiers(numero)
except:
except HTTPException:
raise
except Exception:
raise HTTPException(404, f"Tiers {numero} non trouvé")
if contact.numero != numero:
@ -3134,7 +3124,7 @@ async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpd
@app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"])
async def supprimer_contact(numero: str, contact_numero: int):
try:
resultat = sage_client.supprimer_contact(numero, contact_numero)
sage_client.supprimer_contact(numero, contact_numero)
return {"success": True, "message": f"Contact {contact_numero} supprimé"}
except Exception as e:
logger.error(f"Erreur suppression contact: {e}")

View file

@ -5,7 +5,7 @@ from sqlalchemy import select
from database import get_session, User
from security.auth import decode_token
from typing import Optional
from datetime import datetime # AJOUT MANQUANT - C'ÉTAIT LE BUG !
from datetime import datetime
security = HTTPBearer()
@ -16,7 +16,6 @@ async def get_current_user(
) -> User:
token = credentials.credentials
# Décoder le token
payload = decode_token(token)
if not payload:
raise HTTPException(
@ -25,7 +24,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Vérifier le type
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -33,7 +31,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Extraire user_id
user_id: str = payload.get("sub")
if not user_id:
raise HTTPException(
@ -42,7 +39,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Charger l'utilisateur
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@ -53,7 +49,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
# Vérifications de sécurité
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
@ -65,7 +60,6 @@ async def get_current_user(
detail="Email non vérifié. Consultez votre boîte de réception.",
)
# FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!)
if user.locked_until and user.locked_until > datetime.now():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@ -79,10 +73,6 @@ async def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
session: AsyncSession = Depends(get_session),
) -> Optional[User]:
"""
Version optionnelle - ne lève pas d'erreur si pas de token
Utile pour des endpoints publics avec contenu enrichi si authentifié
"""
if not credentials:
return None
@ -93,15 +83,6 @@ async def get_current_user_optional(
def require_role(*allowed_roles: str):
"""
Décorateur pour restreindre l'accès par rôle
Usage:
@app.get("/admin/users")
async def list_users(user: User = Depends(require_role("admin"))):
...
"""
async def role_checker(user: User = Depends(get_current_user)) -> User:
if user.role not in allowed_roles:
raise HTTPException(

View file

@ -24,8 +24,6 @@ logger = logging.getLogger(__name__)
async def create_admin():
"""Crée un utilisateur admin"""
print("\n" + "=" * 60)
print(" Création d'un compte administrateur")
print("=" * 60 + "\n")
@ -59,7 +57,6 @@ async def create_admin():
else:
print(f" {error_msg}\n")
# Vérifier si l'email existe déjà
async with async_session_factory() as session:
from sqlalchemy import select
@ -78,7 +75,7 @@ async def create_admin():
nom=nom,
prenom=prenom,
role="admin",
is_verified=True, # Admin vérifié par défaut
is_verified=True,
is_active=True,
created_at=datetime.now(),
)
@ -89,7 +86,7 @@ async def create_admin():
print("\n Administrateur créé avec succès!")
print(f" Email: {email}")
print(f" Nom: {prenom} {nom}")
print(f" Rôle: admin")
print(" Rôle: admin")
print(f" ID: {admin.id}")
print("\n Vous pouvez maintenant vous connecter à l'API\n")

18
database/Enum/status.py Normal file
View file

@ -0,0 +1,18 @@
import enum
class StatutEmail(str, enum.Enum):
EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS"
ENVOYE = "ENVOYE"
OUVERT = "OUVERT"
ERREUR = "ERREUR"
BOUNCE = "BOUNCE"
class StatutSignature(str, enum.Enum):
EN_ATTENTE = "EN_ATTENTE"
ENVOYE = "ENVOYE"
SIGNE = "SIGNE"
REFUSE = "REFUSE"
EXPIRE = "EXPIRE"

View file

@ -5,20 +5,22 @@ from database.db_config import (
get_session,
close_db,
)
from database.models import (
Base,
EmailLog,
SignatureLog,
WorkflowLog,
from database.models.generic_model import (
CacheMetadata,
AuditLog,
StatutEmail,
StatutSignature,
User,
RefreshToken,
LoginAttempt,
)
from database.models.user import User
from database.models.email import EmailLog
from database.models.signature import SignatureLog
from database.models.sage_config import SageGatewayConfig
from database.Enum.status import (
StatutEmail,
StatutSignature,
)
from database.models.workflow import WorkflowLog
__all__ = [
"engine",
@ -37,4 +39,5 @@ __all__ = [
"User",
"RefreshToken",
"LoginAttempt",
"SageGatewayConfig",
]

View file

@ -25,10 +25,6 @@ async_session_factory = async_sessionmaker(
async def init_db():
"""
Crée toutes les tables dans la base de données
Utilise create_all qui ne crée QUE les tables manquantes
"""
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@ -42,7 +38,6 @@ async def init_db():
async def get_session() -> AsyncSession:
"""Dependency FastAPI pour obtenir une session DB"""
async with async_session_factory() as session:
try:
yield session
@ -51,6 +46,5 @@ async def get_session() -> AsyncSession:
async def close_db():
"""Ferme proprement toutes les connexions"""
await engine.dispose()
logger.info(" Connexions DB fermées")

View file

@ -1,280 +0,0 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Float,
Text,
Boolean,
Enum as SQLEnum,
)
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
import enum
Base = declarative_base()
class StatutEmail(str, enum.Enum):
"""Statuts possibles d'un email"""
EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS"
ENVOYE = "ENVOYE"
OUVERT = "OUVERT"
ERREUR = "ERREUR"
BOUNCE = "BOUNCE"
class StatutSignature(str, enum.Enum):
"""Statuts possibles d'une signature électronique"""
EN_ATTENTE = "EN_ATTENTE"
ENVOYE = "ENVOYE"
SIGNE = "SIGNE"
REFUSE = "REFUSE"
EXPIRE = "EXPIRE"
class EmailLog(Base):
"""
Journal des emails envoyés via l'API
Permet le suivi et le retry automatique
"""
__tablename__ = "email_logs"
id = Column(String(36), primary_key=True)
destinataire = Column(String(255), nullable=False, index=True)
cc = Column(Text, nullable=True) # JSON stringifié
cci = Column(Text, nullable=True) # JSON stringifié
sujet = Column(String(500), nullable=False)
corps_html = Column(Text, nullable=False)
document_ids = Column(Text, nullable=True) # Séparés par virgules
type_document = Column(Integer, nullable=True)
statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True)
date_creation = Column(DateTime, default=datetime.now, nullable=False)
date_envoi = Column(DateTime, nullable=True)
date_ouverture = Column(DateTime, nullable=True)
nb_tentatives = Column(Integer, default=0)
derniere_erreur = Column(Text, nullable=True)
prochain_retry = Column(DateTime, nullable=True)
ip_envoi = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
def __repr__(self):
return f"<EmailLog {self.id} to={self.destinataire} status={self.statut.value}>"
class SignatureLog(Base):
"""
Journal des demandes de signature Universign
Permet le suivi du workflow de signature
"""
__tablename__ = "signature_logs"
id = Column(String(36), primary_key=True)
document_id = Column(String(100), nullable=False, index=True)
type_document = Column(Integer, nullable=False)
transaction_id = Column(String(100), unique=True, index=True, nullable=True)
signer_url = Column(String(500), nullable=True)
email_signataire = Column(String(255), nullable=False, index=True)
nom_signataire = Column(String(255), nullable=False)
statut = Column(
SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True
)
date_envoi = Column(DateTime, default=datetime.now)
date_signature = Column(DateTime, nullable=True)
date_refus = Column(DateTime, nullable=True)
est_relance = Column(Boolean, default=False)
nb_relances = Column(Integer, default=0)
derniere_relance = Column(DateTime, nullable=True)
raison_refus = Column(Text, nullable=True)
ip_signature = Column(String(45), nullable=True)
def __repr__(self):
return f"<SignatureLog {self.id} doc={self.document_id} status={self.statut.value}>"
class WorkflowLog(Base):
"""
Journal des transformations de documents (Devis Commande Facture)
Permet la traçabilité du workflow commercial
"""
__tablename__ = "workflow_logs"
id = Column(String(36), primary_key=True)
document_source = Column(String(100), nullable=False, index=True)
type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc.
document_cible = Column(String(100), nullable=False, index=True)
type_cible = Column(Integer, nullable=False)
nb_lignes = Column(Integer, nullable=True)
montant_ht = Column(Float, nullable=True)
montant_ttc = Column(Float, nullable=True)
date_transformation = Column(DateTime, default=datetime.now, nullable=False)
utilisateur = Column(String(100), nullable=True)
succes = Column(Boolean, default=True)
erreur = Column(Text, nullable=True)
duree_ms = Column(Integer, nullable=True) # Durée en millisecondes
def __repr__(self):
return f"<WorkflowLog {self.document_source}{self.document_cible}>"
class CacheMetadata(Base):
"""
Métadonnées sur le cache Sage (clients, articles)
Permet le monitoring du cache géré par la gateway Windows
"""
__tablename__ = "cache_metadata"
id = Column(Integer, primary_key=True, autoincrement=True)
cache_type = Column(
String(50), unique=True, nullable=False
) # 'clients' ou 'articles'
last_refresh = Column(DateTime, default=datetime.now)
item_count = Column(Integer, default=0)
refresh_duration_ms = Column(Float, nullable=True)
last_error = Column(Text, nullable=True)
error_count = Column(Integer, default=0)
def __repr__(self):
return f"<CacheMetadata type={self.cache_type} items={self.item_count}>"
class AuditLog(Base):
"""
Journal d'audit pour la sécurité et la conformité
Trace toutes les actions importantes dans l'API
"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
action = Column(
String(100), nullable=False, index=True
) # 'CREATE_DEVIS', 'SEND_EMAIL', etc.
ressource_type = Column(String(50), nullable=True) # 'devis', 'facture', etc.
ressource_id = Column(String(100), nullable=True, index=True)
utilisateur = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True)
succes = Column(Boolean, default=True)
details = Column(Text, nullable=True) # JSON stringifié
erreur = Column(Text, nullable=True)
date_action = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
class User(Base):
"""
Utilisateurs de l'API avec validation email
"""
__tablename__ = "users"
id = Column(String(36), primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
nom = Column(String(100), nullable=False)
prenom = Column(String(100), nullable=False)
role = Column(String(50), default="user") # user, admin, commercial
is_verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True, unique=True, index=True)
verification_token_expires = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
reset_token = Column(String(255), nullable=True, unique=True, index=True)
reset_token_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_login = Column(DateTime, nullable=True)
def __repr__(self):
return f"<User {self.email} verified={self.is_verified}>"
class RefreshToken(Base):
"""
Tokens de rafraîchissement JWT
"""
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True, index=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False)
is_revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
class LoginAttempt(Base):
"""
Journal des tentatives de connexion (détection bruteforce)
"""
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False)
failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>"

43
database/models/email.py Normal file
View file

@ -0,0 +1,43 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Text,
Enum as SQLEnum,
)
from datetime import datetime
from database.models.generic_model import Base
from database.Enum.status import StatutEmail
class EmailLog(Base):
__tablename__ = "email_logs"
id = Column(String(36), primary_key=True)
destinataire = Column(String(255), nullable=False, index=True)
cc = Column(Text, nullable=True)
cci = Column(Text, nullable=True)
sujet = Column(String(500), nullable=False)
corps_html = Column(Text, nullable=False)
document_ids = Column(Text, nullable=True)
type_document = Column(Integer, nullable=True)
statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True)
date_creation = Column(DateTime, default=datetime.now, nullable=False)
date_envoi = Column(DateTime, nullable=True)
date_ouverture = Column(DateTime, nullable=True)
nb_tentatives = Column(Integer, default=0)
derniere_erreur = Column(Text, nullable=True)
prochain_retry = Column(DateTime, nullable=True)
ip_envoi = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
def __repr__(self):
return f"<EmailLog {self.id} to={self.destinataire} status={self.statut.value}>"

View file

@ -0,0 +1,91 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Float,
Text,
Boolean,
)
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
class CacheMetadata(Base):
__tablename__ = "cache_metadata"
id = Column(Integer, primary_key=True, autoincrement=True)
cache_type = Column(String(50), unique=True, nullable=False)
last_refresh = Column(DateTime, default=datetime.now)
item_count = Column(Integer, default=0)
refresh_duration_ms = Column(Float, nullable=True)
last_error = Column(Text, nullable=True)
error_count = Column(Integer, default=0)
def __repr__(self):
return f"<CacheMetadata type={self.cache_type} items={self.item_count}>"
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
action = Column(String(100), nullable=False, index=True)
ressource_type = Column(String(50), nullable=True)
ressource_id = Column(String(100), nullable=True, index=True)
utilisateur = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True)
succes = Column(Boolean, default=True)
details = Column(Text, nullable=True)
erreur = Column(Text, nullable=True)
date_action = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True, index=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False)
is_revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False)
failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>"

View file

@ -0,0 +1,54 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Text,
Boolean,
)
from datetime import datetime
from database.models.generic_model import Base
class SageGatewayConfig(Base):
__tablename__ = "sage_gateway_configs"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
gateway_url = Column(String(500), nullable=False)
gateway_token = Column(String(255), nullable=False)
sage_database = Column(String(255), nullable=True)
sage_company = Column(String(255), nullable=True)
is_active = Column(Boolean, default=False, index=True)
is_default = Column(Boolean, default=False)
priority = Column(Integer, default=0)
last_health_check = Column(DateTime, nullable=True)
last_health_status = Column(Boolean, nullable=True)
last_error = Column(Text, nullable=True)
total_requests = Column(Integer, default=0)
successful_requests = Column(Integer, default=0)
failed_requests = Column(Integer, default=0)
last_used_at = Column(DateTime, nullable=True)
extra_config = Column(Text, nullable=True)
is_encrypted = Column(Boolean, default=False)
allowed_ips = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
created_by = Column(String(36), nullable=True)
is_deleted = Column(Boolean, default=False, index=True)
deleted_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<SageGatewayConfig {self.name} user={self.user_id} active={self.is_active}>"

View file

@ -0,0 +1,44 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Text,
Boolean,
Enum as SQLEnum,
)
from datetime import datetime
from database.models.generic_model import Base
from database.Enum.status import StatutSignature
class SignatureLog(Base):
__tablename__ = "signature_logs"
id = Column(String(36), primary_key=True)
document_id = Column(String(100), nullable=False, index=True)
type_document = Column(Integer, nullable=False)
transaction_id = Column(String(100), unique=True, index=True, nullable=True)
signer_url = Column(String(500), nullable=True)
email_signataire = Column(String(255), nullable=False, index=True)
nom_signataire = Column(String(255), nullable=False)
statut = Column(
SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True
)
date_envoi = Column(DateTime, default=datetime.now)
date_signature = Column(DateTime, nullable=True)
date_refus = Column(DateTime, nullable=True)
est_relance = Column(Boolean, default=False)
nb_relances = Column(Integer, default=0)
derniere_relance = Column(DateTime, nullable=True)
raison_refus = Column(Text, nullable=True)
ip_signature = Column(String(45), nullable=True)
def __repr__(self):
return f"<SignatureLog {self.id} doc={self.document_id} status={self.statut.value}>"

39
database/models/user.py Normal file
View file

@ -0,0 +1,39 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Boolean,
)
from datetime import datetime
from database.models.generic_model import Base
class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
nom = Column(String(100), nullable=False)
prenom = Column(String(100), nullable=False)
role = Column(String(50), default="user")
is_verified = Column(Boolean, default=False)
verification_token = Column(String(255), nullable=True, unique=True, index=True)
verification_token_expires = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True)
failed_login_attempts = Column(Integer, default=0)
locked_until = Column(DateTime, nullable=True)
reset_token = Column(String(255), nullable=True, unique=True, index=True)
reset_token_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
last_login = Column(DateTime, nullable=True)
def __repr__(self):
return f"<User {self.email} verified={self.is_verified}>"

View file

@ -0,0 +1,37 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Float,
Text,
Boolean,
)
from datetime import datetime
from database.models.generic_model import Base
class WorkflowLog(Base):
__tablename__ = "workflow_logs"
id = Column(String(36), primary_key=True)
document_source = Column(String(100), nullable=False, index=True)
type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc.
document_cible = Column(String(100), nullable=False, index=True)
type_cible = Column(Integer, nullable=False)
nb_lignes = Column(Integer, nullable=True)
montant_ht = Column(Float, nullable=True)
montant_ttc = Column(Float, nullable=True)
date_transformation = Column(DateTime, default=datetime.now, nullable=False)
utilisateur = Column(String(100), nullable=True)
succes = Column(Boolean, default=True)
erreur = Column(Text, nullable=True)
duree_ms = Column(Integer, nullable=True) # Durée en millisecondes
def __repr__(self):
return f"<WorkflowLog {self.document_source}{self.document_cible}>"

View file

@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
ULTRA_DEBUG = True
def debug_log(message: str, level: str = "INFO"):
if ULTRA_DEBUG:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
@ -29,13 +30,12 @@ def debug_log(message: str, level: str = "INFO"):
"ERROR": "[ERROR]",
"WARN": "[WARN]",
"STEP": "[STEP]",
"DATA": "[DATA]"
"DATA": "[DATA]",
}.get(level, "")
logger.info(f"{prefix} [{timestamp}] {message}")
class EmailQueue:
def __init__(self):
self.queue = queue.Queue()
self.workers = []
@ -65,13 +65,14 @@ class EmailQueue:
try:
self.queue.join()
logger.info(" Queue email arrêtée proprement")
except:
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
except Exception:
logger.warning(" Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str):
"""Ajoute un email dans la queue"""
self.queue.put(email_log_id)
debug_log(f"Email {email_log_id} ajouté à la queue (taille: {self.queue.qsize()})")
debug_log(
f"Email {email_log_id} ajouté à la queue (taille: {self.queue.qsize()})"
)
def _worker(self):
loop = asyncio.new_event_loop()
@ -84,7 +85,9 @@ class EmailQueue:
while self.running:
try:
email_log_id = self.queue.get(timeout=1)
debug_log(f"[{worker_name}] Traitement email {email_log_id}", "STEP")
debug_log(
f"[{worker_name}] Traitement email {email_log_id}", "STEP"
)
loop.run_until_complete(self._process_email(email_log_id))
@ -96,7 +99,7 @@ class EmailQueue:
logger.error(f" Erreur worker {worker_name}: {e}", exc_info=True)
try:
self.queue.task_done()
except:
except Exception:
pass
finally:
loop.close()
@ -122,7 +125,7 @@ class EmailQueue:
logger.error(f" Email log {email_log_id} introuvable en DB")
return
debug_log(f"Email trouvé en DB:", "DATA")
debug_log("Email trouvé en DB:", "DATA")
debug_log(f" → Destinataire: {email_log.destinataire}")
debug_log(f" → Sujet: {email_log.sujet[:50]}...")
debug_log(f" → Tentative: {email_log.nb_tentatives + 1}")
@ -138,7 +141,9 @@ class EmailQueue:
email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None
debug_log(f"Email envoyé avec succès: {email_log.destinataire}", "SUCCESS")
debug_log(
f"Email envoyé avec succès: {email_log.destinataire}", "SUCCESS"
)
except Exception as e:
error_msg = str(e)
@ -157,15 +162,20 @@ class EmailQueue:
timer.daemon = True
timer.start()
debug_log(f"Retry #{email_log.nb_tentatives + 1} planifié dans {delay}s", "WARN")
debug_log(
f"Retry #{email_log.nb_tentatives + 1} planifié dans {delay}s",
"WARN",
)
else:
debug_log(f"ÉCHEC DÉFINITIF après {email_log.nb_tentatives} tentatives", "ERROR")
debug_log(
f"ÉCHEC DÉFINITIF après {email_log.nb_tentatives} tentatives",
"ERROR",
)
await session.commit()
debug_log(f"═══ FIN TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
async def _send_with_retry(self, email_log):
debug_log("Construction du message MIME...", "STEP")
msg = MIMEMultipart()
@ -173,7 +183,7 @@ class EmailQueue:
msg["To"] = email_log.destinataire
msg["Subject"] = email_log.sujet
debug_log(f"Headers configurés:", "DATA")
debug_log("Headers configurés:", "DATA")
debug_log(f" → From: {settings.smtp_from}")
debug_log(f" → To: {email_log.destinataire}")
debug_log(f" → Subject: {email_log.sujet}")
@ -205,7 +215,10 @@ class EmailQueue:
f'attachment; filename="{doc_id}.pdf"'
)
msg.attach(part)
debug_log(f"PDF attaché: {doc_id}.pdf ({len(pdf_bytes)} bytes)", "SUCCESS")
debug_log(
f"PDF attaché: {doc_id}.pdf ({len(pdf_bytes)} bytes)",
"SUCCESS",
)
except Exception as e:
debug_log(f"Erreur génération PDF {doc_id}: {e}", "ERROR")
@ -224,7 +237,9 @@ class EmailQueue:
debug_log(f" → Host: {settings.smtp_host}")
debug_log(f" → Port: {settings.smtp_port}")
debug_log(f" → User: {settings.smtp_user}")
debug_log(f" → Password: {'*' * len(settings.smtp_password) if settings.smtp_password else 'NON DÉFINI'}")
debug_log(
f" → Password: {'*' * len(settings.smtp_password) if settings.smtp_password else 'NON DÉFINI'}"
)
debug_log(f" → From: {settings.smtp_from}")
debug_log(f" → TLS: {settings.smtp_use_tls}")
debug_log(f" → To: {msg['To']}")
@ -235,11 +250,15 @@ class EmailQueue:
# ═══ ÉTAPE 1: RÉSOLUTION DNS ═══
debug_log("ÉTAPE 1/7: Résolution DNS...", "STEP")
try:
ip_addresses = socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
ip_addresses = socket.getaddrinfo(
settings.smtp_host, settings.smtp_port
)
debug_log(f" → DNS résolu: {ip_addresses[0][4]}", "SUCCESS")
except socket.gaierror as e:
debug_log(f" → ÉCHEC DNS: {e}", "ERROR")
raise Exception(f"Résolution DNS échouée pour {settings.smtp_host}: {e}")
raise Exception(
f"Résolution DNS échouée pour {settings.smtp_host}: {e}"
)
# ═══ ÉTAPE 2: CONNEXION TCP ═══
debug_log("ÉTAPE 2/7: Connexion TCP...", "STEP")
@ -247,21 +266,25 @@ class EmailQueue:
try:
server = smtplib.SMTP(
settings.smtp_host,
settings.smtp_port,
timeout=30
settings.smtp_host, settings.smtp_port, timeout=30
)
server.set_debuglevel(2 if ULTRA_DEBUG else 0) # Active le debug SMTP natif
server.set_debuglevel(
2 if ULTRA_DEBUG else 0
) # Active le debug SMTP natif
connect_time = time.time() - start_time
debug_log(f" → Connexion établie en {connect_time:.2f}s", "SUCCESS")
except socket.timeout:
debug_log(f" → TIMEOUT connexion (>30s)", "ERROR")
raise Exception(f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}")
debug_log(" → TIMEOUT connexion (>30s)", "ERROR")
raise Exception(
f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}"
)
except ConnectionRefusedError:
debug_log(f" → CONNEXION REFUSÉE", "ERROR")
raise Exception(f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}")
debug_log(" → CONNEXION REFUSÉE", "ERROR")
raise Exception(
f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}"
)
except Exception as e:
debug_log(f" → ERREUR CONNEXION: {type(e).__name__}: {e}", "ERROR")
raise
@ -281,15 +304,19 @@ class EmailQueue:
debug_log("ÉTAPE 4/7: Négociation STARTTLS...", "STEP")
try:
# Vérifier si le serveur supporte STARTTLS
if server.has_extn('STARTTLS'):
if server.has_extn("STARTTLS"):
debug_log(" → Serveur supporte STARTTLS", "SUCCESS")
# Créer un contexte SSL
context = ssl.create_default_context()
debug_log(f" → Contexte SSL créé (protocole: {context.protocol})")
debug_log(
f" → Contexte SSL créé (protocole: {context.protocol})"
)
tls_code, tls_msg = server.starttls(context=context)
debug_log(f" → STARTTLS Response: {tls_code} - {tls_msg}", "SUCCESS")
debug_log(
f" → STARTTLS Response: {tls_code} - {tls_msg}", "SUCCESS"
)
# Re-EHLO après STARTTLS
server.ehlo()
@ -315,27 +342,42 @@ class EmailQueue:
try:
# Lister les méthodes d'auth supportées
if server.has_extn('AUTH'):
auth_methods = server.esmtp_features.get('auth', '')
if server.has_extn("AUTH"):
auth_methods = server.esmtp_features.get("auth", "")
debug_log(f" → Méthodes AUTH supportées: {auth_methods}")
server.login(settings.smtp_user, settings.smtp_password)
debug_log(" → Authentification RÉUSSIE", "SUCCESS")
except smtplib.SMTPAuthenticationError as e:
debug_log(f" → ÉCHEC AUTHENTIFICATION: {e.smtp_code} - {e.smtp_error}", "ERROR")
debug_log(
f" → ÉCHEC AUTHENTIFICATION: {e.smtp_code} - {e.smtp_error}",
"ERROR",
)
debug_log(f" → Code: {e.smtp_code}", "ERROR")
debug_log(f" → Message: {e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else e.smtp_error}", "ERROR")
debug_log(
f" → Message: {e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else e.smtp_error}",
"ERROR",
)
# Diagnostic spécifique selon le code d'erreur
if e.smtp_code == 535:
debug_log(" → 535 = Identifiants incorrects ou app password requis", "ERROR")
debug_log(
" → 535 = Identifiants incorrects ou app password requis",
"ERROR",
)
elif e.smtp_code == 534:
debug_log(" → 534 = 2FA requis, utiliser un App Password", "ERROR")
debug_log(
" → 534 = 2FA requis, utiliser un App Password", "ERROR"
)
elif e.smtp_code == 530:
debug_log(" → 530 = Authentification requise mais échouée", "ERROR")
debug_log(
" → 530 = Authentification requise mais échouée", "ERROR"
)
raise Exception(f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}")
raise Exception(
f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}"
)
except smtplib.SMTPException as e:
debug_log(f" → ERREUR SMTP AUTH: {e}", "ERROR")
@ -364,8 +406,13 @@ class EmailQueue:
debug_log(f" → DESTINATAIRE REFUSÉ: {e.recipients}", "ERROR")
raise Exception(f"Destinataire refusé: {e.recipients}")
except smtplib.SMTPSenderRefused as e:
debug_log(f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR")
debug_log(f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer", "ERROR")
debug_log(
f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR"
)
debug_log(
f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer",
"ERROR",
)
raise Exception(f"Expéditeur refusé: {e.smtp_code} - {e.smtp_error}")
except smtplib.SMTPDataError as e:
debug_log(f" → ERREUR DATA: {e.smtp_code} - {e.smtp_error}", "ERROR")
@ -376,7 +423,7 @@ class EmailQueue:
try:
server.quit()
debug_log(" → Connexion fermée proprement", "SUCCESS")
except:
except Exception:
pass
debug_log("═══════════════════════════════════════════", "SUCCESS")
@ -393,13 +440,12 @@ class EmailQueue:
if server:
try:
server.quit()
except:
except Exception:
pass
raise Exception(f"Erreur SMTP: {str(e)}")
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
if not self.sage_client:
logger.error(" sage_client non configuré")
raise Exception("sage_client non disponible")
@ -470,9 +516,7 @@ class EmailQueue:
# FIX: Gérer les valeurs None correctement
designation = (
ligne.get("designation")
or ligne.get("designation_article")
or ""
ligne.get("designation") or ligne.get("designation_article") or ""
)
if designation:
designation = str(designation)[:50]
@ -481,8 +525,16 @@ class EmailQueue:
pdf.drawString(2 * cm, y, designation)
pdf.drawString(10 * cm, y, str(ligne.get("quantite") or 0))
pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire_ht') or ligne.get('prix_unitaire', 0):.2f}")
pdf.drawString(15 * cm, y, f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}")
pdf.drawString(
12 * cm,
y,
f"{ligne.get('prix_unitaire_ht') or ligne.get('prix_unitaire', 0):.2f}",
)
pdf.drawString(
15 * cm,
y,
f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}",
)
y -= 0.6 * cm
y -= 1 * cm

View file

@ -1,20 +1,10 @@
# -*- coding: utf-8 -*-
"""
Script d'initialisation de la base de données SQLite
Lance ce script avant le premier démarrage de l'API
Usage:
python init_db.py
"""
import asyncio
import sys
from pathlib import Path
# Ajouter le répertoire parent au path pour les imports
sys.path.insert(0, str(Path(__file__).parent))
from database import init_db # Import depuis database/__init__.py
from database import init_db
import logging
logging.basicConfig(level=logging.INFO)
@ -22,10 +12,9 @@ logger = logging.getLogger(__name__)
async def main():
"""Crée toutes les tables dans sage_dataven.db"""
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")
try:
@ -33,21 +22,21 @@ async def main():
await init_db()
print("\n Base de données créée avec succès!")
print(f" Fichier: sage_dataven.db")
print(" Fichier: sage_dataven.db")
print("\n📊 Tables créées:")
print("\nTables créées:")
print(" ├─ email_logs (Journalisation emails)")
print(" ├─ signature_logs (Suivi signatures Universign)")
print(" ├─ workflow_logs (Transformations documents)")
print(" ├─ cache_metadata (Métadonnées cache)")
print(" └─ audit_logs (Journal d'audit)")
print("\n📝 Prochaines étapes:")
print("\nProchaines étapes:")
print(" 1. Configurer le fichier .env avec vos credentials")
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(" 4. Ou avec Docker: docker-compose up -d")
print(" 5. Tester: http://votre-vps:8000/docs")
print(" 5. Tester: http://0.0.0.0:8000/docs")
print("\n" + "=" * 60 + "\n")
return True

View file

@ -21,14 +21,12 @@ from security.auth import (
from services.email_service import AuthEmailService
from core.dependencies import get_current_user
from config import settings
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
@ -45,7 +43,7 @@ class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = 86400 # 30 minutes en secondes
expires_in: int = 86400
class RefreshTokenRequest(BaseModel):
@ -69,8 +67,6 @@ class ResendVerificationRequest(BaseModel):
email: EmailStr
async def log_login_attempt(
session: AsyncSession,
email: str,
@ -79,7 +75,6 @@ async def log_login_attempt(
success: bool,
failure_reason: Optional[str] = None,
):
"""Enregistre une tentative de connexion"""
attempt = LoginAttempt(
email=email,
ip_address=ip,
@ -95,13 +90,12 @@ async def log_login_attempt(
async def check_rate_limit(
session: AsyncSession, email: str, ip: str
) -> tuple[bool, str]:
time_window = datetime.now() - timedelta(minutes=15)
result = await session.execute(
select(LoginAttempt).where(
LoginAttempt.email == email,
LoginAttempt.success == False,
LoginAttempt.success,
LoginAttempt.timestamp >= time_window,
)
)
@ -113,15 +107,12 @@ async def check_rate_limit(
return True, ""
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
data: RegisterRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(User).where(User.email == data.email))
existing_user = result.scalar_one_or_none()
@ -171,10 +162,6 @@ async def register(
@router.get("/verify-email")
async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)):
"""
Vérification de l'email via lien cliquable (GET)
Utilisé quand l'utilisateur clique sur le lien dans l'email
"""
result = await session.execute(select(User).where(User.verification_token == token))
user = result.scalar_one_or_none()
@ -209,10 +196,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
async def verify_email_post(
data: VerifyEmailRequest, session: AsyncSession = Depends(get_session)
):
"""
Vérification de l'email via API (POST)
Utilisé pour les appels programmatiques depuis le frontend
"""
result = await session.execute(
select(User).where(User.verification_token == data.token)
)
@ -344,7 +327,6 @@ async def login(
detail="Compte temporairement verrouillé",
)
user.failed_login_attempts = 0
user.locked_until = None
user.last_login = datetime.now()
@ -374,7 +356,7 @@ async def login(
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_jwt,
expires_in=86400, # 30 minutes
expires_in=86400,
)
@ -382,7 +364,6 @@ async def login(
async def refresh_access_token(
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
):
payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(
@ -396,7 +377,7 @@ async def refresh_access_token(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.token_hash == token_hash,
RefreshToken.is_revoked == False,
not RefreshToken.is_revoked,
)
)
token_record = result.scalar_one_or_none()
@ -429,7 +410,7 @@ async def refresh_access_token(
return TokenResponse(
access_token=new_access_token,
refresh_token=data.refresh_token, # Refresh token reste le même
refresh_token=data.refresh_token,
expires_in=86400,
)
@ -440,7 +421,6 @@ async def forgot_password(
request: Request,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none()
@ -474,7 +454,6 @@ async def forgot_password(
async def reset_password(
data: ResetPasswordRequest, session: AsyncSession = Depends(get_session)
):
result = await session.execute(select(User).where(User.reset_token == data.token))
user = result.scalar_one_or_none()
@ -517,7 +496,6 @@ async def logout(
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
token_hash = hash_token(data.refresh_token)
result = await session.execute(
@ -539,7 +517,6 @@ async def logout(
@router.get("/me")
async def get_current_user_info(user: User = Depends(get_current_user)):
return {
"id": user.id,
"email": user.email,

View file

@ -1,5 +1,5 @@
import requests
from typing import Dict, List, Optional, Union
from typing import Dict, List, Optional
from config import settings
import logging
@ -16,7 +16,6 @@ class SageGatewayClient:
self.timeout = 30
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
"""POST avec retry automatique"""
import time
for attempt in range(retries):
@ -38,7 +37,6 @@ class SageGatewayClient:
time.sleep(2**attempt)
def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict:
"""GET avec retry automatique"""
import time
for attempt in range(retries):
@ -60,27 +58,21 @@ class SageGatewayClient:
time.sleep(2**attempt)
def lister_clients(self, filtre: str = "") -> List[Dict]:
"""Liste tous les clients avec filtre optionnel"""
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
def lire_client(self, code: str) -> Optional[Dict]:
"""Lecture d'un client par code"""
return self._post("/sage/clients/get", {"code": code}).get("data")
def lister_articles(self, filtre: str = "") -> List[Dict]:
"""Liste tous les articles avec filtre optionnel"""
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
def lire_article(self, ref: str) -> Optional[Dict]:
"""Lecture d'un article par référence"""
return self._post("/sage/articles/get", {"code": ref}).get("data")
def creer_devis(self, devis_data: Dict) -> Dict:
"""Création d'un devis"""
return self._post("/sage/devis/create", devis_data).get("data", {})
def lire_devis(self, numero: str) -> Optional[Dict]:
"""Lecture d'un devis"""
return self._post("/sage/devis/get", {"code": numero}).get("data")
def lister_devis(
@ -94,7 +86,9 @@ class SageGatewayClient:
payload["statut"] = statut
return self._post("/sage/devis/list", payload).get("data", [])
def changer_statut_document(self, document_type_code: int, numero: str, nouveau_statut: int) -> Dict:
def changer_statut_document(
self, document_type_code: int, numero: str, nouveau_statut: int
) -> Dict:
try:
r = requests.post(
f"{self.url}/sage/document/statut",
@ -113,7 +107,6 @@ class SageGatewayClient:
raise
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
"""Lecture d'un document générique"""
return self._post(
"/sage/documents/get", {"numero": numero, "type_doc": type_doc}
).get("data")
@ -141,7 +134,6 @@ class SageGatewayClient:
def mettre_a_jour_champ_libre(
self, doc_id: str, type_doc: int, nom_champ: str, valeur: str
) -> bool:
"""Mise à jour d'un champ libre"""
resp = self._post(
"/sage/documents/champ-libre",
{
@ -170,60 +162,28 @@ class SageGatewayClient:
return self._post("/sage/factures/list", payload).get("data", [])
def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool:
"""Met à jour le champ 'Dernière relance' d'une facture"""
resp = self._post(
"/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc}
)
return resp.get("success", False)
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
"""Lecture du contact principal d'un client"""
return self._post("/sage/contact/read", {"code": code_client}).get("data")
def lire_remise_max_client(self, code_client: str) -> float:
"""Récupère la remise max autorisée pour un client"""
result = self._post("/sage/client/remise-max", {"code": code_client})
return result.get("data", {}).get("remise_max", 10.0)
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
"""Génère le PDF d'un document via la gateway Windows"""
try:
r = requests.post(
f"{self.url}/sage/documents/generate-pdf",
json={"doc_id": doc_id, "type_doc": type_doc},
headers=self.headers,
timeout=60,
)
r.raise_for_status()
import base64
response_data = r.json()
pdf_base64 = response_data.get("data", {}).get("pdf_base64", "")
if not pdf_base64:
raise ValueError("PDF vide retourné par la gateway")
return base64.b64decode(pdf_base64)
except Exception as e:
logger.error(f"Erreur génération PDF: {e}")
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:
@ -238,43 +198,36 @@ class SageGatewayClient:
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")
def refresh_cache(self) -> Dict:
"""Force le rafraîchissement du cache Windows"""
return self._post("/sage/cache/refresh")
def get_cache_info(self) -> Dict:
"""Récupère les infos du cache Windows"""
return self._get("/sage/cache/info").get("data", {})
def health(self) -> dict:
"""Health check de la gateway Windows"""
try:
r = requests.get(f"{self.url}/health", timeout=5)
return r.json()
except:
except Exception:
return {"status": "down"}
def creer_client(self, client_data: Dict) -> Dict:
@ -412,94 +365,45 @@ class SageGatewayClient:
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]:
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,
)
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
def creer_contact(self, contact_data: Dict) -> Dict:
return self._post("/sage/contacts/create", contact_data)
def lister_contacts(self, numero: str) -> List[Dict]:
return self._post("/sage/contacts/list", {"numero": numero}).get("data", [])
def obtenir_contact(self, numero: str, contact_numero: int) -> Dict:
result = self._post("/sage/contacts/get", {
"numero": numero,
"contact_numero": contact_numero
})
result = self._post(
"/sage/contacts/get", {"numero": numero, "contact_numero": contact_numero}
)
return result.get("data") if result.get("success") else None
def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict:
return self._post("/sage/contacts/update", {
"numero": numero,
"contact_numero": contact_numero,
"updates": updates
})
return self._post(
"/sage/contacts/update",
{"numero": numero, "contact_numero": contact_numero, "updates": updates},
)
def supprimer_contact(self, numero: str, contact_numero: int) -> Dict:
return self._post("/sage/contacts/delete", {
"numero": numero,
"contact_numero": contact_numero
})
return self._post(
"/sage/contacts/delete",
{"numero": numero, "contact_numero": contact_numero},
)
def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict:
return self._post("/sage/contacts/set-default", {
"numero": numero,
"contact_numero": contact_numero
})
def lister_tiers(self, type_tiers: Optional[str] = None, filtre: str = "") -> List[Dict]:
return self._post("/sage/tiers/list", {
"type_tiers": type_tiers,
"filtre": filtre
}).get("data", [])
return self._post(
"/sage/contacts/set-default",
{"numero": numero, "contact_numero": contact_numero},
)
def lister_tiers(
self, type_tiers: Optional[str] = None, filtre: str = ""
) -> List[Dict]:
return self._post(
"/sage/tiers/list", {"type_tiers": type_tiers, "filtre": filtre}
).get("data", [])
def lire_tiers(self, code: str) -> Optional[Dict]:
return self._post("/sage/tiers/get", {
"code": code
}).get("data")
return self._post("/sage/tiers/get", {"code": code}).get("data")
sage_client = SageGatewayClient()

View file

@ -1,7 +1,4 @@
from schemas.tiers.tiers import (
TiersDetails,
TypeTiersInt
)
from schemas.tiers.tiers import TiersDetails, TypeTiersInt
from schemas.tiers.type_tiers import TypeTiers
from schemas.schema_mixte import BaremeRemiseResponse
from schemas.user import UserResponse
@ -9,52 +6,27 @@ from schemas.tiers.clients import (
ClientCreateRequest,
ClientDetails,
ClientResponse,
ClientUpdateRequest
ClientUpdateRequest,
)
from schemas.tiers.contact import (
Contact,
ContactCreate,
ContactUpdate
)
from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate
from schemas.tiers.fournisseurs import (
FournisseurCreateAPIRequest,
FournisseurDetails,
FournisseurUpdateRequest
)
from schemas.documents.avoirs import (
AvoirCreateRequest,
AvoirUpdateRequest
)
from schemas.documents.commandes import (
CommandeCreateRequest,
CommandeUpdateRequest
FournisseurUpdateRequest,
)
from schemas.documents.avoirs import AvoirCreateRequest, AvoirUpdateRequest
from schemas.documents.commandes import CommandeCreateRequest, CommandeUpdateRequest
from schemas.documents.devis import (
DevisRequest,
DevisResponse,
DevisUpdateRequest,
RelanceDevisRequest
)
from schemas.documents.documents import (
TypeDocument,
TypeDocumentSQL
)
from schemas.documents.email import (
StatutEmail,
EmailEnvoiRequest
)
from schemas.documents.factures import (
FactureCreateRequest,
FactureUpdateRequest
)
from schemas.documents.livraisons import (
LivraisonCreateRequest,
LivraisonUpdateRequest
)
from schemas.documents.universign import (
SignatureRequest,
StatutSignature
RelanceDevisRequest,
)
from schemas.documents.documents import TypeDocument, TypeDocumentSQL
from schemas.documents.email import StatutEmail, EmailEnvoiRequest
from schemas.documents.factures import FactureCreateRequest, FactureUpdateRequest
from schemas.documents.livraisons import LivraisonCreateRequest, LivraisonUpdateRequest
from schemas.documents.universign import SignatureRequest, StatutSignature
from schemas.articles.articles import (
ArticleCreateRequest,
ArticleResponse,
@ -62,12 +34,12 @@ from schemas.articles.articles import (
ArticleListResponse,
EntreeStockRequest,
SortieStockRequest,
MouvementStockResponse
MouvementStockResponse,
)
from schemas.articles.famille_article import (
FamilleResponse,
FamilleCreateRequest,
FamilleListResponse
FamilleListResponse,
)
@ -114,5 +86,5 @@ __all__ = [
"FamilleCreateRequest",
"FamilleListResponse",
"ContactCreate",
"ContactUpdate"
"ContactUpdate",
]

View file

@ -1,7 +1,6 @@
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import date, datetime
from enum import Enum, IntEnum
class EmplacementStockModel(BaseModel):

View file

@ -1,10 +1,7 @@
from pydantic import BaseModel, Field
from typing import Optional
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
class FamilleCreateRequest(BaseModel):
"""Schéma pour création de famille d'articles"""
@ -30,7 +27,6 @@ class FamilleCreateRequest(BaseModel):
}
class FamilleResponse(BaseModel):
"""Modèle complet d'une famille avec données comptables et fournisseur"""
@ -57,63 +53,109 @@ class FamilleResponse(BaseModel):
nature: Optional[int] = Field(None, description="Nature de la famille")
pays: Optional[str] = Field(None, description="Pays d'origine")
categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)")
categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)")
categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)")
categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)")
categorie_1: Optional[int] = Field(
None, description="Catégorie comptable 1 (CL_No1)"
)
categorie_2: Optional[int] = Field(
None, description="Catégorie comptable 2 (CL_No2)"
)
categorie_3: Optional[int] = Field(
None, description="Catégorie comptable 3 (CL_No3)"
)
categorie_4: Optional[int] = Field(
None, description="Catégorie comptable 4 (CL_No4)"
)
stat_01: Optional[str] = Field(None, description="Statistique libre 1")
stat_02: Optional[str] = Field(None, description="Statistique libre 2")
stat_03: Optional[str] = Field(None, description="Statistique libre 3")
stat_04: Optional[str] = Field(None, description="Statistique libre 4")
stat_05: Optional[str] = Field(None, description="Statistique libre 5")
hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques")
hors_statistique: Optional[bool] = Field(
None, description="Exclue des statistiques"
)
vente_debit: Optional[bool] = Field(None, description="Vente au débit")
non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents")
non_imprimable: Optional[bool] = Field(
None, description="Non imprimable sur documents"
)
contremarque: Optional[bool] = Field(None, description="Article en contremarque")
fact_poids: Optional[bool] = Field(None, description="Facturation au poids")
fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire")
publie: Optional[bool] = Field(None, description="Publié (e-commerce)")
racine_reference: Optional[str] = Field(None, description="Racine pour génération auto de références")
racine_code_barre: Optional[str] = Field(None, description="Racine pour génération auto de codes-barres")
racine_reference: Optional[str] = Field(
None, description="Racine pour génération auto de références"
)
racine_code_barre: Optional[str] = Field(
None, description="Racine pour génération auto de codes-barres"
)
raccourci: Optional[str] = Field(None, description="Raccourci clavier")
sous_traitance: Optional[bool] = Field(None, description="Famille en sous-traitance")
sous_traitance: Optional[bool] = Field(
None, description="Famille en sous-traitance"
)
fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)")
criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)")
compte_vente: Optional[str] = Field(None, description="Compte général de vente")
compte_auxiliaire_vente: Optional[str] = Field(None, description="Compte auxiliaire de vente")
compte_auxiliaire_vente: Optional[str] = Field(
None, description="Compte auxiliaire de vente"
)
tva_vente_1: Optional[str] = Field(None, description="Code TVA vente principal")
tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire")
tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire")
type_facture_vente: Optional[int] = Field(None, description="Type de facture vente")
compte_achat: Optional[str] = Field(None, description="Compte général d'achat")
compte_auxiliaire_achat: Optional[str] = Field(None, description="Compte auxiliaire d'achat")
compte_auxiliaire_achat: Optional[str] = Field(
None, description="Compte auxiliaire d'achat"
)
tva_achat_1: Optional[str] = Field(None, description="Code TVA achat principal")
tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire")
tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire")
type_facture_achat: Optional[int] = Field(None, description="Type de facture achat")
compte_stock: Optional[str] = Field(None, description="Compte de stock")
compte_auxiliaire_stock: Optional[str] = Field(None, description="Compte auxiliaire de stock")
compte_auxiliaire_stock: Optional[str] = Field(
None, description="Compte auxiliaire de stock"
)
fournisseur_principal: Optional[str] = Field(None, description="N° compte fournisseur principal")
fournisseur_unite: Optional[str] = Field(None, description="Unité d'achat fournisseur")
fournisseur_conversion: Optional[float] = Field(None, description="Coefficient de conversion")
fournisseur_delai_appro: Optional[int] = Field(None, description="Délai d'approvisionnement (jours)")
fournisseur_garantie: Optional[int] = Field(None, description="Garantie fournisseur (mois)")
fournisseur_colisage: Optional[int] = Field(None, description="Colisage fournisseur")
fournisseur_qte_mini: Optional[float] = Field(None, description="Quantité minimum de commande")
fournisseur_principal: Optional[str] = Field(
None, description="N° compte fournisseur principal"
)
fournisseur_unite: Optional[str] = Field(
None, description="Unité d'achat fournisseur"
)
fournisseur_conversion: Optional[float] = Field(
None, description="Coefficient de conversion"
)
fournisseur_delai_appro: Optional[int] = Field(
None, description="Délai d'approvisionnement (jours)"
)
fournisseur_garantie: Optional[int] = Field(
None, description="Garantie fournisseur (mois)"
)
fournisseur_colisage: Optional[int] = Field(
None, description="Colisage fournisseur"
)
fournisseur_qte_mini: Optional[float] = Field(
None, description="Quantité minimum de commande"
)
fournisseur_qte_mont: Optional[float] = Field(None, description="Quantité montant")
fournisseur_devise: Optional[int] = Field(None, description="Devise fournisseur (0=Euro)")
fournisseur_remise: Optional[float] = Field(None, description="Remise fournisseur (%)")
fournisseur_type_remise: Optional[int] = Field(None, description="Type de remise (0=%, 1=Montant)")
fournisseur_devise: Optional[int] = Field(
None, description="Devise fournisseur (0=Euro)"
)
fournisseur_remise: Optional[float] = Field(
None, description="Remise fournisseur (%)"
)
fournisseur_type_remise: Optional[int] = Field(
None, description="Type de remise (0=%, 1=Montant)"
)
nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille")
nb_articles: Optional[int] = Field(
None, description="Nombre d'articles dans la famille"
)
FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille")
FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé")
@ -189,13 +231,14 @@ class FamilleResponse(BaseModel):
"fournisseur_devise": 0,
"fournisseur_remise": 5.0,
"fournisseur_type_remise": 0,
"nb_articles": 156
"nb_articles": 156,
}
}
class FamilleListResponse(BaseModel):
"""Réponse pour la liste des familles"""
familles: list[FamilleResponse]
total: int
filtre: Optional[str] = None
@ -207,7 +250,6 @@ class FamilleListResponse(BaseModel):
"familles": [],
"total": 42,
"filtre": "ELECT",
"inclure_totaux": False
"inclure_totaux": False,
}
}

View file

@ -1,13 +1,9 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
class LigneAvoir(BaseModel):
"""Ligne d'avoir"""
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@ -18,8 +14,6 @@ class LigneAvoir(BaseModel):
class AvoirCreateRequest(BaseModel):
"""Création d'un avoir"""
client_id: str
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
@ -45,8 +39,6 @@ class AvoirCreateRequest(BaseModel):
class AvoirUpdateRequest(BaseModel):
"""Modification d'un avoir existant"""
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneAvoir]] = None

View file

@ -1,13 +1,9 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
class LigneCommande(BaseModel):
"""Ligne de commande"""
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@ -18,8 +14,6 @@ class LigneCommande(BaseModel):
class CommandeCreateRequest(BaseModel):
"""Création d'une commande"""
client_id: str
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
@ -45,8 +39,6 @@ class CommandeCreateRequest(BaseModel):
class CommandeUpdateRequest(BaseModel):
"""Modification d'une commande existante"""
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneCommande]] = None

View file

@ -1,8 +1,7 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
class LigneDevis(BaseModel):
article_code: str
@ -31,7 +30,6 @@ class DevisResponse(BaseModel):
nb_lignes: int
class DevisUpdateRequest(BaseModel):
"""Modèle pour modification d'un devis existant"""
@ -60,7 +58,6 @@ class DevisUpdateRequest(BaseModel):
}
class RelanceDevisRequest(BaseModel):
doc_id: str
message_personnalise: Optional[str] = None

View file

@ -1,7 +1,7 @@
from config import settings
from enum import Enum
class TypeDocument(int, Enum):
DEVIS = settings.SAGE_TYPE_DEVIS
BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE

View file

@ -1,9 +1,9 @@
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
from pydantic import BaseModel, EmailStr
from typing import List, Optional
from enum import Enum
from schemas.documents.documents import TypeDocument
class StatutEmail(str, Enum):
EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS"
@ -12,6 +12,7 @@ class StatutEmail(str, Enum):
ERREUR = "ERREUR"
BOUNCE = "BOUNCE"
class EmailEnvoiRequest(BaseModel):
destinataire: EmailStr
cc: Optional[List[EmailStr]] = []

View file

@ -1,13 +1,9 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
class LigneFacture(BaseModel):
"""Ligne de facture"""
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@ -18,8 +14,6 @@ class LigneFacture(BaseModel):
class FactureCreateRequest(BaseModel):
"""Création d'une facture"""
client_id: str
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
@ -45,8 +39,6 @@ class FactureCreateRequest(BaseModel):
class FactureUpdateRequest(BaseModel):
"""Modification d'une facture existante"""
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneFacture]] = None

View file

@ -1,12 +1,9 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import date
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
class LigneLivraison(BaseModel):
"""Ligne de livraison"""
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@ -17,8 +14,6 @@ class LigneLivraison(BaseModel):
class LivraisonCreateRequest(BaseModel):
"""Création d'une livraison"""
client_id: str
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
@ -44,8 +39,6 @@ class LivraisonCreateRequest(BaseModel):
class LivraisonUpdateRequest(BaseModel):
"""Modification d'une livraison existante"""
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
lignes: Optional[List[LigneLivraison]] = None

View file

@ -1,10 +1,8 @@
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
from typing import List, Optional, Dict, ClassVar, Any
from datetime import date, datetime
from enum import Enum, IntEnum
from pydantic import BaseModel, EmailStr
from enum import Enum
from schemas.documents.documents import TypeDocument
class StatutSignature(str, Enum):
EN_ATTENTE = "EN_ATTENTE"
ENVOYE = "ENVOYE"
@ -12,6 +10,7 @@ class StatutSignature(str, Enum):
REFUSE = "REFUSE"
EXPIRE = "EXPIRE"
class SignatureRequest(BaseModel):
doc_id: str
type_doc: TypeDocument

View file

@ -2,9 +2,8 @@ from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from schemas.tiers.contact import Contact
class ClientResponse(BaseModel):
"""Modèle de réponse client simplifié (pour listes)"""
class ClientResponse(BaseModel):
numero: Optional[str] = None
intitule: Optional[str] = None
adresse: Optional[str] = None
@ -681,12 +680,6 @@ class ClientCreateRequest(BaseModel):
class ClientUpdateRequest(BaseModel):
"""
Modèle pour modification d'un client existant
TOUS les champs de ClientCreateRequest sont modifiables
TOUS optionnels (seuls les champs fournis sont modifiés)
"""
intitule: Optional[str] = Field(None, max_length=69)
qualite: Optional[str] = Field(None, max_length=17)
classement: Optional[str] = Field(None, max_length=17)

View file

@ -3,8 +3,6 @@ from typing import Optional, ClassVar
class Contact(BaseModel):
"""Contact associé à un tiers"""
numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)")
contact_numero: Optional[int] = Field(
None, description="Numéro unique du contact (CT_No)"
@ -52,8 +50,6 @@ class Contact(BaseModel):
class ContactCreate(BaseModel):
"""Données pour créer ou modifier un contact"""
numero: str = Field(..., description="Code du client parent (obligatoire)")
civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société")
@ -100,8 +96,6 @@ class ContactCreate(BaseModel):
class ContactUpdate(BaseModel):
"""Données pour modifier un contact (tous champs optionnels)"""
civilite: Optional[str] = None
nom: Optional[str] = None
prenom: Optional[str] = None

View file

@ -305,8 +305,6 @@ class FournisseurCreateAPIRequest(BaseModel):
class FournisseurUpdateRequest(BaseModel):
"""Modèle pour modification d'un fournisseur existant"""
intitule: Optional[str] = Field(None, min_length=1, max_length=69)
adresse: Optional[str] = Field(None, max_length=35)
code_postal: Optional[str] = Field(None, max_length=9)

View file

@ -1,12 +1,10 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from schemas.tiers.contact import Contact
from enum import Enum, IntEnum
from enum import IntEnum
class TypeTiersInt(IntEnum):
"""CT_Type - Type de tiers"""
CLIENT = 0
FOURNISSEUR = 1
SALARIE = 2

View file

@ -2,8 +2,6 @@ from enum import Enum
class TypeTiers(str, Enum):
"""Types de tiers possibles"""
ALL = "all"
CLIENT = "client"
FOURNISSEUR = "fournisseur"

View file

@ -3,8 +3,6 @@ from typing import Optional
class UserResponse(BaseModel):
"""Modèle de réponse pour un utilisateur"""
id: str
email: str
nom: str

View file

@ -5,7 +5,7 @@ import jwt
import secrets
import hashlib
SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret
SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
@ -14,38 +14,26 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
"""Hash un mot de passe avec bcrypt"""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Vérifie un mot de passe contre son hash"""
return pwd_context.verify(plain_password, hashed_password)
def generate_verification_token() -> str:
"""Génère un token de vérification email sécurisé"""
return secrets.token_urlsafe(32)
def generate_reset_token() -> str:
"""Génère un token de réinitialisation mot de passe"""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""Hash un refresh token pour stockage en DB"""
return hashlib.sha256(token.encode()).hexdigest()
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Crée un JWT access token
Args:
data: Payload (doit contenir 'sub' = user_id)
expires_delta: Durée de validité personnalisée
"""
to_encode = data.copy()
if expires_delta:
@ -60,12 +48,6 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
def create_refresh_token(user_id: str) -> str:
"""
Crée un refresh token (JWT long terme)
Returns:
Token JWT non hashé (à hasher avant stockage DB)
"""
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {
@ -81,12 +63,6 @@ def create_refresh_token(user_id: str) -> str:
def decode_token(token: str) -> Optional[Dict]:
"""
Décode et valide un JWT
Returns:
Payload si valide, None sinon
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
@ -97,12 +73,6 @@ def decode_token(token: str) -> Optional[Dict]:
def validate_password_strength(password: str) -> tuple[bool, str]:
"""
Valide la robustesse d'un mot de passe
Returns:
(is_valid, error_message)
"""
if len(password) < 8:
return False, "Le mot de passe doit contenir au moins 8 caractères"

View file

@ -8,11 +8,8 @@ logger = logging.getLogger(__name__)
class AuthEmailService:
"""Service d'envoi d'emails pour l'authentification"""
@staticmethod
def _send_email(to: str, subject: str, html_body: str) -> bool:
"""Envoi SMTP générique"""
try:
msg = MIMEMultipart()
msg["From"] = settings.smtp_from
@ -41,14 +38,6 @@ class AuthEmailService:
@staticmethod
def send_verification_email(email: str, token: str, base_url: str) -> bool:
"""
Envoie l'email de vérification avec lien de confirmation
Args:
email: Email du destinataire
token: Token de vérification
base_url: URL de base de l'API (ex: https://api.votredomaine.com)
"""
verification_link = f"{base_url}/auth/verify-email?token={token}"
html_body = f"""
@ -112,14 +101,6 @@ class AuthEmailService:
@staticmethod
def send_password_reset_email(email: str, token: str, base_url: str) -> bool:
"""
Envoie l'email de réinitialisation de mot de passe
Args:
email: Email du destinataire
token: Token de reset
base_url: URL de base du frontend
"""
reset_link = f"{base_url}/reset?token={token}"
html_body = f"""
@ -183,7 +164,6 @@ class AuthEmailService:
@staticmethod
def send_password_changed_notification(email: str) -> bool:
"""Notification après changement de mot de passe réussi"""
html_body = """
<!DOCTYPE html>
<html>

View file

@ -1,4 +1,3 @@
from typing import Dict
from config import settings
import logging
@ -14,6 +13,7 @@ from database import EmailLog, StatutEmail as StatutEmailEnum
logger = logging.getLogger(__name__)
async def universign_envoyer(
doc_id: str,
pdf_bytes: bytes,
@ -22,7 +22,6 @@ async def universign_envoyer(
doc_data: Dict,
session: AsyncSession,
) -> Dict:
from email_queue import email_queue
try:

View file

@ -1,24 +1,16 @@
from typing import Optional, Union
def normaliser_type_tiers(type_tiers: Union[str, int, None]) -> Optional[str]:
if type_tiers is None:
return None
# Conversion int → string
mapping_int = {
0: "client",
1: "fournisseur",
2: "prospect",
3: "all"
}
mapping_int = {0: "client", 1: "fournisseur", 2: "prospect", 3: "all"}
# Si c'est un int, on convertit
if isinstance(type_tiers, int):
return mapping_int.get(type_tiers, "all")
# Si c'est une string qui ressemble à un int
if isinstance(type_tiers, str) and type_tiers.isdigit():
return mapping_int.get(int(type_tiers), "all")
# Sinon on retourne tel quel (string normale)
return type_tiers.lower() if isinstance(type_tiers, str) else None