From 792d771667fdead0cbd32fa0e35ea65500db4e69 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 30 Dec 2025 18:35:47 +0300 Subject: [PATCH] refactor: reorganize database models and clean up schemas --- api.py | 164 ++++++++-------- core/dependencies.py | 21 +-- create_admin.py | 7 +- database/Enum/status.py | 18 ++ database/__init__.py | 21 ++- database/db_config.py | 6 - database/models.py | 280 ---------------------------- database/models/email.py | 43 +++++ database/models/generic_model.py | 91 +++++++++ database/models/sage_config.py | 54 ++++++ database/models/signature.py | 44 +++++ database/models/user.py | 39 ++++ database/models/workflow.py | 37 ++++ email_queue.py | 196 ++++++++++++------- init_db.py | 23 +-- routes/auth.py | 33 +--- sage_client.py | 172 ++++------------- schemas/__init__.py | 60 ++---- schemas/articles/articles.py | 5 +- schemas/articles/famille_article.py | 138 +++++++++----- schemas/documents/avoirs.py | 14 +- schemas/documents/commandes.py | 14 +- schemas/documents/devis.py | 11 +- schemas/documents/documents.py | 2 +- schemas/documents/email.py | 11 +- schemas/documents/factures.py | 14 +- schemas/documents/livraisons.py | 13 +- schemas/documents/universign.py | 9 +- schemas/tiers/clients.py | 9 +- schemas/tiers/contact.py | 6 - schemas/tiers/fournisseurs.py | 2 - schemas/tiers/tiers.py | 4 +- schemas/tiers/type_tiers.py | 2 - schemas/user.py | 2 - security/auth.py | 32 +--- services/email_service.py | 20 -- utils/generic_functions.py | 3 +- utils/normalization.py | 22 +-- 38 files changed, 737 insertions(+), 905 deletions(-) create mode 100644 database/Enum/status.py delete mode 100644 database/models.py create mode 100644 database/models/email.py create mode 100644 database/models/generic_model.py create mode 100644 database/models/sage_config.py create mode 100644 database/models/signature.py create mode 100644 database/models/user.py create mode 100644 database/models/workflow.py diff --git a/api.py b/api.py index 17ae027..91e5464 100644 --- a/api.py +++ b/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, @@ -135,7 +129,7 @@ async def universign_envoyer( session: AsyncSession, ) -> dict: import requests - + try: api_key = settings.universign_api_key api_url = settings.universign_api_url @@ -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,13 +277,23 @@ 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}") return {"statut": "ERREUR", "error": str(e)} - + @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients(query: Optional[str] = Query(None)): @@ -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( @@ -831,7 +852,7 @@ async def changer_statut_document( document_type_sql = None document_type_code = None type_doc_normalized = None - + try: match type_doc: case 0: @@ -883,16 +904,22 @@ async def changer_statut_document( f"Le devis {numero} est {statuts_devis.get(statut_actuel, 'verrouillé')} " f"et ne peut plus changer de statut", ) - + 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, @@ -925,7 +958,8 @@ async def changer_statut_document( except Exception as e: 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}") diff --git a/core/dependencies.py b/core/dependencies.py index 69b6751..039081c 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -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( diff --git a/create_admin.py b/create_admin.py index 41b3da3..8513574 100644 --- a/create_admin.py +++ b/create_admin.py @@ -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") diff --git a/database/Enum/status.py b/database/Enum/status.py new file mode 100644 index 0000000..c452f70 --- /dev/null +++ b/database/Enum/status.py @@ -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" diff --git a/database/__init__.py b/database/__init__.py index 829aa19..cc8615a 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -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", ] diff --git a/database/db_config.py b/database/db_config.py index eb7d347..ee1673f 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -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") diff --git a/database/models.py b/database/models.py deleted file mode 100644 index 77c1b30..0000000 --- a/database/models.py +++ /dev/null @@ -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"" - - -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"" - - -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"" - - -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"" - - -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"" - - - - -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"" - - -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"" - - -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"" diff --git a/database/models/email.py b/database/models/email.py new file mode 100644 index 0000000..99318e1 --- /dev/null +++ b/database/models/email.py @@ -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"" diff --git a/database/models/generic_model.py b/database/models/generic_model.py new file mode 100644 index 0000000..840b614 --- /dev/null +++ b/database/models/generic_model.py @@ -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"" + + +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"" + + +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"" + + +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"" diff --git a/database/models/sage_config.py b/database/models/sage_config.py new file mode 100644 index 0000000..f6ed363 --- /dev/null +++ b/database/models/sage_config.py @@ -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"" diff --git a/database/models/signature.py b/database/models/signature.py new file mode 100644 index 0000000..c7fa7dc --- /dev/null +++ b/database/models/signature.py @@ -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"" diff --git a/database/models/user.py b/database/models/user.py new file mode 100644 index 0000000..7c73cd0 --- /dev/null +++ b/database/models/user.py @@ -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"" diff --git a/database/models/workflow.py b/database/models/workflow.py new file mode 100644 index 0000000..018aba2 --- /dev/null +++ b/database/models/workflow.py @@ -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"" diff --git a/email_queue.py b/email_queue.py index 44bbb77..ee25aa5 100644 --- a/email_queue.py +++ b/email_queue.py @@ -20,22 +20,22 @@ 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] prefix = { "INFO": "[INFO]", - "SUCCESS": "[SUCCESS]", + "SUCCESS": "[SUCCESS]", "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,12 +141,14 @@ 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) debug_log(f"ÉCHEC ENVOI: {error_msg}", "ERROR") - + email_log.statut = StatutEmail.ERREUR email_log.derniere_erreur = error_msg[:1000] @@ -157,23 +162,28 @@ 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() msg["From"] = settings.smtp_from 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") @@ -218,50 +231,60 @@ class EmailQueue: debug_log("═══════════════════════════════════════════", "STEP") debug_log(" DÉBUT ENVOI SMTP ULTRA DEBUG", "STEP") debug_log("═══════════════════════════════════════════", "STEP") - + # ═══ CONFIGURATION ═══ debug_log("CONFIGURATION SMTP:", "DATA") 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']}") - + server = None - + try: # ═══ É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") start_time = time.time() - + 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,22 +304,26 @@ 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() debug_log(" → Re-EHLO après TLS: OK", "SUCCESS") else: debug_log(" → Serveur ne supporte PAS STARTTLS!", "WARN") - + except smtplib.SMTPNotSupportedError: debug_log(" → STARTTLS non supporté par le serveur", "WARN") except ssl.SSLError as e: @@ -312,31 +339,46 @@ class EmailQueue: debug_log("ÉTAPE 5/7: Authentification...", "STEP") if settings.smtp_user and settings.smtp_password: debug_log(f" → Tentative login avec: {settings.smtp_user}") - + 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") - - raise Exception(f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}") - + debug_log( + " → 530 = Authentification requise mais échouée", "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") raise @@ -349,23 +391,28 @@ class EmailQueue: debug_log(f" → To: {msg['To']}") debug_log(f" → Subject: {msg['Subject']}") debug_log(f" → Taille message: {len(msg.as_string())} bytes") - + try: # send_message retourne un dict des destinataires refusés refused = server.send_message(msg) - + if refused: debug_log(f" → DESTINATAIRES REFUSÉS: {refused}", "ERROR") raise Exception(f"Destinataires refusés: {refused}") else: debug_log(" → Message envoyé avec succès!", "SUCCESS") - + except smtplib.SMTPRecipientsRefused as e: 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") @@ -388,18 +435,17 @@ class EmailQueue: debug_log(f" ÉCHEC ENVOI SMTP: {type(e).__name__}", "ERROR") debug_log(f" Message: {str(e)}", "ERROR") debug_log("═══════════════════════════════════════════", "ERROR") - + # Fermer la connexion si elle existe 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 @@ -516,4 +568,4 @@ class EmailQueue: return buffer.read() -email_queue = EmailQueue() \ No newline at end of file +email_queue = EmailQueue() diff --git a/init_db.py b/init_db.py index 2f1d61a..89dd700 100644 --- a/init_db.py +++ b/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 diff --git a/routes/auth.py b/routes/auth.py index c59bd20..d037d29 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -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, diff --git a/sage_client.py b/sage_client.py index 2303fe5..bd8c409 100644 --- a/sage_client.py +++ b/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 - }) - 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 - }) - - - def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: - 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", []) + def obtenir_contact(self, numero: str, contact_numero: int) -> Dict: + 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}, + ) + + def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: + 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", []) 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() diff --git a/schemas/__init__.py b/schemas/__init__.py index 006d7b8..8197428 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -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" -] \ No newline at end of file + "ContactUpdate", +] diff --git a/schemas/articles/articles.py b/schemas/articles/articles.py index 0c98d21..1077db9 100644 --- a/schemas/articles/articles.py +++ b/schemas/articles/articles.py @@ -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): diff --git a/schemas/articles/famille_article.py b/schemas/articles/famille_article.py index 90fd7ca..875435f 100644 --- a/schemas/articles/famille_article.py +++ b/schemas/articles/famille_article.py @@ -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""" @@ -40,81 +36,127 @@ class FamilleResponse(BaseModel): type_libelle: str = Field(..., description="Libellé du type") est_total: bool = Field(..., description="True si type Total") est_detail: bool = Field(..., description="True si type Détail") - + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") unite_poids: Optional[str] = Field(None, description="Unité de poids") coef: Optional[float] = Field(None, description="Coefficient multiplicateur") - + suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") delai: Optional[int] = Field(None, description="Délai de livraison (jours)") nb_colis: Optional[int] = Field(None, description="Nombre de colis") - + code_fiscal: Optional[str] = Field(None, description="Code fiscal par défaut") escompte: Optional[bool] = Field(None, description="Escompte autorisé") - + est_centrale: Optional[bool] = Field(None, description="Famille d'achat centralisé") 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") - - 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") + 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_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)") - - nb_articles: Optional[int] = Field(None, description="Nombre d'articles dans la famille") - + 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" + ) + FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille") FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé") FA_Type: Optional[int] = Field(None, description="[Legacy] Type") @@ -189,25 +231,25 @@ 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 inclure_totaux: bool = True - + class Config: json_schema_extra = { "example": { "familles": [], "total": 42, "filtre": "ELECT", - "inclure_totaux": False + "inclure_totaux": False, } } - \ No newline at end of file diff --git a/schemas/documents/avoirs.py b/schemas/documents/avoirs.py index 75c7786..d430a6b 100644 --- a/schemas/documents/avoirs.py +++ b/schemas/documents/avoirs.py @@ -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 diff --git a/schemas/documents/commandes.py b/schemas/documents/commandes.py index 4dc8b15..d16d98e 100644 --- a/schemas/documents/commandes.py +++ b/schemas/documents/commandes.py @@ -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 diff --git a/schemas/documents/devis.py b/schemas/documents/devis.py index f4c938a..63f602b 100644 --- a/schemas/documents/devis.py +++ b/schemas/documents/devis.py @@ -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 \ No newline at end of file + message_personnalise: Optional[str] = None diff --git a/schemas/documents/documents.py b/schemas/documents/documents.py index abbdbf5..95a933d 100644 --- a/schemas/documents/documents.py +++ b/schemas/documents/documents.py @@ -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 diff --git a/schemas/documents/email.py b/schemas/documents/email.py index 8fd425c..43b1526 100644 --- a/schemas/documents/email.py +++ b/schemas/documents/email.py @@ -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]] = [] @@ -19,4 +20,4 @@ class EmailEnvoiRequest(BaseModel): sujet: str corps_html: str document_ids: Optional[List[str]] = None - type_document: Optional[TypeDocument] = None \ No newline at end of file + type_document: Optional[TypeDocument] = None diff --git a/schemas/documents/factures.py b/schemas/documents/factures.py index fb3e927..d90295f 100644 --- a/schemas/documents/factures.py +++ b/schemas/documents/factures.py @@ -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 diff --git a/schemas/documents/livraisons.py b/schemas/documents/livraisons.py index 7d77001..f660e90 100644 --- a/schemas/documents/livraisons.py +++ b/schemas/documents/livraisons.py @@ -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 diff --git a/schemas/documents/universign.py b/schemas/documents/universign.py index fd3d852..3c514ce 100644 --- a/schemas/documents/universign.py +++ b/schemas/documents/universign.py @@ -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 diff --git a/schemas/tiers/clients.py b/schemas/tiers/clients.py index 570f8d2..dc4dcb9 100644 --- a/schemas/tiers/clients.py +++ b/schemas/tiers/clients.py @@ -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) diff --git a/schemas/tiers/contact.py b/schemas/tiers/contact.py index 3676872..b5393d8 100644 --- a/schemas/tiers/contact.py +++ b/schemas/tiers/contact.py @@ -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 diff --git a/schemas/tiers/fournisseurs.py b/schemas/tiers/fournisseurs.py index af880dc..21042c9 100644 --- a/schemas/tiers/fournisseurs.py +++ b/schemas/tiers/fournisseurs.py @@ -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) diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py index badfe78..41c7b98 100644 --- a/schemas/tiers/tiers.py +++ b/schemas/tiers/tiers.py @@ -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 diff --git a/schemas/tiers/type_tiers.py b/schemas/tiers/type_tiers.py index 8c5d423..809005f 100644 --- a/schemas/tiers/type_tiers.py +++ b/schemas/tiers/type_tiers.py @@ -2,8 +2,6 @@ from enum import Enum class TypeTiers(str, Enum): - """Types de tiers possibles""" - ALL = "all" CLIENT = "client" FOURNISSEUR = "fournisseur" diff --git a/schemas/user.py b/schemas/user.py index 1682184..96b99db 100644 --- a/schemas/user.py +++ b/schemas/user.py @@ -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 diff --git a/security/auth.py b/security/auth.py index 05b8d8a..7821a52 100644 --- a/security/auth.py +++ b/security/auth.py @@ -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" diff --git a/services/email_service.py b/services/email_service.py index b234a5f..7550e1f 100644 --- a/services/email_service.py +++ b/services/email_service.py @@ -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 = """ diff --git a/utils/generic_functions.py b/utils/generic_functions.py index 3d87cc4..f7a8e2d 100644 --- a/utils/generic_functions.py +++ b/utils/generic_functions.py @@ -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: diff --git a/utils/normalization.py b/utils/normalization.py index b7e0f07..a7750af 100644 --- a/utils/normalization.py +++ b/utils/normalization.py @@ -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" - } - - # Si c'est un int, on convertit + + mapping_int = {0: "client", 1: "fournisseur", 2: "prospect", 3: "all"} + 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 \ No newline at end of file + + return type_tiers.lower() if isinstance(type_tiers, str) else None