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