feat(tiers): add tiers schemas and API endpoints

This commit is contained in:
Fanilo-Nantenaina 2025-12-29 11:20:55 +03:00
parent 8859152379
commit 1d78c6b46b
14 changed files with 295 additions and 145 deletions

View file

@ -11,7 +11,7 @@ RUN pip install --no-cache-dir --upgrade pip \
# Copier le reste du projet # Copier le reste du projet
COPY . . COPY . .
# Créer dossier persistant pour SQLite avec bonnes permissions # Créer dossier persistant pour SQLite avec bonnes permissions
RUN mkdir -p /app/data && chmod 777 /app/data RUN mkdir -p /app/data && chmod 777 /app/data
# Exposer le port # Exposer le port

56
api.py
View file

@ -30,6 +30,8 @@ from database import (
from email_queue import email_queue from email_queue import email_queue
from sage_client import sage_client from sage_client import sage_client
from schemas import TiersDetails, TypeTiers
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@ -1582,7 +1584,7 @@ class ClientCreateRequest(BaseModel):
def to_sage_dict(self) -> dict: def to_sage_dict(self) -> dict:
""" """
Convertit le modèle en dictionnaire compatible avec creer_client() Convertit le modèle en dictionnaire compatible avec creer_client()
Mapping 1:1 avec les paramètres réels de la fonction Mapping 1:1 avec les paramètres réels de la fonction
""" """
stat01 = self.statistique01 or self.secteur stat01 = self.statistique01 or self.secteur
@ -1701,8 +1703,8 @@ class ClientCreateRequest(BaseModel):
class ClientUpdateRequest(BaseModel): class ClientUpdateRequest(BaseModel):
""" """
Modèle pour modification d'un client existant Modèle pour modification d'un client existant
TOUS les champs de ClientCreateRequest sont modifiables TOUS les champs de ClientCreateRequest sont modifiables
TOUS optionnels (seuls les champs fournis sont modifiés) TOUS optionnels (seuls les champs fournis sont modifiés)
""" """
intitule: Optional[str] = Field(None, max_length=69) intitule: Optional[str] = Field(None, max_length=69)
@ -2842,7 +2844,7 @@ templates_signature_email = {
<tr> <tr>
<td style="padding: 15px;"> <td style="padding: 15px;">
<p style="color: #2c5282; font-size: 13px; line-height: 1.5; margin: 0;"> <p style="color: #2c5282; font-size: 13px; line-height: 1.5; margin: 0;">
🔐 <strong>Signature certifiée :</strong> Ce document a été signé avec une signature <strong>Signature certifiée :</strong> Ce document a été signé avec une signature
électronique qualifiée, ayant la même valeur juridique qu'une signature manuscrite électronique qualifiée, ayant la même valeur juridique qu'une signature manuscrite
conformément au règlement eIDAS. conformément au règlement eIDAS.
</p> </p>
@ -3004,7 +3006,7 @@ async def universign_envoyer(
api_url = settings.universign_api_url api_url = settings.universign_api_url
auth = (api_key, "") auth = (api_key, "")
logger.info(f"🔐 Démarrage processus Universign pour {email}") logger.info(f" Démarrage processus Universign pour {email}")
logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})") logger.info(f"📌 Document: {doc_id} ({doc_data.get('type_label')})")
if not pdf_bytes or len(pdf_bytes) == 0: if not pdf_bytes or len(pdf_bytes) == 0:
@ -3089,7 +3091,7 @@ async def universign_envoyer(
field_id = response.json().get("id") field_id = response.json().get("id")
logger.info(f"Champ créé: {field_id}") logger.info(f"Champ créé: {field_id}")
logger.info("👤 ÉTAPE 5/6 : Liaison signataire au champ") logger.info(" ÉTAPE 5/6 : Liaison signataire au champ")
response = requests.post( response = requests.post(
f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers
@ -3144,7 +3146,7 @@ async def universign_envoyer(
logger.info("URL récupérée") logger.info("URL récupérée")
logger.info("📧 Préparation email") logger.info(" Préparation email")
template = templates_signature_email["demande_signature"] template = templates_signature_email["demande_signature"]
@ -4137,7 +4139,7 @@ async def webhook_universign(
email_queue.enqueue(email_log.id) email_queue.enqueue(email_log.id)
logger.info( logger.info(
f"📧 Email de confirmation envoyé: {signature_log.email_signataire}" f" Email de confirmation envoyé: {signature_log.email_signataire}"
) )
elif event_type == "transaction.refused": elif event_type == "transaction.refused":
@ -4238,7 +4240,7 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se
nb_relances += 1 nb_relances += 1
logger.info( logger.info(
f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" f" Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)"
) )
except Exception as e: except Exception as e:
@ -6083,6 +6085,42 @@ async def definir_contact_defaut(numero: str, contact_numero: int):
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@app.get("/tiers", response_model=List[TiersDetails], tags=["Tiers"])
async def obtenir_tiers(
type_tiers: Optional[TypeTiers] = Query(
None,
description="Filtre par type: client, fournisseur, prospect, ou all"
),
query: Optional[str] = Query(
None,
description="Recherche sur code ou intitulé"
)
):
try:
tiers = sage_client.lister_tiers(
type_tiers=type_tiers.value if type_tiers else None,
filtre=query or ""
)
return [TiersDetails(**t) for t in tiers]
except Exception as e:
logger.error(f" Erreur recherche tiers: {e}")
raise HTTPException(500, str(e))
@app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"])
async def lire_tiers_detail(code: str):
try:
tiers = sage_client.lire_tiers(code)
if not tiers:
raise HTTPException(404, f"Tiers {code} introuvable")
return TiersDetails(**tiers)
except HTTPException:
raise
except Exception as e:
logger.error(f" Erreur lecture tiers {code}: {e}")
raise HTTPException(500, str(e))
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run( uvicorn.run(
"api:app", "api:app",

View file

@ -5,7 +5,7 @@ from sqlalchemy import select
from database import get_session, User from database import get_session, User
from security.auth import decode_token from security.auth import decode_token
from typing import Optional from typing import Optional
from datetime import datetime # AJOUT MANQUANT - C'ÉTAIT LE BUG ! from datetime import datetime # AJOUT MANQUANT - C'ÉTAIT LE BUG !
security = HTTPBearer() security = HTTPBearer()
@ -14,14 +14,6 @@ async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security), credentials: HTTPAuthorizationCredentials = Depends(security),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
) -> User: ) -> User:
"""
Dépendance FastAPI pour extraire l'utilisateur du JWT
Usage dans un endpoint:
@app.get("/protected")
async def protected_route(user: User = Depends(get_current_user)):
return {"user_id": user.id}
"""
token = credentials.credentials token = credentials.credentials
# Décoder le token # Décoder le token
@ -73,7 +65,7 @@ async def get_current_user(
detail="Email non vérifié. Consultez votre boîte de réception.", detail="Email non vérifié. Consultez votre boîte de réception.",
) )
# FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!) # FIX: Vérifier si le compte est verrouillé (maintenant datetime est importé!)
if user.locked_until and user.locked_until > datetime.now(): if user.locked_until and user.locked_until > datetime.now():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,

View file

@ -27,20 +27,20 @@ async def create_admin():
"""Crée un utilisateur admin""" """Crée un utilisateur admin"""
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("🔐 Création d'un compte administrateur") print(" Création d'un compte administrateur")
print("=" * 60 + "\n") print("=" * 60 + "\n")
# Saisie des informations # Saisie des informations
email = input("Email de l'admin: ").strip().lower() email = input("Email de l'admin: ").strip().lower()
if not email or "@" not in email: if not email or "@" not in email:
print(" Email invalide") print(" Email invalide")
return False return False
prenom = input("Prénom: ").strip() prenom = input("Prénom: ").strip()
nom = input("Nom: ").strip() nom = input("Nom: ").strip()
if not prenom or not nom: if not prenom or not nom:
print(" Prénom et nom requis") print(" Prénom et nom requis")
return False return False
# Mot de passe avec validation # Mot de passe avec validation
@ -55,9 +55,9 @@ async def create_admin():
if password == confirm: if password == confirm:
break break
else: else:
print(" Les mots de passe ne correspondent pas\n") print(" Les mots de passe ne correspondent pas\n")
else: else:
print(f" {error_msg}\n") print(f" {error_msg}\n")
# Vérifier si l'email existe déjà # Vérifier si l'email existe déjà
async with async_session_factory() as session: async with async_session_factory() as session:
@ -67,7 +67,7 @@ async def create_admin():
existing = result.scalar_one_or_none() existing = result.scalar_one_or_none()
if existing: if existing:
print(f"\n Un utilisateur avec l'email {email} existe déjà") print(f"\n Un utilisateur avec l'email {email} existe déjà")
return False return False
# Créer l'admin # Créer l'admin
@ -86,12 +86,12 @@ async def create_admin():
session.add(admin) session.add(admin)
await session.commit() await session.commit()
print("\n Administrateur créé avec succès!") print("\n Administrateur créé avec succès!")
print(f"📧 Email: {email}") print(f" Email: {email}")
print(f"👤 Nom: {prenom} {nom}") print(f" Nom: {prenom} {nom}")
print(f"🔑 Rôle: admin") print(f" Rôle: admin")
print(f"🆔 ID: {admin.id}") print(f" ID: {admin.id}")
print("\n💡 Vous pouvez maintenant vous connecter à l'API\n") print("\n Vous pouvez maintenant vous connecter à l'API\n")
return True return True
@ -101,9 +101,9 @@ if __name__ == "__main__":
result = asyncio.run(create_admin()) result = asyncio.run(create_admin())
sys.exit(0 if result else 1) sys.exit(0 if result else 1)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\n Création annulée") print("\n\n Création annulée")
sys.exit(1) sys.exit(1)
except Exception as e: except Exception as e:
print(f"\n Erreur: {e}") print(f"\n Erreur: {e}")
logger.exception("Détails:") logger.exception("Détails:")
sys.exit(1) sys.exit(1)

View file

@ -27,17 +27,17 @@ async_session_factory = async_sessionmaker(
async def init_db(): async def init_db():
""" """
Crée toutes les tables dans la base de données Crée toutes les tables dans la base de données
Utilise create_all qui ne crée QUE les tables manquantes Utilise create_all qui ne crée QUE les tables manquantes
""" """
try: try:
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
logger.info(" Base de données initialisée avec succès") logger.info(" Base de données initialisée avec succès")
logger.info(f"📍 Fichier DB: {DATABASE_URL}") logger.info(f" Fichier DB: {DATABASE_URL}")
except Exception as e: except Exception as e:
logger.error(f" Erreur initialisation DB: {e}") logger.error(f" Erreur initialisation DB: {e}")
raise raise
@ -53,4 +53,4 @@ async def get_session() -> AsyncSession:
async def close_db(): async def close_db():
"""Ferme proprement toutes les connexions""" """Ferme proprement toutes les connexions"""
await engine.dispose() await engine.dispose()
logger.info("🔌 Connexions DB fermées") logger.info(" Connexions DB fermées")

View file

@ -20,9 +20,6 @@ logger = logging.getLogger(__name__)
class EmailQueue: class EmailQueue:
"""
Queue d'emails avec workers threadés et retry automatique
"""
def __init__(self): def __init__(self):
self.queue = queue.Queue() self.queue = queue.Queue()
@ -45,23 +42,23 @@ class EmailQueue:
worker.start() worker.start()
self.workers.append(worker) self.workers.append(worker)
logger.info(f" Queue email démarrée avec {num_workers} worker(s)") logger.info(f" Queue email démarrée avec {num_workers} worker(s)")
def stop(self): def stop(self):
"""Arrête les workers proprement""" """Arrête les workers proprement"""
logger.info("🛑 Arrêt de la queue email...") logger.info(" Arrêt de la queue email...")
self.running = False self.running = False
try: try:
self.queue.join() self.queue.join()
logger.info(" Queue email arrêtée proprement") logger.info(" Queue email arrêtée proprement")
except: except:
logger.warning("⚠️ Timeout lors de l'arrêt de la queue") logger.warning(" Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str): def enqueue(self, email_log_id: str):
"""Ajoute un email dans la queue""" """Ajoute un email dans la queue"""
self.queue.put(email_log_id) self.queue.put(email_log_id)
logger.debug(f"📨 Email {email_log_id} ajouté à la queue") logger.debug(f" Email {email_log_id} ajouté à la queue")
def _worker(self): def _worker(self):
"""Worker qui traite les emails dans un thread""" """Worker qui traite les emails dans un thread"""
@ -80,7 +77,7 @@ class EmailQueue:
except queue.Empty: except queue.Empty:
continue continue
except Exception as e: except Exception as e:
logger.error(f" Erreur worker: {e}", exc_info=True) logger.error(f" Erreur worker: {e}", exc_info=True)
try: try:
self.queue.task_done() self.queue.task_done()
except: except:
@ -94,7 +91,7 @@ class EmailQueue:
from sqlalchemy import select from sqlalchemy import select
if not self.session_factory: if not self.session_factory:
logger.error(" session_factory non configuré") logger.error(" session_factory non configuré")
return return
async with self.session_factory() as session: async with self.session_factory() as session:
@ -104,7 +101,7 @@ class EmailQueue:
email_log = result.scalar_one_or_none() email_log = result.scalar_one_or_none()
if not email_log: if not email_log:
logger.error(f" Email log {email_log_id} introuvable") logger.error(f" Email log {email_log_id} introuvable")
return return
email_log.statut = StatutEmail.EN_COURS email_log.statut = StatutEmail.EN_COURS
@ -117,7 +114,7 @@ class EmailQueue:
email_log.statut = StatutEmail.ENVOYE email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now() email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None email_log.derniere_erreur = None
logger.info(f" Email envoyé: {email_log.destinataire}") logger.info(f" Email envoyé: {email_log.destinataire}")
except Exception as e: except Exception as e:
email_log.statut = StatutEmail.ERREUR email_log.statut = StatutEmail.ERREUR
@ -134,10 +131,10 @@ class EmailQueue:
timer.start() timer.start()
logger.warning( logger.warning(
f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}" f" Retry prévu dans {delay}s pour {email_log.destinataire}"
) )
else: else:
logger.error(f" Échec définitif: {email_log.destinataire} - {e}") logger.error(f" Échec définitif: {email_log.destinataire} - {e}")
await session.commit() await session.commit()
@ -176,20 +173,20 @@ class EmailQueue:
logger.info(f"📎 PDF attaché: {doc_id}.pdf") logger.info(f"📎 PDF attaché: {doc_id}.pdf")
except Exception as e: except Exception as e:
logger.error(f" Erreur génération PDF {doc_id}: {e}") logger.error(f" Erreur génération PDF {doc_id}: {e}")
await asyncio.to_thread(self._send_smtp, msg) await asyncio.to_thread(self._send_smtp, msg)
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
if not self.sage_client: if not self.sage_client:
logger.error(" sage_client non configuré") logger.error(" sage_client non configuré")
raise Exception("sage_client non disponible") raise Exception("sage_client non disponible")
try: try:
doc = self.sage_client.lire_document(doc_id, type_doc) doc = self.sage_client.lire_document(doc_id, type_doc)
except Exception as e: except Exception as e:
logger.error(f" Erreur récupération document {doc_id}: {e}") logger.error(f" Erreur récupération document {doc_id}: {e}")
raise Exception(f"Document {doc_id} inaccessible") raise Exception(f"Document {doc_id} inaccessible")
if not doc: if not doc:
@ -284,11 +281,10 @@ class EmailQueue:
pdf.save() pdf.save()
buffer.seek(0) buffer.seek(0)
logger.info(f" PDF généré: {doc_id}.pdf") logger.info(f" PDF généré: {doc_id}.pdf")
return buffer.read() return buffer.read()
def _send_smtp(self, msg): def _send_smtp(self, msg):
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
try: try:
with smtplib.SMTP( with smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30 settings.smtp_host, settings.smtp_port, timeout=30

View file

@ -14,7 +14,7 @@ from pathlib import Path
# Ajouter le répertoire parent au path pour les imports # Ajouter le répertoire parent au path pour les imports
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from database import init_db # Import depuis database/__init__.py from database import init_db # Import depuis database/__init__.py
import logging import logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -32,8 +32,8 @@ async def main():
# Créer les tables # Créer les tables
await init_db() await init_db()
print("\n Base de données créée avec succès!") print("\n Base de données créée avec succès!")
print(f"📍 Fichier: sage_dataven.db") print(f" Fichier: sage_dataven.db")
print("\n📊 Tables créées:") print("\n📊 Tables créées:")
print(" ├─ email_logs (Journalisation emails)") print(" ├─ email_logs (Journalisation emails)")
@ -53,7 +53,7 @@ async def main():
return True return True
except Exception as e: except Exception as e:
print(f"\n Erreur lors de l'initialisation: {e}") print(f"\n Erreur lors de l'initialisation: {e}")
logger.exception("Détails de l'erreur:") logger.exception("Détails de l'erreur:")
return False return False

View file

@ -95,12 +95,7 @@ async def log_login_attempt(
async def check_rate_limit( async def check_rate_limit(
session: AsyncSession, email: str, ip: str session: AsyncSession, email: str, ip: str
) -> tuple[bool, str]: ) -> tuple[bool, str]:
"""
Vérifie si l'utilisateur/IP est rate limité
Returns:
(is_allowed, error_message)
"""
time_window = datetime.now() - timedelta(minutes=15) time_window = datetime.now() - timedelta(minutes=15)
result = await session.execute( result = await session.execute(
@ -126,13 +121,7 @@ async def register(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""
📝 Inscription d'un nouvel utilisateur
- Valide le mot de passe
- Crée le compte (non vérifié)
- Envoie email de vérification
"""
result = await session.execute(select(User).where(User.email == data.email)) result = await session.execute(select(User).where(User.email == data.email))
existing_user = result.scalar_one_or_none() existing_user = result.scalar_one_or_none()
@ -170,7 +159,7 @@ async def register(
if not email_sent: if not email_sent:
logger.warning(f"Échec envoi email vérification pour {data.email}") logger.warning(f"Échec envoi email vérification pour {data.email}")
logger.info(f" Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") logger.info(f" Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})")
return { return {
"success": True, "success": True,
@ -183,7 +172,7 @@ async def register(
@router.get("/verify-email") @router.get("/verify-email")
async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)):
""" """
Vérification de l'email via lien cliquable (GET) Vérification de l'email via lien cliquable (GET)
Utilisé quand l'utilisateur clique sur le lien dans l'email Utilisé quand l'utilisateur clique sur le lien dans l'email
""" """
result = await session.execute(select(User).where(User.verification_token == token)) result = await session.execute(select(User).where(User.verification_token == token))
@ -207,11 +196,11 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
user.verification_token_expires = None user.verification_token_expires = None
await session.commit() await session.commit()
logger.info(f" Email vérifié: {user.email}") logger.info(f" Email vérifié: {user.email}")
return { return {
"success": True, "success": True,
"message": " Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", "message": " Email vérifié avec succès ! Vous pouvez maintenant vous connecter.",
"email": user.email, "email": user.email,
} }
@ -221,7 +210,7 @@ async def verify_email_post(
data: VerifyEmailRequest, session: AsyncSession = Depends(get_session) data: VerifyEmailRequest, session: AsyncSession = Depends(get_session)
): ):
""" """
Vérification de l'email via API (POST) Vérification de l'email via API (POST)
Utilisé pour les appels programmatiques depuis le frontend Utilisé pour les appels programmatiques depuis le frontend
""" """
result = await session.execute( result = await session.execute(
@ -246,7 +235,7 @@ async def verify_email_post(
user.verification_token_expires = None user.verification_token_expires = None
await session.commit() await session.commit()
logger.info(f" Email vérifié: {user.email}") logger.info(f" Email vérifié: {user.email}")
return { return {
"success": True, "success": True,
@ -260,9 +249,6 @@ async def resend_verification(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""
🔄 Renvoyer l'email de vérification
"""
result = await session.execute(select(User).where(User.email == data.email.lower())) result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -292,11 +278,6 @@ async def resend_verification(
async def login( async def login(
data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session) data: LoginRequest, request: Request, session: AsyncSession = Depends(get_session)
): ):
"""
🔐 Connexion utilisateur
Retourne access_token (30min) et refresh_token (7 jours)
"""
ip = request.client.host if request.client else "unknown" ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "unknown") user_agent = request.headers.get("user-agent", "unknown")
@ -388,7 +369,7 @@ async def login(
await log_login_attempt(session, data.email.lower(), ip, user_agent, True) await log_login_attempt(session, data.email.lower(), ip, user_agent, True)
logger.info(f" Connexion réussie: {user.email}") logger.info(f" Connexion réussie: {user.email}")
return TokenResponse( return TokenResponse(
access_token=access_token, access_token=access_token,
@ -401,9 +382,7 @@ async def login(
async def refresh_access_token( async def refresh_access_token(
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
): ):
"""
🔄 Renouvellement du access_token via refresh_token
"""
payload = decode_token(data.refresh_token) payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh": if not payload or payload.get("type") != "refresh":
raise HTTPException( raise HTTPException(
@ -446,7 +425,7 @@ async def refresh_access_token(
{"sub": user.id, "email": user.email, "role": user.role} {"sub": user.id, "email": user.email, "role": user.role}
) )
logger.info(f"🔄 Token rafraîchi: {user.email}") logger.info(f" Token rafraîchi: {user.email}")
return TokenResponse( return TokenResponse(
access_token=new_access_token, access_token=new_access_token,
@ -461,9 +440,7 @@ async def forgot_password(
request: Request, request: Request,
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""
🔑 Demande de réinitialisation de mot de passe
"""
result = await session.execute(select(User).where(User.email == data.email.lower())) result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -485,7 +462,7 @@ async def forgot_password(
) )
AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url)
logger.info(f"📧 Reset password demandé: {user.email}") logger.info(f" Reset password demandé: {user.email}")
return { return {
"success": True, "success": True,
@ -497,9 +474,7 @@ async def forgot_password(
async def reset_password( async def reset_password(
data: ResetPasswordRequest, session: AsyncSession = Depends(get_session) data: ResetPasswordRequest, session: AsyncSession = Depends(get_session)
): ):
"""
🔐 Réinitialisation du mot de passe avec token
"""
result = await session.execute(select(User).where(User.reset_token == data.token)) result = await session.execute(select(User).where(User.reset_token == data.token))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -528,7 +503,7 @@ async def reset_password(
AuthEmailService.send_password_changed_notification(user.email) AuthEmailService.send_password_changed_notification(user.email)
logger.info(f"🔐 Mot de passe réinitialisé: {user.email}") logger.info(f" Mot de passe réinitialisé: {user.email}")
return { return {
"success": True, "success": True,
@ -542,9 +517,7 @@ async def logout(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
): ):
"""
🚪 Déconnexion (révocation du refresh token)
"""
token_hash = hash_token(data.refresh_token) token_hash = hash_token(data.refresh_token)
result = await session.execute( result = await session.execute(
@ -566,9 +539,7 @@ async def logout(
@router.get("/me") @router.get("/me")
async def get_current_user_info(user: User = Depends(get_current_user)): async def get_current_user_info(user: User = Depends(get_current_user)):
"""
👤 Récupération du profil utilisateur
"""
return { return {
"id": user.id, "id": user.id,
"email": user.email, "email": user.email,

View file

@ -32,7 +32,7 @@ class SageGatewayClient:
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if attempt == retries - 1: if attempt == retries - 1:
logger.error( logger.error(
f" Échec après {retries} tentatives sur {endpoint}: {e}" f" Échec après {retries} tentatives sur {endpoint}: {e}"
) )
raise raise
time.sleep(2**attempt) time.sleep(2**attempt)
@ -54,7 +54,7 @@ class SageGatewayClient:
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
if attempt == retries - 1: if attempt == retries - 1:
logger.error( logger.error(
f" Échec GET après {retries} tentatives sur {endpoint}: {e}" f" Échec GET après {retries} tentatives sur {endpoint}: {e}"
) )
raise raise
time.sleep(2**attempt) time.sleep(2**attempt)
@ -108,7 +108,7 @@ class SageGatewayClient:
r.raise_for_status() r.raise_for_status()
return r.json().get("data", {}) return r.json().get("data", {})
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f" Erreur changement statut: {e}") logger.error(f" Erreur changement statut: {e}")
raise raise
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
@ -134,7 +134,7 @@ class SageGatewayClient:
r.raise_for_status() r.raise_for_status()
return r.json().get("data", {}) return r.json().get("data", {})
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f" Erreur transformation: {e}") logger.error(f" Erreur transformation: {e}")
raise raise
def mettre_a_jour_champ_libre( def mettre_a_jour_champ_libre(
@ -352,7 +352,7 @@ class SageGatewayClient:
pdf_bytes = base64.b64decode(pdf_base64) pdf_bytes = base64.b64decode(pdf_base64)
logger.info(f" PDF décodé: {len(pdf_bytes)} octets") logger.info(f" PDF décodé: {len(pdf_bytes)} octets")
return pdf_bytes return pdf_bytes
@ -364,11 +364,11 @@ class SageGatewayClient:
) )
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f" Erreur HTTP génération PDF: {e}") logger.error(f" Erreur HTTP génération PDF: {e}")
raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f" Erreur génération PDF: {e}", exc_info=True) logger.error(f" Erreur génération PDF: {e}", exc_info=True)
raise raise
def creer_article(self, article_data: Dict) -> Dict: def creer_article(self, article_data: Dict) -> Dict:
@ -420,7 +420,7 @@ class SageGatewayClient:
r.raise_for_status() r.raise_for_status()
return r.json().get("data", {}) return r.json().get("data", {})
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f" Erreur listage modèles: {e}") logger.error(f" Erreur listage modèles: {e}")
raise raise
def generer_pdf_document( def generer_pdf_document(
@ -446,7 +446,7 @@ class SageGatewayClient:
return r.content return r.content
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f" Erreur génération PDF: {e}") logger.error(f" Erreur génération PDF: {e}")
raise raise
@ -476,16 +476,6 @@ class SageGatewayClient:
def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: def supprimer_contact(self, numero: str, contact_numero: int) -> Dict:
"""
Supprime un contact
Args:
numero: Code du client
contact_numero: Numéro unique du contact
Returns:
Dictionnaire avec le statut de la suppression
"""
return self._post("/sage/contacts/delete", { return self._post("/sage/contacts/delete", {
"numero": numero, "numero": numero,
"contact_numero": contact_numero "contact_numero": contact_numero
@ -493,19 +483,22 @@ class SageGatewayClient:
def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict:
"""
Définit un contact comme contact par défaut du client
Args:
numero: Code du client
contact_numero: Numéro unique du contact à définir comme par défaut
Returns:
Dictionnaire avec les données du client mis à jour
"""
return self._post("/sage/contacts/set-default", { return self._post("/sage/contacts/set-default", {
"numero": numero, "numero": numero,
"contact_numero": contact_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")
sage_client = SageGatewayClient() sage_client = SageGatewayClient()

9
schemas/__init__.py Normal file
View file

@ -0,0 +1,9 @@
from schemas.tiers.tiers import (TiersDetails,)
from schemas.tiers.type_tiers import (TypeTiers,)
from schemas.tiers.contact import (Contact,)
__all__ = [
"TiersDetails",
"Contact",
"TypeTiers",
]

41
schemas/tiers/contact.py Normal file
View file

@ -0,0 +1,41 @@
from typing import Optional, ClassVar
from pydantic import BaseModel, Field, validator
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)")
n_contact: Optional[int] = Field(None, description="Numéro de référence contact (N_Contact)")
civilite: Optional[str] = Field(None, description="Civilité : M., Mme, Mlle (CT_Civilite)")
nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)")
prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)")
fonction: Optional[str] = Field(None, description="Fonction/Titre (CT_Fonction)")
service_code: Optional[int] = Field(None, description="Code du service (N_Service)")
telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)")
portable: Optional[str] = Field(None, description="Téléphone mobile (CT_TelPortable)")
telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)")
email: Optional[str] = Field(None, description="Adresse email (CT_EMail)")
facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)")
linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)")
skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)")
est_defaut: Optional[bool] = Field(False, description="Contact par défaut")
civilite_map: ClassVar[dict] = {
0: "M.",
1: "Mme",
2: "Mlle",
3: "Société",
}
@validator("civilite", pre=True, always=True)
def convert_civilite(cls, v):
if v is None:
return v
if isinstance(v, int):
return cls.civilite_map.get(v, str(v))
return v

104
schemas/tiers/tiers.py Normal file
View file

@ -0,0 +1,104 @@
from typing import List, Optional
from pydantic import BaseModel, Field
from schemas import Contact
class TiersDetails(BaseModel):
# IDENTIFICATION
numero: Optional[str] = Field(None, description="Code tiers (CT_Num)")
intitule: Optional[str] = Field(None, description="Raison sociale ou Nom complet (CT_Intitule)")
type_tiers: Optional[int] = Field(None, description="Type : 0=Client, 1=Fournisseur (CT_Type)")
qualite: Optional[str] = Field(None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)")
classement: Optional[str] = Field(None, description="Code de classement (CT_Classement)")
raccourci: Optional[str] = Field(None, description="Code raccourci 7 car. (CT_Raccourci)")
siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)")
tva_intra: Optional[str] = Field(None, description="N° TVA intracommunautaire (CT_Identifiant)")
code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)")
# ADRESSE
contact: Optional[str] = Field(None, description="Nom du contact principal (CT_Contact)")
adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)")
complement: Optional[str] = Field(None, description="Complément d'adresse (CT_Complement)")
code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)")
ville: Optional[str] = Field(None, description="Ville (CT_Ville)")
region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)")
pays: Optional[str] = Field(None, description="Pays (CT_Pays)")
# TELECOM
telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)")
telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)")
email: Optional[str] = Field(None, description="Email principal (CT_EMail)")
site_web: Optional[str] = Field(None, description="Site web (CT_Site)")
facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)")
linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)")
# TAUX
taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)")
taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)")
taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)")
taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)")
# STATISTIQUES
statistique01: Optional[str] = Field(None, description="Statistique 1 (CT_Statistique01)")
statistique02: Optional[str] = Field(None, description="Statistique 2 (CT_Statistique02)")
statistique03: Optional[str] = Field(None, description="Statistique 3 (CT_Statistique03)")
statistique04: Optional[str] = Field(None, description="Statistique 4 (CT_Statistique04)")
statistique05: Optional[str] = Field(None, description="Statistique 5 (CT_Statistique05)")
statistique06: Optional[str] = Field(None, description="Statistique 6 (CT_Statistique06)")
statistique07: Optional[str] = Field(None, description="Statistique 7 (CT_Statistique07)")
statistique08: Optional[str] = Field(None, description="Statistique 8 (CT_Statistique08)")
statistique09: Optional[str] = Field(None, description="Statistique 9 (CT_Statistique09)")
statistique10: Optional[str] = Field(None, description="Statistique 10 (CT_Statistique10)")
# COMMERCIAL
encours_autorise: Optional[float] = Field(None, description="Encours maximum autorisé (CT_Encours)")
assurance_credit: Optional[float] = Field(None, description="Montant assurance crédit (CT_Assurance)")
langue: Optional[int] = Field(None, description="Code langue 0=FR, 1=EN (CT_Langue)")
commercial_code: Optional[int] = Field(None, description="Code du commercial (CO_No)")
# FACTURATION
lettrage_auto: Optional[bool] = Field(None, description="Lettrage automatique (CT_Lettrage)")
est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)")
type_facture: Optional[int] = Field(None, description="Type facture 0=Facture, 1=BL (CT_Facture)")
est_prospect: Optional[bool] = Field(None, description="True si prospect (CT_Prospect=1)")
bl_en_facture: Optional[int] = Field(None, description="Imprimer BL en facture (CT_BLFact)")
saut_page: Optional[int] = Field(None, description="Saut de page sur documents (CT_Saut)")
validation_echeance: Optional[int] = Field(None, description="Valider les échéances (CT_ValidEch)")
controle_encours: Optional[int] = Field(None, description="Contrôler l'encours (CT_ControlEnc)")
exclure_relance: Optional[bool] = Field(None, description="Exclure des relances (CT_NotRappel)")
exclure_penalites: Optional[bool] = Field(None, description="Exclure des pénalités (CT_NotPenal)")
bon_a_payer: Optional[int] = Field(None, description="Bon à payer obligatoire (CT_BonAPayer)")
# LOGISTIQUE
priorite_livraison: Optional[int] = Field(None, description="Priorité livraison (CT_PrioriteLivr)")
livraison_partielle: Optional[int] = Field(None, description="Livraison partielle (CT_LivrPartielle)")
delai_transport: Optional[int] = Field(None, description="Délai transport jours (CT_DelaiTransport)")
delai_appro: Optional[int] = Field(None, description="Délai appro jours (CT_DelaiAppro)")
# COMMENTAIRE
commentaire: Optional[str] = Field(None, description="Commentaire libre (CT_Commentaire)")
# ANALYTIQUE
section_analytique: Optional[str] = Field(None, description="Section analytique (CA_Num)")
# ORGANISATION / SURVEILLANCE
mode_reglement_code: Optional[int] = Field(None, description="Code mode règlement (MR_No)")
surveillance_active: Optional[bool] = Field(None, description="Surveillance financière (CT_Surveillance)")
coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)")
forme_juridique: Optional[str] = Field(None, description="Forme juridique SA, SARL (CT_SvFormeJuri)")
effectif: Optional[str] = Field(None, description="Nombre d'employés (CT_SvEffectif)")
sv_regularite: Optional[str] = Field(None, description="Régularité paiements (CT_SvRegul)")
sv_cotation: Optional[str] = Field(None, description="Cotation crédit (CT_SvCotation)")
sv_objet_maj: Optional[str] = Field(None, description="Objet dernière MAJ (CT_SvObjetMaj)")
sv_chiffre_affaires: Optional[float] = Field(None, description="Chiffre d'affaires (CT_SvCA)")
sv_resultat: Optional[float] = Field(None, description="Résultat financier (CT_SvResultat)")
# COMPTE GENERAL ET CATEGORIES
compte_general: Optional[str] = Field(None, description="Compte général principal (CG_NumPrinc)")
categorie_tarif: Optional[int] = Field(None, description="Catégorie tarifaire (N_CatTarif)")
categorie_compta: Optional[int] = Field(None, description="Catégorie comptable (N_CatCompta)")
# CONTACTS
contacts: Optional[List[Contact]] = Field(
default_factory=list,
description="Liste des contacts du tiers"
)

View file

@ -0,0 +1,6 @@
class TypeTiers(str, Enum):
"""Types de tiers possibles"""
ALL = "all"
CLIENT = "client"
FOURNISSEUR = "fournisseur"
PROSPECT = "prospect"

View file

@ -32,11 +32,11 @@ class AuthEmailService:
server.send_message(msg) server.send_message(msg)
logger.info(f" Email envoyé: {subject}{to}") logger.info(f" Email envoyé: {subject}{to}")
return True return True
except Exception as e: except Exception as e:
logger.error(f" Erreur envoi email: {e}") logger.error(f" Erreur envoi email: {e}")
return False return False
@staticmethod @staticmethod
@ -91,7 +91,7 @@ class AuthEmailService:
</p> </p>
<p style="margin-top: 30px; color: #ef4444;"> <p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>24 heures</strong> Ce lien expire dans <strong>24 heures</strong>
</p> </p>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;"> <p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
@ -107,7 +107,7 @@ class AuthEmailService:
""" """
return AuthEmailService._send_email( return AuthEmailService._send_email(
email, "🔐 Vérifiez votre adresse email - Sage Dataven", html_body email, " Vérifiez votre adresse email - Sage Dataven", html_body
) )
@staticmethod @staticmethod
@ -146,7 +146,7 @@ class AuthEmailService:
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>🔑 Réinitialisation de mot de passe</h1> <h1> Réinitialisation de mot de passe</h1>
</div> </div>
<div class="content"> <div class="content">
<h2>Demande de réinitialisation</h2> <h2>Demande de réinitialisation</h2>
@ -162,7 +162,7 @@ class AuthEmailService:
</p> </p>
<p style="margin-top: 30px; color: #ef4444;"> <p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>1 heure</strong> Ce lien expire dans <strong>1 heure</strong>
</p> </p>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;"> <p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
@ -178,7 +178,7 @@ class AuthEmailService:
""" """
return AuthEmailService._send_email( return AuthEmailService._send_email(
email, "🔐 Réinitialisation de votre mot de passe - Sage Dataven", html_body email, " Réinitialisation de votre mot de passe - Sage Dataven", html_body
) )
@staticmethod @staticmethod
@ -199,14 +199,14 @@ class AuthEmailService:
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1> Mot de passe modifié</h1> <h1> Mot de passe modifié</h1>
</div> </div>
<div class="content"> <div class="content">
<h2>Votre mot de passe a été changé avec succès</h2> <h2>Votre mot de passe a été changé avec succès</h2>
<p>Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.</p> <p>Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.</p>
<p style="margin-top: 30px; padding: 15px; background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;"> <p style="margin-top: 30px; padding: 15px; background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;">
Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support. Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support.
</p> </p>
</div> </div>
<div class="footer"> <div class="footer">
@ -218,5 +218,5 @@ class AuthEmailService:
""" """
return AuthEmailService._send_email( return AuthEmailService._send_email(
email, " Votre mot de passe a été modifié - Sage Dataven", html_body email, " Votre mot de passe a été modifié - Sage Dataven", html_body
) )