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 ( from schemas import (
TiersDetails, TiersDetails,
TypeTiers,
BaremeRemiseResponse, BaremeRemiseResponse,
UserResponse, UserResponse,
ClientCreateRequest, ClientCreateRequest,
ClientDetails, ClientDetails,
ClientResponse,
ClientUpdateRequest, ClientUpdateRequest,
FournisseurCreateAPIRequest, FournisseurCreateAPIRequest,
FournisseurDetails, FournisseurDetails,
@ -60,18 +58,15 @@ from schemas import (
LivraisonUpdateRequest, LivraisonUpdateRequest,
SignatureRequest, SignatureRequest,
StatutSignature, StatutSignature,
TypeTiersInt,
ArticleCreateRequest, ArticleCreateRequest,
ArticleResponse, ArticleResponse,
ArticleUpdateRequest, ArticleUpdateRequest,
ArticleListResponse,
EntreeStockRequest, EntreeStockRequest,
SortieStockRequest, SortieStockRequest,
MouvementStockResponse, MouvementStockResponse,
RelanceDevisRequest, RelanceDevisRequest,
FamilleResponse, FamilleResponse,
FamilleCreateRequest, FamilleCreateRequest,
FamilleListResponse,
ContactCreate, ContactCreate,
ContactUpdate, ContactUpdate,
) )
@ -125,7 +120,6 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
async def universign_envoyer( async def universign_envoyer(
doc_id: str, doc_id: str,
pdf_bytes: bytes, pdf_bytes: bytes,
@ -146,57 +140,67 @@ async def universign_envoyer(
if not pdf_bytes or len(pdf_bytes) == 0: if not pdf_bytes or len(pdf_bytes) == 0:
raise Exception("Le PDF généré est vide") raise Exception("Le PDF généré est vide")
# ÉTAPE 1: Création transaction
response = requests.post( response = requests.post(
f"{api_url}/transactions", f"{api_url}/transactions",
auth=auth, 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, timeout=30,
) )
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"Erreur création transaction: {response.status_code}") raise Exception(f"Erreur création transaction: {response.status_code}")
transaction_id = response.json().get("id") transaction_id = response.json().get("id")
# ÉTAPE 2: Upload PDF files = {
files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")} "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) response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=60)
if response.status_code not in [200, 201]: if response.status_code not in [200, 201]:
raise Exception(f"Erreur upload fichier: {response.status_code}") raise Exception(f"Erreur upload fichier: {response.status_code}")
file_id = response.json().get("id") file_id = response.json().get("id")
# ÉTAPE 3: Ajout document
response = requests.post( response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents", 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]: if response.status_code not in [200, 201]:
raise Exception(f"Erreur ajout document: {response.status_code}") raise Exception(f"Erreur ajout document: {response.status_code}")
document_id = response.json().get("id") document_id = response.json().get("id")
# ÉTAPE 4: Création champ signature
response = requests.post( response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", 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]: if response.status_code not in [200, 201]:
raise Exception(f"Erreur création champ: {response.status_code}") raise Exception(f"Erreur création champ: {response.status_code}")
field_id = response.json().get("id") field_id = response.json().get("id")
# ÉTAPE 5: Liaison signataire
response = requests.post( response = requests.post(
f"{api_url}/transactions/{transaction_id}/signatures", 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]: if response.status_code not in [200, 201]:
raise Exception(f"Erreur liaison signataire: {response.status_code}") raise Exception(f"Erreur liaison signataire: {response.status_code}")
# ÉTAPE 6: Démarrage response = requests.post(
response = requests.post(f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30) f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30
)
if response.status_code not in [200, 201]: if response.status_code not in [200, 201]:
raise Exception(f"Erreur démarrage: {response.status_code}") raise Exception(f"Erreur démarrage: {response.status_code}")
final_data = response.json() final_data = response.json()
# Récupération URL
signer_url = "" signer_url = ""
if final_data.get("actions"): if final_data.get("actions"):
for action in final_data["actions"]: for action in final_data["actions"]:
@ -211,9 +215,14 @@ async def universign_envoyer(
if not signer_url: if not signer_url:
raise ValueError("URL de signature non retournée par Universign") raise ValueError("URL de signature non retournée par Universign")
# Préparation email
template = templates_signature_email["demande_signature"] 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 = { variables = {
"NOM_SIGNATAIRE": nom, "NOM_SIGNATAIRE": nom,
"TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"), "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: async def universign_statut(transaction_id: str) -> dict:
import requests import requests
try: try:
response = requests.get( response = requests.get(
f"{settings.universign_api_url}/transactions/{transaction_id}", 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: if response.status_code == 200:
data = response.json() data = response.json()
statut_map = {"draft": "EN_ATTENTE", "started": "EN_ATTENTE", "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE"} statut_map = {
return {"statut": statut_map.get(data.get("state"), "EN_ATTENTE"), "date_signature": data.get("completed_at")} "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"} return {"statut": "ERREUR"}
except Exception as e: except Exception as e:
logger.error(f"Erreur statut Universign: {e}") logger.error(f"Erreur statut Universign: {e}")
@ -817,6 +837,7 @@ async def envoyer_devis_email(
logger.error(f"Erreur envoi email: {e}") logger.error(f"Erreur envoi email: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.put("/document/{type_doc}/{numero}/statut", tags=["Documents"]) @app.put("/document/{type_doc}/{numero}/statut", tags=["Documents"])
async def changer_statut_document( async def changer_statut_document(
type_doc: int = Path( 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: case 10 | 1 | 20 | 2 | 30 | 3 | 40 | 4 | 50 | 5 | 60 | 6:
if statut_actuel >= 2: if statut_actuel >= 2:
type_names = { type_names = {
10: "la commande", 1: "la commande", 10: "la commande",
20: "la préparation", 2: "la préparation", 1: "la commande",
30: "la livraison", 3: "la livraison", 20: "la préparation",
40: "le retour", 4: "le retour", 2: "la préparation",
50: "l'avoir", 5: "l'avoir", 30: "la livraison",
60: "la facture", 6: "la facture" 3: "la livraison",
40: "le retour",
4: "le retour",
50: "l'avoir",
5: "l'avoir",
60: "la facture",
6: "la facture",
} }
raise HTTPException( raise HTTPException(
400, 400,
@ -900,15 +927,21 @@ async def changer_statut_document(
f"ne peut plus changer de statut (statut actuel ≥ 2)", 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( resultat = sage_client.changer_statut_document(
document_type_code=document_type_int, document_type_code=document_type_int,
numero=numero, 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 { return {
"success": True, "success": True,
@ -926,6 +959,7 @@ async def changer_statut_document(
logger.error(f"Erreur changement statut document {numero}: {e}") logger.error(f"Erreur changement statut document {numero}: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.get("/commandes/{id}", tags=["Commandes"]) @app.get("/commandes/{id}", tags=["Commandes"])
async def lire_commande(id: str): async def lire_commande(id: str):
try: try:
@ -2950,9 +2984,7 @@ async def lister_utilisateurs_debug(
) )
) )
logger.info( logger.info(f"Liste utilisateurs retournée: {len(users_response)} résultat(s)")
f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)"
)
return users_response return users_response
@ -2999,56 +3031,14 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)
raise HTTPException(500, str(e)) 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"]) @app.post("/tiers/{numero}/contacts", response_model=Contact, tags=["Contacts"])
async def creer_contact(numero: str, contact: ContactCreate): async def creer_contact(numero: str, contact: ContactCreate):
try: try:
try: try:
sage_client.lire_tiers(numero) sage_client.lire_tiers(numero)
except: except HTTPException:
raise
except Exception:
raise HTTPException(404, f"Tiers {numero} non trouvé") raise HTTPException(404, f"Tiers {numero} non trouvé")
if contact.numero != numero: 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"]) @app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"])
async def supprimer_contact(numero: str, contact_numero: int): async def supprimer_contact(numero: str, contact_numero: int):
try: 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é"} return {"success": True, "message": f"Contact {contact_numero} supprimé"}
except Exception as e: except Exception as e:
logger.error(f"Erreur suppression contact: {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 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 ! from datetime import datetime
security = HTTPBearer() security = HTTPBearer()
@ -16,7 +16,6 @@ async def get_current_user(
) -> User: ) -> User:
token = credentials.credentials token = credentials.credentials
# Décoder le token
payload = decode_token(token) payload = decode_token(token)
if not payload: if not payload:
raise HTTPException( raise HTTPException(
@ -25,7 +24,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Vérifier le type
if payload.get("type") != "access": if payload.get("type") != "access":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
@ -33,7 +31,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Extraire user_id
user_id: str = payload.get("sub") user_id: str = payload.get("sub")
if not user_id: if not user_id:
raise HTTPException( raise HTTPException(
@ -42,7 +39,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Charger l'utilisateur
result = await session.execute(select(User).where(User.id == user_id)) result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -53,7 +49,6 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# 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, detail="Compte désactivé" 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.", 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(): 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,
@ -79,10 +73,6 @@ 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
Utile pour des endpoints publics avec contenu enrichi si authentifié
"""
if not credentials: if not credentials:
return None return None
@ -93,15 +83,6 @@ async def get_current_user_optional(
def require_role(*allowed_roles: str): def require_role(*allowed_roles: str):
"""
Décorateur pour restreindre l'accès par rôle
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: 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(

View file

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

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, get_session,
close_db, close_db,
) )
from database.models.generic_model import (
from database.models import (
Base,
EmailLog,
SignatureLog,
WorkflowLog,
CacheMetadata, CacheMetadata,
AuditLog, AuditLog,
StatutEmail,
StatutSignature,
User,
RefreshToken, RefreshToken,
LoginAttempt, 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__ = [ __all__ = [
"engine", "engine",
@ -37,4 +39,5 @@ __all__ = [
"User", "User",
"RefreshToken", "RefreshToken",
"LoginAttempt", "LoginAttempt",
"SageGatewayConfig",
] ]

View file

@ -25,10 +25,6 @@ async_session_factory = async_sessionmaker(
async def init_db(): 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: try:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
@ -42,7 +38,6 @@ async def init_db():
async def get_session() -> AsyncSession: async def get_session() -> AsyncSession:
"""Dependency FastAPI pour obtenir une session DB"""
async with async_session_factory() as session: async with async_session_factory() as session:
try: try:
yield session yield session
@ -51,6 +46,5 @@ async def get_session() -> AsyncSession:
async def close_db(): async def close_db():
"""Ferme proprement toutes les connexions"""
await engine.dispose() await engine.dispose()
logger.info(" Connexions DB fermées") 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 ULTRA_DEBUG = True
def debug_log(message: str, level: str = "INFO"): def debug_log(message: str, level: str = "INFO"):
if ULTRA_DEBUG: if ULTRA_DEBUG:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3] timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
@ -29,13 +30,12 @@ def debug_log(message: str, level: str = "INFO"):
"ERROR": "[ERROR]", "ERROR": "[ERROR]",
"WARN": "[WARN]", "WARN": "[WARN]",
"STEP": "[STEP]", "STEP": "[STEP]",
"DATA": "[DATA]" "DATA": "[DATA]",
}.get(level, "") }.get(level, "")
logger.info(f"{prefix} [{timestamp}] {message}") logger.info(f"{prefix} [{timestamp}] {message}")
class EmailQueue: class EmailQueue:
def __init__(self): def __init__(self):
self.queue = queue.Queue() self.queue = queue.Queue()
self.workers = [] self.workers = []
@ -65,13 +65,14 @@ class EmailQueue:
try: try:
self.queue.join() self.queue.join()
logger.info(" Queue email arrêtée proprement") logger.info(" Queue email arrêtée proprement")
except: except Exception:
logger.warning("⚠️ Timeout lors de l'arrêt de la queue") logger.warning(" Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str): def enqueue(self, email_log_id: str):
"""Ajoute un email dans la queue"""
self.queue.put(email_log_id) 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): def _worker(self):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
@ -84,7 +85,9 @@ class EmailQueue:
while self.running: while self.running:
try: try:
email_log_id = self.queue.get(timeout=1) 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)) 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) logger.error(f" Erreur worker {worker_name}: {e}", exc_info=True)
try: try:
self.queue.task_done() self.queue.task_done()
except: except Exception:
pass pass
finally: finally:
loop.close() loop.close()
@ -122,7 +125,7 @@ class EmailQueue:
logger.error(f" Email log {email_log_id} introuvable en DB") logger.error(f" Email log {email_log_id} introuvable en DB")
return 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" → Destinataire: {email_log.destinataire}")
debug_log(f" → Sujet: {email_log.sujet[:50]}...") debug_log(f" → Sujet: {email_log.sujet[:50]}...")
debug_log(f" → Tentative: {email_log.nb_tentatives + 1}") debug_log(f" → Tentative: {email_log.nb_tentatives + 1}")
@ -138,7 +141,9 @@ class EmailQueue:
email_log.statut = StatutEmail.ENVOYE email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now() email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None email_log.derniere_erreur = None
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: except Exception as e:
error_msg = str(e) error_msg = str(e)
@ -157,15 +162,20 @@ class EmailQueue:
timer.daemon = True timer.daemon = True
timer.start() 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: 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() await session.commit()
debug_log(f"═══ FIN TRAITEMENT EMAIL {email_log_id} ═══", "STEP") debug_log(f"═══ FIN TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
async def _send_with_retry(self, email_log): async def _send_with_retry(self, email_log):
debug_log("Construction du message MIME...", "STEP") debug_log("Construction du message MIME...", "STEP")
msg = MIMEMultipart() msg = MIMEMultipart()
@ -173,7 +183,7 @@ class EmailQueue:
msg["To"] = email_log.destinataire msg["To"] = email_log.destinataire
msg["Subject"] = email_log.sujet 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" → From: {settings.smtp_from}")
debug_log(f" → To: {email_log.destinataire}") debug_log(f" → To: {email_log.destinataire}")
debug_log(f" → Subject: {email_log.sujet}") debug_log(f" → Subject: {email_log.sujet}")
@ -205,7 +215,10 @@ class EmailQueue:
f'attachment; filename="{doc_id}.pdf"' f'attachment; filename="{doc_id}.pdf"'
) )
msg.attach(part) 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: except Exception as e:
debug_log(f"Erreur génération PDF {doc_id}: {e}", "ERROR") 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" → Host: {settings.smtp_host}")
debug_log(f" → Port: {settings.smtp_port}") debug_log(f" → Port: {settings.smtp_port}")
debug_log(f" → User: {settings.smtp_user}") 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" → From: {settings.smtp_from}")
debug_log(f" → TLS: {settings.smtp_use_tls}") debug_log(f" → TLS: {settings.smtp_use_tls}")
debug_log(f" → To: {msg['To']}") debug_log(f" → To: {msg['To']}")
@ -235,11 +250,15 @@ class EmailQueue:
# ═══ ÉTAPE 1: RÉSOLUTION DNS ═══ # ═══ ÉTAPE 1: RÉSOLUTION DNS ═══
debug_log("ÉTAPE 1/7: Résolution DNS...", "STEP") debug_log("ÉTAPE 1/7: Résolution DNS...", "STEP")
try: 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") debug_log(f" → DNS résolu: {ip_addresses[0][4]}", "SUCCESS")
except socket.gaierror as e: except socket.gaierror as e:
debug_log(f" → ÉCHEC DNS: {e}", "ERROR") 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 ═══ # ═══ ÉTAPE 2: CONNEXION TCP ═══
debug_log("ÉTAPE 2/7: Connexion TCP...", "STEP") debug_log("ÉTAPE 2/7: Connexion TCP...", "STEP")
@ -247,21 +266,25 @@ class EmailQueue:
try: try:
server = smtplib.SMTP( server = smtplib.SMTP(
settings.smtp_host, settings.smtp_host, settings.smtp_port, timeout=30
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 connect_time = time.time() - start_time
debug_log(f" → Connexion établie en {connect_time:.2f}s", "SUCCESS") debug_log(f" → Connexion établie en {connect_time:.2f}s", "SUCCESS")
except socket.timeout: except socket.timeout:
debug_log(f" → TIMEOUT connexion (>30s)", "ERROR") debug_log(" → TIMEOUT connexion (>30s)", "ERROR")
raise Exception(f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}") raise Exception(
f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}"
)
except ConnectionRefusedError: except ConnectionRefusedError:
debug_log(f" → CONNEXION REFUSÉE", "ERROR") debug_log(" → CONNEXION REFUSÉE", "ERROR")
raise Exception(f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}") raise Exception(
f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}"
)
except Exception as e: except Exception as e:
debug_log(f" → ERREUR CONNEXION: {type(e).__name__}: {e}", "ERROR") debug_log(f" → ERREUR CONNEXION: {type(e).__name__}: {e}", "ERROR")
raise raise
@ -281,15 +304,19 @@ class EmailQueue:
debug_log("ÉTAPE 4/7: Négociation STARTTLS...", "STEP") debug_log("ÉTAPE 4/7: Négociation STARTTLS...", "STEP")
try: try:
# Vérifier si le serveur supporte STARTTLS # Vérifier si le serveur supporte STARTTLS
if server.has_extn('STARTTLS'): if server.has_extn("STARTTLS"):
debug_log(" → Serveur supporte STARTTLS", "SUCCESS") debug_log(" → Serveur supporte STARTTLS", "SUCCESS")
# Créer un contexte SSL # Créer un contexte SSL
context = ssl.create_default_context() 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) 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 # Re-EHLO après STARTTLS
server.ehlo() server.ehlo()
@ -315,27 +342,42 @@ class EmailQueue:
try: try:
# Lister les méthodes d'auth supportées # Lister les méthodes d'auth supportées
if server.has_extn('AUTH'): if server.has_extn("AUTH"):
auth_methods = server.esmtp_features.get('auth', '') auth_methods = server.esmtp_features.get("auth", "")
debug_log(f" → Méthodes AUTH supportées: {auth_methods}") debug_log(f" → Méthodes AUTH supportées: {auth_methods}")
server.login(settings.smtp_user, settings.smtp_password) server.login(settings.smtp_user, settings.smtp_password)
debug_log(" → Authentification RÉUSSIE", "SUCCESS") debug_log(" → Authentification RÉUSSIE", "SUCCESS")
except smtplib.SMTPAuthenticationError as e: 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" → 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 # Diagnostic spécifique selon le code d'erreur
if e.smtp_code == 535: 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: 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: 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: except smtplib.SMTPException as e:
debug_log(f" → ERREUR SMTP AUTH: {e}", "ERROR") debug_log(f" → ERREUR SMTP AUTH: {e}", "ERROR")
@ -364,8 +406,13 @@ class EmailQueue:
debug_log(f" → DESTINATAIRE REFUSÉ: {e.recipients}", "ERROR") debug_log(f" → DESTINATAIRE REFUSÉ: {e.recipients}", "ERROR")
raise Exception(f"Destinataire refusé: {e.recipients}") raise Exception(f"Destinataire refusé: {e.recipients}")
except smtplib.SMTPSenderRefused as e: except smtplib.SMTPSenderRefused as e:
debug_log(f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR") debug_log(
debug_log(f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer", "ERROR") 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}") raise Exception(f"Expéditeur refusé: {e.smtp_code} - {e.smtp_error}")
except smtplib.SMTPDataError as e: except smtplib.SMTPDataError as e:
debug_log(f" → ERREUR DATA: {e.smtp_code} - {e.smtp_error}", "ERROR") debug_log(f" → ERREUR DATA: {e.smtp_code} - {e.smtp_error}", "ERROR")
@ -376,7 +423,7 @@ class EmailQueue:
try: try:
server.quit() server.quit()
debug_log(" → Connexion fermée proprement", "SUCCESS") debug_log(" → Connexion fermée proprement", "SUCCESS")
except: except Exception:
pass pass
debug_log("═══════════════════════════════════════════", "SUCCESS") debug_log("═══════════════════════════════════════════", "SUCCESS")
@ -393,13 +440,12 @@ class EmailQueue:
if server: if server:
try: try:
server.quit() server.quit()
except: except Exception:
pass pass
raise Exception(f"Erreur SMTP: {str(e)}") raise Exception(f"Erreur SMTP: {str(e)}")
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
if not self.sage_client: if not self.sage_client:
logger.error(" sage_client non configuré") logger.error(" sage_client non configuré")
raise Exception("sage_client non disponible") raise Exception("sage_client non disponible")
@ -470,9 +516,7 @@ class EmailQueue:
# FIX: Gérer les valeurs None correctement # FIX: Gérer les valeurs None correctement
designation = ( designation = (
ligne.get("designation") ligne.get("designation") or ligne.get("designation_article") or ""
or ligne.get("designation_article")
or ""
) )
if designation: if designation:
designation = str(designation)[:50] designation = str(designation)[:50]
@ -481,8 +525,16 @@ class EmailQueue:
pdf.drawString(2 * cm, y, designation) pdf.drawString(2 * cm, y, designation)
pdf.drawString(10 * cm, y, str(ligne.get("quantite") or 0)) 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(
pdf.drawString(15 * cm, y, f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}") 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 -= 0.6 * cm
y -= 1 * 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 asyncio
import sys import sys
from pathlib import Path from pathlib import Path
# Ajouter le répertoire parent au path pour les imports
sys.path.insert(0, str(Path(__file__).parent)) 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 import logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -22,10 +12,9 @@ logger = logging.getLogger(__name__)
async def main(): async def main():
"""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:
@ -33,21 +22,21 @@ async def main():
await init_db() await init_db()
print("\n Base de données créée avec succès!") print("\n Base de données créée avec succès!")
print(f" Fichier: sage_dataven.db") print(" Fichier: sage_dataven.db")
print("\n📊 Tables créées:") print("\nTables créées:")
print(" ├─ email_logs (Journalisation emails)") print(" ├─ email_logs (Journalisation emails)")
print(" ├─ signature_logs (Suivi signatures Universign)") print(" ├─ signature_logs (Suivi signatures Universign)")
print(" ├─ workflow_logs (Transformations documents)") print(" ├─ workflow_logs (Transformations documents)")
print(" ├─ cache_metadata (Métadonnées cache)") print(" ├─ cache_metadata (Métadonnées cache)")
print(" └─ audit_logs (Journal d'audit)") print(" └─ audit_logs (Journal d'audit)")
print("\n📝 Prochaines étapes:") print("\nProchaines étapes:")
print(" 1. Configurer le fichier .env avec vos credentials") print(" 1. Configurer le fichier .env avec vos credentials")
print(" 2. Lancer la gateway Windows sur la machine Sage") print(" 2. Lancer la gateway Windows sur la machine Sage")
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000") print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
print(" 4. Ou avec Docker: docker-compose up -d") print(" 4. Ou avec Docker: docker-compose up -d")
print(" 5. Tester: http://votre-vps:8000/docs") print(" 5. Tester: http://0.0.0.0:8000/docs")
print("\n" + "=" * 60 + "\n") print("\n" + "=" * 60 + "\n")
return True return True

View file

@ -21,14 +21,12 @@ from security.auth import (
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
from config import settings from config import settings
from datetime import datetime
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"]) router = APIRouter(prefix="/auth", tags=["Authentication"])
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(..., min_length=8) password: str = Field(..., min_length=8)
@ -45,7 +43,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 = 86400 # 30 minutes en secondes expires_in: int = 86400
class RefreshTokenRequest(BaseModel): class RefreshTokenRequest(BaseModel):
@ -69,8 +67,6 @@ class ResendVerificationRequest(BaseModel):
email: EmailStr email: EmailStr
async def log_login_attempt( async def log_login_attempt(
session: AsyncSession, session: AsyncSession,
email: str, email: str,
@ -79,7 +75,6 @@ async def log_login_attempt(
success: bool, success: bool,
failure_reason: Optional[str] = None, failure_reason: Optional[str] = None,
): ):
"""Enregistre une tentative de connexion"""
attempt = LoginAttempt( attempt = LoginAttempt(
email=email, email=email,
ip_address=ip, ip_address=ip,
@ -95,13 +90,12 @@ async def log_login_attempt(
async def check_rate_limit( async def check_rate_limit(
session: AsyncSession, email: str, ip: str session: AsyncSession, email: str, ip: str
) -> tuple[bool, str]: ) -> tuple[bool, str]:
time_window = datetime.now() - timedelta(minutes=15) time_window = datetime.now() - timedelta(minutes=15)
result = await session.execute( result = await session.execute(
select(LoginAttempt).where( select(LoginAttempt).where(
LoginAttempt.email == email, LoginAttempt.email == email,
LoginAttempt.success == False, LoginAttempt.success,
LoginAttempt.timestamp >= time_window, LoginAttempt.timestamp >= time_window,
) )
) )
@ -113,15 +107,12 @@ async def check_rate_limit(
return True, "" return True, ""
@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),
): ):
result = await session.execute(select(User).where(User.email == data.email)) result = await session.execute(select(User).where(User.email == data.email))
existing_user = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
@ -171,10 +162,6 @@ async def register(
@router.get("/verify-email") @router.get("/verify-email")
async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): 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)) result = await session.execute(select(User).where(User.verification_token == token))
user = result.scalar_one_or_none() 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( async def verify_email_post(
data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) 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( result = await session.execute(
select(User).where(User.verification_token == data.token) select(User).where(User.verification_token == data.token)
) )
@ -344,7 +327,6 @@ async def login(
detail="Compte temporairement verrouillé", detail="Compte temporairement verrouillé",
) )
user.failed_login_attempts = 0 user.failed_login_attempts = 0
user.locked_until = None user.locked_until = None
user.last_login = datetime.now() user.last_login = datetime.now()
@ -374,7 +356,7 @@ 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=86400, # 30 minutes expires_in=86400,
) )
@ -382,7 +364,6 @@ async def login(
async def refresh_access_token( async def refresh_access_token(
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
): ):
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(
@ -396,7 +377,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, not RefreshToken.is_revoked,
) )
) )
token_record = result.scalar_one_or_none() token_record = result.scalar_one_or_none()
@ -429,7 +410,7 @@ async def refresh_access_token(
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,
expires_in=86400, expires_in=86400,
) )
@ -440,7 +421,6 @@ async def forgot_password(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
result = await session.execute(select(User).where(User.email == data.email.lower())) result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -474,7 +454,6 @@ async def forgot_password(
async def reset_password( async def reset_password(
data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) data: ResetPasswordRequest, session: AsyncSession = Depends(get_session)
): ):
result = await session.execute(select(User).where(User.reset_token == data.token)) result = await session.execute(select(User).where(User.reset_token == data.token))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -517,7 +496,6 @@ async def logout(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
token_hash = hash_token(data.refresh_token) token_hash = hash_token(data.refresh_token)
result = await session.execute( result = await session.execute(
@ -539,7 +517,6 @@ async def logout(
@router.get("/me") @router.get("/me")
async def get_current_user_info(user: User = Depends(get_current_user)): async def get_current_user_info(user: User = Depends(get_current_user)):
return { return {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,

View file

@ -1,5 +1,5 @@
import requests import requests
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional
from config import settings from config import settings
import logging import logging
@ -16,7 +16,6 @@ class SageGatewayClient:
self.timeout = 30 self.timeout = 30
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict: def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
"""POST avec retry automatique"""
import time import time
for attempt in range(retries): for attempt in range(retries):
@ -38,7 +37,6 @@ class SageGatewayClient:
time.sleep(2**attempt) time.sleep(2**attempt)
def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict: def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict:
"""GET avec retry automatique"""
import time import time
for attempt in range(retries): for attempt in range(retries):
@ -60,27 +58,21 @@ class SageGatewayClient:
time.sleep(2**attempt) time.sleep(2**attempt)
def lister_clients(self, filtre: str = "") -> List[Dict]: 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", []) return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
def lire_client(self, code: str) -> Optional[Dict]: def lire_client(self, code: str) -> Optional[Dict]:
"""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")
def lister_articles(self, filtre: str = "") -> List[Dict]: 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", []) return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
def lire_article(self, ref: str) -> Optional[Dict]: 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") return self._post("/sage/articles/get", {"code": ref}).get("data")
def creer_devis(self, devis_data: Dict) -> Dict: def creer_devis(self, devis_data: Dict) -> Dict:
"""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", {})
def lire_devis(self, numero: str) -> Optional[Dict]: def lire_devis(self, numero: str) -> Optional[Dict]:
"""Lecture d'un devis"""
return self._post("/sage/devis/get", {"code": numero}).get("data") return self._post("/sage/devis/get", {"code": numero}).get("data")
def lister_devis( def lister_devis(
@ -94,7 +86,9 @@ class SageGatewayClient:
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_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: try:
r = requests.post( r = requests.post(
f"{self.url}/sage/document/statut", f"{self.url}/sage/document/statut",
@ -113,7 +107,6 @@ class SageGatewayClient:
raise raise
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"""
return self._post( return self._post(
"/sage/documents/get", {"numero": numero, "type_doc": type_doc} "/sage/documents/get", {"numero": numero, "type_doc": type_doc}
).get("data") ).get("data")
@ -141,7 +134,6 @@ class SageGatewayClient:
def mettre_a_jour_champ_libre( def mettre_a_jour_champ_libre(
self, doc_id: str, type_doc: int, nom_champ: str, valeur: str self, doc_id: str, type_doc: int, nom_champ: str, valeur: str
) -> bool: ) -> bool:
"""Mise à jour d'un champ libre"""
resp = self._post( resp = self._post(
"/sage/documents/champ-libre", "/sage/documents/champ-libre",
{ {
@ -170,60 +162,28 @@ class SageGatewayClient:
return self._post("/sage/factures/list", payload).get("data", []) return self._post("/sage/factures/list", payload).get("data", [])
def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool: 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( resp = self._post(
"/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc} "/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc}
) )
return resp.get("success", False) return resp.get("success", False)
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"""
return self._post("/sage/contact/read", {"code": code_client}).get("data") return self._post("/sage/contact/read", {"code": code_client}).get("data")
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"""
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)
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]: 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", []) return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", [])
def lire_prospect(self, code: str) -> Optional[Dict]: def lire_prospect(self, code: str) -> Optional[Dict]:
"""Lecture d'un prospect par code"""
return self._post("/sage/prospects/get", {"code": code}).get("data") return self._post("/sage/prospects/get", {"code": code}).get("data")
def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: 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", []) return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", [])
def lire_fournisseur(self, code: str) -> Optional[Dict]: def lire_fournisseur(self, code: str) -> Optional[Dict]:
"""Lecture d'un fournisseur par code"""
return self._post("/sage/fournisseurs/get", {"code": code}).get("data") return self._post("/sage/fournisseurs/get", {"code": code}).get("data")
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
@ -238,43 +198,36 @@ class SageGatewayClient:
def lister_avoirs( def lister_avoirs(
self, limit: int = 100, statut: Optional[int] = None self, limit: int = 100, statut: Optional[int] = None
) -> List[Dict]: ) -> List[Dict]:
"""Liste tous les avoirs"""
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/avoirs/list", payload).get("data", []) return self._post("/sage/avoirs/list", payload).get("data", [])
def lire_avoir(self, numero: str) -> Optional[Dict]: 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") return self._post("/sage/avoirs/get", {"code": numero}).get("data")
def lister_livraisons( def lister_livraisons(
self, limit: int = 100, statut: Optional[int] = None self, limit: int = 100, statut: Optional[int] = None
) -> List[Dict]: ) -> List[Dict]:
"""Liste tous les bons de livraison"""
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/livraisons/list", payload).get("data", []) return self._post("/sage/livraisons/list", payload).get("data", [])
def lire_livraison(self, numero: str) -> Optional[Dict]: 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") return self._post("/sage/livraisons/get", {"code": numero}).get("data")
def refresh_cache(self) -> Dict: def refresh_cache(self) -> Dict:
"""Force le rafraîchissement du cache Windows"""
return self._post("/sage/cache/refresh") return self._post("/sage/cache/refresh")
def get_cache_info(self) -> Dict: def get_cache_info(self) -> Dict:
"""Récupère les infos du cache Windows"""
return self._get("/sage/cache/info").get("data", {}) return self._get("/sage/cache/info").get("data", {})
def health(self) -> dict: def health(self) -> dict:
"""Health check de la gateway Windows"""
try: try:
r = requests.get(f"{self.url}/health", timeout=5) r = requests.get(f"{self.url}/health", timeout=5)
return r.json() return r.json()
except: except Exception:
return {"status": "down"} return {"status": "down"}
def creer_client(self, client_data: Dict) -> Dict: def creer_client(self, client_data: Dict) -> Dict:
@ -412,94 +365,45 @@ class SageGatewayClient:
logger.error(f"Erreur lecture mouvement {numero}: {e}") logger.error(f"Erreur lecture mouvement {numero}: {e}")
return None 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: def creer_contact(self, contact_data: Dict) -> Dict:
return self._post("/sage/contacts/create", contact_data) return self._post("/sage/contacts/create", contact_data)
def lister_contacts(self, numero: str) -> List[Dict]: def lister_contacts(self, numero: str) -> List[Dict]:
return self._post("/sage/contacts/list", {"numero": numero}).get("data", []) return self._post("/sage/contacts/list", {"numero": numero}).get("data", [])
def obtenir_contact(self, numero: str, contact_numero: int) -> Dict: def obtenir_contact(self, numero: str, contact_numero: int) -> Dict:
result = self._post("/sage/contacts/get", { result = self._post(
"numero": numero, "/sage/contacts/get", {"numero": numero, "contact_numero": contact_numero}
"contact_numero": contact_numero )
})
return result.get("data") if result.get("success") else None return result.get("data") if result.get("success") else None
def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict: def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict:
return self._post("/sage/contacts/update", { return self._post(
"numero": numero, "/sage/contacts/update",
"contact_numero": contact_numero, {"numero": numero, "contact_numero": contact_numero, "updates": updates},
"updates": updates )
})
def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: def supprimer_contact(self, numero: str, contact_numero: int) -> Dict:
return self._post("/sage/contacts/delete", { return self._post(
"numero": numero, "/sage/contacts/delete",
"contact_numero": contact_numero {"numero": numero, "contact_numero": contact_numero},
}) )
def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict:
return self._post("/sage/contacts/set-default", { return self._post(
"numero": numero, "/sage/contacts/set-default",
"contact_numero": contact_numero {"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 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]: def lire_tiers(self, code: str) -> Optional[Dict]:
return self._post("/sage/tiers/get", { return self._post("/sage/tiers/get", {"code": code}).get("data")
"code": code
}).get("data")
sage_client = SageGatewayClient() sage_client = SageGatewayClient()

View file

@ -1,7 +1,4 @@
from schemas.tiers.tiers import ( from schemas.tiers.tiers import TiersDetails, TypeTiersInt
TiersDetails,
TypeTiersInt
)
from schemas.tiers.type_tiers import TypeTiers from schemas.tiers.type_tiers import TypeTiers
from schemas.schema_mixte import BaremeRemiseResponse from schemas.schema_mixte import BaremeRemiseResponse
from schemas.user import UserResponse from schemas.user import UserResponse
@ -9,52 +6,27 @@ from schemas.tiers.clients import (
ClientCreateRequest, ClientCreateRequest,
ClientDetails, ClientDetails,
ClientResponse, ClientResponse,
ClientUpdateRequest ClientUpdateRequest,
) )
from schemas.tiers.contact import ( from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate
Contact,
ContactCreate,
ContactUpdate
)
from schemas.tiers.fournisseurs import ( from schemas.tiers.fournisseurs import (
FournisseurCreateAPIRequest, FournisseurCreateAPIRequest,
FournisseurDetails, FournisseurDetails,
FournisseurUpdateRequest FournisseurUpdateRequest,
)
from schemas.documents.avoirs import (
AvoirCreateRequest,
AvoirUpdateRequest
)
from schemas.documents.commandes import (
CommandeCreateRequest,
CommandeUpdateRequest
) )
from schemas.documents.avoirs import AvoirCreateRequest, AvoirUpdateRequest
from schemas.documents.commandes import CommandeCreateRequest, CommandeUpdateRequest
from schemas.documents.devis import ( from schemas.documents.devis import (
DevisRequest, DevisRequest,
DevisResponse, DevisResponse,
DevisUpdateRequest, DevisUpdateRequest,
RelanceDevisRequest 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.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 ( from schemas.articles.articles import (
ArticleCreateRequest, ArticleCreateRequest,
ArticleResponse, ArticleResponse,
@ -62,12 +34,12 @@ from schemas.articles.articles import (
ArticleListResponse, ArticleListResponse,
EntreeStockRequest, EntreeStockRequest,
SortieStockRequest, SortieStockRequest,
MouvementStockResponse MouvementStockResponse,
) )
from schemas.articles.famille_article import ( from schemas.articles.famille_article import (
FamilleResponse, FamilleResponse,
FamilleCreateRequest, FamilleCreateRequest,
FamilleListResponse FamilleListResponse,
) )
@ -114,5 +86,5 @@ __all__ = [
"FamilleCreateRequest", "FamilleCreateRequest",
"FamilleListResponse", "FamilleListResponse",
"ContactCreate", "ContactCreate",
"ContactUpdate" "ContactUpdate",
] ]

View file

@ -1,7 +1,6 @@
from pydantic import BaseModel, Field, EmailStr, validator, field_validator from pydantic import BaseModel, Field, validator
from typing import List, Optional, Dict, ClassVar, Any from typing import List, Optional
from datetime import date, datetime from datetime import date, datetime
from enum import Enum, IntEnum
class EmplacementStockModel(BaseModel): 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): class FamilleCreateRequest(BaseModel):
"""Schéma pour création de famille d'articles""" """Schéma pour création de famille d'articles"""
@ -30,7 +27,6 @@ class FamilleCreateRequest(BaseModel):
} }
class FamilleResponse(BaseModel): class FamilleResponse(BaseModel):
"""Modèle complet d'une famille avec données comptables et fournisseur""" """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") nature: Optional[int] = Field(None, description="Nature de la famille")
pays: Optional[str] = Field(None, description="Pays d'origine") pays: Optional[str] = Field(None, description="Pays d'origine")
categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)") categorie_1: Optional[int] = Field(
categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)") None, description="Catégorie comptable 1 (CL_No1)"
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_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_01: Optional[str] = Field(None, description="Statistique libre 1")
stat_02: Optional[str] = Field(None, description="Statistique libre 2") stat_02: Optional[str] = Field(None, description="Statistique libre 2")
stat_03: Optional[str] = Field(None, description="Statistique libre 3") stat_03: Optional[str] = Field(None, description="Statistique libre 3")
stat_04: Optional[str] = Field(None, description="Statistique libre 4") stat_04: Optional[str] = Field(None, description="Statistique libre 4")
stat_05: Optional[str] = Field(None, description="Statistique libre 5") 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") 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") contremarque: Optional[bool] = Field(None, description="Article en contremarque")
fact_poids: Optional[bool] = Field(None, description="Facturation au poids") fact_poids: Optional[bool] = Field(None, description="Facturation au poids")
fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire")
publie: Optional[bool] = Field(None, description="Publié (e-commerce)") 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_reference: Optional[str] = Field(
racine_code_barre: Optional[str] = Field(None, description="Racine pour génération auto de codes-barres") 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") 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)") fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)")
criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)") 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_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_1: Optional[str] = Field(None, description="Code TVA vente principal")
tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire") tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire")
tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire") tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire")
type_facture_vente: Optional[int] = Field(None, description="Type de facture vente") 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_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_1: Optional[str] = Field(None, description="Code TVA achat principal")
tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire") tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire")
tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire") tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire")
type_facture_achat: Optional[int] = Field(None, description="Type de facture achat") type_facture_achat: Optional[int] = Field(None, description="Type de facture achat")
compte_stock: Optional[str] = Field(None, description="Compte de stock") 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_principal: Optional[str] = Field(
fournisseur_unite: Optional[str] = Field(None, description="Unité d'achat fournisseur") None, description="N° compte fournisseur principal"
fournisseur_conversion: Optional[float] = Field(None, description="Coefficient de conversion") )
fournisseur_delai_appro: Optional[int] = Field(None, description="Délai d'approvisionnement (jours)") fournisseur_unite: Optional[str] = Field(
fournisseur_garantie: Optional[int] = Field(None, description="Garantie fournisseur (mois)") None, description="Unité d'achat fournisseur"
fournisseur_colisage: Optional[int] = Field(None, description="Colisage fournisseur") )
fournisseur_qte_mini: Optional[float] = Field(None, description="Quantité minimum de commande") 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_qte_mont: Optional[float] = Field(None, description="Quantité montant")
fournisseur_devise: Optional[int] = Field(None, description="Devise fournisseur (0=Euro)") fournisseur_devise: Optional[int] = Field(
fournisseur_remise: Optional[float] = Field(None, description="Remise fournisseur (%)") None, description="Devise fournisseur (0=Euro)"
fournisseur_type_remise: Optional[int] = Field(None, description="Type de remise (0=%, 1=Montant)") )
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_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille")
FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé") FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé")
@ -189,13 +231,14 @@ class FamilleResponse(BaseModel):
"fournisseur_devise": 0, "fournisseur_devise": 0,
"fournisseur_remise": 5.0, "fournisseur_remise": 5.0,
"fournisseur_type_remise": 0, "fournisseur_type_remise": 0,
"nb_articles": 156 "nb_articles": 156,
} }
} }
class FamilleListResponse(BaseModel): class FamilleListResponse(BaseModel):
"""Réponse pour la liste des familles""" """Réponse pour la liste des familles"""
familles: list[FamilleResponse] familles: list[FamilleResponse]
total: int total: int
filtre: Optional[str] = None filtre: Optional[str] = None
@ -207,7 +250,6 @@ class FamilleListResponse(BaseModel):
"familles": [], "familles": [],
"total": 42, "total": 42,
"filtre": "ELECT", "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): class LigneAvoir(BaseModel):
"""Ligne d'avoir"""
article_code: str article_code: str
quantite: float quantite: float
remise_pourcentage: Optional[float] = 0.0 remise_pourcentage: Optional[float] = 0.0
@ -18,8 +14,6 @@ class LigneAvoir(BaseModel):
class AvoirCreateRequest(BaseModel): class AvoirCreateRequest(BaseModel):
"""Création d'un avoir"""
client_id: str client_id: str
date_avoir: Optional[date] = None date_avoir: Optional[date] = None
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
@ -45,8 +39,6 @@ class AvoirCreateRequest(BaseModel):
class AvoirUpdateRequest(BaseModel): class AvoirUpdateRequest(BaseModel):
"""Modification d'un avoir existant"""
date_avoir: Optional[date] = None date_avoir: Optional[date] = None
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
lignes: Optional[List[LigneAvoir]] = 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): class LigneCommande(BaseModel):
"""Ligne de commande"""
article_code: str article_code: str
quantite: float quantite: float
remise_pourcentage: Optional[float] = 0.0 remise_pourcentage: Optional[float] = 0.0
@ -18,8 +14,6 @@ class LigneCommande(BaseModel):
class CommandeCreateRequest(BaseModel): class CommandeCreateRequest(BaseModel):
"""Création d'une commande"""
client_id: str client_id: str
date_commande: Optional[date] = None date_commande: Optional[date] = None
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
@ -45,8 +39,6 @@ class CommandeCreateRequest(BaseModel):
class CommandeUpdateRequest(BaseModel): class CommandeUpdateRequest(BaseModel):
"""Modification d'une commande existante"""
date_commande: Optional[date] = None date_commande: Optional[date] = None
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
lignes: Optional[List[LigneCommande]] = 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): class LigneDevis(BaseModel):
article_code: str article_code: str
@ -31,7 +30,6 @@ class DevisResponse(BaseModel):
nb_lignes: int nb_lignes: int
class DevisUpdateRequest(BaseModel): class DevisUpdateRequest(BaseModel):
"""Modèle pour modification d'un devis existant""" """Modèle pour modification d'un devis existant"""
@ -60,7 +58,6 @@ class DevisUpdateRequest(BaseModel):
} }
class RelanceDevisRequest(BaseModel): class RelanceDevisRequest(BaseModel):
doc_id: str doc_id: str
message_personnalise: Optional[str] = None message_personnalise: Optional[str] = None

View file

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

View file

@ -1,9 +1,9 @@
from pydantic import BaseModel, Field, EmailStr, validator, field_validator from pydantic import BaseModel, EmailStr
from typing import List, Optional, Dict, ClassVar, Any from typing import List, Optional
from datetime import date, datetime from enum import Enum
from enum import Enum, IntEnum
from schemas.documents.documents import TypeDocument from schemas.documents.documents import TypeDocument
class StatutEmail(str, Enum): class StatutEmail(str, Enum):
EN_ATTENTE = "EN_ATTENTE" EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS" EN_COURS = "EN_COURS"
@ -12,6 +12,7 @@ class StatutEmail(str, Enum):
ERREUR = "ERREUR" ERREUR = "ERREUR"
BOUNCE = "BOUNCE" BOUNCE = "BOUNCE"
class EmailEnvoiRequest(BaseModel): class EmailEnvoiRequest(BaseModel):
destinataire: EmailStr destinataire: EmailStr
cc: Optional[List[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): class LigneFacture(BaseModel):
"""Ligne de facture"""
article_code: str article_code: str
quantite: float quantite: float
remise_pourcentage: Optional[float] = 0.0 remise_pourcentage: Optional[float] = 0.0
@ -18,8 +14,6 @@ class LigneFacture(BaseModel):
class FactureCreateRequest(BaseModel): class FactureCreateRequest(BaseModel):
"""Création d'une facture"""
client_id: str client_id: str
date_facture: Optional[date] = None date_facture: Optional[date] = None
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
@ -45,8 +39,6 @@ class FactureCreateRequest(BaseModel):
class FactureUpdateRequest(BaseModel): class FactureUpdateRequest(BaseModel):
"""Modification d'une facture existante"""
date_facture: Optional[date] = None date_facture: Optional[date] = None
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
lignes: Optional[List[LigneFacture]] = 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): class LigneLivraison(BaseModel):
"""Ligne de livraison"""
article_code: str article_code: str
quantite: float quantite: float
remise_pourcentage: Optional[float] = 0.0 remise_pourcentage: Optional[float] = 0.0
@ -17,8 +14,6 @@ class LigneLivraison(BaseModel):
class LivraisonCreateRequest(BaseModel): class LivraisonCreateRequest(BaseModel):
"""Création d'une livraison"""
client_id: str client_id: str
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None date_livraison_prevue: Optional[date] = None
@ -44,8 +39,6 @@ class LivraisonCreateRequest(BaseModel):
class LivraisonUpdateRequest(BaseModel): class LivraisonUpdateRequest(BaseModel):
"""Modification d'une livraison existante"""
date_livraison: Optional[date] = None date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None date_livraison_prevue: Optional[date] = None
lignes: Optional[List[LigneLivraison]] = None lignes: Optional[List[LigneLivraison]] = None

View file

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

View file

@ -2,9 +2,8 @@ from pydantic import BaseModel, Field, field_validator
from typing import List, Optional from typing import List, Optional
from schemas.tiers.contact import Contact 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 numero: Optional[str] = None
intitule: Optional[str] = None intitule: Optional[str] = None
adresse: Optional[str] = None adresse: Optional[str] = None
@ -681,12 +680,6 @@ class ClientCreateRequest(BaseModel):
class ClientUpdateRequest(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) intitule: Optional[str] = Field(None, max_length=69)
qualite: Optional[str] = Field(None, max_length=17) qualite: Optional[str] = Field(None, max_length=17)
classement: 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): class Contact(BaseModel):
"""Contact associé à un tiers"""
numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)")
contact_numero: Optional[int] = Field( contact_numero: Optional[int] = Field(
None, description="Numéro unique du contact (CT_No)" None, description="Numéro unique du contact (CT_No)"
@ -52,8 +50,6 @@ class Contact(BaseModel):
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
"""Données pour créer ou modifier un contact"""
numero: str = Field(..., description="Code du client parent (obligatoire)") numero: str = Field(..., description="Code du client parent (obligatoire)")
civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société") civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société")
@ -100,8 +96,6 @@ class ContactCreate(BaseModel):
class ContactUpdate(BaseModel): class ContactUpdate(BaseModel):
"""Données pour modifier un contact (tous champs optionnels)"""
civilite: Optional[str] = None civilite: Optional[str] = None
nom: Optional[str] = None nom: Optional[str] = None
prenom: Optional[str] = None prenom: Optional[str] = None

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,24 +1,16 @@
from typing import Optional, Union from typing import Optional, Union
def normaliser_type_tiers(type_tiers: Union[str, int, None]) -> Optional[str]: def normaliser_type_tiers(type_tiers: Union[str, int, None]) -> Optional[str]:
if type_tiers is None: if type_tiers is None:
return 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): if isinstance(type_tiers, int):
return mapping_int.get(type_tiers, "all") 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(): if isinstance(type_tiers, str) and type_tiers.isdigit():
return mapping_int.get(int(type_tiers), "all") 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 return type_tiers.lower() if isinstance(type_tiers, str) else None