refactor: reorganize database models and clean up schemas
This commit is contained in:
parent
4867b114fe
commit
792d771667
38 changed files with 737 additions and 905 deletions
152
api.py
152
api.py
|
|
@ -32,12 +32,10 @@ from sage_client import sage_client
|
|||
|
||||
from schemas import (
|
||||
TiersDetails,
|
||||
TypeTiers,
|
||||
BaremeRemiseResponse,
|
||||
UserResponse,
|
||||
ClientCreateRequest,
|
||||
ClientDetails,
|
||||
ClientResponse,
|
||||
ClientUpdateRequest,
|
||||
FournisseurCreateAPIRequest,
|
||||
FournisseurDetails,
|
||||
|
|
@ -60,18 +58,15 @@ from schemas import (
|
|||
LivraisonUpdateRequest,
|
||||
SignatureRequest,
|
||||
StatutSignature,
|
||||
TypeTiersInt,
|
||||
ArticleCreateRequest,
|
||||
ArticleResponse,
|
||||
ArticleUpdateRequest,
|
||||
ArticleListResponse,
|
||||
EntreeStockRequest,
|
||||
SortieStockRequest,
|
||||
MouvementStockResponse,
|
||||
RelanceDevisRequest,
|
||||
FamilleResponse,
|
||||
FamilleCreateRequest,
|
||||
FamilleListResponse,
|
||||
ContactCreate,
|
||||
ContactUpdate,
|
||||
)
|
||||
|
|
@ -125,7 +120,6 @@ app.add_middleware(
|
|||
app.include_router(auth_router)
|
||||
|
||||
|
||||
|
||||
async def universign_envoyer(
|
||||
doc_id: str,
|
||||
pdf_bytes: bytes,
|
||||
|
|
@ -146,57 +140,67 @@ async def universign_envoyer(
|
|||
if not pdf_bytes or len(pdf_bytes) == 0:
|
||||
raise Exception("Le PDF généré est vide")
|
||||
|
||||
# ÉTAPE 1: Création transaction
|
||||
response = requests.post(
|
||||
f"{api_url}/transactions",
|
||||
auth=auth,
|
||||
json={"name": f"{doc_data.get('type_label', 'Document')} {doc_id}", "language": "fr"},
|
||||
json={
|
||||
"name": f"{doc_data.get('type_label', 'Document')} {doc_id}",
|
||||
"language": "fr",
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"Erreur création transaction: {response.status_code}")
|
||||
transaction_id = response.json().get("id")
|
||||
|
||||
# ÉTAPE 2: Upload PDF
|
||||
files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")}
|
||||
files = {
|
||||
"file": (
|
||||
f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf",
|
||||
pdf_bytes,
|
||||
"application/pdf",
|
||||
)
|
||||
}
|
||||
response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=60)
|
||||
if response.status_code not in [200, 201]:
|
||||
raise Exception(f"Erreur upload fichier: {response.status_code}")
|
||||
file_id = response.json().get("id")
|
||||
|
||||
# ÉTAPE 3: Ajout document
|
||||
response = requests.post(
|
||||
f"{api_url}/transactions/{transaction_id}/documents",
|
||||
auth=auth, data={"document": file_id}, timeout=30
|
||||
auth=auth,
|
||||
data={"document": file_id},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
raise Exception(f"Erreur ajout document: {response.status_code}")
|
||||
document_id = response.json().get("id")
|
||||
|
||||
# ÉTAPE 4: Création champ signature
|
||||
response = requests.post(
|
||||
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields",
|
||||
auth=auth, data={"type": "signature"}, timeout=30
|
||||
auth=auth,
|
||||
data={"type": "signature"},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
raise Exception(f"Erreur création champ: {response.status_code}")
|
||||
field_id = response.json().get("id")
|
||||
|
||||
# ÉTAPE 5: Liaison signataire
|
||||
response = requests.post(
|
||||
f"{api_url}/transactions/{transaction_id}/signatures",
|
||||
auth=auth, data={"signer": email, "field": field_id}, timeout=30
|
||||
auth=auth,
|
||||
data={"signer": email, "field": field_id},
|
||||
timeout=30,
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
raise Exception(f"Erreur liaison signataire: {response.status_code}")
|
||||
|
||||
# ÉTAPE 6: Démarrage
|
||||
response = requests.post(f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30)
|
||||
response = requests.post(
|
||||
f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30
|
||||
)
|
||||
if response.status_code not in [200, 201]:
|
||||
raise Exception(f"Erreur démarrage: {response.status_code}")
|
||||
final_data = response.json()
|
||||
|
||||
# Récupération URL
|
||||
signer_url = ""
|
||||
if final_data.get("actions"):
|
||||
for action in final_data["actions"]:
|
||||
|
|
@ -211,9 +215,14 @@ async def universign_envoyer(
|
|||
if not signer_url:
|
||||
raise ValueError("URL de signature non retournée par Universign")
|
||||
|
||||
# Préparation email
|
||||
template = templates_signature_email["demande_signature"]
|
||||
type_labels = {0: "Devis", 10: "Commande", 30: "Bon de Livraison", 60: "Facture", 50: "Avoir"}
|
||||
type_labels = {
|
||||
0: "Devis",
|
||||
10: "Commande",
|
||||
30: "Bon de Livraison",
|
||||
60: "Facture",
|
||||
50: "Avoir",
|
||||
}
|
||||
variables = {
|
||||
"NOM_SIGNATAIRE": nom,
|
||||
"TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"),
|
||||
|
|
@ -259,6 +268,7 @@ async def universign_envoyer(
|
|||
|
||||
async def universign_statut(transaction_id: str) -> dict:
|
||||
import requests
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{settings.universign_api_url}/transactions/{transaction_id}",
|
||||
|
|
@ -267,8 +277,18 @@ async def universign_statut(transaction_id: str) -> dict:
|
|||
)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
statut_map = {"draft": "EN_ATTENTE", "started": "EN_ATTENTE", "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE"}
|
||||
return {"statut": statut_map.get(data.get("state"), "EN_ATTENTE"), "date_signature": data.get("completed_at")}
|
||||
statut_map = {
|
||||
"draft": "EN_ATTENTE",
|
||||
"started": "EN_ATTENTE",
|
||||
"completed": "SIGNE",
|
||||
"refused": "REFUSE",
|
||||
"expired": "EXPIRE",
|
||||
"canceled": "REFUSE",
|
||||
}
|
||||
return {
|
||||
"statut": statut_map.get(data.get("state"), "EN_ATTENTE"),
|
||||
"date_signature": data.get("completed_at"),
|
||||
}
|
||||
return {"statut": "ERREUR"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur statut Universign: {e}")
|
||||
|
|
@ -817,6 +837,7 @@ async def envoyer_devis_email(
|
|||
logger.error(f"Erreur envoi email: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.put("/document/{type_doc}/{numero}/statut", tags=["Documents"])
|
||||
async def changer_statut_document(
|
||||
type_doc: int = Path(
|
||||
|
|
@ -887,12 +908,18 @@ async def changer_statut_document(
|
|||
case 10 | 1 | 20 | 2 | 30 | 3 | 40 | 4 | 50 | 5 | 60 | 6:
|
||||
if statut_actuel >= 2:
|
||||
type_names = {
|
||||
10: "la commande", 1: "la commande",
|
||||
20: "la préparation", 2: "la préparation",
|
||||
30: "la livraison", 3: "la livraison",
|
||||
40: "le retour", 4: "le retour",
|
||||
50: "l'avoir", 5: "l'avoir",
|
||||
60: "la facture", 6: "la facture"
|
||||
10: "la commande",
|
||||
1: "la commande",
|
||||
20: "la préparation",
|
||||
2: "la préparation",
|
||||
30: "la livraison",
|
||||
3: "la livraison",
|
||||
40: "le retour",
|
||||
4: "le retour",
|
||||
50: "l'avoir",
|
||||
5: "l'avoir",
|
||||
60: "la facture",
|
||||
6: "la facture",
|
||||
}
|
||||
raise HTTPException(
|
||||
400,
|
||||
|
|
@ -900,15 +927,21 @@ async def changer_statut_document(
|
|||
f"ne peut plus changer de statut (statut actuel ≥ 2)",
|
||||
)
|
||||
|
||||
document_type_int = document_type_code.value if hasattr(document_type_code, 'value') else type_doc_normalized
|
||||
document_type_int = (
|
||||
document_type_code.value
|
||||
if hasattr(document_type_code, "value")
|
||||
else type_doc_normalized
|
||||
)
|
||||
|
||||
resultat = sage_client.changer_statut_document(
|
||||
document_type_code=document_type_int,
|
||||
numero=numero,
|
||||
nouveau_statut=nouveau_statut
|
||||
nouveau_statut=nouveau_statut,
|
||||
)
|
||||
|
||||
logger.info(f"Statut document {numero} changé: {statut_actuel} → {nouveau_statut}")
|
||||
logger.info(
|
||||
f"Statut document {numero} changé: {statut_actuel} → {nouveau_statut}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
@ -926,6 +959,7 @@ async def changer_statut_document(
|
|||
logger.error(f"Erreur changement statut document {numero}: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.get("/commandes/{id}", tags=["Commandes"])
|
||||
async def lire_commande(id: str):
|
||||
try:
|
||||
|
|
@ -2950,9 +2984,7 @@ async def lister_utilisateurs_debug(
|
|||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"📋 Liste utilisateurs retournée: {len(users_response)} résultat(s)"
|
||||
)
|
||||
logger.info(f"Liste utilisateurs retournée: {len(users_response)} résultat(s)")
|
||||
|
||||
return users_response
|
||||
|
||||
|
|
@ -2999,56 +3031,14 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)
|
|||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.get("/modeles", tags=["PDF Sage-Like"])
|
||||
async def get_modeles_disponibles():
|
||||
"""Liste tous les modèles PDF disponibles"""
|
||||
try:
|
||||
modeles = sage_client.lister_modeles_disponibles()
|
||||
return modeles
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur listage modèles: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.get("/documents/{numero}/pdf", tags=["PDF Sage-Like"])
|
||||
async def get_document_pdf(
|
||||
numero: str,
|
||||
type_doc: int = Query(..., description="0=devis, 60=facture, etc."),
|
||||
modele: str = Query(
|
||||
None, description="Nom du modèle (ex: 'Facture client logo.bgc')"
|
||||
),
|
||||
download: bool = Query(False, description="Télécharger au lieu d'afficher"),
|
||||
):
|
||||
try:
|
||||
pdf_bytes = sage_client.generer_pdf_document(
|
||||
numero=numero,
|
||||
type_doc=type_doc,
|
||||
modele=modele,
|
||||
base64_encode=False, # On veut les bytes bruts
|
||||
)
|
||||
|
||||
from fastapi.responses import Response
|
||||
|
||||
disposition = "attachment" if download else "inline"
|
||||
filename = f"{numero}.pdf"
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'{disposition}; filename="{filename}"'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur génération PDF: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.post("/tiers/{numero}/contacts", response_model=Contact, tags=["Contacts"])
|
||||
async def creer_contact(numero: str, contact: ContactCreate):
|
||||
try:
|
||||
try:
|
||||
sage_client.lire_tiers(numero)
|
||||
except:
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
raise HTTPException(404, f"Tiers {numero} non trouvé")
|
||||
|
||||
if contact.numero != numero:
|
||||
|
|
@ -3134,7 +3124,7 @@ async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpd
|
|||
@app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"])
|
||||
async def supprimer_contact(numero: str, contact_numero: int):
|
||||
try:
|
||||
resultat = sage_client.supprimer_contact(numero, contact_numero)
|
||||
sage_client.supprimer_contact(numero, contact_numero)
|
||||
return {"success": True, "message": f"Contact {contact_numero} supprimé"}
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur suppression contact: {e}")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from sqlalchemy import select
|
|||
from database import get_session, User
|
||||
from security.auth import decode_token
|
||||
from typing import Optional
|
||||
from datetime import datetime # AJOUT MANQUANT - C'ÉTAIT LE BUG !
|
||||
from datetime import datetime
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ async def get_current_user(
|
|||
) -> User:
|
||||
token = credentials.credentials
|
||||
|
||||
# Décoder le token
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(
|
||||
|
|
@ -25,7 +24,6 @@ async def get_current_user(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Vérifier le type
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
@ -33,7 +31,6 @@ async def get_current_user(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Extraire user_id
|
||||
user_id: str = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
|
|
@ -42,7 +39,6 @@ async def get_current_user(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Charger l'utilisateur
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -53,7 +49,6 @@ async def get_current_user(
|
|||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# Vérifications de sécurité
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
|
||||
|
|
@ -65,7 +60,6 @@ async def get_current_user(
|
|||
detail="Email non vérifié. Consultez votre boîte de réception.",
|
||||
)
|
||||
|
||||
# FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!)
|
||||
if user.locked_until and user.locked_until > datetime.now():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
|
@ -79,10 +73,6 @@ async def get_current_user_optional(
|
|||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Optional[User]:
|
||||
"""
|
||||
Version optionnelle - ne lève pas d'erreur si pas de token
|
||||
Utile pour des endpoints publics avec contenu enrichi si authentifié
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
|
|
@ -93,15 +83,6 @@ async def get_current_user_optional(
|
|||
|
||||
|
||||
def require_role(*allowed_roles: str):
|
||||
"""
|
||||
Décorateur pour restreindre l'accès par rôle
|
||||
|
||||
Usage:
|
||||
@app.get("/admin/users")
|
||||
async def list_users(user: User = Depends(require_role("admin"))):
|
||||
...
|
||||
"""
|
||||
|
||||
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
||||
if user.role not in allowed_roles:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -24,8 +24,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
async def create_admin():
|
||||
"""Crée un utilisateur admin"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" Création d'un compte administrateur")
|
||||
print("=" * 60 + "\n")
|
||||
|
|
@ -59,7 +57,6 @@ async def create_admin():
|
|||
else:
|
||||
print(f" {error_msg}\n")
|
||||
|
||||
# Vérifier si l'email existe déjà
|
||||
async with async_session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
|
|
@ -78,7 +75,7 @@ async def create_admin():
|
|||
nom=nom,
|
||||
prenom=prenom,
|
||||
role="admin",
|
||||
is_verified=True, # Admin vérifié par défaut
|
||||
is_verified=True,
|
||||
is_active=True,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
|
@ -89,7 +86,7 @@ async def create_admin():
|
|||
print("\n Administrateur créé avec succès!")
|
||||
print(f" Email: {email}")
|
||||
print(f" Nom: {prenom} {nom}")
|
||||
print(f" Rôle: admin")
|
||||
print(" Rôle: admin")
|
||||
print(f" ID: {admin.id}")
|
||||
print("\n Vous pouvez maintenant vous connecter à l'API\n")
|
||||
|
||||
|
|
|
|||
18
database/Enum/status.py
Normal file
18
database/Enum/status.py
Normal 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"
|
||||
|
|
@ -5,20 +5,22 @@ from database.db_config import (
|
|||
get_session,
|
||||
close_db,
|
||||
)
|
||||
|
||||
from database.models import (
|
||||
Base,
|
||||
EmailLog,
|
||||
SignatureLog,
|
||||
WorkflowLog,
|
||||
from database.models.generic_model import (
|
||||
CacheMetadata,
|
||||
AuditLog,
|
||||
StatutEmail,
|
||||
StatutSignature,
|
||||
User,
|
||||
RefreshToken,
|
||||
LoginAttempt,
|
||||
)
|
||||
from database.models.user import User
|
||||
from database.models.email import EmailLog
|
||||
from database.models.signature import SignatureLog
|
||||
from database.models.sage_config import SageGatewayConfig
|
||||
from database.Enum.status import (
|
||||
StatutEmail,
|
||||
StatutSignature,
|
||||
)
|
||||
|
||||
from database.models.workflow import WorkflowLog
|
||||
|
||||
__all__ = [
|
||||
"engine",
|
||||
|
|
@ -37,4 +39,5 @@ __all__ = [
|
|||
"User",
|
||||
"RefreshToken",
|
||||
"LoginAttempt",
|
||||
"SageGatewayConfig",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -25,10 +25,6 @@ async_session_factory = async_sessionmaker(
|
|||
|
||||
|
||||
async def init_db():
|
||||
"""
|
||||
Crée toutes les tables dans la base de données
|
||||
Utilise create_all qui ne crée QUE les tables manquantes
|
||||
"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
|
@ -42,7 +38,6 @@ async def init_db():
|
|||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
"""Dependency FastAPI pour obtenir une session DB"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
|
|
@ -51,6 +46,5 @@ async def get_session() -> AsyncSession:
|
|||
|
||||
|
||||
async def close_db():
|
||||
"""Ferme proprement toutes les connexions"""
|
||||
await engine.dispose()
|
||||
logger.info(" Connexions DB fermées")
|
||||
|
|
|
|||
|
|
@ -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
43
database/models/email.py
Normal 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}>"
|
||||
91
database/models/generic_model.py
Normal file
91
database/models/generic_model.py
Normal 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}>"
|
||||
54
database/models/sage_config.py
Normal file
54
database/models/sage_config.py
Normal 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}>"
|
||||
44
database/models/signature.py
Normal file
44
database/models/signature.py
Normal 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
39
database/models/user.py
Normal 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}>"
|
||||
37
database/models/workflow.py
Normal file
37
database/models/workflow.py
Normal 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}>"
|
||||
146
email_queue.py
146
email_queue.py
|
|
@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
ULTRA_DEBUG = True
|
||||
|
||||
|
||||
def debug_log(message: str, level: str = "INFO"):
|
||||
if ULTRA_DEBUG:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
||||
|
|
@ -29,13 +30,12 @@ def debug_log(message: str, level: str = "INFO"):
|
|||
"ERROR": "[ERROR]",
|
||||
"WARN": "[WARN]",
|
||||
"STEP": "[STEP]",
|
||||
"DATA": "[DATA]"
|
||||
"DATA": "[DATA]",
|
||||
}.get(level, "•")
|
||||
logger.info(f"{prefix} [{timestamp}] {message}")
|
||||
|
||||
|
||||
class EmailQueue:
|
||||
|
||||
def __init__(self):
|
||||
self.queue = queue.Queue()
|
||||
self.workers = []
|
||||
|
|
@ -65,13 +65,14 @@ class EmailQueue:
|
|||
try:
|
||||
self.queue.join()
|
||||
logger.info(" Queue email arrêtée proprement")
|
||||
except:
|
||||
logger.warning("⚠️ Timeout lors de l'arrêt de la queue")
|
||||
except Exception:
|
||||
logger.warning(" Timeout lors de l'arrêt de la queue")
|
||||
|
||||
def enqueue(self, email_log_id: str):
|
||||
"""Ajoute un email dans la queue"""
|
||||
self.queue.put(email_log_id)
|
||||
debug_log(f"Email {email_log_id} ajouté à la queue (taille: {self.queue.qsize()})")
|
||||
debug_log(
|
||||
f"Email {email_log_id} ajouté à la queue (taille: {self.queue.qsize()})"
|
||||
)
|
||||
|
||||
def _worker(self):
|
||||
loop = asyncio.new_event_loop()
|
||||
|
|
@ -84,7 +85,9 @@ class EmailQueue:
|
|||
while self.running:
|
||||
try:
|
||||
email_log_id = self.queue.get(timeout=1)
|
||||
debug_log(f"[{worker_name}] Traitement email {email_log_id}", "STEP")
|
||||
debug_log(
|
||||
f"[{worker_name}] Traitement email {email_log_id}", "STEP"
|
||||
)
|
||||
|
||||
loop.run_until_complete(self._process_email(email_log_id))
|
||||
|
||||
|
|
@ -96,7 +99,7 @@ class EmailQueue:
|
|||
logger.error(f" Erreur worker {worker_name}: {e}", exc_info=True)
|
||||
try:
|
||||
self.queue.task_done()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
loop.close()
|
||||
|
|
@ -122,7 +125,7 @@ class EmailQueue:
|
|||
logger.error(f" Email log {email_log_id} introuvable en DB")
|
||||
return
|
||||
|
||||
debug_log(f"Email trouvé en DB:", "DATA")
|
||||
debug_log("Email trouvé en DB:", "DATA")
|
||||
debug_log(f" → Destinataire: {email_log.destinataire}")
|
||||
debug_log(f" → Sujet: {email_log.sujet[:50]}...")
|
||||
debug_log(f" → Tentative: {email_log.nb_tentatives + 1}")
|
||||
|
|
@ -138,7 +141,9 @@ class EmailQueue:
|
|||
email_log.statut = StatutEmail.ENVOYE
|
||||
email_log.date_envoi = datetime.now()
|
||||
email_log.derniere_erreur = None
|
||||
debug_log(f"Email envoyé avec succès: {email_log.destinataire}", "SUCCESS")
|
||||
debug_log(
|
||||
f"Email envoyé avec succès: {email_log.destinataire}", "SUCCESS"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
|
|
@ -157,15 +162,20 @@ class EmailQueue:
|
|||
timer.daemon = True
|
||||
timer.start()
|
||||
|
||||
debug_log(f"Retry #{email_log.nb_tentatives + 1} planifié dans {delay}s", "WARN")
|
||||
debug_log(
|
||||
f"Retry #{email_log.nb_tentatives + 1} planifié dans {delay}s",
|
||||
"WARN",
|
||||
)
|
||||
else:
|
||||
debug_log(f"ÉCHEC DÉFINITIF après {email_log.nb_tentatives} tentatives", "ERROR")
|
||||
debug_log(
|
||||
f"ÉCHEC DÉFINITIF après {email_log.nb_tentatives} tentatives",
|
||||
"ERROR",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
debug_log(f"═══ FIN TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
|
||||
|
||||
async def _send_with_retry(self, email_log):
|
||||
|
||||
debug_log("Construction du message MIME...", "STEP")
|
||||
|
||||
msg = MIMEMultipart()
|
||||
|
|
@ -173,7 +183,7 @@ class EmailQueue:
|
|||
msg["To"] = email_log.destinataire
|
||||
msg["Subject"] = email_log.sujet
|
||||
|
||||
debug_log(f"Headers configurés:", "DATA")
|
||||
debug_log("Headers configurés:", "DATA")
|
||||
debug_log(f" → From: {settings.smtp_from}")
|
||||
debug_log(f" → To: {email_log.destinataire}")
|
||||
debug_log(f" → Subject: {email_log.sujet}")
|
||||
|
|
@ -205,7 +215,10 @@ class EmailQueue:
|
|||
f'attachment; filename="{doc_id}.pdf"'
|
||||
)
|
||||
msg.attach(part)
|
||||
debug_log(f"PDF attaché: {doc_id}.pdf ({len(pdf_bytes)} bytes)", "SUCCESS")
|
||||
debug_log(
|
||||
f"PDF attaché: {doc_id}.pdf ({len(pdf_bytes)} bytes)",
|
||||
"SUCCESS",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
debug_log(f"Erreur génération PDF {doc_id}: {e}", "ERROR")
|
||||
|
|
@ -224,7 +237,9 @@ class EmailQueue:
|
|||
debug_log(f" → Host: {settings.smtp_host}")
|
||||
debug_log(f" → Port: {settings.smtp_port}")
|
||||
debug_log(f" → User: {settings.smtp_user}")
|
||||
debug_log(f" → Password: {'*' * len(settings.smtp_password) if settings.smtp_password else 'NON DÉFINI'}")
|
||||
debug_log(
|
||||
f" → Password: {'*' * len(settings.smtp_password) if settings.smtp_password else 'NON DÉFINI'}"
|
||||
)
|
||||
debug_log(f" → From: {settings.smtp_from}")
|
||||
debug_log(f" → TLS: {settings.smtp_use_tls}")
|
||||
debug_log(f" → To: {msg['To']}")
|
||||
|
|
@ -235,11 +250,15 @@ class EmailQueue:
|
|||
# ═══ ÉTAPE 1: RÉSOLUTION DNS ═══
|
||||
debug_log("ÉTAPE 1/7: Résolution DNS...", "STEP")
|
||||
try:
|
||||
ip_addresses = socket.getaddrinfo(settings.smtp_host, settings.smtp_port)
|
||||
ip_addresses = socket.getaddrinfo(
|
||||
settings.smtp_host, settings.smtp_port
|
||||
)
|
||||
debug_log(f" → DNS résolu: {ip_addresses[0][4]}", "SUCCESS")
|
||||
except socket.gaierror as e:
|
||||
debug_log(f" → ÉCHEC DNS: {e}", "ERROR")
|
||||
raise Exception(f"Résolution DNS échouée pour {settings.smtp_host}: {e}")
|
||||
raise Exception(
|
||||
f"Résolution DNS échouée pour {settings.smtp_host}: {e}"
|
||||
)
|
||||
|
||||
# ═══ ÉTAPE 2: CONNEXION TCP ═══
|
||||
debug_log("ÉTAPE 2/7: Connexion TCP...", "STEP")
|
||||
|
|
@ -247,21 +266,25 @@ class EmailQueue:
|
|||
|
||||
try:
|
||||
server = smtplib.SMTP(
|
||||
settings.smtp_host,
|
||||
settings.smtp_port,
|
||||
timeout=30
|
||||
settings.smtp_host, settings.smtp_port, timeout=30
|
||||
)
|
||||
server.set_debuglevel(2 if ULTRA_DEBUG else 0) # Active le debug SMTP natif
|
||||
server.set_debuglevel(
|
||||
2 if ULTRA_DEBUG else 0
|
||||
) # Active le debug SMTP natif
|
||||
|
||||
connect_time = time.time() - start_time
|
||||
debug_log(f" → Connexion établie en {connect_time:.2f}s", "SUCCESS")
|
||||
|
||||
except socket.timeout:
|
||||
debug_log(f" → TIMEOUT connexion (>30s)", "ERROR")
|
||||
raise Exception(f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}")
|
||||
debug_log(" → TIMEOUT connexion (>30s)", "ERROR")
|
||||
raise Exception(
|
||||
f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}"
|
||||
)
|
||||
except ConnectionRefusedError:
|
||||
debug_log(f" → CONNEXION REFUSÉE", "ERROR")
|
||||
raise Exception(f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}")
|
||||
debug_log(" → CONNEXION REFUSÉE", "ERROR")
|
||||
raise Exception(
|
||||
f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}"
|
||||
)
|
||||
except Exception as e:
|
||||
debug_log(f" → ERREUR CONNEXION: {type(e).__name__}: {e}", "ERROR")
|
||||
raise
|
||||
|
|
@ -281,15 +304,19 @@ class EmailQueue:
|
|||
debug_log("ÉTAPE 4/7: Négociation STARTTLS...", "STEP")
|
||||
try:
|
||||
# Vérifier si le serveur supporte STARTTLS
|
||||
if server.has_extn('STARTTLS'):
|
||||
if server.has_extn("STARTTLS"):
|
||||
debug_log(" → Serveur supporte STARTTLS", "SUCCESS")
|
||||
|
||||
# Créer un contexte SSL
|
||||
context = ssl.create_default_context()
|
||||
debug_log(f" → Contexte SSL créé (protocole: {context.protocol})")
|
||||
debug_log(
|
||||
f" → Contexte SSL créé (protocole: {context.protocol})"
|
||||
)
|
||||
|
||||
tls_code, tls_msg = server.starttls(context=context)
|
||||
debug_log(f" → STARTTLS Response: {tls_code} - {tls_msg}", "SUCCESS")
|
||||
debug_log(
|
||||
f" → STARTTLS Response: {tls_code} - {tls_msg}", "SUCCESS"
|
||||
)
|
||||
|
||||
# Re-EHLO après STARTTLS
|
||||
server.ehlo()
|
||||
|
|
@ -315,27 +342,42 @@ class EmailQueue:
|
|||
|
||||
try:
|
||||
# Lister les méthodes d'auth supportées
|
||||
if server.has_extn('AUTH'):
|
||||
auth_methods = server.esmtp_features.get('auth', '')
|
||||
if server.has_extn("AUTH"):
|
||||
auth_methods = server.esmtp_features.get("auth", "")
|
||||
debug_log(f" → Méthodes AUTH supportées: {auth_methods}")
|
||||
|
||||
server.login(settings.smtp_user, settings.smtp_password)
|
||||
debug_log(" → Authentification RÉUSSIE", "SUCCESS")
|
||||
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
debug_log(f" → ÉCHEC AUTHENTIFICATION: {e.smtp_code} - {e.smtp_error}", "ERROR")
|
||||
debug_log(
|
||||
f" → ÉCHEC AUTHENTIFICATION: {e.smtp_code} - {e.smtp_error}",
|
||||
"ERROR",
|
||||
)
|
||||
debug_log(f" → Code: {e.smtp_code}", "ERROR")
|
||||
debug_log(f" → Message: {e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else e.smtp_error}", "ERROR")
|
||||
debug_log(
|
||||
f" → Message: {e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else e.smtp_error}",
|
||||
"ERROR",
|
||||
)
|
||||
|
||||
# Diagnostic spécifique selon le code d'erreur
|
||||
if e.smtp_code == 535:
|
||||
debug_log(" → 535 = Identifiants incorrects ou app password requis", "ERROR")
|
||||
debug_log(
|
||||
" → 535 = Identifiants incorrects ou app password requis",
|
||||
"ERROR",
|
||||
)
|
||||
elif e.smtp_code == 534:
|
||||
debug_log(" → 534 = 2FA requis, utiliser un App Password", "ERROR")
|
||||
debug_log(
|
||||
" → 534 = 2FA requis, utiliser un App Password", "ERROR"
|
||||
)
|
||||
elif e.smtp_code == 530:
|
||||
debug_log(" → 530 = Authentification requise mais échouée", "ERROR")
|
||||
debug_log(
|
||||
" → 530 = Authentification requise mais échouée", "ERROR"
|
||||
)
|
||||
|
||||
raise Exception(f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}")
|
||||
raise Exception(
|
||||
f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}"
|
||||
)
|
||||
|
||||
except smtplib.SMTPException as e:
|
||||
debug_log(f" → ERREUR SMTP AUTH: {e}", "ERROR")
|
||||
|
|
@ -364,8 +406,13 @@ class EmailQueue:
|
|||
debug_log(f" → DESTINATAIRE REFUSÉ: {e.recipients}", "ERROR")
|
||||
raise Exception(f"Destinataire refusé: {e.recipients}")
|
||||
except smtplib.SMTPSenderRefused as e:
|
||||
debug_log(f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR")
|
||||
debug_log(f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer", "ERROR")
|
||||
debug_log(
|
||||
f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR"
|
||||
)
|
||||
debug_log(
|
||||
f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer",
|
||||
"ERROR",
|
||||
)
|
||||
raise Exception(f"Expéditeur refusé: {e.smtp_code} - {e.smtp_error}")
|
||||
except smtplib.SMTPDataError as e:
|
||||
debug_log(f" → ERREUR DATA: {e.smtp_code} - {e.smtp_error}", "ERROR")
|
||||
|
|
@ -376,7 +423,7 @@ class EmailQueue:
|
|||
try:
|
||||
server.quit()
|
||||
debug_log(" → Connexion fermée proprement", "SUCCESS")
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
debug_log("═══════════════════════════════════════════", "SUCCESS")
|
||||
|
|
@ -393,13 +440,12 @@ class EmailQueue:
|
|||
if server:
|
||||
try:
|
||||
server.quit()
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise Exception(f"Erreur SMTP: {str(e)}")
|
||||
|
||||
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
|
||||
|
||||
if not self.sage_client:
|
||||
logger.error(" sage_client non configuré")
|
||||
raise Exception("sage_client non disponible")
|
||||
|
|
@ -470,9 +516,7 @@ class EmailQueue:
|
|||
|
||||
# FIX: Gérer les valeurs None correctement
|
||||
designation = (
|
||||
ligne.get("designation")
|
||||
or ligne.get("designation_article")
|
||||
or ""
|
||||
ligne.get("designation") or ligne.get("designation_article") or ""
|
||||
)
|
||||
if designation:
|
||||
designation = str(designation)[:50]
|
||||
|
|
@ -481,8 +525,16 @@ class EmailQueue:
|
|||
|
||||
pdf.drawString(2 * cm, y, designation)
|
||||
pdf.drawString(10 * cm, y, str(ligne.get("quantite") or 0))
|
||||
pdf.drawString(12 * cm, y, f"{ligne.get('prix_unitaire_ht') or ligne.get('prix_unitaire', 0):.2f}€")
|
||||
pdf.drawString(15 * cm, y, f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}€")
|
||||
pdf.drawString(
|
||||
12 * cm,
|
||||
y,
|
||||
f"{ligne.get('prix_unitaire_ht') or ligne.get('prix_unitaire', 0):.2f}€",
|
||||
)
|
||||
pdf.drawString(
|
||||
15 * cm,
|
||||
y,
|
||||
f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}€",
|
||||
)
|
||||
y -= 0.6 * cm
|
||||
|
||||
y -= 1 * cm
|
||||
|
|
|
|||
23
init_db.py
23
init_db.py
|
|
@ -1,20 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script d'initialisation de la base de données SQLite
|
||||
Lance ce script avant le premier démarrage de l'API
|
||||
|
||||
Usage:
|
||||
python init_db.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ajouter le répertoire parent au path pour les imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import init_db # Import depuis database/__init__.py
|
||||
from database import init_db
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
|
@ -22,10 +12,9 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
async def main():
|
||||
"""Crée toutes les tables dans sage_dataven.db"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("🚀 Initialisation de la base de données Sage Dataven")
|
||||
print("Initialisation de la base de données Sage Dataven")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
try:
|
||||
|
|
@ -33,21 +22,21 @@ async def main():
|
|||
await init_db()
|
||||
|
||||
print("\n Base de données créée avec succès!")
|
||||
print(f" Fichier: sage_dataven.db")
|
||||
print(" Fichier: sage_dataven.db")
|
||||
|
||||
print("\n📊 Tables créées:")
|
||||
print("\nTables créées:")
|
||||
print(" ├─ email_logs (Journalisation emails)")
|
||||
print(" ├─ signature_logs (Suivi signatures Universign)")
|
||||
print(" ├─ workflow_logs (Transformations documents)")
|
||||
print(" ├─ cache_metadata (Métadonnées cache)")
|
||||
print(" └─ audit_logs (Journal d'audit)")
|
||||
|
||||
print("\n📝 Prochaines étapes:")
|
||||
print("\nProchaines étapes:")
|
||||
print(" 1. Configurer le fichier .env avec vos credentials")
|
||||
print(" 2. Lancer la gateway Windows sur la machine Sage")
|
||||
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
|
||||
print(" 4. Ou avec Docker: docker-compose up -d")
|
||||
print(" 5. Tester: http://votre-vps:8000/docs")
|
||||
print(" 5. Tester: http://0.0.0.0:8000/docs")
|
||||
|
||||
print("\n" + "=" * 60 + "\n")
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -21,14 +21,12 @@ from security.auth import (
|
|||
from services.email_service import AuthEmailService
|
||||
from core.dependencies import get_current_user
|
||||
from config import settings
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8)
|
||||
|
|
@ -45,7 +43,7 @@ class TokenResponse(BaseModel):
|
|||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int = 86400 # 30 minutes en secondes
|
||||
expires_in: int = 86400
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
|
|
@ -69,8 +67,6 @@ class ResendVerificationRequest(BaseModel):
|
|||
email: EmailStr
|
||||
|
||||
|
||||
|
||||
|
||||
async def log_login_attempt(
|
||||
session: AsyncSession,
|
||||
email: str,
|
||||
|
|
@ -79,7 +75,6 @@ async def log_login_attempt(
|
|||
success: bool,
|
||||
failure_reason: Optional[str] = None,
|
||||
):
|
||||
"""Enregistre une tentative de connexion"""
|
||||
attempt = LoginAttempt(
|
||||
email=email,
|
||||
ip_address=ip,
|
||||
|
|
@ -95,13 +90,12 @@ async def log_login_attempt(
|
|||
async def check_rate_limit(
|
||||
session: AsyncSession, email: str, ip: str
|
||||
) -> tuple[bool, str]:
|
||||
|
||||
time_window = datetime.now() - timedelta(minutes=15)
|
||||
|
||||
result = await session.execute(
|
||||
select(LoginAttempt).where(
|
||||
LoginAttempt.email == email,
|
||||
LoginAttempt.success == False,
|
||||
LoginAttempt.success,
|
||||
LoginAttempt.timestamp >= time_window,
|
||||
)
|
||||
)
|
||||
|
|
@ -113,15 +107,12 @@ async def check_rate_limit(
|
|||
return True, ""
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/register", status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
data: RegisterRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
||||
result = await session.execute(select(User).where(User.email == data.email))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -171,10 +162,6 @@ async def register(
|
|||
|
||||
@router.get("/verify-email")
|
||||
async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)):
|
||||
"""
|
||||
Vérification de l'email via lien cliquable (GET)
|
||||
Utilisé quand l'utilisateur clique sur le lien dans l'email
|
||||
"""
|
||||
result = await session.execute(select(User).where(User.verification_token == token))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -209,10 +196,6 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
|
|||
async def verify_email_post(
|
||||
data: VerifyEmailRequest, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
Vérification de l'email via API (POST)
|
||||
Utilisé pour les appels programmatiques depuis le frontend
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(User).where(User.verification_token == data.token)
|
||||
)
|
||||
|
|
@ -344,7 +327,6 @@ async def login(
|
|||
detail="Compte temporairement verrouillé",
|
||||
)
|
||||
|
||||
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
user.last_login = datetime.now()
|
||||
|
|
@ -374,7 +356,7 @@ async def login(
|
|||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_jwt,
|
||||
expires_in=86400, # 30 minutes
|
||||
expires_in=86400,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -382,7 +364,6 @@ async def login(
|
|||
async def refresh_access_token(
|
||||
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
|
||||
payload = decode_token(data.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
|
|
@ -396,7 +377,7 @@ async def refresh_access_token(
|
|||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.token_hash == token_hash,
|
||||
RefreshToken.is_revoked == False,
|
||||
not RefreshToken.is_revoked,
|
||||
)
|
||||
)
|
||||
token_record = result.scalar_one_or_none()
|
||||
|
|
@ -429,7 +410,7 @@ async def refresh_access_token(
|
|||
|
||||
return TokenResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=data.refresh_token, # Refresh token reste le même
|
||||
refresh_token=data.refresh_token,
|
||||
expires_in=86400,
|
||||
)
|
||||
|
||||
|
|
@ -440,7 +421,6 @@ async def forgot_password(
|
|||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
||||
result = await session.execute(select(User).where(User.email == data.email.lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -474,7 +454,6 @@ async def forgot_password(
|
|||
async def reset_password(
|
||||
data: ResetPasswordRequest, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
|
||||
result = await session.execute(select(User).where(User.reset_token == data.token))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -517,7 +496,6 @@ async def logout(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
|
||||
token_hash = hash_token(data.refresh_token)
|
||||
|
||||
result = await session.execute(
|
||||
|
|
@ -539,7 +517,6 @@ async def logout(
|
|||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(user: User = Depends(get_current_user)):
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
|
|
|
|||
152
sage_client.py
152
sage_client.py
|
|
@ -1,5 +1,5 @@
|
|||
import requests
|
||||
from typing import Dict, List, Optional, Union
|
||||
from typing import Dict, List, Optional
|
||||
from config import settings
|
||||
import logging
|
||||
|
||||
|
|
@ -16,7 +16,6 @@ class SageGatewayClient:
|
|||
self.timeout = 30
|
||||
|
||||
def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict:
|
||||
"""POST avec retry automatique"""
|
||||
import time
|
||||
|
||||
for attempt in range(retries):
|
||||
|
|
@ -38,7 +37,6 @@ class SageGatewayClient:
|
|||
time.sleep(2**attempt)
|
||||
|
||||
def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict:
|
||||
"""GET avec retry automatique"""
|
||||
import time
|
||||
|
||||
for attempt in range(retries):
|
||||
|
|
@ -60,27 +58,21 @@ class SageGatewayClient:
|
|||
time.sleep(2**attempt)
|
||||
|
||||
def lister_clients(self, filtre: str = "") -> List[Dict]:
|
||||
"""Liste tous les clients avec filtre optionnel"""
|
||||
return self._post("/sage/clients/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_client(self, code: str) -> Optional[Dict]:
|
||||
"""Lecture d'un client par code"""
|
||||
return self._post("/sage/clients/get", {"code": code}).get("data")
|
||||
|
||||
def lister_articles(self, filtre: str = "") -> List[Dict]:
|
||||
"""Liste tous les articles avec filtre optionnel"""
|
||||
return self._post("/sage/articles/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_article(self, ref: str) -> Optional[Dict]:
|
||||
"""Lecture d'un article par référence"""
|
||||
return self._post("/sage/articles/get", {"code": ref}).get("data")
|
||||
|
||||
def creer_devis(self, devis_data: Dict) -> Dict:
|
||||
"""Création d'un devis"""
|
||||
return self._post("/sage/devis/create", devis_data).get("data", {})
|
||||
|
||||
def lire_devis(self, numero: str) -> Optional[Dict]:
|
||||
"""Lecture d'un devis"""
|
||||
return self._post("/sage/devis/get", {"code": numero}).get("data")
|
||||
|
||||
def lister_devis(
|
||||
|
|
@ -94,7 +86,9 @@ class SageGatewayClient:
|
|||
payload["statut"] = statut
|
||||
return self._post("/sage/devis/list", payload).get("data", [])
|
||||
|
||||
def changer_statut_document(self, document_type_code: int, numero: str, nouveau_statut: int) -> Dict:
|
||||
def changer_statut_document(
|
||||
self, document_type_code: int, numero: str, nouveau_statut: int
|
||||
) -> Dict:
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.url}/sage/document/statut",
|
||||
|
|
@ -113,7 +107,6 @@ class SageGatewayClient:
|
|||
raise
|
||||
|
||||
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||
"""Lecture d'un document générique"""
|
||||
return self._post(
|
||||
"/sage/documents/get", {"numero": numero, "type_doc": type_doc}
|
||||
).get("data")
|
||||
|
|
@ -141,7 +134,6 @@ class SageGatewayClient:
|
|||
def mettre_a_jour_champ_libre(
|
||||
self, doc_id: str, type_doc: int, nom_champ: str, valeur: str
|
||||
) -> bool:
|
||||
"""Mise à jour d'un champ libre"""
|
||||
resp = self._post(
|
||||
"/sage/documents/champ-libre",
|
||||
{
|
||||
|
|
@ -170,60 +162,28 @@ class SageGatewayClient:
|
|||
return self._post("/sage/factures/list", payload).get("data", [])
|
||||
|
||||
def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool:
|
||||
"""Met à jour le champ 'Dernière relance' d'une facture"""
|
||||
resp = self._post(
|
||||
"/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc}
|
||||
)
|
||||
return resp.get("success", False)
|
||||
|
||||
def lire_contact_client(self, code_client: str) -> Optional[Dict]:
|
||||
"""Lecture du contact principal d'un client"""
|
||||
return self._post("/sage/contact/read", {"code": code_client}).get("data")
|
||||
|
||||
def lire_remise_max_client(self, code_client: str) -> float:
|
||||
"""Récupère la remise max autorisée pour un client"""
|
||||
result = self._post("/sage/client/remise-max", {"code": code_client})
|
||||
return result.get("data", {}).get("remise_max", 10.0)
|
||||
|
||||
def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes:
|
||||
"""Génère le PDF d'un document via la gateway Windows"""
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.url}/sage/documents/generate-pdf",
|
||||
json={"doc_id": doc_id, "type_doc": type_doc},
|
||||
headers=self.headers,
|
||||
timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
import base64
|
||||
|
||||
response_data = r.json()
|
||||
pdf_base64 = response_data.get("data", {}).get("pdf_base64", "")
|
||||
|
||||
if not pdf_base64:
|
||||
raise ValueError("PDF vide retourné par la gateway")
|
||||
|
||||
return base64.b64decode(pdf_base64)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur génération PDF: {e}")
|
||||
raise
|
||||
|
||||
def lister_prospects(self, filtre: str = "") -> List[Dict]:
|
||||
"""Liste tous les prospects avec filtre optionnel"""
|
||||
return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_prospect(self, code: str) -> Optional[Dict]:
|
||||
"""Lecture d'un prospect par code"""
|
||||
return self._post("/sage/prospects/get", {"code": code}).get("data")
|
||||
|
||||
def lister_fournisseurs(self, filtre: str = "") -> List[Dict]:
|
||||
"""Liste tous les fournisseurs avec filtre optionnel"""
|
||||
return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", [])
|
||||
|
||||
def lire_fournisseur(self, code: str) -> Optional[Dict]:
|
||||
"""Lecture d'un fournisseur par code"""
|
||||
return self._post("/sage/fournisseurs/get", {"code": code}).get("data")
|
||||
|
||||
def creer_fournisseur(self, fournisseur_data: Dict) -> Dict:
|
||||
|
|
@ -238,43 +198,36 @@ class SageGatewayClient:
|
|||
def lister_avoirs(
|
||||
self, limit: int = 100, statut: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
"""Liste tous les avoirs"""
|
||||
payload = {"limit": limit}
|
||||
if statut is not None:
|
||||
payload["statut"] = statut
|
||||
return self._post("/sage/avoirs/list", payload).get("data", [])
|
||||
|
||||
def lire_avoir(self, numero: str) -> Optional[Dict]:
|
||||
"""Lecture d'un avoir avec ses lignes"""
|
||||
return self._post("/sage/avoirs/get", {"code": numero}).get("data")
|
||||
|
||||
def lister_livraisons(
|
||||
self, limit: int = 100, statut: Optional[int] = None
|
||||
) -> List[Dict]:
|
||||
"""Liste tous les bons de livraison"""
|
||||
payload = {"limit": limit}
|
||||
if statut is not None:
|
||||
payload["statut"] = statut
|
||||
return self._post("/sage/livraisons/list", payload).get("data", [])
|
||||
|
||||
def lire_livraison(self, numero: str) -> Optional[Dict]:
|
||||
"""Lecture d'une livraison avec ses lignes"""
|
||||
return self._post("/sage/livraisons/get", {"code": numero}).get("data")
|
||||
|
||||
def refresh_cache(self) -> Dict:
|
||||
"""Force le rafraîchissement du cache Windows"""
|
||||
return self._post("/sage/cache/refresh")
|
||||
|
||||
def get_cache_info(self) -> Dict:
|
||||
"""Récupère les infos du cache Windows"""
|
||||
return self._get("/sage/cache/info").get("data", {})
|
||||
|
||||
def health(self) -> dict:
|
||||
"""Health check de la gateway Windows"""
|
||||
try:
|
||||
r = requests.get(f"{self.url}/health", timeout=5)
|
||||
return r.json()
|
||||
except:
|
||||
except Exception:
|
||||
return {"status": "down"}
|
||||
|
||||
def creer_client(self, client_data: Dict) -> Dict:
|
||||
|
|
@ -412,94 +365,45 @@ class SageGatewayClient:
|
|||
logger.error(f"Erreur lecture mouvement {numero}: {e}")
|
||||
return None
|
||||
|
||||
def lister_modeles_disponibles(self) -> Dict:
|
||||
"""Liste les modèles Crystal Reports disponibles"""
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{self.url}/sage/modeles/list", headers=self.headers, timeout=30
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json().get("data", {})
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f" Erreur listage modèles: {e}")
|
||||
raise
|
||||
|
||||
def generer_pdf_document(
|
||||
self, numero: str, type_doc: int, modele: str = None, base64_encode: bool = True
|
||||
) -> Union[bytes, str, Dict]:
|
||||
try:
|
||||
params = {"type_doc": type_doc, "base64_encode": base64_encode}
|
||||
|
||||
if modele:
|
||||
params["modele"] = modele
|
||||
|
||||
r = requests.get(
|
||||
f"{self.url}/sage/documents/{numero}/pdf",
|
||||
params=params,
|
||||
headers=self.headers,
|
||||
timeout=60,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
if base64_encode:
|
||||
return r.json().get("data", {})
|
||||
else:
|
||||
return r.content
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f" Erreur génération PDF: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
||||
def creer_contact(self, contact_data: Dict) -> Dict:
|
||||
return self._post("/sage/contacts/create", contact_data)
|
||||
|
||||
|
||||
def lister_contacts(self, numero: str) -> List[Dict]:
|
||||
return self._post("/sage/contacts/list", {"numero": numero}).get("data", [])
|
||||
|
||||
|
||||
def obtenir_contact(self, numero: str, contact_numero: int) -> Dict:
|
||||
result = self._post("/sage/contacts/get", {
|
||||
"numero": numero,
|
||||
"contact_numero": contact_numero
|
||||
})
|
||||
result = self._post(
|
||||
"/sage/contacts/get", {"numero": numero, "contact_numero": contact_numero}
|
||||
)
|
||||
return result.get("data") if result.get("success") else None
|
||||
|
||||
|
||||
def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict:
|
||||
return self._post("/sage/contacts/update", {
|
||||
"numero": numero,
|
||||
"contact_numero": contact_numero,
|
||||
"updates": updates
|
||||
})
|
||||
|
||||
return self._post(
|
||||
"/sage/contacts/update",
|
||||
{"numero": numero, "contact_numero": contact_numero, "updates": updates},
|
||||
)
|
||||
|
||||
def supprimer_contact(self, numero: str, contact_numero: int) -> Dict:
|
||||
return self._post("/sage/contacts/delete", {
|
||||
"numero": numero,
|
||||
"contact_numero": contact_numero
|
||||
})
|
||||
|
||||
return self._post(
|
||||
"/sage/contacts/delete",
|
||||
{"numero": numero, "contact_numero": contact_numero},
|
||||
)
|
||||
|
||||
def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict:
|
||||
return self._post("/sage/contacts/set-default", {
|
||||
"numero": numero,
|
||||
"contact_numero": contact_numero
|
||||
})
|
||||
|
||||
|
||||
def lister_tiers(self, type_tiers: Optional[str] = None, filtre: str = "") -> List[Dict]:
|
||||
return self._post("/sage/tiers/list", {
|
||||
"type_tiers": type_tiers,
|
||||
"filtre": filtre
|
||||
}).get("data", [])
|
||||
return self._post(
|
||||
"/sage/contacts/set-default",
|
||||
{"numero": numero, "contact_numero": contact_numero},
|
||||
)
|
||||
|
||||
def lister_tiers(
|
||||
self, type_tiers: Optional[str] = None, filtre: str = ""
|
||||
) -> List[Dict]:
|
||||
return self._post(
|
||||
"/sage/tiers/list", {"type_tiers": type_tiers, "filtre": filtre}
|
||||
).get("data", [])
|
||||
|
||||
def lire_tiers(self, code: str) -> Optional[Dict]:
|
||||
return self._post("/sage/tiers/get", {
|
||||
"code": code
|
||||
}).get("data")
|
||||
return self._post("/sage/tiers/get", {"code": code}).get("data")
|
||||
|
||||
|
||||
sage_client = SageGatewayClient()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
from schemas.tiers.tiers import (
|
||||
TiersDetails,
|
||||
TypeTiersInt
|
||||
)
|
||||
from schemas.tiers.tiers import TiersDetails, TypeTiersInt
|
||||
from schemas.tiers.type_tiers import TypeTiers
|
||||
from schemas.schema_mixte import BaremeRemiseResponse
|
||||
from schemas.user import UserResponse
|
||||
|
|
@ -9,52 +6,27 @@ from schemas.tiers.clients import (
|
|||
ClientCreateRequest,
|
||||
ClientDetails,
|
||||
ClientResponse,
|
||||
ClientUpdateRequest
|
||||
ClientUpdateRequest,
|
||||
)
|
||||
from schemas.tiers.contact import (
|
||||
Contact,
|
||||
ContactCreate,
|
||||
ContactUpdate
|
||||
)
|
||||
from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate
|
||||
from schemas.tiers.fournisseurs import (
|
||||
FournisseurCreateAPIRequest,
|
||||
FournisseurDetails,
|
||||
FournisseurUpdateRequest
|
||||
)
|
||||
from schemas.documents.avoirs import (
|
||||
AvoirCreateRequest,
|
||||
AvoirUpdateRequest
|
||||
)
|
||||
from schemas.documents.commandes import (
|
||||
CommandeCreateRequest,
|
||||
CommandeUpdateRequest
|
||||
FournisseurUpdateRequest,
|
||||
)
|
||||
from schemas.documents.avoirs import AvoirCreateRequest, AvoirUpdateRequest
|
||||
from schemas.documents.commandes import CommandeCreateRequest, CommandeUpdateRequest
|
||||
from schemas.documents.devis import (
|
||||
DevisRequest,
|
||||
DevisResponse,
|
||||
DevisUpdateRequest,
|
||||
RelanceDevisRequest
|
||||
)
|
||||
from schemas.documents.documents import (
|
||||
TypeDocument,
|
||||
TypeDocumentSQL
|
||||
)
|
||||
from schemas.documents.email import (
|
||||
StatutEmail,
|
||||
EmailEnvoiRequest
|
||||
)
|
||||
from schemas.documents.factures import (
|
||||
FactureCreateRequest,
|
||||
FactureUpdateRequest
|
||||
)
|
||||
from schemas.documents.livraisons import (
|
||||
LivraisonCreateRequest,
|
||||
LivraisonUpdateRequest
|
||||
)
|
||||
from schemas.documents.universign import (
|
||||
SignatureRequest,
|
||||
StatutSignature
|
||||
RelanceDevisRequest,
|
||||
)
|
||||
from schemas.documents.documents import TypeDocument, TypeDocumentSQL
|
||||
from schemas.documents.email import StatutEmail, EmailEnvoiRequest
|
||||
from schemas.documents.factures import FactureCreateRequest, FactureUpdateRequest
|
||||
from schemas.documents.livraisons import LivraisonCreateRequest, LivraisonUpdateRequest
|
||||
from schemas.documents.universign import SignatureRequest, StatutSignature
|
||||
from schemas.articles.articles import (
|
||||
ArticleCreateRequest,
|
||||
ArticleResponse,
|
||||
|
|
@ -62,12 +34,12 @@ from schemas.articles.articles import (
|
|||
ArticleListResponse,
|
||||
EntreeStockRequest,
|
||||
SortieStockRequest,
|
||||
MouvementStockResponse
|
||||
MouvementStockResponse,
|
||||
)
|
||||
from schemas.articles.famille_article import (
|
||||
FamilleResponse,
|
||||
FamilleCreateRequest,
|
||||
FamilleListResponse
|
||||
FamilleListResponse,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -114,5 +86,5 @@ __all__ = [
|
|||
"FamilleCreateRequest",
|
||||
"FamilleListResponse",
|
||||
"ContactCreate",
|
||||
"ContactUpdate"
|
||||
"ContactUpdate",
|
||||
]
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import List, Optional
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
|
||||
class EmplacementStockModel(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
class FamilleCreateRequest(BaseModel):
|
||||
"""Schéma pour création de famille d'articles"""
|
||||
|
||||
|
|
@ -30,7 +27,6 @@ class FamilleCreateRequest(BaseModel):
|
|||
}
|
||||
|
||||
|
||||
|
||||
class FamilleResponse(BaseModel):
|
||||
"""Modèle complet d'une famille avec données comptables et fournisseur"""
|
||||
|
||||
|
|
@ -57,63 +53,109 @@ class FamilleResponse(BaseModel):
|
|||
nature: Optional[int] = Field(None, description="Nature de la famille")
|
||||
pays: Optional[str] = Field(None, description="Pays d'origine")
|
||||
|
||||
categorie_1: Optional[int] = Field(None, description="Catégorie comptable 1 (CL_No1)")
|
||||
categorie_2: Optional[int] = Field(None, description="Catégorie comptable 2 (CL_No2)")
|
||||
categorie_3: Optional[int] = Field(None, description="Catégorie comptable 3 (CL_No3)")
|
||||
categorie_4: Optional[int] = Field(None, description="Catégorie comptable 4 (CL_No4)")
|
||||
categorie_1: Optional[int] = Field(
|
||||
None, description="Catégorie comptable 1 (CL_No1)"
|
||||
)
|
||||
categorie_2: Optional[int] = Field(
|
||||
None, description="Catégorie comptable 2 (CL_No2)"
|
||||
)
|
||||
categorie_3: Optional[int] = Field(
|
||||
None, description="Catégorie comptable 3 (CL_No3)"
|
||||
)
|
||||
categorie_4: Optional[int] = Field(
|
||||
None, description="Catégorie comptable 4 (CL_No4)"
|
||||
)
|
||||
|
||||
stat_01: Optional[str] = Field(None, description="Statistique libre 1")
|
||||
stat_02: Optional[str] = Field(None, description="Statistique libre 2")
|
||||
stat_03: Optional[str] = Field(None, description="Statistique libre 3")
|
||||
stat_04: Optional[str] = Field(None, description="Statistique libre 4")
|
||||
stat_05: Optional[str] = Field(None, description="Statistique libre 5")
|
||||
hors_statistique: Optional[bool] = Field(None, description="Exclue des statistiques")
|
||||
hors_statistique: Optional[bool] = Field(
|
||||
None, description="Exclue des statistiques"
|
||||
)
|
||||
|
||||
vente_debit: Optional[bool] = Field(None, description="Vente au débit")
|
||||
non_imprimable: Optional[bool] = Field(None, description="Non imprimable sur documents")
|
||||
non_imprimable: Optional[bool] = Field(
|
||||
None, description="Non imprimable sur documents"
|
||||
)
|
||||
contremarque: Optional[bool] = Field(None, description="Article en contremarque")
|
||||
fact_poids: Optional[bool] = Field(None, description="Facturation au poids")
|
||||
fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire")
|
||||
publie: Optional[bool] = Field(None, description="Publié (e-commerce)")
|
||||
|
||||
racine_reference: Optional[str] = Field(None, description="Racine pour génération auto de références")
|
||||
racine_code_barre: Optional[str] = Field(None, description="Racine pour génération auto de codes-barres")
|
||||
racine_reference: Optional[str] = Field(
|
||||
None, description="Racine pour génération auto de références"
|
||||
)
|
||||
racine_code_barre: Optional[str] = Field(
|
||||
None, description="Racine pour génération auto de codes-barres"
|
||||
)
|
||||
raccourci: Optional[str] = Field(None, description="Raccourci clavier")
|
||||
|
||||
sous_traitance: Optional[bool] = Field(None, description="Famille en sous-traitance")
|
||||
sous_traitance: Optional[bool] = Field(
|
||||
None, description="Famille en sous-traitance"
|
||||
)
|
||||
fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)")
|
||||
criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)")
|
||||
|
||||
compte_vente: Optional[str] = Field(None, description="Compte général de vente")
|
||||
compte_auxiliaire_vente: Optional[str] = Field(None, description="Compte auxiliaire de vente")
|
||||
compte_auxiliaire_vente: Optional[str] = Field(
|
||||
None, description="Compte auxiliaire de vente"
|
||||
)
|
||||
tva_vente_1: Optional[str] = Field(None, description="Code TVA vente principal")
|
||||
tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire")
|
||||
tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire")
|
||||
type_facture_vente: Optional[int] = Field(None, description="Type de facture vente")
|
||||
|
||||
compte_achat: Optional[str] = Field(None, description="Compte général d'achat")
|
||||
compte_auxiliaire_achat: Optional[str] = Field(None, description="Compte auxiliaire d'achat")
|
||||
compte_auxiliaire_achat: Optional[str] = Field(
|
||||
None, description="Compte auxiliaire d'achat"
|
||||
)
|
||||
tva_achat_1: Optional[str] = Field(None, description="Code TVA achat principal")
|
||||
tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire")
|
||||
tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire")
|
||||
type_facture_achat: Optional[int] = Field(None, description="Type de facture achat")
|
||||
|
||||
compte_stock: Optional[str] = Field(None, description="Compte de stock")
|
||||
compte_auxiliaire_stock: Optional[str] = Field(None, description="Compte auxiliaire de stock")
|
||||
compte_auxiliaire_stock: Optional[str] = Field(
|
||||
None, description="Compte auxiliaire de stock"
|
||||
)
|
||||
|
||||
fournisseur_principal: Optional[str] = Field(None, description="N° compte fournisseur principal")
|
||||
fournisseur_unite: Optional[str] = Field(None, description="Unité d'achat fournisseur")
|
||||
fournisseur_conversion: Optional[float] = Field(None, description="Coefficient de conversion")
|
||||
fournisseur_delai_appro: Optional[int] = Field(None, description="Délai d'approvisionnement (jours)")
|
||||
fournisseur_garantie: Optional[int] = Field(None, description="Garantie fournisseur (mois)")
|
||||
fournisseur_colisage: Optional[int] = Field(None, description="Colisage fournisseur")
|
||||
fournisseur_qte_mini: Optional[float] = Field(None, description="Quantité minimum de commande")
|
||||
fournisseur_principal: Optional[str] = Field(
|
||||
None, description="N° compte fournisseur principal"
|
||||
)
|
||||
fournisseur_unite: Optional[str] = Field(
|
||||
None, description="Unité d'achat fournisseur"
|
||||
)
|
||||
fournisseur_conversion: Optional[float] = Field(
|
||||
None, description="Coefficient de conversion"
|
||||
)
|
||||
fournisseur_delai_appro: Optional[int] = Field(
|
||||
None, description="Délai d'approvisionnement (jours)"
|
||||
)
|
||||
fournisseur_garantie: Optional[int] = Field(
|
||||
None, description="Garantie fournisseur (mois)"
|
||||
)
|
||||
fournisseur_colisage: Optional[int] = Field(
|
||||
None, description="Colisage fournisseur"
|
||||
)
|
||||
fournisseur_qte_mini: Optional[float] = Field(
|
||||
None, description="Quantité minimum de commande"
|
||||
)
|
||||
fournisseur_qte_mont: Optional[float] = Field(None, description="Quantité montant")
|
||||
fournisseur_devise: Optional[int] = Field(None, description="Devise fournisseur (0=Euro)")
|
||||
fournisseur_remise: Optional[float] = Field(None, description="Remise fournisseur (%)")
|
||||
fournisseur_type_remise: Optional[int] = Field(None, description="Type de remise (0=%, 1=Montant)")
|
||||
fournisseur_devise: Optional[int] = Field(
|
||||
None, description="Devise fournisseur (0=Euro)"
|
||||
)
|
||||
fournisseur_remise: Optional[float] = Field(
|
||||
None, description="Remise fournisseur (%)"
|
||||
)
|
||||
fournisseur_type_remise: Optional[int] = Field(
|
||||
None, description="Type de remise (0=%, 1=Montant)"
|
||||
)
|
||||
|
||||
nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille")
|
||||
nb_articles: Optional[int] = Field(
|
||||
None, description="Nombre d'articles dans la famille"
|
||||
)
|
||||
|
||||
FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille")
|
||||
FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé")
|
||||
|
|
@ -189,13 +231,14 @@ class FamilleResponse(BaseModel):
|
|||
"fournisseur_devise": 0,
|
||||
"fournisseur_remise": 5.0,
|
||||
"fournisseur_type_remise": 0,
|
||||
"nb_articles": 156
|
||||
"nb_articles": 156,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class FamilleListResponse(BaseModel):
|
||||
"""Réponse pour la liste des familles"""
|
||||
|
||||
familles: list[FamilleResponse]
|
||||
total: int
|
||||
filtre: Optional[str] = None
|
||||
|
|
@ -207,7 +250,6 @@ class FamilleListResponse(BaseModel):
|
|||
"familles": [],
|
||||
"total": 42,
|
||||
"filtre": "ELECT",
|
||||
"inclure_totaux": False
|
||||
"inclure_totaux": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
class LigneAvoir(BaseModel):
|
||||
"""Ligne d'avoir"""
|
||||
|
||||
article_code: str
|
||||
quantite: float
|
||||
remise_pourcentage: Optional[float] = 0.0
|
||||
|
|
@ -18,8 +14,6 @@ class LigneAvoir(BaseModel):
|
|||
|
||||
|
||||
class AvoirCreateRequest(BaseModel):
|
||||
"""Création d'un avoir"""
|
||||
|
||||
client_id: str
|
||||
date_avoir: Optional[date] = None
|
||||
date_livraison: Optional[date] = None
|
||||
|
|
@ -45,8 +39,6 @@ class AvoirCreateRequest(BaseModel):
|
|||
|
||||
|
||||
class AvoirUpdateRequest(BaseModel):
|
||||
"""Modification d'un avoir existant"""
|
||||
|
||||
date_avoir: Optional[date] = None
|
||||
date_livraison: Optional[date] = None
|
||||
lignes: Optional[List[LigneAvoir]] = None
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
class LigneCommande(BaseModel):
|
||||
"""Ligne de commande"""
|
||||
|
||||
article_code: str
|
||||
quantite: float
|
||||
remise_pourcentage: Optional[float] = 0.0
|
||||
|
|
@ -18,8 +14,6 @@ class LigneCommande(BaseModel):
|
|||
|
||||
|
||||
class CommandeCreateRequest(BaseModel):
|
||||
"""Création d'une commande"""
|
||||
|
||||
client_id: str
|
||||
date_commande: Optional[date] = None
|
||||
date_livraison: Optional[date] = None
|
||||
|
|
@ -45,8 +39,6 @@ class CommandeCreateRequest(BaseModel):
|
|||
|
||||
|
||||
class CommandeUpdateRequest(BaseModel):
|
||||
"""Modification d'une commande existante"""
|
||||
|
||||
date_commande: Optional[date] = None
|
||||
date_livraison: Optional[date] = None
|
||||
lignes: Optional[List[LigneCommande]] = None
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
class LigneDevis(BaseModel):
|
||||
article_code: str
|
||||
|
|
@ -31,7 +30,6 @@ class DevisResponse(BaseModel):
|
|||
nb_lignes: int
|
||||
|
||||
|
||||
|
||||
class DevisUpdateRequest(BaseModel):
|
||||
"""Modèle pour modification d'un devis existant"""
|
||||
|
||||
|
|
@ -60,7 +58,6 @@ class DevisUpdateRequest(BaseModel):
|
|||
}
|
||||
|
||||
|
||||
|
||||
class RelanceDevisRequest(BaseModel):
|
||||
doc_id: str
|
||||
message_personnalise: Optional[str] = None
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
from config import settings
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TypeDocument(int, Enum):
|
||||
DEVIS = settings.SAGE_TYPE_DEVIS
|
||||
BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
from schemas.documents.documents import TypeDocument
|
||||
|
||||
|
||||
class StatutEmail(str, Enum):
|
||||
EN_ATTENTE = "EN_ATTENTE"
|
||||
EN_COURS = "EN_COURS"
|
||||
|
|
@ -12,6 +12,7 @@ class StatutEmail(str, Enum):
|
|||
ERREUR = "ERREUR"
|
||||
BOUNCE = "BOUNCE"
|
||||
|
||||
|
||||
class EmailEnvoiRequest(BaseModel):
|
||||
destinataire: EmailStr
|
||||
cc: Optional[List[EmailStr]] = []
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
class LigneFacture(BaseModel):
|
||||
"""Ligne de facture"""
|
||||
|
||||
article_code: str
|
||||
quantite: float
|
||||
remise_pourcentage: Optional[float] = 0.0
|
||||
|
|
@ -18,8 +14,6 @@ class LigneFacture(BaseModel):
|
|||
|
||||
|
||||
class FactureCreateRequest(BaseModel):
|
||||
"""Création d'une facture"""
|
||||
|
||||
client_id: str
|
||||
date_facture: Optional[date] = None
|
||||
date_livraison: Optional[date] = None
|
||||
|
|
@ -45,8 +39,6 @@ class FactureCreateRequest(BaseModel):
|
|||
|
||||
|
||||
class FactureUpdateRequest(BaseModel):
|
||||
"""Modification d'une facture existante"""
|
||||
|
||||
date_facture: Optional[date] = None
|
||||
date_livraison: Optional[date] = None
|
||||
lignes: Optional[List[LigneFacture]] = None
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import List, Optional
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
|
||||
class LigneLivraison(BaseModel):
|
||||
"""Ligne de livraison"""
|
||||
|
||||
article_code: str
|
||||
quantite: float
|
||||
remise_pourcentage: Optional[float] = 0.0
|
||||
|
|
@ -17,8 +14,6 @@ class LigneLivraison(BaseModel):
|
|||
|
||||
|
||||
class LivraisonCreateRequest(BaseModel):
|
||||
"""Création d'une livraison"""
|
||||
|
||||
client_id: str
|
||||
date_livraison: Optional[date] = None
|
||||
date_livraison_prevue: Optional[date] = None
|
||||
|
|
@ -44,8 +39,6 @@ class LivraisonCreateRequest(BaseModel):
|
|||
|
||||
|
||||
class LivraisonUpdateRequest(BaseModel):
|
||||
"""Modification d'une livraison existante"""
|
||||
|
||||
date_livraison: Optional[date] = None
|
||||
date_livraison_prevue: Optional[date] = None
|
||||
lignes: Optional[List[LigneLivraison]] = None
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
|
||||
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
|
||||
from typing import List, Optional, Dict, ClassVar, Any
|
||||
from datetime import date, datetime
|
||||
from enum import Enum, IntEnum
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from enum import Enum
|
||||
from schemas.documents.documents import TypeDocument
|
||||
|
||||
|
||||
class StatutSignature(str, Enum):
|
||||
EN_ATTENTE = "EN_ATTENTE"
|
||||
ENVOYE = "ENVOYE"
|
||||
|
|
@ -12,6 +10,7 @@ class StatutSignature(str, Enum):
|
|||
REFUSE = "REFUSE"
|
||||
EXPIRE = "EXPIRE"
|
||||
|
||||
|
||||
class SignatureRequest(BaseModel):
|
||||
doc_id: str
|
||||
type_doc: TypeDocument
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ from pydantic import BaseModel, Field, field_validator
|
|||
from typing import List, Optional
|
||||
from schemas.tiers.contact import Contact
|
||||
|
||||
class ClientResponse(BaseModel):
|
||||
"""Modèle de réponse client simplifié (pour listes)"""
|
||||
|
||||
class ClientResponse(BaseModel):
|
||||
numero: Optional[str] = None
|
||||
intitule: Optional[str] = None
|
||||
adresse: Optional[str] = None
|
||||
|
|
@ -681,12 +680,6 @@ class ClientCreateRequest(BaseModel):
|
|||
|
||||
|
||||
class ClientUpdateRequest(BaseModel):
|
||||
"""
|
||||
Modèle pour modification d'un client existant
|
||||
TOUS les champs de ClientCreateRequest sont modifiables
|
||||
TOUS optionnels (seuls les champs fournis sont modifiés)
|
||||
"""
|
||||
|
||||
intitule: Optional[str] = Field(None, max_length=69)
|
||||
qualite: Optional[str] = Field(None, max_length=17)
|
||||
classement: Optional[str] = Field(None, max_length=17)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ from typing import Optional, ClassVar
|
|||
|
||||
|
||||
class Contact(BaseModel):
|
||||
"""Contact associé à un tiers"""
|
||||
|
||||
numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)")
|
||||
contact_numero: Optional[int] = Field(
|
||||
None, description="Numéro unique du contact (CT_No)"
|
||||
|
|
@ -52,8 +50,6 @@ class Contact(BaseModel):
|
|||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
"""Données pour créer ou modifier un contact"""
|
||||
|
||||
numero: str = Field(..., description="Code du client parent (obligatoire)")
|
||||
|
||||
civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société")
|
||||
|
|
@ -100,8 +96,6 @@ class ContactCreate(BaseModel):
|
|||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
"""Données pour modifier un contact (tous champs optionnels)"""
|
||||
|
||||
civilite: Optional[str] = None
|
||||
nom: Optional[str] = None
|
||||
prenom: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -305,8 +305,6 @@ class FournisseurCreateAPIRequest(BaseModel):
|
|||
|
||||
|
||||
class FournisseurUpdateRequest(BaseModel):
|
||||
"""Modèle pour modification d'un fournisseur existant"""
|
||||
|
||||
intitule: Optional[str] = Field(None, min_length=1, max_length=69)
|
||||
adresse: Optional[str] = Field(None, max_length=35)
|
||||
code_postal: Optional[str] = Field(None, max_length=9)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from schemas.tiers.contact import Contact
|
||||
from enum import Enum, IntEnum
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class TypeTiersInt(IntEnum):
|
||||
"""CT_Type - Type de tiers"""
|
||||
|
||||
CLIENT = 0
|
||||
FOURNISSEUR = 1
|
||||
SALARIE = 2
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ from enum import Enum
|
|||
|
||||
|
||||
class TypeTiers(str, Enum):
|
||||
"""Types de tiers possibles"""
|
||||
|
||||
ALL = "all"
|
||||
CLIENT = "client"
|
||||
FOURNISSEUR = "fournisseur"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ from typing import Optional
|
|||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""Modèle de réponse pour un utilisateur"""
|
||||
|
||||
id: str
|
||||
email: str
|
||||
nom: str
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import jwt
|
|||
import secrets
|
||||
import hashlib
|
||||
|
||||
SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" # À remplacer par settings.jwt_secret
|
||||
SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
|
@ -14,38 +14,26 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash un mot de passe avec bcrypt"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Vérifie un mot de passe contre son hash"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def generate_verification_token() -> str:
|
||||
"""Génère un token de vérification email sécurisé"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_reset_token() -> str:
|
||||
"""Génère un token de réinitialisation mot de passe"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash un refresh token pour stockage en DB"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""
|
||||
Crée un JWT access token
|
||||
|
||||
Args:
|
||||
data: Payload (doit contenir 'sub' = user_id)
|
||||
expires_delta: Durée de validité personnalisée
|
||||
"""
|
||||
to_encode = data.copy()
|
||||
|
||||
if expires_delta:
|
||||
|
|
@ -60,12 +48,6 @@ def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -
|
|||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
"""
|
||||
Crée un refresh token (JWT long terme)
|
||||
|
||||
Returns:
|
||||
Token JWT non hashé (à hasher avant stockage DB)
|
||||
"""
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
to_encode = {
|
||||
|
|
@ -81,12 +63,6 @@ def create_refresh_token(user_id: str) -> str:
|
|||
|
||||
|
||||
def decode_token(token: str) -> Optional[Dict]:
|
||||
"""
|
||||
Décode et valide un JWT
|
||||
|
||||
Returns:
|
||||
Payload si valide, None sinon
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
|
|
@ -97,12 +73,6 @@ def decode_token(token: str) -> Optional[Dict]:
|
|||
|
||||
|
||||
def validate_password_strength(password: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Valide la robustesse d'un mot de passe
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
if len(password) < 8:
|
||||
return False, "Le mot de passe doit contenir au moins 8 caractères"
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,8 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class AuthEmailService:
|
||||
"""Service d'envoi d'emails pour l'authentification"""
|
||||
|
||||
@staticmethod
|
||||
def _send_email(to: str, subject: str, html_body: str) -> bool:
|
||||
"""Envoi SMTP générique"""
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = settings.smtp_from
|
||||
|
|
@ -41,14 +38,6 @@ class AuthEmailService:
|
|||
|
||||
@staticmethod
|
||||
def send_verification_email(email: str, token: str, base_url: str) -> bool:
|
||||
"""
|
||||
Envoie l'email de vérification avec lien de confirmation
|
||||
|
||||
Args:
|
||||
email: Email du destinataire
|
||||
token: Token de vérification
|
||||
base_url: URL de base de l'API (ex: https://api.votredomaine.com)
|
||||
"""
|
||||
verification_link = f"{base_url}/auth/verify-email?token={token}"
|
||||
|
||||
html_body = f"""
|
||||
|
|
@ -112,14 +101,6 @@ class AuthEmailService:
|
|||
|
||||
@staticmethod
|
||||
def send_password_reset_email(email: str, token: str, base_url: str) -> bool:
|
||||
"""
|
||||
Envoie l'email de réinitialisation de mot de passe
|
||||
|
||||
Args:
|
||||
email: Email du destinataire
|
||||
token: Token de reset
|
||||
base_url: URL de base du frontend
|
||||
"""
|
||||
reset_link = f"{base_url}/reset?token={token}"
|
||||
|
||||
html_body = f"""
|
||||
|
|
@ -183,7 +164,6 @@ class AuthEmailService:
|
|||
|
||||
@staticmethod
|
||||
def send_password_changed_notification(email: str) -> bool:
|
||||
"""Notification après changement de mot de passe réussi"""
|
||||
html_body = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
from typing import Dict
|
||||
from config import settings
|
||||
import logging
|
||||
|
|
@ -14,6 +13,7 @@ from database import EmailLog, StatutEmail as StatutEmailEnum
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def universign_envoyer(
|
||||
doc_id: str,
|
||||
pdf_bytes: bytes,
|
||||
|
|
@ -22,7 +22,6 @@ async def universign_envoyer(
|
|||
doc_data: Dict,
|
||||
session: AsyncSession,
|
||||
) -> Dict:
|
||||
|
||||
from email_queue import email_queue
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
from typing import Optional, Union
|
||||
|
||||
|
||||
def normaliser_type_tiers(type_tiers: Union[str, int, None]) -> Optional[str]:
|
||||
if type_tiers is None:
|
||||
return None
|
||||
|
||||
# Conversion int → string
|
||||
mapping_int = {
|
||||
0: "client",
|
||||
1: "fournisseur",
|
||||
2: "prospect",
|
||||
3: "all"
|
||||
}
|
||||
mapping_int = {0: "client", 1: "fournisseur", 2: "prospect", 3: "all"}
|
||||
|
||||
# Si c'est un int, on convertit
|
||||
if isinstance(type_tiers, int):
|
||||
return mapping_int.get(type_tiers, "all")
|
||||
|
||||
# Si c'est une string qui ressemble à un int
|
||||
if isinstance(type_tiers, str) and type_tiers.isdigit():
|
||||
return mapping_int.get(int(type_tiers), "all")
|
||||
|
||||
# Sinon on retourne tel quel (string normale)
|
||||
return type_tiers.lower() if isinstance(type_tiers, str) else None
|
||||
Loading…
Reference in a new issue