feat(tiers): add tiers schemas and API endpoints
This commit is contained in:
parent
8859152379
commit
1d78c6b46b
14 changed files with 295 additions and 145 deletions
|
|
@ -11,7 +11,7 @@ RUN pip install --no-cache-dir --upgrade pip \
|
|||
# Copier le reste du projet
|
||||
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
|
||||
|
||||
# Exposer le port
|
||||
|
|
|
|||
56
api.py
56
api.py
|
|
@ -30,6 +30,8 @@ from database import (
|
|||
from email_queue import email_queue
|
||||
from sage_client import sage_client
|
||||
|
||||
from schemas import TiersDetails, TypeTiers
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
|
|
@ -1582,7 +1584,7 @@ class ClientCreateRequest(BaseModel):
|
|||
def to_sage_dict(self) -> dict:
|
||||
"""
|
||||
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
|
||||
|
||||
|
|
@ -1701,8 +1703,8 @@ 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)
|
||||
TOUS les champs de ClientCreateRequest sont modifiables
|
||||
TOUS optionnels (seuls les champs fournis sont modifiés)
|
||||
"""
|
||||
|
||||
intitule: Optional[str] = Field(None, max_length=69)
|
||||
|
|
@ -2842,7 +2844,7 @@ templates_signature_email = {
|
|||
<tr>
|
||||
<td style="padding: 15px;">
|
||||
<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
|
||||
conformément au règlement eIDAS.
|
||||
</p>
|
||||
|
|
@ -3004,7 +3006,7 @@ async def universign_envoyer(
|
|||
api_url = settings.universign_api_url
|
||||
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')})")
|
||||
|
||||
if not pdf_bytes or len(pdf_bytes) == 0:
|
||||
|
|
@ -3089,7 +3091,7 @@ async def universign_envoyer(
|
|||
field_id = response.json().get("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(
|
||||
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("📧 Préparation email")
|
||||
logger.info(" Préparation email")
|
||||
|
||||
template = templates_signature_email["demande_signature"]
|
||||
|
||||
|
|
@ -4137,7 +4139,7 @@ async def webhook_universign(
|
|||
email_queue.enqueue(email_log.id)
|
||||
|
||||
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":
|
||||
|
|
@ -4238,7 +4240,7 @@ async def relancer_signatures_automatique(session: AsyncSession = Depends(get_se
|
|||
nb_relances += 1
|
||||
|
||||
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:
|
||||
|
|
@ -6083,6 +6085,42 @@ async def definir_contact_defaut(numero: str, contact_numero: int):
|
|||
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__":
|
||||
uvicorn.run(
|
||||
"api:app",
|
||||
|
|
|
|||
|
|
@ -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 # AJOUT MANQUANT - C'ÉTAIT LE BUG !
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
|
@ -14,14 +14,6 @@ async def get_current_user(
|
|||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> 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
|
||||
|
||||
# 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.",
|
||||
)
|
||||
|
||||
# ✅ 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():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
|
|
|
|||
|
|
@ -27,20 +27,20 @@ async def create_admin():
|
|||
"""Crée un utilisateur admin"""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("🔐 Création d'un compte administrateur")
|
||||
print(" Création d'un compte administrateur")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
# Saisie des informations
|
||||
email = input("Email de l'admin: ").strip().lower()
|
||||
if not email or "@" not in email:
|
||||
print("❌ Email invalide")
|
||||
print(" Email invalide")
|
||||
return False
|
||||
|
||||
prenom = input("Prénom: ").strip()
|
||||
nom = input("Nom: ").strip()
|
||||
|
||||
if not prenom or not nom:
|
||||
print("❌ Prénom et nom requis")
|
||||
print(" Prénom et nom requis")
|
||||
return False
|
||||
|
||||
# Mot de passe avec validation
|
||||
|
|
@ -55,9 +55,9 @@ async def create_admin():
|
|||
if password == confirm:
|
||||
break
|
||||
else:
|
||||
print("❌ Les mots de passe ne correspondent pas\n")
|
||||
print(" Les mots de passe ne correspondent pas\n")
|
||||
else:
|
||||
print(f"❌ {error_msg}\n")
|
||||
print(f" {error_msg}\n")
|
||||
|
||||
# Vérifier si l'email existe déjà
|
||||
async with async_session_factory() as session:
|
||||
|
|
@ -67,7 +67,7 @@ async def create_admin():
|
|||
existing = result.scalar_one_or_none()
|
||||
|
||||
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
|
||||
|
||||
# Créer l'admin
|
||||
|
|
@ -86,12 +86,12 @@ async def create_admin():
|
|||
session.add(admin)
|
||||
await session.commit()
|
||||
|
||||
print("\n✅ Administrateur créé avec succès!")
|
||||
print(f"📧 Email: {email}")
|
||||
print(f"👤 Nom: {prenom} {nom}")
|
||||
print(f"🔑 Rôle: admin")
|
||||
print(f"🆔 ID: {admin.id}")
|
||||
print("\n💡 Vous pouvez maintenant vous connecter à l'API\n")
|
||||
print("\n Administrateur créé avec succès!")
|
||||
print(f" Email: {email}")
|
||||
print(f" Nom: {prenom} {nom}")
|
||||
print(f" Rôle: admin")
|
||||
print(f" ID: {admin.id}")
|
||||
print("\n Vous pouvez maintenant vous connecter à l'API\n")
|
||||
|
||||
return True
|
||||
|
||||
|
|
@ -101,9 +101,9 @@ if __name__ == "__main__":
|
|||
result = asyncio.run(create_admin())
|
||||
sys.exit(0 if result else 1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n❌ Création annulée")
|
||||
print("\n\n Création annulée")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\n❌ Erreur: {e}")
|
||||
print(f"\n Erreur: {e}")
|
||||
logger.exception("Détails:")
|
||||
sys.exit(1)
|
||||
|
|
|
|||
|
|
@ -27,17 +27,17 @@ 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
|
||||
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)
|
||||
|
||||
logger.info("✅ Base de données initialisée avec succès")
|
||||
logger.info(f"📍 Fichier DB: {DATABASE_URL}")
|
||||
logger.info(" Base de données initialisée avec succès")
|
||||
logger.info(f" Fichier DB: {DATABASE_URL}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur initialisation DB: {e}")
|
||||
logger.error(f" Erreur initialisation DB: {e}")
|
||||
raise
|
||||
|
||||
|
||||
|
|
@ -53,4 +53,4 @@ async def get_session() -> AsyncSession:
|
|||
async def close_db():
|
||||
"""Ferme proprement toutes les connexions"""
|
||||
await engine.dispose()
|
||||
logger.info("🔌 Connexions DB fermées")
|
||||
logger.info(" Connexions DB fermées")
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class EmailQueue:
|
||||
"""
|
||||
Queue d'emails avec workers threadés et retry automatique
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.queue = queue.Queue()
|
||||
|
|
@ -45,23 +42,23 @@ class EmailQueue:
|
|||
worker.start()
|
||||
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):
|
||||
"""Arrête les workers proprement"""
|
||||
logger.info("🛑 Arrêt de la queue email...")
|
||||
logger.info(" Arrêt de la queue email...")
|
||||
self.running = False
|
||||
|
||||
try:
|
||||
self.queue.join()
|
||||
logger.info("✅ Queue email arrêtée proprement")
|
||||
logger.info(" Queue email arrêtée proprement")
|
||||
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):
|
||||
"""Ajoute un email dans la queue"""
|
||||
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):
|
||||
"""Worker qui traite les emails dans un thread"""
|
||||
|
|
@ -80,7 +77,7 @@ class EmailQueue:
|
|||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur worker: {e}", exc_info=True)
|
||||
logger.error(f" Erreur worker: {e}", exc_info=True)
|
||||
try:
|
||||
self.queue.task_done()
|
||||
except:
|
||||
|
|
@ -94,7 +91,7 @@ class EmailQueue:
|
|||
from sqlalchemy import select
|
||||
|
||||
if not self.session_factory:
|
||||
logger.error("❌ session_factory non configuré")
|
||||
logger.error(" session_factory non configuré")
|
||||
return
|
||||
|
||||
async with self.session_factory() as session:
|
||||
|
|
@ -104,7 +101,7 @@ class EmailQueue:
|
|||
email_log = result.scalar_one_or_none()
|
||||
|
||||
if not email_log:
|
||||
logger.error(f"❌ Email log {email_log_id} introuvable")
|
||||
logger.error(f" Email log {email_log_id} introuvable")
|
||||
return
|
||||
|
||||
email_log.statut = StatutEmail.EN_COURS
|
||||
|
|
@ -117,7 +114,7 @@ class EmailQueue:
|
|||
email_log.statut = StatutEmail.ENVOYE
|
||||
email_log.date_envoi = datetime.now()
|
||||
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:
|
||||
email_log.statut = StatutEmail.ERREUR
|
||||
|
|
@ -134,10 +131,10 @@ class EmailQueue:
|
|||
timer.start()
|
||||
|
||||
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:
|
||||
logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}")
|
||||
logger.error(f" Échec définitif: {email_log.destinataire} - {e}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
|
@ -176,20 +173,20 @@ class EmailQueue:
|
|||
logger.info(f"📎 PDF attaché: {doc_id}.pdf")
|
||||
|
||||
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)
|
||||
|
||||
def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes:
|
||||
|
||||
if not self.sage_client:
|
||||
logger.error("❌ sage_client non configuré")
|
||||
logger.error(" sage_client non configuré")
|
||||
raise Exception("sage_client non disponible")
|
||||
|
||||
try:
|
||||
doc = self.sage_client.lire_document(doc_id, type_doc)
|
||||
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")
|
||||
|
||||
if not doc:
|
||||
|
|
@ -284,11 +281,10 @@ class EmailQueue:
|
|||
pdf.save()
|
||||
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()
|
||||
|
||||
def _send_smtp(self, msg):
|
||||
"""Envoi SMTP bloquant (appelé depuis asyncio.to_thread)"""
|
||||
try:
|
||||
with smtplib.SMTP(
|
||||
settings.smtp_host, settings.smtp_port, timeout=30
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ 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 depuis database/__init__.py
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
|
@ -32,8 +32,8 @@ async def main():
|
|||
# Créer les tables
|
||||
await init_db()
|
||||
|
||||
print("\n✅ Base de données créée avec succès!")
|
||||
print(f"📍 Fichier: sage_dataven.db")
|
||||
print("\n Base de données créée avec succès!")
|
||||
print(f" Fichier: sage_dataven.db")
|
||||
|
||||
print("\n📊 Tables créées:")
|
||||
print(" ├─ email_logs (Journalisation emails)")
|
||||
|
|
@ -53,7 +53,7 @@ async def main():
|
|||
return True
|
||||
|
||||
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:")
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -95,12 +95,7 @@ async def log_login_attempt(
|
|||
async def check_rate_limit(
|
||||
session: AsyncSession, email: str, ip: 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)
|
||||
|
||||
result = await session.execute(
|
||||
|
|
@ -126,13 +121,7 @@ async def register(
|
|||
request: Request,
|
||||
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))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -170,7 +159,7 @@ async def register(
|
|||
if not email_sent:
|
||||
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 {
|
||||
"success": True,
|
||||
|
|
@ -183,7 +172,7 @@ 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)
|
||||
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))
|
||||
|
|
@ -207,11 +196,11 @@ async def verify_email_get(token: str, session: AsyncSession = Depends(get_sessi
|
|||
user.verification_token_expires = None
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Email vérifié: {user.email}")
|
||||
logger.info(f" Email vérifié: {user.email}")
|
||||
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
|
@ -221,7 +210,7 @@ async def verify_email_post(
|
|||
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
|
||||
"""
|
||||
result = await session.execute(
|
||||
|
|
@ -246,7 +235,7 @@ async def verify_email_post(
|
|||
user.verification_token_expires = None
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"✅ Email vérifié: {user.email}")
|
||||
logger.info(f" Email vérifié: {user.email}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
@ -260,9 +249,6 @@ async def resend_verification(
|
|||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""
|
||||
🔄 Renvoyer l'email de vérification
|
||||
"""
|
||||
result = await session.execute(select(User).where(User.email == data.email.lower()))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -292,11 +278,6 @@ async def resend_verification(
|
|||
async def login(
|
||||
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"
|
||||
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)
|
||||
|
||||
logger.info(f"✅ Connexion réussie: {user.email}")
|
||||
logger.info(f" Connexion réussie: {user.email}")
|
||||
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
|
|
@ -401,9 +382,7 @@ async def login(
|
|||
async def refresh_access_token(
|
||||
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
"""
|
||||
🔄 Renouvellement du access_token via refresh_token
|
||||
"""
|
||||
|
||||
payload = decode_token(data.refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
|
|
@ -446,7 +425,7 @@ async def refresh_access_token(
|
|||
{"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(
|
||||
access_token=new_access_token,
|
||||
|
|
@ -461,9 +440,7 @@ async def forgot_password(
|
|||
request: Request,
|
||||
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()))
|
||||
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)
|
||||
|
||||
logger.info(f"📧 Reset password demandé: {user.email}")
|
||||
logger.info(f" Reset password demandé: {user.email}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
@ -497,9 +474,7 @@ async def forgot_password(
|
|||
async def reset_password(
|
||||
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))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
|
|
@ -528,7 +503,7 @@ async def reset_password(
|
|||
|
||||
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 {
|
||||
"success": True,
|
||||
|
|
@ -542,9 +517,7 @@ async def logout(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
🚪 Déconnexion (révocation du refresh token)
|
||||
"""
|
||||
|
||||
token_hash = hash_token(data.refresh_token)
|
||||
|
||||
result = await session.execute(
|
||||
|
|
@ -566,9 +539,7 @@ async def logout(
|
|||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(user: User = Depends(get_current_user)):
|
||||
"""
|
||||
👤 Récupération du profil utilisateur
|
||||
"""
|
||||
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ class SageGatewayClient:
|
|||
except requests.exceptions.RequestException as e:
|
||||
if attempt == retries - 1:
|
||||
logger.error(
|
||||
f"❌ Échec après {retries} tentatives sur {endpoint}: {e}"
|
||||
f" Échec après {retries} tentatives sur {endpoint}: {e}"
|
||||
)
|
||||
raise
|
||||
time.sleep(2**attempt)
|
||||
|
|
@ -54,7 +54,7 @@ class SageGatewayClient:
|
|||
except requests.exceptions.RequestException as e:
|
||||
if attempt == retries - 1:
|
||||
logger.error(
|
||||
f"❌ Échec GET après {retries} tentatives sur {endpoint}: {e}"
|
||||
f" Échec GET après {retries} tentatives sur {endpoint}: {e}"
|
||||
)
|
||||
raise
|
||||
time.sleep(2**attempt)
|
||||
|
|
@ -108,7 +108,7 @@ class SageGatewayClient:
|
|||
r.raise_for_status()
|
||||
return r.json().get("data", {})
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"❌ Erreur changement statut: {e}")
|
||||
logger.error(f" Erreur changement statut: {e}")
|
||||
raise
|
||||
|
||||
def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]:
|
||||
|
|
@ -134,7 +134,7 @@ class SageGatewayClient:
|
|||
r.raise_for_status()
|
||||
return r.json().get("data", {})
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"❌ Erreur transformation: {e}")
|
||||
logger.error(f" Erreur transformation: {e}")
|
||||
raise
|
||||
|
||||
def mettre_a_jour_champ_libre(
|
||||
|
|
@ -352,7 +352,7 @@ class SageGatewayClient:
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -364,11 +364,11 @@ class SageGatewayClient:
|
|||
)
|
||||
|
||||
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)}")
|
||||
|
||||
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
|
||||
|
||||
def creer_article(self, article_data: Dict) -> Dict:
|
||||
|
|
@ -420,7 +420,7 @@ class SageGatewayClient:
|
|||
r.raise_for_status()
|
||||
return r.json().get("data", {})
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"❌ Erreur listage modèles: {e}")
|
||||
logger.error(f" Erreur listage modèles: {e}")
|
||||
raise
|
||||
|
||||
def generer_pdf_document(
|
||||
|
|
@ -446,7 +446,7 @@ class SageGatewayClient:
|
|||
return r.content
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -476,16 +476,6 @@ class SageGatewayClient:
|
|||
|
||||
|
||||
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", {
|
||||
"numero": numero,
|
||||
"contact_numero": contact_numero
|
||||
|
|
@ -493,19 +483,22 @@ class SageGatewayClient:
|
|||
|
||||
|
||||
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", {
|
||||
"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")
|
||||
|
||||
sage_client = SageGatewayClient()
|
||||
|
|
|
|||
9
schemas/__init__.py
Normal file
9
schemas/__init__.py
Normal 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
41
schemas/tiers/contact.py
Normal 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
104
schemas/tiers/tiers.py
Normal 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"
|
||||
)
|
||||
6
schemas/tiers/type_tiers.py
Normal file
6
schemas/tiers/type_tiers.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class TypeTiers(str, Enum):
|
||||
"""Types de tiers possibles"""
|
||||
ALL = "all"
|
||||
CLIENT = "client"
|
||||
FOURNISSEUR = "fournisseur"
|
||||
PROSPECT = "prospect"
|
||||
|
|
@ -32,11 +32,11 @@ class AuthEmailService:
|
|||
|
||||
server.send_message(msg)
|
||||
|
||||
logger.info(f"✅ Email envoyé: {subject} → {to}")
|
||||
logger.info(f" Email envoyé: {subject} → {to}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Erreur envoi email: {e}")
|
||||
logger.error(f" Erreur envoi email: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -91,7 +91,7 @@ class AuthEmailService:
|
|||
</p>
|
||||
|
||||
<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 style="margin-top: 30px; font-size: 14px; color: #6b7280;">
|
||||
|
|
@ -107,7 +107,7 @@ class AuthEmailService:
|
|||
"""
|
||||
|
||||
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
|
||||
|
|
@ -146,7 +146,7 @@ class AuthEmailService:
|
|||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔑 Réinitialisation de mot de passe</h1>
|
||||
<h1> Réinitialisation de mot de passe</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>Demande de réinitialisation</h2>
|
||||
|
|
@ -162,7 +162,7 @@ class AuthEmailService:
|
|||
</p>
|
||||
|
||||
<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 style="margin-top: 30px; font-size: 14px; color: #6b7280;">
|
||||
|
|
@ -178,7 +178,7 @@ class AuthEmailService:
|
|||
"""
|
||||
|
||||
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
|
||||
|
|
@ -199,14 +199,14 @@ class AuthEmailService:
|
|||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>✅ Mot de passe modifié</h1>
|
||||
<h1> Mot de passe modifié</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="footer">
|
||||
|
|
@ -218,5 +218,5 @@ class AuthEmailService:
|
|||
"""
|
||||
|
||||
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
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue