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
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
View file

@ -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",

View file

@ -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,

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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
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)
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
)