From e990cbdc084f147a3063eae92814dd8967e954e5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Thu, 8 Jan 2026 16:58:43 +0300 Subject: [PATCH 01/36] MERGING branch develop INTO MAIN --- .env.example | 2 +- .trunk/.gitignore | 9 + .trunk/trunk.yaml | 32 + Dockerfile | 85 +- api.py | 3100 +++++++++++++++++++++++---- config.py => config/config.py | 33 +- core/dependencies.py | 94 + core/sage_context.py | 77 + create_admin.py | 97 + data/data.py | 405 ++++ data/sage_dataven.db | Bin 0 -> 282624 bytes database/__init__.py | 73 +- database/db_config.py | 37 +- database/enum/status.py | 18 + database/models.py | 204 -- database/models/email.py | 43 + database/models/generic_model.py | 91 + database/models/sage_config.py | 54 + database/models/signature.py | 44 + database/models/universign.py | 303 +++ database/models/user.py | 39 + database/models/workflow.py | 37 + docker-compose.dev.yml | 24 + docker-compose.prod.yml | 23 + docker-compose.staging.yml | 22 + docker-compose.yml | 15 +- email_queue.py | 574 +++-- init_db.py | 54 +- requirements.txt | 9 +- routes/auth.py | 529 +++++ routes/sage_gateway.py | 323 +++ routes/universign.py | 1615 ++++++++++++++ sage_client.py | 421 +++- schemas/__init__.py | 108 + schemas/articles/articles.py | 650 ++++++ schemas/articles/famille_article.py | 255 +++ schemas/documents/avoirs.py | 55 + schemas/documents/commandes.py | 54 + schemas/documents/devis.py | 55 + schemas/documents/documents.py | 22 + schemas/documents/email.py | 23 + schemas/documents/factures.py | 53 + schemas/documents/ligne_document.py | 25 + schemas/documents/livraisons.py | 55 + schemas/documents/universign.py | 18 + schemas/sage/sage_gateway.py | 164 ++ schemas/schema_mixte.py | 9 + schemas/tiers/__init__.py | 0 schemas/tiers/clients.py | 576 +++++ schemas/tiers/commercial.py | 116 + schemas/tiers/contact.py | 111 + schemas/tiers/fournisseurs.py | 79 + schemas/tiers/tiers.py | 217 ++ schemas/tiers/tiers_collab.py | 54 + schemas/tiers/type_tiers.py | 8 + schemas/user.py | 18 + security/auth.py | 92 + services/email_service.py | 202 ++ services/sage_gateway.py | 400 ++++ services/universign_document.py | 156 ++ services/universign_sync.py | 695 ++++++ tools/cleaner.py | 15 + tools/extract_pydantic_models.py | 54 + utils/__init__.py | 27 + utils/enums.py | 129 ++ utils/generic_functions.py | 468 ++++ utils/normalization.py | 16 + utils/universign_status_mapping.py | 165 ++ 68 files changed, 12602 insertions(+), 1028 deletions(-) create mode 100644 .trunk/.gitignore create mode 100644 .trunk/trunk.yaml rename config.py => config/config.py (52%) create mode 100644 core/dependencies.py create mode 100644 core/sage_context.py create mode 100644 create_admin.py create mode 100644 data/data.py create mode 100644 data/sage_dataven.db create mode 100644 database/enum/status.py delete mode 100644 database/models.py create mode 100644 database/models/email.py create mode 100644 database/models/generic_model.py create mode 100644 database/models/sage_config.py create mode 100644 database/models/signature.py create mode 100644 database/models/universign.py create mode 100644 database/models/user.py create mode 100644 database/models/workflow.py create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.staging.yml create mode 100644 routes/auth.py create mode 100644 routes/sage_gateway.py create mode 100644 routes/universign.py create mode 100644 schemas/__init__.py create mode 100644 schemas/articles/articles.py create mode 100644 schemas/articles/famille_article.py create mode 100644 schemas/documents/avoirs.py create mode 100644 schemas/documents/commandes.py create mode 100644 schemas/documents/devis.py create mode 100644 schemas/documents/documents.py create mode 100644 schemas/documents/email.py create mode 100644 schemas/documents/factures.py create mode 100644 schemas/documents/ligne_document.py create mode 100644 schemas/documents/livraisons.py create mode 100644 schemas/documents/universign.py create mode 100644 schemas/sage/sage_gateway.py create mode 100644 schemas/schema_mixte.py create mode 100644 schemas/tiers/__init__.py create mode 100644 schemas/tiers/clients.py create mode 100644 schemas/tiers/commercial.py create mode 100644 schemas/tiers/contact.py create mode 100644 schemas/tiers/fournisseurs.py create mode 100644 schemas/tiers/tiers.py create mode 100644 schemas/tiers/tiers_collab.py create mode 100644 schemas/tiers/type_tiers.py create mode 100644 schemas/user.py create mode 100644 security/auth.py create mode 100644 services/email_service.py create mode 100644 services/sage_gateway.py create mode 100644 services/universign_document.py create mode 100644 services/universign_sync.py create mode 100644 tools/cleaner.py create mode 100644 tools/extract_pydantic_models.py create mode 100644 utils/__init__.py create mode 100644 utils/enums.py create mode 100644 utils/generic_functions.py create mode 100644 utils/normalization.py create mode 100644 utils/universign_status_mapping.py diff --git a/.env.example b/.env.example index 0995196..314aa07 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ SAGE_GATEWAY_URL=http://192.168.1.50:8100 SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f # === Base de données === -DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db +DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db # === SMTP === SMTP_HOST=smtp.office365.com diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 0000000..15966d0 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 0000000..1e4ac82 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,32 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.25.0 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.7.4 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - node@22.16.0 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - git-diff-check + - hadolint@2.14.0 + - markdownlint@0.47.0 + - osv-scanner@2.3.1 + - prettier@3.7.4 + - trufflehog@3.92.4 +actions: + disabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + enabled: + - trunk-upgrade-available diff --git a/Dockerfile b/Dockerfile index 7e49ad0..55d43e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,78 @@ -# Backend Dockerfile -FROM python:3.12-slim - +# ================================ +# Base +# ================================ +FROM python:3.12-slim AS base WORKDIR /app -# Copier et installer les dépendances -COPY requirements.txt . -RUN pip install --no-cache-dir --upgrade pip \ - && pip install --no-cache-dir -r requirements.txt +# Installer dépendances système si nécessaire +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip + +# ================================ +# DEV +# ================================ +FROM base AS dev +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + ENV=development + +# Installer dépendances dev (si vous avez un requirements.dev.txt) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Créer dossiers +RUN mkdir -p /app/data /app/logs && chmod -R 777 /app/data /app/logs -# Copier le reste du projet COPY . . -# ✅ Créer dossier persistant pour SQLite avec bonnes permissions -RUN mkdir -p /app/data && chmod 777 /app/data - -# Exposer le port EXPOSE 8000 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] -# Lancer l'API et initialiser la DB au démarrage -CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000 --reload"] \ No newline at end of file +# ================================ +# STAGING +# ================================ +FROM base AS staging +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + ENV=staging + +RUN pip install --no-cache-dir -r requirements.txt + +RUN mkdir -p /app/data /app/logs && chmod -R 755 /app/data /app/logs + +COPY . . + +# Initialiser la DB au build +RUN python init_db.py || true + +EXPOSE 8002 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", "--log-level", "info"] + +# ================================ +# PROD +# ================================ +FROM base AS prod +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + ENV=production + +RUN pip install --no-cache-dir -r requirements.txt + +# Créer utilisateur non-root pour la sécurité +RUN useradd -m -u 1000 appuser && \ + mkdir -p /app/data /app/logs && \ + chown -R appuser:appuser /app + +COPY --chown=appuser:appuser . . + +# Initialiser la DB au build +RUN python init_db.py || true + +USER appuser + +EXPOSE 8004 +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8004", "--workers", "4"] \ No newline at end of file diff --git a/api.py b/api.py index 368edb1..d37e7f0 100644 --- a/api.py +++ b/api.py @@ -1,11 +1,12 @@ -from fastapi import FastAPI, HTTPException, Query, Depends, status +from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse +from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr -from typing import List, Optional, Dict -from datetime import date, datetime -from enum import Enum +from typing import List, Optional +from datetime import datetime import uvicorn +import asyncio from contextlib import asynccontextmanager import uuid import csv @@ -13,260 +14,149 @@ import io import logging from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select - -# Configuration logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler("sage_api.log"), - logging.StreamHandler() - ] -) -logger = logging.getLogger(__name__) - -# Imports locaux -from config import settings +import os +from pathlib import Path as FilePath +from data.data import TAGS_METADATA, templates_signature_email +from routes.auth import router as auth_router +from config.config import settings from database import ( init_db, async_session_factory, get_session, EmailLog, - StatutEmail as StatutEmailEnum, + StatutEmail as StatutEmailDB, WorkflowLog, SignatureLog, - StatutSignature as StatutSignatureEnum + StatutSignature as StatutSignatureDB, ) from email_queue import email_queue -from sage_client import sage_client +from sage_client import sage_client, SageGatewayClient -# ===================================================== -# ENUMS -# ===================================================== -class TypeDocument(int, Enum): - DEVIS = 0 - BON_LIVRAISON = 1 - BON_RETOUR = 2 - COMMANDE = 3 - PREPARATION = 4 - FACTURE = 5 +from schemas import ( + TiersDetails, + BaremeRemiseResponse, + Users, + ClientCreate, + ClientDetails, + ClientUpdate, + FournisseurCreate, + FournisseurDetails, + FournisseurUpdate, + Contact, + AvoirCreate, + AvoirUpdate, + CommandeCreate, + CommandeUpdate, + DevisRequest, + Devis, + DevisUpdate, + TypeDocument, + TypeDocumentSQL, + StatutEmail, + EmailEnvoi, + FactureCreate, + FactureUpdate, + LivraisonCreate, + LivraisonUpdate, + StatutSignature, + ArticleCreate, + Article, + ArticleUpdate, + EntreeStock, + SortieStock, + MouvementStock, + RelanceDevis, + Familles, + FamilleCreate, + ContactCreate, + ContactUpdate, +) +from schemas.tiers.commercial import ( + CollaborateurCreate, + CollaborateurDetails, + CollaborateurUpdate, +) +from utils.normalization import normaliser_type_tiers +from routes.sage_gateway import router as sage_gateway_router +from routes.universign import router as universign_router -class StatutSignature(str, Enum): - EN_ATTENTE = "EN_ATTENTE" - ENVOYE = "ENVOYE" - SIGNE = "SIGNE" - REFUSE = "REFUSE" - EXPIRE = "EXPIRE" +from services.universign_sync import UniversignSyncService, UniversignSyncScheduler -class StatutEmail(str, Enum): - EN_ATTENTE = "EN_ATTENTE" - EN_COURS = "EN_COURS" - ENVOYE = "ENVOYE" - OUVERT = "OUVERT" - ERREUR = "ERREUR" - BOUNCE = "BOUNCE" +from core.sage_context import ( + get_sage_client_for_user, + get_gateway_context_for_user, + GatewayContext, +) +from utils.generic_functions import ( + _preparer_lignes_document, + universign_envoyer, + universign_statut, +) -# ===================================================== -# MODÈLES PYDANTIC -# ===================================================== -class ClientResponse(BaseModel): - numero: str - intitule: str - adresse: Optional[str] = None - code_postal: Optional[str] = None - ville: Optional[str] = None - email: Optional[str] = None - telephone: Optional[str] = None +if os.path.exists("/app"): + LOGS_DIR = FilePath("/app/logs") +else: + LOGS_DIR = FilePath(__file__).resolve().parent / "logs" -class ArticleResponse(BaseModel): - reference: str - designation: str - prix_vente: float - stock_reel: float +LOGS_DIR.mkdir(parents=True, exist_ok=True) -class LigneDevis(BaseModel): - article_code: str - quantite: float - prix_unitaire_ht: Optional[float] = None - remise_pourcentage: Optional[float] = 0.0 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler(LOGS_DIR / "sage_api.log", encoding="utf-8"), + logging.StreamHandler(), + ], +) -class DevisRequest(BaseModel): - client_id: str - date_devis: Optional[date] = None - lignes: List[LigneDevis] +logger = logging.getLogger(__name__) -class DevisResponse(BaseModel): - id: str - client_id: str - date_devis: str - montant_total_ht: float - montant_total_ttc: float - nb_lignes: int -class SignatureRequest(BaseModel): - doc_id: str - type_doc: TypeDocument - email_signataire: EmailStr - nom_signataire: str - -class EmailEnvoiRequest(BaseModel): - destinataire: EmailStr - cc: Optional[List[EmailStr]] = [] - cci: Optional[List[EmailStr]] = [] - sujet: str - corps_html: str - document_ids: Optional[List[str]] = None - type_document: Optional[TypeDocument] = None - -class RelanceDevisRequest(BaseModel): - doc_id: str - message_personnalise: Optional[str] = None - -# ===================================================== -# SERVICES EXTERNES (Universign) -# ===================================================== -async def universign_envoyer(doc_id: str, pdf_bytes: bytes, email: str, nom: str) -> Dict: - """Envoi signature via API Universign""" - import requests - - try: - api_key = settings.universign_api_key - api_url = settings.universign_api_url - auth = (api_key, "") - - # Étape 1: Créer transaction - response = requests.post( - f"{api_url}/transactions", - auth=auth, - json={"name": f"Devis {doc_id}", "language": "fr"}, - timeout=30 - ) - response.raise_for_status() - transaction_id = response.json().get("id") - - # Étape 2: Upload PDF - files = {'file': (f'Devis_{doc_id}.pdf', pdf_bytes, 'application/pdf')} - response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30) - response.raise_for_status() - file_id = response.json().get("id") - - # Étape 3: Ajouter document - response = requests.post( - f"{api_url}/transactions/{transaction_id}/documents", - auth=auth, - data={"document": file_id}, - timeout=30 - ) - response.raise_for_status() - document_id = response.json().get("id") - - # Étape 4: Créer champ signature - response = requests.post( - f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", - auth=auth, - data={"type": "signature"}, - timeout=30 - ) - response.raise_for_status() - field_id = response.json().get("id") - - # Étape 5: Assigner signataire - response = requests.post( - f"{api_url}/transactions/{transaction_id}/signatures", - auth=auth, - data={"signer": email, "field": field_id}, - timeout=30 - ) - response.raise_for_status() - - # Étape 6: Démarrer transaction - response = requests.post( - f"{api_url}/transactions/{transaction_id}/start", - auth=auth, - timeout=30 - ) - response.raise_for_status() - - final_data = response.json() - signer_url = final_data.get("actions", [{}])[0].get("url", "") if final_data.get("actions") else "" - - logger.info(f"✅ Signature Universign envoyée: {transaction_id}") - - return { - "transaction_id": transaction_id, - "signer_url": signer_url, - "statut": "ENVOYE" - } - - except Exception as e: - logger.error(f"❌ Erreur Universign: {e}") - return {"error": str(e), "statut": "ERREUR"} - -async def universign_statut(transaction_id: str) -> Dict: - """Récupération statut signature""" - import requests - - try: - response = requests.get( - f"{settings.universign_api_url}/transactions/{transaction_id}", - auth=(settings.universign_api_key, ""), - timeout=10 - ) - - if response.status_code == 200: - data = response.json() - statut_map = { - "draft": "EN_ATTENTE", - "started": "EN_ATTENTE", - "completed": "SIGNE", - "refused": "REFUSE", - "expired": "EXPIRE", - "canceled": "REFUSE" - } - return { - "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), - "date_signature": data.get("completed_at") - } - else: - return {"statut": "ERREUR"} - - except Exception as e: - logger.error(f"Erreur statut Universign: {e}") - return {"statut": "ERREUR", "error": str(e)} - -# ===================================================== -# CYCLE DE VIE -# ===================================================== @asynccontextmanager async def lifespan(app: FastAPI): - # Init base de données await init_db() - logger.info("✅ Base de données initialisée") - - # Injecter session_factory dans email_queue - email_queue.session_factory = async_session_factory - - # ⚠️ PAS de sage_connector ici (c'est sur Windows !) - # email_queue utilisera sage_client pour générer les PDFs via HTTP - - # Démarrer queue - email_queue.start(num_workers=settings.max_email_workers) - logger.info(f"✅ Email queue démarrée") - - yield - - # Cleanup - email_queue.stop() - logger.info("👋 Services arrêtés") + logger.info("Base de données initialisée") + + email_queue.session_factory = async_session_factory + email_queue.sage_client = sage_client + + logger.info("sage_client injecté dans email_queue") + + email_queue.start(num_workers=settings.max_email_workers) + logger.info("Email queue démarrée") + + sync_service = UniversignSyncService( + api_url=settings.universign_api_url, api_key=settings.universign_api_key + ) + + # Configuration du service avec les dépendances + sync_service.configure( + sage_client=sage_client, email_queue=email_queue, settings=settings + ) + + scheduler = UniversignSyncScheduler( + sync_service=sync_service, + interval_minutes=5, + ) + + sync_task = asyncio.create_task(scheduler.start(async_session_factory)) + + logger.info("Synchronisation Universign démarrée (5min)") + + yield + + scheduler.stop() + sync_task.cancel() + email_queue.stop() + logger.info("Services arrêtés") + -# ===================================================== -# APPLICATION -# ===================================================== app = FastAPI( - title="API Sage 100c Dataven", - version="2.0.0", - description="API de gestion commerciale - VPS Linux", - lifespan=lifespan + title="Sage Gateways", + version="3.0.0", + description="Configuration multi-tenant des connexions Sage Gateway", + lifespan=lifespan, + openapi_tags=TAGS_METADATA, ) app.add_middleware( @@ -274,117 +164,503 @@ app.add_middleware( allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], - allow_credentials=True + allow_credentials=True, ) -# ===================================================== -# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) -# ===================================================== -@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"]) -async def rechercher_clients(query: Optional[str] = Query(None)): - """🔍 Recherche clients via gateway Windows""" + +app.include_router(auth_router) +app.include_router(sage_gateway_router) +app.include_router(universign_router) + + +@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) +async def obtenir_clients( + query: Optional[str] = Query(None), + #sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: clients = sage_client.lister_clients(filtre=query or "") - return [ClientResponse(**c) for c in clients] + return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") raise HTTPException(500, str(e)) -@app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) + +@app.get("/clients/{code}", response_model=ClientDetails, tags=["Clients"]) +async def lire_client_detail(code: str): + try: + client = sage_client.lire_client(code) + + if not client: + raise HTTPException(404, f"Client {code} introuvable") + + return ClientDetails(**client) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture client {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/clients/{code}", tags=["Clients"]) +async def modifier_client( + code: str, + client_update: ClientUpdate, + session: AsyncSession = Depends(get_session), +): + try: + resultat = sage_client.modifier_client( + code, client_update.dict(exclude_none=True) + ) + + logger.info(f"Client {code} modifié avec succès") + + return { + "success": True, + "message": f"Client {code} modifié avec succès", + "client": resultat, + } + + except ValueError as e: + logger.warning(f"Erreur métier modification client {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + logger.error(f"Erreur technique modification client {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/clients", status_code=201, tags=["Clients"]) +async def ajouter_client( + client: ClientCreate, session: AsyncSession = Depends(get_session) +): + try: + nouveau_client = sage_client.creer_client(client.model_dump(mode="json")) + + logger.info(f"Client créé via API: {nouveau_client.get('numero')}") + + return jsonable_encoder( + { + "success": True, + "message": "Client créé avec succès", + "data": nouveau_client, + } + ) + + except Exception as e: + logger.error(f"Erreur lors de la création du client: {e}") + status = 400 if "existe déjà" in str(e) else 500 + raise HTTPException(status, str(e)) + + +@app.get("/articles", response_model=List[Article], tags=["Articles"]) async def rechercher_articles(query: Optional[str] = Query(None)): - """🔍 Recherche articles via gateway Windows""" try: articles = sage_client.lister_articles(filtre=query or "") - return [ArticleResponse(**a) for a in articles] + return [Article(**a) for a in articles] except Exception as e: logger.error(f"Erreur recherche articles: {e}") raise HTTPException(500, str(e)) -@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) -async def creer_devis(devis: DevisRequest): - """📝 Création de devis via gateway Windows""" + +@app.post( + "/articles", + response_model=Article, + status_code=status.HTTP_201_CREATED, + tags=["Articles"], +) +async def creer_article(article: ArticleCreate): + try: + if not article.reference or not article.designation: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'reference' et 'designation' sont obligatoires", + ) + + article_data = article.dict(exclude_unset=True) + + logger.info(f"Création article: {article.reference} - {article.designation}") + + resultat = sage_client.creer_article(article_data) + + logger.info( + f"Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" + ) + + return Article(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier création article: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + logger.error(f"Erreur technique création article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'article: {str(e)}", + ) + + +@app.put("/articles/{reference}", response_model=Article, tags=["Articles"]) +async def modifier_article( + reference: str = Path(..., description="Référence de l'article à modifier"), + article: ArticleUpdate = Body(...), +): + try: + article_data = article.dict(exclude_unset=True) + + if not article_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Aucun champ à modifier. Fournissez au moins un champ à mettre à jour.", + ) + + logger.info(f"Modification article {reference}: {list(article_data.keys())}") + + resultat = sage_client.modifier_article(reference, article_data) + + if "stock_reel" in article_data: + logger.info( + f"Stock {reference} modifié: {article_data['stock_reel']} " + f"(peut résoudre erreur 2881)" + ) + + logger.info(f"Article {reference} modifié ({len(article_data)} champs)") + + return Article(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier modification article: {e}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + logger.error(f"Erreur technique modification article: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la modification de l'article: {str(e)}", + ) + + +@app.get("/articles/{reference}", response_model=Article, tags=["Articles"]) +async def lire_article( + reference: str = Path(..., description="Référence de l'article"), +): + try: + article = sage_client.lire_article(reference) + + if not article: + logger.warning(f"Article {reference} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Article {reference} introuvable", + ) + + logger.info(f"Article {reference} lu: {article.get('designation', '')}") + + return Article(**article) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture article {reference}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de l'article: {str(e)}", + ) + + +@app.post("/devis", response_model=Devis, status_code=201, tags=["Devis"]) +async def creer_devis(devis: DevisRequest): try: - # Préparer les données pour la gateway devis_data = { "client_id": devis.client_id, "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, - "lignes": [ - { - "article_code": l.article_code, - "quantite": l.quantite, - "prix_unitaire_ht": l.prix_unitaire_ht, - "remise_pourcentage": l.remise_pourcentage - } - for l in devis.lignes - ] + "date_livraison": ( + devis.date_livraison.isoformat() if devis.date_livraison else None + ), + "reference": devis.reference, + "lignes": _preparer_lignes_document(devis.lignes), } - - # Appel HTTP vers Windows + resultat = sage_client.creer_devis(devis_data) - - logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}") - - return DevisResponse( + + logger.info( + f"Devis créé: {resultat.get('numero_devis')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + return Devis( id=resultat["numero_devis"], client_id=devis.client_id, date_devis=resultat["date_devis"], montant_total_ht=resultat["total_ht"], montant_total_ttc=resultat["total_ttc"], - nb_lignes=resultat["nb_lignes"] + nb_lignes=resultat["nb_lignes"], ) - + except Exception as e: logger.error(f"Erreur création devis: {e}") raise HTTPException(500, str(e)) -@app.get("/devis/{id}", tags=["US-A1"]) + +@app.put("/devis/{id}", tags=["Devis"]) +async def modifier_devis( + id: str, + devis_update: DevisUpdate, + session: AsyncSession = Depends(get_session), +): + try: + update_data = {} + + if devis_update.date_devis: + update_data["date_devis"] = devis_update.date_devis.isoformat() + + if devis_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne in devis_update.lignes + ] + + if devis_update.statut is not None: + update_data["statut"] = devis_update.statut + + if devis_update.reference is not None: + update_data["reference"] = devis_update.reference + + resultat = sage_client.modifier_devis(id, update_data) + + logger.info(f"Devis {id} modifié avec succès") + + return { + "success": True, + "message": f"Devis {id} modifié avec succès", + "devis": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification devis {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/commandes", status_code=201, tags=["Commandes"]) +async def creer_commande( + commande: CommandeCreate, session: AsyncSession = Depends(get_session) +): + try: + commande_data = { + "client_id": commande.client_id, + "date_commande": ( + commande.date_commande.isoformat() if commande.date_commande else None + ), + "date_livraison": ( + commande.date_livraison.isoformat() if commande.date_livraison else None + ), + "reference": commande.reference, + "lignes": _preparer_lignes_document(commande.lignes), + } + + resultat = sage_client.creer_commande(commande_data) + + logger.info( + f"Commande créée: {resultat.get('numero_commande')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + return { + "success": True, + "message": "Commande créée avec succès", + "data": { + "numero_commande": resultat["numero_commande"], + "client_id": commande.client_id, + "date_commande": resultat["date_commande"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": resultat.get("reference"), + "date_livraison": resultat.get("date_livraison"), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création commande: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/commandes/{id}", tags=["Commandes"]) +async def modifier_commande( + id: str, + commande_update: CommandeUpdate, + session: AsyncSession = Depends(get_session), +): + try: + update_data = {} + + if commande_update.date_commande: + update_data["date_commande"] = commande_update.date_commande.isoformat() + + if commande_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne in commande_update.lignes + ] + + if commande_update.statut is not None: + update_data["statut"] = commande_update.statut + + if commande_update.reference is not None: + update_data["reference"] = commande_update.reference + + resultat = sage_client.modifier_commande(id, update_data) + + logger.info(f"Commande {id} modifiée avec succès") + + return { + "success": True, + "message": f"Commande {id} modifiée avec succès", + "commande": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification commande {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/devis", tags=["Devis"]) +async def lister_devis( + limit: int = Query(100, le=1000), + statut: Optional[int] = Query(None), + inclure_lignes: bool = Query( + True, description="Inclure les lignes de chaque devis" + ), +): + try: + devis_list = sage_client.lister_devis( + limit=limit, statut=statut, inclure_lignes=inclure_lignes + ) + return devis_list + + except Exception as e: + logger.error(f"Erreur liste devis: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/devis/{id}", tags=["Devis"]) async def lire_devis(id: str): - """📄 Lecture d'un devis via gateway Windows""" try: devis = sage_client.lire_devis(id) + if not devis: raise HTTPException(404, f"Devis {id} introuvable") - return devis + + return {"success": True, "data": devis} + except HTTPException: raise except Exception as e: logger.error(f"Erreur lecture devis: {e}") raise HTTPException(500, str(e)) -@app.get("/devis/{id}/pdf", tags=["US-A1"]) + +@app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf(id: str): - """📄 Téléchargement PDF (généré via email_queue)""" try: - # Générer PDF en appelant la méthode de email_queue - # qui elle-même appellera sage_client pour récupérer les données pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) - + return StreamingResponse( iter([pdf_bytes]), media_type="application/pdf", - headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"} + headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"}, ) except Exception as e: logger.error(f"Erreur génération PDF: {e}") raise HTTPException(500, str(e)) -@app.post("/devis/{id}/envoyer", tags=["US-A1"]) -async def envoyer_devis_email( - id: str, - request: EmailEnvoiRequest, - session: AsyncSession = Depends(get_session) + +@app.get("/documents/{type_doc}/{numero}/pdf", tags=["Documents"]) +async def telecharger_document_pdf( + type_doc: int = Path( + ..., + description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", + ), + numero: str = Path(..., description="Numéro du document"), +): + try: + types_labels = { + 0: "Devis", + 10: "Commande", + 20: "Preparation", + 30: "BonLivraison", + 40: "BonRetour", + 50: "Avoir", + 60: "Facture", + } + + if type_doc not in types_labels: + raise HTTPException( + 400, + f"Type de document invalide: {type_doc}. " + f"Types valides: {list(types_labels.keys())}", + ) + + label = types_labels[type_doc] + + logger.info(f"Génération PDF: {label} {numero} (type={type_doc})") + + pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) + + if not pdf_bytes: + raise HTTPException(500, f"Le PDF du document {numero} est vide") + + logger.info(f"PDF généré: {len(pdf_bytes)} octets") + + filename = f"{label}_{numero}.pdf" + + return StreamingResponse( + iter([pdf_bytes]), + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)), + }, + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Erreur génération PDF {numero} (type {type_doc}): {e}", exc_info=True + ) + raise HTTPException(500, f"Erreur génération PDF: {str(e)}") + + +@app.post("/devis/{id}/envoyer", tags=["Devis"]) +async def envoyer_devis_email( + id: str, request: EmailEnvoi, session: AsyncSession = Depends(get_session) ): - """📧 Envoi devis par email""" try: - # Vérifier que le devis existe - devis = sage_client.lire_devis(id) - if not devis: - raise HTTPException(404, f"Devis {id} introuvable") - - # Créer logs email pour chaque destinataire tous_destinataires = [request.destinataire] + request.cc + request.cci email_logs = [] - + for dest in tous_destinataires: email_log = EmailLog( id=str(uuid.uuid4()), @@ -393,210 +669,639 @@ async def envoyer_devis_email( corps_html=request.corps_html, document_ids=id, type_document=TypeDocument.DEVIS, - statut=StatutEmailEnum.EN_ATTENTE, + statut=StatutEmailDB.EN_ATTENTE, date_creation=datetime.now(), - nb_tentatives=0 + nb_tentatives=0, ) - + session.add(email_log) await session.flush() - + email_queue.enqueue(email_log.id) email_logs.append(email_log.id) - + await session.commit() - - logger.info(f"✅ Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)") - + + logger.info( + f"Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)" + ) + return { "success": True, "email_log_ids": email_logs, "devis_id": id, - "message": f"{len(tous_destinataires)} email(s) en file d'attente" + "message": f"{len(tous_destinataires)} email(s) en file d'attente", } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur envoi email: {e}") raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) -# ===================================================== -@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) -async def devis_vers_commande( - id: str, - session: AsyncSession = Depends(get_session) + +@app.put("/document/{type_doc}/{numero}/statut", tags=["Documents"]) +async def changer_statut_document( + type_doc: int = Path( + ..., + description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", + ), + numero: str = Path(..., description="Numéro du document"), + nouveau_statut: int = Query( + ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" + ), ): - """🔧 Transformation Devis → Commande via gateway Windows""" + document_type_sql = None + document_type_code = None + type_doc_normalized = None + + try: + match type_doc: + case 0: + document_type_sql = TypeDocumentSQL.DEVIS + document_type_code = TypeDocument.DEVIS + type_doc_normalized = 0 + case 10 | 1: + document_type_sql = TypeDocumentSQL.BON_COMMANDE + document_type_code = TypeDocument.BON_COMMANDE + type_doc_normalized = 10 + case 20 | 2: + document_type_sql = TypeDocumentSQL.PREPARATION + document_type_code = TypeDocument.PREPARATION + type_doc_normalized = 20 + case 30 | 3: + document_type_sql = TypeDocumentSQL.BON_LIVRAISON + document_type_code = TypeDocument.BON_LIVRAISON + type_doc_normalized = 30 + case 40 | 4: + document_type_sql = TypeDocumentSQL.BON_RETOUR + document_type_code = TypeDocument.BON_RETOUR + type_doc_normalized = 40 + case 50 | 5: + document_type_sql = TypeDocumentSQL.BON_AVOIR + document_type_code = TypeDocument.BON_AVOIR + type_doc_normalized = 50 + case 60 | 6: + document_type_sql = TypeDocumentSQL.FACTURE + document_type_code = TypeDocument.FACTURE + type_doc_normalized = 60 + case _: + raise HTTPException( + 400, + f"Type de document invalide: {type_doc}", + ) + + document_existant = sage_client.lire_document(numero, document_type_sql) + if not document_existant: + raise HTTPException(404, f"Document {numero} introuvable") + + statut_actuel = document_existant.get("statut", 0) + + match type_doc: + case 0: + if statut_actuel >= 2: + statuts_devis = {2: "accepté", 3: "perdu", 4: "archivé"} + raise HTTPException( + 400, + f"Le devis {numero} est {statuts_devis.get(statut_actuel, 'verrouillé')} " + f"et ne peut plus changer de statut", + ) + + case 10 | 1 | 20 | 2 | 30 | 3 | 40 | 4 | 50 | 5 | 60 | 6: + if statut_actuel >= 2: + type_names = { + 10: "la commande", + 1: "la commande", + 20: "la préparation", + 2: "la préparation", + 30: "la livraison", + 3: "la livraison", + 40: "le retour", + 4: "le retour", + 50: "l'avoir", + 5: "l'avoir", + 60: "la facture", + 6: "la facture", + } + raise HTTPException( + 400, + f"Le document {numero} ({type_names.get(type_doc, 'document')}) " + f"ne peut plus changer de statut (statut actuel ≥ 2)", + ) + + document_type_int = ( + document_type_code.value + if hasattr(document_type_code, "value") + else type_doc_normalized + ) + + resultat = sage_client.changer_statut_document( + document_type_code=document_type_int, + numero=numero, + nouveau_statut=nouveau_statut, + ) + + logger.info( + f"Statut document {numero} changé: {statut_actuel} → {nouveau_statut}" + ) + + return { + "success": True, + "document_id": numero, + "type_document_code": document_type_int, + "type_document_sql": str(document_type_sql.value), + "statut_ancien": resultat.get("statut_ancien", statut_actuel), + "statut_nouveau": resultat.get("statut_nouveau", nouveau_statut), + "message": f"Statut mis à jour: {statut_actuel} → {nouveau_statut}", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur changement statut document {numero}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/commandes/{id}", tags=["Commandes"]) +async def lire_commande(id: str): + try: + commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) + if not commande: + raise HTTPException(404, f"Commande {id} introuvable") + return commande + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture commande: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/commandes", tags=["Commandes"]) +async def lister_commandes( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + try: + commandes = sage_client.lister_commandes(limit=limit, statut=statut) + return commandes + + except Exception as e: + logger.error(f"Erreur liste commandes: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) +async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): try: - # Appel HTTP vers Windows resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.DEVIS, - type_cible=TypeDocument.COMMANDE + type_source=settings.SAGE_TYPE_DEVIS, # = 0 + type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 ) - - # Logger en DB + workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, type_source=TypeDocument.DEVIS, document_cible=resultat.get("document_cible", ""), - type_cible=TypeDocument.COMMANDE, + type_cible=TypeDocument.BON_COMMANDE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), - succes=True + succes=True, ) - + session.add(workflow_log) await session.commit() - - logger.info(f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}") - + + logger.info( + f"Transformation: Devis {id} → Commande {resultat['document_cible']}" + ) + return { "success": True, "document_source": id, "document_cible": resultat["document_cible"], - "nb_lignes": resultat["nb_lignes"] + "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, } - + except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) -@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) -async def commande_vers_facture( - id: str, - session: AsyncSession = Depends(get_session) -): - """🔧 Transformation Commande → Facture via gateway Windows""" + +@app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) +async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): try: resultat = sage_client.transformer_document( numero_source=id, - type_source=TypeDocument.COMMANDE, - type_cible=TypeDocument.FACTURE + type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 ) - + workflow_log = WorkflowLog( id=str(uuid.uuid4()), document_source=id, - type_source=TypeDocument.COMMANDE, + type_source=TypeDocument.BON_COMMANDE, document_cible=resultat.get("document_cible", ""), type_cible=TypeDocument.FACTURE, nb_lignes=resultat.get("nb_lignes", 0), date_transformation=datetime.now(), - succes=True + succes=True, ) - + session.add(workflow_log) await session.commit() - - return resultat - + + logger.info( + f"Transformation: Commande {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + } + except Exception as e: logger.error(f"Erreur transformation: {e}") raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) -# ===================================================== -@app.post("/signature/universign/send", tags=["US-A3"]) -async def envoyer_signature( - demande: SignatureRequest, - session: AsyncSession = Depends(get_session) -): - """✍️ Envoi document pour signature Universign""" + +@app.get("/admin/signatures/relances-auto", tags=["Admin"]) +async def relancer_signatures_automatique(session: AsyncSession = Depends(get_session)): try: - # Générer PDF - pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc) - - # Envoi Universign - resultat = await universign_envoyer( - demande.doc_id, - pdf_bytes, - demande.email_signataire, - demande.nom_signataire + from datetime import timedelta + + date_limite = datetime.now() - timedelta(days=7) + + query = select(SignatureLog).where( + SignatureLog.statut.in_( + [StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE] + ), + SignatureLog.date_envoi < date_limite, + SignatureLog.nb_relances < 3, # Max 3 relances ) - - if "error" in resultat: - raise HTTPException(500, resultat["error"]) - - # Logger en DB - signature_log = SignatureLog( - id=str(uuid.uuid4()), - document_id=demande.doc_id, - type_document=demande.type_doc, - transaction_id=resultat["transaction_id"], - signer_url=resultat["signer_url"], - email_signataire=demande.email_signataire, - nom_signataire=demande.nom_signataire, - statut=StatutSignatureEnum.ENVOYE, - date_envoi=datetime.now() - ) - - session.add(signature_log) + + result = await session.execute(query) + signatures_a_relancer = result.scalars().all() + + nb_relances = 0 + + for signature in signatures_a_relancer: + try: + nb_jours = (datetime.now() - signature.date_envoi).days + jours_restants = 30 - nb_jours # Lien expire après 30 jours + + if jours_restants <= 0: + signature.statut = StatutSignatureDB.EXPIRE + continue + + template = templates_signature_email["relance_signature"] + + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } + + variables = { + "NOM_SIGNATAIRE": signature.nom_signataire, + "TYPE_DOC": type_labels.get(signature.type_document, "Document"), + "NUMERO": signature.document_id, + "NB_JOURS": str(nb_jours), + "JOURS_RESTANTS": str(jours_restants), + "SIGNER_URL": signature.signer_url, + "CONTACT_EMAIL": settings.smtp_from, + } + + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=signature.email_signataire, + sujet=sujet, + corps_html=corps, + document_ids=signature.document_id, + type_document=signature.type_document, + statut=StatutEmailDB.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + email_queue.enqueue(email_log.id) + + signature.est_relance = True + signature.nb_relances = (signature.nb_relances or 0) + 1 + + nb_relances += 1 + + logger.info( + f" Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" + ) + + except Exception as e: + logger.error(f"Erreur relance signature {signature.id}: {e}") + continue + await session.commit() - - # MAJ champ libre Sage via gateway Windows - sage_client.mettre_a_jour_champ_libre( - demande.doc_id, - demande.type_doc, - "UniversignID", - resultat["transaction_id"] - ) - - logger.info(f"✅ Signature envoyée: {demande.doc_id}") - + return { "success": True, - "transaction_id": resultat["transaction_id"], - "signer_url": resultat["signer_url"] + "signatures_verifiees": len(signatures_a_relancer), + "relances_envoyees": nb_relances, + "message": f"{nb_relances} email(s) de relance envoyé(s)", } - + + except Exception as e: + logger.error(f"Erreur relances automatiques: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/signature/universign/status", tags=["Signatures"]) +async def statut_signature(docId: str = Query(...)): + try: + async with async_session_factory() as session: + query = select(SignatureLog).where(SignatureLog.document_id == docId) + result = await session.execute(query) + signature_log = result.scalar_one_or_none() + + if not signature_log: + raise HTTPException(404, "Signature introuvable") + + statut = await universign_statut(signature_log.transaction_id) + + return { + "doc_id": docId, + "statut": statut["statut"], + "date_signature": statut.get("date_signature"), + } except HTTPException: raise except Exception as e: - logger.error(f"Erreur signature: {e}") + logger.error(f"Erreur statut signature: {e}") raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - US-A6 (RELANCE DEVIS) -# ===================================================== -@app.post("/devis/{id}/relancer-signature", tags=["US-A6"]) -async def relancer_devis_signature( - id: str, - relance: RelanceDevisRequest, - session: AsyncSession = Depends(get_session) + +@app.get("/signatures", tags=["Signatures"]) +async def lister_signatures( + statut: Optional[StatutSignature] = Query(None), + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +): + query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) + + if statut: + statut_db = StatutSignatureDB[statut.value] + query = query.where(SignatureLog.statut == statut_db) + + query = query.limit(limit) + result = await session.execute(query) + signatures = result.scalars().all() + + return [ + { + "id": sig.id, + "document_id": sig.document_id, + "type_document": sig.type_document.value, + "transaction_id": sig.transaction_id, + "signer_url": sig.signer_url, + "email_signataire": sig.email_signataire, + "nom_signataire": sig.nom_signataire, + "statut": sig.statut.value, + "date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None, + "date_signature": ( + sig.date_signature.isoformat() if sig.date_signature else None + ), + "est_relance": sig.est_relance, + "nb_relances": sig.nb_relances or 0, + } + for sig in signatures + ] + + +@app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) +async def statut_signature_detail( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) + result = await session.execute(query) + signature_log = result.scalar_one_or_none() + + if not signature_log: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + statut_universign = await universign_statut(transaction_id) + + if statut_universign.get("statut") != "ERREUR": + statut_map = { + "EN_ATTENTE": StatutSignatureDB.EN_ATTENTE, + "ENVOYE": StatutSignatureDB.ENVOYE, + "SIGNE": StatutSignatureDB.SIGNE, + "REFUSE": StatutSignatureDB.REFUSE, + "EXPIRE": StatutSignatureDB.EXPIRE, + } + + nouveau_statut = statut_map.get( + statut_universign["statut"], StatutSignatureDB.EN_ATTENTE + ) + + signature_log.statut = nouveau_statut + + if statut_universign.get("date_signature"): + signature_log.date_signature = datetime.fromisoformat( + statut_universign["date_signature"].replace("Z", "+00:00") + ) + + await session.commit() + + return { + "transaction_id": transaction_id, + "document_id": signature_log.document_id, + "statut": signature_log.statut.value, + "email_signataire": signature_log.email_signataire, + "date_envoi": ( + signature_log.date_envoi.isoformat() if signature_log.date_envoi else None + ), + "date_signature": ( + signature_log.date_signature.isoformat() + if signature_log.date_signature + else None + ), + "signer_url": signature_log.signer_url, + } + + +@app.post("/signatures/refresh-all", tags=["Signatures"]) +async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): + query = select(SignatureLog).where( + SignatureLog.statut.in_( + [StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE] + ) + ) + + result = await session.execute(query) + signatures = result.scalars().all() + nb_mises_a_jour = 0 + + for sig in signatures: + try: + statut_universign = await universign_statut(sig.transaction_id) + + if statut_universign.get("statut") != "ERREUR": + statut_map = { + "SIGNE": StatutSignatureDB.SIGNE, + "REFUSE": StatutSignatureDB.REFUSE, + "EXPIRE": StatutSignatureDB.EXPIRE, + } + + nouveau = statut_map.get(statut_universign["statut"]) + + if nouveau and nouveau != sig.statut: + sig.statut = nouveau + + if statut_universign.get("date_signature"): + sig.date_signature = datetime.fromisoformat( + statut_universign["date_signature"].replace("Z", "+00:00") + ) + + nb_mises_a_jour += 1 + + except Exception as e: + logger.error(f"Erreur refresh signature {sig.transaction_id}: {e}") + continue + + await session.commit() + + return { + "success": True, + "nb_signatures_verifiees": len(signatures), + "nb_mises_a_jour": nb_mises_a_jour, + } + + +class EmailBatch(BaseModel): + destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) + sujet: str = Field(..., min_length=1, max_length=500) + corps_html: str = Field(..., min_length=1) + document_ids: Optional[List[str]] = None + type_document: Optional[TypeDocument] = None + + +@app.post("/emails/send-batch", tags=["Emails"]) +async def envoyer_emails_lot( + batch: EmailBatch, session: AsyncSession = Depends(get_session) +): + resultats = [] + + for destinataire in batch.destinataires: + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=destinataire, + sujet=batch.sujet, + corps_html=batch.corps_html, + document_ids=",".join(batch.document_ids) if batch.document_ids else None, + type_document=batch.type_document, + statut=StatutEmailDB.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + email_queue.enqueue(email_log.id) + + resultats.append( + { + "destinataire": destinataire, + "log_id": email_log.id, + "statut": "EN_ATTENTE", + } + ) + + await session.commit() + + nb_documents = len(batch.document_ids) if batch.document_ids else 0 + + logger.info( + f"{len(batch.destinataires)} emails mis en file avec {nb_documents} docs" + ) + + return { + "total": len(batch.destinataires), + "succes": len(batch.destinataires), + "documents_attaches": nb_documents, + "details": resultats, + } + + +@app.post( + "/devis/valider-remise", response_model=BaremeRemiseResponse, tags=["Validation"] +) +async def valider_remise( + client_id: str = Query(..., min_length=1), + remise_pourcentage: float = Query(0.0, ge=0, le=100), +): + try: + remise_max = sage_client.lire_remise_max_client(client_id) + + autorisee = remise_pourcentage <= remise_max + + if not autorisee: + message = f"Remise trop élevée (max autorisé: {remise_max}%)" + logger.warning( + f"Remise refusée pour {client_id}: {remise_pourcentage}% > {remise_max}%" + ) + else: + message = "Remise autorisée" + + return BaremeRemiseResponse( + client_id=client_id, + remise_max_autorisee=remise_max, + remise_demandee=remise_pourcentage, + autorisee=autorisee, + message=message, + ) + + except Exception as e: + logger.error(f"Erreur validation remise: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/devis/{id}/relancer-signature", tags=["Devis"]) +async def relancer_devis_signature( + id: str, relance: RelanceDevis, session: AsyncSession = Depends(get_session) ): - """📧 Relance devis via Universign""" try: - # Lire devis via gateway devis = sage_client.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - - # Récupérer contact via gateway + contact = sage_client.lire_contact_client(devis["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") - - # Générer PDF + pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) - - # Envoi Universign + resultat = await universign_envoyer( id, pdf_bytes, contact["email"], - contact["nom"] or contact["client_intitule"] + contact["nom"] or contact["client_intitule"], ) - + if "error" in resultat: raise HTTPException(500, resultat["error"]) - - # Logger en DB + signature_log = SignatureLog( id=str(uuid.uuid4()), document_id=id, @@ -605,64 +1310,1603 @@ async def relancer_devis_signature( signer_url=resultat["signer_url"], email_signataire=contact["email"], nom_signataire=contact["nom"] or contact["client_intitule"], - statut=StatutSignatureEnum.ENVOYE, + statut=StatutSignatureDB.ENVOYE, date_envoi=datetime.now(), est_relance=True, - nb_relances=1 + nb_relances=1, ) - + session.add(signature_log) await session.commit() - + return { "success": True, "devis_id": id, "transaction_id": resultat["transaction_id"], - "message": "Relance signature envoyée" + "message": "Relance signature envoyée", } - + except HTTPException: raise except Exception as e: logger.error(f"Erreur relance: {e}") raise HTTPException(500, str(e)) -# ===================================================== -# ENDPOINTS - HEALTH -# ===================================================== + +class ContactClientResponse(BaseModel): + client_code: str + client_intitule: str + email: Optional[str] + nom: Optional[str] + telephone: Optional[str] + peut_etre_relance: bool + + +@app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) +async def recuperer_contact_devis(id: str): + try: + devis = sage_client.lire_devis(id) + if not devis: + raise HTTPException(404, f"Devis {id} introuvable") + + contact = sage_client.lire_contact_client(devis["client_code"]) + if not contact: + raise HTTPException( + 404, f"Contact introuvable pour client {devis['client_code']}" + ) + + peut_relancer = bool(contact.get("email")) + + return ContactClientResponse(**contact, peut_etre_relance=peut_relancer) + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur récupération contact: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/factures", tags=["Factures"]) +async def lister_factures( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + try: + factures = sage_client.lister_factures(limit=limit, statut=statut) + return factures + + except Exception as e: + logger.error(f"Erreur liste factures: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/factures/{numero}", tags=["Factures"]) +async def lire_facture_detail(numero: str): + try: + facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) + + if not facture: + raise HTTPException(404, f"Facture {numero} introuvable") + + return {"success": True, "data": facture} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture facture {numero}: {e}") + raise HTTPException(500, str(e)) + + +class RelanceFacture(BaseModel): + doc_id: str + message_personnalise: Optional[str] = None + + +@app.post("/factures", status_code=201, tags=["Factures"]) +async def creer_facture( + facture: FactureCreate, session: AsyncSession = Depends(get_session) +): + try: + facture_data = { + "client_id": facture.client_id, + "date_facture": ( + facture.date_facture.isoformat() if facture.date_facture else None + ), + "date_livraison": ( + facture.date_livraison.isoformat() if facture.date_livraison else None + ), + "reference": facture.reference, + "lignes": _preparer_lignes_document(facture.lignes), + } + + resultat = sage_client.creer_facture(facture_data) + + logger.info( + f"Facture créée: {resultat.get('numero_facture')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + return { + "success": True, + "message": "Facture créée avec succès", + "data": { + "numero_facture": resultat["numero_facture"], + "client_id": facture.client_id, + "date_facture": resultat["date_facture"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": resultat.get("reference"), + "date_livraison": resultat.get("date_livraison"), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création facture: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/factures/{id}", tags=["Factures"]) +async def modifier_facture( + id: str, + facture_update: FactureUpdate, + session: AsyncSession = Depends(get_session), +): + try: + update_data = {} + + if facture_update.date_facture: + update_data["date_facture"] = facture_update.date_facture.isoformat() + + if facture_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne in facture_update.lignes + ] + + if facture_update.statut is not None: + update_data["statut"] = facture_update.statut + + if facture_update.reference is not None: + update_data["reference"] = facture_update.reference + + resultat = sage_client.modifier_facture(id, update_data) + + logger.info(f"Facture {id} modifiée avec succès") + + return { + "success": True, + "message": f"Facture {id} modifiée avec succès", + "facture": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification facture {id}: {e}") + raise HTTPException(500, str(e)) + + +templates_email_db = { + "relance_facture": { + "id": "relance_facture", + "nom": "Relance Facture", + "sujet": "Rappel - Facture {{DO_Piece}}", + "corps_html": """ +

Bonjour {{CT_Intitule}},

+

La facture {{DO_Piece}} du {{DO_Date}} + d'un montant de {{DO_TotalTTC}}€ TTC reste impayée.

+

Merci de régulariser dans les meilleurs délais.

+

Cordialement,

+ """, + "variables_disponibles": [ + "DO_Piece", + "DO_Date", + "CT_Intitule", + "DO_TotalHT", + "DO_TotalTTC", + ], + } +} + + +@app.post("/factures/{id}/relancer", tags=["Factures"]) +async def relancer_facture( + id: str, + relance: RelanceFacture, + session: AsyncSession = Depends(get_session), +): + try: + facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) + if not facture: + raise HTTPException(404, f"Facture {id} introuvable") + + contact = sage_client.lire_contact_client(facture["client_code"]) + if not contact or not contact.get("email"): + raise HTTPException(400, "Aucun email trouvé pour ce client") + + template = templates_email_db["relance_facture"] + + variables = { + "DO_Piece": facture.get("numero", id), + "DO_Date": str(facture.get("date", "")), + "CT_Intitule": facture.get("client_intitule", ""), + "DO_TotalHT": f"{facture.get('total_ht', 0):.2f}", + "DO_TotalTTC": f"{facture.get('total_ttc', 0):.2f}", + } + + sujet = template["sujet"] + corps = relance.message_personnalise or template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", valeur) + corps = corps.replace(f"{{{{{var}}}}}", valeur) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=contact["email"], + sujet=sujet, + corps_html=corps, + document_ids=id, + type_document=TypeDocument.FACTURE, + statut=StatutEmailDB.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + email_queue.enqueue(email_log.id) + + sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) + + await session.commit() + + logger.info(f"Relance facture: {id} → {contact['email']}") + + return { + "success": True, + "facture_id": id, + "email_log_id": email_log.id, + "destinataire": contact["email"], + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur relance facture: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/emails/logs", tags=["Emails"]) +async def journal_emails( + statut: Optional[StatutEmail] = Query(None), + destinataire: Optional[str] = Query(None), + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +): + query = select(EmailLog) + + if statut: + query = query.where(EmailLog.statut == StatutEmailDB[statut.value]) + + if destinataire: + query = query.where(EmailLog.destinataire.contains(destinataire)) + + query = query.order_by(EmailLog.date_creation.desc()).limit(limit) + + result = await session.execute(query) + logs = result.scalars().all() + + return [ + { + "id": log.id, + "destinataire": log.destinataire, + "sujet": log.sujet, + "statut": log.statut.value, + "date_creation": log.date_creation.isoformat(), + "date_envoi": log.date_envoi.isoformat() if log.date_envoi else None, + "nb_tentatives": log.nb_tentatives, + "derniere_erreur": log.derniere_erreur, + "document_ids": log.document_ids, + } + for log in logs + ] + + +@app.get("/emails/logs/export", tags=["Emails"]) +async def exporter_logs_csv( + statut: Optional[StatutEmail] = Query(None), + session: AsyncSession = Depends(get_session), +): + query = select(EmailLog) + if statut: + query = query.where(EmailLog.statut == StatutEmailDB[statut.value]) + + query = query.order_by(EmailLog.date_creation.desc()) + + result = await session.execute(query) + logs = result.scalars().all() + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow( + [ + "ID", + "Destinataire", + "Sujet", + "Statut", + "Date Création", + "Date Envoi", + "Nb Tentatives", + "Erreur", + "Documents", + ] + ) + + for log in logs: + writer.writerow( + [ + log.id, + log.destinataire, + log.sujet, + log.statut.value, + log.date_creation.isoformat(), + log.date_envoi.isoformat() if log.date_envoi else "", + log.nb_tentatives, + log.derniere_erreur or "", + log.document_ids or "", + ] + ) + + output.seek(0) + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename=emails_logs_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + }, + ) + + +class TemplateEmail(BaseModel): + id: Optional[str] = None + nom: str + sujet: str + corps_html: str + variables_disponibles: List[str] = [] + + +class TemplatePreview(BaseModel): + template_id: str + document_id: str + type_document: TypeDocument + + +@app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) +async def lister_templates(): + return [TemplateEmail(**template) for template in templates_email_db.values()] + + +@app.get( + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] +) +async def lire_template(template_id: str): + if template_id not in templates_email_db: + raise HTTPException(404, f"Template {template_id} introuvable") + + return TemplateEmail(**templates_email_db[template_id]) + + +@app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) +async def creer_template(template: TemplateEmail): + template_id = str(uuid.uuid4()) + + templates_email_db[template_id] = { + "id": template_id, + "nom": template.nom, + "sujet": template.sujet, + "corps_html": template.corps_html, + "variables_disponibles": template.variables_disponibles, + } + + logger.info(f"Template créé: {template_id}") + + return TemplateEmail(id=template_id, **template.dict()) + + +@app.put( + "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] +) +async def modifier_template(template_id: str, template: TemplateEmail): + if template_id not in templates_email_db: + raise HTTPException(404, f"Template {template_id} introuvable") + + if template_id in ["relance_devis", "relance_facture"]: + raise HTTPException(400, "Les templates système ne peuvent pas être modifiés") + + templates_email_db[template_id] = { + "id": template_id, + "nom": template.nom, + "sujet": template.sujet, + "corps_html": template.corps_html, + "variables_disponibles": template.variables_disponibles, + } + + logger.info(f"Template modifié: {template_id}") + + return TemplateEmail(id=template_id, **template.dict()) + + +@app.delete("/templates/emails/{template_id}", tags=["Emails"]) +async def supprimer_template(template_id: str): + if template_id not in templates_email_db: + raise HTTPException(404, f"Template {template_id} introuvable") + + if template_id in ["relance_devis", "relance_facture"]: + raise HTTPException(400, "Les templates système ne peuvent pas être supprimés") + + del templates_email_db[template_id] + + logger.info(f"Template supprimé: {template_id}") + + return {"success": True, "message": f"Template {template_id} supprimé"} + + +@app.post("/templates/emails/preview", tags=["Emails"]) +async def previsualiser_email(preview: TemplatePreview): + if preview.template_id not in templates_email_db: + raise HTTPException(404, f"Template {preview.template_id} introuvable") + + template = templates_email_db[preview.template_id] + + doc = sage_client.lire_document(preview.document_id, preview.type_document) + if not doc: + raise HTTPException(404, f"Document {preview.document_id} introuvable") + + variables = { + "DO_Piece": doc.get("numero", preview.document_id), + "DO_Date": str(doc.get("date", "")), + "CT_Intitule": doc.get("client_intitule", ""), + "DO_TotalHT": f"{doc.get('total_ht', 0):.2f}", + "DO_TotalTTC": f"{doc.get('total_ttc', 0):.2f}", + } + + sujet_preview = template["sujet"] + corps_preview = template["corps_html"] + + for var, valeur in variables.items(): + sujet_preview = sujet_preview.replace(f"{{{{{var}}}}}", valeur) + corps_preview = corps_preview.replace(f"{{{{{var}}}}}", valeur) + + return { + "template_id": preview.template_id, + "document_id": preview.document_id, + "sujet": sujet_preview, + "corps_html": corps_preview, + "variables_utilisees": variables, + } + + +@app.get("/prospects", tags=["Prospects"]) +async def rechercher_prospects(query: Optional[str] = Query(None)): + try: + prospects = sage_client.lister_prospects(filtre=query or "") + return prospects + except Exception as e: + logger.error(f"Erreur recherche prospects: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/prospects/{code}", tags=["Prospects"]) +async def lire_prospect(code: str): + try: + prospect = sage_client.lire_prospect(code) + if not prospect: + raise HTTPException(404, f"Prospect {code} introuvable") + return prospect + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture prospect: {e}") + raise HTTPException(500, str(e)) + + +@app.get( + "/fournisseurs", response_model=List[FournisseurDetails], tags=["Fournisseurs"] +) +async def rechercher_fournisseurs(query: Optional[str] = Query(None)): + try: + fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") + + logger.info(f"{len(fournisseurs)} fournisseurs") + + if len(fournisseurs) == 0: + logger.warning("Aucun fournisseur retourné - vérifier la gateway Windows") + + return [FournisseurDetails(**f) for f in fournisseurs] + + except Exception as e: + logger.error(f"Erreur recherche fournisseurs: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/fournisseurs", status_code=201, tags=["Fournisseurs"]) +async def ajouter_fournisseur( + fournisseur: FournisseurCreate, + session: AsyncSession = Depends(get_session), +): + try: + nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) + + logger.info(f"Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") + + return { + "success": True, + "message": "Fournisseur créé avec succès", + "data": nouveau_fournisseur, + } + + except ValueError as e: + logger.warning(f"Erreur métier création fournisseur: {e}") + raise HTTPException(400, str(e)) + + except Exception as e: + logger.error(f"Erreur technique création fournisseur: {e}") + raise HTTPException(500, str(e)) + + +@app.put( + "/fournisseurs/{code}", response_model=FournisseurDetails, tags=["Fournisseurs"] +) +async def modifier_fournisseur( + code: str, + fournisseur_update: FournisseurUpdate, + session: AsyncSession = Depends(get_session), +): + try: + resultat = sage_client.modifier_fournisseur( + code, fournisseur_update.dict(exclude_none=True) + ) + + logger.info(f"Fournisseur {code} modifié avec succès") + + return FournisseurDetails(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier modification fournisseur {code}: {e}") + raise HTTPException(404, str(e)) + except Exception as e: + logger.error(f"Erreur technique modification fournisseur {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) +async def lire_fournisseur(code: str): + try: + fournisseur = sage_client.lire_fournisseur(code) + if not fournisseur: + raise HTTPException(404, f"Fournisseur {code} introuvable") + return fournisseur + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture fournisseur: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/avoirs", tags=["Avoirs"]) +async def lister_avoirs( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + try: + avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) + return avoirs + except Exception as e: + logger.error(f"Erreur liste avoirs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/avoirs/{numero}", tags=["Avoirs"]) +async def lire_avoir(numero: str): + try: + avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) + if not avoir: + raise HTTPException(404, f"Avoir {numero} introuvable") + return avoir + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture avoir: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/avoirs", status_code=201, tags=["Avoirs"]) +async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_session)): + try: + avoir_data = { + "client_id": avoir.client_id, + "date_avoir": (avoir.date_avoir.isoformat() if avoir.date_avoir else None), + "date_livraison": ( + avoir.date_livraison.isoformat() if avoir.date_livraison else None + ), + "reference": avoir.reference, + "lignes": _preparer_lignes_document(avoir.lignes), + } + + resultat = sage_client.creer_avoir(avoir_data) + + logger.info( + f"Avoir créé: {resultat.get('numero_avoir')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + return { + "success": True, + "message": "Avoir créé avec succès", + "data": { + "numero_avoir": resultat["numero_avoir"], + "client_id": avoir.client_id, + "date_avoir": resultat["date_avoir"], + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": resultat.get("reference"), + "date_livraison": resultat.get("date_livraison"), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création avoir: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/avoirs/{id}", tags=["Avoirs"]) +async def modifier_avoir( + id: str, + avoir_update: AvoirUpdate, + session: AsyncSession = Depends(get_session), +): + try: + update_data = {} + + if avoir_update.date_avoir: + update_data["date_avoir"] = avoir_update.date_avoir.isoformat() + + if avoir_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne in avoir_update.lignes + ] + + if avoir_update.statut is not None: + update_data["statut"] = avoir_update.statut + + if avoir_update.reference is not None: + update_data["reference"] = avoir_update.reference + + resultat = sage_client.modifier_avoir(id, update_data) + + logger.info(f"Avoir {id} modifié avec succès") + + return { + "success": True, + "message": f"Avoir {id} modifié avec succès", + "avoir": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification avoir {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/livraisons", tags=["Livraisons"]) +async def lister_livraisons( + limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) +): + try: + livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) + return livraisons + except Exception as e: + logger.error(f"Erreur liste livraisons: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/livraisons/{numero}", tags=["Livraisons"]) +async def lire_livraison(numero: str): + try: + livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) + if not livraison: + raise HTTPException(404, f"Livraison {numero} introuvable") + return livraison + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture livraison: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/livraisons", status_code=201, tags=["Livraisons"]) +async def creer_livraison( + livraison: LivraisonCreate, session: AsyncSession = Depends(get_session) +): + """ + Crée un bon de livraison dans Sage 100 + + - Le prix_unitaire_ht est optionnel (utilise le prix Sage si non fourni) + - La remise_pourcentage est appliquée après le prix + """ + try: + livraison_data = { + "client_id": livraison.client_id, + "date_livraison": ( + livraison.date_livraison.isoformat() + if livraison.date_livraison + else None + ), + "date_livraison_prevue": ( + livraison.date_livraison_prevue.isoformat() + if livraison.date_livraison_prevue + else None + ), + "reference": livraison.reference, + "lignes": _preparer_lignes_document(livraison.lignes), + } + + resultat = sage_client.creer_livraison(livraison_data) + + logger.info( + f"Livraison créée: {resultat.get('numero_livraison')} " + f"({resultat.get('total_ttc')}€ TTC)" + ) + + return { + "success": True, + "message": "Livraison créée avec succès", + "data": { + "numero_livraison": resultat["numero_livraison"], + "client_id": livraison.client_id, + "date_livraison": resultat["date_livraison"], + "date_livraison_prevue": resultat.get("date_livraison_prevue"), + "total_ht": resultat["total_ht"], + "total_ttc": resultat["total_ttc"], + "nb_lignes": resultat["nb_lignes"], + "reference": resultat.get("reference"), + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création livraison: {e}") + raise HTTPException(500, str(e)) + + +@app.put("/livraisons/{id}", tags=["Livraisons"]) +async def modifier_livraison( + id: str, + livraison_update: LivraisonUpdate, + session: AsyncSession = Depends(get_session), +): + try: + update_data = {} + + if livraison_update.date_livraison: + update_data["date_livraison"] = livraison_update.date_livraison.isoformat() + + if livraison_update.lignes is not None: + update_data["lignes"] = [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "remise_pourcentage": ligne.remise_pourcentage, + } + for ligne in livraison_update.lignes + ] + + if livraison_update.statut is not None: + update_data["statut"] = livraison_update.statut + + if livraison_update.reference is not None: + update_data["reference"] = livraison_update.reference + + resultat = sage_client.modifier_livraison(id, update_data) + + logger.info(f"Livraison {id} modifiée avec succès") + + return { + "success": True, + "message": f"Livraison {id} modifiée avec succès", + "livraison": resultat, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification livraison {id}: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) +async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): + try: + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_LIVRAISON, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"Transformation: Livraison {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + } + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) +async def devis_vers_facture_direct( + id: str, session: AsyncSession = Depends(get_session) +): + try: + devis_existant = sage_client.lire_devis(id) + if not devis_existant: + raise HTTPException(404, f"Devis {id} introuvable") + + statut_devis = devis_existant.get("statut", 0) + if statut_devis == 5: + raise HTTPException( + 400, + f"Le devis {id} a déjà été transformé (statut=5). " + f"Vérifiez les documents déjà créés depuis ce devis.", + ) + + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_DEVIS, # = 0 + type_cible=settings.SAGE_TYPE_FACTURE, # = 60 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.DEVIS, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"Transformation DIRECTE: Devis {id} → Facture {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "devis_to_facture_direct", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "statut_devis_mis_a_jour": True, + "message": f"Facture {resultat['document_cible']} créée directement depuis le devis {id}", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur transformation devis→facture: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) +async def commande_vers_livraison( + id: str, session: AsyncSession = Depends(get_session) +): + try: + commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) + + if not commande_existante: + raise HTTPException(404, f"Commande {id} introuvable") + + statut_commande = commande_existante.get("statut", 0) + if statut_commande == 5: + raise HTTPException( + 400, + f"La commande {id} a déjà été transformée (statut=5). " + f"Un bon de livraison existe probablement déjà.", + ) + + if statut_commande == 6: + raise HTTPException( + 400, + f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.", + ) + + resultat = sage_client.transformer_document( + numero_source=id, + type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 + type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.BON_COMMANDE, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.BON_LIVRAISON, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True, + ) + + session.add(workflow_log) + await session.commit() + + logger.info( + f"Transformation: Commande {id} → Livraison {resultat['document_cible']}" + ) + + return { + "success": True, + "workflow": "commande_to_livraison", + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"], + "message": f"Bon de livraison {resultat['document_cible']} créé depuis la commande {id}", + "next_step": f"Utilisez /workflow/livraison/{resultat['document_cible']}/to-facture pour créer la facture", + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur transformation commande→livraison: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get( + "/familles", + response_model=List[Familles], + tags=["Familles"], + summary="Liste toutes les familles d'articles", +) +async def lister_familles( + filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), +): + try: + familles = sage_client.lister_familles(filtre or "") + + logger.info(f"{len(familles)} famille(s) retournée(s)") + + return [Familles(**f) for f in familles] + + except Exception as e: + logger.error(f"Erreur liste familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des familles: {str(e)}", + ) + + +@app.get( + "/familles/{code}", + response_model=Familles, + tags=["Familles"], + summary="Lecture d'une famille par son code", +) +async def lire_famille( + code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), +): + try: + famille = sage_client.lire_famille(code) + + if not famille: + logger.warning(f"Famille {code} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Famille {code} introuvable", + ) + + logger.info(f"Famille {code} lue: {famille.get('intitule', '')}") + + return Familles(**famille) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture famille {code}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture de la famille: {str(e)}", + ) + + +@app.post( + "/familles", + response_model=Familles, + status_code=status.HTTP_201_CREATED, + tags=["Familles"], + summary="Création d'une famille d'articles", +) +async def creer_famille(famille: FamilleCreate): + try: + if not famille.code or not famille.intitule: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Les champs 'code' et 'intitule' sont obligatoires", + ) + + famille_data = famille.dict() + + logger.info(f"Création famille: {famille.code} - {famille.intitule}") + + resultat = sage_client.creer_famille(famille_data) + + logger.info(f"Famille créée: {resultat.get('code')}") + + return Familles(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier création famille: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + logger.error(f"Erreur technique création famille: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la famille: {str(e)}", + ) + + +@app.post( + "/stock/entree", + response_model=MouvementStock, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock", +) +async def creer_entree_stock(entree: EntreeStock): + try: + entree_data = entree.dict() + if entree_data.get("date_entree"): + entree_data["date_entree"] = entree_data["date_entree"].isoformat() + + logger.info(f"Création entrée stock: {len(entree.lignes)} ligne(s)") + + resultat = sage_client.creer_entree_stock(entree_data) + + logger.info(f"Entrée stock créée: {resultat.get('numero')}") + + return MouvementStock(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier entrée stock: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except Exception as e: + logger.error(f"Erreur technique entrée stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de l'entrée: {str(e)}", + ) + + +@app.post( + "/stock/sortie", + response_model=MouvementStock, + status_code=status.HTTP_201_CREATED, + tags=["Stock"], + summary="SORTIE DE STOCK : Retire des articles du stock", +) +async def creer_sortie_stock(sortie: SortieStock): + try: + sortie_data = sortie.dict() + if sortie_data.get("date_sortie"): + sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() + + logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") + + resultat = sage_client.creer_sortie_stock(sortie_data) + + logger.info(f"Sortie stock créée: {resultat.get('numero')}") + + return MouvementStock(**resultat) + + except ValueError as e: + logger.warning(f"Erreur métier sortie stock: {e}") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + + except Exception as e: + logger.error(f"Erreur technique sortie stock: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la création de la sortie: {str(e)}", + ) + + +@app.get( + "/stock/mouvement/{numero}", + response_model=MouvementStock, + tags=["Stock"], + summary="Lecture d'un mouvement de stock", +) +async def lire_mouvement_stock( + numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), +): + try: + mouvement = sage_client.lire_mouvement_stock(numero) + + if not mouvement: + logger.warning(f"Mouvement {numero} introuvable") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Mouvement de stock {numero} introuvable", + ) + + logger.info(f"Mouvement {numero} lu") + + return MouvementStock(**mouvement) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture mouvement {numero}: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la lecture du mouvement: {str(e)}", + ) + + +@app.get( + "/familles/stats/global", + tags=["Familles"], + summary="Statistiques sur les familles", +) +async def statistiques_familles(): + try: + stats = sage_client.get_stats_familles() + + return {"success": True, "data": stats} + + except Exception as e: + logger.error(f"Erreur stats familles: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Erreur lors de la récupération des statistiques: {str(e)}", + ) + + +@app.get("/debug/users", response_model=List[Users], tags=["Debug"]) +async def lister_utilisateurs_debug( + session: AsyncSession = Depends(get_session), + limit: int = Query(100, le=1000), + role: Optional[str] = Query(None), + verified_only: bool = Query(False), +): + from database import User + from sqlalchemy import select + + try: + query = select(User) + + if role: + query = query.where(User.role == role) + + if verified_only: + query = query.where(User.is_verified) + + query = query.order_by(User.created_at.desc()).limit(limit) + + result = await session.execute(query) + users = result.scalars().all() + + users_response = [] + for user in users: + users_response.append( + Users( + id=user.id, + email=user.email, + nom=user.nom, + prenom=user.prenom, + role=user.role, + is_verified=user.is_verified, + is_active=user.is_active, + created_at=user.created_at.isoformat() if user.created_at else "", + last_login=user.last_login.isoformat() if user.last_login else None, + failed_login_attempts=user.failed_login_attempts or 0, + ) + ) + + logger.info(f"Liste utilisateurs retournée: {len(users_response)} résultat(s)") + + return users_response + + except Exception as e: + logger.error(f"Erreur liste utilisateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/debug/users/stats", tags=["Debug"]) +async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session)): + from database import User + from sqlalchemy import select, func + + try: + total_query = select(func.count(User.id)) + total_result = await session.execute(total_query) + total = total_result.scalar() + + verified_query = select(func.count(User.id)).where(User.is_verified) + verified_result = await session.execute(verified_query) + verified = verified_result.scalar() + + active_query = select(func.count(User.id)).where(User.is_active) + active_result = await session.execute(active_query) + active = active_result.scalar() + + roles_query = select(User.role, func.count(User.id)).group_by(User.role) + roles_result = await session.execute(roles_query) + roles_stats = {role: count for role, count in roles_result.all()} + + return { + "total_utilisateurs": total, + "utilisateurs_verifies": verified, + "utilisateurs_actifs": active, + "utilisateurs_non_verifies": total - verified, + "repartition_roles": roles_stats, + "taux_verification": f"{(verified / total * 100):.1f}%" + if total > 0 + else "0%", + } + + except Exception as e: + logger.error(f"Erreur stats utilisateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/tiers/{numero}/contacts", response_model=Contact, tags=["Contacts"]) +async def creer_contact(numero: str, contact: ContactCreate): + try: + try: + sage_client.lire_tiers(numero) + except HTTPException: + raise + except Exception: + raise HTTPException(404, f"Tiers {numero} non trouvé") + + if contact.numero != numero: + contact.numero = numero + + resultat = sage_client.creer_contact(contact.dict()) + + if isinstance(resultat, dict) and "data" in resultat: + contact_data = resultat["data"] + else: + contact_data = resultat + + return Contact(**contact_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création contact: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@app.get("/tiers/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) +async def lister_contacts(numero: str): + try: + contacts = sage_client.lister_contacts(numero) + return [Contact(**c) for c in contacts] + except Exception as e: + logger.error(f"Erreur liste contacts: {e}") + raise HTTPException(500, str(e)) + + +@app.get( + "/tiers/{numero}/contacts/{contact_numero}", + response_model=Contact, + tags=["Contacts"], +) +async def obtenir_contact(numero: str, contact_numero: int): + try: + contact = sage_client.obtenir_contact(numero, contact_numero) + if not contact: + raise HTTPException( + 404, f"Contact {contact_numero} non trouvé pour client {numero}" + ) + return Contact(**contact) + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur récupération contact: {e}") + raise HTTPException(500, str(e)) + + +@app.put( + "/tiers/{numero}/contacts/{contact_numero}", + response_model=Contact, + tags=["Contacts"], +) +async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpdate): + try: + contact_existant = sage_client.obtenir_contact(numero, contact_numero) + if not contact_existant: + raise HTTPException(404, f"Contact {contact_numero} non trouvé") + + updates = {k: v for k, v in contact.dict().items() if v is not None} + + if not updates: + raise HTTPException(400, "Aucune modification fournie") + + resultat = sage_client.modifier_contact(numero, contact_numero, updates) + if isinstance(resultat, dict) and "data" in resultat: + contact_data = resultat["data"] + else: + contact_data = resultat + + return Contact(**contact_data) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification contact: {e}") + raise HTTPException(500, str(e)) + + +@app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"]) +async def supprimer_contact(numero: str, contact_numero: int): + try: + sage_client.supprimer_contact(numero, contact_numero) + return {"success": True, "message": f"Contact {contact_numero} supprimé"} + except Exception as e: + logger.error(f"Erreur suppression contact: {e}") + raise HTTPException(500, str(e)) + + +@app.post("/tiers/{numero}/contacts/{contact_numero}/definir-defaut", tags=["Contacts"]) +async def definir_contact_defaut(numero: str, contact_numero: int): + try: + resultat = sage_client.definir_contact_defaut(numero, contact_numero) + return { + "success": True, + "message": f"Contact {contact_numero} défini comme contact par défaut", + "data": resultat, + } + except Exception as e: + logger.error(f"Erreur définition contact par défaut: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/tiers", response_model=List[TiersDetails], tags=["Tiers"]) +async def obtenir_tiers( + type_tiers: Optional[str] = Query( + None, + description="Filtre par type: 0/client, 1/fournisseur, 2/prospect, 3/all ou strings", + ), + query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), +): + try: + type_normalise = normaliser_type_tiers(type_tiers) + tiers = sage_client.lister_tiers(type_tiers=type_normalise, filtre=query or "") + return [TiersDetails(**t) for t in tiers] + except Exception as e: + logger.error(f"Erreur recherche tiers: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"]) +async def lire_tiers_detail(code: str): + try: + tiers = sage_client.lire_tiers(code) + if not tiers: + raise HTTPException(404, f"Tiers {code} introuvable") + return TiersDetails(**tiers) + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture tiers {code}: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/sage/current-config", tags=["System"]) +async def get_current_sage_config( + ctx: GatewayContext = Depends(get_gateway_context_for_user), +): + return { + "source": "user_gateway" if not ctx.is_fallback else "fallback_env", + "gateway_id": ctx.gateway_id, + "gateway_name": ctx.gateway_name, + "gateway_url": ctx.url, + "user_id": ctx.user_id, + } + + +# Routes Collaborateurs +@app.get( + "/collaborateurs", + response_model=List[CollaborateurDetails], + tags=["Collaborateurs"], +) +async def lister_collaborateurs( + filtre: Optional[str] = Query(None, description="Filtre sur nom/prénom"), + actifs_seulement: bool = Query( + True, description="Exclure les collaborateurs en sommeil" + ), +): + """Liste tous les collaborateurs""" + try: + collaborateurs = sage_client.lister_collaborateurs(filtre, actifs_seulement) + return [CollaborateurDetails(**c) for c in collaborateurs] + except Exception as e: + logger.error(f"Erreur liste collaborateurs: {e}") + raise HTTPException(500, str(e)) + + +@app.get( + "/collaborateurs/{numero}", + response_model=CollaborateurDetails, + tags=["Collaborateurs"], +) +async def lire_collaborateur_detail(numero: int): + """Lit un collaborateur par son numéro""" + try: + collaborateur = sage_client.lire_collaborateur(numero) + + if not collaborateur: + raise HTTPException(404, f"Collaborateur {numero} introuvable") + + return CollaborateurDetails(**collaborateur) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture collaborateur {numero}: {e}") + raise HTTPException(500, str(e)) + + +@app.post( + "/collaborateurs", + response_model=CollaborateurDetails, + tags=["Collaborateurs"], + status_code=201, +) +async def creer_collaborateur(collaborateur: CollaborateurCreate): + """Crée un nouveau collaborateur""" + try: + nouveau = sage_client.creer_collaborateur(collaborateur.model_dump()) + + if not nouveau: + raise HTTPException(500, "Échec création collaborateur") + + return CollaborateurDetails(**nouveau) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création collaborateur: {e}") + raise HTTPException(500, str(e)) + + +@app.put( + "/collaborateurs/{numero}", + response_model=CollaborateurDetails, + tags=["Collaborateurs"], +) +async def modifier_collaborateur(numero: int, collaborateur: CollaborateurUpdate): + """Modifie un collaborateur existant""" + try: + modifie = sage_client.modifier_collaborateur( + numero, collaborateur.model_dump(exclude_unset=True) + ) + + if not modifie: + raise HTTPException(404, f"Collaborateur {numero} introuvable") + + return CollaborateurDetails(**modifie) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur modification collaborateur {numero}: {e}") + raise HTTPException(500, str(e)) + + @app.get("/health", tags=["System"]) -async def health_check(): - """🏥 Health check""" - gateway_health = sage_client.health() - +async def health_check( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): + gateway_health = sage.health() + return { "status": "healthy", "sage_gateway": gateway_health, + "using_gateway_id": sage.gateway_id, "email_queue": { "running": email_queue.running, "workers": len(email_queue.workers), - "queue_size": email_queue.queue.qsize() + "queue_size": email_queue.queue.qsize(), }, - "timestamp": datetime.now().isoformat() + "timestamp": datetime.now().isoformat(), } + @app.get("/", tags=["System"]) async def root(): - """🏠 Page d'accueil""" return { "api": "Sage 100c Dataven - VPS Linux", "version": "2.0.0", "documentation": "/docs", - "health": "/health" + "health": "/health", } -# ===================================================== -# LANCEMENT -# ===================================================== + +@app.get("/admin/cache/info", tags=["Admin"]) +async def info_cache(): + try: + cache_info = sage_client.get_cache_info() + return cache_info + + except Exception as e: + logger.error(f"Erreur info cache: {e}") + raise HTTPException(500, str(e)) + + +@app.get("/admin/queue/status", tags=["Admin"]) +async def statut_queue(): + return { + "queue_size": email_queue.queue.qsize(), + "workers": len(email_queue.workers), + "running": email_queue.running, + } + + if __name__ == "__main__": uvicorn.run( "api:app", host=settings.api_host, port=settings.api_port, - reload=settings.api_reload - ) \ No newline at end of file + reload=settings.api_reload, + ) diff --git a/config.py b/config/config.py similarity index 52% rename from config.py rename to config/config.py index 2ed4336..63bf99b 100644 --- a/config.py +++ b/config/config.py @@ -1,20 +1,33 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from typing import List + class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore" + env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) + # === JWT & Auth === + jwt_secret: str + jwt_algorithm: str + access_token_expire_minutes: int + refresh_token_expire_days: int + + SAGE_TYPE_DEVIS: int = 0 + SAGE_TYPE_BON_COMMANDE: int = 10 + SAGE_TYPE_PREPARATION: int = 20 + SAGE_TYPE_BON_LIVRAISON: int = 30 + SAGE_TYPE_BON_RETOUR: int = 40 + SAGE_TYPE_BON_AVOIR: int = 50 + SAGE_TYPE_FACTURE: int = 60 + # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str + frontend_url: str # === Base de données === - database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" + database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" # === SMTP === smtp_host: str @@ -22,6 +35,7 @@ class Settings(BaseSettings): smtp_user: str smtp_password: str smtp_from: str + smtp_use_tls: bool = True # === Universign === universign_api_key: str @@ -31,13 +45,14 @@ class Settings(BaseSettings): api_host: str api_port: int api_reload: bool = False - + # === Email Queue === max_email_workers: int = 3 max_retry_attempts: int = 3 - retry_delay_seconds: int = 60 - + retry_delay_seconds: int = 3 + # === CORS === cors_origins: List[str] = ["*"] -settings = Settings() \ No newline at end of file + +settings = Settings() diff --git a/core/dependencies.py b/core/dependencies.py new file mode 100644 index 0000000..039081c --- /dev/null +++ b/core/dependencies.py @@ -0,0 +1,94 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from database import get_session, User +from security.auth import decode_token +from typing import Optional +from datetime import datetime + +security = HTTPBearer() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + session: AsyncSession = Depends(get_session), +) -> User: + token = credentials.credentials + + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Type de token incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token malformé", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception.", + ) + + if user.locked_until and user.locked_until > datetime.now(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", + ) + + return user + + +async def get_current_user_optional( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + session: AsyncSession = Depends(get_session), +) -> Optional[User]: + if not credentials: + return None + + try: + return await get_current_user(credentials, session) + except HTTPException: + return None + + +def require_role(*allowed_roles: str): + async def role_checker(user: User = Depends(get_current_user)) -> User: + if user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", + ) + return user + + return role_checker diff --git a/core/sage_context.py b/core/sage_context.py new file mode 100644 index 0000000..cd092a4 --- /dev/null +++ b/core/sage_context.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass +from typing import Optional +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_session, User +from core.dependencies import get_current_user +from sage_client import SageGatewayClient +from config.config import settings +import logging + +logger = logging.getLogger(__name__) + + +@dataclass +class GatewayContext: + + url: str + token: str + gateway_id: Optional[str] = None + gateway_name: Optional[str] = None + user_id: Optional[str] = None + is_fallback: bool = False + + +async def get_sage_client_for_user( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +) -> SageGatewayClient: + from services.sage_gateway import SageGatewayService + + service = SageGatewayService(session) + active_gateway = await service.get_active_gateway(user.id) + + if active_gateway: + logger.debug(f"Gateway active: {active_gateway.name} pour {user.email}") + return SageGatewayClient( + gateway_url=active_gateway.gateway_url, + gateway_token=active_gateway.gateway_token, + gateway_id=active_gateway.id, + ) + + logger.debug(f"Fallback .env pour {user.email}") + return SageGatewayClient() + + +async def get_gateway_context_for_user( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +) -> GatewayContext: + from services.sage_gateway import SageGatewayService + + service = SageGatewayService(session) + active_gateway = await service.get_active_gateway(user.id) + + if active_gateway: + return GatewayContext( + url=active_gateway.gateway_url, + token=active_gateway.gateway_token, + gateway_id=active_gateway.id, + gateway_name=active_gateway.name, + user_id=user.id, + is_fallback=False, + ) + + return GatewayContext( + url=settings.sage_gateway_url, + token=settings.sage_gateway_token, + gateway_id=None, + gateway_name="Fallback (.env)", + user_id=user.id, + is_fallback=True, + ) + + +async def get_sage_client_public() -> SageGatewayClient: + return SageGatewayClient() diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..d3cb786 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,97 @@ +import asyncio +import sys +from pathlib import Path +import uuid +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) + +from database import async_session_factory, User +from security.auth import hash_password, validate_password_strength +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def create_admin(): + print("\n" + "=" * 60) + print(" Création d'un compte administrateur") + print("=" * 60 + "\n") + + # Saisie des informations + email = input("Email de l'admin: ").strip().lower() + if not email or "@" not in email: + print(" Email invalide") + return False + + prenom = input("Prénom: ").strip() + nom = input("Nom: ").strip() + + if not prenom or not nom: + print(" Prénom et nom requis") + return False + + # Mot de passe avec validation + while True: + password = input( + "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " + ) + is_valid, error_msg = validate_password_strength(password) + + if is_valid: + confirm = input("Confirmez le mot de passe: ") + if password == confirm: + break + else: + print(" Les mots de passe ne correspondent pas\n") + else: + print(f" {error_msg}\n") + + async with async_session_factory() as session: + from sqlalchemy import select + + result = await session.execute(select(User).where(User.email == email)) + existing = result.scalar_one_or_none() + + if existing: + print(f"\n Un utilisateur avec l'email {email} existe déjà") + return False + + # Créer l'admin + admin = User( + id=str(uuid.uuid4()), + email=email, + hashed_password=hash_password(password), + nom=nom, + prenom=prenom, + role="admin", + is_verified=True, + is_active=True, + created_at=datetime.now(), + ) + + session.add(admin) + await session.commit() + + print("\n Administrateur créé avec succès!") + print(f" Email: {email}") + print(f" Nom: {prenom} {nom}") + print(" Rôle: admin") + print(f" ID: {admin.id}") + print("\n Vous pouvez maintenant vous connecter à l'API\n") + + return True + + +if __name__ == "__main__": + try: + result = asyncio.run(create_admin()) + sys.exit(0 if result else 1) + except KeyboardInterrupt: + print("\n\n Création annulée") + sys.exit(1) + except Exception as e: + print(f"\n Erreur: {e}") + logger.exception("Détails:") + sys.exit(1) diff --git a/data/data.py b/data/data.py new file mode 100644 index 0000000..c49c5d5 --- /dev/null +++ b/data/data.py @@ -0,0 +1,405 @@ +TAGS_METADATA = [ + {"name": "System", "description": "Health checks et informations système"}, + {"name": "Admin", "description": "Administration système (cache, queue)"}, + {"name": "Debug", "description": "Routes de debug et diagnostics"}, + { + "name": "Authentication", + "description": "Authentification, gestion des sessions et contrôle d'accès", + }, + { + "name": "Sage Gateways", + "description": "Passerelles de communication avec Sage (API, synchronisation, échanges)", + }, + { + "name": "Tiers", + "description": "Gestion des tiers (clients, fournisseurs et prospects)", + }, + { + "name": "Clients", + "description": "Gestion des clients (recherche, création, modification)", + }, + {"name": "Fournisseurs", "description": "Gestion des fournisseurs"}, + {"name": "Prospects", "description": "Gestion des prospects"}, + { + "name": "Contacts", + "description": "Gestion des contacts rattachés aux tiers", + }, + { + "name": "Familles", + "description": "Gestion des familles et catégories d'articles", + }, + {"name": "Articles", "description": "Gestion des articles et produits"}, + { + "name": "Stock", + "description": "Consultation et gestion des stocks d'articles", + }, + {"name": "Devis", "description": "Création, consultation et gestion des devis"}, + { + "name": "Commandes", + "description": "Création, consultation et gestion des commandes", + }, + { + "name": "Livraisons", + "description": "Création, consultation et gestion des bons de livraison", + }, + { + "name": "Factures", + "description": "Création, consultation et gestion des factures", + }, + {"name": "Avoirs", "description": "Création, consultation et gestion des avoirs"}, + { + "name": "Documents", + "description": "Gestion des documents liés aux tiers (devis, commandes, factures, avoirs)", + }, + { + "name": "Workflows", + "description": "Transformations de documents (devis→commande, commande→facture, etc.)", + }, + {"name": "Signatures", "description": "Signature électronique via Universign"}, + {"name": "Emails", "description": "Envoi d'emails, templates et logs d'envoi"}, + {"name": "Validation", "description": "Validation de données (remises, etc.)"}, +] + +templates_signature_email = { + "demande_signature": { + "id": "demande_signature", + "nom": "Demande de Signature Électronique", + "sujet": "Signature requise - {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ Signature Électronique Requise +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

+ Nous vous invitons à signer électroniquement le document suivant : +

+ + + + + + +
+ + + + + + + + + + + + + + + + + +
Type de document{{TYPE_DOC}}
Numéro{{NUMERO}}
Date{{DATE}}
Montant TTC{{MONTANT_TTC}} €
+
+ +

+ Cliquez sur le bouton ci-dessous pour accéder au document et apposer votre signature électronique sécurisée : +

+ + + + + + +
+ + ✍️ Signer le document + +
+ + + + + + +
+

+ ⏰ Important : Ce lien de signature est valable pendant 30 jours. + Nous vous recommandons de signer ce document dès que possible. +

+
+ +

+ 🔒 Signature électronique sécurisée
+ Votre signature est protégée par notre partenaire de confiance Universign, + certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera + horodaté de manière infalsifiable. +

+
+

+ Vous avez des questions ? Contactez-nous à {{CONTACT_EMAIL}} +

+

+ Cet email a été envoyé automatiquement par le système Sage 100c Dataven.
+ Si vous avez reçu cet email par erreur, veuillez nous en informer. +

+
+
+ + + """, + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "DATE", + "MONTANT_TTC", + "SIGNER_URL", + "CONTACT_EMAIL", + ], + }, + "signature_confirmee": { + "id": "signature_confirmee", + "nom": "Confirmation de Signature", + "sujet": "Document signé - {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ Document Signé avec Succès +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

+ Nous confirmons la signature électronique du document suivant : +

+ + + + + + +
+ + + + + + + + + + + + + +
Document{{TYPE_DOC}} {{NUMERO}}
Signé le{{DATE_SIGNATURE}}
ID Transaction{{TRANSACTION_ID}}
+
+ +

+ Le document signé a été automatiquement archivé et est disponible dans votre espace client. + Un certificat de signature électronique conforme eIDAS a été généré. +

+ + + + + +
+

+ Signature certifiée : Ce document a été signé avec une signature + électronique qualifiée, ayant la même valeur juridique qu'une signature manuscrite + conformément au règlement eIDAS. +

+
+ +

+ Merci pour votre confiance. Notre équipe reste à votre disposition pour toute question. +

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

+ Sage 100c Dataven - Système de signature électronique sécurisée +

+
+
+ + + """, + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "DATE_SIGNATURE", + "TRANSACTION_ID", + "CONTACT_EMAIL", + ], + }, + "relance_signature": { + "id": "relance_signature", + "nom": "Relance Signature en Attente", + "sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}", + "corps_html": """ + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ ⏰ Signature en Attente +

+
+

+ Bonjour {{NOM_SIGNATAIRE}}, +

+ +

+ Nous vous avons envoyé il y a {{NB_JOURS}} jours un document à signer électroniquement. + Nous constatons que celui-ci n'a pas encore été signé. +

+ + + + + + +
+

+ Document en attente : {{TYPE_DOC}} {{NUMERO}} +

+

+ ⏳ Le lien de signature expirera dans {{JOURS_RESTANTS}} jours +

+
+ +

+ Pour éviter tout retard dans le traitement de votre dossier, nous vous invitons à signer ce document dès maintenant : +

+ + + + + + +
+ + ✍️ Signer maintenant + +
+ +

+ Si vous rencontrez des difficultés ou avez des questions, n'hésitez pas à nous contacter. +

+
+

+ Contact : {{CONTACT_EMAIL}} +

+

+ Sage 100c Dataven - Relance automatique +

+
+
+ + + """, + "variables_disponibles": [ + "NOM_SIGNATAIRE", + "TYPE_DOC", + "NUMERO", + "NB_JOURS", + "JOURS_RESTANTS", + "SIGNER_URL", + "CONTACT_EMAIL", + ], + }, +} diff --git a/data/sage_dataven.db b/data/sage_dataven.db new file mode 100644 index 0000000000000000000000000000000000000000..925d98bbeb764bf87bc4f41d7b5eefd269e97091 GIT binary patch literal 282624 zcmeI*Uu@h)eg|-EEH5p|@}DFQFUjRXT`uwR?A&={uME7 z-}E2$c{cq(f7XQGC;i%*`IcQfN>h)V{29yBKKUOfe}3|33;#85E&Tc1KhFKiu{V@I zRo<@ubT+8}-t5tpkTT8W%UC$x=?!HO-mfJIX{!VCykrfT2 z{D8XBG1^^2Ti;wYZfRwCwXIDp&#yJ&67&ZLPb%utQti{Lk#(m>@*MeZXOgnJ?Q0u` zRL1uB${J1cLsJB^E%b!~I2t8L!g*f_7v&p9NFT+fOuHy~N2S6VGOmu+iZ<5riQ zVB2o?dpP_)iL$d=7cLae@`FKW?neC{KSQq9w}*Y=MW*Y7S$WaHfS68_LY2D4n$cmE z#td0a%?mnjyA=`B4u}=GzNf8H+3xxcL#~EpA>N+vX6f0P{&0^3(U4wudMZou?wAp6 z7cFy-gkl>U5_m2NXmLS6hC$ZoLEziFmg|`TiGl;EEO(I9o$6m|@rDmW5}4KwttPu9 zhR3RZr?Y;e-MOv3ZQRxxuA|TE7thqywWZn&kFW5f9vu>`zS&X)39t=%#8fI#N)@GOk(He=K8xgh47qo5{puvrZvV()XC$D+M;zY z$aM!KB#}_*6H_Y{atpNyNA=F>x~i+SP_Bg=@U}fZwS1mWD4BoOAHIB2QB}3}*%dy{ zcscx!Q_|>4u6^pjiYrJver!bTTH!8n%z+h#_xvCqpJN$4zn=w3vpB*A0hyW}_`Q5o zx8ewM(=Z^8c5Q2G!)R}^z#H$#tXkHl@+>CVAJB;-l%qa0Et?G=vG{E|Dbnku(;^)y zRzxSoK_m^Yo^Ri!8HQfu_N3y3J|An5kXG6hJ~_}*>GF||acO!O4jei`Na^fCdsZ05 zP0da#ADjBAx_VWeG&Xsdm`(oU^bb$a5vkWkPp9!JQ<#z4r&F=jAIQf~CV`hN8FF%d z8jEE)O>1Oj>Vri^U8c3qO{kqmt9<&2Lu$@OkbIifh= zP(FY0kZSYu%C0z1)$c#Qps2cDdvL(VvRsS&>9{ny0!~;uxaK6+LLPrMkvfmQj5^?xS;Q9|dHa_Q-BL zONa3flTB4}!b0LzTrcEY!s)E5w9yG`X-H)(-YdknOqtyV(aFMfg&fH z$w_5ZCrSGK?jH+fB8YJ!PY0=(}SZl?v{kO%k!9QVMxx-kJhKu zB%DyJPDs*+vx?fLb)HFMJujVNhoL_VSVueg;iFS370)QvDyHf8-#WtYARd0mLp?7& z|7}(Z@%;Fd88CLtB64hhk~e{E?X!HzP9#qiZctbmES!dPAK-?x6X;eu9XV;*xq62)(!I<@Xqc-Mi2=k2-DMU#i6m%g3D3w(MPE_GvY=EKC0Mm_#b4O<|2Q7RjUu zfp1ZgA(J(nxzXE$zG?e(A0^(rq=U;D2Jz;eRR!kRh2j3cs@;q0f&c^{009U<00Izz00bZa0SIsbJpV@^KmY;|fB*y_ z009U<00Izz00gQpfam|!?_*pc1Rwwb2tWV=5P$##AOHafKmhmuhye&d00Izz00bZa z0SG_<0uX>e^#ySMU;RGD6+!?45P$##AOHafKmY;|fB*#W_x}+C5P$##AOHafKmY;| zfB*y_0D``xsXU0SG_<0uX=z1Rwwb2tWV=5WxLEVgLdVfB*y_009U<00Izz z00baVeF5D6SHF*Og%E%M1Rwwb2tWV=5P$##AOHdU{eQ#&1Rwwb2tWV=5P$##AOHaf zK%n{pc>Z7gKE@S700Izz00bZa0SG_<0uX=z1aSY47=Qo-AOHafKmY;|fB*y_009V8 zUjX<2)$e0mAp{@*0SG_<0uX=z1Rwwb2tWXT{~s{`0SG_<0uX=z1Rwwb2tWV=5U9QY zp8r?Bk8y<%fB*y_009U<00Izz00bZa0o?y11|R?d2tWV=5P$##AOHafKmY>O7r_00 z_4^oC2muH{00Izz00bZa0SG_<0uaFcKVkp^5P$##AOHafKmY;|fB*y_P<;XX_y4Nj z$GAcWKmY;|fB*y_009U<00Izz0G|IN1|R?d2tWV=5P$##AOHafKmY>O7r_00_4^oC z2muH{00Izz00bZa0SG_<0uaFcKVkp^5P$##AOHafKmY;|fB*y_P<;XR_y3=)t<9YH z+Y`UL*ggJt$KB&c=YKTs&i!m|_tJ{e>M9Dvp<-9>ByfQS*fkn9-sN~%&YXI z+Nt(ZZFI)<9J253o1|~KJ+tTUgr-Bn$n~tqas!ed{>nUQT3_^1^>i75= za=pGi>=Q3CT_?=Siw*|FbdnU>m0e@a=&(u?(exsk7j)isi`v-%u_D*^v{l;u-Sr!W zTn)=YyglE|(z7%D;T{R1VL+rL?~WPKcF{8TNGP_!A%W+TfEE`7WEf2yosFTMPwMFY*kn0Xe zNFt%qC#F^^PYJYCE6R$LCWYa^ zq0@qt&MvfPg;Ctp?6mR``NI=*MC!HC(`mfQ*Jnme;^+}nM6~zM9IndX)KoI zG_8@9sSg$vb(z*aH=%YOg7WDn4yidCLGo!@BOXe>c3e>nTH|vQYNXSg8z{dL>ioI`Qe4@dvl8VCXJQT6WYO*kArA}5>4No7?h zN&51!y1J!Ln(hnbHRaE9_rIs`_bnytt9_?gl?MM!ZrIyc^ck1%C+8R18 zpK0SI!}sz7N-+}hB)+`Ru|nf7Kd3d>qT_pdaFoU!y<^DYF3)4K**-ZtKU$wslW;<@ zIw46P&MK;Omy(xGy~5BR2CT1~{P58!m5OH+YZcS<`)?iLHvByVFcIjEZWP6dP3ileU3>FbXy12U`?F71OPJ=&9n;f+9tSL^K4xu-F zz5I5eun`x!=2553<;SMY8n$Kc60=WhrA1q2Q~rG9{!%Sol|JVEAg9Wo9+Ub*PMgvv zamJ#zG$HU!OEL{*SIuVwy4f;qpYE*0o0xRMa)v>C=g<$e>l<5berw0eqNsj;bHhK4 z7gJn-ScS<7(#RW4Z(i`<|EvCYJh(y#KmY;|fB*y_009U<00Izzz~aI`%$%P2eCEU- zo>*V}y#C>_(b2y?YR&$1_O+SMkNoRNf8m|^KRWjB$9{6+@0r!YL8;iWpUnK0dgE+u z^m6*8GJSYK`XoPX^)TpVU(1a>!%s8uFQ>C?W6vx31+j0*?pR^ojPwyAPqqF2 z!14~HGWZu&We*PNH_i^(wuZe(`~q+gxPIV92jVwc{7b7{V)de3lYU)i-<7@v6I1z1 z1kzKOk}td>KeBrC+uM(Z^bwx)s4{tqvpp$D4T;Ywt3FQAM~^+DsB5&=r3tmB@9>Ou&2xuTEu2_Z zzmTjyeBmqn>-W!U@kA_zfPBHvB(=uxM#*W1OuIsj*~~RIyOzGUBz;XUN0L+$8+NQo z^zm_VCT2;+9?#Aln}g{)Awuj;&X;s={v?KveE#%g!kbc=m^8QYGw1__q5L8Uoj2+H z*|R)bdKHAeSCt%uH6Qq5H*_1w>een7VihA~J?URc+tgMOfr?d{E)@C?b4^;hm;fNDJzEdaF4~Y)W3b7zDq}6^V^N`=S=CbD6;c| zJev40+4h6}lvnzuJxv^cW>|biNEZk?OHN@ei(kenYuGm)wPA4)Q`;41(dqlT z|13QTk7q5uQj2ex^1~0?XZg_htj&jpwA+?Gd{Y`4QqmzqgXa~VL>FJRNOtq$Yc1(a za`}?{UQhbOG=4inE|bks|GPIbwq0+B1av>kh6a5pCmVFMdR8iZFD@PeX|vL;bk?)t z)-WPLpS==2zQasY=$#CmMx}R+(kJlgW^mHma(ws7`c2|5vIzaWN2p00bZa0SG_<0uX=z z1Rwx`QUTomm$Kj>1Rwwb2tWV=5P$##AOHafK%i0rxc{$Icj96o009U<00Izz00bZa z0SG_<0;K}D|1V|1K?pzq0uX=z1Rwwb2tWV=5P(3X1n~d=t5kR5Vjutk2tWV=5P$## zAOHafKmY=z0(kyk%7TLsfB*y_009U<00Izz00bZafl3MB{=ZV)iHm^%1Rwwb2tWV= z5P$##AOHaflnUVfzmx?BApijgKmY;|fB*y_009U<00NZ~!2N%vx)T=z0SG_<0uX=z z1Rwwb2tWV=5GWPEfB&zP1qUGj0SG_<0uX=z1Rwwb2tWV=l@h@7|4MZyE(QV+fB*y_ z009U<00Izz00baVDuDa{QWhM900bZa0SG_<0uX=z1Rwwb2vkY{_y3jZPFxHGAOHaf zKmY;|fB*y_009US~)eH#W{|^K(&Pd7))TuJ4&s zvXQ>!_Oe1=X|?21JgZNnR95t0*Z1l(MZtPFQxT8mu6B zfS7#}?fSW1auI&u&~tdtYU`U_W6kKWI^OB5-)MJkYi}F3wTA2P(ywoIjPsrUSZgh;z72`dz--KKtFGkzi)K-lRgLd*t`@NO+sxhyR>Q@wXtyvo_@m9F|Vuabg zx9#qPCciS*5n>`qxthex`sS)}OPhK!t9-lwt&xf-Z(pP4q$;6(g>tujSYYCO_OmjW)AW(J?Rq;T7zC~tjaARi z?D@9ECaSDRHV^S4Lz7KmdqlgowY6ciH`&361bvqdCsNa-uPBswU#HY2jzVzZ4AZcyz5vsf$SQ6Y$F_rV^a#sCaV?cIvM|f$_U1)-$q_Cq z*3EKWIV7Vw9I)6M+d#7Aq?e5BtgvQNE-l`z^94WMN@jB%+XbWtZ4yN8wo4Z%B4ti@ z`?LjCmN_6F4M|9sA#;3R&6ch_y>w%pH9$&D*VlX$m9I-7TLFHN9d^91jd9wRXOmtD zGg~UzBs!kNr&cS9NPiH8;!c_;kRb4b%*+i9uAjmDHQ1>7%3HR8)gjeXhJ}USmn+-za9Ts z$M{xMvN)eKXKX(pdZ$}$aX-s$X2d*NW4tCE&;QeJH1HDw5P$##AOHafKmY;|fB*y_ zP+bAs|5vwfab*yI00bZa0SG_<0uX=z1Rwx`Q~>w?sRVvP00Izz00bZa0SG_<0uX=z z1ga~5|Nmcg`xaLQ0SG_<0uX=z1Rwwb2tWV=5J&~^{6CezPY6H&0uX=z1Rwwb2tWV= z5P(2+1#tgg-M+<@K>z{}fB*y_009U<00Izz00dG2-2bN%_z3|BKmY;|fB*y_009U< z00I!Gt^n@;tJ}A@G6+Bb0uX=z1Rwwb2tWV=5P(1`fcyVc0zV-D0SG_<0uX=z1Rwwb z2tWV=)fK?~e|7s7R|WwHKmY;|fB*y_009U<00Iz51#thLO5i61AOHafKmY;|fB*y_ z009UsWZ0SG_<0uX=z1Rwwb2tWV=sQ~W(QwjWp00bZa0SG_<0uX=z z1Rwwb2vk>KkzLxU>RunN3<3~<00bZa0SG_<0uX=z1Rwx`nHfC)M-)H+0uX=z1Rwwb z2tWV=5P$##sxN^1|LXTKt`GtcfB*y_009U<00Izz00ba__x}+C5P$##AOHafKmY;| zfB*y_0D``xsXU0SG_<0uX=z1Rwwb2tWV=5WxLEVgLdVfB*y_009U<00Izz z00baVeF66V|KjYYGbg(ze!Tee7wPXKwY@mF8^e>OTOq1{r1Rwx`FH&Ii z!gm$*cB?irT+boAElL1V0ocsN3QRgu2WX>>LayME@yHp<$U^sAHJce ztyb;RXCv!QkCfG4c5EeSQ@4F>!wri{z9c^=~t8L!g*f_7v&&f^D-fwqS z-fDLm7cc8lw%GGvK(ZXI3uQUd9@bWAUv}4T7*Z*Nfb6;ckcLBKMZ-`mi?zig_a>y+ zyOy^@Len7;u_NMWUE@}loe~ahn}nfuZEI`8Xm7GZBnbS#q>&a{J0#1v8%2YpaMSi3 zl47tk0ul~z6WXBq^pRRR`>qf`e zTru7gdqT(+@?uy_tQs2z4fvJzdn@f#V_qLUdsR_yHfy7+WkZRMCpxN3p`=2H#u=_n z3((Rsis2b&(u!9$n)!~RuF%#!J*9P|Z@E1&M&rW1`v{HVr%q}MPt;eI>*@#1+6-?% z_)*V|h-nQYpMRF-o#aBB<4t+<;Y+V8YO`7U^xoJYi_aQA=Z%R2EVbgpYtq<}yDSU( z;t-1g(n4NowdC>TS^a$Qu~Zg`gI(XtkJ2H@4i*O2t8zJeF1be>(~7bo%Fkg}GIlf| z+rx0eVXJ2b#0q^c8)ELjv>b;HrhH&uiknFb2~0W~y(r7f)(1KV1a!QSXxDc#mgFM* zz@g{xp8fwCj`b^wx<&(jsU+ZS-`jTSy2h_eS}Ky1t5ZV0aB?B&lT@vdl&!D4_K0Dh zmMeyRa_l<~R$t{||H;K-*e5xJuuqOGPYU}aU29AY`=t3<$I^Ibkw&~dH}Jha3x+h< zi))EBaLr-R6VeNd4a;zs98At|kKEby{kvnOuok3gA+ZCJHzFNVY(|OqE5u{Vup=%m zd>wSXhy;6d0Q6mN7!hf`$m5#q58U9ubgTm@gBzM`hp|V*VrfGxx~vsPWpVMLYeeg^ zqT0)~(e09;6eb$F#M1F*I=($*((+Ov=6iigoQnCz$}2>zn3rz93YF6hSW8hqpzUrS z)^3jwC*Cg#5h<2_|6k;74a$Z91Rwwb2tWV=5P$##AOHafd^rU0-~an^^aEM}0SG_<0uX=z1Rwwb z2tWV=5cnbm@cjRa)J544fB*y_009U<00Izz00bZafiH&u?*G3W{eV_L00Izz00bZa R0SG_<0uX=z1inat{|2lO)an2L literal 0 HcmV?d00001 diff --git a/database/__init__.py b/database/__init__.py index 1912d5d..96eb874 100644 --- a/database/__init__.py +++ b/database/__init__.py @@ -3,37 +3,56 @@ from database.db_config import ( async_session_factory, init_db, get_session, - close_db + close_db, ) - -from database.models import ( - Base, - EmailLog, - SignatureLog, - WorkflowLog, +from database.models.generic_model import ( CacheMetadata, AuditLog, + RefreshToken, + LoginAttempt, +) +from database.models.user import User +from database.models.email import EmailLog +from database.models.signature import SignatureLog +from database.models.sage_config import SageGatewayConfig +from database.enum.status import ( StatutEmail, - StatutSignature + StatutSignature, +) +from database.models.workflow import WorkflowLog +from database.models.universign import ( + UniversignTransaction, + UniversignSigner, + UniversignSyncLog, + UniversignTransactionStatus, + LocalDocumentStatus, + UniversignSignerStatus, + SageDocumentType ) __all__ = [ - # Config - 'engine', - 'async_session_factory', - 'init_db', - 'get_session', - 'close_db', - - # Models - 'Base', - 'EmailLog', - 'SignatureLog', - 'WorkflowLog', - 'CacheMetadata', - 'AuditLog', - - # Enums - 'StatutEmail', - 'StatutSignature', -] \ No newline at end of file + "engine", + "async_session_factory", + "init_db", + "get_session", + "close_db", + "Base", + "EmailLog", + "SignatureLog", + "WorkflowLog", + "CacheMetadata", + "AuditLog", + "StatutEmail", + "StatutSignature", + "User", + "RefreshToken", + "LoginAttempt", + "SageGatewayConfig", + "UniversignTransaction", + "UniversignSigner", + "UniversignSyncLog", + "UniversignTransactionStatus", + "LocalDocumentStatus", + "UniversignSignerStatus", + "SageDocumentType" +] diff --git a/database/db_config.py b/database/db_config.py index 0bbba98..ab89bbb 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -1,19 +1,19 @@ import os from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.pool import StaticPool -from database.models import Base +from sqlalchemy.pool import NullPool import logging +from database.models.generic_model import Base + logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./sage_dataven.db") +DATABASE_URL = os.getenv("DATABASE_URL") engine = create_async_engine( DATABASE_URL, echo=False, future=True, - connect_args={"check_same_thread": False}, - poolclass=StaticPool, + poolclass=NullPool, ) async_session_factory = async_sessionmaker( @@ -25,32 +25,27 @@ async_session_factory = async_sessionmaker( async def init_db(): - """ - Crée toutes les tables dans la base de données - ⚠️ Utilise create_all qui ne crée QUE les tables manquantes - """ + logger.info("Debut init_db") try: + logger.info("Tentative de connexion") async with engine.begin() as conn: + logger.info("Connexion etablie") await conn.run_sync(Base.metadata.create_all) - - logger.info("✅ Base de données initialisée avec succès") - logger.info(f"📍 Fichier DB: {DATABASE_URL}") - + logger.info("create_all execute") + + logger.info("Base de données initialisée avec succès") + logger.info(f"Fichier DB: {DATABASE_URL}") + except Exception as e: - logger.error(f"❌ Erreur initialisation DB: {e}") + logger.error(f"Erreur initialisation DB: {e}") raise async def get_session() -> AsyncSession: - """Dependency FastAPI pour obtenir une session DB""" async with async_session_factory() as session: - try: - yield session - finally: - await session.close() + yield session async def close_db(): - """Ferme proprement toutes les connexions""" await engine.dispose() - logger.info("🔌 Connexions DB fermées") \ No newline at end of file + logger.info("Connexions DB fermées") diff --git a/database/enum/status.py b/database/enum/status.py new file mode 100644 index 0000000..c452f70 --- /dev/null +++ b/database/enum/status.py @@ -0,0 +1,18 @@ +import enum + + +class StatutEmail(str, enum.Enum): + EN_ATTENTE = "EN_ATTENTE" + EN_COURS = "EN_COURS" + ENVOYE = "ENVOYE" + OUVERT = "OUVERT" + ERREUR = "ERREUR" + BOUNCE = "BOUNCE" + + +class StatutSignature(str, enum.Enum): + EN_ATTENTE = "EN_ATTENTE" + ENVOYE = "ENVOYE" + SIGNE = "SIGNE" + REFUSE = "REFUSE" + EXPIRE = "EXPIRE" diff --git a/database/models.py b/database/models.py deleted file mode 100644 index f147305..0000000 --- a/database/models.py +++ /dev/null @@ -1,204 +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() - -# ============================================================================ -# Enums -# ============================================================================ - -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" - -# ============================================================================ -# Tables -# ============================================================================ - -class EmailLog(Base): - """ - Journal des emails envoyés via l'API - Permet le suivi et le retry automatique - """ - __tablename__ = "email_logs" - - # Identifiant - id = Column(String(36), primary_key=True) - - # Destinataires - destinataire = Column(String(255), nullable=False, index=True) - cc = Column(Text, nullable=True) # JSON stringifié - cci = Column(Text, nullable=True) # JSON stringifié - - # Contenu - sujet = Column(String(500), nullable=False) - corps_html = Column(Text, nullable=False) - - # Documents attachés - document_ids = Column(Text, nullable=True) # Séparés par virgules - type_document = Column(Integer, nullable=True) - - # Statut - statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) - - # Tracking temporel - date_creation = Column(DateTime, default=datetime.now, nullable=False) - date_envoi = Column(DateTime, nullable=True) - date_ouverture = Column(DateTime, nullable=True) - - # Retry automatique - nb_tentatives = Column(Integer, default=0) - derniere_erreur = Column(Text, nullable=True) - prochain_retry = Column(DateTime, nullable=True) - - # Métadonnées - ip_envoi = Column(String(45), nullable=True) - user_agent = Column(String(500), nullable=True) - - def __repr__(self): - return f"" - - -class SignatureLog(Base): - """ - Journal des demandes de signature Universign - Permet le suivi du workflow de signature - """ - __tablename__ = "signature_logs" - - # Identifiant - id = Column(String(36), primary_key=True) - - # Document Sage associé - document_id = Column(String(100), nullable=False, index=True) - type_document = Column(Integer, nullable=False) - - # Universign - transaction_id = Column(String(100), unique=True, index=True, nullable=True) - signer_url = Column(String(500), nullable=True) - - # Signataire - email_signataire = Column(String(255), nullable=False, index=True) - nom_signataire = Column(String(255), nullable=False) - - # Statut - 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) - - # Relances - est_relance = Column(Boolean, default=False) - nb_relances = Column(Integer, default=0) - - # Métadonnées - raison_refus = Column(Text, nullable=True) - ip_signature = Column(String(45), nullable=True) - - def __repr__(self): - return f"" - - -class WorkflowLog(Base): - """ - Journal des transformations de documents (Devis → Commande → Facture) - Permet la traçabilité du workflow commercial - """ - __tablename__ = "workflow_logs" - - # Identifiant - id = Column(String(36), primary_key=True) - - # Documents - 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) - - # Métadonnées de transformation - nb_lignes = Column(Integer, nullable=True) - montant_ht = Column(Float, nullable=True) - montant_ttc = Column(Float, nullable=True) - - # Tracking - date_transformation = Column(DateTime, default=datetime.now, nullable=False) - utilisateur = Column(String(100), nullable=True) - - # Résultat - succes = Column(Boolean, default=True) - erreur = Column(Text, nullable=True) - duree_ms = Column(Integer, nullable=True) # Durée en millisecondes - - def __repr__(self): - return f"" - - -class CacheMetadata(Base): - """ - Métadonnées sur le cache Sage (clients, articles) - Permet le monitoring du cache géré par la gateway Windows - """ - __tablename__ = "cache_metadata" - - id = Column(Integer, primary_key=True, autoincrement=True) - - # Type de cache - cache_type = Column(String(50), unique=True, nullable=False) # 'clients' ou 'articles' - - # Statistiques - last_refresh = Column(DateTime, default=datetime.now) - item_count = Column(Integer, default=0) - refresh_duration_ms = Column(Float, nullable=True) - - # Santé - last_error = Column(Text, nullable=True) - error_count = Column(Integer, default=0) - - def __repr__(self): - return f"" - - -class AuditLog(Base): - """ - Journal d'audit pour la sécurité et la conformité - Trace toutes les actions importantes dans l'API - """ - __tablename__ = "audit_logs" - - id = Column(Integer, primary_key=True, autoincrement=True) - - # Action - 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 (si authentification ajoutée plus tard) - utilisateur = Column(String(100), nullable=True) - ip_address = Column(String(45), nullable=True) - - # Résultat - succes = Column(Boolean, default=True) - details = Column(Text, nullable=True) # JSON stringifié - erreur = Column(Text, nullable=True) - - # Timestamp - date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) - - def __repr__(self): - return f"" \ No newline at end of file diff --git a/database/models/email.py b/database/models/email.py new file mode 100644 index 0000000..8ba4177 --- /dev/null +++ b/database/models/email.py @@ -0,0 +1,43 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Text, + Enum as SQLEnum, +) +from datetime import datetime +from database.models.generic_model import Base +from database.enum.status import StatutEmail + + +class EmailLog(Base): + __tablename__ = "email_logs" + + id = Column(String(36), primary_key=True) + + destinataire = Column(String(255), nullable=False, index=True) + cc = Column(Text, nullable=True) + cci = Column(Text, nullable=True) + + sujet = Column(String(500), nullable=False) + corps_html = Column(Text, nullable=False) + + document_ids = Column(Text, nullable=True) + type_document = Column(Integer, nullable=True) + + statut = Column(SQLEnum(StatutEmail), default=StatutEmail.EN_ATTENTE, index=True) + + date_creation = Column(DateTime, default=datetime.now, nullable=False) + date_envoi = Column(DateTime, nullable=True) + date_ouverture = Column(DateTime, nullable=True) + + nb_tentatives = Column(Integer, default=0) + derniere_erreur = Column(Text, nullable=True) + prochain_retry = Column(DateTime, nullable=True) + + ip_envoi = Column(String(45), nullable=True) + user_agent = Column(String(500), nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/generic_model.py b/database/models/generic_model.py new file mode 100644 index 0000000..840b614 --- /dev/null +++ b/database/models/generic_model.py @@ -0,0 +1,91 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, +) +from sqlalchemy.ext.declarative import declarative_base +from datetime import datetime + +Base = declarative_base() + + +class CacheMetadata(Base): + __tablename__ = "cache_metadata" + + id = Column(Integer, primary_key=True, autoincrement=True) + + cache_type = Column(String(50), unique=True, nullable=False) + + last_refresh = Column(DateTime, default=datetime.now) + item_count = Column(Integer, default=0) + refresh_duration_ms = Column(Float, nullable=True) + + last_error = Column(Text, nullable=True) + error_count = Column(Integer, default=0) + + def __repr__(self): + return f"" + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + + action = Column(String(100), nullable=False, index=True) + ressource_type = Column(String(50), nullable=True) + ressource_id = Column(String(100), nullable=True, index=True) + + utilisateur = Column(String(100), nullable=True) + ip_address = Column(String(45), nullable=True) + + succes = Column(Boolean, default=True) + details = Column(Text, nullable=True) + erreur = Column(Text, nullable=True) + + date_action = Column(DateTime, default=datetime.now, nullable=False, index=True) + + def __repr__(self): + return f"" + + +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=False, index=True) + token_hash = Column(String(255), nullable=False, unique=True, index=True) + + device_info = Column(String(500), nullable=True) + ip_address = Column(String(45), nullable=True) + + expires_at = Column(DateTime, nullable=False) + created_at = Column(DateTime, default=datetime.now, nullable=False) + + is_revoked = Column(Boolean, default=False) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class LoginAttempt(Base): + __tablename__ = "login_attempts" + + id = Column(Integer, primary_key=True, autoincrement=True) + + email = Column(String(255), nullable=False, index=True) + ip_address = Column(String(45), nullable=False, index=True) + user_agent = Column(String(500), nullable=True) + + success = Column(Boolean, default=False) + failure_reason = Column(String(255), nullable=True) + + timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) + + def __repr__(self): + return f"" diff --git a/database/models/sage_config.py b/database/models/sage_config.py new file mode 100644 index 0000000..f6ed363 --- /dev/null +++ b/database/models/sage_config.py @@ -0,0 +1,54 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Text, + Boolean, +) +from datetime import datetime +from database.models.generic_model import Base + + +class SageGatewayConfig(Base): + __tablename__ = "sage_gateway_configs" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=False, index=True) + + name = Column(String(100), nullable=False) + description = Column(Text, nullable=True) + + gateway_url = Column(String(500), nullable=False) + gateway_token = Column(String(255), nullable=False) + + sage_database = Column(String(255), nullable=True) + sage_company = Column(String(255), nullable=True) + + is_active = Column(Boolean, default=False, index=True) + is_default = Column(Boolean, default=False) + priority = Column(Integer, default=0) + + last_health_check = Column(DateTime, nullable=True) + last_health_status = Column(Boolean, nullable=True) + last_error = Column(Text, nullable=True) + + total_requests = Column(Integer, default=0) + successful_requests = Column(Integer, default=0) + failed_requests = Column(Integer, default=0) + last_used_at = Column(DateTime, nullable=True) + + extra_config = Column(Text, nullable=True) + + is_encrypted = Column(Boolean, default=False) + allowed_ips = Column(Text, nullable=True) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + created_by = Column(String(36), nullable=True) + + is_deleted = Column(Boolean, default=False, index=True) + deleted_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/signature.py b/database/models/signature.py new file mode 100644 index 0000000..c84abe9 --- /dev/null +++ b/database/models/signature.py @@ -0,0 +1,44 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Text, + Boolean, + Enum as SQLEnum, +) +from datetime import datetime +from database.models.generic_model import Base +from database.enum.status import StatutSignature + + +class SignatureLog(Base): + __tablename__ = "signature_logs" + + id = Column(String(36), primary_key=True) + + document_id = Column(String(100), nullable=False, index=True) + type_document = Column(Integer, nullable=False) + + transaction_id = Column(String(100), unique=True, index=True, nullable=True) + signer_url = Column(String(500), nullable=True) + + email_signataire = Column(String(255), nullable=False, index=True) + nom_signataire = Column(String(255), nullable=False) + + statut = Column( + SQLEnum(StatutSignature), default=StatutSignature.EN_ATTENTE, index=True + ) + date_envoi = Column(DateTime, default=datetime.now) + date_signature = Column(DateTime, nullable=True) + date_refus = Column(DateTime, nullable=True) + + est_relance = Column(Boolean, default=False) + nb_relances = Column(Integer, default=0) + derniere_relance = Column(DateTime, nullable=True) + + raison_refus = Column(Text, nullable=True) + ip_signature = Column(String(45), nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/universign.py b/database/models/universign.py new file mode 100644 index 0000000..e4ad3a3 --- /dev/null +++ b/database/models/universign.py @@ -0,0 +1,303 @@ +from sqlalchemy import ( + Column, + String, + DateTime, + Boolean, + Integer, + Text, + Enum as SQLEnum, + ForeignKey, + Index, +) +from sqlalchemy.orm import relationship +from datetime import datetime +from enum import Enum +from database.models.generic_model import Base + + +class UniversignTransactionStatus(str, Enum): + DRAFT = "draft" + READY = "ready" + STARTED = "started" + COMPLETED = "completed" + CLOSED = "closed" + REFUSED = "refused" + EXPIRED = "expired" + CANCELED = "canceled" + FAILED = "failed" + + +class UniversignSignerStatus(str, Enum): + WAITING = "waiting" + OPEN = "open" + VIEWED = "viewed" + SIGNED = "signed" + COMPLETED = "completed" + REFUSED = "refused" + EXPIRED = "expired" + STALLED = "stalled" + UNKNOWN = "unknown" + + +class LocalDocumentStatus(str, Enum): + PENDING = "EN_ATTENTE" + IN_PROGRESS = "EN_COURS" + SIGNED = "SIGNE" + REJECTED = "REFUSE" + EXPIRED = "EXPIRE" + ERROR = "ERREUR" + + +class SageDocumentType(int, Enum): + DEVIS = 0 + BON_COMMANDE = 10 + PREPARATION = 20 + BON_LIVRAISON = 30 + BON_RETOUR = 40 + BON_AVOIR = 50 + FACTURE = 60 + + +class UniversignTransaction(Base): + __tablename__ = "universign_transactions" + + # === IDENTIFIANTS === + id = Column(String(36), primary_key=True) # UUID local + transaction_id = Column( + String(255), + unique=True, + nullable=False, + index=True, + comment="ID Universign (ex: tr_abc123)", + ) + + # === LIEN AVEC LE DOCUMENT SAGE === + sage_document_id = Column( + String(50), + nullable=False, + index=True, + comment="Numéro du document Sage (ex: DE00123)", + ) + sage_document_type = Column( + SQLEnum(SageDocumentType), nullable=False, comment="Type de document Sage" + ) + + # === STATUTS UNIVERSIGN (SOURCE DE VÉRITÉ) === + universign_status = Column( + SQLEnum(UniversignTransactionStatus), + nullable=False, + default=UniversignTransactionStatus.DRAFT, + index=True, + comment="Statut brut Universign", + ) + universign_status_updated_at = Column( + DateTime, nullable=True, comment="Dernière MAJ du statut Universign" + ) + + # === STATUT LOCAL (DÉDUIT) === + local_status = Column( + SQLEnum(LocalDocumentStatus), + nullable=False, + default=LocalDocumentStatus.PENDING, + index=True, + comment="Statut métier simplifié pour l'UI", + ) + + # === URLS ET MÉTADONNÉES UNIVERSIGN === + signer_url = Column(Text, nullable=True, comment="URL de signature") + document_url = Column(Text, nullable=True, comment="URL du document signé") + + signed_document_path = Column( + Text, nullable=True, comment="Chemin local du PDF signé" + ) + signed_document_downloaded_at = Column( + DateTime, nullable=True, comment="Date de téléchargement du document" + ) + signed_document_size_bytes = Column( + Integer, nullable=True, comment="Taille du fichier en octets" + ) + download_attempts = Column( + Integer, default=0, comment="Nombre de tentatives de téléchargement" + ) + download_error = Column( + Text, nullable=True, comment="Dernière erreur de téléchargement" + ) + + certificate_url = Column(Text, nullable=True, comment="URL du certificat") + + # === SIGNATAIRES === + signers_data = Column( + Text, nullable=True, comment="JSON des signataires (snapshot)" + ) + + # === INFORMATIONS MÉTIER === + requester_email = Column(String(255), nullable=True) + requester_name = Column(String(255), nullable=True) + document_name = Column(String(500), nullable=True) + + # === DATES CLÉS === + created_at = Column( + DateTime, + default=datetime.now, + nullable=False, + comment="Date de création locale", + ) + sent_at = Column( + DateTime, nullable=True, comment="Date d'envoi Universign (started)" + ) + signed_at = Column(DateTime, nullable=True, comment="Date de signature complète") + refused_at = Column(DateTime, nullable=True) + expired_at = Column(DateTime, nullable=True) + canceled_at = Column(DateTime, nullable=True) + + # === SYNCHRONISATION === + last_synced_at = Column( + DateTime, nullable=True, comment="Dernière sync réussie avec Universign" + ) + sync_attempts = Column(Integer, default=0, comment="Nombre de tentatives de sync") + sync_error = Column(Text, nullable=True) + + # === FLAGS === + is_test = Column( + Boolean, default=False, comment="Transaction en environnement .alpha" + ) + needs_sync = Column( + Boolean, default=True, index=True, comment="À synchroniser avec Universign" + ) + webhook_received = Column(Boolean, default=False, comment="Webhook Universign reçu") + + # === RELATION === + signers = relationship( + "UniversignSigner", back_populates="transaction", cascade="all, delete-orphan" + ) + sync_logs = relationship( + "UniversignSyncLog", back_populates="transaction", cascade="all, delete-orphan" + ) + + # === INDEXES COMPOSITES === + __table_args__ = ( + Index("idx_sage_doc", "sage_document_id", "sage_document_type"), + Index("idx_sync_status", "needs_sync", "universign_status"), + Index("idx_dates", "created_at", "signed_at"), + ) + + def __repr__(self): + return ( + f"" + ) + + +class UniversignSigner(Base): + """ + Détail de chaque signataire d'une transaction + """ + + __tablename__ = "universign_signers" + + id = Column(String(36), primary_key=True) + transaction_id = Column( + String(36), + ForeignKey("universign_transactions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # === DONNÉES SIGNATAIRE === + email = Column(String(255), nullable=False, index=True) + name = Column(String(255), nullable=True) + phone = Column(String(50), nullable=True) + + # === STATUT === + status = Column( + SQLEnum(UniversignSignerStatus), + default=UniversignSignerStatus.WAITING, + nullable=False, + ) + + # === ACTIONS === + viewed_at = Column(DateTime, nullable=True) + signed_at = Column(DateTime, nullable=True) + refused_at = Column(DateTime, nullable=True) + refusal_reason = Column(Text, nullable=True) + + # === MÉTADONNÉES === + ip_address = Column(String(45), nullable=True) + user_agent = Column(Text, nullable=True) + signature_method = Column(String(50), nullable=True) + + # === ORDRE === + order_index = Column(Integer, default=0) + + # === RELATION === + transaction = relationship("UniversignTransaction", back_populates="signers") + + def __repr__(self): + return f"" + + +class UniversignSyncLog(Base): + """ + Journal de toutes les synchronisations (audit trail) + """ + + __tablename__ = "universign_sync_logs" + + id = Column(Integer, primary_key=True, autoincrement=True) + transaction_id = Column( + String(36), + ForeignKey("universign_transactions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + + # === SYNC INFO === + sync_type = Column(String(50), nullable=False, comment="webhook, polling, manual") + sync_timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True) + + # === CHANGEMENTS DÉTECTÉS === + previous_status = Column(String(50), nullable=True) + new_status = Column(String(50), nullable=True) + changes_detected = Column(Text, nullable=True, comment="JSON des changements") + + # === RÉSULTAT === + success = Column(Boolean, default=True) + error_message = Column(Text, nullable=True) + http_status_code = Column(Integer, nullable=True) + response_time_ms = Column(Integer, nullable=True) + + # === RELATION === + transaction = relationship("UniversignTransaction", back_populates="sync_logs") + + def __repr__(self): + return f"" + + +class UniversignConfig(Base): + __tablename__ = "universign_configs" + + id = Column(String(36), primary_key=True) + user_id = Column(String(36), nullable=True, index=True) + + environment = Column( + String(50), nullable=False, default="alpha", comment="alpha, prod" + ) + + api_url = Column(String(500), nullable=False) + api_key = Column(String(500), nullable=False, comment="À chiffrer") + + # === OPTIONS === + webhook_url = Column(String(500), nullable=True) + webhook_secret = Column(String(255), nullable=True) + + auto_sync_enabled = Column(Boolean, default=True) + sync_interval_minutes = Column(Integer, default=5) + + signature_expiry_days = Column(Integer, default=30) + + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + + def __repr__(self): + return f"" diff --git a/database/models/user.py b/database/models/user.py new file mode 100644 index 0000000..7c73cd0 --- /dev/null +++ b/database/models/user.py @@ -0,0 +1,39 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Boolean, +) +from datetime import datetime +from database.models.generic_model import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True) + email = Column(String(255), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + nom = Column(String(100), nullable=False) + prenom = Column(String(100), nullable=False) + role = Column(String(50), default="user") + + is_verified = Column(Boolean, default=False) + verification_token = Column(String(255), nullable=True, unique=True, index=True) + verification_token_expires = Column(DateTime, nullable=True) + + is_active = Column(Boolean, default=True) + failed_login_attempts = Column(Integer, default=0) + locked_until = Column(DateTime, nullable=True) + + reset_token = Column(String(255), nullable=True, unique=True, index=True) + reset_token_expires = Column(DateTime, nullable=True) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + last_login = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/database/models/workflow.py b/database/models/workflow.py new file mode 100644 index 0000000..018aba2 --- /dev/null +++ b/database/models/workflow.py @@ -0,0 +1,37 @@ +from sqlalchemy import ( + Column, + Integer, + String, + DateTime, + Float, + Text, + Boolean, +) +from datetime import datetime +from database.models.generic_model import Base + + +class WorkflowLog(Base): + __tablename__ = "workflow_logs" + + id = Column(String(36), primary_key=True) + + document_source = Column(String(100), nullable=False, index=True) + type_source = Column(Integer, nullable=False) # 0=Devis, 3=Commande, etc. + + document_cible = Column(String(100), nullable=False, index=True) + type_cible = Column(Integer, nullable=False) + + nb_lignes = Column(Integer, nullable=True) + montant_ht = Column(Float, nullable=True) + montant_ttc = Column(Float, nullable=True) + + date_transformation = Column(DateTime, default=datetime.now, nullable=False) + utilisateur = Column(String(100), nullable=True) + + succes = Column(Boolean, default=True) + erreur = Column(Text, nullable=True) + duree_ms = Column(Integer, nullable=True) # Durée en millisecondes + + def __repr__(self): + return f"" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..7ee11c7 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,24 @@ +services: + backend: + container_name: dev-sage-api + build: + context: . + target: dev + env_file: .env + volumes: + - .:/app + - /app/__pycache__ + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8000:8000" + environment: + ENV: development + DEBUG: "true" + DATABASE_URL: "sqlite+aiosqlite:///./data/sage_dataven.db" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..027eaf7 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,23 @@ +services: + backend: + container_name: prod_sage_api + build: + context: . + target: prod + env_file: .env.production + volumes: + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8004:8004" + environment: + ENV: production + DEBUG: "false" + DATABASE_URL: "sqlite+aiosqlite:///./data/sage_prod.db" + restart: always + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8004/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..81f9215 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,22 @@ +services: + backend: + container_name: staging_sage_api + build: + context: . + target: staging + env_file: .env.staging + volumes: + - ./data:/app/data + - ./logs:/app/logs + ports: + - "8002:8002" + environment: + ENV: staging + DEBUG: "false" + DATABASE_URL: "sqlite+aiosqlite:///./data/sage_staging.db" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8002/health"] + interval: 30s + timeout: 10s + retries: 3 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 3787019..9989985 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,4 @@ -version: "3.9" - services: - vps-sage-api: - build: . - container_name: vps-sage-api - env_file: .env - volumes: - # ✅ Monter un DOSSIER entier au lieu d'un fichier - - ./data:/app/data - ports: - - "8000:8000" - restart: unless-stopped \ No newline at end of file + backend: + build: + context: . \ No newline at end of file diff --git a/email_queue.py b/email_queue.py index 9c8451d..6079ae8 100644 --- a/email_queue.py +++ b/email_queue.py @@ -1,346 +1,456 @@ -# -*- coding: utf-8 -*- -""" -Queue d'envoi d'emails avec threading et génération PDF -Version VPS Linux - utilise sage_client pour récupérer les données -""" - import threading import queue -import time import asyncio from datetime import datetime, timedelta -from typing import Optional -from tenacity import retry, stop_after_attempt, wait_exponential import smtplib +import ssl +import socket from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.mime.application import MIMEApplication -from config import settings +from config.config import settings import logging +from reportlab.lib.pagesizes import A4 +from reportlab.pdfgen import canvas +from io import BytesIO + +from reportlab.lib.units import mm +from reportlab.lib.colors import HexColor logger = logging.getLogger(__name__) class EmailQueue: - """ - Queue d'emails avec workers threadés et retry automatique - """ - def __init__(self): self.queue = queue.Queue() self.workers = [] self.running = False self.session_factory = None - self.sage_client = None # Sera injecté depuis api.py - + self.sage_client = None + def start(self, num_workers: int = 3): - """Démarre les workers""" if self.running: - logger.warning("Queue déjà démarrée") return - + self.running = True for i in range(num_workers): worker = threading.Thread( - target=self._worker, - name=f"EmailWorker-{i}", - daemon=True + target=self._worker, name=f"EmailWorker-{i}", daemon=True ) worker.start() self.workers.append(worker) - - logger.info(f"✅ Queue email démarrée avec {num_workers} worker(s)") - + + logger.info(f"Queue email démarrée avec {num_workers} worker(s)") + def stop(self): - """Arrête les workers proprement""" - logger.info("🛑 Arrêt de la queue email...") self.running = False - - # Attendre que la queue soit vide (max 30s) try: self.queue.join() - logger.info("✅ Queue email arrêtée proprement") - except: - logger.warning("⚠️ Timeout lors de l'arrêt de la queue") - + except Exception: + pass + def enqueue(self, email_log_id: str): - """Ajoute un email dans la queue""" self.queue.put(email_log_id) - logger.debug(f"📨 Email {email_log_id} ajouté à la queue") - + def _worker(self): - """Worker qui traite les emails dans un thread""" - # Créer une event loop pour ce thread loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - + try: while self.running: try: - # Récupérer un email de la queue (timeout 1s) email_log_id = self.queue.get(timeout=1) - - # Traiter l'email loop.run_until_complete(self._process_email(email_log_id)) - - # Marquer comme traité self.queue.task_done() - except queue.Empty: continue except Exception as e: - logger.error(f"❌ Erreur worker: {e}", exc_info=True) + logger.error(f"Erreur worker: {e}") try: self.queue.task_done() - except: + except Exception: pass finally: loop.close() - + async def _process_email(self, email_log_id: str): - """Traite un email avec retry automatique""" from database import EmailLog, StatutEmail from sqlalchemy import select - + if not self.session_factory: - logger.error("❌ session_factory non configuré") + logger.error("session_factory non configuré") return - + async with self.session_factory() as session: - # Charger l'email log result = await session.execute( select(EmailLog).where(EmailLog.id == email_log_id) ) email_log = result.scalar_one_or_none() - + if not email_log: - logger.error(f"❌ Email log {email_log_id} introuvable") + logger.error(f"Email log {email_log_id} introuvable") return - - # Marquer comme en cours + email_log.statut = StatutEmail.EN_COURS email_log.nb_tentatives += 1 await session.commit() - + try: - # Envoi avec retry automatique await self._send_with_retry(email_log) - - # Succès email_log.statut = StatutEmail.ENVOYE email_log.date_envoi = datetime.now() email_log.derniere_erreur = None - logger.info(f"✅ Email envoyé: {email_log.destinataire}") - + except Exception as e: - # Échec + error_msg = str(e) email_log.statut = StatutEmail.ERREUR - email_log.derniere_erreur = str(e)[:1000] # Limiter la taille - - # Programmer un retry si < max attempts + email_log.derniere_erreur = error_msg[:1000] + if email_log.nb_tentatives < settings.max_retry_attempts: - delay = settings.retry_delay_seconds * (2 ** (email_log.nb_tentatives - 1)) + delay = settings.retry_delay_seconds * ( + 2 ** (email_log.nb_tentatives - 1) + ) email_log.prochain_retry = datetime.now() + timedelta(seconds=delay) - - # Programmer le retry + timer = threading.Timer(delay, self.enqueue, args=[email_log_id]) timer.daemon = True timer.start() - - logger.warning(f"⚠️ Retry prévu dans {delay}s pour {email_log.destinataire}") - else: - logger.error(f"❌ Échec définitif: {email_log.destinataire} - {e}") - + await session.commit() - - @retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=4, max=10) - ) + async def _send_with_retry(self, email_log): - """Envoi SMTP avec retry Tenacity + génération PDF""" - # Préparer le message msg = MIMEMultipart() - msg['From'] = settings.smtp_from - msg['To'] = email_log.destinataire - msg['Subject'] = email_log.sujet - - # Corps HTML - msg.attach(MIMEText(email_log.corps_html, 'html')) - - # 📎 GÉNÉRATION ET ATTACHEMENT DES PDFs + msg["From"] = settings.smtp_from + msg["To"] = email_log.destinataire + msg["Subject"] = email_log.sujet + msg.attach(MIMEText(email_log.corps_html, "html")) + + # Attachement des PDFs if email_log.document_ids: - document_ids = email_log.document_ids.split(',') + document_ids = email_log.document_ids.split(",") type_doc = email_log.type_document - + for doc_id in document_ids: doc_id = doc_id.strip() if not doc_id: continue - + try: - # Générer PDF (appel bloquant dans thread séparé) pdf_bytes = await asyncio.to_thread( - self._generate_pdf, - doc_id, - type_doc + self._generate_pdf, doc_id, type_doc ) - + if pdf_bytes: - # Attacher PDF part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf") - part['Content-Disposition'] = f'attachment; filename="{doc_id}.pdf"' + part["Content-Disposition"] = ( + f'attachment; filename="{doc_id}.pdf"' + ) msg.attach(part) - logger.info(f"📎 PDF attaché: {doc_id}.pdf") - + except Exception as e: - logger.error(f"❌ Erreur génération PDF {doc_id}: {e}") - # Continuer avec les autres PDFs - - # Envoi SMTP (bloquant mais dans thread séparé) + logger.error(f"Erreur génération PDF {doc_id}: {e}") + + # Envoi SMTP await asyncio.to_thread(self._send_smtp, msg) - + + def _send_smtp(self, msg): + server = None + + try: + # Résolution DNS + socket.getaddrinfo(settings.smtp_host, settings.smtp_port) + + # Connexion + server = smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) + + # EHLO + server.ehlo() + + # STARTTLS + if settings.smtp_use_tls: + if server.has_extn("STARTTLS"): + context = ssl.create_default_context() + server.starttls(context=context) + server.ehlo() + + # Authentification + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + + # Envoi + refused = server.send_message(msg) + if refused: + raise Exception(f"Destinataires refusés: {refused}") + + # Fermeture + server.quit() + + except Exception as e: + if server: + try: + server.quit() + except Exception: + pass + raise Exception(f"Erreur SMTP: {str(e)}") + def _generate_pdf(self, doc_id: str, type_doc: int) -> bytes: - """ - Génération PDF via ReportLab + sage_client - - ⚠️ Cette méthode est appelée depuis un thread worker - """ - from reportlab.lib.pagesizes import A4 - from reportlab.pdfgen import canvas - from reportlab.lib.units import cm - from io import BytesIO - if not self.sage_client: - logger.error("❌ sage_client non configuré") raise Exception("sage_client non disponible") - - # 📡 Récupérer document depuis gateway Windows via HTTP + try: doc = self.sage_client.lire_document(doc_id, type_doc) except Exception as e: - logger.error(f"❌ Erreur récupération document {doc_id}: {e}") - raise Exception(f"Document {doc_id} inaccessible") - + raise Exception(f"Document {doc_id} inaccessible : {e}") + if not doc: raise Exception(f"Document {doc_id} introuvable") - - # 📄 Créer PDF avec ReportLab + buffer = BytesIO() pdf = canvas.Canvas(buffer, pagesize=A4) width, height = A4 - - # === EN-TÊTE === + + # Couleurs + green_color = HexColor("#2A6F4F") + gray_400 = HexColor("#9CA3AF") + gray_600 = HexColor("#4B5563") + gray_800 = HexColor("#1F2937") + + # Marges + margin = 8 * mm + content_width = width - 2 * margin + + y = height - margin + + # ===== HEADER ===== + y -= 20 * mm + + # Logo/Nom entreprise à gauche + pdf.setFont("Helvetica-Bold", 18) + pdf.setFillColor(green_color) + pdf.drawString(margin, y, "Bijou S.A.S") + + # Informations document à droite + pdf.setFillColor(gray_800) pdf.setFont("Helvetica-Bold", 20) - pdf.drawString(2*cm, height - 3*cm, f"Document N° {doc_id}") - - # Type de document - type_labels = { - 0: "DEVIS", - 1: "BON DE LIVRAISON", - 2: "BON DE RETOUR", - 3: "COMMANDE", - 4: "PRÉPARATION", - 5: "FACTURE" - } - type_label = type_labels.get(type_doc, "DOCUMENT") - - pdf.setFont("Helvetica", 12) - pdf.drawString(2*cm, height - 4*cm, f"Type: {type_label}") - - # === INFORMATIONS CLIENT === - y = height - 5*cm - pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "CLIENT") - - y -= 0.8*cm - pdf.setFont("Helvetica", 11) - pdf.drawString(2*cm, y, f"Code: {doc.get('client_code', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Nom: {doc.get('client_intitule', '')}") - y -= 0.6*cm - pdf.drawString(2*cm, y, f"Date: {doc.get('date', '')}") - - # === LIGNES === - y -= 1.5*cm - pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(2*cm, y, "ARTICLES") - - y -= 1*cm - pdf.setFont("Helvetica-Bold", 10) - pdf.drawString(2*cm, y, "Désignation") - pdf.drawString(10*cm, y, "Qté") - pdf.drawString(12*cm, y, "Prix Unit.") - pdf.drawString(15*cm, y, "Total HT") - - y -= 0.5*cm - pdf.line(2*cm, y, width - 2*cm, y) - - y -= 0.7*cm + numero = doc.get("numero") or "BROUILLON" + pdf.drawRightString(width - margin, y, numero.upper()) + + y -= 7 * mm pdf.setFont("Helvetica", 9) - - for ligne in doc.get('lignes', []): - # Nouvelle page si nécessaire - if y < 3*cm: - pdf.showPage() - y = height - 3*cm - pdf.setFont("Helvetica", 9) - - designation = ligne.get('designation', '')[:50] - pdf.drawString(2*cm, y, designation) - pdf.drawString(10*cm, y, str(ligne.get('quantite', 0))) - pdf.drawString(12*cm, y, f"{ligne.get('prix_unitaire', 0):.2f}€") - pdf.drawString(15*cm, y, f"{ligne.get('montant_ht', 0):.2f}€") - y -= 0.6*cm - - # === TOTAUX === - y -= 1*cm - pdf.line(12*cm, y, width - 2*cm, y) - - y -= 0.8*cm - pdf.setFont("Helvetica-Bold", 11) - pdf.drawString(12*cm, y, "Total HT:") - pdf.drawString(15*cm, y, f"{doc.get('total_ht', 0):.2f}€") - - y -= 0.6*cm - pdf.drawString(12*cm, y, "TVA (20%):") - tva = doc.get('total_ttc', 0) - doc.get('total_ht', 0) - pdf.drawString(15*cm, y, f"{tva:.2f}€") - - y -= 0.6*cm - pdf.setFont("Helvetica-Bold", 14) - pdf.drawString(12*cm, y, "Total TTC:") - pdf.drawString(15*cm, y, f"{doc.get('total_ttc', 0):.2f}€") - - # === PIED DE PAGE === + pdf.setFillColor(gray_600) + + date_str = doc.get("date") or datetime.now().strftime("%d/%m/%Y") + pdf.drawRightString(width - margin, y, f"Date : {date_str}") + + y -= 5 * mm + date_livraison = ( + doc.get("date_livraison") or doc.get("date_echeance") or date_str + ) + pdf.drawRightString(width - margin, y, f"Validité : {date_livraison}") + + y -= 5 * mm + reference = doc.get("reference") or "—" + pdf.drawRightString(width - margin, y, f"Réf : {reference}") + + # ===== ADDRESSES ===== + y -= 20 * mm + + # Émetteur (gauche) + col1_x = margin + col2_x = margin + content_width / 2 + 6 * mm + col_width = content_width / 2 - 6 * mm + + pdf.setFont("Helvetica-Bold", 8) + pdf.setFillColor(gray_400) + pdf.drawString(col1_x, y, "ÉMETTEUR") + + y_emetteur = y - 5 * mm + pdf.setFont("Helvetica-Bold", 10) + pdf.setFillColor(gray_800) + pdf.drawString(col1_x, y_emetteur, "Bijou S.A.S") + + y_emetteur -= 5 * mm + pdf.setFont("Helvetica", 9) + pdf.setFillColor(gray_600) + pdf.drawString(col1_x, y_emetteur, "123 Avenue de la République") + + y_emetteur -= 4 * mm + pdf.drawString(col1_x, y_emetteur, "75011 Paris, France") + + y_emetteur -= 5 * mm + pdf.drawString(col1_x, y_emetteur, "contact@bijou.com") + + # Destinataire (droite, avec fond gris) + box_y = y - 4 * mm + box_height = 28 * mm + pdf.setFillColorRGB(0.97, 0.97, 0.97) # bg-gray-50 + pdf.roundRect( + col2_x, box_y - box_height, col_width, box_height, 3 * mm, fill=1, stroke=0 + ) + + pdf.setFillColor(gray_400) + pdf.setFont("Helvetica-Bold", 8) + pdf.drawString(col2_x + 4 * mm, y, "DESTINATAIRE") + + y_dest = y - 5 * mm + pdf.setFont("Helvetica-Bold", 10) + pdf.setFillColor(gray_800) + client_name = doc.get("client_intitule") or "Client" + pdf.drawString(col2_x + 4 * mm, y_dest, client_name) + + y_dest -= 5 * mm + pdf.setFont("Helvetica", 9) + pdf.setFillColor(gray_600) + pdf.drawString(col2_x + 4 * mm, y_dest, "10 rue des Clients") + + y_dest -= 4 * mm + pdf.drawString(col2_x + 4 * mm, y_dest, "75001 Paris") + + # ===== LIGNES D'ARTICLES ===== + y = min(y_emetteur, y_dest) - 20 * mm + + # En-têtes des colonnes + col_designation = margin + col_quantite = width - margin - 80 * mm + col_prix_unit = width - margin - 64 * mm + col_taux_taxe = width - margin - 40 * mm + col_montant = width - margin - 24 * mm + + pdf.setFont("Helvetica-Bold", 9) + pdf.setFillColor(gray_800) + pdf.drawString(col_designation, y, "Désignation") + pdf.drawRightString(col_quantite, y, "Qté") + pdf.drawRightString(col_prix_unit, y, "Prix Unit. HT") + pdf.drawRightString(col_taux_taxe, y, "TVA") + pdf.drawRightString(col_montant, y, "Montant HT") + + y -= 7 * mm + + # Lignes d'articles pdf.setFont("Helvetica", 8) - pdf.drawString(2*cm, 2*cm, f"Généré le {datetime.now().strftime('%d/%m/%Y %H:%M')}") - pdf.drawString(2*cm, 1.5*cm, "Sage 100c - API Dataven") - - # Finaliser + lignes = doc.get("lignes", []) + + for ligne in lignes: + if y < 60 * mm: # Nouvelle page si nécessaire + pdf.showPage() + y = height - margin - 20 * mm + pdf.setFont("Helvetica", 8) + + designation = ( + ligne.get("designation") or ligne.get("designation_article") or "" + ) + if len(designation) > 60: + designation = designation[:57] + "..." + + pdf.setFillColor(gray_800) + pdf.setFont("Helvetica-Bold", 8) + pdf.drawString(col_designation, y, designation) + + y -= 4 * mm + + # Description (si différente) + description = ligne.get("description", "") + if description and description != designation: + pdf.setFont("Helvetica", 7) + pdf.setFillColor(gray_600) + if len(description) > 70: + description = description[:67] + "..." + pdf.drawString(col_designation, y, description) + y -= 4 * mm + + # Valeurs + y += 4 * mm # Remonter pour aligner avec la désignation + pdf.setFont("Helvetica", 8) + pdf.setFillColor(gray_800) + + quantite = ligne.get("quantite") or 0 + pdf.drawRightString(col_quantite, y, str(quantite)) + + prix_unit = ligne.get("prix_unitaire_ht") or ligne.get("prix_unitaire", 0) + pdf.drawRightString(col_prix_unit, y, f"{prix_unit:.2f} €") + + taux_taxe = ligne.get("taux_taxe1") or 20 + pdf.drawRightString(col_taux_taxe, y, f"{taux_taxe}%") + + montant = ligne.get("montant_ligne_ht") or ligne.get("montant_ht", 0) + pdf.setFont("Helvetica-Bold", 8) + pdf.drawRightString(col_montant, y, f"{montant:.2f} €") + + y -= 8 * mm + + # Si aucune ligne + if not lignes: + pdf.setFont("Helvetica-Oblique", 9) + pdf.setFillColor(gray_400) + pdf.drawCentredString(width / 2, y, "Aucune ligne") + y -= 15 * mm + + # ===== TOTAUX ===== + y -= 10 * mm + + totals_x = width - margin - 64 * mm + totals_label_width = 40 * mm + + pdf.setFont("Helvetica", 9) + pdf.setFillColor(gray_600) + + # Total HT + pdf.drawString(totals_x, y, "Total HT") + total_ht = doc.get("total_ht_net") or doc.get("total_ht") or 0 + pdf.drawRightString(width - margin, y, f"{total_ht:.2f} €") + + y -= 6 * mm + + # TVA + pdf.drawString(totals_x, y, "TVA") + total_ttc = doc.get("total_ttc") or 0 + tva = total_ttc - total_ht + pdf.drawRightString(width - margin, y, f"{tva:.2f} €") + + y -= 8 * mm + + # Ligne de séparation + pdf.setStrokeColor(gray_400) + pdf.line(totals_x, y + 2 * mm, width - margin, y + 2 * mm) + + # Net à payer + pdf.setFont("Helvetica-Bold", 12) + pdf.setFillColor(green_color) + pdf.drawString(totals_x, y, "Net à payer") + pdf.drawRightString(width - margin, y, f"{total_ttc:.2f} €") + + # ===== NOTES ===== + notes = doc.get("notes_publique") or doc.get("notes") + if notes: + y -= 15 * mm + pdf.setStrokeColor(gray_400) + pdf.line(margin, y + 5 * mm, width - margin, y + 5 * mm) + + y -= 5 * mm + pdf.setFont("Helvetica-Bold", 8) + pdf.setFillColor(gray_400) + pdf.drawString(margin, y, "NOTES & CONDITIONS") + + y -= 5 * mm + pdf.setFont("Helvetica", 8) + pdf.setFillColor(gray_600) + + # Gérer les sauts de ligne dans les notes + for line in notes.split("\n"): + if y < 25 * mm: + break + pdf.drawString(margin, y, line[:100]) + y -= 4 * mm + + # ===== FOOTER ===== + pdf.setFont("Helvetica", 7) + pdf.setFillColor(gray_400) + pdf.drawCentredString(width / 2, 15 * mm, "Page 1 / 1") + pdf.save() buffer.seek(0) - - logger.info(f"✅ PDF généré: {doc_id}.pdf") + return buffer.read() - - def _send_smtp(self, msg): - """Envoi SMTP bloquant (appelé depuis asyncio.to_thread)""" - try: - with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as server: - if settings.smtp_use_tls: - server.starttls() - - if settings.smtp_user and settings.smtp_password: - server.login(settings.smtp_user, settings.smtp_password) - - server.send_message(msg) - - except smtplib.SMTPException as e: - raise Exception(f"Erreur SMTP: {str(e)}") - except Exception as e: - raise Exception(f"Erreur envoi: {str(e)}") -# Instance globale -email_queue = EmailQueue() \ No newline at end of file +email_queue = EmailQueue() diff --git a/init_db.py b/init_db.py index b59d822..0534362 100644 --- a/init_db.py +++ b/init_db.py @@ -1,63 +1,35 @@ -# -*- coding: utf-8 -*- -""" -Script d'initialisation de la base de données SQLite -Lance ce script avant le premier démarrage de l'API - -Usage: - python init_db.py -""" - import asyncio import sys from pathlib import Path -# Ajouter le répertoire parent au path pour les imports sys.path.insert(0, str(Path(__file__).parent)) -from database import init_db # ✅ Import depuis database/__init__.py +from database import init_db import logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) logger = logging.getLogger(__name__) async def main(): - """Crée toutes les tables dans sage_dataven.db""" - - print("\n" + "="*60) - print("🚀 Initialisation de la base de données Sage Dataven") - print("="*60 + "\n") - try: - # Créer les tables + logger.info("Debut de l'initialisation") await init_db() - - print("\n✅ Base de données créée avec succès!") - print(f"📍 Fichier: sage_dataven.db") - - print("\n📊 Tables créées:") - print(" ├─ email_logs (Journalisation emails)") - print(" ├─ signature_logs (Suivi signatures Universign)") - print(" ├─ workflow_logs (Transformations documents)") - print(" ├─ cache_metadata (Métadonnées cache)") - print(" └─ audit_logs (Journal d'audit)") - - print("\n📝 Prochaines étapes:") - print(" 1. Configurer le fichier .env avec vos credentials") - print(" 2. Lancer la gateway Windows sur la machine Sage") - print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000") - print(" 4. Ou avec Docker: docker-compose up -d") - print(" 5. Tester: http://votre-vps:8000/docs") - - print("\n" + "="*60 + "\n") + logger.info("Initialisation terminee") + print("\nInitialisation terminee") + + print("\nBase de données créée avec succès !") + return True - + except Exception as e: - print(f"\n❌ Erreur lors de l'initialisation: {e}") + print(f"\nErreur lors de l'initialisation: {e}") logger.exception("Détails de l'erreur:") return False if __name__ == "__main__": result = asyncio.run(main()) - sys.exit(0 if result else 1) \ No newline at end of file + sys.exit(0 if result else 1) diff --git a/requirements.txt b/requirements.txt index 6138d38..2ece0f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,10 +5,17 @@ pydantic-settings reportlab requests msal + python-multipart email-validator python-dotenv +python-jose[cryptography] +passlib[bcrypt] +bcrypt==4.2.0 + sqlalchemy aiosqlite -tenacity \ No newline at end of file +tenacity + +httpx \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..0d18349 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,529 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from pydantic import BaseModel, EmailStr, Field +from datetime import datetime, timedelta +from typing import Optional +import uuid + +from database import get_session, User, RefreshToken, LoginAttempt +from security.auth import ( + hash_password, + verify_password, + validate_password_strength, + create_access_token, + create_refresh_token, + decode_token, + generate_verification_token, + generate_reset_token, + hash_token, +) +from services.email_service import AuthEmailService +from core.dependencies import get_current_user +from config.config import settings +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +class RegisterRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + nom: str = Field(..., min_length=2, max_length=100) + prenom: str = Field(..., min_length=2, max_length=100) + + +class Login(BaseModel): + email: EmailStr + password: str + + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int = 86400 + + +class RefreshTokenRequest(BaseModel): + refresh_token: str + + +class ForgotPassword(BaseModel): + email: EmailStr + + +class ResetPassword(BaseModel): + token: str + new_password: str = Field(..., min_length=8) + + +class VerifyEmail(BaseModel): + token: str + + +class ResendVerification(BaseModel): + email: EmailStr + + +async def log_login_attempt( + session: AsyncSession, + email: str, + ip: str, + user_agent: str, + success: bool, + failure_reason: Optional[str] = None, +): + attempt = LoginAttempt( + email=email, + ip_address=ip, + user_agent=user_agent, + success=success, + failure_reason=failure_reason, + timestamp=datetime.now(), + ) + session.add(attempt) + await session.commit() + + +async def check_rate_limit( + session: AsyncSession, email: str, ip: str +) -> tuple[bool, str]: + time_window = datetime.now() - timedelta(minutes=15) + + result = await session.execute( + select(LoginAttempt).where( + LoginAttempt.email == email, + LoginAttempt.success, + LoginAttempt.timestamp >= time_window, + ) + ) + failed_attempts = result.scalars().all() + + if len(failed_attempts) >= 5: + return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." + + return True, "" + + +@router.post("/register", status_code=status.HTTP_201_CREATED) +async def register( + data: RegisterRequest, + request: Request, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(User).where(User.email == data.email)) + existing_user = result.scalar_one_or_none() + + if existing_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé" + ) + + is_valid, error_msg = validate_password_strength(data.password) + if not is_valid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + + verification_token = generate_verification_token() + + new_user = User( + id=str(uuid.uuid4()), + email=data.email.lower(), + hashed_password=hash_password(data.password), + nom=data.nom, + prenom=data.prenom, + is_verified=False, + verification_token=verification_token, + verification_token_expires=datetime.now() + timedelta(hours=24), + created_at=datetime.now(), + ) + + session.add(new_user) + await session.commit() + + base_url = str(request.base_url).rstrip("/") + email_sent = AuthEmailService.send_verification_email( + data.email, verification_token, base_url + ) + + if not email_sent: + logger.warning(f"Échec envoi email vérification pour {data.email}") + + logger.info(f" Nouvel utilisateur inscrit: {data.email} (ID: {new_user.id})") + + return { + "success": True, + "message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.", + "user_id": new_user.id, + "email": data.email, + } + + +@router.get("/verify-email") +async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(User).where(User.verification_token == token)) + user = result.scalar_one_or_none() + + if not user: + return { + "success": False, + "message": "Token de vérification invalide ou déjà utilisé.", + } + + if user.verification_token_expires < datetime.now(): + return { + "success": False, + "message": "Token expiré. Veuillez demander un nouvel email de vérification.", + "expired": True, + } + + user.is_verified = True + user.verification_token = None + user.verification_token_expires = None + await session.commit() + + logger.info(f" Email vérifié: {user.email}") + + return { + "success": True, + "message": " Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", + "email": user.email, + } + + +@router.post("/verify-email") +async def verify_email_post( + data: VerifyEmail, session: AsyncSession = Depends(get_session) +): + result = await session.execute( + select(User).where(User.verification_token == data.token) + ) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token de vérification invalide", + ) + + if user.verification_token_expires < datetime.now(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token expiré. Demandez un nouvel email de vérification.", + ) + + user.is_verified = True + user.verification_token = None + user.verification_token_expires = None + await session.commit() + + logger.info(f" Email vérifié: {user.email}") + + return { + "success": True, + "message": "Email vérifié avec succès ! Vous pouvez maintenant vous connecter.", + } + + +@router.post("/resend-verification") +async def resend_verification( + data: ResendVerification, + request: Request, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + if not user: + return { + "success": True, + "message": "Si cet email existe, un nouveau lien de vérification a été envoyé.", + } + + if user.is_verified: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est déjà vérifié" + ) + + verification_token = generate_verification_token() + user.verification_token = verification_token + user.verification_token_expires = datetime.now() + timedelta(hours=24) + await session.commit() + + base_url = str(request.base_url).rstrip("/") + AuthEmailService.send_verification_email(user.email, verification_token, base_url) + + return {"success": True, "message": "Un nouveau lien de vérification a été envoyé."} + + +@router.post("/login", response_model=TokenResponse) +async def login( + data: Login, request: Request, session: AsyncSession = Depends(get_session) +): + ip = request.client.host if request.client else "unknown" + user_agent = request.headers.get("user-agent", "unknown") + + is_allowed, error_msg = await check_rate_limit(session, data.email.lower(), ip) + if not is_allowed: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg + ) + + result = await session.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + if not user or not verify_password(data.password, user.hashed_password): + await log_login_attempt( + session, + data.email.lower(), + ip, + user_agent, + False, + "Identifiants incorrects", + ) + + if user: + user.failed_login_attempts += 1 + + if user.failed_login_attempts >= 5: + user.locked_until = datetime.now() + timedelta(minutes=15) + await session.commit() + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.", + ) + + await session.commit() + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Email ou mot de passe incorrect", + ) + + if not user.is_active: + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte désactivé" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + + if not user.is_verified: + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Email non vérifié" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception.", + ) + + if user.locked_until and user.locked_until > datetime.now(): + await log_login_attempt( + session, data.email.lower(), ip, user_agent, False, "Compte verrouillé" + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé", + ) + + user.failed_login_attempts = 0 + user.locked_until = None + user.last_login = datetime.now() + + access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + refresh_token_jwt = create_refresh_token(user.id) + + refresh_token_record = RefreshToken( + id=str(uuid.uuid4()), + user_id=user.id, + token_hash=hash_token(refresh_token_jwt), + device_info=user_agent[:500], + ip_address=ip, + expires_at=datetime.now() + timedelta(days=7), + created_at=datetime.now(), + ) + + session.add(refresh_token_record) + await session.commit() + + await log_login_attempt(session, data.email.lower(), ip, user_agent, True) + + logger.info(f" Connexion réussie: {user.email}") + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token_jwt, + expires_in=86400, + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_access_token( + data: RefreshTokenRequest, session: AsyncSession = Depends(get_session) +): + payload = decode_token(data.refresh_token) + if not payload or payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide" + ) + + user_id = payload.get("sub") + token_hash = hash_token(data.refresh_token) + + result = await session.execute( + select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.token_hash == token_hash, + not RefreshToken.is_revoked, + ) + ) + token_record = result.scalar_one_or_none() + + if not token_record: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh token révoqué ou introuvable", + ) + + if token_record.expires_at < datetime.now(): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré" + ) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable ou désactivé", + ) + + new_access_token = create_access_token( + {"sub": user.id, "email": user.email, "role": user.role} + ) + + logger.info(f" Token rafraîchi: {user.email}") + + return TokenResponse( + access_token=new_access_token, + refresh_token=data.refresh_token, + expires_in=86400, + ) + + +@router.post("/forgot-password") +async def forgot_password( + data: ForgotPassword, + request: Request, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(User).where(User.email == data.email.lower())) + user = result.scalar_one_or_none() + + if not user: + return { + "success": True, + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", + } + + reset_token = generate_reset_token() + user.reset_token = reset_token + user.reset_token_expires = datetime.now() + timedelta(hours=1) + await session.commit() + + frontend_url = ( + settings.frontend_url + if hasattr(settings, "frontend_url") + else str(request.base_url).rstrip("/") + ) + AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) + + logger.info(f" Reset password demandé: {user.email}") + + return { + "success": True, + "message": "Si cet email existe, un lien de réinitialisation a été envoyé.", + } + + +@router.post("/reset-password") +async def reset_password( + data: ResetPassword, session: AsyncSession = Depends(get_session) +): + result = await session.execute(select(User).where(User.reset_token == data.token)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token de réinitialisation invalide", + ) + + if user.reset_token_expires < datetime.now(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Token expiré. Demandez un nouveau lien de réinitialisation.", + ) + + is_valid, error_msg = validate_password_strength(data.new_password) + if not is_valid: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) + + user.hashed_password = hash_password(data.new_password) + user.reset_token = None + user.reset_token_expires = None + user.failed_login_attempts = 0 + user.locked_until = None + await session.commit() + + AuthEmailService.send_password_changed_notification(user.email) + + logger.info(f" Mot de passe réinitialisé: {user.email}") + + return { + "success": True, + "message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.", + } + + +@router.post("/logout") +async def logout( + data: RefreshTokenRequest, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + token_hash = hash_token(data.refresh_token) + + result = await session.execute( + select(RefreshToken).where( + RefreshToken.user_id == user.id, RefreshToken.token_hash == token_hash + ) + ) + token_record = result.scalar_one_or_none() + + if token_record: + token_record.is_revoked = True + token_record.revoked_at = datetime.now() + await session.commit() + + logger.info(f"👋 Déconnexion: {user.email}") + + return {"success": True, "message": "Déconnexion réussie"} + + +@router.get("/me") +async def get_current_user_info(user: User = Depends(get_current_user)): + return { + "id": user.id, + "email": user.email, + "nom": user.nom, + "prenom": user.prenom, + "role": user.role, + "is_verified": user.is_verified, + "created_at": user.created_at.isoformat(), + "last_login": user.last_login.isoformat() if user.last_login else None, + } diff --git a/routes/sage_gateway.py b/routes/sage_gateway.py new file mode 100644 index 0000000..6b9db87 --- /dev/null +++ b/routes/sage_gateway.py @@ -0,0 +1,323 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +import logging + +from database import get_session, User +from core.dependencies import get_current_user +from services.sage_gateway import ( + SageGatewayService, + gateway_response_from_model, +) +from schemas import ( + SageGatewayCreate, + SageGatewayUpdate, + SageGatewayResponse, + SageGatewayList, + SageGatewayHealthCheck, + SageGatewayTest, + SageGatewayStatsResponse, + CurrentGatewayInfo, +) +from config.config import settings + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/sage-gateways", tags=["Sage Gateways"]) + + +@router.post( + "", response_model=SageGatewayResponse, status_code=status.HTTP_201_CREATED +) +async def create_gateway( + data: SageGatewayCreate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.create(user.id, data.model_dump()) + + logger.info(f"Gateway créée: {gateway.name} par {user.email}") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.get("", response_model=SageGatewayList) +async def list_gateways( + include_deleted: bool = Query(False, description="Inclure les gateways supprimées"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateways = await service.list_for_user(user.id, include_deleted) + active = await service.get_active_gateway(user.id) + + items = [SageGatewayResponse(**gateway_response_from_model(g)) for g in gateways] + + return SageGatewayList( + items=items, + total=len(items), + active_gateway=SageGatewayResponse(**gateway_response_from_model(active)) + if active + else None, + using_fallback=active is None, + ) + + +@router.get("/current", response_model=CurrentGatewayInfo) +async def get_current_gateway( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + url, token, gateway_id = await service.get_effective_gateway_config(user.id) + + if gateway_id: + gateway = await service.get_by_id(gateway_id, user.id) + return CurrentGatewayInfo( + source="user_config", + gateway_id=gateway_id, + gateway_name=gateway.name if gateway else None, + gateway_url=url, + is_healthy=gateway.last_health_status if gateway else None, + user_id=user.id, + ) + else: + return CurrentGatewayInfo( + source="fallback", + gateway_id=None, + gateway_name="Configuration .env (défaut)", + gateway_url=url, + is_healthy=None, + user_id=user.id, + ) + + +@router.get("/stats", response_model=SageGatewayStatsResponse) +async def get_gateway_stats( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + stats = await service.get_stats(user.id) + return SageGatewayStatsResponse(**stats) + + +@router.get("/{gateway_id}", response_model=SageGatewayResponse) +async def get_gateway( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.get_by_id(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.put("/{gateway_id}", response_model=SageGatewayResponse) +async def update_gateway( + gateway_id: str, + data: SageGatewayUpdate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + update_data = {k: v for k, v in data.model_dump().items() if v is not None} + + if not update_data: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Aucun champ à modifier" + ) + + gateway = await service.update(gateway_id, user.id, update_data) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info(f"Gateway mise à jour: {gateway.name} par {user.email}") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.delete("/{gateway_id}") +async def delete_gateway( + gateway_id: str, + hard_delete: bool = Query(False, description="Suppression définitive"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + success = await service.delete(gateway_id, user.id, hard_delete) + if not success: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info( + f"Gateway supprimée: {gateway_id} par {user.email} (hard={hard_delete})" + ) + + return { + "success": True, + "message": f"Gateway supprimée {'définitivement' if hard_delete else '(soft delete)'}", + } + + +@router.post("/{gateway_id}/activate", response_model=SageGatewayResponse) +async def activate_gateway( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.activate(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info(f"Gateway activée: {gateway.name} par {user.email}") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.post("/{gateway_id}/deactivate", response_model=SageGatewayResponse) +async def deactivate_gateway( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + + gateway = await service.deactivate(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + logger.info(f"Gateway désactivée: {gateway.name} - fallback actif") + + return SageGatewayResponse(**gateway_response_from_model(gateway)) + + +@router.post("/deactivate-all") +async def deactivate_all_gateways( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + await service._deactivate_all_for_user(user.id) + await session.commit() + + logger.info( + f"Toutes les gateways désactivées pour {user.email} - fallback .env actif" + ) + + return { + "success": True, + "message": "Toutes les gateways désactivées. Le fallback .env est maintenant utilisé.", + "fallback_url": settings.sage_gateway_url, + } + + +@router.post("/{gateway_id}/health-check", response_model=SageGatewayHealthCheck) +async def check_gateway_health( + gateway_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + from datetime import datetime + + service = SageGatewayService(session) + + gateway = await service.get_by_id(gateway_id, user.id) + if not gateway: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gateway {gateway_id} introuvable", + ) + + result = await service.health_check(gateway_id, user.id) + + return SageGatewayHealthCheck( + gateway_id=gateway_id, + gateway_name=gateway.name, + status=result.get("status", "unknown"), + response_time_ms=result.get("response_time_ms"), + sage_version=result.get("sage_version"), + error=result.get("error"), + checked_at=datetime.now(), + ) + + +@router.post("/test", response_model=dict) +async def test_gateway_config( + data: SageGatewayTest, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + service = SageGatewayService(session) + result = await service.test_gateway(data.gateway_url, data.gateway_token) + + return {"tested_url": data.gateway_url, "result": result} + + +@router.post("/health-check-all") +async def check_all_gateways_health( + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = SageGatewayService(session) + gateways = await service.list_for_user(user.id) + + results = [] + for gateway in gateways: + result = await service.health_check(gateway.id, user.id) + results.append( + { + "gateway_id": gateway.id, + "gateway_name": gateway.name, + "is_active": gateway.is_active, + **result, + } + ) + + healthy_count = sum(1 for r in results if r.get("status") == "healthy") + + return { + "total": len(results), + "healthy": healthy_count, + "unhealthy": len(results) - healthy_count, + "results": results, + } + + +@router.get("/fallback/info") +async def get_fallback_info( + user: User = Depends(get_current_user), +): + return { + "source": ".env", + "gateway_url": settings.sage_gateway_url, + "token_configured": bool(settings.sage_gateway_token), + "token_preview": f"****{settings.sage_gateway_token[-4:]}" + if settings.sage_gateway_token + else None, + "description": "Configuration par défaut utilisée quand aucune gateway utilisateur n'est active", + } diff --git a/routes/universign.py b/routes/universign.py new file mode 100644 index 0000000..9752755 --- /dev/null +++ b/routes/universign.py @@ -0,0 +1,1615 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import false, select, func, or_, and_, true +from sqlalchemy.orm import selectinload +from typing import List, Optional +from datetime import datetime, timedelta +from pydantic import BaseModel, EmailStr +import logging +from data.data import templates_signature_email +from email_queue import email_queue +from database import UniversignSignerStatus, UniversignTransactionStatus, get_session +from database import ( + UniversignTransaction, + UniversignSigner, + UniversignSyncLog, + LocalDocumentStatus, + SageDocumentType, +) +import os +from pathlib import Path +import json +from services.universign_document import UniversignDocumentService +from services.universign_sync import UniversignSyncService +from config.config import settings +from utils.generic_functions import normaliser_type_doc +from utils.universign_status_mapping import get_status_message, map_universign_to_local + +from database.models.email import EmailLog +from database.enum.status import StatutEmail + + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/universign", tags=["Universign"]) + +sync_service = UniversignSyncService( + api_url=settings.universign_api_url, api_key=settings.universign_api_key +) + + +class CreateSignatureRequest(BaseModel): + """Demande de création d'une signature""" + + sage_document_id: str + sage_document_type: SageDocumentType + signer_email: EmailStr + signer_name: str + document_name: Optional[str] = None + + +class TransactionResponse(BaseModel): + """Réponse détaillée d'une transaction""" + + id: str + transaction_id: str + sage_document_id: str + sage_document_type: str + universign_status: str + local_status: str + local_status_label: str + signer_url: Optional[str] + document_url: Optional[str] + created_at: datetime + sent_at: Optional[datetime] + signed_at: Optional[datetime] + last_synced_at: Optional[datetime] + needs_sync: bool + signers: List[dict] + + signed_document_available: bool = False + signed_document_downloaded_at: Optional[datetime] = None + signed_document_size_kb: Optional[float] = None + + +class SyncStatsResponse(BaseModel): + """Statistiques de synchronisation""" + + total_transactions: int + pending_sync: int + signed: int + in_progress: int + refused: int + expired: int + last_sync_at: Optional[datetime] + + +@router.post("/signatures/create", response_model=TransactionResponse) +async def create_signature( + request: CreateSignatureRequest, session: AsyncSession = Depends(get_session) +): + try: + # === VÉRIFICATION DOUBLON RENFORCÉE === + logger.info( + f"🔍 Vérification doublon pour: {request.sage_document_id} " + f"(type: {request.sage_document_type.name})" + ) + + existing_query = select(UniversignTransaction).where( + UniversignTransaction.sage_document_id == request.sage_document_id, + UniversignTransaction.sage_document_type == request.sage_document_type, + ) + existing_result = await session.execute(existing_query) + all_existing = existing_result.scalars().all() + + if all_existing: + logger.warning( + f"{len(all_existing)} transaction(s) existante(s) trouvée(s)" + ) + + # Filtrer les transactions non-finales + active_txs = [ + tx + for tx in all_existing + if tx.local_status + not in [ + LocalDocumentStatus.SIGNED, + LocalDocumentStatus.REJECTED, + LocalDocumentStatus.EXPIRED, + LocalDocumentStatus.ERROR, + ] + ] + + if active_txs: + active_tx = active_txs[0] + logger.error( + f"Transaction active existante: {active_tx.transaction_id} " + f"(statut: {active_tx.local_status.value})" + ) + raise HTTPException( + 400, + f"Une demande de signature est déjà en cours pour {request.sage_document_id} " + f"(transaction: {active_tx.transaction_id}, statut: {active_tx.local_status.value}). " + f"Utilisez GET /universign/documents/{request.sage_document_id}/signatures pour voir toutes les transactions.", + ) + + logger.info( + "Toutes les transactions existantes sont finales, création autorisée" + ) + + # Génération PDF + logger.info(f"📄 Génération PDF: {request.sage_document_id}") + pdf_bytes = email_queue._generate_pdf( + request.sage_document_id, normaliser_type_doc(request.sage_document_type) + ) + + if not pdf_bytes: + raise HTTPException(400, "Échec génération PDF") + + logger.info(f"PDF généré: {len(pdf_bytes)} octets") + + # === CRÉATION TRANSACTION UNIVERSIGN === + import requests + import uuid + + auth = (settings.universign_api_key, "") + + logger.info("🔄 Création transaction Universign...") + + resp = requests.post( + f"{settings.universign_api_url}/transactions", + auth=auth, + json={ + "name": request.document_name + or f"{request.sage_document_type.name} {request.sage_document_id}", + "language": "fr", + }, + timeout=30, + ) + + if resp.status_code != 200: + logger.error(f"Erreur Universign (création): {resp.text}") + raise HTTPException(500, f"Erreur Universign: {resp.status_code}") + + universign_tx_id = resp.json().get("id") + logger.info(f"Transaction Universign créée: {universign_tx_id}") + + # Upload PDF + logger.info("📤 Upload PDF...") + files = { + "file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf") + } + resp = requests.post( + f"{settings.universign_api_url}/files", auth=auth, files=files, timeout=60 + ) + + if resp.status_code not in [200, 201]: + logger.error(f"Erreur upload: {resp.text}") + raise HTTPException(500, "Erreur upload PDF") + + file_id = resp.json().get("id") + logger.info(f"PDF uploadé: {file_id}") + + # Attachement document + logger.info("🔗 Attachement document...") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents", + auth=auth, + data={"document": file_id}, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur attachement document") + + document_id = resp.json().get("id") + + # Création champ signature + logger.info("✍️ Création champ signature...") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields", + auth=auth, + data={"type": "signature"}, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur création champ signature") + + field_id = resp.json().get("id") + + # Liaison signataire + logger.info(f"👤 Liaison signataire: {request.signer_email}") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures", + auth=auth, + data={"signer": request.signer_email, "field": field_id}, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur liaison signataire") + + # Démarrage transaction + logger.info("🚀 Démarrage transaction...") + resp = requests.post( + f"{settings.universign_api_url}/transactions/{universign_tx_id}/start", + auth=auth, + timeout=30, + ) + + if resp.status_code not in [200, 201]: + raise HTTPException(500, "Erreur démarrage transaction") + + final_data = resp.json() + + # Extraction URL de signature + signer_url = "" + if final_data.get("actions"): + for action in final_data["actions"]: + if action.get("url"): + signer_url = action["url"] + break + + if not signer_url: + raise HTTPException(500, "URL de signature non retournée") + + logger.info("URL de signature obtenue") + + # === ENREGISTREMENT LOCAL === + local_id = str(uuid.uuid4()) + + transaction = UniversignTransaction( + id=local_id, + transaction_id=universign_tx_id, # Utiliser l'ID Universign, ne jamais le changer + sage_document_id=request.sage_document_id, + sage_document_type=request.sage_document_type, + universign_status=UniversignTransactionStatus.STARTED, + local_status=LocalDocumentStatus.IN_PROGRESS, + signer_url=signer_url, + requester_email=request.signer_email, + requester_name=request.signer_name, + document_name=request.document_name, + created_at=datetime.now(), + sent_at=datetime.now(), + is_test=True, + needs_sync=True, + ) + + session.add(transaction) + + signer = UniversignSigner( + id=f"{local_id}_signer_0", + transaction_id=local_id, + email=request.signer_email, + name=request.signer_name, + status=UniversignSignerStatus.WAITING, + order_index=0, + ) + + session.add(signer) + await session.commit() + + logger.info( + f"💾 Transaction sauvegardée: {local_id} (Universign: {universign_tx_id})" + ) + + # === ENVOI EMAIL AVEC TEMPLATE === + template = templates_signature_email["demande_signature"] + + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } + + doc_info = email_queue.sage_client.lire_document( + request.sage_document_id, request.sage_document_type.value + ) + montant_ttc = f"{doc_info.get('total_ttc', 0):.2f}" if doc_info else "0.00" + date_doc = ( + doc_info.get("date", datetime.now().strftime("%d/%m/%Y")) + if doc_info + else datetime.now().strftime("%d/%m/%Y") + ) + + variables = { + "NOM_SIGNATAIRE": request.signer_name, + "TYPE_DOC": type_labels.get(request.sage_document_type.value, "Document"), + "NUMERO": request.sage_document_id, + "DATE": date_doc, + "MONTANT_TTC": montant_ttc, + "SIGNER_URL": signer_url, + "CONTACT_EMAIL": settings.smtp_from, + } + + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=request.signer_email, + sujet=sujet, + corps_html=corps, + document_ids=request.sage_document_id, + type_document=request.sage_document_type.value, + statut=StatutEmail.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.commit() + + email_queue.enqueue(email_log.id) + + # === MISE À JOUR STATUT SAGE (Confirmé = 1) === + try: + from sage_client import sage_client + + sage_client.changer_statut_document( + document_type_code=request.sage_document_type.value, + numero=request.sage_document_id, + nouveau_statut=1, + ) + logger.info( + f"Statut Sage mis à jour: {request.sage_document_id} → Confirmé (1)" + ) + except Exception as e: + logger.warning(f"Impossible de mettre à jour le statut Sage: {e}") + + # === RÉPONSE === + return TransactionResponse( + id=transaction.id, + transaction_id=transaction.transaction_id, + sage_document_id=transaction.sage_document_id, + sage_document_type=transaction.sage_document_type.name, + universign_status=transaction.universign_status.value, + local_status=transaction.local_status.value, + local_status_label=get_status_message(transaction.local_status.value), + signer_url=transaction.signer_url, + document_url=None, + created_at=transaction.created_at, + sent_at=transaction.sent_at, + signed_at=None, + last_synced_at=None, + needs_sync=True, + signers=[ + { + "email": signer.email, + "name": signer.name, + "status": signer.status.value, + } + ], + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur création signature: {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.get("/transactions", response_model=List[TransactionResponse]) +async def list_transactions( + status: Optional[LocalDocumentStatus] = None, + sage_document_id: Optional[str] = None, + limit: int = Query(100, le=1000), + session: AsyncSession = Depends(get_session), +): + """Liste toutes les transactions""" + query = select(UniversignTransaction).options( + selectinload(UniversignTransaction.signers) + ) + + if status: + query = query.where(UniversignTransaction.local_status == status) + + if sage_document_id: + query = query.where(UniversignTransaction.sage_document_id == sage_document_id) + + query = query.order_by(UniversignTransaction.created_at.desc()).limit(limit) + + result = await session.execute(query) + transactions = result.scalars().all() + + return [ + TransactionResponse( + id=tx.id, + transaction_id=tx.transaction_id, + sage_document_id=tx.sage_document_id, + sage_document_type=tx.sage_document_type.name, + universign_status=tx.universign_status.value, + local_status=tx.local_status.value, + local_status_label=get_status_message(tx.local_status.value), + signer_url=tx.signer_url, + document_url=tx.document_url, + created_at=tx.created_at, + sent_at=tx.sent_at, + signed_at=tx.signed_at, + last_synced_at=tx.last_synced_at, + needs_sync=tx.needs_sync, + signers=[ + { + "email": s.email, + "name": s.name, + "status": s.status.value, + "signed_at": s.signed_at.isoformat() if s.signed_at else None, + } + for s in tx.signers + ], + # ✅ NOUVEAUX CHAMPS + signed_document_available=bool( + tx.signed_document_path and Path(tx.signed_document_path).exists() + ), + signed_document_downloaded_at=tx.signed_document_downloaded_at, + signed_document_size_kb=( + tx.signed_document_size_bytes / 1024 + if tx.signed_document_size_bytes + else None + ), + ) + for tx in transactions + ] + + +@router.get("/transactions/{transaction_id}", response_model=TransactionResponse) +async def get_transaction( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """Récupère une transaction par son ID""" + query = ( + select(UniversignTransaction) + .where(UniversignTransaction.transaction_id == transaction_id) + .options(selectinload(UniversignTransaction.signers)) + ) + + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, "Transaction introuvable") + + return TransactionResponse( + id=tx.id, + transaction_id=tx.transaction_id, + sage_document_id=tx.sage_document_id, + sage_document_type=tx.sage_document_type.name, + universign_status=tx.universign_status.value, + local_status=tx.local_status.value, + local_status_label=get_status_message(tx.local_status.value), + signer_url=tx.signer_url, + document_url=tx.document_url, + created_at=tx.created_at, + sent_at=tx.sent_at, + signed_at=tx.signed_at, + last_synced_at=tx.last_synced_at, + needs_sync=tx.needs_sync, + signers=[ + { + "email": s.email, + "name": s.name, + "status": s.status.value, + "signed_at": s.signed_at.isoformat() if s.signed_at else None, + } + for s in tx.signers + ], + # ✅ NOUVEAUX CHAMPS + signed_document_available=bool( + tx.signed_document_path and Path(tx.signed_document_path).exists() + ), + signed_document_downloaded_at=tx.signed_document_downloaded_at, + signed_document_size_kb=( + tx.signed_document_size_bytes / 1024 + if tx.signed_document_size_bytes + else None + ), + ) + + +@router.post("/transactions/{transaction_id}/sync") +async def sync_single_transaction( + transaction_id: str, + force: bool = Query(False), + session: AsyncSession = Depends(get_session), +): + """Force la synchronisation d'une transaction""" + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, "Transaction introuvable") + + success, error = await sync_service.sync_transaction( + session, transaction, force=force + ) + + if not success: + raise HTTPException(500, error or "Échec synchronisation") + + return { + "success": True, + "transaction_id": transaction_id, + "new_status": transaction.local_status.value, + "synced_at": transaction.last_synced_at.isoformat(), + } + + +@router.post("/sync/all") +async def sync_all_transactions( + max_transactions: int = Query(50, le=500), + session: AsyncSession = Depends(get_session), +): + """Synchronise toutes les transactions en attente""" + stats = await sync_service.sync_all_pending(session, max_transactions) + + return {"success": True, "stats": stats, "timestamp": datetime.now().isoformat()} + + +@router.post("/webhook") +@router.post("/webhook/") +async def webhook_universign( + request: Request, session: AsyncSession = Depends(get_session) +): + """ + CORRECTION : Extraction correcte du transaction_id selon la structure réelle d'Universign + """ + try: + payload = await request.json() + + # 📋 LOG COMPLET du payload pour débogage + logger.info( + f"📥 Webhook Universign reçu - Type: {payload.get('type', 'unknown')}" + ) + logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}") + + # EXTRACTION CORRECTE DU TRANSACTION_ID + transaction_id = None + + # 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante) + # Exemple : transaction.lifecycle.created, transaction.lifecycle.started, etc. + if payload.get("type", "").startswith("transaction.") and "payload" in payload: + # Le transaction_id est dans payload.object.id + nested_object = payload.get("payload", {}).get("object", {}) + if nested_object.get("object") == "transaction": + transaction_id = nested_object.get("id") + logger.info( + f"Transaction ID extrait de payload.object.id: {transaction_id}" + ) + + # 🔍 Structure 2 : Action événements (action.opened, action.completed) + elif payload.get("type", "").startswith("action."): + # Le transaction_id est directement dans payload.object.transaction_id + transaction_id = ( + payload.get("payload", {}).get("object", {}).get("transaction_id") + ) + logger.info( + f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}" + ) + + # 🔍 Structure 3 : Transaction directe (fallback) + elif payload.get("object") == "transaction": + transaction_id = payload.get("id") + logger.info(f"Transaction ID extrait direct: {transaction_id}") + + # 🔍 Structure 4 : Ancien format (pour rétro-compatibilité) + elif "transaction" in payload: + transaction_id = payload.get("transaction", {}).get("id") + logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}") + + # Échec d'extraction + if not transaction_id: + logger.error( + f"Transaction ID introuvable dans webhook\n" + f"Type d'événement: {payload.get('type', 'unknown')}\n" + f"Clés racine: {list(payload.keys())}\n" + f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}" + ) + return { + "status": "error", + "message": "Transaction ID manquant dans webhook", + "event_type": payload.get("type"), + "event_id": payload.get("id"), + }, 400 + + logger.info(f"🎯 Transaction ID identifié: {transaction_id}") + + # Vérifier si la transaction existe localement + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + logger.warning( + f"Transaction {transaction_id} inconnue en local\n" + f"Type d'événement: {payload.get('type')}\n" + f"Elle sera synchronisée au prochain polling" + ) + return { + "status": "accepted", + "message": f"Transaction {transaction_id} non trouvée localement, sera synchronisée au prochain polling", + "transaction_id": transaction_id, + "event_type": payload.get("type"), + } + + # Traiter le webhook + success, error = await sync_service.process_webhook( + session, payload, transaction_id + ) + + if not success: + logger.error(f"Erreur traitement webhook: {error}") + return { + "status": "error", + "message": error, + "transaction_id": transaction_id, + }, 500 + + # Succès + logger.info( + f"Webhook traité avec succès\n" + f"Transaction: {transaction_id}\n" + f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n" + f"Type d'événement: {payload.get('type')}" + ) + + return { + "status": "processed", + "transaction_id": transaction_id, + "local_status": tx.local_status.value if tx else None, + "event_type": payload.get("type"), + "event_id": payload.get("id"), + } + + except Exception as e: + logger.error(f"💥 Erreur critique webhook: {e}", exc_info=True) + return {"status": "error", "message": str(e)}, 500 + + +@router.get("/stats", response_model=SyncStatsResponse) +async def get_sync_stats(session: AsyncSession = Depends(get_session)): + """Statistiques globales de synchronisation""" + + # Total + total_query = select(func.count(UniversignTransaction.id)) + total = (await session.execute(total_query)).scalar() + + # En attente de sync + pending_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.needs_sync + ) + pending = (await session.execute(pending_query)).scalar() + + # Par statut + signed_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.SIGNED + ) + signed = (await session.execute(signed_query)).scalar() + + in_progress_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.IN_PROGRESS + ) + in_progress = (await session.execute(in_progress_query)).scalar() + + refused_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.REJECTED + ) + refused = (await session.execute(refused_query)).scalar() + + expired_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.local_status == LocalDocumentStatus.EXPIRED + ) + expired = (await session.execute(expired_query)).scalar() + + # Dernière sync + last_sync_query = select(func.max(UniversignTransaction.last_synced_at)) + last_sync = (await session.execute(last_sync_query)).scalar() + + return SyncStatsResponse( + total_transactions=total, + pending_sync=pending, + signed=signed, + in_progress=in_progress, + refused=refused, + expired=expired, + last_sync_at=last_sync, + ) + + +@router.get("/transactions/{transaction_id}/logs") +async def get_transaction_logs( + transaction_id: str, + limit: int = Query(50, le=500), + session: AsyncSession = Depends(get_session), +): + # Trouver la transaction + tx_query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + tx_result = await session.execute(tx_query) + tx = tx_result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, "Transaction introuvable") + + # Logs + logs_query = ( + select(UniversignSyncLog) + .where(UniversignSyncLog.transaction_id == tx.id) + .order_by(UniversignSyncLog.sync_timestamp.desc()) + .limit(limit) + ) + + logs_result = await session.execute(logs_query) + logs = logs_result.scalars().all() + + return { + "transaction_id": transaction_id, + "total_syncs": len(logs), + "logs": [ + { + "sync_type": log.sync_type, + "timestamp": log.sync_timestamp.isoformat(), + "success": log.success, + "previous_status": log.previous_status, + "new_status": log.new_status, + "error_message": log.error_message, + "response_time_ms": log.response_time_ms, + } + for log in logs + ], + } + + +# Ajouter ces routes dans universign.py + + +@router.get("/documents/{sage_document_id}/signatures") +async def get_signatures_for_document( + sage_document_id: str, + session: AsyncSession = Depends(get_session), +): + """Liste toutes les transactions de signature pour un document Sage""" + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .where(UniversignTransaction.sage_document_id == sage_document_id) + .order_by(UniversignTransaction.created_at.desc()) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + return [ + { + "id": tx.id, + "transaction_id": tx.transaction_id, + "local_status": tx.local_status.value, + "universign_status": tx.universign_status.value + if tx.universign_status + else None, + "created_at": tx.created_at.isoformat(), + "signed_at": tx.signed_at.isoformat() if tx.signed_at else None, + "signer_url": tx.signer_url, + "signers_count": len(tx.signers), + } + for tx in transactions + ] + + +@router.delete("/documents/{sage_document_id}/duplicates") +async def cleanup_duplicate_signatures( + sage_document_id: str, + keep_latest: bool = Query( + True, description="Garder la plus récente (True) ou la plus ancienne (False)" + ), + session: AsyncSession = Depends(get_session), +): + """ + Supprime les doublons de signatures pour un document. + Garde une seule transaction (la plus récente ou ancienne selon le paramètre). + """ + query = ( + select(UniversignTransaction) + .where(UniversignTransaction.sage_document_id == sage_document_id) + .order_by( + UniversignTransaction.created_at.desc() + if keep_latest + else UniversignTransaction.created_at.asc() + ) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + if len(transactions) <= 1: + return { + "success": True, + "message": "Aucun doublon trouvé", + "kept": transactions[0].transaction_id if transactions else None, + "deleted_count": 0, + } + + # Garder la première (selon l'ordre), supprimer les autres + to_keep = transactions[0] + to_delete = transactions[1:] + + deleted_ids = [] + for tx in to_delete: + deleted_ids.append(tx.transaction_id) + await session.delete(tx) + + await session.commit() + + logger.info( + f"Nettoyage doublons {sage_document_id}: gardé {to_keep.transaction_id}, supprimé {deleted_ids}" + ) + + return { + "success": True, + "document_id": sage_document_id, + "kept": { + "id": to_keep.id, + "transaction_id": to_keep.transaction_id, + "status": to_keep.local_status.value, + "created_at": to_keep.created_at.isoformat(), + }, + "deleted_count": len(deleted_ids), + "deleted_transaction_ids": deleted_ids, + } + + +@router.delete("/transactions/{transaction_id}") +async def delete_transaction( + transaction_id: str, + session: AsyncSession = Depends(get_session), +): + """Supprime une transaction spécifique par son ID Universign""" + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + await session.delete(tx) + await session.commit() + + logger.info(f"Transaction {transaction_id} supprimée") + + return { + "success": True, + "deleted_transaction_id": transaction_id, + "document_id": tx.sage_document_id, + } + + +@router.post("/cleanup/all-duplicates") +async def cleanup_all_duplicates( + session: AsyncSession = Depends(get_session), +): + """ + Nettoie tous les doublons dans la base. + Pour chaque document avec plusieurs transactions, garde la plus récente non-erreur ou la plus récente. + """ + from sqlalchemy import func + + # Trouver les documents avec plusieurs transactions + subquery = ( + select( + UniversignTransaction.sage_document_id, + func.count(UniversignTransaction.id).label("count"), + ) + .group_by(UniversignTransaction.sage_document_id) + .having(func.count(UniversignTransaction.id) > 1) + ).subquery() + + duplicates_query = select(subquery.c.sage_document_id) + duplicates_result = await session.execute(duplicates_query) + duplicate_docs = [row[0] for row in duplicates_result.fetchall()] + + total_deleted = 0 + cleanup_details = [] + + for doc_id in duplicate_docs: + # Récupérer toutes les transactions pour ce document + tx_query = ( + select(UniversignTransaction) + .where(UniversignTransaction.sage_document_id == doc_id) + .order_by(UniversignTransaction.created_at.desc()) + ) + tx_result = await session.execute(tx_query) + transactions = tx_result.scalars().all() + + # Priorité: SIGNE > EN_COURS > EN_ATTENTE > autres + priority = {"SIGNE": 0, "EN_COURS": 1, "EN_ATTENTE": 2} + + def sort_key(tx): + status_priority = priority.get(tx.local_status.value, 99) + return (status_priority, -tx.created_at.timestamp()) + + sorted_txs = sorted(transactions, key=sort_key) + to_keep = sorted_txs[0] + to_delete = sorted_txs[1:] + + for tx in to_delete: + await session.delete(tx) + total_deleted += 1 + + cleanup_details.append( + { + "document_id": doc_id, + "kept": to_keep.transaction_id, + "kept_status": to_keep.local_status.value, + "deleted_count": len(to_delete), + } + ) + + await session.commit() + + logger.info( + f"Nettoyage global: {total_deleted} doublons supprimés sur {len(duplicate_docs)} documents" + ) + + return { + "success": True, + "documents_processed": len(duplicate_docs), + "total_deleted": total_deleted, + "details": cleanup_details, + } + + +@router.get("/admin/diagnostic", tags=["Admin"]) +async def diagnostic_complet(session: AsyncSession = Depends(get_session)): + """ + Diagnostic complet de l'état des transactions Universign + """ + try: + # Statistiques générales + total_query = select(func.count(UniversignTransaction.id)) + total = (await session.execute(total_query)).scalar() + + # Par statut local + statuts_query = select( + UniversignTransaction.local_status, func.count(UniversignTransaction.id) + ).group_by(UniversignTransaction.local_status) + statuts_result = await session.execute(statuts_query) + statuts = {status.value: count for status, count in statuts_result.all()} + + # Transactions sans sync récente + date_limite = datetime.now() - timedelta(hours=1) + sans_sync_query = select(func.count(UniversignTransaction.id)).where( + and_( + UniversignTransaction.needs_sync, + or_( + UniversignTransaction.last_synced_at < date_limite, + UniversignTransaction.last_synced_at.is_(None), + ), + ) + ) + sans_sync = (await session.execute(sans_sync_query)).scalar() + + # Doublons potentiels + doublons_query = ( + select( + UniversignTransaction.sage_document_id, + func.count(UniversignTransaction.id).label("count"), + ) + .group_by(UniversignTransaction.sage_document_id) + .having(func.count(UniversignTransaction.id) > 1) + ) + doublons_result = await session.execute(doublons_query) + doublons = doublons_result.fetchall() + + # Transactions avec erreurs de sync + erreurs_query = select(func.count(UniversignTransaction.id)).where( + UniversignTransaction.sync_error.isnot(None) + ) + erreurs = (await session.execute(erreurs_query)).scalar() + + # Transactions sans webhook reçu + sans_webhook_query = select(func.count(UniversignTransaction.id)).where( + and_( + not UniversignTransaction.webhook_received, + UniversignTransaction.local_status != LocalDocumentStatus.PENDING, + ) + ) + sans_webhook = (await session.execute(sans_webhook_query)).scalar() + + diagnostic = { + "timestamp": datetime.now().isoformat(), + "total_transactions": total, + "repartition_statuts": statuts, + "problemes_detectes": { + "sans_sync_recente": sans_sync, + "doublons_possibles": len(doublons), + "erreurs_sync": erreurs, + "sans_webhook": sans_webhook, + }, + "documents_avec_doublons": [ + {"document_id": doc_id, "nombre_transactions": count} + for doc_id, count in doublons + ], + "recommandations": [], + } + + # Recommandations + if sans_sync > 0: + diagnostic["recommandations"].append( + f"🔄 {sans_sync} transaction(s) à synchroniser. " + f"Utilisez POST /universign/sync/all" + ) + + if len(doublons) > 0: + diagnostic["recommandations"].append( + f"{len(doublons)} document(s) avec doublons. " + f"Utilisez POST /universign/cleanup/all-duplicates" + ) + + if erreurs > 0: + diagnostic["recommandations"].append( + f"{erreurs} transaction(s) en erreur. " + f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR" + ) + + return diagnostic + + except Exception as e: + logger.error(f"Erreur diagnostic: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/force-sync-all", tags=["Admin"]) +async def forcer_sync_toutes_transactions( + max_transactions: int = Query(200, le=500), + session: AsyncSession = Depends(get_session), +): + """ + Force la synchronisation de TOUTES les transactions (même finales) + À utiliser pour réparer les incohérences + """ + try: + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .order_by(UniversignTransaction.created_at.desc()) + .limit(max_transactions) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + stats = { + "total_verifie": len(transactions), + "success": 0, + "failed": 0, + "status_changes": 0, + "details": [], + } + + for transaction in transactions: + try: + previous_status = transaction.local_status.value + + logger.info( + f"🔄 Force sync: {transaction.transaction_id} (statut: {previous_status})" + ) + + success, error = await sync_service.sync_transaction( + session, transaction, force=True + ) + + new_status = transaction.local_status.value + + if success: + stats["success"] += 1 + if new_status != previous_status: + stats["status_changes"] += 1 + stats["details"].append( + { + "transaction_id": transaction.transaction_id, + "document_id": transaction.sage_document_id, + "changement": f"{previous_status} → {new_status}", + } + ) + else: + stats["failed"] += 1 + stats["details"].append( + { + "transaction_id": transaction.transaction_id, + "document_id": transaction.sage_document_id, + "erreur": error, + } + ) + + except Exception as e: + logger.error(f"Erreur sync {transaction.transaction_id}: {e}") + stats["failed"] += 1 + + return { + "success": True, + "stats": stats, + "timestamp": datetime.now().isoformat(), + } + + except Exception as e: + logger.error(f"Erreur force sync: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/repair-transaction/{transaction_id}", tags=["Admin"]) +async def reparer_transaction( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Répare une transaction spécifique en la re-synchronisant depuis Universign + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + old_status = transaction.local_status.value + old_universign_status = ( + transaction.universign_status.value + if transaction.universign_status + else None + ) + + # Force sync + success, error = await sync_service.sync_transaction( + session, transaction, force=True + ) + + if not success: + return { + "success": False, + "transaction_id": transaction_id, + "erreur": error, + "ancien_statut": old_status, + } + + return { + "success": True, + "transaction_id": transaction_id, + "reparation": { + "ancien_statut_local": old_status, + "nouveau_statut_local": transaction.local_status.value, + "ancien_statut_universign": old_universign_status, + "nouveau_statut_universign": transaction.universign_status.value, + "statut_change": old_status != transaction.local_status.value, + }, + "derniere_sync": transaction.last_synced_at.isoformat(), + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur réparation: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/admin/transactions-inconsistantes", tags=["Admin"]) +async def trouver_transactions_inconsistantes( + session: AsyncSession = Depends(get_session), +): + """ + Trouve les transactions dont le statut local ne correspond pas au statut Universign + """ + try: + # Toutes les transactions non-finales + query = select(UniversignTransaction).where( + UniversignTransaction.local_status.in_( + [LocalDocumentStatus.PENDING, LocalDocumentStatus.IN_PROGRESS] + ) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + inconsistantes = [] + + for tx in transactions: + try: + # Récupérer le statut depuis Universign + universign_data = sync_service.fetch_transaction_status( + tx.transaction_id + ) + + if not universign_data: + inconsistantes.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "probleme": "Impossible de récupérer depuis Universign", + "statut_local": tx.local_status.value, + "statut_universign": None, + } + ) + continue + + universign_status = universign_data["transaction"].get("state") + expected_local_status = map_universign_to_local(universign_status) + + if expected_local_status != tx.local_status.value: + inconsistantes.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "probleme": "Statut incohérent", + "statut_local": tx.local_status.value, + "statut_universign": universign_status, + "statut_attendu": expected_local_status, + "derniere_sync": tx.last_synced_at.isoformat() + if tx.last_synced_at + else None, + } + ) + + except Exception as e: + logger.error(f"Erreur vérification {tx.transaction_id}: {e}") + inconsistantes.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "probleme": f"Erreur: {str(e)}", + "statut_local": tx.local_status.value, + } + ) + + return { + "total_verifie": len(transactions), + "inconsistantes": len(inconsistantes), + "details": inconsistantes, + "recommandation": ( + "Utilisez POST /universign/admin/force-sync-all pour corriger" + if inconsistantes + else "Aucune incohérence détectée" + ), + } + + except Exception as e: + logger.error(f"Erreur recherche incohérences: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/nettoyer-transactions-erreur", tags=["Admin"]) +async def nettoyer_transactions_erreur( + age_jours: int = Query( + 7, description="Supprimer les transactions en erreur de plus de X jours" + ), + session: AsyncSession = Depends(get_session), +): + """ + Nettoie les transactions en erreur anciennes + """ + try: + date_limite = datetime.now() - timedelta(days=age_jours) + + query = select(UniversignTransaction).where( + and_( + UniversignTransaction.local_status == LocalDocumentStatus.ERROR, + UniversignTransaction.created_at < date_limite, + ) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + supprimees = [] + for tx in transactions: + supprimees.append( + { + "transaction_id": tx.transaction_id, + "document_id": tx.sage_document_id, + "date_creation": tx.created_at.isoformat(), + "erreur": tx.sync_error, + } + ) + await session.delete(tx) + + await session.commit() + + return { + "success": True, + "transactions_supprimees": len(supprimees), + "age_limite_jours": age_jours, + "details": supprimees, + } + + except Exception as e: + logger.error(f"Erreur nettoyage: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/debug/webhook-payload/{transaction_id}", tags=["Debug"]) +async def voir_dernier_webhook( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Affiche le dernier payload webhook reçu pour une transaction + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + tx = result.scalar_one_or_none() + + if not tx: + raise HTTPException(404, "Transaction introuvable") + + # Récupérer le dernier log de type webhook + logs_query = ( + select(UniversignSyncLog) + .where( + and_( + UniversignSyncLog.transaction_id == tx.id, + UniversignSyncLog.sync_type.like("webhook:%"), + ) + ) + .order_by(UniversignSyncLog.sync_timestamp.desc()) + .limit(1) + ) + + logs_result = await session.execute(logs_query) + last_webhook_log = logs_result.scalar_one_or_none() + + if not last_webhook_log: + return { + "transaction_id": transaction_id, + "webhook_recu": tx.webhook_received, + "dernier_payload": None, + "message": "Aucun webhook reçu pour cette transaction", + } + + return { + "transaction_id": transaction_id, + "webhook_recu": tx.webhook_received, + "dernier_webhook": { + "timestamp": last_webhook_log.sync_timestamp.isoformat(), + "type": last_webhook_log.sync_type, + "success": last_webhook_log.success, + "payload": json.loads(last_webhook_log.changes_detected) + if last_webhook_log.changes_detected + else None, + }, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur debug webhook: {e}") + raise HTTPException(500, str(e)) + + +@router.get( + "/transactions/{transaction_id}/document/download", tags=["Documents Signés"] +) +async def telecharger_document_signe( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Télécharge le document signé localement stocké + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + if not transaction.signed_document_path: + raise HTTPException( + 404, + "Document signé non disponible localement. " + "Utilisez POST /admin/download-missing-documents pour le récupérer.", + ) + + file_path = Path(transaction.signed_document_path) + + if not file_path.exists(): + # Document perdu, on peut tenter de le retélécharger + logger.warning(f"Fichier perdu : {file_path}") + raise HTTPException( + 404, + "Fichier introuvable sur le serveur. " + "Utilisez POST /admin/download-missing-documents pour le récupérer.", + ) + + # Génération du nom de fichier pour le téléchargement + download_name = ( + f"{transaction.sage_document_id}_" + f"{transaction.sage_document_type.name}_" + f"signe.pdf" + ) + + return FileResponse( + path=str(file_path), media_type="application/pdf", filename=download_name + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur téléchargement document : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.get("/transactions/{transaction_id}/document/info", tags=["Documents Signés"]) +async def info_document_signe( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Informations sur le document signé + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + file_exists = False + file_size_mb = None + + if transaction.signed_document_path: + file_path = Path(transaction.signed_document_path) + file_exists = file_path.exists() + + if file_exists: + file_size_mb = os.path.getsize(file_path) / (1024 * 1024) + + return { + "transaction_id": transaction_id, + "document_available_locally": file_exists, + "document_url_universign": transaction.document_url, + "downloaded_at": ( + transaction.signed_document_downloaded_at.isoformat() + if transaction.signed_document_downloaded_at + else None + ), + "file_size_mb": round(file_size_mb, 2) if file_size_mb else None, + "download_attempts": transaction.download_attempts, + "last_download_error": transaction.download_error, + "local_path": transaction.signed_document_path if file_exists else None, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur info document : {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/download-missing-documents", tags=["Admin"]) +async def telecharger_documents_manquants( + force_redownload: bool = Query( + False, description="Forcer le retéléchargement même si déjà présent" + ), + session: AsyncSession = Depends(get_session), +): + """ + Télécharge tous les documents signés manquants pour les transactions SIGNE + """ + try: + # Transactions signées sans document local + query = select(UniversignTransaction).where( + UniversignTransaction.local_status == LocalDocumentStatus.SIGNED, + or_( + UniversignTransaction.signed_document_path.is_(None), + force_redownload, + ), + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + logger.info(f"📥 {len(transactions)} document(s) à télécharger") + + document_service = UniversignDocumentService( + api_key=settings.universign_api_key, timeout=60 + ) + + results = {"total": len(transactions), "success": 0, "failed": 0, "details": []} + + for transaction in transactions: + try: + ( + success, + error, + ) = await document_service.download_and_store_signed_document( + session=session, transaction=transaction, force=force_redownload + ) + + if success: + results["success"] += 1 + results["details"].append( + { + "transaction_id": transaction.transaction_id, + "sage_document_id": transaction.sage_document_id, + "status": "success", + } + ) + else: + results["failed"] += 1 + results["details"].append( + { + "transaction_id": transaction.transaction_id, + "sage_document_id": transaction.sage_document_id, + "status": "failed", + "error": error, + } + ) + + except Exception as e: + logger.error(f"Erreur téléchargement {transaction.transaction_id}: {e}") + results["failed"] += 1 + results["details"].append( + {"transaction_id": transaction.transaction_id, "error": str(e)} + ) + + await session.commit() + + logger.info( + f"Téléchargement terminé : {results['success']}/{results['total']} réussis" + ) + + return results + + except Exception as e: + logger.error(f"Erreur téléchargement batch : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.post("/admin/cleanup-old-documents", tags=["Admin"]) +async def nettoyer_anciens_documents( + days_to_keep: int = Query( + 90, ge=7, le=365, description="Nombre de jours à conserver" + ), +): + """ + Supprime les documents signés de plus de X jours (par défaut 90) + """ + try: + document_service = UniversignDocumentService( + api_key=settings.universign_api_key + ) + + deleted, size_freed_mb = await document_service.cleanup_old_documents( + days_to_keep=days_to_keep + ) + + return { + "success": True, + "files_deleted": deleted, + "space_freed_mb": size_freed_mb, + "days_kept": days_to_keep, + } + + except Exception as e: + logger.error(f"Erreur nettoyage : {e}") + raise HTTPException(500, str(e)) diff --git a/sage_client.py b/sage_client.py index a0b92fe..8caef65 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,99 +1,444 @@ +# sage_client.py import requests from typing import Dict, List, Optional -from config import settings +from config.config import settings import logging logger = logging.getLogger(__name__) + class SageGatewayClient: - """ - Client HTTP pour communiquer avec la gateway Sage Windows - """ - - def __init__(self): - self.url = settings.sage_gateway_url.rstrip("/") + def __init__( + self, + gateway_url: Optional[str] = None, + gateway_token: Optional[str] = None, + gateway_id: Optional[str] = None, + ): + self.url = (gateway_url or settings.sage_gateway_url).rstrip("/") + self.token = gateway_token or settings.sage_gateway_token + self.gateway_id = gateway_id + self.headers = { - "X-Sage-Token": settings.sage_gateway_token, - "Content-Type": "application/json" + "X-Sage-Token": self.token, + "Content-Type": "application/json", } self.timeout = 30 + @classmethod + def from_context( + cls, url: str, token: str, gateway_id: Optional[str] = None + ) -> "SageGatewayClient": + return cls(gateway_url=url, gateway_token=token, gateway_id=gateway_id) + def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict: - """POST avec retry automatique""" import time - + for attempt in range(retries): try: r = requests.post( f"{self.url}{endpoint}", json=data or {}, headers=self.headers, - timeout=self.timeout + timeout=self.timeout, ) r.raise_for_status() return r.json() except requests.exceptions.RequestException as e: if attempt == retries - 1: - logger.error(f"❌ Échec après {retries} tentatives sur {endpoint}: {e}") + logger.error( + f"Échec après {retries} tentatives sur {endpoint}: {e}" + ) raise - time.sleep(2 ** attempt) # Backoff exponentiel + time.sleep(2**attempt) + + def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict: + import time + + for attempt in range(retries): + try: + r = requests.get( + f"{self.url}{endpoint}", + params=params or {}, + headers=self.headers, + timeout=self.timeout, + ) + r.raise_for_status() + return r.json() + except requests.exceptions.RequestException as e: + if attempt == retries - 1: + logger.error( + f"Échec GET après {retries} tentatives sur {endpoint}: {e}" + ) + raise + time.sleep(2**attempt) - # === CLIENTS === def lister_clients(self, filtre: str = "") -> List[Dict]: return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) def lire_client(self, code: str) -> Optional[Dict]: return self._post("/sage/clients/get", {"code": code}).get("data") - # === ARTICLES === + def creer_client(self, client_data: Dict) -> Dict: + return self._post("/sage/clients/create", client_data).get("data", {}) + + def modifier_client(self, code: str, client_data: Dict) -> Dict: + return self._post( + "/sage/clients/update", {"code": code, "client_data": client_data} + ).get("data", {}) + def lister_articles(self, filtre: str = "") -> List[Dict]: return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) def lire_article(self, ref: str) -> Optional[Dict]: return self._post("/sage/articles/get", {"code": ref}).get("data") - # === DEVIS === + def creer_article(self, article_data: Dict) -> Dict: + return self._post("/sage/articles/create", article_data).get("data", {}) + + def modifier_article(self, reference: str, article_data: Dict) -> Dict: + return self._post( + "/sage/articles/update", + {"reference": reference, "article_data": article_data}, + ).get("data", {}) + def creer_devis(self, devis_data: Dict) -> Dict: return self._post("/sage/devis/create", devis_data).get("data", {}) def lire_devis(self, numero: str) -> Optional[Dict]: return self._post("/sage/devis/get", {"code": numero}).get("data") - # === DOCUMENTS === + def lister_devis( + self, + limit: int = 100, + statut: Optional[int] = None, + inclure_lignes: bool = True, + ) -> List[Dict]: + payload = {"limit": limit, "inclure_lignes": inclure_lignes} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/devis/list", payload).get("data", []) + + def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: + return self._post( + "/sage/devis/update", {"numero": numero, "devis_data": devis_data} + ).get("data", {}) + + def lister_commandes( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/commandes/list", payload).get("data", []) + + def creer_commande(self, commande_data: Dict) -> Dict: + return self._post("/sage/commandes/create", commande_data).get("data", {}) + + def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: + return self._post( + "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} + ).get("data", {}) + + def lister_factures( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/factures/list", payload).get("data", []) + + def creer_facture(self, facture_data: Dict) -> Dict: + return self._post("/sage/factures/create", facture_data).get("data", {}) + + def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: + return self._post( + "/sage/factures/update", {"numero": numero, "facture_data": facture_data} + ).get("data", {}) + + def lister_livraisons( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/livraisons/list", payload).get("data", []) + + def creer_livraison(self, livraison_data: Dict) -> Dict: + return self._post("/sage/livraisons/create", livraison_data).get("data", {}) + + def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: + return self._post( + "/sage/livraisons/update", + {"numero": numero, "livraison_data": livraison_data}, + ).get("data", {}) + + def lister_avoirs( + self, limit: int = 100, statut: Optional[int] = None + ) -> List[Dict]: + payload = {"limit": limit} + if statut is not None: + payload["statut"] = statut + return self._post("/sage/avoirs/list", payload).get("data", []) + + def creer_avoir(self, avoir_data: Dict) -> Dict: + return self._post("/sage/avoirs/create", avoir_data).get("data", {}) + + def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: + return self._post( + "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} + ).get("data", {}) + def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: - return self._post("/sage/documents/get", {"numero": numero, "type_doc": type_doc}).get("data") + return self._post( + "/sage/documents/get", {"numero": numero, "type_doc": type_doc} + ).get("data") - def transformer_document(self, numero_source: str, type_source: int, type_cible: int) -> Dict: - return self._post("/sage/documents/transform", { - "numero_source": numero_source, - "type_source": type_source, - "type_cible": type_cible - }).get("data", {}) + def changer_statut_document( + self, document_type_code: int, numero: str, nouveau_statut: int + ) -> Dict: + try: + r = requests.post( + f"{self.url}/sage/document/statut", + params={ + "numero": numero, + "type_doc": document_type_code, + "nouveau_statut": nouveau_statut, + }, + headers=self.headers, + timeout=self.timeout, + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"Erreur changement statut: {e}") + raise - def mettre_a_jour_champ_libre(self, doc_id: str, type_doc: int, nom_champ: str, valeur: str) -> bool: - resp = self._post("/sage/documents/champ-libre", { - "doc_id": doc_id, - "type_doc": type_doc, - "nom_champ": nom_champ, - "valeur": valeur - }) + def transformer_document( + self, numero_source: str, type_source: int, type_cible: int + ) -> Dict: + try: + r = requests.post( + f"{self.url}/sage/documents/transform", + params={ + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible, + }, + headers=self.headers, + timeout=60, + ) + r.raise_for_status() + return r.json().get("data", {}) + except requests.exceptions.RequestException as e: + logger.error(f"Erreur transformation: {e}") + raise + + def mettre_a_jour_champ_libre( + self, doc_id: str, type_doc: int, nom_champ: str, valeur: str + ) -> bool: + resp = self._post( + "/sage/documents/champ-libre", + { + "doc_id": doc_id, + "type_doc": type_doc, + "nom_champ": nom_champ, + "valeur": valeur, + }, + ) return resp.get("success", False) - # === CONTACTS === + def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool: + resp = self._post( + "/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc} + ) + return resp.get("success", False) + + def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: + try: + logger.info(f"Demande génération PDF: doc_id={doc_id}, type={type_doc}") + + 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() + + if not response_data.get("success"): + error_msg = response_data.get("error", "Erreur inconnue") + raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") + + pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") + + if not pdf_base64: + raise ValueError( + f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" + ) + + pdf_bytes = base64.b64decode(pdf_base64) + + logger.info(f"PDF décodé: {len(pdf_bytes)} octets") + + return pdf_bytes + + except requests.exceptions.Timeout: + logger.error(f"Timeout génération PDF pour {doc_id}") + raise RuntimeError( + f"Timeout lors de la génération du PDF (>60s). " + f"Le document {doc_id} est peut-être trop volumineux." + ) + + except requests.exceptions.RequestException as e: + logger.error(f"Erreur HTTP génération PDF: {e}") + raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") + + except Exception as e: + logger.error(f"Erreur génération PDF: {e}", exc_info=True) + raise + + def lister_prospects(self, filtre: str = "") -> List[Dict]: + return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) + + def lire_prospect(self, code: str) -> Optional[Dict]: + return self._post("/sage/prospects/get", {"code": code}).get("data") + + def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: + return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) + + def lire_fournisseur(self, code: str) -> Optional[Dict]: + return self._post("/sage/fournisseurs/get", {"code": code}).get("data") + + def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: + return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) + + def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: + return self._post( + "/sage/fournisseurs/update", + {"code": code, "fournisseur_data": fournisseur_data}, + ).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]: + return self._post("/sage/tiers/get", {"code": code}).get("data") + def lire_contact_client(self, code_client: str) -> Optional[Dict]: return self._post("/sage/contact/read", {"code": code_client}).get("data") - # === CACHE === + def creer_contact(self, contact_data: Dict) -> Dict: + return self._post("/sage/contacts/create", contact_data) + + def lister_contacts(self, numero: str) -> List[Dict]: + return self._post("/sage/contacts/list", {"numero": numero}).get("data", []) + + def obtenir_contact(self, numero: str, contact_numero: int) -> Dict: + result = self._post( + "/sage/contacts/get", {"numero": numero, "contact_numero": contact_numero} + ) + return result.get("data") if result.get("success") else None + + def modifier_contact(self, numero: str, contact_numero: int, updates: Dict) -> Dict: + return self._post( + "/sage/contacts/update", + {"numero": numero, "contact_numero": contact_numero, "updates": updates}, + ) + + def supprimer_contact(self, numero: str, contact_numero: int) -> Dict: + return self._post( + "/sage/contacts/delete", + {"numero": numero, "contact_numero": contact_numero}, + ) + + def definir_contact_defaut(self, numero: str, contact_numero: int) -> Dict: + return self._post( + "/sage/contacts/set-default", + {"numero": numero, "contact_numero": contact_numero}, + ) + + def lister_familles(self, filtre: str = "") -> List[Dict]: + return self._get("/sage/familles", params={"filtre": filtre}).get("data", []) + + def lire_famille(self, code: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/familles/{code}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture famille {code}: {e}") + return None + + def creer_famille(self, famille_data: Dict) -> Dict: + return self._post("/sage/familles/create", famille_data).get("data", {}) + + def get_stats_familles(self) -> Dict: + return self._get("/sage/familles/stats").get("data", {}) + + def creer_entree_stock(self, entree_data: Dict) -> Dict: + return self._post("/sage/stock/entree", entree_data).get("data", {}) + + def creer_sortie_stock(self, sortie_data: Dict) -> Dict: + return self._post("/sage/stock/sortie", sortie_data).get("data", {}) + + def lire_mouvement_stock(self, numero: str) -> Optional[Dict]: + try: + response = self._get(f"/sage/stock/mouvement/{numero}") + return response.get("data") + except Exception as e: + logger.error(f"Erreur lecture mouvement {numero}: {e}") + return None + + def lire_remise_max_client(self, code_client: str) -> float: + result = self._post("/sage/client/remise-max", {"code": code_client}) + return result.get("data", {}).get("remise_max", 10.0) + + def lister_collaborateurs( + self, filtre: Optional[str] = None, actifs_seulement: bool = True + ) -> List[Dict]: + """Liste tous les collaborateurs""" + return self._post( + "/sage/collaborateurs/list", + { + "filtre": filtre or "", # Convertir None en "" + "actifs_seulement": actifs_seulement, + }, + ).get("data", []) + + def lire_collaborateur(self, numero: int) -> Optional[Dict]: + """Lit un collaborateur par numéro""" + return self._post("/sage/collaborateurs/get", {"numero": numero}).get("data") + + def creer_collaborateur(self, data: Dict) -> Optional[Dict]: + """Crée un nouveau collaborateur""" + return self._post("/sage/collaborateurs/create", data).get("data") + + def modifier_collaborateur(self, numero: int, data: Dict) -> Optional[Dict]: + """Modifie un collaborateur existant""" + return self._post( + "/sage/collaborateurs/update", {"numero": numero, **data} + ).get("data") + def refresh_cache(self) -> Dict: return self._post("/sage/cache/refresh") - # === HEALTH === + def get_cache_info(self) -> Dict: + return self._get("/sage/cache/info").get("data", {}) + def health(self) -> dict: try: r = requests.get(f"{self.url}/health", timeout=5) return r.json() - except: + except Exception: return {"status": "down"} -# Instance globale -sage_client = SageGatewayClient() \ No newline at end of file + +sage_client = SageGatewayClient() diff --git a/schemas/__init__.py b/schemas/__init__.py new file mode 100644 index 0000000..ececf1d --- /dev/null +++ b/schemas/__init__.py @@ -0,0 +1,108 @@ +from schemas.tiers.tiers import TiersDetails, TypeTiersInt +from schemas.tiers.type_tiers import TypeTiers +from schemas.schema_mixte import BaremeRemiseResponse +from schemas.user import Users +from schemas.tiers.clients import ( + ClientCreate, + ClientDetails, + ClientResponse, + ClientUpdate, +) +from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate +from schemas.tiers.fournisseurs import ( + FournisseurCreate, + FournisseurDetails, + FournisseurUpdate, +) +from schemas.documents.avoirs import AvoirCreate, AvoirUpdate +from schemas.documents.commandes import CommandeCreate, CommandeUpdate +from schemas.documents.devis import ( + DevisRequest, + Devis, + DevisUpdate, + RelanceDevis, +) +from schemas.documents.documents import TypeDocument, TypeDocumentSQL +from schemas.documents.email import StatutEmail, EmailEnvoi +from schemas.documents.factures import FactureCreate, FactureUpdate +from schemas.documents.livraisons import LivraisonCreate, LivraisonUpdate +from schemas.documents.universign import Signature, StatutSignature +from schemas.articles.articles import ( + ArticleCreate, + Article, + ArticleUpdate, + ArticleList, + EntreeStock, + SortieStock, + MouvementStock, +) +from schemas.articles.famille_article import ( + Familles, + FamilleCreate, + FamilleList, +) + +from schemas.sage.sage_gateway import ( + SageGatewayCreate, + SageGatewayUpdate, + SageGatewayResponse, + SageGatewayList, + SageGatewayHealthCheck, + SageGatewayTest, + SageGatewayStatsResponse, + CurrentGatewayInfo, +) + +__all__ = [ + "TiersDetails", + "TypeTiers", + "BaremeRemiseResponse", + "Users", + "ClientCreate", + "ClientDetails", + "ClientResponse", + "ClientUpdate", + "FournisseurCreate", + "FournisseurDetails", + "FournisseurUpdate", + "Contact", + "AvoirCreate", + "AvoirUpdate", + "CommandeCreate", + "CommandeUpdate", + "DevisRequest", + "Devis", + "DevisUpdate", + "TypeDocument", + "TypeDocumentSQL", + "StatutEmail", + "EmailEnvoi", + "FactureCreate", + "FactureUpdate", + "LivraisonCreate", + "LivraisonUpdate", + "Signature", + "StatutSignature", + "TypeTiersInt", + "ArticleCreate", + "Article", + "ArticleUpdate", + "ArticleList", + "EntreeStock", + "SortieStock", + "MouvementStock", + "RelanceDevis", + "Familles", + "FamilleCreate", + "FamilleList", + "ContactCreate", + "ContactUpdate", + "SageGatewayCreate", + "SageGatewayUpdate", + "SageGatewayResponse", + "SageGatewayList", + "SageGatewayHealthCheck", + "SageGatewayTest", + "SageGatewayStatsResponse", + "CurrentGatewayInfo", +] diff --git a/schemas/articles/articles.py b/schemas/articles/articles.py new file mode 100644 index 0000000..79b2d62 --- /dev/null +++ b/schemas/articles/articles.py @@ -0,0 +1,650 @@ +from pydantic import BaseModel, Field, validator, field_validator +from typing import List, Optional +from datetime import date + +from utils import ( + NomenclatureType, + SuiviStockType, + TypeArticle, + normalize_enum_to_int, + normalize_string_field, +) + + +class Article(BaseModel): + """Article complet avec tous les enrichissements disponibles""" + + reference: str = Field(..., description="Référence article (AR_Ref)") + designation: str = Field(..., description="Désignation principale (AR_Design)") + + code_ean: Optional[str] = Field( + None, description="Code EAN / Code-barres principal (AR_CodeBarre)" + ) + code_barre: Optional[str] = Field( + None, description="Code-barres (alias de code_ean)" + ) + edi_code: Optional[str] = Field(None, description="Code EDI (AR_EdiCode)") + raccourci: Optional[str] = Field(None, description="Code raccourci (AR_Raccourci)") + + prix_vente: float = Field(..., description="Prix de vente HT unitaire (AR_PrixVen)") + prix_achat: Optional[float] = Field( + None, description="Prix d'achat HT (AR_PrixAch)" + ) + coef: Optional[float] = Field( + None, description="Coefficient multiplicateur (AR_Coef)" + ) + prix_net: Optional[float] = Field(None, description="Prix unitaire net (AR_PUNet)") + + prix_achat_nouveau: Optional[float] = Field( + None, description="Nouveau prix d'achat à venir (AR_PrixAchNouv)" + ) + coef_nouveau: Optional[float] = Field( + None, description="Nouveau coefficient à venir (AR_CoefNouv)" + ) + prix_vente_nouveau: Optional[float] = Field( + None, description="Nouveau prix de vente à venir (AR_PrixVenNouv)" + ) + date_application_prix: Optional[str] = Field( + None, description="Date d'application des nouveaux prix (AR_DateApplication)" + ) + + cout_standard: Optional[float] = Field( + None, description="Coût standard (AR_CoutStd)" + ) + + stock_reel: float = Field( + default=0.0, description="Stock réel total (F_ARTSTOCK.AS_QteSto)" + ) + stock_mini: Optional[float] = Field( + None, description="Stock minimum (F_ARTSTOCK.AS_QteMini)" + ) + stock_maxi: Optional[float] = Field( + None, description="Stock maximum (F_ARTSTOCK.AS_QteMaxi)" + ) + stock_reserve: Optional[float] = Field( + None, description="Stock réservé / en commande client (F_ARTSTOCK.AS_QteRes)" + ) + stock_commande: Optional[float] = Field( + None, description="Stock en commande fournisseur (F_ARTSTOCK.AS_QteCom)" + ) + stock_disponible: Optional[float] = Field( + None, description="Stock disponible = réel - réservé" + ) + + emplacements: List[dict] = Field( + default_factory=list, description="Détail du stock par emplacement" + ) + nb_emplacements: int = Field(0, description="Nombre d'emplacements") + + # Champs énumérés normalisés + suivi_stock: Optional[int] = Field( + None, + description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé", + ) + suivi_stock_libelle: Optional[str] = Field( + None, description="Libellé du type de suivi de stock" + ) + + nomenclature: Optional[int] = Field( + None, + description="Type de nomenclature (AR_Nomencl): 0=Non, 1=Fabrication, 2=Commerciale", + ) + nomenclature_libelle: Optional[str] = Field( + None, description="Libellé du type de nomenclature" + ) + + qte_composant: Optional[float] = Field( + None, description="Quantité de composant (AR_QteComp)" + ) + qte_operatoire: Optional[float] = Field( + None, description="Quantité opératoire (AR_QteOperatoire)" + ) + + unite_vente: Optional[str] = Field( + None, max_length=10, description="Unité de vente (AR_UniteVen)" + ) + unite_poids: Optional[str] = Field( + None, max_length=10, description="Unité de poids (AR_UnitePoids)" + ) + poids_net: Optional[float] = Field( + None, description="Poids net unitaire en kg (AR_PoidsNet)" + ) + poids_brut: Optional[float] = Field( + None, description="Poids brut unitaire en kg (AR_PoidsBrut)" + ) + + gamme_1: Optional[str] = Field(None, description="Énumération gamme 1 (AR_Gamme1)") + gamme_2: Optional[str] = Field(None, description="Énumération gamme 2 (AR_Gamme2)") + + gammes: List[dict] = Field(default_factory=list, description="Détail des gammes") + nb_gammes: int = Field(0, description="Nombre de gammes") + + tarifs_clients: List[dict] = Field( + default_factory=list, description="Tarifs spécifiques par client/catégorie" + ) + nb_tarifs_clients: int = Field(0, description="Nombre de tarifs clients") + + composants: List[dict] = Field( + default_factory=list, description="Composants/Opérations de production" + ) + nb_composants: int = Field(0, description="Nombre de composants") + + compta_vente: List[dict] = Field( + default_factory=list, description="Comptabilité vente" + ) + compta_achat: List[dict] = Field( + default_factory=list, description="Comptabilité achat" + ) + compta_stock: List[dict] = Field( + default_factory=list, description="Comptabilité stock" + ) + + fournisseurs: List[dict] = Field( + default_factory=list, description="Tous les fournisseurs de l'article" + ) + nb_fournisseurs: int = Field(0, description="Nombre de fournisseurs") + + refs_enumerees: List[dict] = Field( + default_factory=list, description="Références énumérées" + ) + nb_refs_enumerees: int = Field(0, description="Nombre de références énumérées") + + medias: List[dict] = Field(default_factory=list, description="Médias attachés") + nb_medias: int = Field(0, description="Nombre de médias") + + prix_gammes: List[dict] = Field( + default_factory=list, description="Prix par combinaison de gammes" + ) + nb_prix_gammes: int = Field(0, description="Nombre de prix par gammes") + + type_article: Optional[int] = Field( + None, + ge=0, + le=3, + description="Type : 0=Article, 1=Prestation, 2=Divers, 3=Nomenclature (AR_Type)", + ) + type_article_libelle: Optional[str] = Field( + None, description="Libellé du type d'article" + ) + + famille_code: Optional[str] = Field( + None, max_length=20, description="Code famille (FA_CodeFamille)" + ) + famille_libelle: Optional[str] = Field(None, description="Libellé de la famille") + famille_type: Optional[int] = Field( + None, description="Type de famille : 0=Détail, 1=Total" + ) + famille_unite_vente: Optional[str] = Field( + None, description="Unité de vente de la famille" + ) + famille_coef: Optional[float] = Field(None, description="Coefficient de la famille") + famille_suivi_stock: Optional[bool] = Field( + None, description="Suivi stock de la famille" + ) + famille_garantie: Optional[int] = Field(None, description="Garantie de la famille") + famille_unite_poids: Optional[str] = Field( + None, description="Unité de poids de la famille" + ) + famille_delai: Optional[int] = Field(None, description="Délai de la famille") + famille_nb_colis: Optional[int] = Field( + None, description="Nombre de colis de la famille" + ) + famille_code_fiscal: Optional[str] = Field( + None, description="Code fiscal de la famille" + ) + famille_escompte: Optional[bool] = Field(None, description="Escompte de la famille") + famille_centrale: Optional[bool] = Field(None, description="Famille centrale") + famille_nature: Optional[int] = Field(None, description="Nature de la famille") + famille_hors_stat: Optional[bool] = Field( + None, description="Hors statistique famille" + ) + famille_pays: Optional[str] = Field(None, description="Pays de la famille") + + nature: Optional[int] = Field(None, description="Nature de l'article (AR_Nature)") + garantie: Optional[int] = Field( + None, description="Durée de garantie en mois (AR_Garantie)" + ) + code_fiscal: Optional[str] = Field( + None, max_length=10, description="Code fiscal/TVA (AR_CodeFiscal)" + ) + pays: Optional[str] = Field(None, description="Pays d'origine (AR_Pays)") + + fournisseur_principal: Optional[int] = Field( + None, description="N° compte du fournisseur principal" + ) + fournisseur_nom: Optional[str] = Field( + None, description="Nom du fournisseur principal" + ) + + conditionnement: Optional[str] = Field( + None, description="Conditionnement d'achat (AR_Condition)" + ) + conditionnement_qte: Optional[float] = Field( + None, description="Quantité conditionnement" + ) + conditionnement_edi: Optional[str] = Field( + None, description="Code EDI conditionnement" + ) + + nb_colis: Optional[int] = Field( + None, description="Nombre de colis par unité (AR_NbColis)" + ) + prevision: Optional[bool] = Field( + None, description="Gestion en prévision (AR_Prevision)" + ) + + est_actif: bool = Field(default=True, description="Article actif (AR_Sommeil = 0)") + en_sommeil: bool = Field( + default=False, description="Article en sommeil (AR_Sommeil = 1)" + ) + article_substitut: Optional[str] = Field( + None, description="Référence article de substitution (AR_Substitut)" + ) + soumis_escompte: Optional[bool] = Field( + None, description="Soumis à escompte (AR_Escompte)" + ) + delai: Optional[int] = Field( + None, description="Délai de livraison en jours (AR_Delai)" + ) + + publie: Optional[bool] = Field( + None, description="Publié sur web/catalogue (AR_Publie)" + ) + hors_statistique: Optional[bool] = Field( + None, description="Exclus des statistiques (AR_HorsStat)" + ) + vente_debit: Optional[bool] = Field( + None, description="Vente au débit (AR_VteDebit)" + ) + non_imprimable: Optional[bool] = Field( + None, description="Non imprimable sur documents (AR_NotImp)" + ) + transfere: Optional[bool] = Field( + None, description="Article transféré (AR_Transfere)" + ) + contremarque: Optional[bool] = Field( + None, description="Article en contremarque (AR_Contremarque)" + ) + fact_poids: Optional[bool] = Field( + None, description="Facturation au poids (AR_FactPoids)" + ) + fact_forfait: Optional[bool] = Field( + None, description="Facturation au forfait (AR_FactForfait)" + ) + saisie_variable: Optional[bool] = Field( + None, description="Saisie variable (AR_SaisieVar)" + ) + fictif: Optional[bool] = Field(None, description="Article fictif (AR_Fictif)") + sous_traitance: Optional[bool] = Field( + None, description="Article en sous-traitance (AR_SousTraitance)" + ) + criticite: Optional[int] = Field( + None, description="Niveau de criticité (AR_Criticite)" + ) + + reprise_code_defaut: Optional[str] = Field( + None, description="Code reprise par défaut (RP_CodeDefaut)" + ) + delai_fabrication: Optional[int] = Field( + None, description="Délai de fabrication (AR_DelaiFabrication)" + ) + delai_peremption: Optional[int] = Field( + None, description="Délai de péremption (AR_DelaiPeremption)" + ) + delai_securite: Optional[int] = Field( + None, description="Délai de sécurité (AR_DelaiSecurite)" + ) + type_lancement: Optional[int] = Field( + None, description="Type de lancement production (AR_TypeLancement)" + ) + cycle: Optional[int] = Field(None, description="Cycle de production (AR_Cycle)") + + photo: Optional[str] = Field( + None, description="Chemin/nom du fichier photo (AR_Photo)" + ) + langue_1: Optional[str] = Field(None, description="Texte en langue 1 (AR_Langue1)") + langue_2: Optional[str] = Field(None, description="Texte en langue 2 (AR_Langue2)") + + frais_01_denomination: Optional[str] = Field( + None, description="Dénomination frais 1" + ) + frais_02_denomination: Optional[str] = Field( + None, description="Dénomination frais 2" + ) + frais_03_denomination: Optional[str] = Field( + None, description="Dénomination frais 3" + ) + + tva_code: Optional[str] = Field(None, description="Code TVA (F_TAXE.TA_Code)") + tva_taux: Optional[float] = Field( + None, description="Taux de TVA en % (F_TAXE.TA_Taux)" + ) + + stat_01: Optional[str] = Field(None, description="Statistique 1 (AR_Stat01)") + stat_02: Optional[str] = Field(None, description="Statistique 2 (AR_Stat02)") + stat_03: Optional[str] = Field(None, description="Statistique 3 (AR_Stat03)") + stat_04: Optional[str] = Field(None, description="Statistique 4 (AR_Stat04)") + stat_05: Optional[str] = Field(None, description="Statistique 5 (AR_Stat05)") + + categorie_1: Optional[int] = Field( + None, description="Catégorie comptable 1 (CL_No1)" + ) + categorie_2: Optional[int] = Field( + None, description="Catégorie comptable 2 (CL_No2)" + ) + categorie_3: Optional[int] = Field( + None, description="Catégorie comptable 3 (CL_No3)" + ) + categorie_4: Optional[int] = Field( + None, description="Catégorie comptable 4 (CL_No4)" + ) + + date_modification: Optional[str] = Field( + None, description="Date de dernière modification (AR_DateModif)" + ) + + marque_commerciale: Optional[str] = Field(None, description="Marque commerciale") + objectif_qtes_vendues: Optional[str] = Field( + None, description="Objectif / Quantités vendues" + ) + pourcentage_or: Optional[str] = Field(None, description="Pourcentage teneur en or") + premiere_commercialisation: Optional[str] = Field( + None, description="Date de 1ère commercialisation" + ) + interdire_commande: Optional[bool] = Field( + None, description="Interdire la commande" + ) + exclure: Optional[bool] = Field(None, description="Exclure de certains traitements") + + @field_validator("fournisseur_principal", mode="before") + @classmethod + def convert_fournisseur_principal(cls, v): + if v in (None, "", " ", " "): + return None + if isinstance(v, str): + v = v.strip() + if not v: + return None + try: + return int(v) + except (ValueError, TypeError): + return None + return v + + @field_validator( + "unite_vente", + "unite_poids", + "gamme_1", + "gamme_2", + "conditionnement", + "code_fiscal", + "pays", + "article_substitut", + "reprise_code_defaut", + mode="before", + ) + @classmethod + def convert_string_fields(cls, v): + """Convertit les champs string qui peuvent venir comme int depuis la DB""" + return normalize_string_field(v) + + @field_validator("suivi_stock", "nomenclature", mode="before") + @classmethod + def convert_enum_fields(cls, v): + """Convertit les champs énumérés en int""" + return normalize_enum_to_int(v) + + def model_post_init(self, __context): + """Génère automatiquement les libellés après l'initialisation""" + if self.suivi_stock is not None: + self.suivi_stock_libelle = SuiviStockType.get_label(self.suivi_stock) + + if self.nomenclature is not None: + self.nomenclature_libelle = NomenclatureType.get_label(self.nomenclature) + + if self.type_article is not None: + self.type_article_libelle = TypeArticle.get_label(self.type_article) + + class Config: + json_schema_extra = { + "example": { + "reference": "BAGUE-001", + "designation": "Bague Or 18K Diamant", + "prix_vente": 1299.00, + "stock_reel": 15.0, + "suivi_stock": 1, + "suivi_stock_libelle": "CMUP", + "nomenclature": 0, + "nomenclature_libelle": "Non", + } + } + + +class ArticleList(BaseModel): + """Réponse pour une liste d'articles""" + + total: int = Field(..., description="Nombre total d'articles") + articles: List[Article] = Field(..., description="Liste des articles") + filtre_applique: Optional[str] = Field( + None, description="Filtre de recherche appliqué" + ) + avec_stock: bool = Field(True, description="Indique si les stocks ont été chargés") + avec_famille: bool = Field( + True, description="Indique si les familles ont été enrichies" + ) + avec_enrichissements_complets: bool = Field( + False, description="Indique si tous les enrichissements sont activés" + ) + + +class ArticleCreate(BaseModel): + reference: str = Field(..., max_length=18, description="Référence article") + designation: str = Field(..., max_length=69, description="Désignation") + + famille: Optional[str] = Field(None, max_length=18, description="Code famille") + + prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT") + prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT") + coef: Optional[float] = Field(None, ge=0, description="Coefficient") + + stock_reel: Optional[float] = Field(None, ge=0, description="Stock initial") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum") + + code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres EAN") + unite_vente: Optional[str] = Field("UN", max_length=10, description="Unité vente") + tva_code: Optional[str] = Field(None, max_length=10, description="Code TVA") + code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal") + + description: Optional[str] = Field( + None, max_length=255, description="Description/Commentaire" + ) + + pays: Optional[str] = Field(None, max_length=3, description="Pays d'origine") + garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois") + delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours") + + poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg") + poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg") + + stat_01: Optional[str] = Field(None, max_length=20, description="Statistique 1") + stat_02: Optional[str] = Field(None, max_length=20, description="Statistique 2") + stat_03: Optional[str] = Field(None, max_length=20, description="Statistique 3") + stat_04: Optional[str] = Field(None, max_length=20, description="Statistique 4") + stat_05: Optional[str] = Field(None, max_length=20, description="Statistique 5") + + soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte") + publie: Optional[bool] = Field(None, description="Publié web/catalogue") + en_sommeil: Optional[bool] = Field(None, description="Article en sommeil") + + +class ArticleUpdate(BaseModel): + designation: Optional[str] = Field(None, max_length=69, description="Désignation") + + famille: Optional[str] = Field(None, max_length=18, description="Code famille") + + prix_vente: Optional[float] = Field(None, ge=0, description="Prix vente HT") + prix_achat: Optional[float] = Field(None, ge=0, description="Prix achat HT") + coef: Optional[float] = Field(None, ge=0, description="Coefficient") + + stock_reel: Optional[float] = Field(None, ge=0, description="Stock réel") + stock_mini: Optional[float] = Field(None, ge=0, description="Stock minimum") + stock_maxi: Optional[float] = Field(None, ge=0, description="Stock maximum") + + code_ean: Optional[str] = Field(None, max_length=13, description="Code-barres EAN") + unite_vente: Optional[str] = Field(None, max_length=10, description="Unité vente") + code_fiscal: Optional[str] = Field(None, max_length=10, description="Code fiscal") + + description: Optional[str] = Field(None, max_length=255, description="Description") + + pays: Optional[str] = Field(None, max_length=3, description="Pays d'origine") + garantie: Optional[int] = Field(None, ge=0, description="Garantie en mois") + delai: Optional[int] = Field(None, ge=0, description="Délai livraison jours") + + poids_net: Optional[float] = Field(None, ge=0, description="Poids net kg") + poids_brut: Optional[float] = Field(None, ge=0, description="Poids brut kg") + + stat_01: Optional[str] = Field(None, max_length=20, description="Statistique 1") + stat_02: Optional[str] = Field(None, max_length=20, description="Statistique 2") + stat_03: Optional[str] = Field(None, max_length=20, description="Statistique 3") + stat_04: Optional[str] = Field(None, max_length=20, description="Statistique 4") + stat_05: Optional[str] = Field(None, max_length=20, description="Statistique 5") + + soumis_escompte: Optional[bool] = Field(None, description="Soumis à escompte") + publie: Optional[bool] = Field(None, description="Publié web/catalogue") + en_sommeil: Optional[bool] = Field(None, description="Article en sommeil") + + +class MouvementStockLigne(BaseModel): + article_ref: str = Field(..., description="Référence de l'article") + quantite: float = Field(..., gt=0, description="Quantité (>0)") + depot_code: Optional[str] = Field(None, description="Code du dépôt (ex: '01')") + prix_unitaire: Optional[float] = Field( + None, ge=0, description="Prix unitaire (optionnel)" + ) + commentaire: Optional[str] = Field(None, description="Commentaire ligne") + numero_lot: Optional[str] = Field( + None, description="Numéro de lot (pour FIFO/LIFO)" + ) + stock_mini: Optional[float] = Field( + None, + ge=0, + description="""Stock minimum à définir pour cet article. + Si fourni, met à jour AS_QteMini dans F_ARTSTOCK. + Laisser None pour ne pas modifier.""", + ) + stock_maxi: Optional[float] = Field( + None, + ge=0, + description="""Stock maximum à définir pour cet article. + Doit être > stock_mini si les deux sont fournis.""", + ) + + class Config: + json_schema_extra = { + "example": { + "article_ref": "ARTS-001", + "quantite": 50.0, + "depot_code": "01", + "prix_unitaire": 100.0, + "commentaire": "Réapprovisionnement", + "numero_lot": "LOT20241217", + "stock_mini": 10.0, + "stock_maxi": 200.0, + } + } + + @validator("stock_maxi") + def validate_stock_maxi(cls, v, values): + """Valide que stock_maxi > stock_mini si les deux sont fournis""" + if ( + v is not None + and "stock_mini" in values + and values["stock_mini"] is not None + ): + if v <= values["stock_mini"]: + raise ValueError( + "stock_maxi doit être strictement supérieur à stock_mini" + ) + return v + + +class EntreeStock(BaseModel): + """Création d'un bon d'entrée en stock""" + + date_entree: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigne] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_entree": "2025-01-15", + "reference": "REC-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 50, + "depot_code": "01", + "prix_unitaire": 10.50, + "commentaire": "Réception fournisseur", + } + ], + "commentaire": "Réception livraison fournisseur XYZ", + } + } + + +class SortieStock(BaseModel): + """Création d'un bon de sortie de stock""" + + date_sortie: Optional[date] = Field( + None, description="Date du mouvement (aujourd'hui par défaut)" + ) + reference: Optional[str] = Field(None, description="Référence externe") + depot_code: Optional[str] = Field( + None, description="Dépôt principal (si applicable)" + ) + lignes: List[MouvementStockLigne] = Field( + ..., min_items=1, description="Lignes du mouvement" + ) + commentaire: Optional[str] = Field(None, description="Commentaire général") + + class Config: + json_schema_extra = { + "example": { + "date_sortie": "2025-01-15", + "reference": "SOR-2025-001", + "depot_code": "01", + "lignes": [ + { + "article_ref": "ART001", + "quantite": 10, + "depot_code": "01", + "commentaire": "Utilisation interne", + } + ], + "commentaire": "Consommation atelier", + } + } + + +class MouvementStock(BaseModel): + """Réponse pour un mouvement de stock""" + + article_ref: str = Field(..., description="Numéro d'article") + numero: str = Field(..., description="Numéro du mouvement") + type: int = Field(..., description="Type (0=Entrée, 1=Sortie)") + type_libelle: str = Field(..., description="Libellé du type") + date: str = Field(..., description="Date du mouvement") + reference: Optional[str] = Field(None, description="Référence externe") + nb_lignes: int = Field(..., description="Nombre de lignes") diff --git a/schemas/articles/famille_article.py b/schemas/articles/famille_article.py new file mode 100644 index 0000000..59b6c31 --- /dev/null +++ b/schemas/articles/famille_article.py @@ -0,0 +1,255 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class FamilleCreate(BaseModel): + """Schéma pour création de famille d'articles""" + + code: str = Field(..., max_length=18, description="Code famille (max 18 car)") + intitule: str = Field(..., max_length=69, description="Intitulé (max 69 car)") + type: int = Field(0, ge=0, le=1, description="0=Détail, 1=Total") + compte_achat: Optional[str] = Field( + None, max_length=13, description="Compte général achat (ex: 607000)" + ) + compte_vente: Optional[str] = Field( + None, max_length=13, description="Compte général vente (ex: 707000)" + ) + + class Config: + json_schema_extra = { + "example": { + "code": "PRODLAIT", + "intitule": "Produits laitiers", + "type": 0, + "compte_achat": "607000", + "compte_vente": "707000", + } + } + + +class Familles(BaseModel): + """Modèle complet d'une famille avec données comptables et fournisseur""" + + code: str = Field(..., description="Code famille") + intitule: str = Field(..., description="Intitulé") + type: int = Field(..., description="Type (0=Détail, 1=Total)") + type_libelle: str = Field(..., description="Libellé du type") + est_total: bool = Field(..., description="True si type Total") + est_detail: bool = Field(..., description="True si type Détail") + + unite_vente: Optional[str] = Field(None, description="Unité de vente par défaut") + unite_poids: Optional[str] = Field(None, description="Unité de poids") + coef: Optional[float] = Field(None, description="Coefficient multiplicateur") + + suivi_stock: Optional[bool] = Field(None, description="Suivi du stock activé") + garantie: Optional[int] = Field(None, description="Durée de garantie (mois)") + delai: Optional[int] = Field(None, description="Délai de livraison (jours)") + nb_colis: Optional[int] = Field(None, description="Nombre de colis") + + code_fiscal: Optional[str] = Field(None, description="Code fiscal par défaut") + escompte: Optional[bool] = Field(None, description="Escompte autorisé") + + est_centrale: Optional[bool] = Field(None, description="Famille d'achat centralisé") + nature: Optional[int] = Field(None, description="Nature de la famille") + pays: Optional[str] = Field(None, description="Pays d'origine") + + categorie_1: Optional[int] = Field( + None, description="Catégorie comptable 1 (CL_No1)" + ) + categorie_2: Optional[int] = Field( + None, description="Catégorie comptable 2 (CL_No2)" + ) + categorie_3: Optional[int] = Field( + None, description="Catégorie comptable 3 (CL_No3)" + ) + categorie_4: Optional[int] = Field( + None, description="Catégorie comptable 4 (CL_No4)" + ) + + stat_01: Optional[str] = Field(None, description="Statistique libre 1") + stat_02: Optional[str] = Field(None, description="Statistique libre 2") + stat_03: Optional[str] = Field(None, description="Statistique libre 3") + stat_04: Optional[str] = Field(None, description="Statistique libre 4") + stat_05: Optional[str] = Field(None, description="Statistique libre 5") + hors_statistique: Optional[bool] = Field( + None, description="Exclue des statistiques" + ) + + vente_debit: Optional[bool] = Field(None, description="Vente au débit") + non_imprimable: Optional[bool] = Field( + None, description="Non imprimable sur documents" + ) + contremarque: Optional[bool] = Field(None, description="Article en contremarque") + fact_poids: Optional[bool] = Field(None, description="Facturation au poids") + fact_forfait: Optional[bool] = Field(None, description="Facturation forfaitaire") + publie: Optional[bool] = Field(None, description="Publié (e-commerce)") + + racine_reference: Optional[str] = Field( + None, description="Racine pour génération auto de références" + ) + racine_code_barre: Optional[str] = Field( + None, description="Racine pour génération auto de codes-barres" + ) + raccourci: Optional[str] = Field(None, description="Raccourci clavier") + + sous_traitance: Optional[bool] = Field( + None, description="Famille en sous-traitance" + ) + fictif: Optional[bool] = Field(None, description="Famille fictive (nomenclature)") + criticite: Optional[int] = Field(None, description="Niveau de criticité (0-5)") + + compte_vente: Optional[str] = Field(None, description="Compte général de vente") + compte_auxiliaire_vente: Optional[str] = Field( + None, description="Compte auxiliaire de vente" + ) + tva_vente_1: Optional[str] = Field(None, description="Code TVA vente principal") + tva_vente_2: Optional[str] = Field(None, description="Code TVA vente secondaire") + tva_vente_3: Optional[str] = Field(None, description="Code TVA vente tertiaire") + type_facture_vente: Optional[int] = Field(None, description="Type de facture vente") + + compte_achat: Optional[str] = Field(None, description="Compte général d'achat") + compte_auxiliaire_achat: Optional[str] = Field( + None, description="Compte auxiliaire d'achat" + ) + tva_achat_1: Optional[str] = Field(None, description="Code TVA achat principal") + tva_achat_2: Optional[str] = Field(None, description="Code TVA achat secondaire") + tva_achat_3: Optional[str] = Field(None, description="Code TVA achat tertiaire") + type_facture_achat: Optional[int] = Field(None, description="Type de facture achat") + + compte_stock: Optional[str] = Field(None, description="Compte de stock") + compte_auxiliaire_stock: Optional[str] = Field( + None, description="Compte auxiliaire de stock" + ) + + fournisseur_principal: Optional[str] = Field( + None, description="N° compte fournisseur principal" + ) + fournisseur_unite: Optional[str] = Field( + None, description="Unité d'achat fournisseur" + ) + fournisseur_conversion: Optional[float] = Field( + None, description="Coefficient de conversion" + ) + fournisseur_delai_appro: Optional[int] = Field( + None, description="Délai d'approvisionnement (jours)" + ) + fournisseur_garantie: Optional[int] = Field( + None, description="Garantie fournisseur (mois)" + ) + fournisseur_colisage: Optional[int] = Field( + None, description="Colisage fournisseur" + ) + fournisseur_qte_mini: Optional[float] = Field( + None, description="Quantité minimum de commande" + ) + fournisseur_qte_mont: Optional[float] = Field(None, description="Quantité montant") + fournisseur_devise: Optional[int] = Field( + None, description="Devise fournisseur (0=Euro)" + ) + fournisseur_remise: Optional[float] = Field( + None, description="Remise fournisseur (%)" + ) + fournisseur_type_remise: Optional[int] = Field( + None, description="Type de remise (0=%, 1=Montant)" + ) + + nb_articles: Optional[int] = Field( + None, description="Nombre d'articles dans la famille" + ) + + FA_CodeFamille: Optional[str] = Field(None, description="[Legacy] Code famille") + FA_Intitule: Optional[str] = Field(None, description="[Legacy] Intitulé") + FA_Type: Optional[int] = Field(None, description="[Legacy] Type") + CG_NumVte: Optional[str] = Field(None, description="[Legacy] Compte vente") + CG_NumAch: Optional[str] = Field(None, description="[Legacy] Compte achat") + + class Config: + json_schema_extra = { + "example": { + "code": "ELECT", + "intitule": "Électronique et Informatique", + "type": 0, + "type_libelle": "Détail", + "est_total": False, + "est_detail": True, + "unite_vente": "U", + "unite_poids": "KG", + "coef": 2.5, + "suivi_stock": True, + "garantie": 24, + "delai": 5, + "nb_colis": 1, + "code_fiscal": "C19", + "escompte": True, + "est_centrale": False, + "nature": 0, + "pays": "FR", + "categorie_1": 1, + "categorie_2": 0, + "categorie_3": 0, + "categorie_4": 0, + "stat_01": "HIGH_TECH", + "stat_02": "", + "stat_03": "", + "stat_04": "", + "stat_05": "", + "hors_statistique": False, + "vente_debit": False, + "non_imprimable": False, + "contremarque": False, + "fact_poids": False, + "fact_forfait": False, + "publie": True, + "racine_reference": "ELEC", + "racine_code_barre": "339", + "raccourci": "F5", + "sous_traitance": False, + "fictif": False, + "criticite": 2, + "compte_vente": "707100", + "compte_auxiliaire_vente": "", + "tva_vente_1": "C19", + "tva_vente_2": "", + "tva_vente_3": "", + "type_facture_vente": 0, + "compte_achat": "607100", + "compte_auxiliaire_achat": "", + "tva_achat_1": "C19", + "tva_achat_2": "", + "tva_achat_3": "", + "type_facture_achat": 0, + "compte_stock": "350000", + "compte_auxiliaire_stock": "", + "fournisseur_principal": "FTECH001", + "fournisseur_unite": "U", + "fournisseur_conversion": 1.0, + "fournisseur_delai_appro": 7, + "fournisseur_garantie": 12, + "fournisseur_colisage": 10, + "fournisseur_qte_mini": 5.0, + "fournisseur_qte_mont": 100.0, + "fournisseur_devise": 0, + "fournisseur_remise": 5.0, + "fournisseur_type_remise": 0, + "nb_articles": 156, + } + } + + +class FamilleList(BaseModel): + """Réponse pour la liste des familles""" + + familles: list[Familles] + total: int + filtre: Optional[str] = None + inclure_totaux: bool = True + + class Config: + json_schema_extra = { + "example": { + "familles": [], + "total": 42, + "filtre": "ELECT", + "inclure_totaux": False, + } + } diff --git a/schemas/documents/avoirs.py b/schemas/documents/avoirs.py new file mode 100644 index 0000000..66f363e --- /dev/null +++ b/schemas/documents/avoirs.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + +class AvoirCreate(BaseModel): + client_id: str + date_avoir: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_avoir": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "AV-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 0.0, + } + ], + } + } + + +class AvoirUpdate(BaseModel): + date_avoir: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_avoir": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "AV-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/commandes.py b/schemas/documents/commandes.py new file mode 100644 index 0000000..5c920dc --- /dev/null +++ b/schemas/documents/commandes.py @@ -0,0 +1,54 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + +class CommandeCreate(BaseModel): + client_id: str + date_commande: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_commande": "2024-01-15T10:00:00", + "reference": "CMD-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0, + } + ], + } + } + + +class CommandeUpdate(BaseModel): + date_commande: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_commande": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "CMD-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/devis.py b/schemas/documents/devis.py new file mode 100644 index 0000000..d43ad40 --- /dev/null +++ b/schemas/documents/devis.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + + +class DevisRequest(BaseModel): + client_id: str + date_devis: Optional[datetime] = None + date_livraison: Optional[datetime] = None + reference: Optional[str] = None + lignes: List[LigneDocument] + + +class Devis(BaseModel): + id: str + client_id: str + date_devis: str + montant_total_ht: float + montant_total_ttc: float + nb_lignes: int + + +class DevisUpdate(BaseModel): + """Modèle pour modification d'un devis existant""" + + date_devis: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + reference: Optional[str] = None + statut: Optional[int] = Field(None, ge=0, le=6) + + class Config: + json_schema_extra = { + "example": { + "date_devis": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "reference": "DEV-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 5.0, + "prix_unitaire_ht": 100.0, + "remise_pourcentage": 10.0, + } + ], + "statut": 2, + } + } + + +class RelanceDevis(BaseModel): + doc_id: str + message_personnalise: Optional[str] = None diff --git a/schemas/documents/documents.py b/schemas/documents/documents.py new file mode 100644 index 0000000..509d2ad --- /dev/null +++ b/schemas/documents/documents.py @@ -0,0 +1,22 @@ +from config.config import settings +from enum import Enum + + +class TypeDocument(int, Enum): + DEVIS = settings.SAGE_TYPE_DEVIS + BON_COMMANDE = settings.SAGE_TYPE_BON_COMMANDE + PREPARATION = settings.SAGE_TYPE_PREPARATION + BON_LIVRAISON = settings.SAGE_TYPE_BON_LIVRAISON + BON_RETOUR = settings.SAGE_TYPE_BON_RETOUR + BON_AVOIR = settings.SAGE_TYPE_BON_AVOIR + FACTURE = settings.SAGE_TYPE_FACTURE + + +class TypeDocumentSQL(int, Enum): + DEVIS = settings.SAGE_TYPE_DEVIS + BON_COMMANDE = 1 + PREPARATION = 2 + BON_LIVRAISON = 3 + BON_RETOUR = 4 + BON_AVOIR = 5 + FACTURE = 6 diff --git a/schemas/documents/email.py b/schemas/documents/email.py new file mode 100644 index 0000000..49229ac --- /dev/null +++ b/schemas/documents/email.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, EmailStr +from typing import List, Optional +from enum import Enum +from schemas.documents.documents import TypeDocument + + +class StatutEmail(str, Enum): + EN_ATTENTE = "EN_ATTENTE" + EN_COURS = "EN_COURS" + ENVOYE = "ENVOYE" + OUVERT = "OUVERT" + ERREUR = "ERREUR" + BOUNCE = "BOUNCE" + + +class EmailEnvoi(BaseModel): + destinataire: EmailStr + cc: Optional[List[EmailStr]] = [] + cci: Optional[List[EmailStr]] = [] + sujet: str + corps_html: str + document_ids: Optional[List[str]] = None + type_document: Optional[TypeDocument] = None diff --git a/schemas/documents/factures.py b/schemas/documents/factures.py new file mode 100644 index 0000000..0ab6e21 --- /dev/null +++ b/schemas/documents/factures.py @@ -0,0 +1,53 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + +class FactureCreate(BaseModel): + client_id: str + date_facture: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_facture": "2024-01-15T10:00:00", + "reference": "FA-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0, + } + ], + } + } + + +class FactureUpdate(BaseModel): + date_facture: Optional[datetime] = None + date_livraison: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_facture": "2024-01-15T10:00:00", + "date_livraison": "2024-01-15T10:00:00", + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/ligne_document.py b/schemas/documents/ligne_document.py new file mode 100644 index 0000000..4666ace --- /dev/null +++ b/schemas/documents/ligne_document.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, field_validator +from typing import Optional + + +class LigneDocument(BaseModel): + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + + @field_validator("article_code", mode="before") + def strip_insecables(cls, v): + return v.replace("\xa0", "").strip() + + @field_validator("quantite") + def validate_quantite(cls, v): + if v <= 0: + raise ValueError("La quantité doit être positive") + return v + + @field_validator("remise_pourcentage") + def validate_remise(cls, v): + if v is not None and (v < 0 or v > 100): + raise ValueError("La remise doit être entre 0 et 100") + return v diff --git a/schemas/documents/livraisons.py b/schemas/documents/livraisons.py new file mode 100644 index 0000000..3dc9eb9 --- /dev/null +++ b/schemas/documents/livraisons.py @@ -0,0 +1,55 @@ +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + +from schemas.documents.ligne_document import LigneDocument + + +class LivraisonCreate(BaseModel): + client_id: str + date_livraison: Optional[datetime] = None + date_livraison_prevue: Optional[datetime] = None + lignes: List[LigneDocument] + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "client_id": "CLI000001", + "date_livraison": "2024-01-15T10:00:00", + "reference": "BL-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 10.0, + "prix_unitaire_ht": 50.0, + "remise_pourcentage": 5.0, + } + ], + } + } + + +class LivraisonUpdate(BaseModel): + date_livraison: Optional[datetime] = None + date_livraison_prevue: Optional[datetime] = None + lignes: Optional[List[LigneDocument]] = None + statut: Optional[int] = Field(None, ge=0, le=6) + reference: Optional[str] = None + + class Config: + json_schema_extra = { + "example": { + "date_livraison": "2024-01-15T10:00:00", + "date_livraison_prevue": "2024-01-15T10:00:00", + "reference": "BL-EXT-001", + "lignes": [ + { + "article_code": "ART001", + "quantite": 15.0, + "prix_unitaire_ht": 45.0, + } + ], + "statut": 2, + } + } diff --git a/schemas/documents/universign.py b/schemas/documents/universign.py new file mode 100644 index 0000000..ba866ac --- /dev/null +++ b/schemas/documents/universign.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel, EmailStr +from enum import Enum +from schemas.documents.documents import TypeDocument + + +class StatutSignature(str, Enum): + EN_ATTENTE = "EN_ATTENTE" + ENVOYE = "ENVOYE" + SIGNE = "SIGNE" + REFUSE = "REFUSE" + EXPIRE = "EXPIRE" + + +class Signature(BaseModel): + doc_id: str + type_doc: TypeDocument + email_signataire: EmailStr + nom_signataire: str diff --git a/schemas/sage/sage_gateway.py b/schemas/sage/sage_gateway.py new file mode 100644 index 0000000..e503641 --- /dev/null +++ b/schemas/sage/sage_gateway.py @@ -0,0 +1,164 @@ +from pydantic import BaseModel, Field, field_validator +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum + + +class GatewayHealthStatus(str, Enum): + HEALTHY = "healthy" + UNHEALTHY = "unhealthy" + UNKNOWN = "unknown" + + +# === CREATE === +class SageGatewayCreate(BaseModel): + + name: str = Field( + ..., min_length=2, max_length=100, description="Nom de la gateway" + ) + description: Optional[str] = Field(None, max_length=500) + + gateway_url: str = Field( + ..., description="URL de la gateway Sage (ex: http://192.168.1.50:8100)" + ) + gateway_token: str = Field( + ..., min_length=10, description="Token d'authentification" + ) + + sage_database: Optional[str] = Field(None, max_length=255) + sage_company: Optional[str] = Field(None, max_length=255) + + is_active: bool = Field(False, description="Activer immédiatement cette gateway") + is_default: bool = Field(False, description="Définir comme gateway par défaut") + priority: int = Field(0, ge=0, le=100) + + extra_config: Optional[Dict[str, Any]] = Field( + None, description="Configuration JSON additionnelle" + ) + allowed_ips: Optional[List[str]] = Field( + None, description="Liste des IPs autorisées" + ) + + @field_validator("gateway_url") + @classmethod + def validate_url(cls, v): + if not v.startswith(("http://", "https://")): + raise ValueError("L'URL doit commencer par http:// ou https://") + return v.rstrip("/") + + +class SageGatewayUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=2, max_length=100) + description: Optional[str] = Field(None, max_length=500) + + gateway_url: Optional[str] = None + gateway_token: Optional[str] = Field(None, min_length=10) + + sage_database: Optional[str] = None + sage_company: Optional[str] = None + + is_default: Optional[bool] = None + priority: Optional[int] = Field(None, ge=0, le=100) + + extra_config: Optional[Dict[str, Any]] = None + allowed_ips: Optional[List[str]] = None + + @field_validator("gateway_url") + @classmethod + def validate_url(cls, v): + if v and not v.startswith(("http://", "https://")): + raise ValueError("L'URL doit commencer par http:// ou https://") + return v.rstrip("/") if v else v + + +# === RESPONSE === +class SageGatewayResponse(BaseModel): + + id: str + user_id: str + + name: str + description: Optional[str] = None + + gateway_url: str + token_preview: str + + sage_database: Optional[str] = None + sage_company: Optional[str] = None + + is_active: bool + is_default: bool + priority: int + + health_status: GatewayHealthStatus + last_health_check: Optional[datetime] = None + last_error: Optional[str] = None + + total_requests: int + successful_requests: int + failed_requests: int + success_rate: float + last_used_at: Optional[datetime] = None + + extra_config: Optional[Dict[str, Any]] = None + allowed_ips: Optional[List[str]] = None + + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SageGatewayList(BaseModel): + + items: List[SageGatewayResponse] + total: int + active_gateway: Optional[SageGatewayResponse] = None + using_fallback: bool = False + + +class SageGatewayHealthCheck(BaseModel): + gateway_id: str + gateway_name: str + status: GatewayHealthStatus + response_time_ms: Optional[float] = None + sage_version: Optional[str] = None + error: Optional[str] = None + checked_at: datetime + + +class SageGatewayActivateRequest(BaseModel): + gateway_id: str + + +class SageGatewayTest(BaseModel): + gateway_url: str + gateway_token: str + + @field_validator("gateway_url") + @classmethod + def validate_url(cls, v): + if not v.startswith(("http://", "https://")): + raise ValueError("L'URL doit commencer par http:// ou https://") + return v.rstrip("/") + + +class SageGatewayStatsResponse(BaseModel): + total_gateways: int + active_gateways: int + total_requests: int + successful_requests: int + failed_requests: int + average_success_rate: float + most_used_gateway: Optional[str] = None + last_activity: Optional[datetime] = None + + +class CurrentGatewayInfo(BaseModel): + source: str + gateway_id: Optional[str] = None + gateway_name: Optional[str] = None + gateway_url: str + is_healthy: Optional[bool] = None + user_id: Optional[str] = None diff --git a/schemas/schema_mixte.py b/schemas/schema_mixte.py new file mode 100644 index 0000000..8a69976 --- /dev/null +++ b/schemas/schema_mixte.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class BaremeRemiseResponse(BaseModel): + client_id: str + remise_max_autorisee: float + remise_demandee: float + autorisee: bool + message: str diff --git a/schemas/tiers/__init__.py b/schemas/tiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/schemas/tiers/clients.py b/schemas/tiers/clients.py new file mode 100644 index 0000000..8085375 --- /dev/null +++ b/schemas/tiers/clients.py @@ -0,0 +1,576 @@ +from pydantic import BaseModel, Field, field_validator +from typing import Optional +from schemas.tiers.tiers import TiersDetails + + +class ClientResponse(BaseModel): + numero: Optional[str] = None + intitule: Optional[str] = None + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + email: Optional[str] = None + telephone: Optional[str] = None + + +class ClientDetails(TiersDetails): + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "intitule": "SARL EXEMPLE", + "type_tiers": 0, + "commercial_code": 1, + "commercial": { + "numero": 1, + "nom": "DUPONT", + "prenom": "Jean", + "email": "j.dupont@entreprise.fr", + }, + } + } + + +class ClientCreate(BaseModel): + intitule: str = Field( + ..., max_length=69, description="Nom du client (CT_Intitule) - OBLIGATOIRE" + ) + + numero: str = Field( + ..., max_length=17, description="Numéro client CT_Num (auto si None)" + ) + + type_tiers: int = Field( + 0, + ge=0, + le=3, + description="CT_Type: 0=Client, 1=Fournisseur, 2=Salarié, 3=Autre", + ) + + qualite: Optional[str] = Field( + "CLI", max_length=17, description="CT_Qualite: CLI/FOU/SAL/DIV/AUT" + ) + + classement: Optional[str] = Field(None, max_length=17, description="CT_Classement") + + raccourci: Optional[str] = Field( + None, max_length=7, description="CT_Raccourci (7 chars max, unique)" + ) + + siret: Optional[str] = Field( + None, max_length=15, description="CT_Siret (14-15 chars)" + ) + + tva_intra: Optional[str] = Field( + None, max_length=25, description="CT_Identifiant (TVA intracommunautaire)" + ) + + code_naf: Optional[str] = Field( + None, max_length=7, description="CT_Ape (Code NAF/APE)" + ) + + contact: Optional[str] = Field( + None, + max_length=35, + description="CT_Contact (double affectation: client + adresse)", + ) + + adresse: Optional[str] = Field(None, max_length=35, description="Adresse.Adresse") + + complement: Optional[str] = Field( + None, max_length=35, description="Adresse.Complement" + ) + + code_postal: Optional[str] = Field( + None, max_length=9, description="Adresse.CodePostal" + ) + + ville: Optional[str] = Field(None, max_length=35, description="Adresse.Ville") + + region: Optional[str] = Field(None, max_length=25, description="Adresse.CodeRegion") + + pays: Optional[str] = Field(None, max_length=35, description="Adresse.Pays") + + telephone: Optional[str] = Field( + None, max_length=21, description="Telecom.Telephone" + ) + + telecopie: Optional[str] = Field( + None, max_length=21, description="Telecom.Telecopie (fax)" + ) + + email: Optional[str] = Field(None, max_length=69, description="Telecom.EMail") + + site_web: Optional[str] = Field(None, max_length=69, description="Telecom.Site") + + portable: Optional[str] = Field(None, max_length=21, description="Telecom.Portable") + + facebook: Optional[str] = Field( + None, max_length=69, description="Telecom.Facebook ou CT_Facebook" + ) + + linkedin: Optional[str] = Field( + None, max_length=69, description="Telecom.LinkedIn ou CT_LinkedIn" + ) + + compte_general: Optional[str] = Field( + None, + max_length=13, + description="CompteGPrinc (défaut selon type_tiers: 4110000, 4010000, 421, 471)", + ) + + categorie_tarifaire: Optional[str] = Field( + None, description="N_CatTarif (ID catégorie tarifaire, défaut '0' ou '1')" + ) + + categorie_comptable: Optional[str] = Field( + None, description="N_CatCompta (ID catégorie comptable, défaut '0' ou '1')" + ) + + taux01: Optional[float] = Field(None, description="CT_Taux01") + taux02: Optional[float] = Field(None, description="CT_Taux02") + taux03: Optional[float] = Field(None, description="CT_Taux03") + taux04: Optional[float] = Field(None, description="CT_Taux04") + + secteur: Optional[str] = Field( + None, max_length=21, description="Alias de statistique01 (CT_Statistique01)" + ) + + statistique01: Optional[str] = Field( + None, max_length=21, description="CT_Statistique01" + ) + statistique02: Optional[str] = Field( + None, max_length=21, description="CT_Statistique02" + ) + statistique03: Optional[str] = Field( + None, max_length=21, description="CT_Statistique03" + ) + statistique04: Optional[str] = Field( + None, max_length=21, description="CT_Statistique04" + ) + statistique05: Optional[str] = Field( + None, max_length=21, description="CT_Statistique05" + ) + statistique06: Optional[str] = Field( + None, max_length=21, description="CT_Statistique06" + ) + statistique07: Optional[str] = Field( + None, max_length=21, description="CT_Statistique07" + ) + statistique08: Optional[str] = Field( + None, max_length=21, description="CT_Statistique08" + ) + statistique09: Optional[str] = Field( + None, max_length=21, description="CT_Statistique09" + ) + statistique10: Optional[str] = Field( + None, max_length=21, description="CT_Statistique10" + ) + + encours_autorise: Optional[float] = Field( + None, description="CT_Encours (montant max autorisé)" + ) + + assurance_credit: Optional[float] = Field( + None, description="CT_Assurance (montant assurance crédit)" + ) + + langue: Optional[int] = Field( + None, ge=0, description="CT_Langue (0=Français, 1=Anglais, etc.)" + ) + + commercial_code: Optional[int] = Field( + None, description="CO_No (ID du collaborateur commercial)" + ) + + lettrage_auto: Optional[bool] = Field( + True, description="CT_Lettrage (1=oui, 0=non)" + ) + + est_actif: Optional[bool] = Field( + True, description="Inverse de CT_Sommeil (True=actif, False=en sommeil)" + ) + + type_facture: Optional[int] = Field( + 1, ge=0, le=2, description="CT_Facture: 0=aucune, 1=normale, 2=regroupée" + ) + + est_prospect: Optional[bool] = Field( + False, description="CT_Prospect (1=oui, 0=non)" + ) + + bl_en_facture: Optional[int] = Field( + None, ge=0, le=1, description="CT_BLFact (impression BL sur facture)" + ) + + saut_page: Optional[int] = Field( + None, ge=0, le=1, description="CT_Saut (saut de page après impression)" + ) + + validation_echeance: Optional[int] = Field( + None, ge=0, le=1, description="CT_ValidEch" + ) + + controle_encours: Optional[int] = Field( + None, ge=0, le=1, description="CT_ControlEnc" + ) + + exclure_relance: Optional[int] = Field(None, ge=0, le=1, description="CT_NotRappel") + + exclure_penalites: Optional[int] = Field( + None, ge=0, le=1, description="CT_NotPenal" + ) + + bon_a_payer: Optional[int] = Field(None, ge=0, le=1, description="CT_BonAPayer") + + priorite_livraison: Optional[int] = Field( + None, ge=0, le=5, description="CT_PrioriteLivr" + ) + + livraison_partielle: Optional[int] = Field( + None, ge=0, le=1, description="CT_LivrPartielle" + ) + + delai_transport: Optional[int] = Field( + None, ge=0, description="CT_DelaiTransport (jours)" + ) + + delai_appro: Optional[int] = Field(None, ge=0, description="CT_DelaiAppro (jours)") + + commentaire: Optional[str] = Field( + None, max_length=35, description="CT_Commentaire" + ) + + section_analytique: Optional[str] = Field(None, max_length=13, description="CA_Num") + + mode_reglement_code: Optional[int] = Field( + None, description="MR_No (ID du mode de règlement)" + ) + + surveillance_active: Optional[int] = Field( + None, ge=0, le=1, description="CT_Surveillance (DOIT être défini AVANT coface)" + ) + + coface: Optional[str] = Field( + None, max_length=25, description="CT_Coface (code Coface)" + ) + + forme_juridique: Optional[str] = Field( + None, max_length=33, description="CT_SvFormeJuri (SARL, SA, etc.)" + ) + + effectif: Optional[str] = Field(None, max_length=11, description="CT_SvEffectif") + + sv_regularite: Optional[str] = Field(None, max_length=3, description="CT_SvRegul") + + sv_cotation: Optional[str] = Field(None, max_length=5, description="CT_SvCotation") + + sv_objet_maj: Optional[str] = Field( + None, max_length=61, description="CT_SvObjetMaj" + ) + + ca_annuel: Optional[float] = Field( + None, + description="CT_SvCA (Chiffre d'affaires annuel) - alias: sv_chiffre_affaires", + ) + + sv_chiffre_affaires: Optional[float] = Field( + None, description="CT_SvCA (alias de ca_annuel)" + ) + + sv_resultat: Optional[float] = Field(None, description="CT_SvResultat") + + @field_validator("siret") + @classmethod + def validate_siret(cls, v): + """Valide et nettoie le SIRET""" + if v and v.lower() not in ("none", "null", ""): + cleaned = v.replace(" ", "").replace("-", "") + if len(cleaned) not in (14, 15): + raise ValueError("Le SIRET doit contenir 14 ou 15 caractères") + return cleaned + return None + + @field_validator("email") + @classmethod + def validate_email(cls, v): + """Valide le format email""" + if v and v.lower() not in ("none", "null", ""): + v = v.strip() + if "@" not in v: + raise ValueError("Format email invalide") + return v + return None + + @field_validator("raccourci") + @classmethod + def validate_raccourci(cls, v): + """Force le raccourci en majuscules""" + if v and v.lower() not in ("none", "null", ""): + return v.upper().strip()[:7] + return None + + @field_validator( + "adresse", + "code_postal", + "ville", + "pays", + "telephone", + "tva_intra", + "contact", + "complement", + mode="before", + ) + @classmethod + def clean_none_strings(cls, v): + """Convertit les chaînes 'None'/'null'/'' en None""" + if isinstance(v, str) and v.lower() in ("none", "null", ""): + return None + return v + + def to_sage_dict(self) -> dict: + """ + Convertit le modèle en dictionnaire compatible avec creer_client() + Mapping 1:1 avec les paramètres réels de la fonction + """ + stat01 = self.statistique01 or self.secteur + + ca = self.ca_annuel or self.sv_chiffre_affaires + + return { + "intitule": self.intitule, + "numero": self.numero, + "type_tiers": self.type_tiers, + "qualite": self.qualite, + "classement": self.classement, + "raccourci": self.raccourci, + "siret": self.siret, + "tva_intra": self.tva_intra, + "code_naf": self.code_naf, + "contact": self.contact, + "adresse": self.adresse, + "complement": self.complement, + "code_postal": self.code_postal, + "ville": self.ville, + "region": self.region, + "pays": self.pays, + "telephone": self.telephone, + "telecopie": self.telecopie, + "email": self.email, + "site_web": self.site_web, + "portable": self.portable, + "facebook": self.facebook, + "linkedin": self.linkedin, + "compte_general": self.compte_general, + "categorie_tarifaire": self.categorie_tarifaire, + "categorie_comptable": self.categorie_comptable, + "taux01": self.taux01, + "taux02": self.taux02, + "taux03": self.taux03, + "taux04": self.taux04, + "statistique01": stat01, + "statistique02": self.statistique02, + "statistique03": self.statistique03, + "statistique04": self.statistique04, + "statistique05": self.statistique05, + "statistique06": self.statistique06, + "statistique07": self.statistique07, + "statistique08": self.statistique08, + "statistique09": self.statistique09, + "statistique10": self.statistique10, + "secteur": self.secteur, # Gardé pour compatibilité + "encours_autorise": self.encours_autorise, + "assurance_credit": self.assurance_credit, + "langue": self.langue, + "commercial_code": self.commercial_code, + "lettrage_auto": self.lettrage_auto, + "est_actif": self.est_actif, + "type_facture": self.type_facture, + "est_prospect": self.est_prospect, + "bl_en_facture": self.bl_en_facture, + "saut_page": self.saut_page, + "validation_echeance": self.validation_echeance, + "controle_encours": self.controle_encours, + "exclure_relance": self.exclure_relance, + "exclure_penalites": self.exclure_penalites, + "bon_a_payer": self.bon_a_payer, + "priorite_livraison": self.priorite_livraison, + "livraison_partielle": self.livraison_partielle, + "delai_transport": self.delai_transport, + "delai_appro": self.delai_appro, + "commentaire": self.commentaire, + "section_analytique": self.section_analytique, + "mode_reglement_code": self.mode_reglement_code, + "surveillance_active": self.surveillance_active, + "coface": self.coface, + "forme_juridique": self.forme_juridique, + "effectif": self.effectif, + "sv_regularite": self.sv_regularite, + "sv_cotation": self.sv_cotation, + "sv_objet_maj": self.sv_objet_maj, + "ca_annuel": ca, + "sv_chiffre_affaires": self.sv_chiffre_affaires, + "sv_resultat": self.sv_resultat, + } + + class Config: + json_schema_extra = { + "example": { + "intitule": "ENTREPRISE EXEMPLE SARL", + "numero": "CLI00123", + "type_tiers": 0, + "qualite": "CLI", + "compte_general": "411000", + "est_prospect": False, + "est_actif": True, + "email": "contact@exemple.fr", + "telephone": "0123456789", + "adresse": "123 Rue de la Paix", + "code_postal": "75001", + "ville": "Paris", + "pays": "France", + } + } + + +class ClientUpdate(BaseModel): + intitule: Optional[str] = Field(None, max_length=69) + qualite: Optional[str] = Field(None, max_length=17) + classement: Optional[str] = Field(None, max_length=17) + raccourci: Optional[str] = Field(None, max_length=7) + + siret: Optional[str] = Field(None, max_length=15) + tva_intra: Optional[str] = Field(None, max_length=25) + code_naf: Optional[str] = Field(None, max_length=7) + + contact: Optional[str] = Field(None, max_length=35) + adresse: Optional[str] = Field(None, max_length=35) + complement: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + region: Optional[str] = Field(None, max_length=25) + pays: Optional[str] = Field(None, max_length=35) + + telephone: Optional[str] = Field(None, max_length=21) + telecopie: Optional[str] = Field(None, max_length=21) + email: Optional[str] = Field(None, max_length=69) + site_web: Optional[str] = Field(None, max_length=69) + portable: Optional[str] = Field(None, max_length=21) + facebook: Optional[str] = Field(None, max_length=69) + linkedin: Optional[str] = Field(None, max_length=69) + + compte_general: Optional[str] = Field(None, max_length=13) + + categorie_tarifaire: Optional[str] = None + categorie_comptable: Optional[str] = None + + taux01: Optional[float] = None + taux02: Optional[float] = None + taux03: Optional[float] = None + taux04: Optional[float] = None + + secteur: Optional[str] = Field(None, max_length=21) + statistique01: Optional[str] = Field(None, max_length=21) + statistique02: Optional[str] = Field(None, max_length=21) + statistique03: Optional[str] = Field(None, max_length=21) + statistique04: Optional[str] = Field(None, max_length=21) + statistique05: Optional[str] = Field(None, max_length=21) + statistique06: Optional[str] = Field(None, max_length=21) + statistique07: Optional[str] = Field(None, max_length=21) + statistique08: Optional[str] = Field(None, max_length=21) + statistique09: Optional[str] = Field(None, max_length=21) + statistique10: Optional[str] = Field(None, max_length=21) + + encours_autorise: Optional[float] = None + assurance_credit: Optional[float] = None + langue: Optional[int] = Field(None, ge=0) + commercial_code: Optional[int] = None + + lettrage_auto: Optional[bool] = None + est_actif: Optional[bool] = None + type_facture: Optional[int] = Field(None, ge=0, le=2) + est_prospect: Optional[bool] = None + bl_en_facture: Optional[int] = Field(None, ge=0, le=1) + saut_page: Optional[int] = Field(None, ge=0, le=1) + validation_echeance: Optional[int] = Field(None, ge=0, le=1) + controle_encours: Optional[int] = Field(None, ge=0, le=1) + exclure_relance: Optional[int] = Field(None, ge=0, le=1) + exclure_penalites: Optional[int] = Field(None, ge=0, le=1) + bon_a_payer: Optional[int] = Field(None, ge=0, le=1) + + priorite_livraison: Optional[int] = Field(None, ge=0, le=5) + livraison_partielle: Optional[int] = Field(None, ge=0, le=1) + delai_transport: Optional[int] = Field(None, ge=0) + delai_appro: Optional[int] = Field(None, ge=0) + + commentaire: Optional[str] = Field(None, max_length=35) + + section_analytique: Optional[str] = Field(None, max_length=13) + + mode_reglement_code: Optional[int] = None + + surveillance_active: Optional[int] = Field(None, ge=0, le=1) + coface: Optional[str] = Field(None, max_length=25) + forme_juridique: Optional[str] = Field(None, max_length=33) + effectif: Optional[str] = Field(None, max_length=11) + sv_regularite: Optional[str] = Field(None, max_length=3) + sv_cotation: Optional[str] = Field(None, max_length=5) + sv_objet_maj: Optional[str] = Field(None, max_length=61) + ca_annuel: Optional[float] = None + sv_chiffre_affaires: Optional[float] = None + sv_resultat: Optional[float] = None + + @field_validator("siret") + @classmethod + def validate_siret(cls, v): + if v and v.lower() not in ("none", "null", ""): + cleaned = v.replace(" ", "").replace("-", "") + if len(cleaned) not in (14, 15): + raise ValueError("Le SIRET doit contenir 14 ou 15 caractères") + return cleaned + return None + + @field_validator("email") + @classmethod + def validate_email(cls, v): + if v and v.lower() not in ("none", "null", ""): + v = v.strip() + if "@" not in v: + raise ValueError("Format email invalide") + return v + return None + + @field_validator("raccourci") + @classmethod + def validate_raccourci(cls, v): + if v and v.lower() not in ("none", "null", ""): + return v.upper().strip()[:7] + return None + + @field_validator( + "adresse", + "code_postal", + "ville", + "pays", + "telephone", + "tva_intra", + "contact", + "complement", + mode="before", + ) + @classmethod + def clean_none_strings(cls, v): + if isinstance(v, str) and v.lower() in ("none", "null", ""): + return None + return v + + class Config: + json_schema_extra = { + "example": { + "email": "nouveau@email.fr", + "telephone": "0198765432", + "portable": "0687654321", + "adresse": "456 Avenue Nouvelle", + "ville": "Lyon", + } + } diff --git a/schemas/tiers/commercial.py b/schemas/tiers/commercial.py new file mode 100644 index 0000000..5a4685b --- /dev/null +++ b/schemas/tiers/commercial.py @@ -0,0 +1,116 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional + + +class CollaborateurBase(BaseModel): + """Champs communs collaborateur""" + + nom: str = Field(..., max_length=50) + prenom: Optional[str] = Field(None, max_length=50) + fonction: Optional[str] = Field(None, max_length=50) + + # Adresse + adresse: Optional[str] = Field(None, max_length=100) + complement: Optional[str] = Field(None, max_length=100) + code_postal: Optional[str] = Field(None, max_length=10) + ville: Optional[str] = Field(None, max_length=50) + code_region: Optional[str] = Field(None, max_length=50) + pays: Optional[str] = Field(None, max_length=50) + + # Services + service: Optional[str] = Field(None, max_length=50) + vendeur: bool = Field(default=False) + caissier: bool = Field(default=False) + acheteur: bool = Field(default=False) + chef_ventes: bool = Field(default=False) + numero_chef_ventes: Optional[int] = None + + # Contact + telephone: Optional[str] = Field(None, max_length=20) + telecopie: Optional[str] = Field(None, max_length=20) + email: Optional[EmailStr] = None + tel_portable: Optional[str] = Field(None, max_length=20) + + # Réseaux sociaux + facebook: Optional[str] = Field(None, max_length=100) + linkedin: Optional[str] = Field(None, max_length=100) + skype: Optional[str] = Field(None, max_length=100) + + # Autres + matricule: Optional[str] = Field(None, max_length=20) + sommeil: bool = Field(default=False) + + +class CollaborateurCreate(CollaborateurBase): + """Création d'un collaborateur""" + + pass + + +class CollaborateurUpdate(BaseModel): + """Modification d'un collaborateur (tous champs optionnels)""" + + nom: Optional[str] = Field(None, max_length=50) + prenom: Optional[str] = Field(None, max_length=50) + fonction: Optional[str] = Field(None, max_length=50) + + adresse: Optional[str] = Field(None, max_length=100) + complement: Optional[str] = Field(None, max_length=100) + code_postal: Optional[str] = Field(None, max_length=10) + ville: Optional[str] = Field(None, max_length=50) + code_region: Optional[str] = Field(None, max_length=50) + pays: Optional[str] = Field(None, max_length=50) + + service: Optional[str] = Field(None, max_length=50) + vendeur: Optional[bool] = None + caissier: Optional[bool] = None + acheteur: Optional[bool] = None + chef_ventes: Optional[bool] = None + numero_chef_ventes: Optional[int] = None + + telephone: Optional[str] = Field(None, max_length=20) + telecopie: Optional[str] = Field(None, max_length=20) + email: Optional[EmailStr] = None + tel_portable: Optional[str] = Field(None, max_length=20) + + facebook: Optional[str] = Field(None, max_length=100) + linkedin: Optional[str] = Field(None, max_length=100) + skype: Optional[str] = Field(None, max_length=100) + + matricule: Optional[str] = Field(None, max_length=20) + sommeil: Optional[bool] = None + + +class CollaborateurListe(BaseModel): + """Vue liste simplifiée""" + + numero: int + nom: str + prenom: Optional[str] + fonction: Optional[str] + service: Optional[str] + email: Optional[str] + telephone: Optional[str] + vendeur: bool + sommeil: bool + + +class CollaborateurDetails(CollaborateurBase): + """Détails complets d'un collaborateur""" + + numero: int + + class Config: + json_schema_extra = { + "example": { + "numero": 1, + "nom": "DUPONT", + "prenom": "Jean", + "fonction": "Directeur Commercial", + "service": "Commercial", + "vendeur": True, + "email": "j.dupont@entreprise.fr", + "telephone": "0123456789", + "sommeil": False, + } + } diff --git a/schemas/tiers/contact.py b/schemas/tiers/contact.py new file mode 100644 index 0000000..b5393d8 --- /dev/null +++ b/schemas/tiers/contact.py @@ -0,0 +1,111 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional, ClassVar + + +class Contact(BaseModel): + numero: Optional[str] = Field(None, description="Code du tiers parent (CT_Num)") + contact_numero: Optional[int] = Field( + None, description="Numéro unique du contact (CT_No)" + ) + n_contact: Optional[int] = Field( + None, description="Numéro de référence contact (N_Contact)" + ) + + civilite: Optional[str] = Field( + None, description="Civilité : M., Mme, Mlle (CT_Civilite)" + ) + nom: Optional[str] = Field(None, description="Nom de famille (CT_Nom)") + prenom: Optional[str] = Field(None, description="Prénom (CT_Prenom)") + fonction: Optional[str] = Field(None, description="Fonction/Titre (CT_Fonction)") + + service_code: Optional[int] = Field(None, description="Code du service (N_Service)") + + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") + portable: Optional[str] = Field( + None, description="Téléphone mobile (CT_TelPortable)" + ) + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Adresse email (CT_EMail)") + + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + skype: Optional[str] = Field(None, description="Identifiant Skype (CT_Skype)") + + est_defaut: Optional[bool] = Field(False, description="Contact par défaut") + + civilite_map: ClassVar[dict] = { + 0: "M.", + 1: "Mme", + 2: "Mlle", + 3: "Société", + } + + @validator("civilite", pre=True, always=True) + def convert_civilite(cls, v): + if v is None: + return v + if isinstance(v, int): + return cls.civilite_map.get(v, str(v)) + return v + + +class ContactCreate(BaseModel): + numero: str = Field(..., description="Code du client parent (obligatoire)") + + civilite: Optional[str] = Field(None, description="M., Mme, Mlle, Société") + nom: str = Field(..., description="Nom de famille (obligatoire)") + prenom: Optional[str] = Field(None, description="Prénom") + fonction: Optional[str] = Field(None, description="Fonction/Titre") + + est_defaut: Optional[bool] = Field( + False, description="Définir comme contact par défaut du client" + ) + + service_code: Optional[int] = Field(None, description="Code du service") + + telephone: Optional[str] = Field(None, description="Téléphone fixe") + portable: Optional[str] = Field(None, description="Téléphone mobile") + telecopie: Optional[str] = Field(None, description="Fax") + email: Optional[str] = Field(None, description="Email") + + facebook: Optional[str] = Field(None, description="URL Facebook") + linkedin: Optional[str] = Field(None, description="URL LinkedIn") + skype: Optional[str] = Field(None, description="Identifiant Skype") + + @validator("civilite") + def validate_civilite(cls, v): + if v and v not in ["M.", "Mme", "Mlle", "Société"]: + raise ValueError("Civilité doit être: M., Mme, Mlle ou Société") + return v + + class Config: + json_schema_extra = { + "example": { + "numero": "CLI000001", + "civilite": "M.", + "nom": "Dupont", + "prenom": "Jean", + "fonction": "Directeur Commercial", + "telephone": "0123456789", + "portable": "0612345678", + "email": "j.dupont@exemple.fr", + "linkedin": "https://linkedin.com/in/jeandupont", + "est_defaut": True, + } + } + + +class ContactUpdate(BaseModel): + civilite: Optional[str] = None + nom: Optional[str] = None + prenom: Optional[str] = None + fonction: Optional[str] = None + service_code: Optional[int] = None + telephone: Optional[str] = None + portable: Optional[str] = None + telecopie: Optional[str] = None + email: Optional[str] = None + facebook: Optional[str] = None + linkedin: Optional[str] = None + skype: Optional[str] = None + est_defaut: Optional[bool] = None diff --git a/schemas/tiers/fournisseurs.py b/schemas/tiers/fournisseurs.py new file mode 100644 index 0000000..6807560 --- /dev/null +++ b/schemas/tiers/fournisseurs.py @@ -0,0 +1,79 @@ +from pydantic import BaseModel, Field, EmailStr +from typing import Optional +from schemas.tiers.tiers import TiersDetails + + +class FournisseurDetails(TiersDetails): + class Config: + json_schema_extra = { + "example": { + "numero": "FOU000001", + "intitule": "SARL FOURNISSEUR", + "type_tiers": 1, + "commercial_code": 1, + "commercial": { + "numero": 1, + "nom": "MARTIN", + "prenom": "Sophie", + "email": "s.martin@entreprise.fr", + }, + } + } + + +class FournisseurCreate(BaseModel): + intitule: str = Field( + ..., min_length=1, max_length=69, description="Raison sociale du fournisseur" + ) + compte_collectif: str = Field( + "401000", description="Compte comptable fournisseur (ex: 401000)" + ) + num: Optional[str] = Field( + None, max_length=17, description="Code fournisseur souhaité (optionnel)" + ) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES SARL", + "compte_collectif": "401000", + "num": "FOUR001", + "adresse": "15 Rue du Commerce", + "code_postal": "75001", + "ville": "Paris", + "pays": "France", + "email": "contact@acmesupplies.fr", + "telephone": "0145678901", + "siret": "12345678901234", + "tva_intra": "FR12345678901", + } + } + + +class FournisseurUpdate(BaseModel): + intitule: Optional[str] = Field(None, min_length=1, max_length=69) + adresse: Optional[str] = Field(None, max_length=35) + code_postal: Optional[str] = Field(None, max_length=9) + ville: Optional[str] = Field(None, max_length=35) + pays: Optional[str] = Field(None, max_length=35) + email: Optional[EmailStr] = None + telephone: Optional[str] = Field(None, max_length=21) + siret: Optional[str] = Field(None, max_length=14) + tva_intra: Optional[str] = Field(None, max_length=25) + + class Config: + json_schema_extra = { + "example": { + "intitule": "ACME SUPPLIES MODIFIÉ", + "email": "nouveau@acme.fr", + "telephone": "0198765432", + } + } diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py new file mode 100644 index 0000000..58166a1 --- /dev/null +++ b/schemas/tiers/tiers.py @@ -0,0 +1,217 @@ +from typing import List, Optional +from pydantic import BaseModel, Field +from schemas.tiers.contact import Contact +from enum import IntEnum + +from schemas.tiers.tiers_collab import Collaborateur + + +class TypeTiersInt(IntEnum): + CLIENT = 0 + FOURNISSEUR = 1 + SALARIE = 2 + AUTRE = 3 + + +class TiersDetails(BaseModel): + # IDENTIFICATION + numero: Optional[str] = Field(None, description="Code tiers (CT_Num)") + intitule: Optional[str] = Field( + None, description="Raison sociale ou Nom complet (CT_Intitule)" + ) + type_tiers: Optional[int] = Field( + None, description="Type : 0=Client, 1=Fournisseur (CT_Type)" + ) + qualite: Optional[str] = Field( + None, description="Qualité Sage : CLI, FOU, PRO (CT_Qualite)" + ) + classement: Optional[str] = Field( + None, description="Code de classement (CT_Classement)" + ) + raccourci: Optional[str] = Field( + None, description="Code raccourci 7 car. (CT_Raccourci)" + ) + siret: Optional[str] = Field(None, description="N° SIRET 14 chiffres (CT_Siret)") + tva_intra: Optional[str] = Field( + None, description="N° TVA intracommunautaire (CT_Identifiant)" + ) + code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") + + # ADRESSE + contact: Optional[str] = Field( + None, description="Nom du contact principal (CT_Contact)" + ) + adresse: Optional[str] = Field(None, description="Adresse ligne 1 (CT_Adresse)") + complement: Optional[str] = Field( + None, description="Complément d'adresse (CT_Complement)" + ) + code_postal: Optional[str] = Field(None, description="Code postal (CT_CodePostal)") + ville: Optional[str] = Field(None, description="Ville (CT_Ville)") + region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") + pays: Optional[str] = Field(None, description="Pays (CT_Pays)") + + # TELECOM + telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") + telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") + email: Optional[str] = Field(None, description="Email principal (CT_EMail)") + site_web: Optional[str] = Field(None, description="Site web (CT_Site)") + facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") + linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") + + # TAUX + taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") + taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") + taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") + taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") + + # STATISTIQUES + statistique01: Optional[str] = Field( + None, description="Statistique 1 (CT_Statistique01)" + ) + statistique02: Optional[str] = Field( + None, description="Statistique 2 (CT_Statistique02)" + ) + statistique03: Optional[str] = Field( + None, description="Statistique 3 (CT_Statistique03)" + ) + statistique04: Optional[str] = Field( + None, description="Statistique 4 (CT_Statistique04)" + ) + statistique05: Optional[str] = Field( + None, description="Statistique 5 (CT_Statistique05)" + ) + statistique06: Optional[str] = Field( + None, description="Statistique 6 (CT_Statistique06)" + ) + statistique07: Optional[str] = Field( + None, description="Statistique 7 (CT_Statistique07)" + ) + statistique08: Optional[str] = Field( + None, description="Statistique 8 (CT_Statistique08)" + ) + statistique09: Optional[str] = Field( + None, description="Statistique 9 (CT_Statistique09)" + ) + statistique10: Optional[str] = Field( + None, description="Statistique 10 (CT_Statistique10)" + ) + + # COMMERCIAL + encours_autorise: Optional[float] = Field( + None, description="Encours maximum autorisé (CT_Encours)" + ) + assurance_credit: Optional[float] = Field( + None, description="Montant assurance crédit (CT_Assurance)" + ) + langue: Optional[int] = Field( + None, description="Code langue 0=FR, 1=EN (CT_Langue)" + ) + commercial_code: Optional[int] = Field( + None, description="Code du commercial (CO_No)" + ) + commercial: Optional[Collaborateur] = Field( + None, description="Détails du commercial/collaborateur" + ) + + # FACTURATION + lettrage_auto: Optional[bool] = Field( + None, description="Lettrage automatique (CT_Lettrage)" + ) + est_actif: Optional[bool] = Field(None, description="True si actif (CT_Sommeil=0)") + type_facture: Optional[int] = Field( + None, description="Type facture 0=Facture, 1=BL (CT_Facture)" + ) + est_prospect: Optional[bool] = Field( + None, description="True si prospect (CT_Prospect=1)" + ) + bl_en_facture: Optional[int] = Field( + None, description="Imprimer BL en facture (CT_BLFact)" + ) + saut_page: Optional[int] = Field( + None, description="Saut de page sur documents (CT_Saut)" + ) + validation_echeance: Optional[int] = Field( + None, description="Valider les échéances (CT_ValidEch)" + ) + controle_encours: Optional[int] = Field( + None, description="Contrôler l'encours (CT_ControlEnc)" + ) + exclure_relance: Optional[bool] = Field( + None, description="Exclure des relances (CT_NotRappel)" + ) + exclure_penalites: Optional[bool] = Field( + None, description="Exclure des pénalités (CT_NotPenal)" + ) + bon_a_payer: Optional[int] = Field( + None, description="Bon à payer obligatoire (CT_BonAPayer)" + ) + + # LOGISTIQUE + priorite_livraison: Optional[int] = Field( + None, description="Priorité livraison (CT_PrioriteLivr)" + ) + livraison_partielle: Optional[int] = Field( + None, description="Livraison partielle (CT_LivrPartielle)" + ) + delai_transport: Optional[int] = Field( + None, description="Délai transport jours (CT_DelaiTransport)" + ) + delai_appro: Optional[int] = Field( + None, description="Délai appro jours (CT_DelaiAppro)" + ) + + # COMMENTAIRE + commentaire: Optional[str] = Field( + None, description="Commentaire libre (CT_Commentaire)" + ) + + # ANALYTIQUE + section_analytique: Optional[str] = Field( + None, description="Section analytique (CA_Num)" + ) + + # ORGANISATION / SURVEILLANCE + mode_reglement_code: Optional[int] = Field( + None, description="Code mode règlement (MR_No)" + ) + surveillance_active: Optional[bool] = Field( + None, description="Surveillance financière (CT_Surveillance)" + ) + coface: Optional[str] = Field(None, description="Code Coface 25 car. (CT_Coface)") + forme_juridique: Optional[str] = Field( + None, description="Forme juridique SA, SARL (CT_SvFormeJuri)" + ) + effectif: Optional[str] = Field( + None, description="Nombre d'employés (CT_SvEffectif)" + ) + sv_regularite: Optional[str] = Field( + None, description="Régularité paiements (CT_SvRegul)" + ) + sv_cotation: Optional[str] = Field( + None, description="Cotation crédit (CT_SvCotation)" + ) + sv_objet_maj: Optional[str] = Field( + None, description="Objet dernière MAJ (CT_SvObjetMaj)" + ) + sv_chiffre_affaires: Optional[float] = Field( + None, description="Chiffre d'affaires (CT_SvCA)" + ) + sv_resultat: Optional[float] = Field( + None, description="Résultat financier (CT_SvResultat)" + ) + + # COMPTE GENERAL ET CATEGORIES + compte_general: Optional[str] = Field( + None, description="Compte général principal (CG_NumPrinc)" + ) + categorie_tarif: Optional[int] = Field( + None, description="Catégorie tarifaire (N_CatTarif)" + ) + categorie_compta: Optional[int] = Field( + None, description="Catégorie comptable (N_CatCompta)" + ) + + # CONTACTS + contacts: Optional[List[Contact]] = Field( + default_factory=list, description="Liste des contacts du tiers" + ) diff --git a/schemas/tiers/tiers_collab.py b/schemas/tiers/tiers_collab.py new file mode 100644 index 0000000..3d727ed --- /dev/null +++ b/schemas/tiers/tiers_collab.py @@ -0,0 +1,54 @@ +from typing import Optional +from pydantic import BaseModel, Field + + +class Collaborateur(BaseModel): + """Modèle pour un collaborateur/commercial""" + + numero: Optional[int] = Field(None, description="Numéro du collaborateur (CO_No)") + nom: Optional[str] = Field(None, description="Nom (CO_Nom)") + prenom: Optional[str] = Field(None, description="Prénom (CO_Prenom)") + fonction: Optional[str] = Field(None, description="Fonction (CO_Fonction)") + adresse: Optional[str] = Field(None, description="Adresse (CO_Adresse)") + complement: Optional[str] = Field( + None, description="Complément adresse (CO_Complement)" + ) + code_postal: Optional[str] = Field(None, description="Code postal (CO_CodePostal)") + ville: Optional[str] = Field(None, description="Ville (CO_Ville)") + region: Optional[str] = Field(None, description="Région (CO_CodeRegion)") + pays: Optional[str] = Field(None, description="Pays (CO_Pays)") + service: Optional[str] = Field(None, description="Service (CO_Service)") + est_vendeur: Optional[bool] = Field(None, description="Est vendeur (CO_Vendeur)") + est_caissier: Optional[bool] = Field(None, description="Est caissier (CO_Caissier)") + est_acheteur: Optional[bool] = Field(None, description="Est acheteur (CO_Acheteur)") + telephone: Optional[str] = Field(None, description="Téléphone (CO_Telephone)") + telecopie: Optional[str] = Field(None, description="Fax (CO_Telecopie)") + email: Optional[str] = Field(None, description="Email (CO_EMail)") + tel_portable: Optional[str] = Field(None, description="Portable (CO_TelPortable)") + matricule: Optional[str] = Field(None, description="Matricule (CO_Matricule)") + facebook: Optional[str] = Field(None, description="Facebook (CO_Facebook)") + linkedin: Optional[str] = Field(None, description="LinkedIn (CO_LinkedIn)") + skype: Optional[str] = Field(None, description="Skype (CO_Skype)") + est_actif: Optional[bool] = Field(None, description="Est actif (CO_Sommeil=0)") + est_chef_ventes: Optional[bool] = Field( + None, description="Est chef des ventes (CO_ChefVentes)" + ) + chef_ventes_numero: Optional[int] = Field( + None, description="N° chef des ventes (CO_NoChefVentes)" + ) + + class Config: + json_schema_extra = { + "example": { + "numero": 1, + "nom": "DUPONT", + "prenom": "Jean", + "fonction": "Commercial", + "service": "Ventes", + "est_vendeur": True, + "telephone": "0123456789", + "email": "j.dupont@entreprise.fr", + "tel_portable": "0612345678", + "est_actif": True, + } + } diff --git a/schemas/tiers/type_tiers.py b/schemas/tiers/type_tiers.py new file mode 100644 index 0000000..809005f --- /dev/null +++ b/schemas/tiers/type_tiers.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class TypeTiers(str, Enum): + ALL = "all" + CLIENT = "client" + FOURNISSEUR = "fournisseur" + PROSPECT = "prospect" diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..28150bb --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel +from typing import Optional + + +class Users(BaseModel): + id: str + email: str + nom: str + prenom: str + role: str + is_verified: bool + is_active: bool + created_at: str + last_login: Optional[str] = None + failed_login_attempts: int = 0 + + class Config: + from_attributes = True diff --git a/security/auth.py b/security/auth.py new file mode 100644 index 0000000..970a90f --- /dev/null +++ b/security/auth.py @@ -0,0 +1,92 @@ +from passlib.context import CryptContext +from datetime import datetime, timedelta +from typing import Optional, Dict +import jwt +import secrets +import hashlib + +SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 10080 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + + +def generate_verification_token() -> str: + return secrets.token_urlsafe(32) + + +def generate_reset_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str: + to_encode = data.copy() + + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"}) + + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def create_refresh_token(user_id: str) -> str: + expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode = { + "sub": user_id, + "exp": expire, + "iat": datetime.utcnow(), + "type": "refresh", + "jti": secrets.token_urlsafe(16), # Unique ID + } + + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> Optional[Dict]: + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.JWTError: + return None + + +def validate_password_strength(password: str) -> tuple[bool, str]: + if len(password) < 8: + return False, "Le mot de passe doit contenir au moins 8 caractères" + + if not any(c.isupper() for c in password): + return False, "Le mot de passe doit contenir au moins une majuscule" + + if not any(c.islower() for c in password): + return False, "Le mot de passe doit contenir au moins une minuscule" + + if not any(c.isdigit() for c in password): + return False, "Le mot de passe doit contenir au moins un chiffre" + + special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + if not any(c in special_chars for c in password): + return False, "Le mot de passe doit contenir au moins un caractère spécial" + + return True, "" diff --git a/services/email_service.py b/services/email_service.py new file mode 100644 index 0000000..2a6e9e3 --- /dev/null +++ b/services/email_service.py @@ -0,0 +1,202 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from config.config import settings +import logging + +logger = logging.getLogger(__name__) + + +class AuthEmailService: + @staticmethod + def _send_email(to: str, subject: str, html_body: str) -> bool: + try: + msg = MIMEMultipart() + msg["From"] = settings.smtp_from + msg["To"] = to + msg["Subject"] = subject + + msg.attach(MIMEText(html_body, "html")) + + with smtplib.SMTP( + settings.smtp_host, settings.smtp_port, timeout=30 + ) as server: + if settings.smtp_use_tls: + server.starttls() + + if settings.smtp_user and settings.smtp_password: + server.login(settings.smtp_user, settings.smtp_password) + + server.send_message(msg) + + logger.info(f" Email envoyé: {subject} → {to}") + return True + + except Exception as e: + logger.error(f" Erreur envoi email: {e}") + return False + + @staticmethod + def send_verification_email(email: str, token: str, base_url: str) -> bool: + verification_link = f"{base_url}/auth/verify-email?token={token}" + + html_body = f""" + + + + + + +
+
+

🎉 Bienvenue sur Sage Dataven

+
+
+

Vérifiez votre adresse email

+

Merci de vous être inscrit ! Pour activer votre compte, veuillez cliquer sur le bouton ci-dessous :

+ +
+ +

Ou copiez ce lien dans votre navigateur :

+

+ {verification_link} +

+ +

+ Ce lien expire dans 24 heures +

+ +

+ Si vous n'avez pas créé de compte, ignorez cet email. +

+
+ +
+ + + """ + + return AuthEmailService._send_email( + email, " Vérifiez votre adresse email - Sage Dataven", html_body + ) + + @staticmethod + def send_password_reset_email(email: str, token: str, base_url: str) -> bool: + reset_link = f"{base_url}/reset?token={token}" + + html_body = f""" + + + + + + +
+
+

Réinitialisation de mot de passe

+
+
+

Demande de réinitialisation

+

Vous avez demandé à réinitialiser votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :

+ + + +

Ou copiez ce lien dans votre navigateur :

+

+ {reset_link} +

+ +

+ Ce lien expire dans 1 heure +

+ +

+ Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé. +

+
+ +
+ + + """ + + return AuthEmailService._send_email( + email, " Réinitialisation de votre mot de passe - Sage Dataven", html_body + ) + + @staticmethod + def send_password_changed_notification(email: str) -> bool: + html_body = """ + + + + + + +
+
+

Mot de passe modifié

+
+
+

Votre mot de passe a été changé avec succès

+

Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.

+ +

+ Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support. +

+
+ +
+ + + """ + + return AuthEmailService._send_email( + email, " Votre mot de passe a été modifié - Sage Dataven", html_body + ) diff --git a/services/sage_gateway.py b/services/sage_gateway.py new file mode 100644 index 0000000..feccaaf --- /dev/null +++ b/services/sage_gateway.py @@ -0,0 +1,400 @@ +from __future__ import annotations + +import uuid +import json +import httpx +from datetime import datetime +from typing import Optional, Tuple, List +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import false, select, true, update, and_ +import logging + +from config.config import settings +from database import SageGatewayConfig + +logger = logging.getLogger(__name__) + + +class SageGatewayService: + def __init__(self, session: AsyncSession): + self.session = session + + async def create(self, user_id: str, data: dict) -> SageGatewayConfig: + """Créer une nouvelle configuration gateway""" + + if data.get("is_active"): + await self._deactivate_all_for_user(user_id) + + if data.get("is_default"): + await self._unset_default_for_user(user_id) + + extra_config = data.pop("extra_config", None) + allowed_ips = data.pop("allowed_ips", None) + + gateway = SageGatewayConfig( + id=str(uuid.uuid4()), + user_id=user_id, + created_by=user_id, + extra_config=json.dumps(extra_config) if extra_config else None, + allowed_ips=json.dumps(allowed_ips) if allowed_ips else None, + **data, + ) + + self.session.add(gateway) + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway créée: {gateway.name} pour user {user_id}") + return gateway + + async def get_by_id( + self, gateway_id: str, user_id: str + ) -> Optional[SageGatewayConfig]: + result = await self.session.execute( + select(SageGatewayConfig).where( + and_( + SageGatewayConfig.id == gateway_id, + SageGatewayConfig.user_id == user_id, + SageGatewayConfig.is_deleted == false(), + ) + ) + ) + return result.scalar_one_or_none() + + async def list_for_user( + self, user_id: str, include_deleted: bool = False + ) -> List[SageGatewayConfig]: + query = select(SageGatewayConfig).where(SageGatewayConfig.user_id == user_id) + + if not include_deleted: + query = query.where(SageGatewayConfig.is_deleted == false()) + + query = query.order_by( + SageGatewayConfig.is_active.desc(), + SageGatewayConfig.priority.desc(), + SageGatewayConfig.name, + ) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def update( + self, gateway_id: str, user_id: str, data: dict + ) -> Optional[SageGatewayConfig]: + """Mettre à jour une gateway""" + + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return None + + if data.get("is_default") and not gateway.is_default: + await self._unset_default_for_user(user_id) + + if "extra_config" in data: + data["extra_config"] = ( + json.dumps(data["extra_config"]) if data["extra_config"] else None + ) + if "allowed_ips" in data: + data["allowed_ips"] = ( + json.dumps(data["allowed_ips"]) if data["allowed_ips"] else None + ) + + for key, value in data.items(): + if value is not None and hasattr(gateway, key): + setattr(gateway, key, value) + + gateway.updated_at = datetime.now() + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway mise à jour: {gateway.name}") + return gateway + + async def delete( + self, gateway_id: str, user_id: str, hard_delete: bool = False + ) -> bool: + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return False + + if hard_delete: + await self.session.delete(gateway) + else: + gateway.is_deleted = True + gateway.deleted_at = datetime.now() + gateway.is_active = False + + await self.session.commit() + logger.info(f"Gateway supprimée: {gateway.name} (hard={hard_delete})") + return True + + async def activate( + self, gateway_id: str, user_id: str + ) -> Optional[SageGatewayConfig]: + """Activer une gateway (désactive les autres)""" + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return None + + await self._deactivate_all_for_user(user_id) + + gateway.is_active = True + gateway.updated_at = datetime.now() + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway activée: {gateway.name} pour user {user_id}") + return gateway + + async def deactivate( + self, gateway_id: str, user_id: str + ) -> Optional[SageGatewayConfig]: + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return None + + gateway.is_active = False + gateway.updated_at = datetime.now() + await self.session.commit() + await self.session.refresh(gateway) + + logger.info(f"Gateway désactivée: {gateway.name} - fallback .env actif") + return gateway + + async def get_active_gateway(self, user_id: str) -> Optional[SageGatewayConfig]: + result = await self.session.execute( + select(SageGatewayConfig).where( + and_( + SageGatewayConfig.user_id == user_id, + SageGatewayConfig.is_active, + SageGatewayConfig.is_deleted == false(), + ) + ) + ) + return result.scalar_one_or_none() + + async def get_effective_gateway_config( + self, user_id: Optional[str] + ) -> Tuple[str, str, Optional[str]]: + if user_id: + active = await self.get_active_gateway(user_id) + if active: + active.total_requests += 1 + active.last_used_at = datetime.now() + await self.session.commit() + + return (active.gateway_url, active.gateway_token, active.id) + + return (settings.sage_gateway_url, settings.sage_gateway_token, None) + + async def health_check(self, gateway_id: str, user_id: str) -> dict: + import time + + gateway = await self.get_by_id(gateway_id, user_id) + if not gateway: + return {"error": "Gateway introuvable"} + + start_time = time.time() + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{gateway.gateway_url}/health", + headers={"Authorization": f"Bearer {gateway.gateway_token}"}, + ) + + response_time = (time.time() - start_time) * 1000 + + if response.status_code == 200: + data = response.json() + gateway.last_health_check = datetime.now() + gateway.last_health_status = True + gateway.last_error = None + await self.session.commit() + + return { + "status": "healthy", + "response_time_ms": round(response_time, 2), + "sage_version": data.get("sage_version"), + "details": data, + } + else: + raise Exception(f"HTTP {response.status_code}") + + except Exception as e: + gateway.last_health_check = datetime.now() + gateway.last_health_status = False + gateway.last_error = str(e) + await self.session.commit() + + return { + "status": "unhealthy", + "error": str(e), + "response_time_ms": round((time.time() - start_time) * 1000, 2), + } + + async def test_gateway(self, url: str, token: str) -> dict: + """Tester une configuration gateway avant création""" + import time + + start_time = time.time() + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{url}/health", headers={"Authorization": f"Bearer {token}"} + ) + + response_time = (time.time() - start_time) * 1000 + + if response.status_code == 200: + return { + "success": True, + "status": "healthy", + "response_time_ms": round(response_time, 2), + "details": response.json(), + } + else: + return { + "success": False, + "status": "unhealthy", + "error": f"HTTP {response.status_code}: {response.text}", + } + + except httpx.TimeoutException: + return { + "success": False, + "status": "timeout", + "error": "Connexion timeout (10s)", + } + except httpx.ConnectError as e: + return { + "success": False, + "status": "unreachable", + "error": f"Impossible de se connecter: {e}", + } + except Exception as e: + return {"success": False, "status": "error", "error": str(e)} + + async def record_request(self, gateway_id: str, success: bool) -> None: + """Enregistrer une requête (succès/échec)""" + + if not gateway_id: + return + + result = await self.session.execute( + select(SageGatewayConfig).where(SageGatewayConfig.id == gateway_id) + ) + gateway = result.scalar_one_or_none() + + if gateway: + gateway.total_requests += 1 + if success: + gateway.successful_requests += 1 + else: + gateway.failed_requests += 1 + gateway.last_used_at = datetime.now() + await self.session.commit() + + async def get_stats(self, user_id: str) -> dict: + """Statistiques d'utilisation pour un utilisateur""" + gateways = await self.list_for_user(user_id) + + total_requests = sum(g.total_requests for g in gateways) + successful = sum(g.successful_requests for g in gateways) + failed = sum(g.failed_requests for g in gateways) + + most_used = max(gateways, key=lambda g: g.total_requests) if gateways else None + last_activity = max( + (g.last_used_at for g in gateways if g.last_used_at), default=None + ) + + return { + "total_gateways": len(gateways), + "active_gateways": sum(1 for g in gateways if g.is_active), + "total_requests": total_requests, + "successful_requests": successful, + "failed_requests": failed, + "average_success_rate": (successful / total_requests * 100) + if total_requests > 0 + else 0, + "most_used_gateway": most_used.name if most_used else None, + "last_activity": last_activity, + } + + async def _deactivate_all_for_user(self, user_id: str) -> None: + """Désactiver toutes les gateways d'un utilisateur""" + + await self.session.execute( + update(SageGatewayConfig) + .where(SageGatewayConfig.user_id == user_id) + .values(is_active=False) + ) + + async def _unset_default_for_user(self, user_id: str) -> None: + """Retirer le flag default de toutes les gateways""" + + await self.session.execute( + update(SageGatewayConfig) + .where(SageGatewayConfig.user_id == user_id) + .values(is_default=False) + ) + + +def gateway_response_from_model(gateway: SageGatewayConfig) -> dict: + """Convertir un model en réponse API (masque le token)""" + + token_preview = ( + f"****{gateway.gateway_token[-4:]}" if gateway.gateway_token else "****" + ) + + success_rate = 0.0 + if gateway.total_requests > 0: + success_rate = (gateway.successful_requests / gateway.total_requests) * 100 + + if gateway.last_health_status is None: + health_status = "unknown" + elif gateway.last_health_status: + health_status = "healthy" + else: + health_status = "unhealthy" + + extra_config = None + if gateway.extra_config: + try: + extra_config = json.loads(gateway.extra_config) + except json.JSONDecodeError: + pass + + allowed_ips = None + if gateway.allowed_ips: + try: + allowed_ips = json.loads(gateway.allowed_ips) + except json.JSONDecodeError: + pass + + return { + "id": gateway.id, + "user_id": gateway.user_id, + "name": gateway.name, + "description": gateway.description, + "gateway_url": gateway.gateway_url, + "token_preview": token_preview, + "sage_database": gateway.sage_database, + "sage_company": gateway.sage_company, + "is_active": gateway.is_active, + "is_default": gateway.is_default, + "priority": gateway.priority, + "health_status": health_status, + "last_health_check": gateway.last_health_check, + "last_error": gateway.last_error, + "total_requests": gateway.total_requests, + "successful_requests": gateway.successful_requests, + "failed_requests": gateway.failed_requests, + "success_rate": round(success_rate, 2), + "last_used_at": gateway.last_used_at, + "extra_config": extra_config, + "allowed_ips": allowed_ips, + "created_at": gateway.created_at, + "updated_at": gateway.updated_at, + } diff --git a/services/universign_document.py b/services/universign_document.py new file mode 100644 index 0000000..98baf68 --- /dev/null +++ b/services/universign_document.py @@ -0,0 +1,156 @@ +import os +import logging +import requests +from pathlib import Path +from datetime import datetime +from typing import Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents")) +SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True) + + +class UniversignDocumentService: + """Service de gestion des documents signés Universign""" + + def __init__(self, api_key: str, timeout: int = 60): + self.api_key = api_key + self.timeout = timeout + self.auth = (api_key, "") + + async def download_and_store_signed_document( + self, session: AsyncSession, transaction, force: bool = False + ) -> Tuple[bool, Optional[str]]: + if not force and transaction.signed_document_path: + if os.path.exists(transaction.signed_document_path): + logger.debug(f"Document déjà téléchargé : {transaction.transaction_id}") + return True, None + + if not transaction.document_url: + error = "Aucune URL de document disponible" + logger.warning(f"{error} pour {transaction.transaction_id}") + transaction.download_error = error + await session.commit() + return False, error + + try: + logger.info(f"Téléchargement document signé : {transaction.transaction_id}") + + transaction.download_attempts += 1 + + response = requests.get( + transaction.document_url, + auth=self.auth, + timeout=self.timeout, + stream=True, + ) + + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower(): + error = f"Type de contenu invalide : {content_type}" + logger.error(error) + transaction.download_error = error + await session.commit() + return False, error + + filename = self._generate_filename(transaction) + file_path = SIGNED_DOCS_DIR / filename + + with open(file_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + file_size = os.path.getsize(file_path) + + if file_size < 1024: # Moins de 1 KB = suspect + error = f"Fichier trop petit : {file_size} octets" + logger.error(error) + os.remove(file_path) + transaction.download_error = error + await session.commit() + return False, error + + transaction.signed_document_path = str(file_path) + transaction.signed_document_downloaded_at = datetime.now() + transaction.signed_document_size_bytes = file_size + transaction.download_error = None + + await session.commit() + + logger.info(f"Document téléchargé : {filename} ({file_size / 1024:.1f} KB)") + + return True, None + + except requests.exceptions.RequestException as e: + error = f"Erreur HTTP : {str(e)}" + logger.error(f"{error} pour {transaction.transaction_id}") + transaction.download_error = error + await session.commit() + return False, error + + except OSError as e: + error = f"Erreur filesystem : {str(e)}" + logger.error(f"{error}") + transaction.download_error = error + await session.commit() + return False, error + + except Exception as e: + error = f"Erreur inattendue : {str(e)}" + logger.error(f"{error}", exc_info=True) + transaction.download_error = error + await session.commit() + return False, error + + def _generate_filename(self, transaction) -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + tx_id = transaction.transaction_id.replace("tr_", "") + + filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}.pdf" + + return filename + + def get_document_path(self, transaction) -> Optional[Path]: + if not transaction.signed_document_path: + return None + + path = Path(transaction.signed_document_path) + if path.exists(): + return path + + return None + + async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]: + from datetime import timedelta + + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + + deleted = 0 + size_freed = 0 + + for file_path in SIGNED_DOCS_DIR.glob("*.pdf"): + try: + file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) + + if file_time < cutoff_date: + size_freed += os.path.getsize(file_path) + os.remove(file_path) + deleted += 1 + logger.info(f"🗑️ Supprimé : {file_path.name}") + + except Exception as e: + logger.error(f"Erreur suppression {file_path}: {e}") + + size_freed_mb = size_freed / (1024 * 1024) + + logger.info( + f"Nettoyage terminé : {deleted} fichiers supprimés " + f"({size_freed_mb:.2f} MB libérés)" + ) + + return deleted, int(size_freed_mb) diff --git a/services/universign_sync.py b/services/universign_sync.py new file mode 100644 index 0000000..bde966a --- /dev/null +++ b/services/universign_sync.py @@ -0,0 +1,695 @@ +import requests +import json +import logging +import uuid +from typing import Dict, Optional, Tuple +from datetime import datetime, timedelta +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +from sqlalchemy.orm import selectinload + +from database import ( + UniversignTransaction, + UniversignSigner, + UniversignSyncLog, + UniversignTransactionStatus, + LocalDocumentStatus, + UniversignSignerStatus, + EmailLog, + StatutEmail, +) +from data.data import templates_signature_email +from services.universign_document import UniversignDocumentService +from utils.universign_status_mapping import ( + map_universign_to_local, + is_transition_allowed, + get_status_actions, + is_final_status, + resolve_status_conflict, +) + +logger = logging.getLogger(__name__) + + +class UniversignSyncService: + def __init__(self, api_url: str, api_key: str, timeout: int = 30): + self.api_url = api_url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self.auth = (api_key, "") + self.sage_client = None + self.email_queue = None + self.settings = None + self.document_service = UniversignDocumentService(api_key=api_key, timeout=60) + + def configure(self, sage_client, email_queue, settings): + self.sage_client = sage_client + self.email_queue = email_queue + self.settings = settings + + def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]: + start_time = datetime.now() + + try: + response = requests.get( + f"{self.api_url}/transactions/{transaction_id}", + auth=self.auth, + timeout=self.timeout, + headers={"Accept": "application/json"}, + ) + + response_time_ms = int((datetime.now() - start_time).total_seconds() * 1000) + + if response.status_code == 200: + data = response.json() + logger.info( + f"Fetch OK: {transaction_id} status={data.get('state')} ({response_time_ms}ms)" + ) + return { + "transaction": data, + "http_status": 200, + "response_time_ms": response_time_ms, + "fetched_at": datetime.now(), + } + + elif response.status_code == 404: + logger.warning( + f"Transaction {transaction_id} introuvable sur Universign" + ) + return None + + else: + logger.error( + f"Erreur HTTP {response.status_code} pour {transaction_id}: {response.text}" + ) + return None + + except requests.exceptions.Timeout: + logger.error(f"Timeout récupération {transaction_id} (>{self.timeout}s)") + return None + + except Exception as e: + logger.error(f"Erreur fetch {transaction_id}: {e}", exc_info=True) + return None + + async def sync_all_pending( + self, session: AsyncSession, max_transactions: int = 50 + ) -> Dict[str, int]: + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .where( + and_( + UniversignTransaction.needs_sync, + or_( + ~UniversignTransaction.local_status.in_( + [ + LocalDocumentStatus.SIGNED, + LocalDocumentStatus.REJECTED, + LocalDocumentStatus.EXPIRED, + ] + ), + UniversignTransaction.last_synced_at + < (datetime.now() - timedelta(hours=1)), + UniversignTransaction.last_synced_at.is_(None), + ), + ) + ) + .order_by(UniversignTransaction.created_at.asc()) + .limit(max_transactions) + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + stats = { + "total_found": len(transactions), + "success": 0, + "failed": 0, + "skipped": 0, + "status_changes": 0, + } + + for transaction in transactions: + try: + previous_status = transaction.local_status.value + + success, error = await self.sync_transaction( + session, transaction, force=False + ) + + if success: + stats["success"] += 1 + if transaction.local_status.value != previous_status: + stats["status_changes"] += 1 + else: + stats["failed"] += 1 + + except Exception as e: + logger.error( + f"Erreur sync {transaction.transaction_id}: {e}", exc_info=True + ) + stats["failed"] += 1 + + logger.info( + f"Polling terminé: {stats['success']}/{stats['total_found']} OK, {stats['status_changes']} changements détectés" + ) + + return stats + + # CORRECTION 1 : process_webhook dans universign_sync.py + async def process_webhook( + self, session: AsyncSession, payload: Dict, transaction_id: str = None + ) -> Tuple[bool, Optional[str]]: + """ + Traite un webhook Universign - CORRECTION : meilleure gestion des payloads + """ + try: + # Si transaction_id n'est pas fourni, essayer de l'extraire + if not transaction_id: + # Même logique que dans universign.py + if ( + payload.get("type", "").startswith("transaction.") + and "payload" in payload + ): + nested_object = payload.get("payload", {}).get("object", {}) + if nested_object.get("object") == "transaction": + transaction_id = nested_object.get("id") + elif payload.get("type", "").startswith("action."): + transaction_id = ( + payload.get("payload", {}) + .get("object", {}) + .get("transaction_id") + ) + elif payload.get("object") == "transaction": + transaction_id = payload.get("id") + + if not transaction_id: + return False, "Transaction ID manquant" + + event_type = payload.get("type", "webhook") + + logger.info( + f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}" + ) + + # Récupérer la transaction locale + query = ( + select(UniversignTransaction) + .options(selectinload(UniversignTransaction.signers)) + .where(UniversignTransaction.transaction_id == transaction_id) + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + logger.warning(f"Transaction {transaction_id} inconnue localement") + return False, "Transaction inconnue" + + # Marquer comme webhook reçu + transaction.webhook_received = True + + # Stocker l'ancien statut pour comparaison + old_status = transaction.local_status.value + + # Force la synchronisation complète + success, error = await self.sync_transaction( + session, transaction, force=True + ) + + # Log du changement de statut + if success and transaction.local_status.value != old_status: + logger.info( + f"Webhook traité: {transaction_id} | " + f"{old_status} → {transaction.local_status.value}" + ) + + # Enregistrer le log du webhook + await self._log_sync_attempt( + session=session, + transaction=transaction, + sync_type=f"webhook:{event_type}", + success=success, + error_message=error, + previous_status=old_status, + new_status=transaction.local_status.value, + changes=json.dumps( + payload, default=str + ), # Ajout default=str pour éviter les erreurs JSON + ) + + await session.commit() + + return success, error + + except Exception as e: + logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True) + return False, str(e) + + # CORRECTION 2 : _sync_signers - Ne pas écraser les signers existants + async def _sync_signers( + self, + session: AsyncSession, + transaction: UniversignTransaction, + universign_data: Dict, + ): + signers_data = universign_data.get("participants", []) + if not signers_data: + signers_data = universign_data.get("signers", []) + + if not signers_data: + logger.debug("Aucun signataire dans les données Universign") + return + + existing_signers = {s.email: s for s in transaction.signers} + + for idx, signer_data in enumerate(signers_data): + email = signer_data.get("email", "") + if not email: + logger.warning(f"Signataire sans email à l'index {idx}, ignoré") + continue + + # PROTECTION : gérer les statuts inconnus + raw_status = signer_data.get("status") or signer_data.get( + "state", "waiting" + ) + try: + status = UniversignSignerStatus(raw_status) + except ValueError: + logger.warning( + f"Statut inconnu pour signer {email}: {raw_status}, utilisation de 'unknown'" + ) + status = UniversignSignerStatus.UNKNOWN + + if email in existing_signers: + signer = existing_signers[email] + signer.status = status + + viewed_at = self._parse_date(signer_data.get("viewed_at")) + if viewed_at and not signer.viewed_at: + signer.viewed_at = viewed_at + + signed_at = self._parse_date(signer_data.get("signed_at")) + if signed_at and not signer.signed_at: + signer.signed_at = signed_at + + refused_at = self._parse_date(signer_data.get("refused_at")) + if refused_at and not signer.refused_at: + signer.refused_at = refused_at + + if signer_data.get("name") and not signer.name: + signer.name = signer_data.get("name") + else: + # Nouveau signer avec gestion d'erreur intégrée + try: + signer = UniversignSigner( + id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", + transaction_id=transaction.id, + email=email, + name=signer_data.get("name"), + status=status, + order_index=idx, + viewed_at=self._parse_date(signer_data.get("viewed_at")), + signed_at=self._parse_date(signer_data.get("signed_at")), + refused_at=self._parse_date(signer_data.get("refused_at")), + ) + session.add(signer) + logger.info( + f"➕ Nouveau signataire ajouté: {email} (statut: {status.value})" + ) + except Exception as e: + logger.error(f"Erreur création signer {email}: {e}") + + # CORRECTION 3 : Amélioration du logging dans sync_transaction + async def sync_transaction( + self, + session: AsyncSession, + transaction: UniversignTransaction, + force: bool = False, + ) -> Tuple[bool, Optional[str]]: + """ + CORRECTION : Meilleur logging et gestion d'erreurs + """ + + # Si statut final et pas de force, skip + if is_final_status(transaction.local_status.value) and not force: + logger.debug( + f"⏭️ Skip {transaction.transaction_id}: statut final {transaction.local_status.value}" + ) + transaction.needs_sync = False + await session.commit() + return True, None + + # Récupération du statut distant + logger.info(f"🔄 Synchronisation: {transaction.transaction_id}") + + result = self.fetch_transaction_status(transaction.transaction_id) + + if not result: + error = "Échec récupération données Universign" + logger.error(f"{error}: {transaction.transaction_id}") + + # CORRECTION : Incrémenter les tentatives MÊME en cas d'échec + transaction.sync_attempts += 1 + transaction.sync_error = error + + await self._log_sync_attempt(session, transaction, "polling", False, error) + await session.commit() + return False, error + + try: + universign_data = result["transaction"] + universign_status_raw = universign_data.get("state", "draft") + + logger.info(f"📊 Statut Universign brut: {universign_status_raw}") + + # Convertir le statut + new_local_status = map_universign_to_local(universign_status_raw) + previous_local_status = transaction.local_status.value + + logger.info( + f"🔄 Mapping: {universign_status_raw} (Universign) → " + f"{new_local_status} (Local) | Actuel: {previous_local_status}" + ) + + # Vérifier la transition + if not is_transition_allowed(previous_local_status, new_local_status): + logger.warning( + f"Transition refusée: {previous_local_status} → {new_local_status}" + ) + new_local_status = resolve_status_conflict( + previous_local_status, new_local_status + ) + logger.info(f"Résolution conflit: statut résolu = {new_local_status}") + + status_changed = previous_local_status != new_local_status + + if status_changed: + logger.info( + f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" + ) + + # Mise à jour du statut Universign brut + try: + transaction.universign_status = UniversignTransactionStatus( + universign_status_raw + ) + except ValueError: + logger.warning(f"Statut Universign inconnu: {universign_status_raw}") + # Fallback intelligent + if new_local_status == "SIGNE": + transaction.universign_status = ( + UniversignTransactionStatus.COMPLETED + ) + elif new_local_status == "REFUSE": + transaction.universign_status = UniversignTransactionStatus.REFUSED + elif new_local_status == "EXPIRE": + transaction.universign_status = UniversignTransactionStatus.EXPIRED + else: + transaction.universign_status = UniversignTransactionStatus.STARTED + + # Mise à jour du statut local + transaction.local_status = LocalDocumentStatus(new_local_status) + transaction.universign_status_updated_at = datetime.now() + + # Mise à jour des dates + if new_local_status == "EN_COURS" and not transaction.sent_at: + transaction.sent_at = datetime.now() + logger.info("📅 Date d'envoi mise à jour") + + if new_local_status == "SIGNE" and not transaction.signed_at: + transaction.signed_at = datetime.now() + logger.info("Date de signature mise à jour") + + if new_local_status == "REFUSE" and not transaction.refused_at: + transaction.refused_at = datetime.now() + logger.info("Date de refus mise à jour") + + if new_local_status == "EXPIRE" and not transaction.expired_at: + transaction.expired_at = datetime.now() + logger.info("⏰ Date d'expiration mise à jour") + + # Mise à jour des URLs + if ( + universign_data.get("documents") + and len(universign_data["documents"]) > 0 + ): + first_doc = universign_data["documents"][0] + if first_doc.get("url"): + transaction.document_url = first_doc["url"] + + # NOUVEAU : Téléchargement automatique du document signé + if new_local_status == "SIGNE" and transaction.document_url: + if not transaction.signed_document_path: + logger.info("Déclenchement téléchargement document signé") + + ( + download_success, + download_error, + ) = await self.document_service.download_and_store_signed_document( + session=session, transaction=transaction, force=False + ) + + if download_success: + logger.info("Document signé téléchargé avec succès") + else: + logger.warning(f"Échec téléchargement : {download_error}") + + # Synchroniser les signataires + await self._sync_signers(session, transaction, universign_data) + + # Mise à jour des métadonnées de sync + transaction.last_synced_at = datetime.now() + transaction.sync_attempts += 1 + transaction.needs_sync = not is_final_status(new_local_status) + transaction.sync_error = None # Effacer l'erreur précédente + + # Log de la tentative + await self._log_sync_attempt( + session=session, + transaction=transaction, + sync_type="polling", + success=True, + error_message=None, + previous_status=previous_local_status, + new_status=new_local_status, + changes=json.dumps( + { + "status_changed": status_changed, + "universign_raw": universign_status_raw, + "response_time_ms": result.get("response_time_ms"), + }, + default=str, # Éviter les erreurs de sérialisation + ), + ) + + await session.commit() + + # Exécuter les actions post-changement + if status_changed: + logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") + await self._execute_status_actions( + session, transaction, new_local_status + ) + + logger.info( + f"Sync terminée: {transaction.transaction_id} | " + f"{previous_local_status} → {new_local_status}" + ) + + return True, None + + except Exception as e: + error_msg = f"Erreur lors de la synchronisation: {str(e)}" + logger.error(f"{error_msg}", exc_info=True) + + transaction.sync_error = error_msg[:1000] # Tronquer si trop long + transaction.sync_attempts += 1 + + await self._log_sync_attempt( + session, transaction, "polling", False, error_msg + ) + await session.commit() + + return False, error_msg + + async def _log_sync_attempt( + self, + session: AsyncSession, + transaction: UniversignTransaction, + sync_type: str, + success: bool, + error_message: Optional[str] = None, + previous_status: Optional[str] = None, + new_status: Optional[str] = None, + changes: Optional[str] = None, + ): + log = UniversignSyncLog( + transaction_id=transaction.id, + sync_type=sync_type, + sync_timestamp=datetime.now(), + previous_status=previous_status, + new_status=new_status, + changes_detected=changes, + success=success, + error_message=error_message, + ) + session.add(log) + + async def _execute_status_actions( + self, session: AsyncSession, transaction: UniversignTransaction, new_status: str + ): + actions = get_status_actions(new_status) + if not actions: + return + + if actions.get("update_sage_status") and self.sage_client: + await self._update_sage_status(transaction, new_status) + elif actions.get("update_sage_status"): + logger.debug( + f"sage_client non configuré, skip MAJ Sage pour {transaction.sage_document_id}" + ) + + if actions.get("send_notification") and self.email_queue and self.settings: + await self._send_notification(session, transaction, new_status) + elif actions.get("send_notification"): + logger.debug( + f"email_queue/settings non configuré, skip notification pour {transaction.transaction_id}" + ) + + async def _update_sage_status( + self, transaction: UniversignTransaction, status: str + ): + if not self.sage_client: + logger.warning("sage_client non configuré pour mise à jour Sage") + return + + try: + type_doc = transaction.sage_document_type.value + doc_id = transaction.sage_document_id + + if status == "SIGNE": + self.sage_client.changer_statut_document( + document_type_code=type_doc, numero=doc_id, nouveau_statut=2 + ) + logger.info(f"Statut Sage mis à jour: {doc_id} → Accepté (2)") + + elif status == "EN_COURS": + self.sage_client.changer_statut_document( + document_type_code=type_doc, numero=doc_id, nouveau_statut=1 + ) + logger.info(f"Statut Sage mis à jour: {doc_id} → Confirmé (1)") + + except Exception as e: + logger.error( + f"Erreur mise à jour Sage pour {transaction.sage_document_id}: {e}" + ) + + async def _send_notification( + self, session: AsyncSession, transaction: UniversignTransaction, status: str + ): + if not self.email_queue or not self.settings: + logger.warning("email_queue ou settings non configuré") + return + + try: + if status == "SIGNE": + template = templates_signature_email["signature_confirmee"] + + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } + + variables = { + "NOM_SIGNATAIRE": transaction.requester_name or "Client", + "TYPE_DOC": type_labels.get( + transaction.sage_document_type.value, "Document" + ), + "NUMERO": transaction.sage_document_id, + "DATE_SIGNATURE": transaction.signed_at.strftime("%d/%m/%Y à %H:%M") + if transaction.signed_at + else datetime.now().strftime("%d/%m/%Y à %H:%M"), + "TRANSACTION_ID": transaction.transaction_id, + "CONTACT_EMAIL": self.settings.smtp_from, + } + + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=transaction.requester_email, + sujet=sujet, + corps_html=corps, + document_ids=transaction.sage_document_id, + type_document=transaction.sage_document_type.value, + statut=StatutEmail.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + self.email_queue.enqueue(email_log.id) + + logger.info( + f"Email confirmation signature envoyé à {transaction.requester_email}" + ) + + except Exception as e: + logger.error( + f"Erreur envoi notification pour {transaction.transaction_id}: {e}" + ) + + @staticmethod + def _parse_date(date_str: Optional[str]) -> Optional[datetime]: + if not date_str: + return None + try: + return datetime.fromisoformat(date_str.replace("Z", "+00:00")) + except Exception: + return None + + +class UniversignSyncScheduler: + def __init__(self, sync_service: UniversignSyncService, interval_minutes: int = 5): + self.sync_service = sync_service + self.interval_minutes = interval_minutes + self.is_running = False + + async def start(self, session_factory): + import asyncio + + self.is_running = True + + logger.info( + f"Démarrage polling Universign (intervalle: {self.interval_minutes}min)" + ) + + while self.is_running: + try: + async with session_factory() as session: + stats = await self.sync_service.sync_all_pending(session) + + logger.info( + f"Polling: {stats['success']} transactions synchronisées, " + f"{stats['status_changes']} changements" + ) + + except Exception as e: + logger.error(f"Erreur polling: {e}", exc_info=True) + + await asyncio.sleep(self.interval_minutes * 60) + + def stop(self): + self.is_running = False + logger.info("Arrêt polling Universign") diff --git a/tools/cleaner.py b/tools/cleaner.py new file mode 100644 index 0000000..6da2e19 --- /dev/null +++ b/tools/cleaner.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def supprimer_commentaires_ligne(fichier): + path = Path(fichier) + lignes = path.read_text(encoding="utf-8").splitlines() + lignes_sans_commentaires = [line for line in lignes if not line.lstrip().startswith("#")] + path.write_text("\n".join(lignes_sans_commentaires), encoding="utf-8") + + +if __name__ == "__main__": + base_dir = Path(__file__).resolve().parent.parent + fichier_api = base_dir / "data/data.py" + + supprimer_commentaires_ligne(fichier_api) diff --git a/tools/extract_pydantic_models.py b/tools/extract_pydantic_models.py new file mode 100644 index 0000000..595e15f --- /dev/null +++ b/tools/extract_pydantic_models.py @@ -0,0 +1,54 @@ +import ast +import os +import textwrap + +SOURCE_FILE = "main.py" +MODELS_DIR = "../models" + +os.makedirs(MODELS_DIR, exist_ok=True) + +with open(SOURCE_FILE, "r", encoding="utf-8") as f: + source_code = f.read() + +tree = ast.parse(source_code) + +pydantic_classes = [] +other_nodes = [] + +for node in tree.body: + if isinstance(node, ast.ClassDef): + if any( + isinstance(base, ast.Name) and base.id == "BaseModel" for base in node.bases + ): + pydantic_classes.append(node) + continue + other_nodes.append(node) + +# --- Extraction des classes --- +imports = """ +from pydantic import BaseModel, Field +from typing import Optional, List +""" + +for cls in pydantic_classes: + class_name = cls.name + file_name = f"{class_name.lower()}.py" + file_path = os.path.join(MODELS_DIR, file_name) + + class_code = ast.get_source_segment(source_code, cls) + class_code = textwrap.dedent(class_code) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(imports.strip() + "\n\n") + f.write(class_code) + + print(f"✅ Modèle extrait : {class_name} → {file_path}") + +# --- Réécriture du fichier source sans les modèles --- +new_tree = ast.Module(body=other_nodes, type_ignores=[]) +new_source = ast.unparse(new_tree) + +with open(SOURCE_FILE, "w", encoding="utf-8") as f: + f.write(new_source) + +print("\n🎉 Extraction terminée") diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..562d73f --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,27 @@ +from .enums import ( + TypeArticle, + TypeCompta, + TypeRessource, + TypeTiers, + TypeEmplacement, + TypeFamille, + NomenclatureType, + SuiviStockType, + normalize_enum_to_string, + normalize_enum_to_int, + normalize_string_field, +) + +__all__ = [ + "TypeArticle", + "TypeCompta", + "TypeRessource", + "TypeTiers", + "TypeEmplacement", + "TypeFamille", + "NomenclatureType", + "SuiviStockType", + "normalize_enum_to_string", + "normalize_enum_to_int", + "normalize_string_field", +] diff --git a/utils/enums.py b/utils/enums.py new file mode 100644 index 0000000..646fd01 --- /dev/null +++ b/utils/enums.py @@ -0,0 +1,129 @@ +from enum import IntEnum +from typing import Optional + + +class SuiviStockType(IntEnum): + AUCUN = 0 + CMUP = 1 + FIFO_LIFO = 2 + SERIALISE = 3 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Aucun", 1: "CMUP", 2: "FIFO/LIFO", 3: "Sérialisé"} + return labels.get(value) if value is not None else None + + +class NomenclatureType(IntEnum): + NON = 0 + FABRICATION = 1 + COMMERCIALE = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Non", 1: "Fabrication", 2: "Commerciale/Composé"} + return labels.get(value) if value is not None else None + + +class TypeArticle(IntEnum): + ARTICLE = 0 + PRESTATION = 1 + DIVERS = 2 + NOMENCLATURE = 3 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = { + 0: "Article", + 1: "Prestation de service", + 2: "Divers / Frais", + 3: "Nomenclature", + } + return labels.get(value) if value is not None else None + + +class TypeFamille(IntEnum): + DETAIL = 0 + TOTAL = 1 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Détail", 1: "Total"} + return labels.get(value) if value is not None else None + + +class TypeCompta(IntEnum): + VENTE = 0 + ACHAT = 1 + STOCK = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Vente", 1: "Achat", 2: "Stock"} + return labels.get(value) if value is not None else None + + +class TypeRessource(IntEnum): + MAIN_OEUVRE = 0 + MACHINE = 1 + SOUS_TRAITANCE = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Main d'œuvre", 1: "Machine", 2: "Sous-traitance"} + return labels.get(value) if value is not None else None + + +class TypeTiers(IntEnum): + CLIENT = 0 + FOURNISSEUR = 1 + SALARIE = 2 + AUTRE = 3 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Client", 1: "Fournisseur", 2: "Salarié", 3: "Autre"} + return labels.get(value) if value is not None else None + + +class TypeEmplacement(IntEnum): + NORMAL = 0 + QUARANTAINE = 1 + REBUT = 2 + + @classmethod + def get_label(cls, value: Optional[int]) -> Optional[str]: + labels = {0: "Normal", 1: "Quarantaine", 2: "Rebut"} + return labels.get(value) if value is not None else None + + +def normalize_enum_to_string(value, default="0") -> Optional[str]: + if value is None: + return None + if value == 0: + return None + return str(value) + + +def normalize_enum_to_int(value, default=0) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return default + + +def normalize_string_field(value) -> Optional[str]: + if value is None: + return None + if isinstance(value, int): + if value == 0: + return None + return str(value) + if isinstance(value, str): + stripped = value.strip() + if stripped in ("", "0"): + return None + return stripped + return str(value) diff --git a/utils/generic_functions.py b/utils/generic_functions.py new file mode 100644 index 0000000..f09ee5f --- /dev/null +++ b/utils/generic_functions.py @@ -0,0 +1,468 @@ +from typing import Dict, List +from config.config import settings +import logging + +from datetime import datetime +import uuid +import requests + +from sqlalchemy.ext.asyncio import AsyncSession + +from data.data import templates_signature_email +from database import EmailLog, StatutEmail as StatutEmailEnum + +logger = logging.getLogger(__name__) + + +async def universign_envoyer( + doc_id: str, + pdf_bytes: bytes, + email: str, + nom: str, + doc_data: Dict, + session: AsyncSession, +) -> Dict: + from email_queue import email_queue + + try: + api_key = settings.universign_api_key + api_url = settings.universign_api_url + auth = (api_key, "") + + logger.info(f" Démarrage processus Universign pour {email}") + logger.info(f"Document: {doc_id} ({doc_data.get('type_label')})") + + if not pdf_bytes or len(pdf_bytes) == 0: + raise Exception("Le PDF généré est vide") + + logger.info(f"PDF valide : {len(pdf_bytes)} octets") + + logger.info("ÉTAPE 1/6 : Création transaction") + + response = requests.post( + f"{api_url}/transactions", + auth=auth, + json={ + "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", + "language": "fr", + }, + timeout=30, + ) + + if response.status_code != 200: + logger.error(f"Erreur création transaction: {response.text}") + raise Exception(f"Erreur création transaction: {response.status_code}") + + transaction_id = response.json().get("id") + logger.info(f"Transaction créée: {transaction_id}") + + logger.info("ÉTAPE 2/6 : Upload PDF") + + files = { + "file": ( + f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", + pdf_bytes, + "application/pdf", + ) + } + + response = requests.post( + f"{api_url}/files", + auth=auth, + files=files, + timeout=60, + ) + + if response.status_code not in [200, 201]: + logger.error(f"Erreur upload: {response.text}") + raise Exception(f"Erreur upload fichier: {response.status_code}") + + file_id = response.json().get("id") + logger.info(f"Fichier uploadé: {file_id}") + + logger.info("ÉTAPE 3/6 : Ajout document à transaction") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/documents", + auth=auth, + data={"document": file_id}, + timeout=30, + ) + + if response.status_code not in [200, 201]: + logger.error(f"Erreur ajout document: {response.text}") + raise Exception(f"Erreur ajout document: {response.status_code}") + + document_id = response.json().get("id") + logger.info(f"Document ajouté: {document_id}") + + logger.info("ÉTAPE 4/6 : Création champ signature") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", + auth=auth, + data={ + "type": "signature", + }, + timeout=30, + ) + + if response.status_code not in [200, 201]: + logger.error(f"Erreur création champ: {response.text}") + raise Exception(f"Erreur création champ: {response.status_code}") + + field_id = response.json().get("id") + logger.info(f"Champ créé: {field_id}") + + logger.info(" ÉTAPE 5/6 : Liaison signataire au champ") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers + auth=auth, + data={ + "signer": email, + "field": field_id, + }, + timeout=30, + ) + + if response.status_code not in [200, 201]: + logger.error(f"Erreur liaison signataire: {response.text}") + raise Exception(f"Erreur liaison signataire: {response.status_code}") + + logger.info(f"Signataire lié: {email}") + + logger.info("ÉTAPE 6/6 : Démarrage transaction") + + response = requests.post( + f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 + ) + + if response.status_code not in [200, 201]: + logger.error(f"Erreur démarrage: {response.text}") + raise Exception(f"Erreur démarrage: {response.status_code}") + + final_data = response.json() + logger.info("Transaction démarrée") + + logger.info("Récupération URL de signature") + + signer_url = "" + + if final_data.get("actions"): + for action in final_data["actions"]: + if action.get("url"): + signer_url = action["url"] + break + + if not signer_url and final_data.get("signers"): + for signer in final_data["signers"]: + if signer.get("email") == email: + signer_url = signer.get("url", "") + break + + if not signer_url: + logger.error(f"URL introuvable dans: {final_data}") + raise ValueError("URL de signature non retournée par Universign") + + logger.info("URL récupérée") + + logger.info(" Préparation email") + + template = templates_signature_email["demande_signature"] + + type_labels = { + 0: "Devis", + 10: "Commande", + 30: "Bon de Livraison", + 60: "Facture", + 50: "Avoir", + } + + variables = { + "NOM_SIGNATAIRE": nom, + "TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"), + "NUMERO": doc_id, + "DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")), + "MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}", + "SIGNER_URL": signer_url, + "CONTACT_EMAIL": settings.smtp_from, + } + + sujet = template["sujet"] + corps = template["corps_html"] + + for var, valeur in variables.items(): + sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) + corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) + + email_log = EmailLog( + id=str(uuid.uuid4()), + destinataire=email, + sujet=sujet, + corps_html=corps, + document_ids=doc_id, + type_document=doc_data.get("type_doc"), + statut=StatutEmailEnum.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0, + ) + + session.add(email_log) + await session.flush() + + email_queue.enqueue(email_log.id) + + logger.info(f"Email mis en file pour {email}") + logger.info("🎉 Processus terminé avec succès") + + return { + "transaction_id": transaction_id, + "signer_url": signer_url, + "statut": "ENVOYE", + "email_log_id": email_log.id, + "email_sent": True, + } + + except Exception as e: + logger.error(f"Erreur Universign: {e}", exc_info=True) + return { + "error": str(e), + "statut": "ERREUR", + "email_sent": False, + } + + +async def universign_statut(transaction_id: str) -> Dict: + """Récupération statut signature""" + import requests + + try: + response = requests.get( + f"{settings.universign_api_url}/transactions/{transaction_id}", + auth=(settings.universign_api_key, ""), + timeout=10, + ) + + if response.status_code == 200: + data = response.json() + statut_map = { + "draft": "EN_ATTENTE", + "started": "EN_ATTENTE", + "completed": "SIGNE", + "refused": "REFUSE", + "expired": "EXPIRE", + "canceled": "REFUSE", + } + return { + "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), + "date_signature": data.get("completed_at"), + } + else: + return {"statut": "ERREUR"} + + except Exception as e: + logger.error(f"Erreur statut Universign: {e}") + return {"statut": "ERREUR", "error": str(e)} + + +def normaliser_type_doc(type_doc: int) -> int: + TYPES_AUTORISES = {0, 10, 30, 50, 60} + + if type_doc not in TYPES_AUTORISES: + raise ValueError( + f"type_doc invalide ({type_doc}). Valeurs autorisées : {sorted(TYPES_AUTORISES)}" + ) + + return type_doc if type_doc == 0 else type_doc // 10 + + +def _preparer_lignes_document(lignes: List) -> List[Dict]: + return [ + { + "article_code": ligne.article_code, + "quantite": ligne.quantite, + "prix_unitaire_ht": ligne.prix_unitaire_ht, + "remise_pourcentage": ligne.remise_pourcentage or 0.0, + } + for ligne in lignes + ] + + +UNIVERSIGN_TO_LOCAL: Dict[str, str] = { + # États initiaux + "draft": "EN_ATTENTE", + "ready": "EN_ATTENTE", + # En cours + "started": "EN_COURS", + # États finaux (succès) + "completed": "SIGNE", + "closed": "SIGNE", + # États finaux (échec) + "refused": "REFUSE", + "expired": "EXPIRE", + "canceled": "REFUSE", + "failed": "ERREUR", +} + + +LOCAL_TO_SAGE_STATUS: Dict[str, int] = { + "EN_ATTENTE": 0, + "EN_COURS": 1, + "SIGNE": 2, + "REFUSE": 3, + "EXPIRE": 4, + "ERREUR": 5, +} + +STATUS_ACTIONS: Dict[str, Dict[str, any]] = { + """ + Actions automatiques à déclencher selon le statut + """ + "SIGNE": { + "update_sage_status": True, # Mettre à jour Sage + "trigger_workflow": True, # Déclencher transformation (devis→commande) + "send_notification": True, # Email de confirmation + "archive_document": True, # Archiver le PDF signé + "update_sage_field": "CB_DateSignature", # Champ libre Sage + }, + "REFUSE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "alert_sales": True, # Alerter commercial + }, + "EXPIRE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "schedule_reminder": True, # Programmer relance + }, + "ERREUR": { + "update_sage_status": False, + "trigger_workflow": False, + "send_notification": False, + "log_error": True, + "retry_sync": True, + }, +} + + +ALLOWED_TRANSITIONS: Dict[str, list] = { + """ + Transitions de statuts autorisées (validation) + """ + "EN_ATTENTE": ["EN_COURS", "ERREUR"], + "EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"], + "SIGNE": [], # État final, pas de retour + "REFUSE": [], # État final + "EXPIRE": [], # État final + "ERREUR": ["EN_ATTENTE", "EN_COURS"], # Retry possible +} + + +def map_universign_to_local(universign_status: str) -> str: + return UNIVERSIGN_TO_LOCAL.get( + universign_status.lower(), + "ERREUR", # Fallback si statut inconnu + ) + + +def get_sage_status_code(local_status: str) -> int: + return LOCAL_TO_SAGE_STATUS.get(local_status, 5) + + +def is_transition_allowed(from_status: str, to_status: str) -> bool: + if from_status == to_status: + return True # Même statut = OK (idempotence) + + allowed = ALLOWED_TRANSITIONS.get(from_status, []) + return to_status in allowed + + +def get_status_actions(local_status: str) -> Dict[str, any]: + return STATUS_ACTIONS.get(local_status, {}) + + +def is_final_status(local_status: str) -> bool: + return local_status in ["SIGNE", "REFUSE", "EXPIRE"] + + +STATUS_PRIORITY: Dict[str, int] = { + "ERREUR": 0, + "EN_ATTENTE": 1, + "EN_COURS": 2, + "EXPIRE": 3, + "REFUSE": 4, + "SIGNE": 5, +} + + +def resolve_status_conflict(status_a: str, status_b: str) -> str: + priority_a = STATUS_PRIORITY.get(status_a, 0) + priority_b = STATUS_PRIORITY.get(status_b, 0) + + return status_a if priority_a >= priority_b else status_b + + +STATUS_MESSAGES: Dict[str, Dict[str, str]] = { + "EN_ATTENTE": { + "fr": "Document en attente d'envoi", + "en": "Document pending", + "icon": "⏳", + "color": "gray", + }, + "EN_COURS": { + "fr": "En attente de signature", + "en": "Awaiting signature", + "icon": "✍️", + "color": "blue", + }, + "SIGNE": { + "fr": "Signé avec succès", + "en": "Successfully signed", + "icon": "✅", + "color": "green", + }, + "REFUSE": { + "fr": "Signature refusée", + "en": "Signature refused", + "icon": "❌", + "color": "red", + }, + "EXPIRE": { + "fr": "Délai de signature expiré", + "en": "Signature expired", + "icon": "⏰", + "color": "orange", + }, + "ERREUR": { + "fr": "Erreur technique", + "en": "Technical error", + "icon": "⚠️", + "color": "red", + }, +} + + +def get_status_message(local_status: str, lang: str = "fr") -> str: + """ + Obtient le message utilisateur pour un statut + + Args: + local_status: Statut local + lang: Langue (fr, en) + + Returns: + Message formaté + """ + status_info = STATUS_MESSAGES.get(local_status, {}) + icon = status_info.get("icon", "") + message = status_info.get(lang, local_status) + + return f"{icon} {message}" + + +__all__ = ["_preparer_lignes_document", "normaliser_type_doc"] diff --git a/utils/normalization.py b/utils/normalization.py new file mode 100644 index 0000000..a7750af --- /dev/null +++ b/utils/normalization.py @@ -0,0 +1,16 @@ +from typing import Optional, Union + + +def normaliser_type_tiers(type_tiers: Union[str, int, None]) -> Optional[str]: + if type_tiers is None: + return None + + mapping_int = {0: "client", 1: "fournisseur", 2: "prospect", 3: "all"} + + if isinstance(type_tiers, int): + return mapping_int.get(type_tiers, "all") + + if isinstance(type_tiers, str) and type_tiers.isdigit(): + return mapping_int.get(int(type_tiers), "all") + + return type_tiers.lower() if isinstance(type_tiers, str) else None diff --git a/utils/universign_status_mapping.py b/utils/universign_status_mapping.py new file mode 100644 index 0000000..90bb383 --- /dev/null +++ b/utils/universign_status_mapping.py @@ -0,0 +1,165 @@ +from typing import Dict, Any +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +UNIVERSIGN_TO_LOCAL: Dict[str, str] = { + "draft": "EN_ATTENTE", + "ready": "EN_ATTENTE", + "started": "EN_COURS", + "completed": "SIGNE", + "closed": "SIGNE", + "refused": "REFUSE", + "expired": "EXPIRE", + "canceled": "REFUSE", + "failed": "ERREUR", +} + +LOCAL_TO_SAGE_STATUS: Dict[str, int] = { + "EN_ATTENTE": 0, + "EN_COURS": 1, + "SIGNE": 2, + "REFUSE": 3, + "EXPIRE": 4, + "ERREUR": 5, +} + +STATUS_ACTIONS: Dict[str, Dict[str, Any]] = { + "SIGNE": { + "update_sage_status": True, + "trigger_workflow": True, + "send_notification": True, + "archive_document": True, + "update_sage_field": "CB_DateSignature", + }, + "REFUSE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "alert_sales": True, + }, + "EXPIRE": { + "update_sage_status": True, + "trigger_workflow": False, + "send_notification": True, + "archive_document": False, + "schedule_reminder": True, + }, + "ERREUR": { + "update_sage_status": False, + "trigger_workflow": False, + "send_notification": False, + "log_error": True, + "retry_sync": True, + }, +} + +ALLOWED_TRANSITIONS: Dict[str, list] = { + "EN_ATTENTE": ["EN_COURS", "ERREUR"], + "EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"], + "SIGNE": [], + "REFUSE": [], + "EXPIRE": [], + "ERREUR": ["EN_ATTENTE", "EN_COURS"], +} + +STATUS_PRIORITY: Dict[str, int] = { + "ERREUR": 0, + "EN_ATTENTE": 1, + "EN_COURS": 2, + "EXPIRE": 3, + "REFUSE": 4, + "SIGNE": 5, +} + +STATUS_MESSAGES: Dict[str, Dict[str, str]] = { + "EN_ATTENTE": { + "fr": "Document en attente d'envoi", + "en": "Document pending", + "icon": "⏳", + "color": "gray", + }, + "EN_COURS": { + "fr": "En attente de signature", + "en": "Awaiting signature", + "icon": "✍️", + "color": "blue", + }, + "SIGNE": { + "fr": "Signé avec succès", + "en": "Successfully signed", + "icon": "✅", + "color": "green", + }, + "REFUSE": { + "fr": "Signature refusée", + "en": "Signature refused", + "icon": "❌", + "color": "red", + }, + "EXPIRE": { + "fr": "Délai de signature expiré", + "en": "Signature expired", + "icon": "⏰", + "color": "orange", + }, + "ERREUR": { + "fr": "Erreur technique", + "en": "Technical error", + "icon": "⚠️", + "color": "red", + }, +} + + +def map_universign_to_local(universign_status: str) -> str: + """Convertit un statut Universign en statut local avec fallback robuste.""" + normalized = universign_status.lower().strip() + mapped = UNIVERSIGN_TO_LOCAL.get(normalized) + + if not mapped: + logger.warning( + f"Statut Universign inconnu: '{universign_status}', mapping vers ERREUR" + ) + return "ERREUR" + + return mapped + + +def get_sage_status_code(local_status: str) -> int: + """Obtient le code numérique pour Sage.""" + return LOCAL_TO_SAGE_STATUS.get(local_status, 5) + + +def is_transition_allowed(from_status: str, to_status: str) -> bool: + """Vérifie si une transition de statut est valide.""" + if from_status == to_status: + return True + return to_status in ALLOWED_TRANSITIONS.get(from_status, []) + + +def get_status_actions(local_status: str) -> Dict[str, Any]: + """Obtient les actions à exécuter pour un statut.""" + return STATUS_ACTIONS.get(local_status, {}) + + +def is_final_status(local_status: str) -> bool: + """Détermine si le statut est final.""" + return local_status in ["SIGNE", "REFUSE", "EXPIRE"] + + +def resolve_status_conflict(status_a: str, status_b: str) -> str: + """Résout un conflit entre deux statuts (prend le plus prioritaire).""" + priority_a = STATUS_PRIORITY.get(status_a, 0) + priority_b = STATUS_PRIORITY.get(status_b, 0) + return status_a if priority_a >= priority_b else status_b + + +def get_status_message(local_status: str, lang: str = "fr") -> str: + """Obtient le message utilisateur pour un statut.""" + status_info = STATUS_MESSAGES.get(local_status, {}) + icon = status_info.get("icon", "") + message = status_info.get(lang, local_status) + return f"{icon} {message}" From b3419eafaa2e9ecf171b386b73955015f0028740 Mon Sep 17 00:00:00 2001 From: fanilo Date: Thu, 8 Jan 2026 17:29:16 +0000 Subject: [PATCH 02/36] Deleted cached dev database --- data/sage_dataven.db | Bin 282624 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 data/sage_dataven.db diff --git a/data/sage_dataven.db b/data/sage_dataven.db deleted file mode 100644 index 925d98bbeb764bf87bc4f41d7b5eefd269e97091..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 282624 zcmeI*Uu@h)eg|-EEH5p|@}DFQFUjRXT`uwR?A&={uME7 z-}E2$c{cq(f7XQGC;i%*`IcQfN>h)V{29yBKKUOfe}3|33;#85E&Tc1KhFKiu{V@I zRo<@ubT+8}-t5tpkTT8W%UC$x=?!HO-mfJIX{!VCykrfT2 z{D8XBG1^^2Ti;wYZfRwCwXIDp&#yJ&67&ZLPb%utQti{Lk#(m>@*MeZXOgnJ?Q0u` zRL1uB${J1cLsJB^E%b!~I2t8L!g*f_7v&p9NFT+fOuHy~N2S6VGOmu+iZ<5riQ zVB2o?dpP_)iL$d=7cLae@`FKW?neC{KSQq9w}*Y=MW*Y7S$WaHfS68_LY2D4n$cmE z#td0a%?mnjyA=`B4u}=GzNf8H+3xxcL#~EpA>N+vX6f0P{&0^3(U4wudMZou?wAp6 z7cFy-gkl>U5_m2NXmLS6hC$ZoLEziFmg|`TiGl;EEO(I9o$6m|@rDmW5}4KwttPu9 zhR3RZr?Y;e-MOv3ZQRxxuA|TE7thqywWZn&kFW5f9vu>`zS&X)39t=%#8fI#N)@GOk(He=K8xgh47qo5{puvrZvV()XC$D+M;zY z$aM!KB#}_*6H_Y{atpNyNA=F>x~i+SP_Bg=@U}fZwS1mWD4BoOAHIB2QB}3}*%dy{ zcscx!Q_|>4u6^pjiYrJver!bTTH!8n%z+h#_xvCqpJN$4zn=w3vpB*A0hyW}_`Q5o zx8ewM(=Z^8c5Q2G!)R}^z#H$#tXkHl@+>CVAJB;-l%qa0Et?G=vG{E|Dbnku(;^)y zRzxSoK_m^Yo^Ri!8HQfu_N3y3J|An5kXG6hJ~_}*>GF||acO!O4jei`Na^fCdsZ05 zP0da#ADjBAx_VWeG&Xsdm`(oU^bb$a5vkWkPp9!JQ<#z4r&F=jAIQf~CV`hN8FF%d z8jEE)O>1Oj>Vri^U8c3qO{kqmt9<&2Lu$@OkbIifh= zP(FY0kZSYu%C0z1)$c#Qps2cDdvL(VvRsS&>9{ny0!~;uxaK6+LLPrMkvfmQj5^?xS;Q9|dHa_Q-BL zONa3flTB4}!b0LzTrcEY!s)E5w9yG`X-H)(-YdknOqtyV(aFMfg&fH z$w_5ZCrSGK?jH+fB8YJ!PY0=(}SZl?v{kO%k!9QVMxx-kJhKu zB%DyJPDs*+vx?fLb)HFMJujVNhoL_VSVueg;iFS370)QvDyHf8-#WtYARd0mLp?7& z|7}(Z@%;Fd88CLtB64hhk~e{E?X!HzP9#qiZctbmES!dPAK-?x6X;eu9XV;*xq62)(!I<@Xqc-Mi2=k2-DMU#i6m%g3D3w(MPE_GvY=EKC0Mm_#b4O<|2Q7RjUu zfp1ZgA(J(nxzXE$zG?e(A0^(rq=U;D2Jz;eRR!kRh2j3cs@;q0f&c^{009U<00Izz00bZa0SIsbJpV@^KmY;|fB*y_ z009U<00Izz00gQpfam|!?_*pc1Rwwb2tWV=5P$##AOHafKmhmuhye&d00Izz00bZa z0SG_<0uX>e^#ySMU;RGD6+!?45P$##AOHafKmY;|fB*#W_x}+C5P$##AOHafKmY;| zfB*y_0D``xsXU0SG_<0uX=z1Rwwb2tWV=5WxLEVgLdVfB*y_009U<00Izz z00baVeF5D6SHF*Og%E%M1Rwwb2tWV=5P$##AOHdU{eQ#&1Rwwb2tWV=5P$##AOHaf zK%n{pc>Z7gKE@S700Izz00bZa0SG_<0uX=z1aSY47=Qo-AOHafKmY;|fB*y_009V8 zUjX<2)$e0mAp{@*0SG_<0uX=z1Rwwb2tWXT{~s{`0SG_<0uX=z1Rwwb2tWV=5U9QY zp8r?Bk8y<%fB*y_009U<00Izz00bZa0o?y11|R?d2tWV=5P$##AOHafKmY>O7r_00 z_4^oC2muH{00Izz00bZa0SG_<0uaFcKVkp^5P$##AOHafKmY;|fB*y_P<;XX_y4Nj z$GAcWKmY;|fB*y_009U<00Izz0G|IN1|R?d2tWV=5P$##AOHafKmY>O7r_00_4^oC z2muH{00Izz00bZa0SG_<0uaFcKVkp^5P$##AOHafKmY;|fB*y_P<;XR_y3=)t<9YH z+Y`UL*ggJt$KB&c=YKTs&i!m|_tJ{e>M9Dvp<-9>ByfQS*fkn9-sN~%&YXI z+Nt(ZZFI)<9J253o1|~KJ+tTUgr-Bn$n~tqas!ed{>nUQT3_^1^>i75= za=pGi>=Q3CT_?=Siw*|FbdnU>m0e@a=&(u?(exsk7j)isi`v-%u_D*^v{l;u-Sr!W zTn)=YyglE|(z7%D;T{R1VL+rL?~WPKcF{8TNGP_!A%W+TfEE`7WEf2yosFTMPwMFY*kn0Xe zNFt%qC#F^^PYJYCE6R$LCWYa^ zq0@qt&MvfPg;Ctp?6mR``NI=*MC!HC(`mfQ*Jnme;^+}nM6~zM9IndX)KoI zG_8@9sSg$vb(z*aH=%YOg7WDn4yidCLGo!@BOXe>c3e>nTH|vQYNXSg8z{dL>ioI`Qe4@dvl8VCXJQT6WYO*kArA}5>4No7?h zN&51!y1J!Ln(hnbHRaE9_rIs`_bnytt9_?gl?MM!ZrIyc^ck1%C+8R18 zpK0SI!}sz7N-+}hB)+`Ru|nf7Kd3d>qT_pdaFoU!y<^DYF3)4K**-ZtKU$wslW;<@ zIw46P&MK;Omy(xGy~5BR2CT1~{P58!m5OH+YZcS<`)?iLHvByVFcIjEZWP6dP3ileU3>FbXy12U`?F71OPJ=&9n;f+9tSL^K4xu-F zz5I5eun`x!=2553<;SMY8n$Kc60=WhrA1q2Q~rG9{!%Sol|JVEAg9Wo9+Ub*PMgvv zamJ#zG$HU!OEL{*SIuVwy4f;qpYE*0o0xRMa)v>C=g<$e>l<5berw0eqNsj;bHhK4 z7gJn-ScS<7(#RW4Z(i`<|EvCYJh(y#KmY;|fB*y_009U<00Izzz~aI`%$%P2eCEU- zo>*V}y#C>_(b2y?YR&$1_O+SMkNoRNf8m|^KRWjB$9{6+@0r!YL8;iWpUnK0dgE+u z^m6*8GJSYK`XoPX^)TpVU(1a>!%s8uFQ>C?W6vx31+j0*?pR^ojPwyAPqqF2 z!14~HGWZu&We*PNH_i^(wuZe(`~q+gxPIV92jVwc{7b7{V)de3lYU)i-<7@v6I1z1 z1kzKOk}td>KeBrC+uM(Z^bwx)s4{tqvpp$D4T;Ywt3FQAM~^+DsB5&=r3tmB@9>Ou&2xuTEu2_Z zzmTjyeBmqn>-W!U@kA_zfPBHvB(=uxM#*W1OuIsj*~~RIyOzGUBz;XUN0L+$8+NQo z^zm_VCT2;+9?#Aln}g{)Awuj;&X;s={v?KveE#%g!kbc=m^8QYGw1__q5L8Uoj2+H z*|R)bdKHAeSCt%uH6Qq5H*_1w>een7VihA~J?URc+tgMOfr?d{E)@C?b4^;hm;fNDJzEdaF4~Y)W3b7zDq}6^V^N`=S=CbD6;c| zJev40+4h6}lvnzuJxv^cW>|biNEZk?OHN@ei(kenYuGm)wPA4)Q`;41(dqlT z|13QTk7q5uQj2ex^1~0?XZg_htj&jpwA+?Gd{Y`4QqmzqgXa~VL>FJRNOtq$Yc1(a za`}?{UQhbOG=4inE|bks|GPIbwq0+B1av>kh6a5pCmVFMdR8iZFD@PeX|vL;bk?)t z)-WPLpS==2zQasY=$#CmMx}R+(kJlgW^mHma(ws7`c2|5vIzaWN2p00bZa0SG_<0uX=z z1Rwx`QUTomm$Kj>1Rwwb2tWV=5P$##AOHafK%i0rxc{$Icj96o009U<00Izz00bZa z0SG_<0;K}D|1V|1K?pzq0uX=z1Rwwb2tWV=5P(3X1n~d=t5kR5Vjutk2tWV=5P$## zAOHafKmY=z0(kyk%7TLsfB*y_009U<00Izz00bZafl3MB{=ZV)iHm^%1Rwwb2tWV= z5P$##AOHaflnUVfzmx?BApijgKmY;|fB*y_009U<00NZ~!2N%vx)T=z0SG_<0uX=z z1Rwwb2tWV=5GWPEfB&zP1qUGj0SG_<0uX=z1Rwwb2tWV=l@h@7|4MZyE(QV+fB*y_ z009U<00Izz00baVDuDa{QWhM900bZa0SG_<0uX=z1Rwwb2vkY{_y3jZPFxHGAOHaf zKmY;|fB*y_009US~)eH#W{|^K(&Pd7))TuJ4&s zvXQ>!_Oe1=X|?21JgZNnR95t0*Z1l(MZtPFQxT8mu6B zfS7#}?fSW1auI&u&~tdtYU`U_W6kKWI^OB5-)MJkYi}F3wTA2P(ywoIjPsrUSZgh;z72`dz--KKtFGkzi)K-lRgLd*t`@NO+sxhyR>Q@wXtyvo_@m9F|Vuabg zx9#qPCciS*5n>`qxthex`sS)}OPhK!t9-lwt&xf-Z(pP4q$;6(g>tujSYYCO_OmjW)AW(J?Rq;T7zC~tjaARi z?D@9ECaSDRHV^S4Lz7KmdqlgowY6ciH`&361bvqdCsNa-uPBswU#HY2jzVzZ4AZcyz5vsf$SQ6Y$F_rV^a#sCaV?cIvM|f$_U1)-$q_Cq z*3EKWIV7Vw9I)6M+d#7Aq?e5BtgvQNE-l`z^94WMN@jB%+XbWtZ4yN8wo4Z%B4ti@ z`?LjCmN_6F4M|9sA#;3R&6ch_y>w%pH9$&D*VlX$m9I-7TLFHN9d^91jd9wRXOmtD zGg~UzBs!kNr&cS9NPiH8;!c_;kRb4b%*+i9uAjmDHQ1>7%3HR8)gjeXhJ}USmn+-za9Ts z$M{xMvN)eKXKX(pdZ$}$aX-s$X2d*NW4tCE&;QeJH1HDw5P$##AOHafKmY;|fB*y_ zP+bAs|5vwfab*yI00bZa0SG_<0uX=z1Rwx`Q~>w?sRVvP00Izz00bZa0SG_<0uX=z z1ga~5|Nmcg`xaLQ0SG_<0uX=z1Rwwb2tWV=5J&~^{6CezPY6H&0uX=z1Rwwb2tWV= z5P(2+1#tgg-M+<@K>z{}fB*y_009U<00Izz00dG2-2bN%_z3|BKmY;|fB*y_009U< z00I!Gt^n@;tJ}A@G6+Bb0uX=z1Rwwb2tWV=5P(1`fcyVc0zV-D0SG_<0uX=z1Rwwb z2tWV=)fK?~e|7s7R|WwHKmY;|fB*y_009U<00Iz51#thLO5i61AOHafKmY;|fB*y_ z009UsWZ0SG_<0uX=z1Rwwb2tWV=sQ~W(QwjWp00bZa0SG_<0uX=z z1Rwwb2vk>KkzLxU>RunN3<3~<00bZa0SG_<0uX=z1Rwx`nHfC)M-)H+0uX=z1Rwwb z2tWV=5P$##sxN^1|LXTKt`GtcfB*y_009U<00Izz00ba__x}+C5P$##AOHafKmY;| zfB*y_0D``xsXU0SG_<0uX=z1Rwwb2tWV=5WxLEVgLdVfB*y_009U<00Izz z00baVeF66V|KjYYGbg(ze!Tee7wPXKwY@mF8^e>OTOq1{r1Rwx`FH&Ii z!gm$*cB?irT+boAElL1V0ocsN3QRgu2WX>>LayME@yHp<$U^sAHJce ztyb;RXCv!QkCfG4c5EeSQ@4F>!wri{z9c^=~t8L!g*f_7v&&f^D-fwqS z-fDLm7cc8lw%GGvK(ZXI3uQUd9@bWAUv}4T7*Z*Nfb6;ckcLBKMZ-`mi?zig_a>y+ zyOy^@Len7;u_NMWUE@}loe~ahn}nfuZEI`8Xm7GZBnbS#q>&a{J0#1v8%2YpaMSi3 zl47tk0ul~z6WXBq^pRRR`>qf`e zTru7gdqT(+@?uy_tQs2z4fvJzdn@f#V_qLUdsR_yHfy7+WkZRMCpxN3p`=2H#u=_n z3((Rsis2b&(u!9$n)!~RuF%#!J*9P|Z@E1&M&rW1`v{HVr%q}MPt;eI>*@#1+6-?% z_)*V|h-nQYpMRF-o#aBB<4t+<;Y+V8YO`7U^xoJYi_aQA=Z%R2EVbgpYtq<}yDSU( z;t-1g(n4NowdC>TS^a$Qu~Zg`gI(XtkJ2H@4i*O2t8zJeF1be>(~7bo%Fkg}GIlf| z+rx0eVXJ2b#0q^c8)ELjv>b;HrhH&uiknFb2~0W~y(r7f)(1KV1a!QSXxDc#mgFM* zz@g{xp8fwCj`b^wx<&(jsU+ZS-`jTSy2h_eS}Ky1t5ZV0aB?B&lT@vdl&!D4_K0Dh zmMeyRa_l<~R$t{||H;K-*e5xJuuqOGPYU}aU29AY`=t3<$I^Ibkw&~dH}Jha3x+h< zi))EBaLr-R6VeNd4a;zs98At|kKEby{kvnOuok3gA+ZCJHzFNVY(|OqE5u{Vup=%m zd>wSXhy;6d0Q6mN7!hf`$m5#q58U9ubgTm@gBzM`hp|V*VrfGxx~vsPWpVMLYeeg^ zqT0)~(e09;6eb$F#M1F*I=($*((+Ov=6iigoQnCz$}2>zn3rz93YF6hSW8hqpzUrS z)^3jwC*Cg#5h<2_|6k;74a$Z91Rwwb2tWV=5P$##AOHafd^rU0-~an^^aEM}0SG_<0uX=z1Rwwb z2tWV=5cnbm@cjRa)J544fB*y_009U<00Izz00bZafiH&u?*G3W{eV_L00Izz00bZa R0SG_<0uX=z1inat{|2lO)an2L From 6b6246b6e5f59e99a2394457783a81df1535a369 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 12 Jan 2026 18:56:37 +0300 Subject: [PATCH 03/36] Testing multi-sage users --- .gitignore | 2 ++ api.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f1f93a8..a5c558f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ data/*.db.bak *.db tools/ + +.env.staging \ No newline at end of file diff --git a/api.py b/api.py index d37e7f0..009bfdb 100644 --- a/api.py +++ b/api.py @@ -176,7 +176,7 @@ app.include_router(universign_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients( query: Optional[str] = Query(None), - #sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: clients = sage_client.lister_clients(filtre=query or "") From c389129ae708d64b695fe3c567936a28529b9110 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 12 Jan 2026 19:04:09 +0300 Subject: [PATCH 04/36] Updated using the correct redirection structure --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index 009bfdb..da967df 100644 --- a/api.py +++ b/api.py @@ -179,7 +179,7 @@ async def obtenir_clients( sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - clients = sage_client.lister_clients(filtre=query or "") + clients = sage.lister_clients(filtre=query or "") return [ClientDetails(**c) for c in clients] except Exception as e: logger.error(f"Erreur recherche clients: {e}") From c5c17fdd9be3a34a8ab5af8712a39df4165e8ec7 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 13 Jan 2026 10:42:58 +0300 Subject: [PATCH 05/36] fix(auth): increase failed login attempt threshold from 5 to 15 --- routes/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index 0d18349..5fc2554 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -101,7 +101,7 @@ async def check_rate_limit( ) failed_attempts = result.scalars().all() - if len(failed_attempts) >= 5: + if len(failed_attempts) >= 15: return False, "Trop de tentatives échouées. Réessayez dans 15 minutes." return True, "" @@ -286,7 +286,7 @@ async def login( if user: user.failed_login_attempts += 1 - if user.failed_login_attempts >= 5: + if user.failed_login_attempts >= 15: user.locked_until = datetime.now() + timedelta(minutes=15) await session.commit() raise HTTPException( From 18d72b3bf94f1acc2c65d81b09eda80a4200521d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 16 Jan 2026 12:47:56 +0300 Subject: [PATCH 06/36] Secured all routes --- api.py | 497 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 331 insertions(+), 166 deletions(-) diff --git a/api.py b/api.py index 8077a9a..6ee5d3f 100644 --- a/api.py +++ b/api.py @@ -191,9 +191,12 @@ async def obtenir_clients( @app.get("/clients/{code}", response_model=ClientDetails, tags=["Clients"]) -async def lire_client_detail(code: str): +async def lire_client_detail( + code: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - client = sage_client.lire_client(code) + client = sage.lire_client(code) if not client: raise HTTPException(404, f"Client {code} introuvable") @@ -212,11 +215,10 @@ async def modifier_client( code: str, client_update: ClientUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.modifier_client( - code, client_update.dict(exclude_none=True) - ) + resultat = sage.modifier_client(code, client_update.dict(exclude_none=True)) logger.info(f"Client {code} modifié avec succès") @@ -236,10 +238,12 @@ async def modifier_client( @app.post("/clients", status_code=201, tags=["Clients"]) async def ajouter_client( - client: ClientCreate, session: AsyncSession = Depends(get_session) + client: ClientCreate, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - nouveau_client = sage_client.creer_client(client.model_dump(mode="json")) + nouveau_client = sage.creer_client(client.model_dump(mode="json")) logger.info(f"Client créé via API: {nouveau_client.get('numero')}") @@ -258,9 +262,12 @@ async def ajouter_client( @app.get("/articles", response_model=List[Article], tags=["Articles"]) -async def rechercher_articles(query: Optional[str] = Query(None)): +async def rechercher_articles( + query: Optional[str] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - articles = sage_client.lister_articles(filtre=query or "") + articles = sage.lister_articles(filtre=query or "") return [Article(**a) for a in articles] except Exception as e: logger.error(f"Erreur recherche articles: {e}") @@ -273,7 +280,10 @@ async def rechercher_articles(query: Optional[str] = Query(None)): status_code=status.HTTP_201_CREATED, tags=["Articles"], ) -async def creer_article(article: ArticleCreate): +async def creer_article( + article: ArticleCreate, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: if not article.reference or not article.designation: raise HTTPException( @@ -285,7 +295,7 @@ async def creer_article(article: ArticleCreate): logger.info(f"Création article: {article.reference} - {article.designation}") - resultat = sage_client.creer_article(article_data) + resultat = sage.creer_article(article_data) logger.info( f"Article créé: {resultat.get('reference')} (stock: {resultat.get('stock_reel', 0)})" @@ -312,6 +322,7 @@ async def creer_article(article: ArticleCreate): async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: article_data = article.dict(exclude_unset=True) @@ -324,7 +335,7 @@ async def modifier_article( logger.info(f"Modification article {reference}: {list(article_data.keys())}") - resultat = sage_client.modifier_article(reference, article_data) + resultat = sage.modifier_article(reference, article_data) if "stock_reel" in article_data: logger.info( @@ -354,9 +365,10 @@ async def modifier_article( @app.get("/articles/{reference}", response_model=Article, tags=["Articles"]) async def lire_article( reference: str = Path(..., description="Référence de l'article"), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - article = sage_client.lire_article(reference) + article = sage.lire_article(reference) if not article: logger.warning(f"Article {reference} introuvable") @@ -380,7 +392,10 @@ async def lire_article( @app.post("/devis", response_model=Devis, status_code=201, tags=["Devis"]) -async def creer_devis(devis: DevisRequest): +async def creer_devis( + devis: DevisRequest, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: devis_data = { "client_id": devis.client_id, @@ -392,7 +407,7 @@ async def creer_devis(devis: DevisRequest): "lignes": _preparer_lignes_document(devis.lignes), } - resultat = sage_client.creer_devis(devis_data) + resultat = sage.creer_devis(devis_data) logger.info( f"Devis créé: {resultat.get('numero_devis')} " @@ -418,6 +433,7 @@ async def modifier_devis( id: str, devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -441,7 +457,7 @@ async def modifier_devis( if devis_update.reference is not None: update_data["reference"] = devis_update.reference - resultat = sage_client.modifier_devis(id, update_data) + resultat = sage.modifier_devis(id, update_data) logger.info(f"Devis {id} modifié avec succès") @@ -460,7 +476,9 @@ async def modifier_devis( @app.post("/commandes", status_code=201, tags=["Commandes"]) async def creer_commande( - commande: CommandeCreate, session: AsyncSession = Depends(get_session) + commande: CommandeCreate, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande_data = { @@ -475,7 +493,7 @@ async def creer_commande( "lignes": _preparer_lignes_document(commande.lignes), } - resultat = sage_client.creer_commande(commande_data) + resultat = sage.creer_commande(commande_data) logger.info( f"Commande créée: {resultat.get('numero_commande')} " @@ -509,6 +527,7 @@ async def modifier_commande( id: str, commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -532,7 +551,7 @@ async def modifier_commande( if commande_update.reference is not None: update_data["reference"] = commande_update.reference - resultat = sage_client.modifier_commande(id, update_data) + resultat = sage.modifier_commande(id, update_data) logger.info(f"Commande {id} modifiée avec succès") @@ -556,9 +575,10 @@ async def lister_devis( inclure_lignes: bool = Query( True, description="Inclure les lignes de chaque devis" ), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - devis_list = sage_client.lister_devis( + devis_list = sage.lister_devis( limit=limit, statut=statut, inclure_lignes=inclure_lignes ) return devis_list @@ -569,9 +589,12 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) -async def lire_devis(id: str): +async def lire_devis( + id: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - devis = sage_client.lire_devis(id) + devis = sage.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") @@ -586,7 +609,10 @@ async def lire_devis(id: str): @app.get("/devis/{id}/pdf", tags=["Devis"]) -async def telecharger_devis_pdf(id: str): +async def telecharger_devis_pdf( + id: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) @@ -607,6 +633,7 @@ async def telecharger_document_pdf( description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", ), numero: str = Path(..., description="Numéro du document"), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: types_labels = { @@ -630,7 +657,7 @@ async def telecharger_document_pdf( logger.info(f"Génération PDF: {label} {numero} (type={type_doc})") - pdf_bytes = sage_client.generer_pdf_document(numero, type_doc) + pdf_bytes = sage.generer_pdf_document(numero, type_doc) if not pdf_bytes: raise HTTPException(500, f"Le PDF du document {numero} est vide") @@ -659,7 +686,10 @@ async def telecharger_document_pdf( @app.post("/devis/{id}/envoyer", tags=["Devis"]) async def envoyer_devis_email( - id: str, request: EmailEnvoi, session: AsyncSession = Depends(get_session) + id: str, + request: EmailEnvoi, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: tous_destinataires = [request.destinataire] + request.cc + request.cci @@ -714,6 +744,7 @@ async def changer_statut_document( nouveau_statut: int = Query( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): document_type_sql = None document_type_code = None @@ -755,7 +786,7 @@ async def changer_statut_document( f"Type de document invalide: {type_doc}", ) - document_existant = sage_client.lire_document(numero, document_type_sql) + document_existant = sage.lire_document(numero, document_type_sql) if not document_existant: raise HTTPException(404, f"Document {numero} introuvable") @@ -799,7 +830,7 @@ async def changer_statut_document( else type_doc_normalized ) - resultat = sage_client.changer_statut_document( + resultat = sage.changer_statut_document( document_type_code=document_type_int, numero=numero, nouveau_statut=nouveau_statut, @@ -827,9 +858,12 @@ async def changer_statut_document( @app.get("/commandes/{id}", tags=["Commandes"]) -async def lire_commande(id: str): +async def lire_commande( + id: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - commande = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) + commande = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande: raise HTTPException(404, f"Commande {id} introuvable") return commande @@ -842,10 +876,12 @@ async def lire_commande(id: str): @app.get("/commandes", tags=["Commandes"]) async def lister_commandes( - limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) + limit: int = Query(100, le=1000), + statut: Optional[int] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - commandes = sage_client.lister_commandes(limit=limit, statut=statut) + commandes = sage.lister_commandes(limit=limit, statut=statut) return commandes except Exception as e: @@ -854,9 +890,13 @@ async def lister_commandes( @app.post("/workflow/devis/{id}/to-commande", tags=["Workflows"]) -async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_session)): +async def devis_vers_commande( + id: str, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - resultat = sage_client.transformer_document( + resultat = sage.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_BON_COMMANDE, # = 10 @@ -894,9 +934,13 @@ async def devis_vers_commande(id: str, session: AsyncSession = Depends(get_sessi @app.post("/workflow/commande/{id}/to-facture", tags=["Workflows"]) -async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_session)): +async def commande_vers_facture( + id: str, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - resultat = sage_client.transformer_document( + resultat = sage.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 @@ -1254,9 +1298,10 @@ async def envoyer_emails_lot( async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - remise_max = sage_client.lire_remise_max_client(client_id) + remise_max = sage.lire_remise_max_client(client_id) autorisee = remise_pourcentage <= remise_max @@ -1283,14 +1328,17 @@ async def valider_remise( @app.post("/devis/{id}/relancer-signature", tags=["Devis"]) async def relancer_devis_signature( - id: str, relance: RelanceDevis, session: AsyncSession = Depends(get_session) + id: str, + relance: RelanceDevis, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - devis = sage_client.lire_devis(id) + devis = sage.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - contact = sage_client.lire_contact_client(devis["client_code"]) + contact = sage.lire_contact_client(devis["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") @@ -1347,13 +1395,16 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) -async def recuperer_contact_devis(id: str): +async def recuperer_contact_devis( + id: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - devis = sage_client.lire_devis(id) + devis = sage.lire_devis(id) if not devis: raise HTTPException(404, f"Devis {id} introuvable") - contact = sage_client.lire_contact_client(devis["client_code"]) + contact = sage.lire_contact_client(devis["client_code"]) if not contact: raise HTTPException( 404, f"Contact introuvable pour client {devis['client_code']}" @@ -1371,10 +1422,12 @@ async def recuperer_contact_devis(id: str): @app.get("/factures", tags=["Factures"]) async def lister_factures( - limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) + limit: int = Query(100, le=1000), + statut: Optional[int] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - factures = sage_client.lister_factures(limit=limit, statut=statut) + factures = sage.lister_factures(limit=limit, statut=statut) return factures except Exception as e: @@ -1383,9 +1436,12 @@ async def lister_factures( @app.get("/factures/{numero}", tags=["Factures"]) -async def lire_facture_detail(numero: str): +async def lire_facture_detail( + numero: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - facture = sage_client.lire_document(numero, TypeDocumentSQL.FACTURE) + facture = sage.lire_document(numero, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {numero} introuvable") @@ -1406,7 +1462,9 @@ class RelanceFacture(BaseModel): @app.post("/factures", status_code=201, tags=["Factures"]) async def creer_facture( - facture: FactureCreate, session: AsyncSession = Depends(get_session) + facture: FactureCreate, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture_data = { @@ -1421,7 +1479,7 @@ async def creer_facture( "lignes": _preparer_lignes_document(facture.lignes), } - resultat = sage_client.creer_facture(facture_data) + resultat = sage.creer_facture(facture_data) logger.info( f"Facture créée: {resultat.get('numero_facture')} " @@ -1455,6 +1513,7 @@ async def modifier_facture( id: str, facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -1478,7 +1537,7 @@ async def modifier_facture( if facture_update.reference is not None: update_data["reference"] = facture_update.reference - resultat = sage_client.modifier_facture(id, update_data) + resultat = sage.modifier_facture(id, update_data) logger.info(f"Facture {id} modifiée avec succès") @@ -1523,13 +1582,14 @@ async def relancer_facture( id: str, relance: RelanceFacture, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - facture = sage_client.lire_document(id, TypeDocumentSQL.FACTURE) + facture = sage.lire_document(id, TypeDocumentSQL.FACTURE) if not facture: raise HTTPException(404, f"Facture {id} introuvable") - contact = sage_client.lire_contact_client(facture["client_code"]) + contact = sage.lire_contact_client(facture["client_code"]) if not contact or not contact.get("email"): raise HTTPException(400, "Aucun email trouvé pour ce client") @@ -1567,7 +1627,7 @@ async def relancer_facture( email_queue.enqueue(email_log.id) - sage_client.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) + sage.mettre_a_jour_derniere_relance(id, TypeDocument.FACTURE) await session.commit() @@ -1592,6 +1652,7 @@ async def journal_emails( destinataire: Optional[str] = Query(None), limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1626,6 +1687,7 @@ async def journal_emails( async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) if statut: @@ -1764,13 +1826,16 @@ async def supprimer_template(template_id: str): @app.post("/templates/emails/preview", tags=["Emails"]) -async def previsualiser_email(preview: TemplatePreview): +async def previsualiser_email( + preview: TemplatePreview, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") template = templates_email_db[preview.template_id] - doc = sage_client.lire_document(preview.document_id, preview.type_document) + doc = sage.lire_document(preview.document_id, preview.type_document) if not doc: raise HTTPException(404, f"Document {preview.document_id} introuvable") @@ -1799,9 +1864,12 @@ async def previsualiser_email(preview: TemplatePreview): @app.get("/prospects", tags=["Prospects"]) -async def rechercher_prospects(query: Optional[str] = Query(None)): +async def rechercher_prospects( + query: Optional[str] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - prospects = sage_client.lister_prospects(filtre=query or "") + prospects = sage.lister_prospects(filtre=query or "") return prospects except Exception as e: logger.error(f"Erreur recherche prospects: {e}") @@ -1809,9 +1877,12 @@ async def rechercher_prospects(query: Optional[str] = Query(None)): @app.get("/prospects/{code}", tags=["Prospects"]) -async def lire_prospect(code: str): +async def lire_prospect( + code: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - prospect = sage_client.lire_prospect(code) + prospect = sage.lire_prospect(code) if not prospect: raise HTTPException(404, f"Prospect {code} introuvable") return prospect @@ -1825,9 +1896,12 @@ async def lire_prospect(code: str): @app.get( "/fournisseurs", response_model=List[FournisseurDetails], tags=["Fournisseurs"] ) -async def rechercher_fournisseurs(query: Optional[str] = Query(None)): +async def rechercher_fournisseurs( + query: Optional[str] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - fournisseurs = sage_client.lister_fournisseurs(filtre=query or "") + fournisseurs = sage.lister_fournisseurs(filtre=query or "") logger.info(f"{len(fournisseurs)} fournisseurs") @@ -1845,9 +1919,10 @@ async def rechercher_fournisseurs(query: Optional[str] = Query(None)): async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - nouveau_fournisseur = sage_client.creer_fournisseur(fournisseur.dict()) + nouveau_fournisseur = sage.creer_fournisseur(fournisseur.dict()) logger.info(f"Fournisseur créé via API: {nouveau_fournisseur.get('numero')}") @@ -1873,9 +1948,10 @@ async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.modifier_fournisseur( + resultat = sage.modifier_fournisseur( code, fournisseur_update.dict(exclude_none=True) ) @@ -1892,9 +1968,12 @@ async def modifier_fournisseur( @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) -async def lire_fournisseur(code: str): +async def lire_fournisseur( + code: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - fournisseur = sage_client.lire_fournisseur(code) + fournisseur = sage.lire_fournisseur(code) if not fournisseur: raise HTTPException(404, f"Fournisseur {code} introuvable") return fournisseur @@ -1907,10 +1986,12 @@ async def lire_fournisseur(code: str): @app.get("/avoirs", tags=["Avoirs"]) async def lister_avoirs( - limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) + limit: int = Query(100, le=1000), + statut: Optional[int] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - avoirs = sage_client.lister_avoirs(limit=limit, statut=statut) + avoirs = sage.lister_avoirs(limit=limit, statut=statut) return avoirs except Exception as e: logger.error(f"Erreur liste avoirs: {e}") @@ -1918,9 +1999,12 @@ async def lister_avoirs( @app.get("/avoirs/{numero}", tags=["Avoirs"]) -async def lire_avoir(numero: str): +async def lire_avoir( + numero: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - avoir = sage_client.lire_document(numero, TypeDocumentSQL.BON_AVOIR) + avoir = sage.lire_document(numero, TypeDocumentSQL.BON_AVOIR) if not avoir: raise HTTPException(404, f"Avoir {numero} introuvable") return avoir @@ -1932,7 +2016,11 @@ async def lire_avoir(numero: str): @app.post("/avoirs", status_code=201, tags=["Avoirs"]) -async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_session)): +async def creer_avoir( + avoir: AvoirCreate, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: avoir_data = { "client_id": avoir.client_id, @@ -1944,7 +2032,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se "lignes": _preparer_lignes_document(avoir.lignes), } - resultat = sage_client.creer_avoir(avoir_data) + resultat = sage.creer_avoir(avoir_data) logger.info( f"Avoir créé: {resultat.get('numero_avoir')} " @@ -1978,6 +2066,7 @@ async def modifier_avoir( id: str, avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -2001,7 +2090,7 @@ async def modifier_avoir( if avoir_update.reference is not None: update_data["reference"] = avoir_update.reference - resultat = sage_client.modifier_avoir(id, update_data) + resultat = sage.modifier_avoir(id, update_data) logger.info(f"Avoir {id} modifié avec succès") @@ -2020,10 +2109,12 @@ async def modifier_avoir( @app.get("/livraisons", tags=["Livraisons"]) async def lister_livraisons( - limit: int = Query(100, le=1000), statut: Optional[int] = Query(None) + limit: int = Query(100, le=1000), + statut: Optional[int] = Query(None), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - livraisons = sage_client.lister_livraisons(limit=limit, statut=statut) + livraisons = sage.lister_livraisons(limit=limit, statut=statut) return livraisons except Exception as e: logger.error(f"Erreur liste livraisons: {e}") @@ -2031,9 +2122,12 @@ async def lister_livraisons( @app.get("/livraisons/{numero}", tags=["Livraisons"]) -async def lire_livraison(numero: str): +async def lire_livraison( + numero: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - livraison = sage_client.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) + livraison = sage.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) if not livraison: raise HTTPException(404, f"Livraison {numero} introuvable") return livraison @@ -2046,14 +2140,10 @@ async def lire_livraison(numero: str): @app.post("/livraisons", status_code=201, tags=["Livraisons"]) async def creer_livraison( - livraison: LivraisonCreate, session: AsyncSession = Depends(get_session) + livraison: LivraisonCreate, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): - """ - Crée un bon de livraison dans Sage 100 - - - Le prix_unitaire_ht est optionnel (utilise le prix Sage si non fourni) - - La remise_pourcentage est appliquée après le prix - """ try: livraison_data = { "client_id": livraison.client_id, @@ -2071,7 +2161,7 @@ async def creer_livraison( "lignes": _preparer_lignes_document(livraison.lignes), } - resultat = sage_client.creer_livraison(livraison_data) + resultat = sage.creer_livraison(livraison_data) logger.info( f"Livraison créée: {resultat.get('numero_livraison')} " @@ -2105,6 +2195,7 @@ async def modifier_livraison( id: str, livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -2128,7 +2219,7 @@ async def modifier_livraison( if livraison_update.reference is not None: update_data["reference"] = livraison_update.reference - resultat = sage_client.modifier_livraison(id, update_data) + resultat = sage.modifier_livraison(id, update_data) logger.info(f"Livraison {id} modifiée avec succès") @@ -2146,9 +2237,13 @@ async def modifier_livraison( @app.post("/workflow/livraison/{id}/to-facture", tags=["Workflows"]) -async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_session)): +async def livraison_vers_facture( + id: str, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - resultat = sage_client.transformer_document( + resultat = sage.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 @@ -2186,10 +2281,12 @@ async def livraison_vers_facture(id: str, session: AsyncSession = Depends(get_se @app.post("/workflow/devis/{id}/to-facture", tags=["Workflows"]) async def devis_vers_facture_direct( - id: str, session: AsyncSession = Depends(get_session) + id: str, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - devis_existant = sage_client.lire_devis(id) + devis_existant = sage.lire_devis(id) if not devis_existant: raise HTTPException(404, f"Devis {id} introuvable") @@ -2201,7 +2298,7 @@ async def devis_vers_facture_direct( f"Vérifiez les documents déjà créés depuis ce devis.", ) - resultat = sage_client.transformer_document( + resultat = sage.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_DEVIS, # = 0 type_cible=settings.SAGE_TYPE_FACTURE, # = 60 @@ -2244,10 +2341,12 @@ async def devis_vers_facture_direct( @app.post("/workflow/commande/{id}/to-livraison", tags=["Workflows"]) async def commande_vers_livraison( - id: str, session: AsyncSession = Depends(get_session) + id: str, + session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - commande_existante = sage_client.lire_document(id, TypeDocumentSQL.BON_COMMANDE) + commande_existante = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) if not commande_existante: raise HTTPException(404, f"Commande {id} introuvable") @@ -2266,7 +2365,7 @@ async def commande_vers_livraison( f"La commande {id} est annulée (statut=6) et ne peut pas être transformée.", ) - resultat = sage_client.transformer_document( + resultat = sage.transformer_document( numero_source=id, type_source=settings.SAGE_TYPE_BON_COMMANDE, # = 10 type_cible=settings.SAGE_TYPE_BON_LIVRAISON, # = 30 @@ -2315,9 +2414,10 @@ async def commande_vers_livraison( ) async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - familles = sage_client.lister_familles(filtre or "") + familles = sage.lister_familles(filtre or "") logger.info(f"{len(familles)} famille(s) retournée(s)") @@ -2339,9 +2439,10 @@ async def lister_familles( ) async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - famille = sage_client.lire_famille(code) + famille = sage.lire_famille(code) if not famille: logger.warning(f"Famille {code} introuvable") @@ -2371,7 +2472,10 @@ async def lire_famille( tags=["Familles"], summary="Création d'une famille d'articles", ) -async def creer_famille(famille: FamilleCreate): +async def creer_famille( + famille: FamilleCreate, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: if not famille.code or not famille.intitule: raise HTTPException( @@ -2383,7 +2487,7 @@ async def creer_famille(famille: FamilleCreate): logger.info(f"Création famille: {famille.code} - {famille.intitule}") - resultat = sage_client.creer_famille(famille_data) + resultat = sage.creer_famille(famille_data) logger.info(f"Famille créée: {resultat.get('code')}") @@ -2411,7 +2515,10 @@ async def creer_famille(famille: FamilleCreate): tags=["Stock"], summary="ENTRÉE EN STOCK : Ajoute des articles dans le stock", ) -async def creer_entree_stock(entree: EntreeStock): +async def creer_entree_stock( + entree: EntreeStock, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: entree_data = entree.dict() if entree_data.get("date_entree"): @@ -2419,7 +2526,7 @@ async def creer_entree_stock(entree: EntreeStock): logger.info(f"Création entrée stock: {len(entree.lignes)} ligne(s)") - resultat = sage_client.creer_entree_stock(entree_data) + resultat = sage.creer_entree_stock(entree_data) logger.info(f"Entrée stock créée: {resultat.get('numero')}") @@ -2444,7 +2551,10 @@ async def creer_entree_stock(entree: EntreeStock): tags=["Stock"], summary="SORTIE DE STOCK : Retire des articles du stock", ) -async def creer_sortie_stock(sortie: SortieStock): +async def creer_sortie_stock( + sortie: SortieStock, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: sortie_data = sortie.dict() if sortie_data.get("date_sortie"): @@ -2452,7 +2562,7 @@ async def creer_sortie_stock(sortie: SortieStock): logger.info(f"Création sortie stock: {len(sortie.lignes)} ligne(s)") - resultat = sage_client.creer_sortie_stock(sortie_data) + resultat = sage.creer_sortie_stock(sortie_data) logger.info(f"Sortie stock créée: {resultat.get('numero')}") @@ -2478,9 +2588,10 @@ async def creer_sortie_stock(sortie: SortieStock): ) async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - mouvement = sage_client.lire_mouvement_stock(numero) + mouvement = sage.lire_mouvement_stock(numero) if not mouvement: logger.warning(f"Mouvement {numero} introuvable") @@ -2508,9 +2619,11 @@ async def lire_mouvement_stock( tags=["Familles"], summary="Statistiques sur les familles", ) -async def statistiques_familles(): +async def statistiques_familles( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - stats = sage_client.get_stats_familles() + stats = sage.get_stats_familles() return {"success": True, "data": stats} @@ -2611,10 +2724,14 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) @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, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: try: - sage_client.lire_tiers(numero) + sage.lire_tiers(numero) except HTTPException: raise except Exception: @@ -2623,7 +2740,7 @@ async def creer_contact(numero: str, contact: ContactCreate): if contact.numero != numero: contact.numero = numero - resultat = sage_client.creer_contact(contact.dict()) + resultat = sage.creer_contact(contact.dict()) if isinstance(resultat, dict) and "data" in resultat: contact_data = resultat["data"] @@ -2640,9 +2757,12 @@ async def creer_contact(numero: str, contact: ContactCreate): @app.get("/tiers/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) -async def lister_contacts(numero: str): +async def lister_contacts( + numero: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - contacts = sage_client.lister_contacts(numero) + contacts = sage.lister_contacts(numero) return [Contact(**c) for c in contacts] except Exception as e: logger.error(f"Erreur liste contacts: {e}") @@ -2654,9 +2774,13 @@ async def lister_contacts(numero: str): response_model=Contact, tags=["Contacts"], ) -async def obtenir_contact(numero: str, contact_numero: int): +async def obtenir_contact( + numero: str, + contact_numero: int, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - contact = sage_client.obtenir_contact(numero, contact_numero) + contact = sage.obtenir_contact(numero, contact_numero) if not contact: raise HTTPException( 404, f"Contact {contact_numero} non trouvé pour client {numero}" @@ -2674,9 +2798,14 @@ async def obtenir_contact(numero: str, contact_numero: int): response_model=Contact, tags=["Contacts"], ) -async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpdate): +async def modifier_contact( + numero: str, + contact_numero: int, + contact: ContactUpdate, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - contact_existant = sage_client.obtenir_contact(numero, contact_numero) + contact_existant = sage.obtenir_contact(numero, contact_numero) if not contact_existant: raise HTTPException(404, f"Contact {contact_numero} non trouvé") @@ -2685,7 +2814,7 @@ async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpd if not updates: raise HTTPException(400, "Aucune modification fournie") - resultat = sage_client.modifier_contact(numero, contact_numero, updates) + resultat = sage.modifier_contact(numero, contact_numero, updates) if isinstance(resultat, dict) and "data" in resultat: contact_data = resultat["data"] else: @@ -2701,9 +2830,13 @@ async def modifier_contact(numero: str, contact_numero: int, contact: ContactUpd @app.delete("/tiers/{numero}/contacts/{contact_numero}", tags=["Contacts"]) -async def supprimer_contact(numero: str, contact_numero: int): +async def supprimer_contact( + numero: str, + contact_numero: int, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - sage_client.supprimer_contact(numero, contact_numero) + sage.supprimer_contact(numero, contact_numero) return {"success": True, "message": f"Contact {contact_numero} supprimé"} except Exception as e: logger.error(f"Erreur suppression contact: {e}") @@ -2711,9 +2844,13 @@ async def supprimer_contact(numero: str, contact_numero: int): @app.post("/tiers/{numero}/contacts/{contact_numero}/definir-defaut", tags=["Contacts"]) -async def definir_contact_defaut(numero: str, contact_numero: int): +async def definir_contact_defaut( + numero: str, + contact_numero: int, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - resultat = sage_client.definir_contact_defaut(numero, contact_numero) + resultat = sage.definir_contact_defaut(numero, contact_numero) return { "success": True, "message": f"Contact {contact_numero} défini comme contact par défaut", @@ -2731,10 +2868,11 @@ async def obtenir_tiers( description="Filtre par type: 0/client, 1/fournisseur, 2/prospect, 3/all ou strings", ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: type_normalise = normaliser_type_tiers(type_tiers) - tiers = sage_client.lister_tiers(type_tiers=type_normalise, filtre=query or "") + tiers = sage.lister_tiers(type_tiers=type_normalise, filtre=query or "") return [TiersDetails(**t) for t in tiers] except Exception as e: logger.error(f"Erreur recherche tiers: {e}") @@ -2742,9 +2880,12 @@ async def obtenir_tiers( @app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"]) -async def lire_tiers_detail(code: str): +async def lire_tiers_detail( + code: str, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - tiers = sage_client.lire_tiers(code) + tiers = sage.lire_tiers(code) if not tiers: raise HTTPException(404, f"Tiers {code} introuvable") return TiersDetails(**tiers) @@ -2779,10 +2920,11 @@ async def lister_collaborateurs( actifs_seulement: bool = Query( True, description="Exclure les collaborateurs en sommeil" ), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste tous les collaborateurs""" try: - collaborateurs = sage_client.lister_collaborateurs(filtre, actifs_seulement) + collaborateurs = sage.lister_collaborateurs(filtre, actifs_seulement) return [CollaborateurDetails(**c) for c in collaborateurs] except Exception as e: logger.error(f"Erreur liste collaborateurs: {e}") @@ -2794,10 +2936,13 @@ async def lister_collaborateurs( response_model=CollaborateurDetails, tags=["Collaborateurs"], ) -async def lire_collaborateur_detail(numero: int): +async def lire_collaborateur_detail( + numero: int, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Lit un collaborateur par son numéro""" try: - collaborateur = sage_client.lire_collaborateur(numero) + collaborateur = sage.lire_collaborateur(numero) if not collaborateur: raise HTTPException(404, f"Collaborateur {numero} introuvable") @@ -2817,10 +2962,13 @@ async def lire_collaborateur_detail(numero: int): tags=["Collaborateurs"], status_code=201, ) -async def creer_collaborateur(collaborateur: CollaborateurCreate): +async def creer_collaborateur( + collaborateur: CollaborateurCreate, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Crée un nouveau collaborateur""" try: - nouveau = sage_client.creer_collaborateur(collaborateur.model_dump()) + nouveau = sage.creer_collaborateur(collaborateur.model_dump()) if not nouveau: raise HTTPException(500, "Échec création collaborateur") @@ -2839,10 +2987,14 @@ async def creer_collaborateur(collaborateur: CollaborateurCreate): response_model=CollaborateurDetails, tags=["Collaborateurs"], ) -async def modifier_collaborateur(numero: int, collaborateur: CollaborateurUpdate): +async def modifier_collaborateur( + numero: int, + collaborateur: CollaborateurUpdate, + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Modifie un collaborateur existant""" try: - modifie = sage_client.modifier_collaborateur( + modifie = sage.modifier_collaborateur( numero, collaborateur.model_dump(exclude_unset=True) ) @@ -2859,9 +3011,11 @@ async def modifier_collaborateur(numero: int, collaborateur: CollaborateurUpdate @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) -async def obtenir_informations_societe(): +async def obtenir_informations_societe( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - societe = sage_client.lire_informations_societe() + societe = sage.lire_informations_societe() if not societe: raise HTTPException(404, "Informations société introuvables") @@ -2876,10 +3030,12 @@ async def obtenir_informations_societe(): @app.get("/societe/logo", tags=["Société"]) -async def obtenir_logo_societe(): +async def obtenir_logo_societe( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Retourne le logo en tant qu'image directe""" try: - societe = sage_client.lire_informations_societe() + societe = sage.lire_informations_societe() if not societe or not societe.get("logo_base64"): raise HTTPException(404, "Logo introuvable") @@ -2898,10 +3054,12 @@ async def obtenir_logo_societe(): @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) -async def preview_societe(): +async def preview_societe( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Page HTML pour visualiser les infos société avec logo""" try: - societe = sage_client.lire_informations_societe() + societe = sage.lire_informations_societe() if not societe: return "

Société introuvable

" @@ -2970,9 +3128,10 @@ async def preview_societe(): async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.valider_facture(numero_facture) + resultat = sage.valider_facture(numero_facture) logger.info( f"Facture {numero_facture} validée: {resultat.get('action_effectuee')}" ) @@ -2992,9 +3151,10 @@ async def valider_facture( async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.devalider_facture(numero_facture) + resultat = sage.devalider_facture(numero_facture) logger.info( f"Facture {numero_facture} dévalidée: {resultat.get('action_effectuee')}" ) @@ -3014,9 +3174,10 @@ async def devalider_facture( async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.get_statut_validation(numero_facture) + resultat = sage.get_statut_validation(numero_facture) return { "success": True, "data": resultat, @@ -3033,9 +3194,10 @@ async def regler_facture( numero_facture: str, reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.regler_facture( + resultat = sage.regler_facture( numero_facture=numero_facture, montant=float(reglement.montant), mode_reglement=reglement.mode_reglement, @@ -3075,9 +3237,10 @@ async def regler_facture( async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.regler_factures_client( + resultat = sage.regler_factures_client( client_code=reglement.client_id, montant_total=reglement.montant_total, mode_reglement=reglement.mode_reglement, @@ -3112,9 +3275,10 @@ async def regler_factures_multiple( async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.get_reglements_facture(numero_facture) + resultat = sage.get_reglements_facture(numero_facture) return { "success": True, @@ -3135,9 +3299,10 @@ async def get_reglements_client( date_fin: Optional[datetime] = Query(None, description="Date fin"), inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: - resultat = sage_client.get_reglements_client( + resultat = sage.get_reglements_client( client_code=client_id, date_debut=date_debut.isoformat() if date_debut else None, date_fin=date_fin.isoformat() if date_fin else None, @@ -3157,9 +3322,11 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) -async def get_journaux_banque(): +async def get_journaux_banque( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): try: - resultat = sage_client.get_journaux_banque() + resultat = sage.get_journaux_banque() return {"success": True, "data": resultat} except Exception as e: logger.error(f"Erreur lecture journaux: {e}") @@ -3167,10 +3334,12 @@ async def get_journaux_banque(): @app.get("/reglements/modes", tags=["Référentiels"]) -async def get_modes_reglement(): +async def get_modes_reglement( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Liste des modes de règlement disponibles dans Sage""" try: - modes = sage_client.get_modes_reglement() + modes = sage.get_modes_reglement() return {"success": True, "data": {"modes": modes}} except Exception as e: logger.error(f"Erreur lecture modes règlement: {e}") @@ -3178,10 +3347,12 @@ async def get_modes_reglement(): @app.get("/devises", tags=["Référentiels"]) -async def get_devises(): +async def get_devises( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Liste des devises disponibles dans Sage""" try: - devises = sage_client.get_devises() + devises = sage.get_devises() return {"success": True, "data": {"devises": devises}} except Exception as e: logger.error(f"Erreur lecture devises: {e}") @@ -3189,10 +3360,12 @@ async def get_devises(): @app.get("/journaux/tresorerie", tags=["Référentiels"]) -async def get_journaux_tresorerie(): +async def get_journaux_tresorerie( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Liste des journaux de trésorerie (banque + caisse)""" try: - journaux = sage_client.get_journaux_tresorerie() + journaux = sage.get_journaux_tresorerie() return {"success": True, "data": {"journaux": journaux}} except Exception as e: logger.error(f"Erreur lecture journaux: {e}") @@ -3206,12 +3379,11 @@ async def get_comptes_generaux( None, description="client | fournisseur | banque | caisse | tva | produit | charge", ), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des comptes généraux""" try: - comptes = sage_client.get_comptes_generaux( - prefixe=prefixe, type_compte=type_compte - ) + comptes = sage.get_comptes_generaux(prefixe=prefixe, type_compte=type_compte) return {"success": True, "data": {"comptes": comptes, "total": len(comptes)}} except Exception as e: logger.error(f"Erreur lecture comptes généraux: {e}") @@ -3219,10 +3391,12 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) -async def get_tva_taux(): +async def get_tva_taux( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Liste des taux de TVA""" try: - taux = sage_client.get_tva_taux() + taux = sage.get_tva_taux() return {"success": True, "data": {"taux": taux}} except Exception as e: logger.error(f"Erreur lecture taux TVA: {e}") @@ -3230,10 +3404,12 @@ async def get_tva_taux(): @app.get("/parametres/encaissement", tags=["Référentiels"]) -async def get_parametres_encaissement(): +async def get_parametres_encaissement( + sage: SageGatewayClient = Depends(get_sage_client_for_user), +): """Paramètres TVA sur encaissement""" try: - params = sage_client.get_parametres_encaissement() + params = sage.get_parametres_encaissement() return {"success": True, "data": params} except Exception as e: logger.error(f"Erreur lecture paramètres encaissement: {e}") @@ -3269,17 +3445,6 @@ async def root(): } -@app.get("/admin/cache/info", tags=["Admin"]) -async def info_cache(): - try: - cache_info = sage_client.get_cache_info() - return cache_info - - except Exception as e: - logger.error(f"Erreur info cache: {e}") - raise HTTPException(500, str(e)) - - @app.get("/admin/queue/status", tags=["Admin"]) async def statut_queue(): return { From 18603ded6ef749f46beb2bf4585c3e8058aaf566 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 16 Jan 2026 13:22:13 +0300 Subject: [PATCH 07/36] refactor(auth): reorganize imports and remove unused dependencies - Added some missing auth --- api.py | 293 ++++----------------------------------- routes/auth.py | 2 +- routes/universign.py | 170 ++--------------------- services/sage_gateway.py | 2 +- 4 files changed, 35 insertions(+), 432 deletions(-) diff --git a/api.py b/api.py index 6ee5d3f..bbac204 100644 --- a/api.py +++ b/api.py @@ -16,9 +16,10 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select import os from pathlib import Path as FilePath -from data.data import TAGS_METADATA, templates_signature_email +from data.data import TAGS_METADATA from config.config import settings from database import ( + User, init_db, async_session_factory, get_session, @@ -58,7 +59,6 @@ from schemas import ( FactureUpdate, LivraisonCreate, LivraisonUpdate, - StatutSignature, ArticleCreate, Article, ArticleUpdate, @@ -93,9 +93,10 @@ from core.sage_context import ( from utils.generic_functions import ( _preparer_lignes_document, universign_envoyer, - universign_statut, ) +from core.dependencies import get_current_user + if os.path.exists("/app"): LOGS_DIR = FilePath("/app/logs") else: @@ -976,266 +977,6 @@ async def commande_vers_facture( raise HTTPException(500, str(e)) -@app.get("/admin/signatures/relances-auto", tags=["Admin"]) -async def relancer_signatures_automatique(session: AsyncSession = Depends(get_session)): - try: - from datetime import timedelta - - date_limite = datetime.now() - timedelta(days=7) - - query = select(SignatureLog).where( - SignatureLog.statut.in_( - [StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE] - ), - SignatureLog.date_envoi < date_limite, - SignatureLog.nb_relances < 3, # Max 3 relances - ) - - result = await session.execute(query) - signatures_a_relancer = result.scalars().all() - - nb_relances = 0 - - for signature in signatures_a_relancer: - try: - nb_jours = (datetime.now() - signature.date_envoi).days - jours_restants = 30 - nb_jours # Lien expire après 30 jours - - if jours_restants <= 0: - signature.statut = StatutSignatureDB.EXPIRE - continue - - template = templates_signature_email["relance_signature"] - - type_labels = { - 0: "Devis", - 10: "Commande", - 30: "Bon de Livraison", - 60: "Facture", - 50: "Avoir", - } - - variables = { - "NOM_SIGNATAIRE": signature.nom_signataire, - "TYPE_DOC": type_labels.get(signature.type_document, "Document"), - "NUMERO": signature.document_id, - "NB_JOURS": str(nb_jours), - "JOURS_RESTANTS": str(jours_restants), - "SIGNER_URL": signature.signer_url, - "CONTACT_EMAIL": settings.smtp_from, - } - - sujet = template["sujet"] - corps = template["corps_html"] - - for var, valeur in variables.items(): - sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) - corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) - - email_log = EmailLog( - id=str(uuid.uuid4()), - destinataire=signature.email_signataire, - sujet=sujet, - corps_html=corps, - document_ids=signature.document_id, - type_document=signature.type_document, - statut=StatutEmailDB.EN_ATTENTE, - date_creation=datetime.now(), - nb_tentatives=0, - ) - - session.add(email_log) - email_queue.enqueue(email_log.id) - - signature.est_relance = True - signature.nb_relances = (signature.nb_relances or 0) + 1 - - nb_relances += 1 - - logger.info( - f" Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)" - ) - - except Exception as e: - logger.error(f"Erreur relance signature {signature.id}: {e}") - continue - - await session.commit() - - return { - "success": True, - "signatures_verifiees": len(signatures_a_relancer), - "relances_envoyees": nb_relances, - "message": f"{nb_relances} email(s) de relance envoyé(s)", - } - - except Exception as e: - logger.error(f"Erreur relances automatiques: {e}") - raise HTTPException(500, str(e)) - - -@app.get("/signature/universign/status", tags=["Signatures"]) -async def statut_signature(docId: str = Query(...)): - try: - async with async_session_factory() as session: - query = select(SignatureLog).where(SignatureLog.document_id == docId) - result = await session.execute(query) - signature_log = result.scalar_one_or_none() - - if not signature_log: - raise HTTPException(404, "Signature introuvable") - - statut = await universign_statut(signature_log.transaction_id) - - return { - "doc_id": docId, - "statut": statut["statut"], - "date_signature": statut.get("date_signature"), - } - except HTTPException: - raise - except Exception as e: - logger.error(f"Erreur statut signature: {e}") - raise HTTPException(500, str(e)) - - -@app.get("/signatures", tags=["Signatures"]) -async def lister_signatures( - statut: Optional[StatutSignature] = Query(None), - limit: int = Query(100, le=1000), - session: AsyncSession = Depends(get_session), -): - query = select(SignatureLog).order_by(SignatureLog.date_envoi.desc()) - - if statut: - statut_db = StatutSignatureDB[statut.value] - query = query.where(SignatureLog.statut == statut_db) - - query = query.limit(limit) - result = await session.execute(query) - signatures = result.scalars().all() - - return [ - { - "id": sig.id, - "document_id": sig.document_id, - "type_document": sig.type_document.value, - "transaction_id": sig.transaction_id, - "signer_url": sig.signer_url, - "email_signataire": sig.email_signataire, - "nom_signataire": sig.nom_signataire, - "statut": sig.statut.value, - "date_envoi": sig.date_envoi.isoformat() if sig.date_envoi else None, - "date_signature": ( - sig.date_signature.isoformat() if sig.date_signature else None - ), - "est_relance": sig.est_relance, - "nb_relances": sig.nb_relances or 0, - } - for sig in signatures - ] - - -@app.get("/signatures/{transaction_id}/status", tags=["Signatures"]) -async def statut_signature_detail( - transaction_id: str, session: AsyncSession = Depends(get_session) -): - query = select(SignatureLog).where(SignatureLog.transaction_id == transaction_id) - result = await session.execute(query) - signature_log = result.scalar_one_or_none() - - if not signature_log: - raise HTTPException(404, f"Transaction {transaction_id} introuvable") - - statut_universign = await universign_statut(transaction_id) - - if statut_universign.get("statut") != "ERREUR": - statut_map = { - "EN_ATTENTE": StatutSignatureDB.EN_ATTENTE, - "ENVOYE": StatutSignatureDB.ENVOYE, - "SIGNE": StatutSignatureDB.SIGNE, - "REFUSE": StatutSignatureDB.REFUSE, - "EXPIRE": StatutSignatureDB.EXPIRE, - } - - nouveau_statut = statut_map.get( - statut_universign["statut"], StatutSignatureDB.EN_ATTENTE - ) - - signature_log.statut = nouveau_statut - - if statut_universign.get("date_signature"): - signature_log.date_signature = datetime.fromisoformat( - statut_universign["date_signature"].replace("Z", "+00:00") - ) - - await session.commit() - - return { - "transaction_id": transaction_id, - "document_id": signature_log.document_id, - "statut": signature_log.statut.value, - "email_signataire": signature_log.email_signataire, - "date_envoi": ( - signature_log.date_envoi.isoformat() if signature_log.date_envoi else None - ), - "date_signature": ( - signature_log.date_signature.isoformat() - if signature_log.date_signature - else None - ), - "signer_url": signature_log.signer_url, - } - - -@app.post("/signatures/refresh-all", tags=["Signatures"]) -async def rafraichir_statuts_signatures(session: AsyncSession = Depends(get_session)): - query = select(SignatureLog).where( - SignatureLog.statut.in_( - [StatutSignatureDB.EN_ATTENTE, StatutSignatureDB.ENVOYE] - ) - ) - - result = await session.execute(query) - signatures = result.scalars().all() - nb_mises_a_jour = 0 - - for sig in signatures: - try: - statut_universign = await universign_statut(sig.transaction_id) - - if statut_universign.get("statut") != "ERREUR": - statut_map = { - "SIGNE": StatutSignatureDB.SIGNE, - "REFUSE": StatutSignatureDB.REFUSE, - "EXPIRE": StatutSignatureDB.EXPIRE, - } - - nouveau = statut_map.get(statut_universign["statut"]) - - if nouveau and nouveau != sig.statut: - sig.statut = nouveau - - if statut_universign.get("date_signature"): - sig.date_signature = datetime.fromisoformat( - statut_universign["date_signature"].replace("Z", "+00:00") - ) - - nb_mises_a_jour += 1 - - except Exception as e: - logger.error(f"Erreur refresh signature {sig.transaction_id}: {e}") - continue - - await session.commit() - - return { - "success": True, - "nb_signatures_verifiees": len(signatures), - "nb_mises_a_jour": nb_mises_a_jour, - } - - class EmailBatch(BaseModel): destinataires: List[EmailStr] = Field(..., min_length=1, max_length=100) sujet: str = Field(..., min_length=1, max_length=500) @@ -1756,14 +1497,19 @@ class TemplatePreview(BaseModel): @app.get("/templates/emails", response_model=List[TemplateEmail], tags=["Emails"]) -async def lister_templates(): +async def lister_templates( + user: User = Depends(get_current_user), +): return [TemplateEmail(**template) for template in templates_email_db.values()] @app.get( "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) -async def lire_template(template_id: str): +async def lire_template( + template_id: str, + user: User = Depends(get_current_user), +): if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -1771,7 +1517,10 @@ async def lire_template(template_id: str): @app.post("/templates/emails", response_model=TemplateEmail, tags=["Emails"]) -async def creer_template(template: TemplateEmail): +async def creer_template( + template: TemplateEmail, + user: User = Depends(get_current_user), +): template_id = str(uuid.uuid4()) templates_email_db[template_id] = { @@ -1790,7 +1539,11 @@ async def creer_template(template: TemplateEmail): @app.put( "/templates/emails/{template_id}", response_model=TemplateEmail, tags=["Emails"] ) -async def modifier_template(template_id: str, template: TemplateEmail): +async def modifier_template( + template_id: str, + template: TemplateEmail, + user: User = Depends(get_current_user), +): if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -1811,7 +1564,10 @@ async def modifier_template(template_id: str, template: TemplateEmail): @app.delete("/templates/emails/{template_id}", tags=["Emails"]) -async def supprimer_template(template_id: str): +async def supprimer_template( + template_id: str, + user: User = Depends(get_current_user), +): if template_id not in templates_email_db: raise HTTPException(404, f"Template {template_id} introuvable") @@ -2641,6 +2397,7 @@ async def lister_utilisateurs_debug( limit: int = Query(100, le=1000), role: Optional[str] = Query(None), verified_only: bool = Query(False), + user: User = Depends(get_current_user), ): from database import User from sqlalchemy import select diff --git a/routes/auth.py b/routes/auth.py index 5fc2554..d6e6761 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -7,6 +7,7 @@ from typing import Optional import uuid from database import get_session, User, RefreshToken, LoginAttempt +from core.dependencies import get_current_user from security.auth import ( hash_password, verify_password, @@ -19,7 +20,6 @@ from security.auth import ( hash_token, ) from services.email_service import AuthEmailService -from core.dependencies import get_current_user from config.config import settings import logging diff --git a/routes/universign.py b/routes/universign.py index 20d7960..e8dfada 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -1,11 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ +from sqlalchemy import select, func from sqlalchemy.orm import selectinload from typing import List, Optional -from datetime import datetime, timedelta +from datetime import datetime import logging +from core.dependencies import get_current_user from data.data import templates_signature_email from email_queue import email_queue from database import UniversignSignerStatus, UniversignTransactionStatus, get_session @@ -32,7 +32,9 @@ from schemas import ( logger = logging.getLogger(__name__) -router = APIRouter(prefix="/universign", tags=["Universign"]) +router = APIRouter( + prefix="/universign", tags=["Universign"], dependencies=[Depends(get_current_user)] +) sync_service = UniversignSyncService( api_url=settings.universign_api_url, api_key=settings.universign_api_key @@ -494,14 +496,11 @@ async def sync_all_transactions( return {"success": True, "stats": stats, "timestamp": datetime.now().isoformat()} -@router.post("/webhook") -@router.post("/webhook/") +@router.post("/webhook", dependencies=[]) +@router.post("/webhook/", dependencies=[]) async def webhook_universign( request: Request, session: AsyncSession = Depends(get_session) ): - """ - CORRECTION : Extraction correcte du transaction_id selon la structure réelle d'Universign - """ try: payload = await request.json() @@ -1082,159 +1081,6 @@ async def trouver_transactions_inconsistantes( raise HTTPException(500, str(e)) -@router.post("/admin/nettoyer-transactions-erreur", tags=["Admin"]) -async def nettoyer_transactions_erreur( - age_jours: int = Query( - 7, description="Supprimer les transactions en erreur de plus de X jours" - ), - session: AsyncSession = Depends(get_session), -): - try: - date_limite = datetime.now() - timedelta(days=age_jours) - - query = select(UniversignTransaction).where( - and_( - UniversignTransaction.local_status == LocalDocumentStatus.ERROR, - UniversignTransaction.created_at < date_limite, - ) - ) - - result = await session.execute(query) - transactions = result.scalars().all() - - supprimees = [] - for tx in transactions: - supprimees.append( - { - "transaction_id": tx.transaction_id, - "document_id": tx.sage_document_id, - "date_creation": tx.created_at.isoformat(), - "erreur": tx.sync_error, - } - ) - await session.delete(tx) - - await session.commit() - - return { - "success": True, - "transactions_supprimees": len(supprimees), - "age_limite_jours": age_jours, - "details": supprimees, - } - - except Exception as e: - logger.error(f"Erreur nettoyage: {e}") - raise HTTPException(500, str(e)) - - -@router.get("/debug/webhook-payload/{transaction_id}", tags=["Debug"]) -async def voir_dernier_webhook( - transaction_id: str, session: AsyncSession = Depends(get_session) -): - try: - query = select(UniversignTransaction).where( - UniversignTransaction.transaction_id == transaction_id - ) - result = await session.execute(query) - tx = result.scalar_one_or_none() - - if not tx: - raise HTTPException(404, "Transaction introuvable") - - logs_query = ( - select(UniversignSyncLog) - .where( - and_( - UniversignSyncLog.transaction_id == tx.id, - UniversignSyncLog.sync_type.like("webhook:%"), - ) - ) - .order_by(UniversignSyncLog.sync_timestamp.desc()) - .limit(1) - ) - - logs_result = await session.execute(logs_query) - last_webhook_log = logs_result.scalar_one_or_none() - - if not last_webhook_log: - return { - "transaction_id": transaction_id, - "webhook_recu": tx.webhook_received, - "dernier_payload": None, - "message": "Aucun webhook reçu pour cette transaction", - } - - return { - "transaction_id": transaction_id, - "webhook_recu": tx.webhook_received, - "dernier_webhook": { - "timestamp": last_webhook_log.sync_timestamp.isoformat(), - "type": last_webhook_log.sync_type, - "success": last_webhook_log.success, - "payload": json.loads(last_webhook_log.changes_detected) - if last_webhook_log.changes_detected - else None, - }, - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Erreur debug webhook: {e}") - raise HTTPException(500, str(e)) - - -@router.get( - "/transactions/{transaction_id}/document/download", tags=["Documents Signés"] -) -async def telecharger_document_signe( - transaction_id: str, session: AsyncSession = Depends(get_session) -): - try: - query = select(UniversignTransaction).where( - UniversignTransaction.transaction_id == transaction_id - ) - result = await session.execute(query) - transaction = result.scalar_one_or_none() - - if not transaction: - raise HTTPException(404, f"Transaction {transaction_id} introuvable") - - if not transaction.signed_document_path: - raise HTTPException( - 404, - "Document signé non disponible localement. " - "Utilisez POST /admin/download-missing-documents pour le récupérer.", - ) - - file_path = Path(transaction.signed_document_path) - - if not file_path.exists(): - logger.warning(f"Fichier perdu : {file_path}") - raise HTTPException( - 404, - "Fichier introuvable sur le serveur. " - "Utilisez POST /admin/download-missing-documents pour le récupérer.", - ) - - download_name = ( - f"{transaction.sage_document_id}_" - f"{transaction.sage_document_type.name}_" - f"signe.pdf" - ) - - return FileResponse( - path=str(file_path), media_type="application/pdf", filename=download_name - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Erreur téléchargement document : {e}", exc_info=True) - raise HTTPException(500, str(e)) - - @router.get("/transactions/{transaction_id}/document/info", tags=["Documents Signés"]) async def info_document_signe( transaction_id: str, session: AsyncSession = Depends(get_session) diff --git a/services/sage_gateway.py b/services/sage_gateway.py index feccaaf..29abf1e 100644 --- a/services/sage_gateway.py +++ b/services/sage_gateway.py @@ -6,7 +6,7 @@ import httpx from datetime import datetime from typing import Optional, Tuple, List from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import false, select, true, update, and_ +from sqlalchemy import false, select, update, and_ import logging from config.config import settings From 9f12727bd3c603a278d3e013da32bb6377cc6ab8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Fri, 16 Jan 2026 13:26:24 +0300 Subject: [PATCH 08/36] refactor(routes): remove authentication dependency from universign router --- routes/universign.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/routes/universign.py b/routes/universign.py index e8dfada..b8a16c3 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -33,7 +33,9 @@ from schemas import ( logger = logging.getLogger(__name__) router = APIRouter( - prefix="/universign", tags=["Universign"], dependencies=[Depends(get_current_user)] + prefix="/universign", + tags=["Universign"], + # dependencies=[Depends(get_current_user)] ) sync_service = UniversignSyncService( From 09eae50952efea3ad62134eb83b8a211188e3dda Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 19 Jan 2026 20:32:40 +0300 Subject: [PATCH 09/36] refactor: clean up code by removing unnecessary comments --- api.py | 3 -- config/config.py | 8 ----- create_admin.py | 3 -- database/models/api_key.py | 64 +++++++++++++++++++++++++++++++++ routes/enterprise.py | 1 - routes/universign.py | 1 - sage_client.py | 2 -- schemas/articles/articles.py | 1 - schemas/documents/reglements.py | 6 ---- schemas/sage/sage_gateway.py | 2 -- schemas/tiers/commercial.py | 5 --- schemas/tiers/tiers.py | 13 ------- services/universign_document.py | 37 ++++++------------- services/universign_sync.py | 43 +++------------------- utils/generic_functions.py | 6 +--- 15 files changed, 80 insertions(+), 115 deletions(-) create mode 100644 database/models/api_key.py diff --git a/api.py b/api.py index 1b2e078..27f5de3 100644 --- a/api.py +++ b/api.py @@ -132,7 +132,6 @@ async def lifespan(app: FastAPI): api_url=settings.universign_api_url, api_key=settings.universign_api_key ) - # Configuration du service avec les dépendances sync_service.configure( sage_client=sage_client, email_queue=email_queue, settings=settings ) @@ -180,7 +179,6 @@ app.include_router(entreprises_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients( query: Optional[str] = Query(None), - # sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: clients = sage_client.lister_clients(filtre=query or "") @@ -2768,7 +2766,6 @@ async def get_current_sage_config( } -# Routes Collaborateurs @app.get( "/collaborateurs", response_model=List[CollaborateurDetails], diff --git a/config/config.py b/config/config.py index 63bf99b..d60c4d8 100644 --- a/config/config.py +++ b/config/config.py @@ -7,7 +7,6 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore" ) - # === JWT & Auth === jwt_secret: str jwt_algorithm: str access_token_expire_minutes: int @@ -21,15 +20,12 @@ class Settings(BaseSettings): SAGE_TYPE_BON_AVOIR: int = 50 SAGE_TYPE_FACTURE: int = 60 - # === Sage Gateway (Windows) === sage_gateway_url: str sage_gateway_token: str frontend_url: str - # === Base de données === database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db" - # === SMTP === smtp_host: str smtp_port: int = 587 smtp_user: str @@ -37,21 +33,17 @@ class Settings(BaseSettings): smtp_from: str smtp_use_tls: bool = True - # === Universign === universign_api_key: str universign_api_url: str - # === API === api_host: str api_port: int api_reload: bool = False - # === Email Queue === max_email_workers: int = 3 max_retry_attempts: int = 3 retry_delay_seconds: int = 3 - # === CORS === cors_origins: List[str] = ["*"] diff --git a/create_admin.py b/create_admin.py index d3cb786..96197ec 100644 --- a/create_admin.py +++ b/create_admin.py @@ -19,7 +19,6 @@ async def create_admin(): print(" Création d'un compte administrateur") print("=" * 60 + "\n") - # Saisie des informations email = input("Email de l'admin: ").strip().lower() if not email or "@" not in email: print(" Email invalide") @@ -32,7 +31,6 @@ async def create_admin(): print(" Prénom et nom requis") return False - # Mot de passe avec validation while True: password = input( "Mot de passe (min 8 car., 1 maj, 1 min, 1 chiffre, 1 spécial): " @@ -58,7 +56,6 @@ async def create_admin(): print(f"\n Un utilisateur avec l'email {email} existe déjà") return False - # Créer l'admin admin = User( id=str(uuid.uuid4()), email=email, diff --git a/database/models/api_key.py b/database/models/api_key.py new file mode 100644 index 0000000..1e54342 --- /dev/null +++ b/database/models/api_key.py @@ -0,0 +1,64 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text +from datetime import datetime +import uuid + +from database.models.generic_model import Base + + +class ApiKey(Base): + """Modèle pour les clés API publiques""" + + __tablename__ = "api_keys" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + key_hash = Column(String(64), unique=True, nullable=False, index=True) + key_prefix = Column( + String(10), nullable=False + ) # Premiers caractères pour identification + + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + # Métadonnées + user_id = Column(String(36), nullable=True) # Optionnel si associé à un utilisateur + created_by = Column(String(255), nullable=False) + + # Contrôle d'accès + is_active = Column(Boolean, default=True, nullable=False) + rate_limit_per_minute = Column(Integer, default=60, nullable=False) + allowed_endpoints = Column( + Text, nullable=True + ) # JSON array des endpoints autorisés + + # Statistiques + total_requests = Column(Integer, default=0, nullable=False) + last_used_at = Column(DateTime, nullable=True) + + # Dates + created_at = Column(DateTime, default=datetime.now, nullable=False) + expires_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class SwaggerUser(Base): + """Modèle pour les utilisateurs autorisés à accéder au Swagger""" + + __tablename__ = "swagger_users" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username = Column(String(100), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + full_name = Column(String(255), nullable=True) + email = Column(String(255), nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + last_login = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/routes/enterprise.py b/routes/enterprise.py index 2ed18d1..2de1e5f 100644 --- a/routes/enterprise.py +++ b/routes/enterprise.py @@ -22,7 +22,6 @@ async def rechercher_entreprise( try: logger.info(f" Recherche entreprise: '{q}'") - # Appel API api_response = await rechercher_entreprise_api(q, per_page) resultats_api = api_response.get("results", []) diff --git a/routes/universign.py b/routes/universign.py index 20d7960..f67d042 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -511,7 +511,6 @@ async def webhook_universign( transaction_id = None if payload.get("type", "").startswith("transaction.") and "payload" in payload: - # Le transaction_id est dans payload.object.id nested_object = payload.get("payload", {}).get("object", {}) if nested_object.get("object") == "transaction": transaction_id = nested_object.get("id") diff --git a/sage_client.py b/sage_client.py index 0137512..9ad7b50 100644 --- a/sage_client.py +++ b/sage_client.py @@ -1,4 +1,3 @@ -# sage_client.py import requests from typing import Dict, List, Optional from config.config import settings @@ -468,7 +467,6 @@ class SageGatewayClient: "tva_encaissement": tva_encaissement, } - # Champs optionnels if date_reglement: payload["date_reglement"] = date_reglement if code_journal: diff --git a/schemas/articles/articles.py b/schemas/articles/articles.py index 79b2d62..26996a7 100644 --- a/schemas/articles/articles.py +++ b/schemas/articles/articles.py @@ -76,7 +76,6 @@ class Article(BaseModel): ) nb_emplacements: int = Field(0, description="Nombre d'emplacements") - # Champs énumérés normalisés suivi_stock: Optional[int] = Field( None, description="Type de suivi de stock (AR_SuiviStock): 0=Aucun, 1=CMUP, 2=FIFO/LIFO, 3=Sérialisé", diff --git a/schemas/documents/reglements.py b/schemas/documents/reglements.py index bf6d178..5cc5e2c 100644 --- a/schemas/documents/reglements.py +++ b/schemas/documents/reglements.py @@ -10,12 +10,10 @@ logger = logging.getLogger(__name__) class ReglementFactureCreate(BaseModel): """Requête de règlement d'une facture côté VPS""" - # Montant et devise montant: Decimal = Field(..., gt=0, description="Montant à régler") devise_code: Optional[int] = Field(0, description="Code devise (0=EUR par défaut)") cours_devise: Optional[Decimal] = Field(1.0, description="Cours de la devise") - # Mode et journal mode_reglement: int = Field( ..., ge=0, description="Code mode règlement depuis /reglements/modes" ) @@ -23,13 +21,11 @@ class ReglementFactureCreate(BaseModel): ..., min_length=1, description="Code journal depuis /journaux/tresorerie" ) - # Dates date_reglement: Optional[date] = Field( None, description="Date du règlement (défaut: aujourd'hui)" ) date_echeance: Optional[date] = Field(None, description="Date d'échéance") - # Références reference: Optional[str] = Field( "", max_length=17, description="Référence pièce règlement" ) @@ -37,7 +33,6 @@ class ReglementFactureCreate(BaseModel): "", max_length=35, description="Libellé du règlement" ) - # TVA sur encaissement tva_encaissement: Optional[bool] = Field( False, description="Appliquer TVA sur encaissement" ) @@ -81,7 +76,6 @@ class ReglementMultipleCreate(BaseModel): libelle: Optional[str] = Field("") tva_encaissement: Optional[bool] = Field(False) - # Factures spécifiques (optionnel) numeros_factures: Optional[List[str]] = Field( None, description="Si vide, règle les plus anciennes en premier" ) diff --git a/schemas/sage/sage_gateway.py b/schemas/sage/sage_gateway.py index e503641..9501129 100644 --- a/schemas/sage/sage_gateway.py +++ b/schemas/sage/sage_gateway.py @@ -10,7 +10,6 @@ class GatewayHealthStatus(str, Enum): UNKNOWN = "unknown" -# === CREATE === class SageGatewayCreate(BaseModel): name: str = Field( @@ -71,7 +70,6 @@ class SageGatewayUpdate(BaseModel): return v.rstrip("/") if v else v -# === RESPONSE === class SageGatewayResponse(BaseModel): id: str diff --git a/schemas/tiers/commercial.py b/schemas/tiers/commercial.py index 5a4685b..de74165 100644 --- a/schemas/tiers/commercial.py +++ b/schemas/tiers/commercial.py @@ -9,7 +9,6 @@ class CollaborateurBase(BaseModel): prenom: Optional[str] = Field(None, max_length=50) fonction: Optional[str] = Field(None, max_length=50) - # Adresse adresse: Optional[str] = Field(None, max_length=100) complement: Optional[str] = Field(None, max_length=100) code_postal: Optional[str] = Field(None, max_length=10) @@ -17,7 +16,6 @@ class CollaborateurBase(BaseModel): code_region: Optional[str] = Field(None, max_length=50) pays: Optional[str] = Field(None, max_length=50) - # Services service: Optional[str] = Field(None, max_length=50) vendeur: bool = Field(default=False) caissier: bool = Field(default=False) @@ -25,18 +23,15 @@ class CollaborateurBase(BaseModel): chef_ventes: bool = Field(default=False) numero_chef_ventes: Optional[int] = None - # Contact telephone: Optional[str] = Field(None, max_length=20) telecopie: Optional[str] = Field(None, max_length=20) email: Optional[EmailStr] = None tel_portable: Optional[str] = Field(None, max_length=20) - # Réseaux sociaux facebook: Optional[str] = Field(None, max_length=100) linkedin: Optional[str] = Field(None, max_length=100) skype: Optional[str] = Field(None, max_length=100) - # Autres matricule: Optional[str] = Field(None, max_length=20) sommeil: bool = Field(default=False) diff --git a/schemas/tiers/tiers.py b/schemas/tiers/tiers.py index 58166a1..7a46ef5 100644 --- a/schemas/tiers/tiers.py +++ b/schemas/tiers/tiers.py @@ -14,7 +14,6 @@ class TypeTiersInt(IntEnum): class TiersDetails(BaseModel): - # IDENTIFICATION numero: Optional[str] = Field(None, description="Code tiers (CT_Num)") intitule: Optional[str] = Field( None, description="Raison sociale ou Nom complet (CT_Intitule)" @@ -37,7 +36,6 @@ class TiersDetails(BaseModel): ) code_naf: Optional[str] = Field(None, description="Code NAF/APE (CT_Ape)") - # ADRESSE contact: Optional[str] = Field( None, description="Nom du contact principal (CT_Contact)" ) @@ -50,7 +48,6 @@ class TiersDetails(BaseModel): region: Optional[str] = Field(None, description="Région/État (CT_CodeRegion)") pays: Optional[str] = Field(None, description="Pays (CT_Pays)") - # TELECOM telephone: Optional[str] = Field(None, description="Téléphone fixe (CT_Telephone)") telecopie: Optional[str] = Field(None, description="Fax (CT_Telecopie)") email: Optional[str] = Field(None, description="Email principal (CT_EMail)") @@ -58,13 +55,11 @@ class TiersDetails(BaseModel): facebook: Optional[str] = Field(None, description="Profil Facebook (CT_Facebook)") linkedin: Optional[str] = Field(None, description="Profil LinkedIn (CT_LinkedIn)") - # TAUX taux01: Optional[float] = Field(None, description="Taux personnalisé 1 (CT_Taux01)") taux02: Optional[float] = Field(None, description="Taux personnalisé 2 (CT_Taux02)") taux03: Optional[float] = Field(None, description="Taux personnalisé 3 (CT_Taux03)") taux04: Optional[float] = Field(None, description="Taux personnalisé 4 (CT_Taux04)") - # STATISTIQUES statistique01: Optional[str] = Field( None, description="Statistique 1 (CT_Statistique01)" ) @@ -96,7 +91,6 @@ class TiersDetails(BaseModel): None, description="Statistique 10 (CT_Statistique10)" ) - # COMMERCIAL encours_autorise: Optional[float] = Field( None, description="Encours maximum autorisé (CT_Encours)" ) @@ -113,7 +107,6 @@ class TiersDetails(BaseModel): None, description="Détails du commercial/collaborateur" ) - # FACTURATION lettrage_auto: Optional[bool] = Field( None, description="Lettrage automatique (CT_Lettrage)" ) @@ -146,7 +139,6 @@ class TiersDetails(BaseModel): None, description="Bon à payer obligatoire (CT_BonAPayer)" ) - # LOGISTIQUE priorite_livraison: Optional[int] = Field( None, description="Priorité livraison (CT_PrioriteLivr)" ) @@ -160,17 +152,14 @@ class TiersDetails(BaseModel): None, description="Délai appro jours (CT_DelaiAppro)" ) - # COMMENTAIRE commentaire: Optional[str] = Field( None, description="Commentaire libre (CT_Commentaire)" ) - # ANALYTIQUE section_analytique: Optional[str] = Field( None, description="Section analytique (CA_Num)" ) - # ORGANISATION / SURVEILLANCE mode_reglement_code: Optional[int] = Field( None, description="Code mode règlement (MR_No)" ) @@ -200,7 +189,6 @@ class TiersDetails(BaseModel): None, description="Résultat financier (CT_SvResultat)" ) - # COMPTE GENERAL ET CATEGORIES compte_general: Optional[str] = Field( None, description="Compte général principal (CG_NumPrinc)" ) @@ -211,7 +199,6 @@ class TiersDetails(BaseModel): None, description="Catégorie comptable (N_CatCompta)" ) - # CONTACTS contacts: Optional[List[Contact]] = Field( default_factory=list, description="Liste des contacts du tiers" ) diff --git a/services/universign_document.py b/services/universign_document.py index fa899a9..9cb714e 100644 --- a/services/universign_document.py +++ b/services/universign_document.py @@ -38,7 +38,6 @@ class UniversignDocumentService: logger.info(f"{len(documents)} document(s) trouvé(s)") - # Log détaillé de chaque document for idx, doc in enumerate(documents): logger.debug( f" Document {idx}: id={doc.get('id')}, " @@ -64,7 +63,7 @@ class UniversignDocumentService: logger.error(f"⏱️ Timeout récupération transaction {transaction_id}") return None except Exception as e: - logger.error(f"❌ Erreur fetch documents: {e}", exc_info=True) + logger.error(f" Erreur fetch documents: {e}", exc_info=True) return None def download_signed_document( @@ -94,7 +93,6 @@ class UniversignDocumentService: f"Content-Type={content_type}, Size={content_length}" ) - # Vérification du type de contenu if ( "pdf" not in content_type.lower() and "octet-stream" not in content_type.lower() @@ -104,31 +102,30 @@ class UniversignDocumentService: f"Tentative de lecture quand même..." ) - # Lecture du contenu content = response.content if len(content) < 1024: - logger.error(f"❌ Document trop petit: {len(content)} octets") + logger.error(f" Document trop petit: {len(content)} octets") return None return content elif response.status_code == 404: logger.error( - f"❌ Document {document_id} introuvable pour transaction {transaction_id}" + f" Document {document_id} introuvable pour transaction {transaction_id}" ) return None elif response.status_code == 403: logger.error( - f"❌ Accès refusé au document {document_id}. " + f" Accès refusé au document {document_id}. " f"Vérifiez que la transaction est bien signée." ) return None else: logger.error( - f"❌ Erreur HTTP {response.status_code}: {response.text[:500]}" + f" Erreur HTTP {response.status_code}: {response.text[:500]}" ) return None @@ -136,13 +133,12 @@ class UniversignDocumentService: logger.error(f"⏱️ Timeout téléchargement document {document_id}") return None except Exception as e: - logger.error(f"❌ Erreur téléchargement: {e}", exc_info=True) + logger.error(f" Erreur téléchargement: {e}", exc_info=True) return None async def download_and_store_signed_document( self, session: AsyncSession, transaction, force: bool = False ) -> Tuple[bool, Optional[str]]: - # Vérification si déjà téléchargé if not force and transaction.signed_document_path: if os.path.exists(transaction.signed_document_path): logger.debug( @@ -153,7 +149,6 @@ class UniversignDocumentService: transaction.download_attempts += 1 try: - # ÉTAPE 1: Récupérer les documents de la transaction logger.info( f"Récupération document signé pour: {transaction.transaction_id}" ) @@ -167,13 +162,11 @@ class UniversignDocumentService: await session.commit() return False, error - # ÉTAPE 2: Récupérer le premier document (ou chercher celui qui est signé) document_id = None for doc in documents: doc_id = doc.get("id") doc_status = doc.get("status", "").lower() - # Priorité aux documents marqués comme signés/complétés if doc_status in ["signed", "completed", "closed"]: document_id = doc_id logger.info( @@ -181,34 +174,30 @@ class UniversignDocumentService: ) break - # Fallback sur le premier document si aucun n'est explicitement signé if document_id is None: document_id = doc_id if not document_id: error = "Impossible de déterminer l'ID du document à télécharger" - logger.error(f"❌ {error}") + logger.error(f" {error}") transaction.download_error = error await session.commit() return False, error - # Stocker le document_id pour référence future if hasattr(transaction, "universign_document_id"): transaction.universign_document_id = document_id - # ÉTAPE 3: Télécharger le document signé pdf_content = self.download_signed_document( transaction_id=transaction.transaction_id, document_id=document_id ) if not pdf_content: error = f"Échec téléchargement document {document_id}" - logger.error(f"❌ {error}") + logger.error(f" {error}") transaction.download_error = error await session.commit() return False, error - # ÉTAPE 4: Stocker le fichier localement filename = self._generate_filename(transaction) file_path = SIGNED_DOCS_DIR / filename @@ -217,13 +206,11 @@ class UniversignDocumentService: file_size = os.path.getsize(file_path) - # Mise à jour de la transaction transaction.signed_document_path = str(file_path) transaction.signed_document_downloaded_at = datetime.now() transaction.signed_document_size_bytes = file_size transaction.download_error = None - # Stocker aussi l'URL de téléchargement pour référence transaction.document_url = ( f"{self.api_url}/transactions/{transaction.transaction_id}" f"/documents/{document_id}/download" @@ -239,14 +226,14 @@ class UniversignDocumentService: except OSError as e: error = f"Erreur filesystem: {str(e)}" - logger.error(f"❌ {error}") + logger.error(f" {error}") transaction.download_error = error await session.commit() return False, error except Exception as e: error = f"Erreur inattendue: {str(e)}" - logger.error(f"❌ {error}", exc_info=True) + logger.error(f" {error}", exc_info=True) transaction.download_error = error await session.commit() return False, error @@ -294,7 +281,6 @@ class UniversignDocumentService: return deleted, int(size_freed_mb) - # === MÉTHODES DE DIAGNOSTIC === def diagnose_transaction(self, transaction_id: str) -> Dict: """ @@ -308,7 +294,6 @@ class UniversignDocumentService: } try: - # Test 1: Récupération de la transaction logger.info(f"Diagnostic transaction: {transaction_id}") response = requests.get( @@ -334,7 +319,6 @@ class UniversignDocumentService: "participants_count": len(data.get("participants", [])), } - # Test 2: Documents disponibles documents = data.get("documents", []) result["checks"]["documents"] = [] @@ -345,7 +329,6 @@ class UniversignDocumentService: "status": doc.get("status"), } - # Test téléchargement if doc.get("id"): download_url = ( f"{self.api_url}/transactions/{transaction_id}" diff --git a/services/universign_sync.py b/services/universign_sync.py index 28e633c..807802d 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -159,7 +159,6 @@ class UniversignSyncService: return stats - # CORRECTION 1 : process_webhook dans universign_sync.py async def process_webhook( self, session: AsyncSession, payload: Dict, transaction_id: str = None ) -> Tuple[bool, Optional[str]]: @@ -167,9 +166,7 @@ class UniversignSyncService: Traite un webhook Universign - CORRECTION : meilleure gestion des payloads """ try: - # Si transaction_id n'est pas fourni, essayer de l'extraire if not transaction_id: - # Même logique que dans universign.py if ( payload.get("type", "").startswith("transaction.") and "payload" in payload @@ -195,7 +192,6 @@ class UniversignSyncService: f"📨 Traitement webhook: transaction={transaction_id}, event={event_type}" ) - # Récupérer la transaction locale query = ( select(UniversignTransaction) .options(selectinload(UniversignTransaction.signers)) @@ -208,25 +204,20 @@ class UniversignSyncService: logger.warning(f"Transaction {transaction_id} inconnue localement") return False, "Transaction inconnue" - # Marquer comme webhook reçu transaction.webhook_received = True - # Stocker l'ancien statut pour comparaison old_status = transaction.local_status.value - # Force la synchronisation complète success, error = await self.sync_transaction( session, transaction, force=True ) - # Log du changement de statut if success and transaction.local_status.value != old_status: logger.info( f"Webhook traité: {transaction_id} | " f"{old_status} → {transaction.local_status.value}" ) - # Enregistrer le log du webhook await self._log_sync_attempt( session=session, transaction=transaction, @@ -248,7 +239,6 @@ class UniversignSyncService: logger.error(f"💥 Erreur traitement webhook: {e}", exc_info=True) return False, str(e) - # CORRECTION 2 : _sync_signers - Ne pas écraser les signers existants async def _sync_signers( self, session: AsyncSession, @@ -271,7 +261,6 @@ class UniversignSyncService: logger.warning(f"Signataire sans email à l'index {idx}, ignoré") continue - # PROTECTION : gérer les statuts inconnus raw_status = signer_data.get("status") or signer_data.get( "state", "waiting" ) @@ -302,7 +291,6 @@ class UniversignSyncService: if signer_data.get("name") and not signer.name: signer.name = signer_data.get("name") else: - # Nouveau signer avec gestion d'erreur intégrée try: signer = UniversignSigner( id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", @@ -330,7 +318,6 @@ class UniversignSyncService: ): import json - # Si statut final et pas de force, skip if is_final_status(transaction.local_status.value) and not force: logger.debug( f"⏭️ Skip {transaction.transaction_id}: statut final " @@ -340,14 +327,13 @@ class UniversignSyncService: await session.commit() return True, None - # Récupération du statut distant logger.info(f"Synchronisation: {transaction.transaction_id}") result = self.fetch_transaction_status(transaction.transaction_id) if not result: error = "Échec récupération données Universign" - logger.error(f"❌ {error}: {transaction.transaction_id}") + logger.error(f" {error}: {transaction.transaction_id}") transaction.sync_attempts += 1 transaction.sync_error = error await self._log_sync_attempt(session, transaction, "polling", False, error) @@ -360,7 +346,6 @@ class UniversignSyncService: logger.info(f"📊 Statut Universign brut: {universign_status_raw}") - # Convertir le statut new_local_status = map_universign_to_local(universign_status_raw) previous_local_status = transaction.local_status.value @@ -369,7 +354,6 @@ class UniversignSyncService: f"{new_local_status} (Local) | Actuel: {previous_local_status}" ) - # Vérifier la transition if not is_transition_allowed(previous_local_status, new_local_status): logger.warning( f"Transition refusée: {previous_local_status} → {new_local_status}" @@ -386,7 +370,6 @@ class UniversignSyncService: f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" ) - # Mise à jour du statut Universign brut try: transaction.universign_status = UniversignTransactionStatus( universign_status_raw @@ -404,11 +387,9 @@ class UniversignSyncService: else: transaction.universign_status = UniversignTransactionStatus.STARTED - # Mise à jour du statut local transaction.local_status = LocalDocumentStatus(new_local_status) transaction.universign_status_updated_at = datetime.now() - # Mise à jour des dates if new_local_status == "EN_COURS" and not transaction.sent_at: transaction.sent_at = datetime.now() logger.info("📅 Date d'envoi mise à jour") @@ -419,15 +400,12 @@ class UniversignSyncService: if new_local_status == "REFUSE" and not transaction.refused_at: transaction.refused_at = datetime.now() - logger.info("❌ Date de refus mise à jour") + logger.info(" Date de refus mise à jour") if new_local_status == "EXPIRE" and not transaction.expired_at: transaction.expired_at = datetime.now() logger.info("⏰ Date d'expiration mise à jour") - # === SECTION CORRIGÉE: Gestion des documents === - # Ne plus chercher document_url dans la réponse (elle n'existe pas!) - # Le téléchargement se fait via le service document qui utilise le bon endpoint documents = universign_data.get("documents", []) if documents: @@ -437,7 +415,6 @@ class UniversignSyncService: f"status={first_doc.get('status')}" ) - # Téléchargement automatique du document signé if new_local_status == "SIGNE" and not transaction.signed_document_path: logger.info("Déclenchement téléchargement document signé...") @@ -456,20 +433,16 @@ class UniversignSyncService: except Exception as e: logger.error( - f"❌ Erreur téléchargement document: {e}", exc_info=True + f" Erreur téléchargement document: {e}", exc_info=True ) - # === FIN SECTION CORRIGÉE === - # Synchroniser les signataires await self._sync_signers(session, transaction, universign_data) - # Mise à jour des métadonnées de sync transaction.last_synced_at = datetime.now() transaction.sync_attempts += 1 transaction.needs_sync = not is_final_status(new_local_status) transaction.sync_error = None - # Log de la tentative await self._log_sync_attempt( session=session, transaction=transaction, @@ -491,7 +464,6 @@ class UniversignSyncService: await session.commit() - # Exécuter les actions post-changement if status_changed: logger.info(f"🎬 Exécution actions pour statut: {new_local_status}") await self._execute_status_actions( @@ -507,7 +479,7 @@ class UniversignSyncService: except Exception as e: error_msg = f"Erreur lors de la synchronisation: {str(e)}" - logger.error(f"❌ {error_msg}", exc_info=True) + logger.error(f" {error_msg}", exc_info=True) transaction.sync_error = error_msg[:1000] transaction.sync_attempts += 1 @@ -519,20 +491,16 @@ class UniversignSyncService: return False, error_msg - # CORRECTION 3 : Amélioration du logging dans sync_transaction async def _sync_transaction_documents_corrected( self, session, transaction, universign_data: dict, new_local_status: str ): - # Récupérer et stocker les infos documents documents = universign_data.get("documents", []) if documents: - # Stocker le premier document_id pour référence first_doc = documents[0] first_doc_id = first_doc.get("id") if first_doc_id: - # Stocker l'ID du document (si le champ existe dans le modèle) if hasattr(transaction, "universign_document_id"): transaction.universign_document_id = first_doc_id @@ -543,7 +511,6 @@ class UniversignSyncService: else: logger.debug("Aucun document dans la réponse Universign") - # Téléchargement automatique si signé if new_local_status == "SIGNE": if not transaction.signed_document_path: logger.info("Déclenchement téléchargement document signé...") @@ -563,7 +530,7 @@ class UniversignSyncService: except Exception as e: logger.error( - f"❌ Erreur téléchargement document: {e}", exc_info=True + f" Erreur téléchargement document: {e}", exc_info=True ) else: logger.debug( diff --git a/utils/generic_functions.py b/utils/generic_functions.py index 41b734b..4cce52f 100644 --- a/utils/generic_functions.py +++ b/utils/generic_functions.py @@ -290,15 +290,11 @@ def _preparer_lignes_document(lignes: List) -> List[Dict]: UNIVERSIGN_TO_LOCAL: Dict[str, str] = { - # États initiaux "draft": "EN_ATTENTE", "ready": "EN_ATTENTE", - # En cours "started": "EN_COURS", - # États finaux (succès) "completed": "SIGNE", "closed": "SIGNE", - # États finaux (échec) "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", @@ -441,7 +437,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "ERREUR": { "fr": "Erreur technique", "en": "Technical error", - "icon": "", + "icon": "⚠️", "color": "red", }, } From 9f6c1de8efcb808397f4d149293ea8bdd97d86df Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 19 Jan 2026 20:35:03 +0300 Subject: [PATCH 10/36] feat(api-keys): implement api key management system --- middleware/security.py | 296 +++++++++++++++++++++++++++++++++++++++++ routes/api_keys.py | 180 +++++++++++++++++++++++++ schemas/api_key.py | 77 +++++++++++ 3 files changed, 553 insertions(+) create mode 100644 middleware/security.py create mode 100644 routes/api_keys.py create mode 100644 schemas/api_key.py diff --git a/middleware/security.py b/middleware/security.py new file mode 100644 index 0000000..17186a0 --- /dev/null +++ b/middleware/security.py @@ -0,0 +1,296 @@ +import secrets +from fastapi import Request, HTTPException, status +from fastapi.responses import JSONResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from sqlalchemy import select +from typing import Optional +from datetime import datetime +import logging + +from database import get_session +from database.models.api_key import SwaggerUser +from security.auth import verify_password + +logger = logging.getLogger(__name__) + +# === Configuration Swagger === +security = HTTPBasic() + + +async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: + """ + VERSION 2: Vérification des identifiants Swagger via base de données + + ✅ Plus sécurisé + ✅ Gestion centralisée + ✅ Tracking des connexions + """ + username = credentials.username + password = credentials.password + + try: + # Utiliser get_session de manière asynchrone + async for session in get_session(): + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + swagger_user = result.scalar_one_or_none() + + if swagger_user and swagger_user.is_active: + if verify_password(password, swagger_user.hashed_password): + # Mise à jour de la dernière connexion + swagger_user.last_login = datetime.now() + await session.commit() + + logger.info(f"✅ Accès Swagger autorisé (DB): {username}") + return True + + logger.warning(f"⚠️ Tentative d'accès Swagger refusée: {username}") + return False + + except Exception as e: + logger.error(f"❌ Erreur vérification Swagger credentials: {e}") + return False + + +class SwaggerAuthMiddleware: + """ + Middleware pour protéger les endpoints de documentation + (/docs, /redoc, /openapi.json) + + VERSION 2: Avec vérification en base de données + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + # Endpoints à protéger + protected_paths = ["/docs", "/redoc", "/openapi.json"] + + if any(path.startswith(protected_path) for protected_path in protected_paths): + # Vérification de l'authentification Basic + auth_header = request.headers.get("Authorization") + + if not auth_header or not auth_header.startswith("Basic "): + # Demande d'authentification + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise pour accéder à la documentation" + }, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + # Extraction des credentials + try: + import base64 + + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode( + "utf-8" + ) + username, password = decoded_credentials.split(":", 1) + + credentials = HTTPBasicCredentials(username=username, password=password) + + # Vérification via DB + if not await verify_swagger_credentials(credentials): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Identifiants invalides"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + except Exception as e: + logger.error(f"❌ Erreur parsing auth header: {e}") + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Format d'authentification invalide"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + # Si tout est OK, continuer + await self.app(scope, receive, send) + + +class ApiKeyMiddleware: + """ + Middleware HYBRIDE pour vérifier les clés API sur les endpoints publics + + ✅ Accepte X-API-Key OU Authorization: Bearer (JWT) + ✅ Les deux méthodes sont équivalentes + ✅ Les endpoints /auth/* restent accessibles sans auth + """ + + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + # ============================================ + # ENDPOINTS EXCLUS (accessibles sans auth) + # ============================================ + excluded_paths = [ + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/", + # === ROUTES AUTH (toujours publiques) === + "/api/v1/auth/login", + "/api/v1/auth/register", + "/api/v1/auth/verify-email", + "/api/v1/auth/reset-password", + "/api/v1/auth/request-reset", + "/api/v1/auth/refresh", + ] + + if any(path.startswith(excluded_path) for excluded_path in excluded_paths): + await self.app(scope, receive, send) + return + + # ============================================ + # VÉRIFICATION HYBRIDE: API Key OU JWT + # ============================================ + + # Option 1: Vérifier si JWT présent + auth_header = request.headers.get("Authorization") + has_jwt = auth_header and auth_header.startswith("Bearer ") + + # Option 2: Vérifier si API Key présente + api_key = request.headers.get("X-API-Key") + has_api_key = api_key is not None + + # ============================================ + # LOGIQUE HYBRIDE + # ============================================ + + if has_jwt: + # JWT présent → laisser passer, sera validé par les dependencies FastAPI + logger.debug(f"🔑 JWT détecté pour {path}") + await self.app(scope, receive, send) + return + + elif has_api_key: + # API Key présente → valider la clé + logger.debug(f"🔑 API Key détectée pour {path}") + + from services.api_key import ApiKeyService + + try: + async for session in get_session(): + api_key_service = ApiKeyService(session) + api_key_obj = await api_key_service.verify_api_key(api_key) + + if not api_key_obj: + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Clé API invalide ou expirée", + "hint": "Utilisez X-API-Key: sdk_live_xxx ou Authorization: Bearer ", + }, + ) + await response(scope, receive, send) + return + + # Vérification du rate limit + is_allowed, rate_info = await api_key_service.check_rate_limit( + api_key_obj + ) + if not is_allowed: + response = JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit dépassé"}, + headers={ + "X-RateLimit-Limit": str(rate_info["limit"]), + "X-RateLimit-Remaining": "0", + }, + ) + await response(scope, receive, send) + return + + # Vérification de l'accès à l'endpoint + has_access = await api_key_service.check_endpoint_access( + api_key_obj, path + ) + if not has_access: + response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "Accès non autorisé à cet endpoint", + "endpoint": path, + "api_key": api_key_obj.key_prefix + "...", + }, + ) + await response(scope, receive, send) + return + + # ✅ Clé valide → ajouter les infos à la requête + request.state.api_key = api_key_obj + request.state.authenticated_via = "api_key" + + logger.info(f"✅ API Key valide: {api_key_obj.name} → {path}") + + # Continuer la requête + await self.app(scope, receive, send) + return + + except Exception as e: + logger.error(f"❌ Erreur validation API Key: {e}", exc_info=True) + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "Erreur interne lors de la validation de la clé" + }, + ) + await response(scope, receive, send) + return + + else: + # ❌ Ni JWT ni API Key + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + await response(scope, receive, send) + return + + +# === Fonction helper pour récupérer l'API Key depuis la requête === +def get_api_key_from_request(request: Request) -> Optional: + """Récupère l'objet ApiKey depuis la requête si présent""" + return getattr(request.state, "api_key", None) + + +def get_auth_method(request: Request) -> str: + """ + Retourne la méthode d'authentification utilisée + + Returns: + "jwt" | "api_key" | "none" + """ + return getattr(request.state, "authenticated_via", "none") diff --git a/routes/api_keys.py b/routes/api_keys.py new file mode 100644 index 0000000..8c8f6db --- /dev/null +++ b/routes/api_keys.py @@ -0,0 +1,180 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +import logging + +from database import get_session, User +from core.dependencies import get_current_user, require_role +from services.api_key import ApiKeyService, api_key_to_response +from schemas.api_key import ( + ApiKeyCreate, + ApiKeyCreatedResponse, + ApiKeyResponse, + ApiKeyList, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api-keys", tags=["API Keys Management"]) + + +@router.post( + "", + response_model=ApiKeyCreatedResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_role("admin", "super_admin"))], +) +async def create_api_key( + data: ApiKeyCreate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ + 🔑 Créer une nouvelle clé API + + **Réservé aux admins** + + ⚠️ La clé en clair ne sera affichée qu'une seule fois ! + """ + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=data.name, + description=data.description, + created_by=user.email, + user_id=user.id, + expires_in_days=data.expires_in_days, + rate_limit_per_minute=data.rate_limit_per_minute, + allowed_endpoints=data.allowed_endpoints, + ) + + logger.info(f"✅ Clé API créée par {user.email}: {data.name}") + + response_data = api_key_to_response(api_key_obj) + response_data["api_key"] = api_key_plain + + return ApiKeyCreatedResponse(**response_data) + + +@router.get("", response_model=ApiKeyList) +async def list_api_keys( + include_revoked: bool = Query(False, description="Inclure les clés révoquées"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ + 📋 Lister toutes les clés API + + **Réservé aux admins** - liste toutes les clés + **Utilisateurs standards** - liste uniquement leurs clés + """ + service = ApiKeyService(session) + + # Si admin, voir toutes les clés, sinon seulement les siennes + user_id = None if user.role in ["admin", "super_admin"] else user.id + + keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id) + + items = [ApiKeyResponse(**api_key_to_response(k)) for k in keys] + + return ApiKeyList(total=len(items), items=items) + + +@router.get("/{key_id}", response_model=ApiKeyResponse) +async def get_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """🔍 Récupérer une clé API par son ID""" + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + # Vérification des permissions + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + return ApiKeyResponse(**api_key_to_response(api_key_obj)) + + +@router.delete("/{key_id}", status_code=status.HTTP_200_OK) +async def revoke_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ + 🚫 Révoquer une clé API + + **Action irréversible** - la clé sera désactivée définitivement + """ + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + # Vérification des permissions + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + success = await service.revoke_api_key(key_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erreur lors de la révocation", + ) + + logger.info(f"🚫 Clé API révoquée par {user.email}: {api_key_obj.name}") + + return { + "success": True, + "message": f"Clé API '{api_key_obj.name}' révoquée avec succès", + } + + +@router.post("/verify", status_code=status.HTTP_200_OK) +async def verify_api_key_endpoint( + api_key: str = Query(..., description="Clé API à vérifier"), + session: AsyncSession = Depends(get_session), +): + """ + ✅ Vérifier la validité d'une clé API + + **Endpoint public** - permet de tester une clé + """ + service = ApiKeyService(session) + + api_key_obj = await service.verify_api_key(api_key) + + if not api_key_obj: + return { + "valid": False, + "message": "Clé API invalide, expirée ou révoquée", + } + + return { + "valid": True, + "message": "Clé API valide", + "key_name": api_key_obj.name, + "rate_limit": api_key_obj.rate_limit_per_minute, + "expires_at": api_key_obj.expires_at, + } diff --git a/schemas/api_key.py b/schemas/api_key.py new file mode 100644 index 0000000..6a2d659 --- /dev/null +++ b/schemas/api_key.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class ApiKeyCreate(BaseModel): + """Schema pour créer une clé API""" + + name: str = Field(..., min_length=3, max_length=255, description="Nom de la clé") + description: Optional[str] = Field(None, description="Description de l'usage") + expires_in_days: Optional[int] = Field( + None, ge=1, le=3650, description="Expiration en jours (max 10 ans)" + ) + rate_limit_per_minute: int = Field( + 60, ge=1, le=1000, description="Limite de requêtes par minute" + ) + allowed_endpoints: Optional[List[str]] = Field( + None, description="Endpoints autorisés ([] = tous, ['/clients*'] = wildcard)" + ) + + +class ApiKeyResponse(BaseModel): + """Schema de réponse pour une clé API""" + + id: str + name: str + description: Optional[str] + key_prefix: str + is_active: bool + is_expired: bool + rate_limit_per_minute: int + allowed_endpoints: Optional[List[str]] + total_requests: int + last_used_at: Optional[datetime] + created_at: datetime + expires_at: Optional[datetime] + revoked_at: Optional[datetime] + created_by: str + + +class ApiKeyCreatedResponse(ApiKeyResponse): + """Schema de réponse après création (inclut la clé en clair)""" + + api_key: str = Field( + ..., description="⚠️ Clé API en clair - à sauvegarder immédiatement" + ) + + +class ApiKeyList(BaseModel): + """Liste de clés API""" + + total: int + items: List[ApiKeyResponse] + + +class SwaggerUserCreate(BaseModel): + """Schema pour créer un utilisateur Swagger""" + + username: str = Field(..., min_length=3, max_length=100) + password: str = Field(..., min_length=8) + full_name: Optional[str] = None + email: Optional[str] = None + + +class SwaggerUserResponse(BaseModel): + """Schema de réponse pour un utilisateur Swagger""" + + id: str + username: str + full_name: Optional[str] + email: Optional[str] + is_active: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True From a10fda072cf489405d9d0d8f9870947ff75d1335 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 19 Jan 2026 20:38:01 +0300 Subject: [PATCH 11/36] feat(security): implement API key authentication system --- .gitignore | 4 +- config/cors_config.py | 321 ++++++++++++++++++++++++++++++++ core/dependencies.py | 239 ++++++++++++++++++------ scripts/manage_security.py | 290 +++++++++++++++++++++++++++++ scripts/test_security.py | 369 +++++++++++++++++++++++++++++++++++++ services/api_key.py | 233 +++++++++++++++++++++++ 6 files changed, 1395 insertions(+), 61 deletions(-) create mode 100644 config/cors_config.py create mode 100644 scripts/manage_security.py create mode 100644 scripts/test_security.py create mode 100644 services/api_key.py diff --git a/.gitignore b/.gitignore index 7d75fa8..ed8f8ab 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,6 @@ tools/ .env.staging .env.production -.trunk \ No newline at end of file +.trunk + +*clean*.py \ No newline at end of file diff --git a/config/cors_config.py b/config/cors_config.py new file mode 100644 index 0000000..6ee880e --- /dev/null +++ b/config/cors_config.py @@ -0,0 +1,321 @@ +""" +CONFIGURATION CORS POUR API AVEC CLÉS API MULTIPLES + +Problématique: +- Les clés API seront utilisées depuis de nombreux domaines/IPs différents +- Impossible de lister tous les origins autorisés à l'avance +- Solution: CORS permissif mais sécurisé par les clés API + +Stratégies: +1. CORS ouvert avec validation par clé API (RECOMMANDÉ) +2. CORS dynamique basé sur whitelist +3. CORS avec wildcard et credentials=False +""" + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from typing import List +import os +import logging + +logger = logging.getLogger(__name__) + + +# ============================================ +# STRATÉGIE 1: CORS OUVERT + VALIDATION API KEY +# ============================================ +# ✅ RECOMMANDÉ pour les API publiques avec clés +# +# Principe: +# - Accepter toutes les origines (allow_origins=["*"]) +# - La sécurité est assurée par la validation des clés API +# - Les clés API protègent l'accès, pas le CORS + + +def configure_cors_open(app: FastAPI): + """ + Configuration CORS ouverte (RECOMMANDÉE) + + ✅ Accepte toutes les origines + ✅ Sécurité assurée par les clés API + ✅ Simplifie l'utilisation pour les clients + + ⚠️ Attention: credentials=False obligatoire avec allow_origins=["*"] + """ + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Accepte toutes les origines + allow_credentials=False, # ⚠️ Obligatoire avec "*" + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["*"], # Accepte tous les headers (dont X-API-Key) + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, # Cache preflight requests pendant 1h + ) + + logger.info("🌐 CORS configuré: Mode OUVERT (sécurisé par API Keys)") + logger.info(" - Origins: * (toutes)") + logger.info(" - Headers: * (dont X-API-Key)") + logger.info(" - Credentials: False") + + +# ============================================ +# STRATÉGIE 2: CORS AVEC WHITELIST DYNAMIQUE +# ============================================ +# 🔶 Pour environnements contrôlés +# +# Principe: +# - Lister explicitement les domaines autorisés +# - Peut inclure des patterns wildcards +# - Credentials possible (cookies, etc.) + + +def configure_cors_whitelist(app: FastAPI): + """ + Configuration CORS avec whitelist (MODE CONTRÔLÉ) + + ✅ Meilleur contrôle des origines + ✅ Credentials possible + ❌ Nécessite maintenance de la liste + + À utiliser si vous connaissez tous les domaines clients + """ + + # Charger depuis .env ou config + allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") + + if allowed_origins_str: + allowed_origins = [ + origin.strip() + for origin in allowed_origins_str.split(",") + if origin.strip() + ] + else: + # Valeurs par défaut + allowed_origins = [ + "http://localhost:3000", # Frontend dev React/Vue + "http://localhost:5173", # Vite dev + "http://localhost:8080", # Frontend dev alternatif + "https://app.votre-domaine.com", + "https://admin.votre-domaine.com", + # Ajouter vos domaines de production + ] + + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, # ✅ Possible avec liste explicite + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info("🌐 CORS configuré: Mode WHITELIST") + logger.info(f" - Origins autorisées: {len(allowed_origins)}") + for origin in allowed_origins: + logger.info(f" • {origin}") + + +# ============================================ +# STRATÉGIE 3: CORS AVEC REGEX (AVANCÉ) +# ============================================ +# 🔶 Pour patterns complexes +# +# Exemple: Autoriser tous les sous-domaines *.votre-domaine.com + + +def configure_cors_regex(app: FastAPI): + """ + Configuration CORS avec patterns regex (AVANCÉ) + + ✅ Flexible pour sous-domaines + ✅ Supporte patterns complexes + ❌ Plus complexe à configurer + + Utilise allow_origin_regex au lieu de allow_origins + """ + + # Pattern regex pour autoriser tous les sous-domaines + origin_regex = r"https://.*\.votre-domaine\.com" + + app.add_middleware( + CORSMiddleware, + allow_origin_regex=origin_regex, # ⚠️ Utilise regex au lieu de liste + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info("🌐 CORS configuré: Mode REGEX") + logger.info(f" - Pattern: {origin_regex}") + + +# ============================================ +# STRATÉGIE 4: CORS HYBRIDE (PRODUCTION) +# ============================================ +# ✅ RECOMMANDÉ pour production +# +# Principe: +# - Whitelist pour les domaines connus (credentials=True) +# - Fallback sur "*" pour le reste (credentials=False) + + +def configure_cors_hybrid(app: FastAPI): + """ + Configuration CORS hybride (PRODUCTION) + + ✅ Meilleur des deux mondes + ✅ Whitelist pour domaines connus + ✅ Fallback ouvert pour API Keys externes + + Note: Nécessite un middleware custom pour gérer les deux modes + """ + from starlette.middleware.base import BaseHTTPMiddleware + from starlette.responses import Response + + class HybridCORSMiddleware(BaseHTTPMiddleware): + def __init__(self, app, known_origins: List[str]): + super().__init__(app) + self.known_origins = set(known_origins) + + async def dispatch(self, request, call_next): + origin = request.headers.get("origin") + + # Si origin connue → CORS strict avec credentials + if origin in self.known_origins: + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return response + + # Sinon → CORS ouvert sans credentials + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = "*" + return response + + # Domaines connus (whitelist) + known_origins = [ + "https://app.votre-domaine.com", + "https://admin.votre-domaine.com", + "http://localhost:3000", + "http://localhost:5173", + ] + + app.add_middleware(HybridCORSMiddleware, known_origins=known_origins) + + logger.info("🌐 CORS configuré: Mode HYBRIDE") + logger.info(f" - Whitelist: {len(known_origins)} domaines") + logger.info(" - Fallback: * (ouvert)") + + +# ============================================ +# FONCTION PRINCIPALE +# ============================================ + + +def setup_cors(app: FastAPI, mode: str = "open"): + """ + Configure CORS selon le mode choisi + + Args: + app: Instance FastAPI + mode: "open" | "whitelist" | "regex" | "hybrid" + + Recommandations: + - Development: "open" + - Production (API publique): "open" ou "hybrid" + - Production (API interne): "whitelist" + """ + + if mode == "open": + configure_cors_open(app) + elif mode == "whitelist": + configure_cors_whitelist(app) + elif mode == "regex": + configure_cors_regex(app) + elif mode == "hybrid": + configure_cors_hybrid(app) + else: + logger.warning( + f"⚠️ Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." + ) + configure_cors_open(app) + + +# ============================================ +# EXEMPLE D'UTILISATION DANS api.py +# ============================================ + +""" +# Dans api.py + +from config.cors_config import setup_cors + +app = FastAPI(...) + +# DÉVELOPPEMENT +setup_cors(app, mode="open") + +# PRODUCTION (API publique avec clés) +setup_cors(app, mode="hybrid") + +# PRODUCTION (API interne uniquement) +setup_cors(app, mode="whitelist") +""" + + +# ============================================ +# VARIABLES D'ENVIRONNEMENT (.env) +# ============================================ + +""" +# Pour mode "whitelist" +CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com,http://localhost:3000 + +# Pour mode "regex" +CORS_ORIGIN_REGEX=https://.*\.example\.com + +# Choisir le mode +CORS_MODE=open +""" + + +# ============================================ +# FAQ CORS + API KEYS +# ============================================ + +""" +Q: Pourquoi allow_origins=["*"] est sécurisé avec des API Keys ? +R: Les clés API protègent l'accès aux données. CORS empêche seulement les + navigateurs web de faire des requêtes cross-origin. Un attaquant peut + contourner CORS facilement (curl, postman), donc la vraie sécurité vient + de la validation des clés API, pas du CORS. + +Q: Pourquoi credentials=False avec allow_origins=["*"] ? +R: C'est une restriction du navigateur (spec CORS). Vous ne pouvez pas avoir + credentials=True ET origins=["*"] en même temps. + +Q: Mes clients utilisent des IPs dynamiques, que faire ? +R: Utilisez mode "open". Les clés API sont justement faites pour ça - + permettre l'accès depuis n'importe quelle origine, de manière sécurisée. + +Q: Je veux quand même utiliser des cookies/sessions ? +R: Utilisez mode "whitelist" ou "hybrid" pour les domaines qui ont besoin + de credentials, et laissez les autres utiliser X-API-Key sans credentials. + +Q: Comment tester CORS localement ? +R: Créez un simple fichier HTML avec fetch() et ouvrez-le en file:// + Ou utilisez un serveur local (python -m http.server) +""" diff --git a/core/dependencies.py b/core/dependencies.py index 039081c..6782e98 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select @@ -6,89 +6,208 @@ from database import get_session, User from security.auth import decode_token from typing import Optional from datetime import datetime +import logging -security = HTTPBearer() +logger = logging.getLogger(__name__) + +security = HTTPBearer(auto_error=False) # ⚠️ auto_error=False pour gérer manuellement -async def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), +async def get_current_user_hybrid( + request: Request, + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - token = credentials.credentials + """ + VERSION HYBRIDE: Accepte JWT OU API Key - payload = decode_token(token) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token invalide ou expiré", - headers={"WWW-Authenticate": "Bearer"}, + Priorité: + 1. JWT (Authorization: Bearer) + 2. API Key (déjà validée par middleware) + + Si API Key utilisée, retourne un "user virtuel" basé sur la clé + """ + + # ============================================ + # OPTION 1: JWT (comportement standard) + # ============================================ + if credentials and credentials.credentials: + token = credentials.credentials + + payload = decode_token(token) + if not payload: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide ou expiré", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if payload.get("type") != "access": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Type de token incorrect", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if not user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token malformé", + headers={"WWW-Authenticate": "Bearer"}, + ) + + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" + ) + + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email non vérifié. Consultez votre boîte de réception.", + ) + + if user.locked_until and user.locked_until > datetime.now(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Compte temporairement verrouillé suite à trop de tentatives échouées", + ) + + logger.debug(f"🔐 Authentifié via JWT: {user.email}") + return user + + # ============================================ + # OPTION 2: API Key (validée par middleware) + # ============================================ + api_key_obj = getattr(request.state, "api_key", None) + + if api_key_obj: + # Créer un "user virtuel" basé sur la clé API + # Cela permet aux routes existantes de fonctionner sans modification + + # Si la clé est associée à un vrai user, le récupérer + if api_key_obj.user_id: + result = await session.execute( + select(User).where(User.id == api_key_obj.user_id) + ) + user = result.scalar_one_or_none() + + if user: + logger.debug( + f"🔑 Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" + ) + return user + + # Sinon, créer un user virtuel (pour les clés API sans user associé) + from database import User as UserModel + + virtual_user = UserModel( + id=f"api_key_{api_key_obj.id}", + email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", + nom="API Key", + prenom=api_key_obj.name, + role="api_client", # Rôle spécial pour les API keys + is_verified=True, + is_active=True, ) - if payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Type de token incorrect", - headers={"WWW-Authenticate": "Bearer"}, - ) + # Marquer que c'est un user virtuel + virtual_user._is_api_key_user = True + virtual_user._api_key_obj = api_key_obj - user_id: str = payload.get("sub") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token malformé", - headers={"WWW-Authenticate": "Bearer"}, - ) + logger.debug(f"🔑 Authentifié via API Key: {api_key_obj.name} (user virtuel)") + return virtual_user - result = await session.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" - ) - - if not user.is_verified: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception.", - ) - - if user.locked_until and user.locked_until > datetime.now(): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées", - ) - - return user + # ============================================ + # AUCUNE AUTHENTIFICATION + # ============================================ + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise (JWT ou API Key)", + headers={"WWW-Authenticate": "Bearer"}, + ) -async def get_current_user_optional( +async def get_current_user_optional_hybrid( + request: Request, credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> Optional[User]: - if not credentials: - return None - + """Version optionnelle de get_current_user_hybrid (ne lève pas d'erreur)""" try: - return await get_current_user(credentials, session) + return await get_current_user_hybrid(request, credentials, session) except HTTPException: return None -def require_role(*allowed_roles: str): - async def role_checker(user: User = Depends(get_current_user)) -> User: - if user.role not in allowed_roles: +def require_role_hybrid(*allowed_roles: str): + """ + VERSION HYBRIDE: Vérification de rôle compatible avec API Keys + + Notes: + - Les users via JWT ont leur vrai rôle + - Les users via API Key ont le rôle "api_client" par défaut + - Vous pouvez autoriser "api_client" dans allowed_roles pour permettre l'accès aux API Keys + """ + + async def role_checker( + request: Request, user: User = Depends(get_current_user_hybrid) + ) -> User: + # Vérifier si c'est un user API Key + is_api_key_user = getattr(user, "_is_api_key_user", False) + + if is_api_key_user: + # Pour les API Keys, vérifier si "api_client" est autorisé + if "api_client" not in allowed_roles and "*" not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", + ) + logger.debug(f"✅ API Key autorisée pour cette route") + return user + + # Pour les vrais users, vérification standard + if user.role not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", ) + return user return role_checker + + +# ============================================ +# HELPERS +# ============================================ + + +def is_api_key_user(user: User) -> bool: + """Vérifie si l'utilisateur est authentifié via API Key""" + return getattr(user, "_is_api_key_user", False) + + +def get_api_key_from_user(user: User): + """Récupère l'objet API Key depuis un user virtuel""" + return getattr(user, "_api_key_obj", None) + + +# ============================================ +# RÉTROCOMPATIBILITÉ +# ============================================ + +# Alias pour garder la compatibilité avec le code existant +get_current_user = get_current_user_hybrid +get_current_user_optional = get_current_user_optional_hybrid diff --git a/scripts/manage_security.py b/scripts/manage_security.py new file mode 100644 index 0000000..35c0cff --- /dev/null +++ b/scripts/manage_security.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +""" +Script CLI pour gérer les clés API et utilisateurs Swagger + +Usage: + python manage_security.py swagger add + python manage_security.py swagger list + python manage_security.py swagger delete + + python manage_security.py apikey create [--days 365] [--rate-limit 60] + python manage_security.py apikey list + python manage_security.py apikey revoke + python manage_security.py apikey verify +""" + +import asyncio +import sys +from pathlib import Path +import argparse + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from database import get_session +from database.models.api_key import SwaggerUser +from services.api_key import ApiKeyService +from security.auth import hash_password, verify_password +from sqlalchemy import select +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ============================================ +# GESTION DES UTILISATEURS SWAGGER +# ============================================ + +async def add_swagger_user(username: str, password: str, full_name: str = None): + """Ajouter un utilisateur Swagger""" + async with get_session() as session: + # Vérifier si existe + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + existing = result.scalar_one_or_none() + + if existing: + logger.error(f"❌ L'utilisateur {username} existe déjà") + return + + # Créer + user = SwaggerUser( + username=username, + hashed_password=hash_password(password), + full_name=full_name or username, + is_active=True, + ) + + session.add(user) + await session.commit() + + logger.info(f"✅ Utilisateur Swagger créé: {username}") + print(f"\n✅ Utilisateur créé avec succès") + print(f" Username: {username}") + print(f" Accès: https://votre-serveur/docs") + + +async def list_swagger_users(): + """Lister les utilisateurs Swagger""" + async with get_session() as session: + result = await session.execute(select(SwaggerUser)) + users = result.scalars().all() + + if not users: + print("Aucun utilisateur Swagger trouvé") + return + + print(f"\n📋 {len(users)} utilisateur(s) Swagger:\n") + for user in users: + status = "✅ Actif" if user.is_active else "❌ Inactif" + print(f" • {user.username:<20} {status}") + if user.full_name: + print(f" Nom: {user.full_name}") + if user.last_login: + print(f" Dernière connexion: {user.last_login}") + print() + + +async def delete_swagger_user(username: str): + """Supprimer un utilisateur Swagger""" + async with get_session() as session: + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + user = result.scalar_one_or_none() + + if not user: + logger.error(f"❌ Utilisateur {username} introuvable") + return + + await session.delete(user) + await session.commit() + + logger.info(f"🗑️ Utilisateur supprimé: {username}") + + +# ============================================ +# GESTION DES CLÉS API +# ============================================ + +async def create_api_key( + name: str, + description: str = None, + expires_in_days: int = 365, + rate_limit: int = 60, + endpoints: list = None, +): + """Créer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=name, + description=description, + created_by="CLI", + expires_in_days=expires_in_days, + rate_limit_per_minute=rate_limit, + allowed_endpoints=endpoints, + ) + + print(f"\n✅ Clé API créée avec succès\n") + print(f" ID: {api_key_obj.id}") + print(f" Nom: {name}") + print(f" Clé: {api_key_plain}") + print(f" Préfixe: {api_key_obj.key_prefix}") + print(f" Rate limit: {rate_limit} req/min") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") + print(f"\n⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + + +async def list_api_keys(): + """Lister les clés API""" + async with get_session() as session: + service = ApiKeyService(session) + keys = await service.list_api_keys() + + if not keys: + print("Aucune clé API trouvée") + return + + print(f"\n📋 {len(keys)} clé(s) API:\n") + for key in keys: + status = "✅" if key.is_active else "❌" + expired = "⏰ Expirée" if key.expires_at and key.expires_at < datetime.now() else "" + + print(f" {status} {key.name:<30} ({key.key_prefix}...)") + print(f" ID: {key.id}") + print(f" Requêtes: {key.total_requests}") + print(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + if expired: + print(f" {expired}") + print() + + +async def revoke_api_key(key_id: str): + """Révoquer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key = await service.get_by_id(key_id) + if not api_key: + logger.error(f"❌ Clé {key_id} introuvable") + return + + success = await service.revoke_api_key(key_id) + + if success: + logger.info(f"🚫 Clé révoquée: {api_key.name}") + print(f"\n✅ Clé '{api_key.name}' révoquée avec succès") + else: + logger.error("❌ Erreur lors de la révocation") + + +async def verify_api_key_cmd(api_key: str): + """Vérifier une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) + + if api_key_obj: + print(f"\n✅ Clé API valide\n") + print(f" Nom: {api_key_obj.name}") + print(f" ID: {api_key_obj.id}") + print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") + print(f" Requêtes: {api_key_obj.total_requests}") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") + else: + print(f"\n❌ Clé API invalide, expirée ou révoquée\n") + + +# ============================================ +# CLI PRINCIPAL +# ============================================ + +async def main(): + parser = argparse.ArgumentParser( + description="Gestion de la sécurité Sage Dataven API" + ) + + subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") + + # === SWAGGER === + swagger_parser = subparsers.add_parser("swagger", help="Gestion utilisateurs Swagger") + swagger_subparsers = swagger_parser.add_subparsers(dest="action") + + # swagger add + swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") + swagger_add.add_argument("username", help="Nom d'utilisateur") + swagger_add.add_argument("password", help="Mot de passe") + swagger_add.add_argument("--full-name", help="Nom complet") + + # swagger list + swagger_subparsers.add_parser("list", help="Lister les utilisateurs") + + # swagger delete + swagger_delete = swagger_subparsers.add_parser("delete", help="Supprimer un utilisateur") + swagger_delete.add_argument("username", help="Nom d'utilisateur") + + # === API KEYS === + apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") + apikey_subparsers = apikey_parser.add_subparsers(dest="action") + + # apikey create + apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") + apikey_create.add_argument("name", help="Nom de la clé") + apikey_create.add_argument("--description", help="Description") + apikey_create.add_argument("--days", type=int, default=365, help="Expiration en jours") + apikey_create.add_argument("--rate-limit", type=int, default=60, help="Limite req/min") + apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") + + # apikey list + apikey_subparsers.add_parser("list", help="Lister les clés") + + # apikey revoke + apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") + apikey_revoke.add_argument("key_id", help="ID de la clé") + + # apikey verify + apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") + apikey_verify.add_argument("api_key", help="Clé API à vérifier") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + # Exécution des commandes + if args.command == "swagger": + if args.action == "add": + await add_swagger_user(args.username, args.password, args.full_name) + elif args.action == "list": + await list_swagger_users() + elif args.action == "delete": + await delete_swagger_user(args.username) + else: + swagger_parser.print_help() + + elif args.command == "apikey": + if args.action == "create": + await create_api_key( + args.name, + args.description, + args.days, + args.rate_limit, + args.endpoints, + ) + elif args.action == "list": + await list_api_keys() + elif args.action == "revoke": + await revoke_api_key(args.key_id) + elif args.action == "verify": + await verify_api_key_cmd(args.api_key) + else: + apikey_parser.print_help() + + +if __name__ == "__main__": + from datetime import datetime + asyncio.run(main()) \ No newline at end of file diff --git a/scripts/test_security.py b/scripts/test_security.py new file mode 100644 index 0000000..79f0299 --- /dev/null +++ b/scripts/test_security.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +Script de test automatisé pour vérifier la sécurité de l'API + +Usage: + python test_security.py --url http://votre-vps:8000 --swagger-user admin --swagger-pass password +""" + +import requests +import argparse +import sys +from typing import Dict, Tuple +import json + + +class SecurityTester: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.results = {"passed": 0, "failed": 0, "tests": []} + + def log_test(self, name: str, passed: bool, details: str = ""): + """Enregistrer le résultat d'un test""" + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{status} - {name}") + if details: + print(f" {details}") + + self.results["tests"].append( + {"name": name, "passed": passed, "details": details} + ) + + if passed: + self.results["passed"] += 1 + else: + self.results["failed"] += 1 + + def test_swagger_without_auth(self) -> bool: + """Test 1: Swagger UI devrait demander une authentification""" + print("\n🔍 Test 1: Protection Swagger UI") + + try: + response = requests.get(f"{self.base_url}/docs", timeout=5) + + if response.status_code == 401: + self.log_test( + "Swagger protégé", + True, + "Code 401 retourné sans authentification", + ) + return True + else: + self.log_test( + "Swagger protégé", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Swagger protégé", False, f"Erreur: {str(e)}") + return False + + def test_swagger_with_auth(self, username: str, password: str) -> bool: + """Test 2: Swagger UI accessible avec credentials valides""" + print("\n🔍 Test 2: Accès Swagger avec authentification") + + try: + response = requests.get( + f"{self.base_url}/docs", auth=(username, password), timeout=5 + ) + + if response.status_code == 200: + self.log_test( + "Accès Swagger avec auth", + True, + f"Authentifié comme {username}", + ) + return True + else: + self.log_test( + "Accès Swagger avec auth", + False, + f"Code {response.status_code}, credentials invalides?", + ) + return False + + except Exception as e: + self.log_test("Accès Swagger avec auth", False, f"Erreur: {str(e)}") + return False + + def test_api_without_auth(self) -> bool: + """Test 3: Endpoints API devraient demander une authentification""" + print("\n🔍 Test 3: Protection des endpoints API") + + test_endpoints = ["/api/v1/clients", "/api/v1/documents"] + + all_protected = True + for endpoint in test_endpoints: + try: + response = requests.get(f"{self.base_url}{endpoint}", timeout=5) + + if response.status_code == 401: + print(f" ✅ {endpoint} protégé (401)") + else: + print( + f" ❌ {endpoint} accessible sans auth (code {response.status_code})" + ) + all_protected = False + + except Exception as e: + print(f" ⚠️ {endpoint} erreur: {str(e)}") + all_protected = False + + self.log_test("Endpoints API protégés", all_protected) + return all_protected + + def test_health_endpoint_public(self) -> bool: + """Test 4: Endpoint /health devrait être accessible sans auth""" + print("\n🔍 Test 4: Endpoint /health public") + + try: + response = requests.get(f"{self.base_url}/health", timeout=5) + + if response.status_code == 200: + self.log_test("/health accessible", True, "Endpoint public fonctionne") + return True + else: + self.log_test( + "/health accessible", + False, + f"Code {response.status_code} inattendu", + ) + return False + + except Exception as e: + self.log_test("/health accessible", False, f"Erreur: {str(e)}") + return False + + def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]: + """Test 5: Créer une clé API via l'endpoint""" + print("\n🔍 Test 5: Création d'une clé API") + + try: + # 1. Login pour obtenir un JWT + login_response = requests.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": username, "password": password}, + timeout=5, + ) + + if login_response.status_code != 200: + self.log_test( + "Création clé API", + False, + "Impossible de se connecter pour obtenir un JWT", + ) + return False, "" + + jwt_token = login_response.json().get("access_token") + + # 2. Créer une clé API + create_response = requests.post( + f"{self.base_url}/api/v1/api-keys", + headers={"Authorization": f"Bearer {jwt_token}"}, + json={ + "name": "Test API Key", + "description": "Clé de test automatisé", + "rate_limit_per_minute": 60, + "expires_in_days": 30, + }, + timeout=5, + ) + + if create_response.status_code == 201: + api_key = create_response.json().get("api_key") + self.log_test("Création clé API", True, f"Clé créée: {api_key[:20]}...") + return True, api_key + else: + self.log_test( + "Création clé API", + False, + f"Code {create_response.status_code}", + ) + return False, "" + + except Exception as e: + self.log_test("Création clé API", False, f"Erreur: {str(e)}") + return False, "" + + def test_api_key_usage(self, api_key: str) -> bool: + """Test 6: Utiliser une clé API pour accéder à un endpoint""" + print("\n🔍 Test 6: Utilisation d'une clé API") + + if not api_key: + self.log_test("Utilisation clé API", False, "Pas de clé disponible") + return False + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": api_key}, + timeout=5, + ) + + if response.status_code == 200: + self.log_test("Utilisation clé API", True, "Clé acceptée") + return True + else: + self.log_test( + "Utilisation clé API", + False, + f"Code {response.status_code}, clé refusée?", + ) + return False + + except Exception as e: + self.log_test("Utilisation clé API", False, f"Erreur: {str(e)}") + return False + + def test_invalid_api_key(self) -> bool: + """Test 7: Une clé invalide devrait être refusée""" + print("\n🔍 Test 7: Rejet de clé API invalide") + + invalid_key = "sdk_live_invalid_key_12345" + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": invalid_key}, + timeout=5, + ) + + if response.status_code == 401: + self.log_test("Clé invalide rejetée", True, "Code 401 comme attendu") + return True + else: + self.log_test( + "Clé invalide rejetée", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Clé invalide rejetée", False, f"Erreur: {str(e)}") + return False + + def test_rate_limiting(self, api_key: str) -> bool: + """Test 8: Rate limiting (optionnel, peut prendre du temps)""" + print("\n🔍 Test 8: Rate limiting (test simple)") + + if not api_key: + self.log_test("Rate limiting", False, "Pas de clé disponible") + return False + + # Envoyer 70 requêtes rapidement (limite = 60/min) + print(" Envoi de 70 requêtes rapides...") + + rate_limited = False + for i in range(70): + try: + response = requests.get( + f"{self.base_url}/health", + headers={"X-API-Key": api_key}, + timeout=1, + ) + + if response.status_code == 429: + rate_limited = True + print(f" ⚠️ Rate limit atteint à la requête {i + 1}") + break + + except Exception: + pass + + if rate_limited: + self.log_test("Rate limiting", True, "Rate limit détecté") + return True + else: + self.log_test( + "Rate limiting", + True, + "Aucun rate limit détecté (peut être normal si pas implémenté)", + ) + return True + + def print_summary(self): + """Afficher le résumé des tests""" + print("\n" + "=" * 60) + print("📊 RÉSUMÉ DES TESTS") + print("=" * 60) + + total = self.results["passed"] + self.results["failed"] + success_rate = (self.results["passed"] / total * 100) if total > 0 else 0 + + print(f"\nTotal: {total} tests") + print(f"✅ Réussis: {self.results['passed']}") + print(f"❌ Échoués: {self.results['failed']}") + print(f"📈 Taux de réussite: {success_rate:.1f}%\n") + + if self.results["failed"] == 0: + print("🎉 Tous les tests sont passés ! Sécurité OK.") + return 0 + else: + print("⚠️ Certains tests ont échoué. Vérifiez la configuration.") + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description="Test automatisé de la sécurité de l'API" + ) + + parser.add_argument( + "--url", + required=True, + help="URL de base de l'API (ex: http://localhost:8000)", + ) + + parser.add_argument( + "--swagger-user", required=True, help="Utilisateur Swagger pour les tests" + ) + + parser.add_argument( + "--swagger-pass", required=True, help="Mot de passe Swagger pour les tests" + ) + + parser.add_argument( + "--skip-rate-limit", + action="store_true", + help="Sauter le test de rate limiting (long)", + ) + + args = parser.parse_args() + + print("🚀 Démarrage des tests de sécurité") + print(f"🎯 URL cible: {args.url}\n") + + tester = SecurityTester(args.url) + + # Exécuter les tests + tester.test_swagger_without_auth() + tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass) + tester.test_api_without_auth() + tester.test_health_endpoint_public() + + # Tests nécessitant une clé API + success, api_key = tester.test_api_key_creation( + args.swagger_user, args.swagger_pass + ) + + if success and api_key: + tester.test_api_key_usage(api_key) + tester.test_invalid_api_key() + + if not args.skip_rate_limit: + tester.test_rate_limiting(api_key) + else: + print("\n⏭️ Test de rate limiting sauté") + else: + print("\n⚠️ Tests avec clé API sautés (création échouée)") + + # Résumé + exit_code = tester.print_summary() + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/services/api_key.py b/services/api_key.py new file mode 100644 index 0000000..d54943a --- /dev/null +++ b/services/api_key.py @@ -0,0 +1,233 @@ +import secrets +import hashlib +import json +from datetime import datetime, timedelta +from typing import Optional, List, Dict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +import logging + +from database.models.api_key import ApiKey + +logger = logging.getLogger(__name__) + + +class ApiKeyService: + """Service de gestion des clés API""" + + def __init__(self, session: AsyncSession): + self.session = session + + @staticmethod + def generate_api_key() -> str: + """Génère une clé API unique et sécurisée""" + # Format: sdk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + random_part = secrets.token_urlsafe(32) + return f"sdk_live_{random_part}" + + @staticmethod + def hash_api_key(api_key: str) -> str: + """Hash la clé API pour stockage sécurisé""" + return hashlib.sha256(api_key.encode()).hexdigest() + + @staticmethod + def get_key_prefix(api_key: str) -> str: + """Extrait le préfixe de la clé pour identification""" + # Retourne les 12 premiers caractères + return api_key[:12] if len(api_key) >= 12 else api_key + + async def create_api_key( + self, + name: str, + description: Optional[str] = None, + created_by: str = "system", + user_id: Optional[str] = None, + expires_in_days: Optional[int] = None, + rate_limit_per_minute: int = 60, + allowed_endpoints: Optional[List[str]] = None, + ) -> tuple[ApiKey, str]: + """ + Crée une nouvelle clé API + + Returns: + tuple[ApiKey, str]: (objet ApiKey, clé en clair - à ne montrer qu'une fois) + """ + # Génération de la clé + api_key_plain = self.generate_api_key() + key_hash = self.hash_api_key(api_key_plain) + key_prefix = self.get_key_prefix(api_key_plain) + + # Calcul de la date d'expiration + expires_at = None + if expires_in_days: + expires_at = datetime.now() + timedelta(days=expires_in_days) + + # Création de l'objet + api_key_obj = ApiKey( + key_hash=key_hash, + key_prefix=key_prefix, + name=name, + description=description, + created_by=created_by, + user_id=user_id, + expires_at=expires_at, + rate_limit_per_minute=rate_limit_per_minute, + allowed_endpoints=json.dumps(allowed_endpoints) + if allowed_endpoints + else None, + ) + + self.session.add(api_key_obj) + await self.session.commit() + await self.session.refresh(api_key_obj) + + logger.info(f"✅ Clé API créée: {name} (prefix: {key_prefix})") + + return api_key_obj, api_key_plain + + async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]: + """ + Vérifie une clé API et retourne l'objet si valide + + Returns: + Optional[ApiKey]: L'objet ApiKey si valide, None sinon + """ + key_hash = self.hash_api_key(api_key_plain) + + result = await self.session.execute( + select(ApiKey).where( + and_( + ApiKey.key_hash == key_hash, + ApiKey.is_active == True, + ApiKey.revoked_at.is_(None), + or_( + ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now() + ), + ) + ) + ) + + api_key_obj = result.scalar_one_or_none() + + if api_key_obj: + # Mise à jour des statistiques + api_key_obj.total_requests += 1 + api_key_obj.last_used_at = datetime.now() + await self.session.commit() + + logger.debug(f"🔑 Clé API validée: {api_key_obj.name}") + else: + logger.warning(f"⚠️ Clé API invalide ou expirée") + + return api_key_obj + + async def list_api_keys( + self, + include_revoked: bool = False, + user_id: Optional[str] = None, + ) -> List[ApiKey]: + """Liste les clés API""" + query = select(ApiKey) + + if not include_revoked: + query = query.where(ApiKey.revoked_at.is_(None)) + + if user_id: + query = query.where(ApiKey.user_id == user_id) + + query = query.order_by(ApiKey.created_at.desc()) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def revoke_api_key(self, key_id: str) -> bool: + """Révoque une clé API""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + api_key_obj = result.scalar_one_or_none() + + if not api_key_obj: + return False + + api_key_obj.is_active = False + api_key_obj.revoked_at = datetime.now() + await self.session.commit() + + logger.info(f"🚫 Clé API révoquée: {api_key_obj.name}") + return True + + async def get_by_id(self, key_id: str) -> Optional[ApiKey]: + """Récupère une clé API par son ID""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + return result.scalar_one_or_none() + + async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]: + """ + Vérifie le rate limit d'une clé API (à implémenter avec Redis/cache) + + Returns: + tuple[bool, Dict]: (is_allowed, info_dict) + """ + # TODO: Implémenter avec Redis pour un vrai rate limiting + # Pour l'instant, retourne toujours True + return True, { + "allowed": True, + "limit": api_key_obj.rate_limit_per_minute, + "remaining": api_key_obj.rate_limit_per_minute, + } + + async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: + """Vérifie si la clé a accès à un endpoint spécifique""" + if not api_key_obj.allowed_endpoints: + # Si aucune restriction, accès total + return True + + try: + allowed = json.loads(api_key_obj.allowed_endpoints) + + # Support des wildcards + for pattern in allowed: + if pattern == "*": + return True + if pattern.endswith("*"): + prefix = pattern[:-1] + if endpoint.startswith(prefix): + return True + if pattern == endpoint: + return True + + return False + except json.JSONDecodeError: + logger.error(f"❌ Erreur parsing allowed_endpoints pour {api_key_obj.id}") + return False + + +def api_key_to_response(api_key_obj: ApiKey, show_key: bool = False) -> Dict: + """Convertit un objet ApiKey en réponse API""" + + allowed_endpoints = None + if api_key_obj.allowed_endpoints: + try: + allowed_endpoints = json.loads(api_key_obj.allowed_endpoints) + except json.JSONDecodeError: + pass + + is_expired = False + if api_key_obj.expires_at: + is_expired = api_key_obj.expires_at < datetime.now() + + return { + "id": api_key_obj.id, + "name": api_key_obj.name, + "description": api_key_obj.description, + "key_prefix": api_key_obj.key_prefix, + "is_active": api_key_obj.is_active, + "is_expired": is_expired, + "rate_limit_per_minute": api_key_obj.rate_limit_per_minute, + "allowed_endpoints": allowed_endpoints, + "total_requests": api_key_obj.total_requests, + "last_used_at": api_key_obj.last_used_at, + "created_at": api_key_obj.created_at, + "expires_at": api_key_obj.expires_at, + "revoked_at": api_key_obj.revoked_at, + "created_by": api_key_obj.created_by, + } From 1164c7975ae2742c6248cd443a0b3175d26c0a97 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 06:31:17 +0300 Subject: [PATCH 12/36] refactor: remove emojis and clean up code comments --- config/cors_config.py | 254 ++++------------------------- core/dependencies.py | 59 +------ database/models/api_key.py | 14 +- middleware/security.py | 90 ++-------- routes/api_keys.py | 32 +--- schemas/api_key.py | 2 +- scripts/manage_security.py | 108 +++++------- scripts/test_security.py | 61 +++---- services/api_key.py | 40 +---- services/universign_document.py | 2 +- services/universign_sync.py | 17 +- utils/generic_functions.py | 4 +- utils/universign_status_mapping.py | 2 +- 13 files changed, 137 insertions(+), 548 deletions(-) diff --git a/config/cors_config.py b/config/cors_config.py index 6ee880e..0f3a4d2 100644 --- a/config/cors_config.py +++ b/config/cors_config.py @@ -1,17 +1,3 @@ -""" -CONFIGURATION CORS POUR API AVEC CLÉS API MULTIPLES - -Problématique: -- Les clés API seront utilisées depuis de nombreux domaines/IPs différents -- Impossible de lister tous les origins autorisés à l'avance -- Solution: CORS permissif mais sécurisé par les clés API - -Stratégies: -1. CORS ouvert avec validation par clé API (RECOMMANDÉ) -2. CORS dynamique basé sur whitelist -3. CORS avec wildcard et credentials=False -""" - from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from typing import List @@ -21,66 +7,24 @@ import logging logger = logging.getLogger(__name__) -# ============================================ -# STRATÉGIE 1: CORS OUVERT + VALIDATION API KEY -# ============================================ -# ✅ RECOMMANDÉ pour les API publiques avec clés -# -# Principe: -# - Accepter toutes les origines (allow_origins=["*"]) -# - La sécurité est assurée par la validation des clés API -# - Les clés API protègent l'accès, pas le CORS - - def configure_cors_open(app: FastAPI): - """ - Configuration CORS ouverte (RECOMMANDÉE) - - ✅ Accepte toutes les origines - ✅ Sécurité assurée par les clés API - ✅ Simplifie l'utilisation pour les clients - - ⚠️ Attention: credentials=False obligatoire avec allow_origins=["*"] - """ app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Accepte toutes les origines - allow_credentials=False, # ⚠️ Obligatoire avec "*" + allow_origins=["*"], + allow_credentials=False, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["*"], # Accepte tous les headers (dont X-API-Key) + allow_headers=["*"], expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], - max_age=3600, # Cache preflight requests pendant 1h + max_age=3600, ) - logger.info("🌐 CORS configuré: Mode OUVERT (sécurisé par API Keys)") + logger.info(" CORS configuré: Mode OUVERT (sécurisé par API Keys)") logger.info(" - Origins: * (toutes)") logger.info(" - Headers: * (dont X-API-Key)") logger.info(" - Credentials: False") -# ============================================ -# STRATÉGIE 2: CORS AVEC WHITELIST DYNAMIQUE -# ============================================ -# 🔶 Pour environnements contrôlés -# -# Principe: -# - Lister explicitement les domaines autorisés -# - Peut inclure des patterns wildcards -# - Credentials possible (cookies, etc.) - - def configure_cors_whitelist(app: FastAPI): - """ - Configuration CORS avec whitelist (MODE CONTRÔLÉ) - - ✅ Meilleur contrôle des origines - ✅ Credentials possible - ❌ Nécessite maintenance de la liste - - À utiliser si vous connaissez tous les domaines clients - """ - - # Charger depuis .env ou config allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") if allowed_origins_str: @@ -90,57 +34,11 @@ def configure_cors_whitelist(app: FastAPI): if origin.strip() ] else: - # Valeurs par défaut - allowed_origins = [ - "http://localhost:3000", # Frontend dev React/Vue - "http://localhost:5173", # Vite dev - "http://localhost:8080", # Frontend dev alternatif - "https://app.votre-domaine.com", - "https://admin.votre-domaine.com", - # Ajouter vos domaines de production - ] + allowed_origins = ["*"] app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, - allow_credentials=True, # ✅ Possible avec liste explicite - allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["Content-Type", "Authorization", "X-API-Key"], - expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], - max_age=3600, - ) - - logger.info("🌐 CORS configuré: Mode WHITELIST") - logger.info(f" - Origins autorisées: {len(allowed_origins)}") - for origin in allowed_origins: - logger.info(f" • {origin}") - - -# ============================================ -# STRATÉGIE 3: CORS AVEC REGEX (AVANCÉ) -# ============================================ -# 🔶 Pour patterns complexes -# -# Exemple: Autoriser tous les sous-domaines *.votre-domaine.com - - -def configure_cors_regex(app: FastAPI): - """ - Configuration CORS avec patterns regex (AVANCÉ) - - ✅ Flexible pour sous-domaines - ✅ Supporte patterns complexes - ❌ Plus complexe à configurer - - Utilise allow_origin_regex au lieu de allow_origins - """ - - # Pattern regex pour autoriser tous les sous-domaines - origin_regex = r"https://.*\.votre-domaine\.com" - - app.add_middleware( - CORSMiddleware, - allow_origin_regex=origin_regex, # ⚠️ Utilise regex au lieu de liste allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], allow_headers=["Content-Type", "Authorization", "X-API-Key"], @@ -148,32 +46,31 @@ def configure_cors_regex(app: FastAPI): max_age=3600, ) - logger.info("🌐 CORS configuré: Mode REGEX") + logger.info(" CORS configuré: Mode WHITELIST") + logger.info(f" - Origins autorisées: {len(allowed_origins)}") + for origin in allowed_origins: + logger.info(f" • {origin}") + + +def configure_cors_regex(app: FastAPI): + origin_regex = r"*" + + app.add_middleware( + CORSMiddleware, + allow_origin_regex=origin_regex, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode REGEX") logger.info(f" - Pattern: {origin_regex}") -# ============================================ -# STRATÉGIE 4: CORS HYBRIDE (PRODUCTION) -# ============================================ -# ✅ RECOMMANDÉ pour production -# -# Principe: -# - Whitelist pour les domaines connus (credentials=True) -# - Fallback sur "*" pour le reste (credentials=False) - - def configure_cors_hybrid(app: FastAPI): - """ - Configuration CORS hybride (PRODUCTION) - - ✅ Meilleur des deux mondes - ✅ Whitelist pour domaines connus - ✅ Fallback ouvert pour API Keys externes - - Note: Nécessite un middleware custom pour gérer les deux modes - """ from starlette.middleware.base import BaseHTTPMiddleware - from starlette.responses import Response class HybridCORSMiddleware(BaseHTTPMiddleware): def __init__(self, app, known_origins: List[str]): @@ -183,7 +80,6 @@ def configure_cors_hybrid(app: FastAPI): async def dispatch(self, request, call_next): origin = request.headers.get("origin") - # Si origin connue → CORS strict avec credentials if origin in self.known_origins: response = await call_next(request) response.headers["Access-Control-Allow-Origin"] = origin @@ -196,7 +92,6 @@ def configure_cors_hybrid(app: FastAPI): ) return response - # Sinon → CORS ouvert sans credentials response = await call_next(request) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = ( @@ -205,40 +100,16 @@ def configure_cors_hybrid(app: FastAPI): response.headers["Access-Control-Allow-Headers"] = "*" return response - # Domaines connus (whitelist) - known_origins = [ - "https://app.votre-domaine.com", - "https://admin.votre-domaine.com", - "http://localhost:3000", - "http://localhost:5173", - ] + known_origins = ["*"] app.add_middleware(HybridCORSMiddleware, known_origins=known_origins) - logger.info("🌐 CORS configuré: Mode HYBRIDE") + logger.info(" CORS configuré: Mode HYBRIDE") logger.info(f" - Whitelist: {len(known_origins)} domaines") logger.info(" - Fallback: * (ouvert)") -# ============================================ -# FONCTION PRINCIPALE -# ============================================ - - def setup_cors(app: FastAPI, mode: str = "open"): - """ - Configure CORS selon le mode choisi - - Args: - app: Instance FastAPI - mode: "open" | "whitelist" | "regex" | "hybrid" - - Recommandations: - - Development: "open" - - Production (API publique): "open" ou "hybrid" - - Production (API interne): "whitelist" - """ - if mode == "open": configure_cors_open(app) elif mode == "whitelist": @@ -249,73 +120,6 @@ def setup_cors(app: FastAPI, mode: str = "open"): configure_cors_hybrid(app) else: logger.warning( - f"⚠️ Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." + f" Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." ) configure_cors_open(app) - - -# ============================================ -# EXEMPLE D'UTILISATION DANS api.py -# ============================================ - -""" -# Dans api.py - -from config.cors_config import setup_cors - -app = FastAPI(...) - -# DÉVELOPPEMENT -setup_cors(app, mode="open") - -# PRODUCTION (API publique avec clés) -setup_cors(app, mode="hybrid") - -# PRODUCTION (API interne uniquement) -setup_cors(app, mode="whitelist") -""" - - -# ============================================ -# VARIABLES D'ENVIRONNEMENT (.env) -# ============================================ - -""" -# Pour mode "whitelist" -CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com,http://localhost:3000 - -# Pour mode "regex" -CORS_ORIGIN_REGEX=https://.*\.example\.com - -# Choisir le mode -CORS_MODE=open -""" - - -# ============================================ -# FAQ CORS + API KEYS -# ============================================ - -""" -Q: Pourquoi allow_origins=["*"] est sécurisé avec des API Keys ? -R: Les clés API protègent l'accès aux données. CORS empêche seulement les - navigateurs web de faire des requêtes cross-origin. Un attaquant peut - contourner CORS facilement (curl, postman), donc la vraie sécurité vient - de la validation des clés API, pas du CORS. - -Q: Pourquoi credentials=False avec allow_origins=["*"] ? -R: C'est une restriction du navigateur (spec CORS). Vous ne pouvez pas avoir - credentials=True ET origins=["*"] en même temps. - -Q: Mes clients utilisent des IPs dynamiques, que faire ? -R: Utilisez mode "open". Les clés API sont justement faites pour ça - - permettre l'accès depuis n'importe quelle origine, de manière sécurisée. - -Q: Je veux quand même utiliser des cookies/sessions ? -R: Utilisez mode "whitelist" ou "hybrid" pour les domaines qui ont besoin - de credentials, et laissez les autres utiliser X-API-Key sans credentials. - -Q: Comment tester CORS localement ? -R: Créez un simple fichier HTML avec fetch() et ouvrez-le en file:// - Ou utilisez un serveur local (python -m http.server) -""" diff --git a/core/dependencies.py b/core/dependencies.py index 6782e98..ff443a6 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -10,7 +10,7 @@ import logging logger = logging.getLogger(__name__) -security = HTTPBearer(auto_error=False) # ⚠️ auto_error=False pour gérer manuellement +security = HTTPBearer(auto_error=False) async def get_current_user_hybrid( @@ -18,19 +18,6 @@ async def get_current_user_hybrid( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - """ - VERSION HYBRIDE: Accepte JWT OU API Key - - Priorité: - 1. JWT (Authorization: Bearer) - 2. API Key (déjà validée par middleware) - - Si API Key utilisée, retourne un "user virtuel" basé sur la clé - """ - - # ============================================ - # OPTION 1: JWT (comportement standard) - # ============================================ if credentials and credentials.credentials: token = credentials.credentials @@ -84,19 +71,12 @@ async def get_current_user_hybrid( detail="Compte temporairement verrouillé suite à trop de tentatives échouées", ) - logger.debug(f"🔐 Authentifié via JWT: {user.email}") + logger.debug(f" Authentifié via JWT: {user.email}") return user - # ============================================ - # OPTION 2: API Key (validée par middleware) - # ============================================ api_key_obj = getattr(request.state, "api_key", None) if api_key_obj: - # Créer un "user virtuel" basé sur la clé API - # Cela permet aux routes existantes de fonctionner sans modification - - # Si la clé est associée à un vrai user, le récupérer if api_key_obj.user_id: result = await session.execute( select(User).where(User.id == api_key_obj.user_id) @@ -105,11 +85,10 @@ async def get_current_user_hybrid( if user: logger.debug( - f"🔑 Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" + f" Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" ) return user - # Sinon, créer un user virtuel (pour les clés API sans user associé) from database import User as UserModel virtual_user = UserModel( @@ -117,21 +96,17 @@ async def get_current_user_hybrid( email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", nom="API Key", prenom=api_key_obj.name, - role="api_client", # Rôle spécial pour les API keys + role="api_client", is_verified=True, is_active=True, ) - # Marquer que c'est un user virtuel virtual_user._is_api_key_user = True virtual_user._api_key_obj = api_key_obj - logger.debug(f"🔑 Authentifié via API Key: {api_key_obj.name} (user virtuel)") + logger.debug(f" Authentifié via API Key: {api_key_obj.name} (user virtuel)") return virtual_user - # ============================================ - # AUCUNE AUTHENTIFICATION - # ============================================ raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentification requise (JWT ou API Key)", @@ -152,32 +127,20 @@ async def get_current_user_optional_hybrid( def require_role_hybrid(*allowed_roles: str): - """ - VERSION HYBRIDE: Vérification de rôle compatible avec API Keys - - Notes: - - Les users via JWT ont leur vrai rôle - - Les users via API Key ont le rôle "api_client" par défaut - - Vous pouvez autoriser "api_client" dans allowed_roles pour permettre l'accès aux API Keys - """ - async def role_checker( request: Request, user: User = Depends(get_current_user_hybrid) ) -> User: - # Vérifier si c'est un user API Key is_api_key_user = getattr(user, "_is_api_key_user", False) if is_api_key_user: - # Pour les API Keys, vérifier si "api_client" est autorisé if "api_client" not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", ) - logger.debug(f"✅ API Key autorisée pour cette route") + logger.debug(" API Key autorisée pour cette route") return user - # Pour les vrais users, vérification standard if user.role not in allowed_roles and "*" not in allowed_roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -189,11 +152,6 @@ def require_role_hybrid(*allowed_roles: str): return role_checker -# ============================================ -# HELPERS -# ============================================ - - def is_api_key_user(user: User) -> bool: """Vérifie si l'utilisateur est authentifié via API Key""" return getattr(user, "_is_api_key_user", False) @@ -204,10 +162,5 @@ def get_api_key_from_user(user: User): return getattr(user, "_api_key_obj", None) -# ============================================ -# RÉTROCOMPATIBILITÉ -# ============================================ - -# Alias pour garder la compatibilité avec le code existant get_current_user = get_current_user_hybrid get_current_user_optional = get_current_user_optional_hybrid diff --git a/database/models/api_key.py b/database/models/api_key.py index 1e54342..0d246ab 100644 --- a/database/models/api_key.py +++ b/database/models/api_key.py @@ -12,29 +12,21 @@ class ApiKey(Base): id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) key_hash = Column(String(64), unique=True, nullable=False, index=True) - key_prefix = Column( - String(10), nullable=False - ) # Premiers caractères pour identification + key_prefix = Column(String(10), nullable=False) name = Column(String(255), nullable=False) description = Column(Text, nullable=True) - # Métadonnées - user_id = Column(String(36), nullable=True) # Optionnel si associé à un utilisateur + user_id = Column(String(36), nullable=True) created_by = Column(String(255), nullable=False) - # Contrôle d'accès is_active = Column(Boolean, default=True, nullable=False) rate_limit_per_minute = Column(Integer, default=60, nullable=False) - allowed_endpoints = Column( - Text, nullable=True - ) # JSON array des endpoints autorisés + allowed_endpoints = Column(Text, nullable=True) - # Statistiques total_requests = Column(Integer, default=0, nullable=False) last_used_at = Column(DateTime, nullable=True) - # Dates created_at = Column(DateTime, default=datetime.now, nullable=False) expires_at = Column(DateTime, nullable=True) revoked_at = Column(DateTime, nullable=True) diff --git a/middleware/security.py b/middleware/security.py index 17186a0..137e7dd 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,5 +1,4 @@ -import secrets -from fastapi import Request, HTTPException, status +from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from sqlalchemy import select @@ -13,23 +12,14 @@ from security.auth import verify_password logger = logging.getLogger(__name__) -# === Configuration Swagger === security = HTTPBasic() async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: - """ - VERSION 2: Vérification des identifiants Swagger via base de données - - ✅ Plus sécurisé - ✅ Gestion centralisée - ✅ Tracking des connexions - """ username = credentials.username password = credentials.password try: - # Utiliser get_session de manière asynchrone async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) @@ -38,29 +28,21 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: if swagger_user and swagger_user.is_active: if verify_password(password, swagger_user.hashed_password): - # Mise à jour de la dernière connexion swagger_user.last_login = datetime.now() await session.commit() - logger.info(f"✅ Accès Swagger autorisé (DB): {username}") + logger.info(f" Accès Swagger autorisé (DB): {username}") return True - logger.warning(f"⚠️ Tentative d'accès Swagger refusée: {username}") + logger.warning(f" Tentative d'accès Swagger refusée: {username}") return False except Exception as e: - logger.error(f"❌ Erreur vérification Swagger credentials: {e}") + logger.error(f" Erreur vérification Swagger credentials: {e}") return False class SwaggerAuthMiddleware: - """ - Middleware pour protéger les endpoints de documentation - (/docs, /redoc, /openapi.json) - - VERSION 2: Avec vérification en base de données - """ - def __init__(self, app): self.app = app @@ -72,15 +54,12 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - # Endpoints à protéger protected_paths = ["/docs", "/redoc", "/openapi.json"] if any(path.startswith(protected_path) for protected_path in protected_paths): - # Vérification de l'authentification Basic auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Basic "): - # Demande d'authentification response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -91,7 +70,6 @@ class SwaggerAuthMiddleware: await response(scope, receive, send) return - # Extraction des credentials try: import base64 @@ -103,7 +81,6 @@ class SwaggerAuthMiddleware: credentials = HTTPBasicCredentials(username=username, password=password) - # Vérification via DB if not await verify_swagger_credentials(credentials): response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, @@ -114,7 +91,7 @@ class SwaggerAuthMiddleware: return except Exception as e: - logger.error(f"❌ Erreur parsing auth header: {e}") + logger.error(f" Erreur parsing auth header: {e}") response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={"detail": "Format d'authentification invalide"}, @@ -123,19 +100,10 @@ class SwaggerAuthMiddleware: await response(scope, receive, send) return - # Si tout est OK, continuer await self.app(scope, receive, send) class ApiKeyMiddleware: - """ - Middleware HYBRIDE pour vérifier les clés API sur les endpoints publics - - ✅ Accepte X-API-Key OU Authorization: Bearer (JWT) - ✅ Les deux méthodes sont équivalentes - ✅ Les endpoints /auth/* restent accessibles sans auth - """ - def __init__(self, app): self.app = app @@ -147,53 +115,37 @@ class ApiKeyMiddleware: request = Request(scope, receive=receive) path = request.url.path - # ============================================ - # ENDPOINTS EXCLUS (accessibles sans auth) - # ============================================ excluded_paths = [ "/docs", "/redoc", "/openapi.json", "/health", "/", - # === ROUTES AUTH (toujours publiques) === - "/api/v1/auth/login", - "/api/v1/auth/register", - "/api/v1/auth/verify-email", - "/api/v1/auth/reset-password", - "/api/v1/auth/request-reset", - "/api/v1/auth/refresh", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", ] if any(path.startswith(excluded_path) for excluded_path in excluded_paths): await self.app(scope, receive, send) return - # ============================================ - # VÉRIFICATION HYBRIDE: API Key OU JWT - # ============================================ - - # Option 1: Vérifier si JWT présent auth_header = request.headers.get("Authorization") has_jwt = auth_header and auth_header.startswith("Bearer ") - # Option 2: Vérifier si API Key présente api_key = request.headers.get("X-API-Key") has_api_key = api_key is not None - # ============================================ - # LOGIQUE HYBRIDE - # ============================================ - if has_jwt: - # JWT présent → laisser passer, sera validé par les dependencies FastAPI - logger.debug(f"🔑 JWT détecté pour {path}") + logger.debug(f" JWT détecté pour {path}") await self.app(scope, receive, send) return elif has_api_key: - # API Key présente → valider la clé - logger.debug(f"🔑 API Key détectée pour {path}") + logger.debug(f" API Key détectée pour {path}") from services.api_key import ApiKeyService @@ -213,7 +165,6 @@ class ApiKeyMiddleware: await response(scope, receive, send) return - # Vérification du rate limit is_allowed, rate_info = await api_key_service.check_rate_limit( api_key_obj ) @@ -229,7 +180,6 @@ class ApiKeyMiddleware: await response(scope, receive, send) return - # Vérification de l'accès à l'endpoint has_access = await api_key_service.check_endpoint_access( api_key_obj, path ) @@ -245,18 +195,16 @@ class ApiKeyMiddleware: await response(scope, receive, send) return - # ✅ Clé valide → ajouter les infos à la requête request.state.api_key = api_key_obj request.state.authenticated_via = "api_key" - logger.info(f"✅ API Key valide: {api_key_obj.name} → {path}") + logger.info(f" API Key valide: {api_key_obj.name} → {path}") - # Continuer la requête await self.app(scope, receive, send) return except Exception as e: - logger.error(f"❌ Erreur validation API Key: {e}", exc_info=True) + logger.error(f" Erreur validation API Key: {e}", exc_info=True) response = JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ @@ -267,7 +215,6 @@ class ApiKeyMiddleware: return else: - # ❌ Ni JWT ni API Key response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -280,17 +227,10 @@ class ApiKeyMiddleware: return -# === Fonction helper pour récupérer l'API Key depuis la requête === def get_api_key_from_request(request: Request) -> Optional: """Récupère l'objet ApiKey depuis la requête si présent""" return getattr(request.state, "api_key", None) def get_auth_method(request: Request) -> str: - """ - Retourne la méthode d'authentification utilisée - - Returns: - "jwt" | "api_key" | "none" - """ return getattr(request.state, "authenticated_via", "none") diff --git a/routes/api_keys.py b/routes/api_keys.py index 8c8f6db..27f0efc 100644 --- a/routes/api_keys.py +++ b/routes/api_keys.py @@ -27,13 +27,6 @@ async def create_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 🔑 Créer une nouvelle clé API - - **Réservé aux admins** - - ⚠️ La clé en clair ne sera affichée qu'une seule fois ! - """ service = ApiKeyService(session) api_key_obj, api_key_plain = await service.create_api_key( @@ -46,7 +39,7 @@ async def create_api_key( allowed_endpoints=data.allowed_endpoints, ) - logger.info(f"✅ Clé API créée par {user.email}: {data.name}") + logger.info(f" Clé API créée par {user.email}: {data.name}") response_data = api_key_to_response(api_key_obj) response_data["api_key"] = api_key_plain @@ -60,15 +53,8 @@ async def list_api_keys( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 📋 Lister toutes les clés API - - **Réservé aux admins** - liste toutes les clés - **Utilisateurs standards** - liste uniquement leurs clés - """ service = ApiKeyService(session) - # Si admin, voir toutes les clés, sinon seulement les siennes user_id = None if user.role in ["admin", "super_admin"] else user.id keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id) @@ -84,7 +70,7 @@ async def get_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """🔍 Récupérer une clé API par son ID""" + """ Récupérer une clé API par son ID""" service = ApiKeyService(session) api_key_obj = await service.get_by_id(key_id) @@ -95,7 +81,6 @@ async def get_api_key( detail=f"Clé API {key_id} introuvable", ) - # Vérification des permissions if user.role not in ["admin", "super_admin"]: if api_key_obj.user_id != user.id: raise HTTPException( @@ -112,11 +97,6 @@ async def revoke_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ - 🚫 Révoquer une clé API - - **Action irréversible** - la clé sera désactivée définitivement - """ service = ApiKeyService(session) api_key_obj = await service.get_by_id(key_id) @@ -127,7 +107,6 @@ async def revoke_api_key( detail=f"Clé API {key_id} introuvable", ) - # Vérification des permissions if user.role not in ["admin", "super_admin"]: if api_key_obj.user_id != user.id: raise HTTPException( @@ -143,7 +122,7 @@ async def revoke_api_key( detail="Erreur lors de la révocation", ) - logger.info(f"🚫 Clé API révoquée par {user.email}: {api_key_obj.name}") + logger.info(f" Clé API révoquée par {user.email}: {api_key_obj.name}") return { "success": True, @@ -156,11 +135,6 @@ async def verify_api_key_endpoint( api_key: str = Query(..., description="Clé API à vérifier"), session: AsyncSession = Depends(get_session), ): - """ - ✅ Vérifier la validité d'une clé API - - **Endpoint public** - permet de tester une clé - """ service = ApiKeyService(session) api_key_obj = await service.verify_api_key(api_key) diff --git a/schemas/api_key.py b/schemas/api_key.py index 6a2d659..4ec49b6 100644 --- a/schemas/api_key.py +++ b/schemas/api_key.py @@ -42,7 +42,7 @@ class ApiKeyCreatedResponse(ApiKeyResponse): """Schema de réponse après création (inclut la clé en clair)""" api_key: str = Field( - ..., description="⚠️ Clé API en clair - à sauvegarder immédiatement" + ..., description=" Clé API en clair - à sauvegarder immédiatement" ) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 35c0cff..1f234b9 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,18 +1,3 @@ -#!/usr/bin/env python3 -""" -Script CLI pour gérer les clés API et utilisateurs Swagger - -Usage: - python manage_security.py swagger add - python manage_security.py swagger list - python manage_security.py swagger delete - - python manage_security.py apikey create [--days 365] [--rate-limit 60] - python manage_security.py apikey list - python manage_security.py apikey revoke - python manage_security.py apikey verify -""" - import asyncio import sys from pathlib import Path @@ -23,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from database import get_session from database.models.api_key import SwaggerUser from services.api_key import ApiKeyService -from security.auth import hash_password, verify_password +from security.auth import hash_password from sqlalchemy import select import logging @@ -31,24 +16,18 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -# ============================================ -# GESTION DES UTILISATEURS SWAGGER -# ============================================ - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" async with get_session() as session: - # Vérifier si existe result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) existing = result.scalar_one_or_none() if existing: - logger.error(f"❌ L'utilisateur {username} existe déjà") + logger.error(f" L'utilisateur {username} existe déjà") return - # Créer user = SwaggerUser( username=username, hashed_password=hash_password(password), @@ -59,10 +38,10 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): session.add(user) await session.commit() - logger.info(f"✅ Utilisateur Swagger créé: {username}") - print(f"\n✅ Utilisateur créé avec succès") + logger.info(f" Utilisateur Swagger créé: {username}") + print("\n Utilisateur créé avec succès") print(f" Username: {username}") - print(f" Accès: https://votre-serveur/docs") + print(" Accès: https://votre-serveur/docs") async def list_swagger_users(): @@ -75,9 +54,9 @@ async def list_swagger_users(): print("Aucun utilisateur Swagger trouvé") return - print(f"\n📋 {len(users)} utilisateur(s) Swagger:\n") + print(f"\n {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = "✅ Actif" if user.is_active else "❌ Inactif" + status = " Actif" if user.is_active else " Inactif" print(f" • {user.username:<20} {status}") if user.full_name: print(f" Nom: {user.full_name}") @@ -95,7 +74,7 @@ async def delete_swagger_user(username: str): user = result.scalar_one_or_none() if not user: - logger.error(f"❌ Utilisateur {username} introuvable") + logger.error(f" Utilisateur {username} introuvable") return await session.delete(user) @@ -104,10 +83,6 @@ async def delete_swagger_user(username: str): logger.info(f"🗑️ Utilisateur supprimé: {username}") -# ============================================ -# GESTION DES CLÉS API -# ============================================ - async def create_api_key( name: str, description: str = None, @@ -128,14 +103,14 @@ async def create_api_key( allowed_endpoints=endpoints, ) - print(f"\n✅ Clé API créée avec succès\n") + print("\n Clé API créée avec succès\n") print(f" ID: {api_key_obj.id}") print(f" Nom: {name}") print(f" Clé: {api_key_plain}") print(f" Préfixe: {api_key_obj.key_prefix}") print(f" Rate limit: {rate_limit} req/min") print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") - print(f"\n⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + print("\n IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") async def list_api_keys(): @@ -148,11 +123,15 @@ async def list_api_keys(): print("Aucune clé API trouvée") return - print(f"\n📋 {len(keys)} clé(s) API:\n") + print(f"\n {len(keys)} clé(s) API:\n") for key in keys: - status = "✅" if key.is_active else "❌" - expired = "⏰ Expirée" if key.expires_at and key.expires_at < datetime.now() else "" - + status = "" if key.is_active else "" + expired = ( + "⏰ Expirée" + if key.expires_at and key.expires_at < datetime.now() + else "" + ) + print(f" {status} {key.name:<30} ({key.key_prefix}...)") print(f" ID: {key.id}") print(f" Requêtes: {key.total_requests}") @@ -166,19 +145,19 @@ async def revoke_api_key(key_id: str): """Révoquer une clé API""" async with get_session() as session: service = ApiKeyService(session) - + api_key = await service.get_by_id(key_id) if not api_key: - logger.error(f"❌ Clé {key_id} introuvable") + logger.error(f" Clé {key_id} introuvable") return success = await service.revoke_api_key(key_id) - + if success: - logger.info(f"🚫 Clé révoquée: {api_key.name}") - print(f"\n✅ Clé '{api_key.name}' révoquée avec succès") + logger.info(f" Clé révoquée: {api_key.name}") + print(f"\n Clé '{api_key.name}' révoquée avec succès") else: - logger.error("❌ Erreur lors de la révocation") + logger.error(" Erreur lors de la révocation") async def verify_api_key_cmd(api_key: str): @@ -188,64 +167,59 @@ async def verify_api_key_cmd(api_key: str): api_key_obj = await service.verify_api_key(api_key) if api_key_obj: - print(f"\n✅ Clé API valide\n") + print("\n Clé API valide\n") print(f" Nom: {api_key_obj.name}") print(f" ID: {api_key_obj.id}") print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") print(f" Requêtes: {api_key_obj.total_requests}") print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") else: - print(f"\n❌ Clé API invalide, expirée ou révoquée\n") + print("\n Clé API invalide, expirée ou révoquée\n") -# ============================================ -# CLI PRINCIPAL -# ============================================ - async def main(): parser = argparse.ArgumentParser( description="Gestion de la sécurité Sage Dataven API" ) - + subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") - # === SWAGGER === - swagger_parser = subparsers.add_parser("swagger", help="Gestion utilisateurs Swagger") + swagger_parser = subparsers.add_parser( + "swagger", help="Gestion utilisateurs Swagger" + ) swagger_subparsers = swagger_parser.add_subparsers(dest="action") - # swagger add swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") swagger_add.add_argument("username", help="Nom d'utilisateur") swagger_add.add_argument("password", help="Mot de passe") swagger_add.add_argument("--full-name", help="Nom complet") - # swagger list swagger_subparsers.add_parser("list", help="Lister les utilisateurs") - # swagger delete - swagger_delete = swagger_subparsers.add_parser("delete", help="Supprimer un utilisateur") + swagger_delete = swagger_subparsers.add_parser( + "delete", help="Supprimer un utilisateur" + ) swagger_delete.add_argument("username", help="Nom d'utilisateur") - # === API KEYS === apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") apikey_subparsers = apikey_parser.add_subparsers(dest="action") - # apikey create apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") apikey_create.add_argument("name", help="Nom de la clé") apikey_create.add_argument("--description", help="Description") - apikey_create.add_argument("--days", type=int, default=365, help="Expiration en jours") - apikey_create.add_argument("--rate-limit", type=int, default=60, help="Limite req/min") + apikey_create.add_argument( + "--days", type=int, default=365, help="Expiration en jours" + ) + apikey_create.add_argument( + "--rate-limit", type=int, default=60, help="Limite req/min" + ) apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") - # apikey list apikey_subparsers.add_parser("list", help="Lister les clés") - # apikey revoke apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") apikey_revoke.add_argument("key_id", help="ID de la clé") - # apikey verify apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") apikey_verify.add_argument("api_key", help="Clé API à vérifier") @@ -255,7 +229,6 @@ async def main(): parser.print_help() return - # Exécution des commandes if args.command == "swagger": if args.action == "add": await add_swagger_user(args.username, args.password, args.full_name) @@ -287,4 +260,5 @@ async def main(): if __name__ == "__main__": from datetime import datetime - asyncio.run(main()) \ No newline at end of file + + asyncio.run(main()) diff --git a/scripts/test_security.py b/scripts/test_security.py index 79f0299..497870e 100644 --- a/scripts/test_security.py +++ b/scripts/test_security.py @@ -1,16 +1,7 @@ -#!/usr/bin/env python3 -""" -Script de test automatisé pour vérifier la sécurité de l'API - -Usage: - python test_security.py --url http://votre-vps:8000 --swagger-user admin --swagger-pass password -""" - import requests import argparse import sys -from typing import Dict, Tuple -import json +from typing import Tuple class SecurityTester: @@ -20,7 +11,7 @@ class SecurityTester: def log_test(self, name: str, passed: bool, details: str = ""): """Enregistrer le résultat d'un test""" - status = "✅ PASS" if passed else "❌ FAIL" + status = " PASS" if passed else " FAIL" print(f"{status} - {name}") if details: print(f" {details}") @@ -36,7 +27,7 @@ class SecurityTester: def test_swagger_without_auth(self) -> bool: """Test 1: Swagger UI devrait demander une authentification""" - print("\n🔍 Test 1: Protection Swagger UI") + print("\n Test 1: Protection Swagger UI") try: response = requests.get(f"{self.base_url}/docs", timeout=5) @@ -62,7 +53,7 @@ class SecurityTester: def test_swagger_with_auth(self, username: str, password: str) -> bool: """Test 2: Swagger UI accessible avec credentials valides""" - print("\n🔍 Test 2: Accès Swagger avec authentification") + print("\n Test 2: Accès Swagger avec authentification") try: response = requests.get( @@ -90,7 +81,7 @@ class SecurityTester: def test_api_without_auth(self) -> bool: """Test 3: Endpoints API devraient demander une authentification""" - print("\n🔍 Test 3: Protection des endpoints API") + print("\n Test 3: Protection des endpoints API") test_endpoints = ["/api/v1/clients", "/api/v1/documents"] @@ -100,15 +91,15 @@ class SecurityTester: response = requests.get(f"{self.base_url}{endpoint}", timeout=5) if response.status_code == 401: - print(f" ✅ {endpoint} protégé (401)") + print(f" {endpoint} protégé (401)") else: print( - f" ❌ {endpoint} accessible sans auth (code {response.status_code})" + f" {endpoint} accessible sans auth (code {response.status_code})" ) all_protected = False except Exception as e: - print(f" ⚠️ {endpoint} erreur: {str(e)}") + print(f" {endpoint} erreur: {str(e)}") all_protected = False self.log_test("Endpoints API protégés", all_protected) @@ -116,7 +107,7 @@ class SecurityTester: def test_health_endpoint_public(self) -> bool: """Test 4: Endpoint /health devrait être accessible sans auth""" - print("\n🔍 Test 4: Endpoint /health public") + print("\n Test 4: Endpoint /health public") try: response = requests.get(f"{self.base_url}/health", timeout=5) @@ -138,10 +129,9 @@ class SecurityTester: def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]: """Test 5: Créer une clé API via l'endpoint""" - print("\n🔍 Test 5: Création d'une clé API") + print("\n Test 5: Création d'une clé API") try: - # 1. Login pour obtenir un JWT login_response = requests.post( f"{self.base_url}/api/v1/auth/login", json={"email": username, "password": password}, @@ -158,7 +148,6 @@ class SecurityTester: jwt_token = login_response.json().get("access_token") - # 2. Créer une clé API create_response = requests.post( f"{self.base_url}/api/v1/api-keys", headers={"Authorization": f"Bearer {jwt_token}"}, @@ -189,7 +178,7 @@ class SecurityTester: def test_api_key_usage(self, api_key: str) -> bool: """Test 6: Utiliser une clé API pour accéder à un endpoint""" - print("\n🔍 Test 6: Utilisation d'une clé API") + print("\n Test 6: Utilisation d'une clé API") if not api_key: self.log_test("Utilisation clé API", False, "Pas de clé disponible") @@ -219,7 +208,7 @@ class SecurityTester: def test_invalid_api_key(self) -> bool: """Test 7: Une clé invalide devrait être refusée""" - print("\n🔍 Test 7: Rejet de clé API invalide") + print("\n Test 7: Rejet de clé API invalide") invalid_key = "sdk_live_invalid_key_12345" @@ -247,13 +236,12 @@ class SecurityTester: def test_rate_limiting(self, api_key: str) -> bool: """Test 8: Rate limiting (optionnel, peut prendre du temps)""" - print("\n🔍 Test 8: Rate limiting (test simple)") + print("\n Test 8: Rate limiting (test simple)") if not api_key: self.log_test("Rate limiting", False, "Pas de clé disponible") return False - # Envoyer 70 requêtes rapidement (limite = 60/min) print(" Envoi de 70 requêtes rapides...") rate_limited = False @@ -267,7 +255,7 @@ class SecurityTester: if response.status_code == 429: rate_limited = True - print(f" ⚠️ Rate limit atteint à la requête {i + 1}") + print(f" Rate limit atteint à la requête {i + 1}") break except Exception: @@ -287,22 +275,22 @@ class SecurityTester: def print_summary(self): """Afficher le résumé des tests""" print("\n" + "=" * 60) - print("📊 RÉSUMÉ DES TESTS") + print(" RÉSUMÉ DES TESTS") print("=" * 60) total = self.results["passed"] + self.results["failed"] success_rate = (self.results["passed"] / total * 100) if total > 0 else 0 print(f"\nTotal: {total} tests") - print(f"✅ Réussis: {self.results['passed']}") - print(f"❌ Échoués: {self.results['failed']}") - print(f"📈 Taux de réussite: {success_rate:.1f}%\n") + print(f" Réussis: {self.results['passed']}") + print(f" Échoués: {self.results['failed']}") + print(f"Taux de réussite: {success_rate:.1f}%\n") if self.results["failed"] == 0: print("🎉 Tous les tests sont passés ! Sécurité OK.") return 0 else: - print("⚠️ Certains tests ont échoué. Vérifiez la configuration.") + print(" Certains tests ont échoué. Vérifiez la configuration.") return 1 @@ -333,18 +321,16 @@ def main(): args = parser.parse_args() - print("🚀 Démarrage des tests de sécurité") - print(f"🎯 URL cible: {args.url}\n") + print(" Démarrage des tests de sécurité") + print(f" URL cible: {args.url}\n") tester = SecurityTester(args.url) - # Exécuter les tests tester.test_swagger_without_auth() tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass) tester.test_api_without_auth() tester.test_health_endpoint_public() - # Tests nécessitant une clé API success, api_key = tester.test_api_key_creation( args.swagger_user, args.swagger_pass ) @@ -356,11 +342,10 @@ def main(): if not args.skip_rate_limit: tester.test_rate_limiting(api_key) else: - print("\n⏭️ Test de rate limiting sauté") + print("\n Test de rate limiting sauté") else: - print("\n⚠️ Tests avec clé API sautés (création échouée)") + print("\n Tests avec clé API sautés (création échouée)") - # Résumé exit_code = tester.print_summary() sys.exit(exit_code) diff --git a/services/api_key.py b/services/api_key.py index d54943a..ad3cf6f 100644 --- a/services/api_key.py +++ b/services/api_key.py @@ -21,7 +21,6 @@ class ApiKeyService: @staticmethod def generate_api_key() -> str: """Génère une clé API unique et sécurisée""" - # Format: sdk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx random_part = secrets.token_urlsafe(32) return f"sdk_live_{random_part}" @@ -33,7 +32,6 @@ class ApiKeyService: @staticmethod def get_key_prefix(api_key: str) -> str: """Extrait le préfixe de la clé pour identification""" - # Retourne les 12 premiers caractères return api_key[:12] if len(api_key) >= 12 else api_key async def create_api_key( @@ -46,23 +44,14 @@ class ApiKeyService: rate_limit_per_minute: int = 60, allowed_endpoints: Optional[List[str]] = None, ) -> tuple[ApiKey, str]: - """ - Crée une nouvelle clé API - - Returns: - tuple[ApiKey, str]: (objet ApiKey, clé en clair - à ne montrer qu'une fois) - """ - # Génération de la clé api_key_plain = self.generate_api_key() key_hash = self.hash_api_key(api_key_plain) key_prefix = self.get_key_prefix(api_key_plain) - # Calcul de la date d'expiration expires_at = None if expires_in_days: expires_at = datetime.now() + timedelta(days=expires_in_days) - # Création de l'objet api_key_obj = ApiKey( key_hash=key_hash, key_prefix=key_prefix, @@ -81,24 +70,18 @@ class ApiKeyService: await self.session.commit() await self.session.refresh(api_key_obj) - logger.info(f"✅ Clé API créée: {name} (prefix: {key_prefix})") + logger.info(f" Clé API créée: {name} (prefix: {key_prefix})") return api_key_obj, api_key_plain async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]: - """ - Vérifie une clé API et retourne l'objet si valide - - Returns: - Optional[ApiKey]: L'objet ApiKey si valide, None sinon - """ key_hash = self.hash_api_key(api_key_plain) result = await self.session.execute( select(ApiKey).where( and_( ApiKey.key_hash == key_hash, - ApiKey.is_active == True, + ApiKey.is_active, ApiKey.revoked_at.is_(None), or_( ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now() @@ -110,14 +93,13 @@ class ApiKeyService: api_key_obj = result.scalar_one_or_none() if api_key_obj: - # Mise à jour des statistiques api_key_obj.total_requests += 1 api_key_obj.last_used_at = datetime.now() await self.session.commit() - logger.debug(f"🔑 Clé API validée: {api_key_obj.name}") + logger.debug(f" Clé API validée: {api_key_obj.name}") else: - logger.warning(f"⚠️ Clé API invalide ou expirée") + logger.warning(" Clé API invalide ou expirée") return api_key_obj @@ -152,7 +134,7 @@ class ApiKeyService: api_key_obj.revoked_at = datetime.now() await self.session.commit() - logger.info(f"🚫 Clé API révoquée: {api_key_obj.name}") + logger.info(f" Clé API révoquée: {api_key_obj.name}") return True async def get_by_id(self, key_id: str) -> Optional[ApiKey]: @@ -161,14 +143,6 @@ class ApiKeyService: return result.scalar_one_or_none() async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]: - """ - Vérifie le rate limit d'une clé API (à implémenter avec Redis/cache) - - Returns: - tuple[bool, Dict]: (is_allowed, info_dict) - """ - # TODO: Implémenter avec Redis pour un vrai rate limiting - # Pour l'instant, retourne toujours True return True, { "allowed": True, "limit": api_key_obj.rate_limit_per_minute, @@ -178,13 +152,11 @@ class ApiKeyService: async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: """Vérifie si la clé a accès à un endpoint spécifique""" if not api_key_obj.allowed_endpoints: - # Si aucune restriction, accès total return True try: allowed = json.loads(api_key_obj.allowed_endpoints) - # Support des wildcards for pattern in allowed: if pattern == "*": return True @@ -197,7 +169,7 @@ class ApiKeyService: return False except json.JSONDecodeError: - logger.error(f"❌ Erreur parsing allowed_endpoints pour {api_key_obj.id}") + logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}") return False diff --git a/services/universign_document.py b/services/universign_document.py index 9cb714e..394c3ce 100644 --- a/services/universign_document.py +++ b/services/universign_document.py @@ -23,7 +23,7 @@ class UniversignDocumentService: def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]: try: - logger.info(f"📋 Récupération documents pour transaction: {transaction_id}") + logger.info(f" Récupération documents pour transaction: {transaction_id}") response = requests.get( f"{self.api_url}/transactions/{transaction_id}", diff --git a/services/universign_sync.py b/services/universign_sync.py index 807802d..da634f2 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -344,7 +344,7 @@ class UniversignSyncService: universign_data = result["transaction"] universign_status_raw = universign_data.get("state", "draft") - logger.info(f"📊 Statut Universign brut: {universign_status_raw}") + logger.info(f" Statut Universign brut: {universign_status_raw}") new_local_status = map_universign_to_local(universign_status_raw) previous_local_status = transaction.local_status.value @@ -367,7 +367,7 @@ class UniversignSyncService: if status_changed: logger.info( - f"🔔 CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" + f"CHANGEMENT DÉTECTÉ: {previous_local_status} → {new_local_status}" ) try: @@ -392,7 +392,7 @@ class UniversignSyncService: if new_local_status == "EN_COURS" and not transaction.sent_at: transaction.sent_at = datetime.now() - logger.info("📅 Date d'envoi mise à jour") + logger.info("Date d'envoi mise à jour") if new_local_status == "SIGNE" and not transaction.signed_at: transaction.signed_at = datetime.now() @@ -404,8 +404,7 @@ class UniversignSyncService: if new_local_status == "EXPIRE" and not transaction.expired_at: transaction.expired_at = datetime.now() - logger.info("⏰ Date d'expiration mise à jour") - + logger.info("Date d'expiration mise à jour") documents = universign_data.get("documents", []) if documents: @@ -432,9 +431,7 @@ class UniversignSyncService: logger.warning(f"Échec téléchargement: {download_error}") except Exception as e: - logger.error( - f" Erreur téléchargement document: {e}", exc_info=True - ) + logger.error(f" Erreur téléchargement document: {e}", exc_info=True) await self._sync_signers(session, transaction, universign_data) @@ -529,9 +526,7 @@ class UniversignSyncService: logger.warning(f"Échec téléchargement: {download_error}") except Exception as e: - logger.error( - f" Erreur téléchargement document: {e}", exc_info=True - ) + logger.error(f" Erreur téléchargement document: {e}", exc_info=True) else: logger.debug( f"Document déjà téléchargé: {transaction.signed_document_path}" diff --git a/utils/generic_functions.py b/utils/generic_functions.py index 4cce52f..29a361e 100644 --- a/utils/generic_functions.py +++ b/utils/generic_functions.py @@ -425,7 +425,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", - "icon": "❌", + "icon": "", "color": "red", }, "EXPIRE": { @@ -437,7 +437,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "ERREUR": { "fr": "Erreur technique", "en": "Technical error", - "icon": "⚠️", + "icon": "", "color": "red", }, } diff --git a/utils/universign_status_mapping.py b/utils/universign_status_mapping.py index 50e29cc..8391698 100644 --- a/utils/universign_status_mapping.py +++ b/utils/universign_status_mapping.py @@ -96,7 +96,7 @@ STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", - "icon": "❌", + "icon": "", "color": "red", }, "EXPIRE": { From abc9ff820a807d67ecdced7b3ba2bd69d5733a80 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:11:32 +0300 Subject: [PATCH 13/36] feat(security): implement api key management and authentication system --- config/cors_config.py | 125 +++++++++++++ database/models/api_key.py | 56 ++++++ middleware/security.py | 236 +++++++++++++++++++++++++ routes/api_keys.py | 154 ++++++++++++++++ schemas/api_key.py | 77 ++++++++ scripts/manage_security.py | 264 +++++++++++++++++++++++++++ scripts/test_security.py | 354 +++++++++++++++++++++++++++++++++++++ services/api_key.py | 205 +++++++++++++++++++++ 8 files changed, 1471 insertions(+) create mode 100644 config/cors_config.py create mode 100644 database/models/api_key.py create mode 100644 middleware/security.py create mode 100644 routes/api_keys.py create mode 100644 schemas/api_key.py create mode 100644 scripts/manage_security.py create mode 100644 scripts/test_security.py create mode 100644 services/api_key.py diff --git a/config/cors_config.py b/config/cors_config.py new file mode 100644 index 0000000..0f3a4d2 --- /dev/null +++ b/config/cors_config.py @@ -0,0 +1,125 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from typing import List +import os +import logging + +logger = logging.getLogger(__name__) + + +def configure_cors_open(app: FastAPI): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["*"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode OUVERT (sécurisé par API Keys)") + logger.info(" - Origins: * (toutes)") + logger.info(" - Headers: * (dont X-API-Key)") + logger.info(" - Credentials: False") + + +def configure_cors_whitelist(app: FastAPI): + allowed_origins_str = os.getenv("CORS_ALLOWED_ORIGINS", "") + + if allowed_origins_str: + allowed_origins = [ + origin.strip() + for origin in allowed_origins_str.split(",") + if origin.strip() + ] + else: + allowed_origins = ["*"] + + app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode WHITELIST") + logger.info(f" - Origins autorisées: {len(allowed_origins)}") + for origin in allowed_origins: + logger.info(f" • {origin}") + + +def configure_cors_regex(app: FastAPI): + origin_regex = r"*" + + app.add_middleware( + CORSMiddleware, + allow_origin_regex=origin_regex, + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], + allow_headers=["Content-Type", "Authorization", "X-API-Key"], + expose_headers=["X-RateLimit-Limit", "X-RateLimit-Remaining"], + max_age=3600, + ) + + logger.info(" CORS configuré: Mode REGEX") + logger.info(f" - Pattern: {origin_regex}") + + +def configure_cors_hybrid(app: FastAPI): + from starlette.middleware.base import BaseHTTPMiddleware + + class HybridCORSMiddleware(BaseHTTPMiddleware): + def __init__(self, app, known_origins: List[str]): + super().__init__(app) + self.known_origins = set(known_origins) + + async def dispatch(self, request, call_next): + origin = request.headers.get("origin") + + if origin in self.known_origins: + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return response + + response = await call_next(request) + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, PATCH, OPTIONS" + ) + response.headers["Access-Control-Allow-Headers"] = "*" + return response + + known_origins = ["*"] + + app.add_middleware(HybridCORSMiddleware, known_origins=known_origins) + + logger.info(" CORS configuré: Mode HYBRIDE") + logger.info(f" - Whitelist: {len(known_origins)} domaines") + logger.info(" - Fallback: * (ouvert)") + + +def setup_cors(app: FastAPI, mode: str = "open"): + if mode == "open": + configure_cors_open(app) + elif mode == "whitelist": + configure_cors_whitelist(app) + elif mode == "regex": + configure_cors_regex(app) + elif mode == "hybrid": + configure_cors_hybrid(app) + else: + logger.warning( + f" Mode CORS inconnu: {mode}. Utilisation de 'open' par défaut." + ) + configure_cors_open(app) diff --git a/database/models/api_key.py b/database/models/api_key.py new file mode 100644 index 0000000..0d246ab --- /dev/null +++ b/database/models/api_key.py @@ -0,0 +1,56 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text +from datetime import datetime +import uuid + +from database.models.generic_model import Base + + +class ApiKey(Base): + """Modèle pour les clés API publiques""" + + __tablename__ = "api_keys" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + key_hash = Column(String(64), unique=True, nullable=False, index=True) + key_prefix = Column(String(10), nullable=False) + + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + + user_id = Column(String(36), nullable=True) + created_by = Column(String(255), nullable=False) + + is_active = Column(Boolean, default=True, nullable=False) + rate_limit_per_minute = Column(Integer, default=60, nullable=False) + allowed_endpoints = Column(Text, nullable=True) + + total_requests = Column(Integer, default=0, nullable=False) + last_used_at = Column(DateTime, nullable=True) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + expires_at = Column(DateTime, nullable=True) + revoked_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class SwaggerUser(Base): + """Modèle pour les utilisateurs autorisés à accéder au Swagger""" + + __tablename__ = "swagger_users" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username = Column(String(100), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + + full_name = Column(String(255), nullable=True) + email = Column(String(255), nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + + created_at = Column(DateTime, default=datetime.now, nullable=False) + last_login = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" diff --git a/middleware/security.py b/middleware/security.py new file mode 100644 index 0000000..137e7dd --- /dev/null +++ b/middleware/security.py @@ -0,0 +1,236 @@ +from fastapi import Request, status +from fastapi.responses import JSONResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from sqlalchemy import select +from typing import Optional +from datetime import datetime +import logging + +from database import get_session +from database.models.api_key import SwaggerUser +from security.auth import verify_password + +logger = logging.getLogger(__name__) + +security = HTTPBasic() + + +async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: + username = credentials.username + password = credentials.password + + try: + async for session in get_session(): + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + swagger_user = result.scalar_one_or_none() + + if swagger_user and swagger_user.is_active: + if verify_password(password, swagger_user.hashed_password): + swagger_user.last_login = datetime.now() + await session.commit() + + logger.info(f" Accès Swagger autorisé (DB): {username}") + return True + + logger.warning(f" Tentative d'accès Swagger refusée: {username}") + return False + + except Exception as e: + logger.error(f" Erreur vérification Swagger credentials: {e}") + return False + + +class SwaggerAuthMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + protected_paths = ["/docs", "/redoc", "/openapi.json"] + + if any(path.startswith(protected_path) for protected_path in protected_paths): + auth_header = request.headers.get("Authorization") + + if not auth_header or not auth_header.startswith("Basic "): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise pour accéder à la documentation" + }, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + try: + import base64 + + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode( + "utf-8" + ) + username, password = decoded_credentials.split(":", 1) + + credentials = HTTPBasicCredentials(username=username, password=password) + + if not await verify_swagger_credentials(credentials): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Identifiants invalides"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + except Exception as e: + logger.error(f" Erreur parsing auth header: {e}") + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Format d'authentification invalide"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +class ApiKeyMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive=receive) + path = request.url.path + + excluded_paths = [ + "/docs", + "/redoc", + "/openapi.json", + "/health", + "/", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", + ] + + if any(path.startswith(excluded_path) for excluded_path in excluded_paths): + await self.app(scope, receive, send) + return + + auth_header = request.headers.get("Authorization") + has_jwt = auth_header and auth_header.startswith("Bearer ") + + api_key = request.headers.get("X-API-Key") + has_api_key = api_key is not None + + if has_jwt: + logger.debug(f" JWT détecté pour {path}") + await self.app(scope, receive, send) + return + + elif has_api_key: + logger.debug(f" API Key détectée pour {path}") + + from services.api_key import ApiKeyService + + try: + async for session in get_session(): + api_key_service = ApiKeyService(session) + api_key_obj = await api_key_service.verify_api_key(api_key) + + if not api_key_obj: + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Clé API invalide ou expirée", + "hint": "Utilisez X-API-Key: sdk_live_xxx ou Authorization: Bearer ", + }, + ) + await response(scope, receive, send) + return + + is_allowed, rate_info = await api_key_service.check_rate_limit( + api_key_obj + ) + if not is_allowed: + response = JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit dépassé"}, + headers={ + "X-RateLimit-Limit": str(rate_info["limit"]), + "X-RateLimit-Remaining": "0", + }, + ) + await response(scope, receive, send) + return + + has_access = await api_key_service.check_endpoint_access( + api_key_obj, path + ) + if not has_access: + response = JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "Accès non autorisé à cet endpoint", + "endpoint": path, + "api_key": api_key_obj.key_prefix + "...", + }, + ) + await response(scope, receive, send) + return + + request.state.api_key = api_key_obj + request.state.authenticated_via = "api_key" + + logger.info(f" API Key valide: {api_key_obj.name} → {path}") + + await self.app(scope, receive, send) + return + + except Exception as e: + logger.error(f" Erreur validation API Key: {e}", exc_info=True) + response = JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "Erreur interne lors de la validation de la clé" + }, + ) + await response(scope, receive, send) + return + + else: + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + await response(scope, receive, send) + return + + +def get_api_key_from_request(request: Request) -> Optional: + """Récupère l'objet ApiKey depuis la requête si présent""" + return getattr(request.state, "api_key", None) + + +def get_auth_method(request: Request) -> str: + return getattr(request.state, "authenticated_via", "none") diff --git a/routes/api_keys.py b/routes/api_keys.py new file mode 100644 index 0000000..27f0efc --- /dev/null +++ b/routes/api_keys.py @@ -0,0 +1,154 @@ +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession +import logging + +from database import get_session, User +from core.dependencies import get_current_user, require_role +from services.api_key import ApiKeyService, api_key_to_response +from schemas.api_key import ( + ApiKeyCreate, + ApiKeyCreatedResponse, + ApiKeyResponse, + ApiKeyList, +) + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api-keys", tags=["API Keys Management"]) + + +@router.post( + "", + response_model=ApiKeyCreatedResponse, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_role("admin", "super_admin"))], +) +async def create_api_key( + data: ApiKeyCreate, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=data.name, + description=data.description, + created_by=user.email, + user_id=user.id, + expires_in_days=data.expires_in_days, + rate_limit_per_minute=data.rate_limit_per_minute, + allowed_endpoints=data.allowed_endpoints, + ) + + logger.info(f" Clé API créée par {user.email}: {data.name}") + + response_data = api_key_to_response(api_key_obj) + response_data["api_key"] = api_key_plain + + return ApiKeyCreatedResponse(**response_data) + + +@router.get("", response_model=ApiKeyList) +async def list_api_keys( + include_revoked: bool = Query(False, description="Inclure les clés révoquées"), + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = ApiKeyService(session) + + user_id = None if user.role in ["admin", "super_admin"] else user.id + + keys = await service.list_api_keys(include_revoked=include_revoked, user_id=user_id) + + items = [ApiKeyResponse(**api_key_to_response(k)) for k in keys] + + return ApiKeyList(total=len(items), items=items) + + +@router.get("/{key_id}", response_model=ApiKeyResponse) +async def get_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + """ Récupérer une clé API par son ID""" + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + return ApiKeyResponse(**api_key_to_response(api_key_obj)) + + +@router.delete("/{key_id}", status_code=status.HTTP_200_OK) +async def revoke_api_key( + key_id: str, + session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), +): + service = ApiKeyService(session) + + api_key_obj = await service.get_by_id(key_id) + + if not api_key_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Clé API {key_id} introuvable", + ) + + if user.role not in ["admin", "super_admin"]: + if api_key_obj.user_id != user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accès refusé à cette clé", + ) + + success = await service.revoke_api_key(key_id) + + if not success: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Erreur lors de la révocation", + ) + + logger.info(f" Clé API révoquée par {user.email}: {api_key_obj.name}") + + return { + "success": True, + "message": f"Clé API '{api_key_obj.name}' révoquée avec succès", + } + + +@router.post("/verify", status_code=status.HTTP_200_OK) +async def verify_api_key_endpoint( + api_key: str = Query(..., description="Clé API à vérifier"), + session: AsyncSession = Depends(get_session), +): + service = ApiKeyService(session) + + api_key_obj = await service.verify_api_key(api_key) + + if not api_key_obj: + return { + "valid": False, + "message": "Clé API invalide, expirée ou révoquée", + } + + return { + "valid": True, + "message": "Clé API valide", + "key_name": api_key_obj.name, + "rate_limit": api_key_obj.rate_limit_per_minute, + "expires_at": api_key_obj.expires_at, + } diff --git a/schemas/api_key.py b/schemas/api_key.py new file mode 100644 index 0000000..4ec49b6 --- /dev/null +++ b/schemas/api_key.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class ApiKeyCreate(BaseModel): + """Schema pour créer une clé API""" + + name: str = Field(..., min_length=3, max_length=255, description="Nom de la clé") + description: Optional[str] = Field(None, description="Description de l'usage") + expires_in_days: Optional[int] = Field( + None, ge=1, le=3650, description="Expiration en jours (max 10 ans)" + ) + rate_limit_per_minute: int = Field( + 60, ge=1, le=1000, description="Limite de requêtes par minute" + ) + allowed_endpoints: Optional[List[str]] = Field( + None, description="Endpoints autorisés ([] = tous, ['/clients*'] = wildcard)" + ) + + +class ApiKeyResponse(BaseModel): + """Schema de réponse pour une clé API""" + + id: str + name: str + description: Optional[str] + key_prefix: str + is_active: bool + is_expired: bool + rate_limit_per_minute: int + allowed_endpoints: Optional[List[str]] + total_requests: int + last_used_at: Optional[datetime] + created_at: datetime + expires_at: Optional[datetime] + revoked_at: Optional[datetime] + created_by: str + + +class ApiKeyCreatedResponse(ApiKeyResponse): + """Schema de réponse après création (inclut la clé en clair)""" + + api_key: str = Field( + ..., description=" Clé API en clair - à sauvegarder immédiatement" + ) + + +class ApiKeyList(BaseModel): + """Liste de clés API""" + + total: int + items: List[ApiKeyResponse] + + +class SwaggerUserCreate(BaseModel): + """Schema pour créer un utilisateur Swagger""" + + username: str = Field(..., min_length=3, max_length=100) + password: str = Field(..., min_length=8) + full_name: Optional[str] = None + email: Optional[str] = None + + +class SwaggerUserResponse(BaseModel): + """Schema de réponse pour un utilisateur Swagger""" + + id: str + username: str + full_name: Optional[str] + email: Optional[str] + is_active: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True diff --git a/scripts/manage_security.py b/scripts/manage_security.py new file mode 100644 index 0000000..1f234b9 --- /dev/null +++ b/scripts/manage_security.py @@ -0,0 +1,264 @@ +import asyncio +import sys +from pathlib import Path +import argparse + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from database import get_session +from database.models.api_key import SwaggerUser +from services.api_key import ApiKeyService +from security.auth import hash_password +from sqlalchemy import select +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def add_swagger_user(username: str, password: str, full_name: str = None): + """Ajouter un utilisateur Swagger""" + async with get_session() as session: + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + existing = result.scalar_one_or_none() + + if existing: + logger.error(f" L'utilisateur {username} existe déjà") + return + + user = SwaggerUser( + username=username, + hashed_password=hash_password(password), + full_name=full_name or username, + is_active=True, + ) + + session.add(user) + await session.commit() + + logger.info(f" Utilisateur Swagger créé: {username}") + print("\n Utilisateur créé avec succès") + print(f" Username: {username}") + print(" Accès: https://votre-serveur/docs") + + +async def list_swagger_users(): + """Lister les utilisateurs Swagger""" + async with get_session() as session: + result = await session.execute(select(SwaggerUser)) + users = result.scalars().all() + + if not users: + print("Aucun utilisateur Swagger trouvé") + return + + print(f"\n {len(users)} utilisateur(s) Swagger:\n") + for user in users: + status = " Actif" if user.is_active else " Inactif" + print(f" • {user.username:<20} {status}") + if user.full_name: + print(f" Nom: {user.full_name}") + if user.last_login: + print(f" Dernière connexion: {user.last_login}") + print() + + +async def delete_swagger_user(username: str): + """Supprimer un utilisateur Swagger""" + async with get_session() as session: + result = await session.execute( + select(SwaggerUser).where(SwaggerUser.username == username) + ) + user = result.scalar_one_or_none() + + if not user: + logger.error(f" Utilisateur {username} introuvable") + return + + await session.delete(user) + await session.commit() + + logger.info(f"🗑️ Utilisateur supprimé: {username}") + + +async def create_api_key( + name: str, + description: str = None, + expires_in_days: int = 365, + rate_limit: int = 60, + endpoints: list = None, +): + """Créer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key_obj, api_key_plain = await service.create_api_key( + name=name, + description=description, + created_by="CLI", + expires_in_days=expires_in_days, + rate_limit_per_minute=rate_limit, + allowed_endpoints=endpoints, + ) + + print("\n Clé API créée avec succès\n") + print(f" ID: {api_key_obj.id}") + print(f" Nom: {name}") + print(f" Clé: {api_key_plain}") + print(f" Préfixe: {api_key_obj.key_prefix}") + print(f" Rate limit: {rate_limit} req/min") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") + print("\n IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + + +async def list_api_keys(): + """Lister les clés API""" + async with get_session() as session: + service = ApiKeyService(session) + keys = await service.list_api_keys() + + if not keys: + print("Aucune clé API trouvée") + return + + print(f"\n {len(keys)} clé(s) API:\n") + for key in keys: + status = "" if key.is_active else "" + expired = ( + "⏰ Expirée" + if key.expires_at and key.expires_at < datetime.now() + else "" + ) + + print(f" {status} {key.name:<30} ({key.key_prefix}...)") + print(f" ID: {key.id}") + print(f" Requêtes: {key.total_requests}") + print(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + if expired: + print(f" {expired}") + print() + + +async def revoke_api_key(key_id: str): + """Révoquer une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + + api_key = await service.get_by_id(key_id) + if not api_key: + logger.error(f" Clé {key_id} introuvable") + return + + success = await service.revoke_api_key(key_id) + + if success: + logger.info(f" Clé révoquée: {api_key.name}") + print(f"\n Clé '{api_key.name}' révoquée avec succès") + else: + logger.error(" Erreur lors de la révocation") + + +async def verify_api_key_cmd(api_key: str): + """Vérifier une clé API""" + async with get_session() as session: + service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) + + if api_key_obj: + print("\n Clé API valide\n") + print(f" Nom: {api_key_obj.name}") + print(f" ID: {api_key_obj.id}") + print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") + print(f" Requêtes: {api_key_obj.total_requests}") + print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") + else: + print("\n Clé API invalide, expirée ou révoquée\n") + + +async def main(): + parser = argparse.ArgumentParser( + description="Gestion de la sécurité Sage Dataven API" + ) + + subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") + + swagger_parser = subparsers.add_parser( + "swagger", help="Gestion utilisateurs Swagger" + ) + swagger_subparsers = swagger_parser.add_subparsers(dest="action") + + swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") + swagger_add.add_argument("username", help="Nom d'utilisateur") + swagger_add.add_argument("password", help="Mot de passe") + swagger_add.add_argument("--full-name", help="Nom complet") + + swagger_subparsers.add_parser("list", help="Lister les utilisateurs") + + swagger_delete = swagger_subparsers.add_parser( + "delete", help="Supprimer un utilisateur" + ) + swagger_delete.add_argument("username", help="Nom d'utilisateur") + + apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") + apikey_subparsers = apikey_parser.add_subparsers(dest="action") + + apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") + apikey_create.add_argument("name", help="Nom de la clé") + apikey_create.add_argument("--description", help="Description") + apikey_create.add_argument( + "--days", type=int, default=365, help="Expiration en jours" + ) + apikey_create.add_argument( + "--rate-limit", type=int, default=60, help="Limite req/min" + ) + apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") + + apikey_subparsers.add_parser("list", help="Lister les clés") + + apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") + apikey_revoke.add_argument("key_id", help="ID de la clé") + + apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") + apikey_verify.add_argument("api_key", help="Clé API à vérifier") + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + if args.command == "swagger": + if args.action == "add": + await add_swagger_user(args.username, args.password, args.full_name) + elif args.action == "list": + await list_swagger_users() + elif args.action == "delete": + await delete_swagger_user(args.username) + else: + swagger_parser.print_help() + + elif args.command == "apikey": + if args.action == "create": + await create_api_key( + args.name, + args.description, + args.days, + args.rate_limit, + args.endpoints, + ) + elif args.action == "list": + await list_api_keys() + elif args.action == "revoke": + await revoke_api_key(args.key_id) + elif args.action == "verify": + await verify_api_key_cmd(args.api_key) + else: + apikey_parser.print_help() + + +if __name__ == "__main__": + from datetime import datetime + + asyncio.run(main()) diff --git a/scripts/test_security.py b/scripts/test_security.py new file mode 100644 index 0000000..497870e --- /dev/null +++ b/scripts/test_security.py @@ -0,0 +1,354 @@ +import requests +import argparse +import sys +from typing import Tuple + + +class SecurityTester: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + self.results = {"passed": 0, "failed": 0, "tests": []} + + def log_test(self, name: str, passed: bool, details: str = ""): + """Enregistrer le résultat d'un test""" + status = " PASS" if passed else " FAIL" + print(f"{status} - {name}") + if details: + print(f" {details}") + + self.results["tests"].append( + {"name": name, "passed": passed, "details": details} + ) + + if passed: + self.results["passed"] += 1 + else: + self.results["failed"] += 1 + + def test_swagger_without_auth(self) -> bool: + """Test 1: Swagger UI devrait demander une authentification""" + print("\n Test 1: Protection Swagger UI") + + try: + response = requests.get(f"{self.base_url}/docs", timeout=5) + + if response.status_code == 401: + self.log_test( + "Swagger protégé", + True, + "Code 401 retourné sans authentification", + ) + return True + else: + self.log_test( + "Swagger protégé", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Swagger protégé", False, f"Erreur: {str(e)}") + return False + + def test_swagger_with_auth(self, username: str, password: str) -> bool: + """Test 2: Swagger UI accessible avec credentials valides""" + print("\n Test 2: Accès Swagger avec authentification") + + try: + response = requests.get( + f"{self.base_url}/docs", auth=(username, password), timeout=5 + ) + + if response.status_code == 200: + self.log_test( + "Accès Swagger avec auth", + True, + f"Authentifié comme {username}", + ) + return True + else: + self.log_test( + "Accès Swagger avec auth", + False, + f"Code {response.status_code}, credentials invalides?", + ) + return False + + except Exception as e: + self.log_test("Accès Swagger avec auth", False, f"Erreur: {str(e)}") + return False + + def test_api_without_auth(self) -> bool: + """Test 3: Endpoints API devraient demander une authentification""" + print("\n Test 3: Protection des endpoints API") + + test_endpoints = ["/api/v1/clients", "/api/v1/documents"] + + all_protected = True + for endpoint in test_endpoints: + try: + response = requests.get(f"{self.base_url}{endpoint}", timeout=5) + + if response.status_code == 401: + print(f" {endpoint} protégé (401)") + else: + print( + f" {endpoint} accessible sans auth (code {response.status_code})" + ) + all_protected = False + + except Exception as e: + print(f" {endpoint} erreur: {str(e)}") + all_protected = False + + self.log_test("Endpoints API protégés", all_protected) + return all_protected + + def test_health_endpoint_public(self) -> bool: + """Test 4: Endpoint /health devrait être accessible sans auth""" + print("\n Test 4: Endpoint /health public") + + try: + response = requests.get(f"{self.base_url}/health", timeout=5) + + if response.status_code == 200: + self.log_test("/health accessible", True, "Endpoint public fonctionne") + return True + else: + self.log_test( + "/health accessible", + False, + f"Code {response.status_code} inattendu", + ) + return False + + except Exception as e: + self.log_test("/health accessible", False, f"Erreur: {str(e)}") + return False + + def test_api_key_creation(self, username: str, password: str) -> Tuple[bool, str]: + """Test 5: Créer une clé API via l'endpoint""" + print("\n Test 5: Création d'une clé API") + + try: + login_response = requests.post( + f"{self.base_url}/api/v1/auth/login", + json={"email": username, "password": password}, + timeout=5, + ) + + if login_response.status_code != 200: + self.log_test( + "Création clé API", + False, + "Impossible de se connecter pour obtenir un JWT", + ) + return False, "" + + jwt_token = login_response.json().get("access_token") + + create_response = requests.post( + f"{self.base_url}/api/v1/api-keys", + headers={"Authorization": f"Bearer {jwt_token}"}, + json={ + "name": "Test API Key", + "description": "Clé de test automatisé", + "rate_limit_per_minute": 60, + "expires_in_days": 30, + }, + timeout=5, + ) + + if create_response.status_code == 201: + api_key = create_response.json().get("api_key") + self.log_test("Création clé API", True, f"Clé créée: {api_key[:20]}...") + return True, api_key + else: + self.log_test( + "Création clé API", + False, + f"Code {create_response.status_code}", + ) + return False, "" + + except Exception as e: + self.log_test("Création clé API", False, f"Erreur: {str(e)}") + return False, "" + + def test_api_key_usage(self, api_key: str) -> bool: + """Test 6: Utiliser une clé API pour accéder à un endpoint""" + print("\n Test 6: Utilisation d'une clé API") + + if not api_key: + self.log_test("Utilisation clé API", False, "Pas de clé disponible") + return False + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": api_key}, + timeout=5, + ) + + if response.status_code == 200: + self.log_test("Utilisation clé API", True, "Clé acceptée") + return True + else: + self.log_test( + "Utilisation clé API", + False, + f"Code {response.status_code}, clé refusée?", + ) + return False + + except Exception as e: + self.log_test("Utilisation clé API", False, f"Erreur: {str(e)}") + return False + + def test_invalid_api_key(self) -> bool: + """Test 7: Une clé invalide devrait être refusée""" + print("\n Test 7: Rejet de clé API invalide") + + invalid_key = "sdk_live_invalid_key_12345" + + try: + response = requests.get( + f"{self.base_url}/api/v1/clients", + headers={"X-API-Key": invalid_key}, + timeout=5, + ) + + if response.status_code == 401: + self.log_test("Clé invalide rejetée", True, "Code 401 comme attendu") + return True + else: + self.log_test( + "Clé invalide rejetée", + False, + f"Code {response.status_code} au lieu de 401", + ) + return False + + except Exception as e: + self.log_test("Clé invalide rejetée", False, f"Erreur: {str(e)}") + return False + + def test_rate_limiting(self, api_key: str) -> bool: + """Test 8: Rate limiting (optionnel, peut prendre du temps)""" + print("\n Test 8: Rate limiting (test simple)") + + if not api_key: + self.log_test("Rate limiting", False, "Pas de clé disponible") + return False + + print(" Envoi de 70 requêtes rapides...") + + rate_limited = False + for i in range(70): + try: + response = requests.get( + f"{self.base_url}/health", + headers={"X-API-Key": api_key}, + timeout=1, + ) + + if response.status_code == 429: + rate_limited = True + print(f" Rate limit atteint à la requête {i + 1}") + break + + except Exception: + pass + + if rate_limited: + self.log_test("Rate limiting", True, "Rate limit détecté") + return True + else: + self.log_test( + "Rate limiting", + True, + "Aucun rate limit détecté (peut être normal si pas implémenté)", + ) + return True + + def print_summary(self): + """Afficher le résumé des tests""" + print("\n" + "=" * 60) + print(" RÉSUMÉ DES TESTS") + print("=" * 60) + + total = self.results["passed"] + self.results["failed"] + success_rate = (self.results["passed"] / total * 100) if total > 0 else 0 + + print(f"\nTotal: {total} tests") + print(f" Réussis: {self.results['passed']}") + print(f" Échoués: {self.results['failed']}") + print(f"Taux de réussite: {success_rate:.1f}%\n") + + if self.results["failed"] == 0: + print("🎉 Tous les tests sont passés ! Sécurité OK.") + return 0 + else: + print(" Certains tests ont échoué. Vérifiez la configuration.") + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description="Test automatisé de la sécurité de l'API" + ) + + parser.add_argument( + "--url", + required=True, + help="URL de base de l'API (ex: http://localhost:8000)", + ) + + parser.add_argument( + "--swagger-user", required=True, help="Utilisateur Swagger pour les tests" + ) + + parser.add_argument( + "--swagger-pass", required=True, help="Mot de passe Swagger pour les tests" + ) + + parser.add_argument( + "--skip-rate-limit", + action="store_true", + help="Sauter le test de rate limiting (long)", + ) + + args = parser.parse_args() + + print(" Démarrage des tests de sécurité") + print(f" URL cible: {args.url}\n") + + tester = SecurityTester(args.url) + + tester.test_swagger_without_auth() + tester.test_swagger_with_auth(args.swagger_user, args.swagger_pass) + tester.test_api_without_auth() + tester.test_health_endpoint_public() + + success, api_key = tester.test_api_key_creation( + args.swagger_user, args.swagger_pass + ) + + if success and api_key: + tester.test_api_key_usage(api_key) + tester.test_invalid_api_key() + + if not args.skip_rate_limit: + tester.test_rate_limiting(api_key) + else: + print("\n Test de rate limiting sauté") + else: + print("\n Tests avec clé API sautés (création échouée)") + + exit_code = tester.print_summary() + sys.exit(exit_code) + + +if __name__ == "__main__": + main() diff --git a/services/api_key.py b/services/api_key.py new file mode 100644 index 0000000..ad3cf6f --- /dev/null +++ b/services/api_key.py @@ -0,0 +1,205 @@ +import secrets +import hashlib +import json +from datetime import datetime, timedelta +from typing import Optional, List, Dict +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_ +import logging + +from database.models.api_key import ApiKey + +logger = logging.getLogger(__name__) + + +class ApiKeyService: + """Service de gestion des clés API""" + + def __init__(self, session: AsyncSession): + self.session = session + + @staticmethod + def generate_api_key() -> str: + """Génère une clé API unique et sécurisée""" + random_part = secrets.token_urlsafe(32) + return f"sdk_live_{random_part}" + + @staticmethod + def hash_api_key(api_key: str) -> str: + """Hash la clé API pour stockage sécurisé""" + return hashlib.sha256(api_key.encode()).hexdigest() + + @staticmethod + def get_key_prefix(api_key: str) -> str: + """Extrait le préfixe de la clé pour identification""" + return api_key[:12] if len(api_key) >= 12 else api_key + + async def create_api_key( + self, + name: str, + description: Optional[str] = None, + created_by: str = "system", + user_id: Optional[str] = None, + expires_in_days: Optional[int] = None, + rate_limit_per_minute: int = 60, + allowed_endpoints: Optional[List[str]] = None, + ) -> tuple[ApiKey, str]: + api_key_plain = self.generate_api_key() + key_hash = self.hash_api_key(api_key_plain) + key_prefix = self.get_key_prefix(api_key_plain) + + expires_at = None + if expires_in_days: + expires_at = datetime.now() + timedelta(days=expires_in_days) + + api_key_obj = ApiKey( + key_hash=key_hash, + key_prefix=key_prefix, + name=name, + description=description, + created_by=created_by, + user_id=user_id, + expires_at=expires_at, + rate_limit_per_minute=rate_limit_per_minute, + allowed_endpoints=json.dumps(allowed_endpoints) + if allowed_endpoints + else None, + ) + + self.session.add(api_key_obj) + await self.session.commit() + await self.session.refresh(api_key_obj) + + logger.info(f" Clé API créée: {name} (prefix: {key_prefix})") + + return api_key_obj, api_key_plain + + async def verify_api_key(self, api_key_plain: str) -> Optional[ApiKey]: + key_hash = self.hash_api_key(api_key_plain) + + result = await self.session.execute( + select(ApiKey).where( + and_( + ApiKey.key_hash == key_hash, + ApiKey.is_active, + ApiKey.revoked_at.is_(None), + or_( + ApiKey.expires_at.is_(None), ApiKey.expires_at > datetime.now() + ), + ) + ) + ) + + api_key_obj = result.scalar_one_or_none() + + if api_key_obj: + api_key_obj.total_requests += 1 + api_key_obj.last_used_at = datetime.now() + await self.session.commit() + + logger.debug(f" Clé API validée: {api_key_obj.name}") + else: + logger.warning(" Clé API invalide ou expirée") + + return api_key_obj + + async def list_api_keys( + self, + include_revoked: bool = False, + user_id: Optional[str] = None, + ) -> List[ApiKey]: + """Liste les clés API""" + query = select(ApiKey) + + if not include_revoked: + query = query.where(ApiKey.revoked_at.is_(None)) + + if user_id: + query = query.where(ApiKey.user_id == user_id) + + query = query.order_by(ApiKey.created_at.desc()) + + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def revoke_api_key(self, key_id: str) -> bool: + """Révoque une clé API""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + api_key_obj = result.scalar_one_or_none() + + if not api_key_obj: + return False + + api_key_obj.is_active = False + api_key_obj.revoked_at = datetime.now() + await self.session.commit() + + logger.info(f" Clé API révoquée: {api_key_obj.name}") + return True + + async def get_by_id(self, key_id: str) -> Optional[ApiKey]: + """Récupère une clé API par son ID""" + result = await self.session.execute(select(ApiKey).where(ApiKey.id == key_id)) + return result.scalar_one_or_none() + + async def check_rate_limit(self, api_key_obj: ApiKey) -> tuple[bool, Dict]: + return True, { + "allowed": True, + "limit": api_key_obj.rate_limit_per_minute, + "remaining": api_key_obj.rate_limit_per_minute, + } + + async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: + """Vérifie si la clé a accès à un endpoint spécifique""" + if not api_key_obj.allowed_endpoints: + return True + + try: + allowed = json.loads(api_key_obj.allowed_endpoints) + + for pattern in allowed: + if pattern == "*": + return True + if pattern.endswith("*"): + prefix = pattern[:-1] + if endpoint.startswith(prefix): + return True + if pattern == endpoint: + return True + + return False + except json.JSONDecodeError: + logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}") + return False + + +def api_key_to_response(api_key_obj: ApiKey, show_key: bool = False) -> Dict: + """Convertit un objet ApiKey en réponse API""" + + allowed_endpoints = None + if api_key_obj.allowed_endpoints: + try: + allowed_endpoints = json.loads(api_key_obj.allowed_endpoints) + except json.JSONDecodeError: + pass + + is_expired = False + if api_key_obj.expires_at: + is_expired = api_key_obj.expires_at < datetime.now() + + return { + "id": api_key_obj.id, + "name": api_key_obj.name, + "description": api_key_obj.description, + "key_prefix": api_key_obj.key_prefix, + "is_active": api_key_obj.is_active, + "is_expired": is_expired, + "rate_limit_per_minute": api_key_obj.rate_limit_per_minute, + "allowed_endpoints": allowed_endpoints, + "total_requests": api_key_obj.total_requests, + "last_used_at": api_key_obj.last_used_at, + "created_at": api_key_obj.created_at, + "expires_at": api_key_obj.expires_at, + "revoked_at": api_key_obj.revoked_at, + "created_by": api_key_obj.created_by, + } From 2aafd525cdf6d3f5ff5cae1bfea63941910de101 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:23:10 +0300 Subject: [PATCH 14/36] refactor(api): update middleware and cors configuration --- api.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index bfb0e8d..c3d9e39 100644 --- a/api.py +++ b/api.py @@ -95,7 +95,11 @@ from utils.generic_functions import ( universign_envoyer, ) + +from middleware.security import SwaggerAuthMiddleware, ApiKeyMiddleware from core.dependencies import get_current_user +from config.cors_config import setup_cors +from routes.api_keys import router as api_keys_router if os.path.exists("/app"): LOGS_DIR = FilePath("/app/logs") @@ -162,13 +166,18 @@ app = FastAPI( openapi_tags=TAGS_METADATA, ) -app.add_middleware( +""" app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins, allow_methods=["GET", "POST", "PUT", "DELETE"], allow_headers=["*"], allow_credentials=True, -) +) """ + + +setup_cors(app, mode="open") +app.add_middleware(SwaggerAuthMiddleware) +app.add_middleware(ApiKeyMiddleware) app.include_router(auth_router) From f59e56490c20c2e5a07c1ce117efe341e43c80a8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:24:42 +0300 Subject: [PATCH 15/36] feat(api): add api keys router to middleware stack --- api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.py b/api.py index c3d9e39..1cab0ea 100644 --- a/api.py +++ b/api.py @@ -179,7 +179,7 @@ setup_cors(app, mode="open") app.add_middleware(SwaggerAuthMiddleware) app.add_middleware(ApiKeyMiddleware) - +app.include_router(api_keys_router) app.include_router(auth_router) app.include_router(sage_gateway_router) app.include_router(universign_router) From e0f08fd83ad61207e8cfe54a4d897b68cf996de8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:29:12 +0300 Subject: [PATCH 16/36] refactor(dependencies): rename require_role_hybrid to require_role for consistency --- core/dependencies.py | 2 +- routes/api_keys.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/dependencies.py b/core/dependencies.py index ff443a6..8bb30ab 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -126,7 +126,7 @@ async def get_current_user_optional_hybrid( return None -def require_role_hybrid(*allowed_roles: str): +def require_role(*allowed_roles: str): async def role_checker( request: Request, user: User = Depends(get_current_user_hybrid) ) -> User: diff --git a/routes/api_keys.py b/routes/api_keys.py index 27f0efc..1e753de 100644 --- a/routes/api_keys.py +++ b/routes/api_keys.py @@ -70,7 +70,7 @@ async def get_api_key( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): - """ Récupérer une clé API par son ID""" + """Récupérer une clé API par son ID""" service = ApiKeyService(session) api_key_obj = await service.get_by_id(key_id) From 9bd0f6245958d476555dbc2ffddd43e8e20387a8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 11:49:57 +0300 Subject: [PATCH 17/36] feat(api): add authentication to all endpoints and update OpenAPI schema --- api.py | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 1cab0ea..851608c 100644 --- a/api.py +++ b/api.py @@ -175,6 +175,28 @@ app = FastAPI( ) """ +def custom_openapi(): + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = app.openapi() + + # Définir deux schémas de sécurité + openapi_schema["components"]["securitySchemes"] = { + "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, + } + + openapi_schema["security"] = [{"HTTPBearer": []}, {"ApiKeyAuth": []}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + +# Après app = FastAPI(...), ajouter: +app.openapi = custom_openapi + + setup_cors(app, mode="open") app.add_middleware(SwaggerAuthMiddleware) app.add_middleware(ApiKeyMiddleware) @@ -189,6 +211,7 @@ app.include_router(entreprises_router) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) async def obtenir_clients( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -202,6 +225,7 @@ async def obtenir_clients( @app.get("/clients/{code}", response_model=ClientDetails, tags=["Clients"]) async def lire_client_detail( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -224,6 +248,7 @@ async def modifier_client( code: str, client_update: ClientUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -249,6 +274,7 @@ async def modifier_client( async def ajouter_client( client: ClientCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -273,6 +299,7 @@ async def ajouter_client( @app.get("/articles", response_model=List[Article], tags=["Articles"]) async def rechercher_articles( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -291,6 +318,7 @@ async def rechercher_articles( ) async def creer_article( article: ArticleCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -331,6 +359,7 @@ async def creer_article( async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -374,6 +403,7 @@ async def modifier_article( @app.get("/articles/{reference}", response_model=Article, tags=["Articles"]) async def lire_article( reference: str = Path(..., description="Référence de l'article"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -403,6 +433,7 @@ async def lire_article( @app.post("/devis", response_model=Devis, status_code=201, tags=["Devis"]) async def creer_devis( devis: DevisRequest, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -442,6 +473,7 @@ async def modifier_devis( id: str, devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -487,6 +519,7 @@ async def modifier_devis( async def creer_commande( commande: CommandeCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -536,6 +569,7 @@ async def modifier_commande( id: str, commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -584,6 +618,7 @@ async def lister_devis( inclure_lignes: bool = Query( True, description="Inclure les lignes de chaque devis" ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -600,6 +635,7 @@ async def lister_devis( @app.get("/devis/{id}", tags=["Devis"]) async def lire_devis( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -620,6 +656,7 @@ async def lire_devis( @app.get("/devis/{id}/pdf", tags=["Devis"]) async def telecharger_devis_pdf( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -642,6 +679,7 @@ async def telecharger_document_pdf( description="Type de document (0=Devis, 10=Commande, 30=Livraison, 60=Facture, 50=Avoir)", ), numero: str = Path(..., description="Numéro du document"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -698,6 +736,7 @@ async def envoyer_devis_email( id: str, request: EmailEnvoi, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -753,6 +792,7 @@ async def changer_statut_document( nouveau_statut: int = Query( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): document_type_sql = None @@ -869,6 +909,7 @@ async def changer_statut_document( @app.get("/commandes/{id}", tags=["Commandes"]) async def lire_commande( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -887,6 +928,7 @@ async def lire_commande( async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -902,6 +944,7 @@ async def lister_commandes( async def devis_vers_commande( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -946,6 +989,7 @@ async def devis_vers_commande( async def commande_vers_facture( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1047,6 +1091,7 @@ async def envoyer_emails_lot( async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1080,6 +1125,7 @@ async def relancer_devis_signature( id: str, relance: RelanceDevis, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1146,6 +1192,7 @@ class ContactClientResponse(BaseModel): @app.get("/devis/{id}/contact", response_model=ContactClientResponse, tags=["Devis"]) async def recuperer_contact_devis( id: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1173,6 +1220,7 @@ async def recuperer_contact_devis( async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1187,6 +1235,7 @@ async def lister_factures( @app.get("/factures/{numero}", tags=["Factures"]) async def lire_facture_detail( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1213,6 +1262,7 @@ class RelanceFacture(BaseModel): async def creer_facture( facture: FactureCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1262,6 +1312,7 @@ async def modifier_facture( id: str, facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1331,6 +1382,7 @@ async def relancer_facture( id: str, relance: RelanceFacture, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1401,6 +1453,7 @@ async def journal_emails( destinataire: Optional[str] = Query(None), limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1436,6 +1489,7 @@ async def journal_emails( async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1592,6 +1646,7 @@ async def supprimer_template( @app.post("/templates/emails/preview", tags=["Emails"]) async def previsualiser_email( preview: TemplatePreview, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): if preview.template_id not in templates_email_db: @@ -1630,6 +1685,7 @@ async def previsualiser_email( @app.get("/prospects", tags=["Prospects"]) async def rechercher_prospects( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1643,6 +1699,7 @@ async def rechercher_prospects( @app.get("/prospects/{code}", tags=["Prospects"]) async def lire_prospect( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1662,6 +1719,7 @@ async def lire_prospect( ) async def rechercher_fournisseurs( query: Optional[str] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1683,6 +1741,7 @@ async def rechercher_fournisseurs( async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1712,6 +1771,7 @@ async def modifier_fournisseur( code: str, fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1734,6 +1794,7 @@ async def modifier_fournisseur( @app.get("/fournisseurs/{code}", tags=["Fournisseurs"]) async def lire_fournisseur( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1752,6 +1813,7 @@ async def lire_fournisseur( async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1765,6 +1827,7 @@ async def lister_avoirs( @app.get("/avoirs/{numero}", tags=["Avoirs"]) async def lire_avoir( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1783,6 +1846,7 @@ async def lire_avoir( async def creer_avoir( avoir: AvoirCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1830,6 +1894,7 @@ async def modifier_avoir( id: str, avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1875,6 +1940,7 @@ async def modifier_avoir( async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1888,6 +1954,7 @@ async def lister_livraisons( @app.get("/livraisons/{numero}", tags=["Livraisons"]) async def lire_livraison( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1906,6 +1973,7 @@ async def lire_livraison( async def creer_livraison( livraison: LivraisonCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -1959,6 +2027,7 @@ async def modifier_livraison( id: str, livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2004,6 +2073,7 @@ async def modifier_livraison( async def livraison_vers_facture( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2047,6 +2117,7 @@ async def livraison_vers_facture( async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2107,6 +2178,7 @@ async def devis_vers_facture_direct( async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2178,6 +2250,7 @@ async def commande_vers_livraison( ) async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2203,6 +2276,7 @@ async def lister_familles( ) async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2238,6 +2312,7 @@ async def lire_famille( ) async def creer_famille( famille: FamilleCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2281,6 +2356,7 @@ async def creer_famille( ) async def creer_entree_stock( entree: EntreeStock, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2317,6 +2393,7 @@ async def creer_entree_stock( ) async def creer_sortie_stock( sortie: SortieStock, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2352,6 +2429,7 @@ async def creer_sortie_stock( ) async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2384,6 +2462,7 @@ async def lire_mouvement_stock( summary="Statistiques sur les familles", ) async def statistiques_familles( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2492,6 +2571,7 @@ async def statistiques_utilisateurs(session: AsyncSession = Depends(get_session) async def creer_contact( numero: str, contact: ContactCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2524,6 +2604,7 @@ async def creer_contact( @app.get("/tiers/{numero}/contacts", response_model=List[Contact], tags=["Contacts"]) async def lister_contacts( numero: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2542,6 +2623,7 @@ async def lister_contacts( async def obtenir_contact( numero: str, contact_numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2567,6 +2649,7 @@ async def modifier_contact( numero: str, contact_numero: int, contact: ContactUpdate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2598,6 +2681,7 @@ async def modifier_contact( async def supprimer_contact( numero: str, contact_numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2612,6 +2696,7 @@ async def supprimer_contact( async def definir_contact_defaut( numero: str, contact_numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2633,6 +2718,7 @@ async def obtenir_tiers( description="Filtre par type: 0/client, 1/fournisseur, 2/prospect, 3/all ou strings", ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2647,6 +2733,7 @@ async def obtenir_tiers( @app.get("/tiers/{code}", response_model=TiersDetails, tags=["Tiers"]) async def lire_tiers_detail( code: str, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2684,6 +2771,7 @@ async def lister_collaborateurs( actifs_seulement: bool = Query( True, description="Exclure les collaborateurs en sommeil" ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste tous les collaborateurs""" @@ -2702,6 +2790,7 @@ async def lister_collaborateurs( ) async def lire_collaborateur_detail( numero: int, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Lit un collaborateur par son numéro""" @@ -2728,6 +2817,7 @@ async def lire_collaborateur_detail( ) async def creer_collaborateur( collaborateur: CollaborateurCreate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Crée un nouveau collaborateur""" @@ -2754,6 +2844,7 @@ async def creer_collaborateur( async def modifier_collaborateur( numero: int, collaborateur: CollaborateurUpdate, + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Modifie un collaborateur existant""" @@ -2776,6 +2867,7 @@ async def modifier_collaborateur( @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) async def obtenir_informations_societe( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2795,6 +2887,7 @@ async def obtenir_informations_societe( @app.get("/societe/logo", tags=["Société"]) async def obtenir_logo_societe( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Retourne le logo en tant qu'image directe""" @@ -2819,6 +2912,7 @@ async def obtenir_logo_societe( @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) async def preview_societe( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Page HTML pour visualiser les infos société avec logo""" @@ -2892,6 +2986,7 @@ async def preview_societe( async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2915,6 +3010,7 @@ async def valider_facture( async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2938,6 +3034,7 @@ async def devalider_facture( async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -2958,6 +3055,7 @@ async def regler_facture( numero_facture: str, reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3001,6 +3099,7 @@ async def regler_facture( async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3039,6 +3138,7 @@ async def regler_factures_multiple( async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3063,6 +3163,7 @@ async def get_reglements_client( date_fin: Optional[datetime] = Query(None, description="Date fin"), inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3087,6 +3188,7 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) async def get_journaux_banque( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: @@ -3099,6 +3201,7 @@ async def get_journaux_banque( @app.get("/reglements/modes", tags=["Référentiels"]) async def get_modes_reglement( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des modes de règlement disponibles dans Sage""" @@ -3112,6 +3215,7 @@ async def get_modes_reglement( @app.get("/devises", tags=["Référentiels"]) async def get_devises( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des devises disponibles dans Sage""" @@ -3125,6 +3229,7 @@ async def get_devises( @app.get("/journaux/tresorerie", tags=["Référentiels"]) async def get_journaux_tresorerie( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des journaux de trésorerie (banque + caisse)""" @@ -3143,6 +3248,7 @@ async def get_comptes_generaux( None, description="client | fournisseur | banque | caisse | tva | produit | charge", ), + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des comptes généraux""" @@ -3156,6 +3262,7 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) async def get_tva_taux( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des taux de TVA""" @@ -3169,6 +3276,7 @@ async def get_tva_taux( @app.get("/parametres/encaissement", tags=["Référentiels"]) async def get_parametres_encaissement( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Paramètres TVA sur encaissement""" @@ -3215,6 +3323,7 @@ async def get_reglement_detail(rg_no): @app.get("/health", tags=["System"]) async def health_check( + user: User = Depends(get_current_user), sage: SageGatewayClient = Depends(get_sage_client_for_user), ): gateway_health = sage.health() @@ -3236,9 +3345,23 @@ async def health_check( async def root(): return { "api": "Sage 100c Dataven - VPS Linux", - "version": "2.0.0", - "documentation": "/docs", + "version": "3.0.0", + "documentation": "/docs (authentification requise)", "health": "/health", + "authentication": { + "methods": [ + { + "type": "JWT", + "header": "Authorization: Bearer ", + "endpoint": "/api/auth/login", + }, + { + "type": "API Key", + "header": "X-API-Key: sdk_live_xxx", + "endpoint": "/api/api-keys", + }, + ] + }, } From cc0062b3bc716f784f17f5df33b5a14787621c3a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:11:20 +0300 Subject: [PATCH 18/36] refactor(security): improve security management script with better logging and structure --- api.py | 2 - routes/universign.py | 1 - scripts/manage_security.py | 298 ++++++++++++++++++------------- tools/extract_pydantic_models.py | 2 - 4 files changed, 177 insertions(+), 126 deletions(-) diff --git a/api.py b/api.py index 851608c..0196fa7 100644 --- a/api.py +++ b/api.py @@ -181,7 +181,6 @@ def custom_openapi(): openapi_schema = app.openapi() - # Définir deux schémas de sécurité openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, @@ -193,7 +192,6 @@ def custom_openapi(): return app.openapi_schema -# Après app = FastAPI(...), ajouter: app.openapi = custom_openapi diff --git a/routes/universign.py b/routes/universign.py index 2bf0e7a..bada5aa 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -35,7 +35,6 @@ logger = logging.getLogger(__name__) router = APIRouter( prefix="/universign", tags=["Universign"], - # dependencies=[Depends(get_current_user)] ) sync_service = UniversignSyncService( diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 1f234b9..3745f53 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,86 +1,94 @@ import asyncio import sys -from pathlib import Path -import argparse - -sys.path.insert(0, str(Path(__file__).parent.parent)) - from database import get_session -from database.models.api_key import SwaggerUser +from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password from sqlalchemy import select +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import argparse +from datetime import datetime import logging -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" - async with get_session() as session: + + async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) existing = result.scalar_one_or_none() if existing: - logger.error(f" L'utilisateur {username} existe déjà") + logger.error(f" L'utilisateur '{username}' existe déjà") return - user = SwaggerUser( + swagger_user = SwaggerUser( username=username, hashed_password=hash_password(password), full_name=full_name or username, is_active=True, ) - session.add(user) + session.add(swagger_user) await session.commit() logger.info(f" Utilisateur Swagger créé: {username}") - print("\n Utilisateur créé avec succès") - print(f" Username: {username}") - print(" Accès: https://votre-serveur/docs") + logger.info(f" Nom complet: {swagger_user.full_name}") + logger.info(f" Actif: {swagger_user.is_active}") + + break async def list_swagger_users(): - """Lister les utilisateurs Swagger""" - async with get_session() as session: + """Lister tous les utilisateurs Swagger""" + + async for session in get_session(): result = await session.execute(select(SwaggerUser)) users = result.scalars().all() if not users: - print("Aucun utilisateur Swagger trouvé") - return + logger.info(" Aucun utilisateur Swagger") + break + + logger.info(f" {len(users)} utilisateur(s) Swagger:\n") - print(f"\n {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = " Actif" if user.is_active else " Inactif" - print(f" • {user.username:<20} {status}") - if user.full_name: - print(f" Nom: {user.full_name}") - if user.last_login: - print(f" Dernière connexion: {user.last_login}") - print() + status = "" if user.is_active else "" + logger.info(f" {status} {user.username}") + logger.info(f" Nom: {user.full_name}") + logger.info(f" Créé: {user.created_at}") + logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}") + logger.info("") + + break async def delete_swagger_user(username: str): """Supprimer un utilisateur Swagger""" - async with get_session() as session: + + async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) user = result.scalar_one_or_none() if not user: - logger.error(f" Utilisateur {username} introuvable") - return + logger.error(f" Utilisateur '{username}' introuvable") + break await session.delete(user) await session.commit() - logger.info(f"🗑️ Utilisateur supprimé: {username}") + logger.info(f" Utilisateur Swagger supprimé: {username}") + break async def create_api_key( @@ -91,137 +99,180 @@ async def create_api_key( endpoints: list = None, ): """Créer une clé API""" - async with get_session() as session: + + async for session in get_session(): service = ApiKeyService(session) api_key_obj, api_key_plain = await service.create_api_key( name=name, description=description, - created_by="CLI", + created_by="cli", expires_in_days=expires_in_days, rate_limit_per_minute=rate_limit, allowed_endpoints=endpoints, ) - print("\n Clé API créée avec succès\n") - print(f" ID: {api_key_obj.id}") - print(f" Nom: {name}") - print(f" Clé: {api_key_plain}") - print(f" Préfixe: {api_key_obj.key_prefix}") - print(f" Rate limit: {rate_limit} req/min") - print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}") - print("\n IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !\n") + logger.info("=" * 60) + logger.info(" Clé API créée avec succès") + logger.info("=" * 60) + logger.info(f" ID: {api_key_obj.id}") + logger.info(f" Nom: {api_key_obj.name}") + logger.info(f" Clé: {api_key_plain}") + logger.info(f" Préfixe: {api_key_obj.key_prefix}") + logger.info(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") + logger.info(f" Créée le: {api_key_obj.created_at}") + logger.info(f" Expire le: {api_key_obj.expires_at}") + + if api_key_obj.allowed_endpoints: + logger.info( + f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" + ) + else: + logger.info(" Endpoints autorisés: Tous") + + logger.info("=" * 60) + logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info("=" * 60) + + break async def list_api_keys(): - """Lister les clés API""" - async with get_session() as session: + """Lister toutes les clés API""" + + async for session in get_session(): service = ApiKeyService(session) keys = await service.list_api_keys() if not keys: - print("Aucune clé API trouvée") - return + logger.info(" Aucune clé API") + break + + logger.info(f" {len(keys)} clé(s) API:\n") - print(f"\n {len(keys)} clé(s) API:\n") for key in keys: - status = "" if key.is_active else "" - expired = ( - "⏰ Expirée" - if key.expires_at and key.expires_at < datetime.now() + status = ( + "" + if key.is_active + and (not key.expires_at or key.expires_at > datetime.now()) else "" ) - print(f" {status} {key.name:<30} ({key.key_prefix}...)") - print(f" ID: {key.id}") - print(f" Requêtes: {key.total_requests}") - print(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") - if expired: - print(f" {expired}") - print() + logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") + logger.info(f" ID: {key.id}") + logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") + logger.info(f" Requêtes: {key.total_requests}") + logger.info(f" Créée le: {key.created_at}") + logger.info(f" Expire le: {key.expires_at or 'Jamais'}") + logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + + if key.allowed_endpoints: + logger.info( + f" Endpoints: {', '.join(key.allowed_endpoints[:3])}..." + ) + + logger.info("") + + break async def revoke_api_key(key_id: str): """Révoquer une clé API""" - async with get_session() as session: + + async for session in get_session(): service = ApiKeyService(session) - api_key = await service.get_by_id(key_id) - if not api_key: - logger.error(f" Clé {key_id} introuvable") - return + result = await session.execute(select(ApiKey).where(ApiKey.id == key_id)) + key = result.scalar_one_or_none() - success = await service.revoke_api_key(key_id) + if not key: + logger.error(f" Clé API '{key_id}' introuvable") + break - if success: - logger.info(f" Clé révoquée: {api_key.name}") - print(f"\n Clé '{api_key.name}' révoquée avec succès") - else: - logger.error(" Erreur lors de la révocation") + key.is_active = False + await session.commit() + + logger.info(f" Clé API révoquée: {key.name}") + logger.info(f" ID: {key.id}") + logger.info(f" Préfixe: {key.key_prefix}") + + break -async def verify_api_key_cmd(api_key: str): +async def verify_api_key(api_key: str): """Vérifier une clé API""" - async with get_session() as session: - service = ApiKeyService(session) - api_key_obj = await service.verify_api_key(api_key) - if api_key_obj: - print("\n Clé API valide\n") - print(f" Nom: {api_key_obj.name}") - print(f" ID: {api_key_obj.id}") - print(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") - print(f" Requêtes: {api_key_obj.total_requests}") - print(f" Expire le: {api_key_obj.expires_at or 'Jamais'}\n") - else: - print("\n Clé API invalide, expirée ou révoquée\n") + async for session in get_session(): + service = ApiKeyService(session) + + key = await service.verify_api_key(api_key) + + if not key: + logger.error(" Clé API invalide ou expirée") + break + + logger.info("=" * 60) + logger.info(" Clé API valide") + logger.info("=" * 60) + logger.info(f" Nom: {key.name}") + logger.info(f" ID: {key.id}") + logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") + logger.info(f" Requêtes totales: {key.total_requests}") + logger.info(f" Expire le: {key.expires_at or 'Jamais'}") + logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + logger.info("=" * 60) + + break async def main(): parser = argparse.ArgumentParser( - description="Gestion de la sécurité Sage Dataven API" + description="Gestion des utilisateurs Swagger et clés API" ) - subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") swagger_parser = subparsers.add_parser( - "swagger", help="Gestion utilisateurs Swagger" + "swagger", help="Gestion des utilisateurs Swagger" ) - swagger_subparsers = swagger_parser.add_subparsers(dest="action") + swagger_subparsers = swagger_parser.add_subparsers(dest="swagger_command") - swagger_add = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") - swagger_add.add_argument("username", help="Nom d'utilisateur") - swagger_add.add_argument("password", help="Mot de passe") - swagger_add.add_argument("--full-name", help="Nom complet") + add_parser = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") + add_parser.add_argument("username", help="Nom d'utilisateur") + add_parser.add_argument("password", help="Mot de passe") + add_parser.add_argument("--full-name", help="Nom complet (optionnel)") swagger_subparsers.add_parser("list", help="Lister les utilisateurs") - swagger_delete = swagger_subparsers.add_parser( + delete_parser = swagger_subparsers.add_parser( "delete", help="Supprimer un utilisateur" ) - swagger_delete.add_argument("username", help="Nom d'utilisateur") + delete_parser.add_argument("username", help="Nom d'utilisateur") - apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") - apikey_subparsers = apikey_parser.add_subparsers(dest="action") + apikey_parser = subparsers.add_parser("apikey", help="Gestion des clés API") + apikey_subparsers = apikey_parser.add_subparsers(dest="apikey_command") - apikey_create = apikey_subparsers.add_parser("create", help="Créer une clé API") - apikey_create.add_argument("name", help="Nom de la clé") - apikey_create.add_argument("--description", help="Description") - apikey_create.add_argument( - "--days", type=int, default=365, help="Expiration en jours" + create_parser = apikey_subparsers.add_parser("create", help="Créer une clé API") + create_parser.add_argument("name", help="Nom de la clé") + create_parser.add_argument("--description", help="Description (optionnel)") + create_parser.add_argument( + "--days", type=int, default=365, help="Jours avant expiration (défaut: 365)" ) - apikey_create.add_argument( - "--rate-limit", type=int, default=60, help="Limite req/min" + create_parser.add_argument( + "--rate-limit", type=int, default=60, help="Requêtes par minute (défaut: 60)" + ) + create_parser.add_argument( + "--endpoints", + nargs="+", + help="Endpoints autorisés (ex: /clients /articles)", ) - apikey_create.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") - apikey_subparsers.add_parser("list", help="Lister les clés") + apikey_subparsers.add_parser("list", help="Lister les clés API") - apikey_revoke = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") - apikey_revoke.add_argument("key_id", help="ID de la clé") + revoke_parser = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") + revoke_parser.add_argument("key_id", help="ID de la clé") - apikey_verify = apikey_subparsers.add_parser("verify", help="Vérifier une clé") - apikey_verify.add_argument("api_key", help="Clé API à vérifier") + verify_parser = apikey_subparsers.add_parser("verify", help="Vérifier une clé") + verify_parser.add_argument("api_key", help="Clé API complète") args = parser.parse_args() @@ -230,35 +281,40 @@ async def main(): return if args.command == "swagger": - if args.action == "add": + if args.swagger_command == "add": await add_swagger_user(args.username, args.password, args.full_name) - elif args.action == "list": + elif args.swagger_command == "list": await list_swagger_users() - elif args.action == "delete": + elif args.swagger_command == "delete": await delete_swagger_user(args.username) else: swagger_parser.print_help() elif args.command == "apikey": - if args.action == "create": + if args.apikey_command == "create": await create_api_key( - args.name, - args.description, - args.days, - args.rate_limit, - args.endpoints, + name=args.name, + description=args.description, + expires_in_days=args.days, + rate_limit=args.rate_limit, + endpoints=args.endpoints, ) - elif args.action == "list": + elif args.apikey_command == "list": await list_api_keys() - elif args.action == "revoke": + elif args.apikey_command == "revoke": await revoke_api_key(args.key_id) - elif args.action == "verify": - await verify_api_key_cmd(args.api_key) + elif args.apikey_command == "verify": + await verify_api_key(args.api_key) else: apikey_parser.print_help() if __name__ == "__main__": - from datetime import datetime - - asyncio.run(main()) + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("\n👋 Interrupted") + sys.exit(0) + except Exception as e: + logger.error(f" Erreur: {e}") + sys.exit(1) diff --git a/tools/extract_pydantic_models.py b/tools/extract_pydantic_models.py index 595e15f..5718790 100644 --- a/tools/extract_pydantic_models.py +++ b/tools/extract_pydantic_models.py @@ -24,7 +24,6 @@ for node in tree.body: continue other_nodes.append(node) -# --- Extraction des classes --- imports = """ from pydantic import BaseModel, Field from typing import Optional, List @@ -44,7 +43,6 @@ for cls in pydantic_classes: print(f"✅ Modèle extrait : {class_name} → {file_path}") -# --- Réécriture du fichier source sans les modèles --- new_tree = ast.Module(body=other_nodes, type_ignores=[]) new_source = ast.unparse(new_tree) From dd65ae4d9625aa06cea5c060a30e0b4be5a59850 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:17:52 +0300 Subject: [PATCH 19/36] style: remove emoji from log messages --- routes/auth.py | 2 +- scripts/manage_security.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/routes/auth.py b/routes/auth.py index d6e6761..d401d86 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -510,7 +510,7 @@ async def logout( token_record.revoked_at = datetime.now() await session.commit() - logger.info(f"👋 Déconnexion: {user.email}") + logger.info(f" Déconnexion: {user.email}") return {"success": True, "message": "Déconnexion réussie"} diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 3745f53..a495033 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,6 +1,6 @@ import asyncio import sys -from database import get_session +from database.db_config import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password @@ -313,7 +313,7 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - logger.info("\n👋 Interrupted") + logger.info("\n Interrupted") sys.exit(0) except Exception as e: logger.error(f" Erreur: {e}") From e51a5e0a0b48480296d9b44fbd66dbaa8c6a3dff Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:19:51 +0300 Subject: [PATCH 20/36] refactor(database): update import to use direct get_session import --- scripts/manage_security.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index a495033..be2d256 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,6 +1,6 @@ import asyncio import sys -from database.db_config import get_session +from database import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password From cce1cdf76a31175a4625c81fde51ee555076ee69 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:32:49 +0300 Subject: [PATCH 21/36] refactor(scripts): improve manage_security.py organization and error handling --- scripts/manage_security.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index be2d256..ce2a3e0 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,17 +1,20 @@ import asyncio import sys +from pathlib import Path +import argparse +from datetime import datetime +import logging + from database import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password from sqlalchemy import select -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent)) +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent +sys.path.insert(0, str(parent_dir)) -import argparse -from datetime import datetime -import logging logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) @@ -131,7 +134,7 @@ async def create_api_key( logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) - logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") logger.info("=" * 60) break @@ -317,4 +320,7 @@ if __name__ == "__main__": sys.exit(0) except Exception as e: logger.error(f" Erreur: {e}") + import traceback + + traceback.print_exc() sys.exit(1) From 72d1ac58d110d602270d7c08bdff20d80eeedfc1 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 12:40:56 +0300 Subject: [PATCH 22/36] refactor(security): reorganize imports and improve logging message --- scripts/manage_security.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index ce2a3e0..b1c14de 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,24 +1,25 @@ import asyncio import sys +import os from pathlib import Path -import argparse -from datetime import datetime -import logging - -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password -from sqlalchemy import select current_dir = Path(__file__).resolve().parent parent_dir = current_dir.parent sys.path.insert(0, str(parent_dir)) +import argparse +from datetime import datetime +import logging logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password, verify_password +from sqlalchemy import select + async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -131,7 +132,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(" Endpoints autorisés: Tous") + logger.info(f" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") From 022149c23786092b4aedda6aaa1ed2e50eb6219c Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:46:27 +0300 Subject: [PATCH 23/36] refactor(api): replace get_sage_client_for_user with get_current_user for dependency injection --- api.py | 174 ++++++++++++++++++------------------- scripts/manage_security.py | 22 +++-- 2 files changed, 97 insertions(+), 99 deletions(-) diff --git a/api.py b/api.py index 0196fa7..7fe1356 100644 --- a/api.py +++ b/api.py @@ -210,7 +210,7 @@ app.include_router(entreprises_router) async def obtenir_clients( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: clients = sage.lister_clients(filtre=query or "") @@ -224,7 +224,7 @@ async def obtenir_clients( async def lire_client_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: client = sage.lire_client(code) @@ -247,7 +247,7 @@ async def modifier_client( client_update: ClientUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.modifier_client(code, client_update.dict(exclude_none=True)) @@ -273,7 +273,7 @@ async def ajouter_client( client: ClientCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: nouveau_client = sage.creer_client(client.model_dump(mode="json")) @@ -298,7 +298,7 @@ async def ajouter_client( async def rechercher_articles( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: articles = sage.lister_articles(filtre=query or "") @@ -317,7 +317,7 @@ async def rechercher_articles( async def creer_article( article: ArticleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: if not article.reference or not article.designation: @@ -358,7 +358,7 @@ async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: article_data = article.dict(exclude_unset=True) @@ -402,7 +402,7 @@ async def modifier_article( async def lire_article( reference: str = Path(..., description="Référence de l'article"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: article = sage.lire_article(reference) @@ -432,7 +432,7 @@ async def lire_article( async def creer_devis( devis: DevisRequest, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis_data = { @@ -472,7 +472,7 @@ async def modifier_devis( devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -518,7 +518,7 @@ async def creer_commande( commande: CommandeCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commande_data = { @@ -568,7 +568,7 @@ async def modifier_commande( commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -617,7 +617,7 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis_list = sage.lister_devis( @@ -634,7 +634,7 @@ async def lister_devis( async def lire_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis = sage.lire_devis(id) @@ -655,7 +655,7 @@ async def lire_devis( async def telecharger_devis_pdf( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) @@ -678,7 +678,7 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: types_labels = { @@ -735,7 +735,7 @@ async def envoyer_devis_email( request: EmailEnvoi, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: tous_destinataires = [request.destinataire] + request.cc + request.cci @@ -791,7 +791,7 @@ async def changer_statut_document( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): document_type_sql = None document_type_code = None @@ -908,7 +908,7 @@ async def changer_statut_document( async def lire_commande( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commande = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -927,7 +927,7 @@ async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commandes = sage.lister_commandes(limit=limit, statut=statut) @@ -943,7 +943,7 @@ async def devis_vers_commande( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.transformer_document( @@ -988,7 +988,7 @@ async def commande_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.transformer_document( @@ -1090,7 +1090,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: remise_max = sage.lire_remise_max_client(client_id) @@ -1124,7 +1124,7 @@ async def relancer_devis_signature( relance: RelanceDevis, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis = sage.lire_devis(id) @@ -1191,7 +1191,7 @@ class ContactClientResponse(BaseModel): async def recuperer_contact_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis = sage.lire_devis(id) @@ -1219,7 +1219,7 @@ async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: factures = sage.lister_factures(limit=limit, statut=statut) @@ -1234,7 +1234,7 @@ async def lister_factures( async def lire_facture_detail( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: facture = sage.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -1261,7 +1261,7 @@ async def creer_facture( facture: FactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: facture_data = { @@ -1311,7 +1311,7 @@ async def modifier_facture( facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -1381,7 +1381,7 @@ async def relancer_facture( relance: RelanceFacture, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: facture = sage.lire_document(id, TypeDocumentSQL.FACTURE) @@ -1452,7 +1452,7 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): query = select(EmailLog) @@ -1488,7 +1488,7 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): query = select(EmailLog) if statut: @@ -1645,7 +1645,7 @@ async def supprimer_template( async def previsualiser_email( preview: TemplatePreview, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -1684,7 +1684,7 @@ async def previsualiser_email( async def rechercher_prospects( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: prospects = sage.lister_prospects(filtre=query or "") @@ -1698,7 +1698,7 @@ async def rechercher_prospects( async def lire_prospect( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: prospect = sage.lire_prospect(code) @@ -1718,7 +1718,7 @@ async def lire_prospect( async def rechercher_fournisseurs( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: fournisseurs = sage.lister_fournisseurs(filtre=query or "") @@ -1740,7 +1740,7 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: nouveau_fournisseur = sage.creer_fournisseur(fournisseur.dict()) @@ -1770,7 +1770,7 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.modifier_fournisseur( @@ -1793,7 +1793,7 @@ async def modifier_fournisseur( async def lire_fournisseur( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: fournisseur = sage.lire_fournisseur(code) @@ -1812,7 +1812,7 @@ async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: avoirs = sage.lister_avoirs(limit=limit, statut=statut) @@ -1826,7 +1826,7 @@ async def lister_avoirs( async def lire_avoir( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: avoir = sage.lire_document(numero, TypeDocumentSQL.BON_AVOIR) @@ -1845,7 +1845,7 @@ async def creer_avoir( avoir: AvoirCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: avoir_data = { @@ -1893,7 +1893,7 @@ async def modifier_avoir( avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -1939,7 +1939,7 @@ async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: livraisons = sage.lister_livraisons(limit=limit, statut=statut) @@ -1953,7 +1953,7 @@ async def lister_livraisons( async def lire_livraison( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: livraison = sage.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) @@ -1972,7 +1972,7 @@ async def creer_livraison( livraison: LivraisonCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: livraison_data = { @@ -2026,7 +2026,7 @@ async def modifier_livraison( livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: update_data = {} @@ -2072,7 +2072,7 @@ async def livraison_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.transformer_document( @@ -2116,7 +2116,7 @@ async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: devis_existant = sage.lire_devis(id) @@ -2177,7 +2177,7 @@ async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: commande_existante = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -2249,7 +2249,7 @@ async def commande_vers_livraison( async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: familles = sage.lister_familles(filtre or "") @@ -2275,7 +2275,7 @@ async def lister_familles( async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: famille = sage.lire_famille(code) @@ -2311,7 +2311,7 @@ async def lire_famille( async def creer_famille( famille: FamilleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: if not famille.code or not famille.intitule: @@ -2355,7 +2355,7 @@ async def creer_famille( async def creer_entree_stock( entree: EntreeStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: entree_data = entree.dict() @@ -2392,7 +2392,7 @@ async def creer_entree_stock( async def creer_sortie_stock( sortie: SortieStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: sortie_data = sortie.dict() @@ -2428,7 +2428,7 @@ async def creer_sortie_stock( async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: mouvement = sage.lire_mouvement_stock(numero) @@ -2461,7 +2461,7 @@ async def lire_mouvement_stock( ) async def statistiques_familles( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: stats = sage.get_stats_familles() @@ -2570,7 +2570,7 @@ async def creer_contact( numero: str, contact: ContactCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: try: @@ -2603,7 +2603,7 @@ async def creer_contact( async def lister_contacts( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: contacts = sage.lister_contacts(numero) @@ -2622,7 +2622,7 @@ async def obtenir_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: contact = sage.obtenir_contact(numero, contact_numero) @@ -2648,7 +2648,7 @@ async def modifier_contact( contact_numero: int, contact: ContactUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: contact_existant = sage.obtenir_contact(numero, contact_numero) @@ -2680,7 +2680,7 @@ async def supprimer_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: sage.supprimer_contact(numero, contact_numero) @@ -2695,7 +2695,7 @@ async def definir_contact_defaut( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.definir_contact_defaut(numero, contact_numero) @@ -2717,7 +2717,7 @@ async def obtenir_tiers( ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: type_normalise = normaliser_type_tiers(type_tiers) @@ -2732,7 +2732,7 @@ async def obtenir_tiers( async def lire_tiers_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: tiers = sage.lire_tiers(code) @@ -2770,7 +2770,7 @@ async def lister_collaborateurs( True, description="Exclure les collaborateurs en sommeil" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste tous les collaborateurs""" try: @@ -2789,7 +2789,7 @@ async def lister_collaborateurs( async def lire_collaborateur_detail( numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Lit un collaborateur par son numéro""" try: @@ -2816,7 +2816,7 @@ async def lire_collaborateur_detail( async def creer_collaborateur( collaborateur: CollaborateurCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Crée un nouveau collaborateur""" try: @@ -2843,7 +2843,7 @@ async def modifier_collaborateur( numero: int, collaborateur: CollaborateurUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Modifie un collaborateur existant""" try: @@ -2866,7 +2866,7 @@ async def modifier_collaborateur( @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) async def obtenir_informations_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: societe = sage.lire_informations_societe() @@ -2886,7 +2886,7 @@ async def obtenir_informations_societe( @app.get("/societe/logo", tags=["Société"]) async def obtenir_logo_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Retourne le logo en tant qu'image directe""" try: @@ -2911,7 +2911,7 @@ async def obtenir_logo_societe( @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) async def preview_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Page HTML pour visualiser les infos société avec logo""" try: @@ -2985,7 +2985,7 @@ async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.valider_facture(numero_facture) @@ -3009,7 +3009,7 @@ async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.devalider_facture(numero_facture) @@ -3033,7 +3033,7 @@ async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_statut_validation(numero_facture) @@ -3054,7 +3054,7 @@ async def regler_facture( reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.regler_facture( @@ -3098,7 +3098,7 @@ async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.regler_factures_client( @@ -3137,7 +3137,7 @@ async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_reglements_facture(numero_facture) @@ -3162,7 +3162,7 @@ async def get_reglements_client( inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_reglements_client( @@ -3187,7 +3187,7 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) async def get_journaux_banque( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): try: resultat = sage.get_journaux_banque() @@ -3200,7 +3200,7 @@ async def get_journaux_banque( @app.get("/reglements/modes", tags=["Référentiels"]) async def get_modes_reglement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des modes de règlement disponibles dans Sage""" try: @@ -3214,7 +3214,7 @@ async def get_modes_reglement( @app.get("/devises", tags=["Référentiels"]) async def get_devises( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des devises disponibles dans Sage""" try: @@ -3228,7 +3228,7 @@ async def get_devises( @app.get("/journaux/tresorerie", tags=["Référentiels"]) async def get_journaux_tresorerie( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des journaux de trésorerie (banque + caisse)""" try: @@ -3247,7 +3247,7 @@ async def get_comptes_generaux( description="client | fournisseur | banque | caisse | tva | produit | charge", ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des comptes généraux""" try: @@ -3261,7 +3261,7 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) async def get_tva_taux( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Liste des taux de TVA""" try: @@ -3275,7 +3275,7 @@ async def get_tva_taux( @app.get("/parametres/encaissement", tags=["Référentiels"]) async def get_parametres_encaissement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): """Paramètres TVA sur encaissement""" try: @@ -3322,7 +3322,7 @@ async def get_reglement_detail(rg_no): @app.get("/health", tags=["System"]) async def health_check( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_sage_client_for_user), + sage: SageGatewayClient = Depends(get_current_user), ): gateway_health = sage.health() diff --git a/scripts/manage_security.py b/scripts/manage_security.py index b1c14de..066e92c 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,25 +1,23 @@ import asyncio import sys -import os from pathlib import Path - -current_dir = Path(__file__).resolve().parent -parent_dir = current_dir.parent -sys.path.insert(0, str(parent_dir)) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password +from sqlalchemy import select import argparse from datetime import datetime import logging +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent +sys.path.insert(0, str(parent_dir)) + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password, verify_password -from sqlalchemy import select - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -132,7 +130,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(f" Endpoints autorisés: Tous") + logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") From 5b584bf9692aafaabd8f453b7cf5b96cdb36804d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:51:09 +0300 Subject: [PATCH 24/36] refactor(security): improve auth middleware and logging --- middleware/security.py | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 137e7dd..c6e75e7 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -34,7 +34,7 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: logger.info(f" Accès Swagger autorisé (DB): {username}") return True - logger.warning(f" Tentative d'accès Swagger refusée: {username}") + logger.warning(f"Tentative d'accès Swagger refusée: {username}") return False except Exception as e: @@ -43,6 +43,7 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: class SwaggerAuthMiddleware: + def __init__(self, app): self.app = app @@ -54,7 +55,7 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - protected_paths = ["/docs", "/redoc", "/openapi.json"] + protected_paths = ["/docs", "/redoc"] if any(path.startswith(protected_path) for protected_path in protected_paths): auth_header = request.headers.get("Authorization") @@ -104,6 +105,7 @@ class SwaggerAuthMiddleware: class ApiKeyMiddleware: + def __init__(self, app): self.app = app @@ -115,21 +117,24 @@ class ApiKeyMiddleware: request = Request(scope, receive=receive) path = request.url.path - excluded_paths = [ + public_exact_paths = [ + "/", + "/health", "/docs", "/redoc", "/openapi.json", - "/health", - "/", - "/auth/login", - "/auth/register", - "/auth/verify-email", - "/auth/reset-password", - "/auth/request-reset", - "/auth/refresh", ] - if any(path.startswith(excluded_path) for excluded_path in excluded_paths): + public_path_prefixes = [ + "/api/v1/auth/", + ] + + is_public = path in public_exact_paths or any( + path.startswith(prefix) for prefix in public_path_prefixes + ) + + if is_public: + logger.debug(f"Chemin public: {path}") await self.app(scope, receive, send) return @@ -140,12 +145,12 @@ class ApiKeyMiddleware: has_api_key = api_key is not None if has_jwt: - logger.debug(f" JWT détecté pour {path}") + logger.debug(f"🔑 JWT détecté pour {path}") await self.app(scope, receive, send) return elif has_api_key: - logger.debug(f" API Key détectée pour {path}") + logger.debug(f"🔑 API Key détectée pour {path}") from services.api_key import ApiKeyService @@ -218,8 +223,9 @@ class ApiKeyMiddleware: response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ - "detail": "Authentification requise", + "detail": "Authentification requise (JWT ou API Key)", "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", + "endpoint": path, }, headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, ) @@ -233,4 +239,5 @@ def get_api_key_from_request(request: Request) -> Optional: def get_auth_method(request: Request) -> str: + return getattr(request.state, "authenticated_via", "none") From 0001dbe634d326ecc4a14f12abd179652a0c58f9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:54:36 +0300 Subject: [PATCH 25/36] docs(api): add comments for security schemas and openapi setup --- api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api.py b/api.py index 0196fa7..851608c 100644 --- a/api.py +++ b/api.py @@ -181,6 +181,7 @@ def custom_openapi(): openapi_schema = app.openapi() + # Définir deux schémas de sécurité openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, @@ -192,6 +193,7 @@ def custom_openapi(): return app.openapi_schema +# Après app = FastAPI(...), ajouter: app.openapi = custom_openapi From fa95d0d11728e0335ab8ee5b7b2bf1343082352a Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 13:56:16 +0300 Subject: [PATCH 26/36] refactor(api): replace get_current_user with get_sage_client_for_user in dependencies --- api.py | 174 ++++++++++++++++++------------------- middleware/security.py | 37 ++++---- scripts/manage_security.py | 22 ++--- 3 files changed, 114 insertions(+), 119 deletions(-) diff --git a/api.py b/api.py index a35be7c..851608c 100644 --- a/api.py +++ b/api.py @@ -212,7 +212,7 @@ app.include_router(entreprises_router) async def obtenir_clients( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: clients = sage.lister_clients(filtre=query or "") @@ -226,7 +226,7 @@ async def obtenir_clients( async def lire_client_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: client = sage.lire_client(code) @@ -249,7 +249,7 @@ async def modifier_client( client_update: ClientUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.modifier_client(code, client_update.dict(exclude_none=True)) @@ -275,7 +275,7 @@ async def ajouter_client( client: ClientCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: nouveau_client = sage.creer_client(client.model_dump(mode="json")) @@ -300,7 +300,7 @@ async def ajouter_client( async def rechercher_articles( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: articles = sage.lister_articles(filtre=query or "") @@ -319,7 +319,7 @@ async def rechercher_articles( async def creer_article( article: ArticleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: if not article.reference or not article.designation: @@ -360,7 +360,7 @@ async def modifier_article( reference: str = Path(..., description="Référence de l'article à modifier"), article: ArticleUpdate = Body(...), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: article_data = article.dict(exclude_unset=True) @@ -404,7 +404,7 @@ async def modifier_article( async def lire_article( reference: str = Path(..., description="Référence de l'article"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: article = sage.lire_article(reference) @@ -434,7 +434,7 @@ async def lire_article( async def creer_devis( devis: DevisRequest, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis_data = { @@ -474,7 +474,7 @@ async def modifier_devis( devis_update: DevisUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -520,7 +520,7 @@ async def creer_commande( commande: CommandeCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande_data = { @@ -570,7 +570,7 @@ async def modifier_commande( commande_update: CommandeUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -619,7 +619,7 @@ async def lister_devis( True, description="Inclure les lignes de chaque devis" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis_list = sage.lister_devis( @@ -636,7 +636,7 @@ async def lister_devis( async def lire_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis = sage.lire_devis(id) @@ -657,7 +657,7 @@ async def lire_devis( async def telecharger_devis_pdf( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) @@ -680,7 +680,7 @@ async def telecharger_document_pdf( ), numero: str = Path(..., description="Numéro du document"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: types_labels = { @@ -737,7 +737,7 @@ async def envoyer_devis_email( request: EmailEnvoi, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: tous_destinataires = [request.destinataire] + request.cc + request.cci @@ -793,7 +793,7 @@ async def changer_statut_document( ..., ge=0, le=6, description="0=Saisi, 1=Confirmé, 2=Accepté" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): document_type_sql = None document_type_code = None @@ -910,7 +910,7 @@ async def changer_statut_document( async def lire_commande( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -929,7 +929,7 @@ async def lister_commandes( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commandes = sage.lister_commandes(limit=limit, statut=statut) @@ -945,7 +945,7 @@ async def devis_vers_commande( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.transformer_document( @@ -990,7 +990,7 @@ async def commande_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.transformer_document( @@ -1092,7 +1092,7 @@ async def valider_remise( client_id: str = Query(..., min_length=1), remise_pourcentage: float = Query(0.0, ge=0, le=100), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: remise_max = sage.lire_remise_max_client(client_id) @@ -1126,7 +1126,7 @@ async def relancer_devis_signature( relance: RelanceDevis, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis = sage.lire_devis(id) @@ -1193,7 +1193,7 @@ class ContactClientResponse(BaseModel): async def recuperer_contact_devis( id: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis = sage.lire_devis(id) @@ -1221,7 +1221,7 @@ async def lister_factures( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: factures = sage.lister_factures(limit=limit, statut=statut) @@ -1236,7 +1236,7 @@ async def lister_factures( async def lire_facture_detail( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture = sage.lire_document(numero, TypeDocumentSQL.FACTURE) @@ -1263,7 +1263,7 @@ async def creer_facture( facture: FactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture_data = { @@ -1313,7 +1313,7 @@ async def modifier_facture( facture_update: FactureUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -1383,7 +1383,7 @@ async def relancer_facture( relance: RelanceFacture, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: facture = sage.lire_document(id, TypeDocumentSQL.FACTURE) @@ -1454,7 +1454,7 @@ async def journal_emails( limit: int = Query(100, le=1000), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) @@ -1490,7 +1490,7 @@ async def exporter_logs_csv( statut: Optional[StatutEmail] = Query(None), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): query = select(EmailLog) if statut: @@ -1647,7 +1647,7 @@ async def supprimer_template( async def previsualiser_email( preview: TemplatePreview, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): if preview.template_id not in templates_email_db: raise HTTPException(404, f"Template {preview.template_id} introuvable") @@ -1686,7 +1686,7 @@ async def previsualiser_email( async def rechercher_prospects( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: prospects = sage.lister_prospects(filtre=query or "") @@ -1700,7 +1700,7 @@ async def rechercher_prospects( async def lire_prospect( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: prospect = sage.lire_prospect(code) @@ -1720,7 +1720,7 @@ async def lire_prospect( async def rechercher_fournisseurs( query: Optional[str] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: fournisseurs = sage.lister_fournisseurs(filtre=query or "") @@ -1742,7 +1742,7 @@ async def ajouter_fournisseur( fournisseur: FournisseurCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: nouveau_fournisseur = sage.creer_fournisseur(fournisseur.dict()) @@ -1772,7 +1772,7 @@ async def modifier_fournisseur( fournisseur_update: FournisseurUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.modifier_fournisseur( @@ -1795,7 +1795,7 @@ async def modifier_fournisseur( async def lire_fournisseur( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: fournisseur = sage.lire_fournisseur(code) @@ -1814,7 +1814,7 @@ async def lister_avoirs( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: avoirs = sage.lister_avoirs(limit=limit, statut=statut) @@ -1828,7 +1828,7 @@ async def lister_avoirs( async def lire_avoir( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: avoir = sage.lire_document(numero, TypeDocumentSQL.BON_AVOIR) @@ -1847,7 +1847,7 @@ async def creer_avoir( avoir: AvoirCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: avoir_data = { @@ -1895,7 +1895,7 @@ async def modifier_avoir( avoir_update: AvoirUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -1941,7 +1941,7 @@ async def lister_livraisons( limit: int = Query(100, le=1000), statut: Optional[int] = Query(None), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: livraisons = sage.lister_livraisons(limit=limit, statut=statut) @@ -1955,7 +1955,7 @@ async def lister_livraisons( async def lire_livraison( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: livraison = sage.lire_document(numero, TypeDocumentSQL.BON_LIVRAISON) @@ -1974,7 +1974,7 @@ async def creer_livraison( livraison: LivraisonCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: livraison_data = { @@ -2028,7 +2028,7 @@ async def modifier_livraison( livraison_update: LivraisonUpdate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: update_data = {} @@ -2074,7 +2074,7 @@ async def livraison_vers_facture( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.transformer_document( @@ -2118,7 +2118,7 @@ async def devis_vers_facture_direct( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: devis_existant = sage.lire_devis(id) @@ -2179,7 +2179,7 @@ async def commande_vers_livraison( id: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: commande_existante = sage.lire_document(id, TypeDocumentSQL.BON_COMMANDE) @@ -2251,7 +2251,7 @@ async def commande_vers_livraison( async def lister_familles( filtre: Optional[str] = Query(None, description="Filtre sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: familles = sage.lister_familles(filtre or "") @@ -2277,7 +2277,7 @@ async def lister_familles( async def lire_famille( code: str = Path(..., description="Code de la famille (ex: ZDIVERS)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: famille = sage.lire_famille(code) @@ -2313,7 +2313,7 @@ async def lire_famille( async def creer_famille( famille: FamilleCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: if not famille.code or not famille.intitule: @@ -2357,7 +2357,7 @@ async def creer_famille( async def creer_entree_stock( entree: EntreeStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: entree_data = entree.dict() @@ -2394,7 +2394,7 @@ async def creer_entree_stock( async def creer_sortie_stock( sortie: SortieStock, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: sortie_data = sortie.dict() @@ -2430,7 +2430,7 @@ async def creer_sortie_stock( async def lire_mouvement_stock( numero: str = Path(..., description="Numéro du mouvement (ex: ME00123 ou MS00124)"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: mouvement = sage.lire_mouvement_stock(numero) @@ -2463,7 +2463,7 @@ async def lire_mouvement_stock( ) async def statistiques_familles( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: stats = sage.get_stats_familles() @@ -2572,7 +2572,7 @@ async def creer_contact( numero: str, contact: ContactCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: try: @@ -2605,7 +2605,7 @@ async def creer_contact( async def lister_contacts( numero: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: contacts = sage.lister_contacts(numero) @@ -2624,7 +2624,7 @@ async def obtenir_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: contact = sage.obtenir_contact(numero, contact_numero) @@ -2650,7 +2650,7 @@ async def modifier_contact( contact_numero: int, contact: ContactUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: contact_existant = sage.obtenir_contact(numero, contact_numero) @@ -2682,7 +2682,7 @@ async def supprimer_contact( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: sage.supprimer_contact(numero, contact_numero) @@ -2697,7 +2697,7 @@ async def definir_contact_defaut( numero: str, contact_numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.definir_contact_defaut(numero, contact_numero) @@ -2719,7 +2719,7 @@ async def obtenir_tiers( ), query: Optional[str] = Query(None, description="Recherche sur code ou intitulé"), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: type_normalise = normaliser_type_tiers(type_tiers) @@ -2734,7 +2734,7 @@ async def obtenir_tiers( async def lire_tiers_detail( code: str, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: tiers = sage.lire_tiers(code) @@ -2772,7 +2772,7 @@ async def lister_collaborateurs( True, description="Exclure les collaborateurs en sommeil" ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste tous les collaborateurs""" try: @@ -2791,7 +2791,7 @@ async def lister_collaborateurs( async def lire_collaborateur_detail( numero: int, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Lit un collaborateur par son numéro""" try: @@ -2818,7 +2818,7 @@ async def lire_collaborateur_detail( async def creer_collaborateur( collaborateur: CollaborateurCreate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Crée un nouveau collaborateur""" try: @@ -2845,7 +2845,7 @@ async def modifier_collaborateur( numero: int, collaborateur: CollaborateurUpdate, user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Modifie un collaborateur existant""" try: @@ -2868,7 +2868,7 @@ async def modifier_collaborateur( @app.get("/societe/info", response_model=SocieteInfo, tags=["Société"]) async def obtenir_informations_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: societe = sage.lire_informations_societe() @@ -2888,7 +2888,7 @@ async def obtenir_informations_societe( @app.get("/societe/logo", tags=["Société"]) async def obtenir_logo_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Retourne le logo en tant qu'image directe""" try: @@ -2913,7 +2913,7 @@ async def obtenir_logo_societe( @app.get("/societe/preview", response_class=HTMLResponse, tags=["Société"]) async def preview_societe( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Page HTML pour visualiser les infos société avec logo""" try: @@ -2987,7 +2987,7 @@ async def valider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.valider_facture(numero_facture) @@ -3011,7 +3011,7 @@ async def devalider_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.devalider_facture(numero_facture) @@ -3035,7 +3035,7 @@ async def get_statut_validation_facture( numero_facture: str, _: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_statut_validation(numero_facture) @@ -3056,7 +3056,7 @@ async def regler_facture( reglement: ReglementFactureCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.regler_facture( @@ -3100,7 +3100,7 @@ async def regler_factures_multiple( reglement: ReglementMultipleCreate, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.regler_factures_client( @@ -3139,7 +3139,7 @@ async def get_reglements_facture( numero_facture: str, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_reglements_facture(numero_facture) @@ -3164,7 +3164,7 @@ async def get_reglements_client( inclure_soldees: bool = Query(True, description="Inclure les factures soldées"), session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_reglements_client( @@ -3189,7 +3189,7 @@ async def get_reglements_client( @app.get("/journaux/banque", tags=["Règlements"]) async def get_journaux_banque( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): try: resultat = sage.get_journaux_banque() @@ -3202,7 +3202,7 @@ async def get_journaux_banque( @app.get("/reglements/modes", tags=["Référentiels"]) async def get_modes_reglement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des modes de règlement disponibles dans Sage""" try: @@ -3216,7 +3216,7 @@ async def get_modes_reglement( @app.get("/devises", tags=["Référentiels"]) async def get_devises( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des devises disponibles dans Sage""" try: @@ -3230,7 +3230,7 @@ async def get_devises( @app.get("/journaux/tresorerie", tags=["Référentiels"]) async def get_journaux_tresorerie( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des journaux de trésorerie (banque + caisse)""" try: @@ -3249,7 +3249,7 @@ async def get_comptes_generaux( description="client | fournisseur | banque | caisse | tva | produit | charge", ), user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des comptes généraux""" try: @@ -3263,7 +3263,7 @@ async def get_comptes_generaux( @app.get("/tva/taux", tags=["Référentiels"]) async def get_tva_taux( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Liste des taux de TVA""" try: @@ -3277,7 +3277,7 @@ async def get_tva_taux( @app.get("/parametres/encaissement", tags=["Référentiels"]) async def get_parametres_encaissement( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): """Paramètres TVA sur encaissement""" try: @@ -3324,7 +3324,7 @@ async def get_reglement_detail(rg_no): @app.get("/health", tags=["System"]) async def health_check( user: User = Depends(get_current_user), - sage: SageGatewayClient = Depends(get_current_user), + sage: SageGatewayClient = Depends(get_sage_client_for_user), ): gateway_health = sage.health() diff --git a/middleware/security.py b/middleware/security.py index c6e75e7..137e7dd 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -34,7 +34,7 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: logger.info(f" Accès Swagger autorisé (DB): {username}") return True - logger.warning(f"Tentative d'accès Swagger refusée: {username}") + logger.warning(f" Tentative d'accès Swagger refusée: {username}") return False except Exception as e: @@ -43,7 +43,6 @@ async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: class SwaggerAuthMiddleware: - def __init__(self, app): self.app = app @@ -55,7 +54,7 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - protected_paths = ["/docs", "/redoc"] + protected_paths = ["/docs", "/redoc", "/openapi.json"] if any(path.startswith(protected_path) for protected_path in protected_paths): auth_header = request.headers.get("Authorization") @@ -105,7 +104,6 @@ class SwaggerAuthMiddleware: class ApiKeyMiddleware: - def __init__(self, app): self.app = app @@ -117,24 +115,21 @@ class ApiKeyMiddleware: request = Request(scope, receive=receive) path = request.url.path - public_exact_paths = [ - "/", - "/health", + excluded_paths = [ "/docs", "/redoc", "/openapi.json", + "/health", + "/", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", ] - public_path_prefixes = [ - "/api/v1/auth/", - ] - - is_public = path in public_exact_paths or any( - path.startswith(prefix) for prefix in public_path_prefixes - ) - - if is_public: - logger.debug(f"Chemin public: {path}") + if any(path.startswith(excluded_path) for excluded_path in excluded_paths): await self.app(scope, receive, send) return @@ -145,12 +140,12 @@ class ApiKeyMiddleware: has_api_key = api_key is not None if has_jwt: - logger.debug(f"🔑 JWT détecté pour {path}") + logger.debug(f" JWT détecté pour {path}") await self.app(scope, receive, send) return elif has_api_key: - logger.debug(f"🔑 API Key détectée pour {path}") + logger.debug(f" API Key détectée pour {path}") from services.api_key import ApiKeyService @@ -223,9 +218,8 @@ class ApiKeyMiddleware: response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ - "detail": "Authentification requise (JWT ou API Key)", + "detail": "Authentification requise", "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", - "endpoint": path, }, headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, ) @@ -239,5 +233,4 @@ def get_api_key_from_request(request: Request) -> Optional: def get_auth_method(request: Request) -> str: - return getattr(request.state, "authenticated_via", "none") diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 066e92c..b1c14de 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,23 +1,25 @@ import asyncio import sys +import os from pathlib import Path -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password -from sqlalchemy import select - -import argparse -from datetime import datetime -import logging current_dir = Path(__file__).resolve().parent parent_dir = current_dir.parent sys.path.insert(0, str(parent_dir)) +import argparse +from datetime import datetime +import logging + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password, verify_password +from sqlalchemy import select + async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -130,7 +132,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(" Endpoints autorisés: Tous") + logger.info(f" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") From 918f5d3f1980d74040b9db41b40099709867fc57 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:06:10 +0300 Subject: [PATCH 27/36] docs(api): fix incorrect comment syntax in openapi configuration --- api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api.py b/api.py index 851608c..53a2273 100644 --- a/api.py +++ b/api.py @@ -193,8 +193,8 @@ def custom_openapi(): return app.openapi_schema -# Après app = FastAPI(...), ajouter: -app.openapi = custom_openapi +""" # Après app = FastAPI(...), ajouter: +app.openapi = custom_openapi """ setup_cors(app, mode="open") From 41ca202d4b128e3371192aa1fc857e1aa8c00be9 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:19:48 +0300 Subject: [PATCH 28/36] refactor(security): move security config to environment variables and improve error handling --- scripts/manage_security.py | 22 +++++++++++----------- security/auth.py | 19 ++++++++++++------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index b1c14de..17df3af 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,25 +1,25 @@ import asyncio import sys -import os from pathlib import Path -current_dir = Path(__file__).resolve().parent -parent_dir = current_dir.parent -sys.path.insert(0, str(parent_dir)) +from database import get_session +from database.models.api_key import SwaggerUser, ApiKey +from services.api_key import ApiKeyService +from security.auth import hash_password +from sqlalchemy import select import argparse from datetime import datetime import logging +current_dir = Path(__file__).resolve().parent +parent_dir = current_dir.parent +sys.path.insert(0, str(parent_dir)) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) -from database import get_session -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password, verify_password -from sqlalchemy import select - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" @@ -132,7 +132,7 @@ async def create_api_key( f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" ) else: - logger.info(f" Endpoints autorisés: Tous") + logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") diff --git a/security/auth.py b/security/auth.py index 970a90f..3708708 100644 --- a/security/auth.py +++ b/security/auth.py @@ -4,11 +4,12 @@ from typing import Optional, Dict import jwt import secrets import hashlib +import os -SECRET_KEY = "VOTRE_SECRET_KEY_A_METTRE_EN_.ENV" -ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_MINUTES = 10080 -REFRESH_TOKEN_EXPIRE_DAYS = 7 +SECRET_KEY = os.getenv("JWT_SECRET") +ALGORITHM = os.getenv("JWT_ALGORITHM") +ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES") +REFRESH_TOKEN_EXPIRE_DAYS = os.getenv("REFRESH_TOKEN_EXPIRE_DAYS") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -67,9 +68,13 @@ def decode_token(token: str) -> Optional[Dict]: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) return payload except jwt.ExpiredSignatureError: - return None - except jwt.JWTError: - return None + raise jwt.InvalidTokenError("Token expiré") + except jwt.DecodeError: + raise jwt.InvalidTokenError("Token invalide (format incorrect)") + except jwt.InvalidTokenError as e: + raise jwt.InvalidTokenError(f"Token invalide: {str(e)}") + except Exception as e: + raise jwt.InvalidTokenError(f"Erreur lors du décodage du token: {str(e)}") def validate_password_strength(password: str) -> tuple[bool, str]: From c84e4ddc20b40ca4fcc660db7016cb862bdcc3fa Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:25:06 +0300 Subject: [PATCH 29/36] refactor(auth): simplify authentication logic and improve error handling --- api.py | 1 - core/dependencies.py | 163 +++++++++++++++---------------------------- 2 files changed, 56 insertions(+), 108 deletions(-) diff --git a/api.py b/api.py index 53a2273..c35e280 100644 --- a/api.py +++ b/api.py @@ -181,7 +181,6 @@ def custom_openapi(): openapi_schema = app.openapi() - # Définir deux schémas de sécurité openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, diff --git a/core/dependencies.py b/core/dependencies.py index 8bb30ab..76c85be 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -2,13 +2,11 @@ from fastapi import Depends, HTTPException, status, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select +from typing import Optional +from jwt.exceptions import InvalidTokenError + from database import get_session, User from security.auth import decode_token -from typing import Optional -from datetime import datetime -import logging - -logger = logging.getLogger(__name__) security = HTTPBearer(auto_error=False) @@ -18,62 +16,6 @@ async def get_current_user_hybrid( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), session: AsyncSession = Depends(get_session), ) -> User: - if credentials and credentials.credentials: - token = credentials.credentials - - payload = decode_token(token) - if not payload: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token invalide ou expiré", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if payload.get("type") != "access": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Type de token incorrect", - headers={"WWW-Authenticate": "Bearer"}, - ) - - user_id: str = payload.get("sub") - if not user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token malformé", - headers={"WWW-Authenticate": "Bearer"}, - ) - - result = await session.execute(select(User).where(User.id == user_id)) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Utilisateur introuvable", - headers={"WWW-Authenticate": "Bearer"}, - ) - - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé" - ) - - if not user.is_verified: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Email non vérifié. Consultez votre boîte de réception.", - ) - - if user.locked_until and user.locked_until > datetime.now(): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Compte temporairement verrouillé suite à trop de tentatives échouées", - ) - - logger.debug(f" Authentifié via JWT: {user.email}") - return user - api_key_obj = getattr(request.state, "api_key", None) if api_key_obj: @@ -84,69 +26,76 @@ async def get_current_user_hybrid( user = result.scalar_one_or_none() if user: - logger.debug( - f" Authentifié via API Key ({api_key_obj.name}) → User: {user.email}" - ) + user._is_api_key_user = True + user._api_key_obj = api_key_obj return user - from database import User as UserModel - - virtual_user = UserModel( + virtual_user = User( id=f"api_key_{api_key_obj.id}", - email=f"{api_key_obj.name.lower().replace(' ', '_')}@api-key.local", - nom="API Key", - prenom=api_key_obj.name, + email=f"api_key_{api_key_obj.id}@virtual.local", + username=api_key_obj.name, role="api_client", - is_verified=True, is_active=True, ) virtual_user._is_api_key_user = True virtual_user._api_key_obj = api_key_obj - logger.debug(f" Authentifié via API Key: {api_key_obj.name} (user virtuel)") return virtual_user - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentification requise (JWT ou API Key)", - headers={"WWW-Authenticate": "Bearer"}, - ) + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentification requise (JWT ou API Key)", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = credentials.credentials -async def get_current_user_optional_hybrid( - request: Request, - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), - session: AsyncSession = Depends(get_session), -) -> Optional[User]: - """Version optionnelle de get_current_user_hybrid (ne lève pas d'erreur)""" try: - return await get_current_user_hybrid(request, credentials, session) - except HTTPException: - return None + payload = decode_token(token) + user_id: str = payload.get("sub") - -def require_role(*allowed_roles: str): - async def role_checker( - request: Request, user: User = Depends(get_current_user_hybrid) - ) -> User: - is_api_key_user = getattr(user, "_is_api_key_user", False) - - if is_api_key_user: - if "api_client" not in allowed_roles and "*" not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}. API Keys non autorisées.", - ) - logger.debug(" API Key autorisée pour cette route") - return user - - if user.role not in allowed_roles and "*" not in allowed_roles: + if user_id is None: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Accès refusé. Rôles requis: {', '.join(allowed_roles)}", + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token invalide: user_id manquant", + headers={"WWW-Authenticate": "Bearer"}, ) + result = await session.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Utilisateur introuvable", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Utilisateur inactif", + ) + + return user + + except InvalidTokenError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Token invalide: {str(e)}", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def require_role_hybrid(*allowed_roles: str): + async def role_checker(user: User = Depends(get_current_user_hybrid)) -> User: + if user.role not in allowed_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Accès interdit. Rôles autorisés: {', '.join(allowed_roles)}", + ) return user return role_checker @@ -158,9 +107,9 @@ def is_api_key_user(user: User) -> bool: def get_api_key_from_user(user: User): - """Récupère l'objet API Key depuis un user virtuel""" + """Récupère l'objet ApiKey depuis un utilisateur (si applicable)""" return getattr(user, "_api_key_obj", None) get_current_user = get_current_user_hybrid -get_current_user_optional = get_current_user_optional_hybrid +require_role = require_role_hybrid From 3cdb490ee5975877b43d8463c99b92a19769f3fb Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 14:47:07 +0300 Subject: [PATCH 30/36] refactor(security): improve middleware structure and configuration handling --- api.py | 4 +- database/db_config.py | 4 +- middleware/security.py | 345 +++++++++++++++++++++-------------------- security/auth.py | 11 +- 4 files changed, 188 insertions(+), 176 deletions(-) diff --git a/api.py b/api.py index c35e280..06f57e4 100644 --- a/api.py +++ b/api.py @@ -96,7 +96,7 @@ from utils.generic_functions import ( ) -from middleware.security import SwaggerAuthMiddleware, ApiKeyMiddleware +from middleware.security import SwaggerAuthMiddleware, ApiKeyMiddlewareHTTP from core.dependencies import get_current_user from config.cors_config import setup_cors from routes.api_keys import router as api_keys_router @@ -198,7 +198,7 @@ app.openapi = custom_openapi """ setup_cors(app, mode="open") app.add_middleware(SwaggerAuthMiddleware) -app.add_middleware(ApiKeyMiddleware) +app.add_middleware(ApiKeyMiddlewareHTTP) app.include_router(api_keys_router) app.include_router(auth_router) diff --git a/database/db_config.py b/database/db_config.py index bb98f5c..692822c 100644 --- a/database/db_config.py +++ b/database/db_config.py @@ -1,14 +1,14 @@ -import os from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker from sqlalchemy.pool import NullPool from sqlalchemy import event, text import logging +from config.config import settings from database.models.generic_model import Base logger = logging.getLogger(__name__) -DATABASE_URL = os.getenv("DATABASE_URL") +DATABASE_URL = settings.database_url def _configure_sqlite_connection(dbapi_connection, connection_record): diff --git a/middleware/security.py b/middleware/security.py index 137e7dd..2bea533 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,49 +1,23 @@ from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp from sqlalchemy import select -from typing import Optional +from typing import Callable from datetime import datetime import logging - -from database import get_session -from database.models.api_key import SwaggerUser -from security.auth import verify_password +import base64 logger = logging.getLogger(__name__) security = HTTPBasic() -async def verify_swagger_credentials(credentials: HTTPBasicCredentials) -> bool: - username = credentials.username - password = credentials.password - - try: - async for session in get_session(): - result = await session.execute( - select(SwaggerUser).where(SwaggerUser.username == username) - ) - swagger_user = result.scalar_one_or_none() - - if swagger_user and swagger_user.is_active: - if verify_password(password, swagger_user.hashed_password): - swagger_user.last_login = datetime.now() - await session.commit() - - logger.info(f" Accès Swagger autorisé (DB): {username}") - return True - - logger.warning(f" Tentative d'accès Swagger refusée: {username}") - return False - - except Exception as e: - logger.error(f" Erreur vérification Swagger credentials: {e}") - return False - - class SwaggerAuthMiddleware: - def __init__(self, app): + PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"] + + def __init__(self, app: ASGIApp): self.app = app async def __call__(self, scope, receive, send): @@ -54,183 +28,220 @@ class SwaggerAuthMiddleware: request = Request(scope, receive=receive) path = request.url.path - protected_paths = ["/docs", "/redoc", "/openapi.json"] + if not any(path.startswith(p) for p in self.PROTECTED_PATHS): + await self.app(scope, receive, send) + return - if any(path.startswith(protected_path) for protected_path in protected_paths): - auth_header = request.headers.get("Authorization") + auth_header = request.headers.get("Authorization") - if not auth_header or not auth_header.startswith("Basic "): + if not auth_header or not auth_header.startswith("Basic "): + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Authentification requise pour la documentation"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return + + try: + encoded_credentials = auth_header.split(" ")[1] + decoded_credentials = base64.b64decode(encoded_credentials).decode("utf-8") + username, password = decoded_credentials.split(":", 1) + + credentials = HTTPBasicCredentials(username=username, password=password) + + if not await self._verify_credentials(credentials): response = JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise pour accéder à la documentation" - }, + content={"detail": "Identifiants invalides"}, headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, ) await response(scope, receive, send) return - try: - import base64 - - encoded_credentials = auth_header.split(" ")[1] - decoded_credentials = base64.b64decode(encoded_credentials).decode( - "utf-8" - ) - username, password = decoded_credentials.split(":", 1) - - credentials = HTTPBasicCredentials(username=username, password=password) - - if not await verify_swagger_credentials(credentials): - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"detail": "Identifiants invalides"}, - headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, - ) - await response(scope, receive, send) - return - - except Exception as e: - logger.error(f" Erreur parsing auth header: {e}") - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={"detail": "Format d'authentification invalide"}, - headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, - ) - await response(scope, receive, send) - return + except Exception as e: + logger.error(f"Erreur parsing auth header: {e}") + response = JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": "Format d'authentification invalide"}, + headers={"WWW-Authenticate": 'Basic realm="Swagger UI"'}, + ) + await response(scope, receive, send) + return await self.app(scope, receive, send) + async def _verify_credentials(self, credentials: HTTPBasicCredentials) -> bool: + """Vérifie les identifiants dans la base de données""" + from database.db_config import async_session_factory + from database.models.api_key import SwaggerUser + from security.auth import verify_password -class ApiKeyMiddleware: - def __init__(self, app): - self.app = app + try: + async with async_session_factory() as session: + result = await session.execute( + select(SwaggerUser).where( + SwaggerUser.username == credentials.username + ) + ) + swagger_user = result.scalar_one_or_none() - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.app(scope, receive, send) - return + if swagger_user and swagger_user.is_active: + if verify_password( + credentials.password, swagger_user.hashed_password + ): + swagger_user.last_login = datetime.now() + await session.commit() + logger.info(f"✓ Accès Swagger autorisé: {credentials.username}") + return True - request = Request(scope, receive=receive) + logger.warning(f"✗ Accès Swagger refusé: {credentials.username}") + return False + + except Exception as e: + logger.error(f"Erreur vérification credentials: {e}") + return False + + +class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): + EXCLUDED_PATHS = [ + "/docs", + "/redoc", + "/openapi.json", + "/", + "/auth/login", + "/auth/register", + "/auth/verify-email", + "/auth/reset-password", + "/auth/request-reset", + "/auth/refresh", + ] + + async def dispatch(self, request: Request, call_next: Callable): path = request.url.path + method = request.method - excluded_paths = [ - "/docs", - "/redoc", - "/openapi.json", - "/health", - "/", - "/auth/login", - "/auth/register", - "/auth/verify-email", - "/auth/reset-password", - "/auth/request-reset", - "/auth/refresh", - ] - - if any(path.startswith(excluded_path) for excluded_path in excluded_paths): - await self.app(scope, receive, send) - return + if self._is_excluded_path(path): + return await call_next(request) auth_header = request.headers.get("Authorization") has_jwt = auth_header and auth_header.startswith("Bearer ") api_key = request.headers.get("X-API-Key") - has_api_key = api_key is not None + has_api_key = bool(api_key) if has_jwt: - logger.debug(f" JWT détecté pour {path}") - await self.app(scope, receive, send) - return + logger.debug(f"JWT détecté pour {method} {path}") + return await call_next(request) - elif has_api_key: - logger.debug(f" API Key détectée pour {path}") + if has_api_key: + logger.debug(f"API Key détectée pour {method} {path}") + return await self._handle_api_key_auth( + request, api_key, path, method, call_next + ) + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + + def _is_excluded_path(self, path: str) -> bool: + """Vérifie si le chemin est exclu de l'authentification""" + if path == "/": + return True + + for excluded in self.EXCLUDED_PATHS: + if excluded == "/": + continue + if path == excluded or path.startswith(excluded + "/"): + return True + + return False + + async def _handle_api_key_auth( + self, + request: Request, + api_key: str, + path: str, + method: str, + call_next: Callable, + ): + """Gère l'authentification par API Key""" + try: + from database.db_config import async_session_factory from services.api_key import ApiKeyService - try: - async for session in get_session(): - api_key_service = ApiKeyService(session) - api_key_obj = await api_key_service.verify_api_key(api_key) + async with async_session_factory() as session: + service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) - if not api_key_obj: - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Clé API invalide ou expirée", - "hint": "Utilisez X-API-Key: sdk_live_xxx ou Authorization: Bearer ", - }, - ) - await response(scope, receive, send) - return - - is_allowed, rate_info = await api_key_service.check_rate_limit( - api_key_obj + if not api_key_obj: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Clé API invalide ou expirée", + "hint": "Vérifiez votre clé X-API-Key", + }, ) - if not is_allowed: - response = JSONResponse( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - content={"detail": "Rate limit dépassé"}, - headers={ - "X-RateLimit-Limit": str(rate_info["limit"]), - "X-RateLimit-Remaining": "0", - }, - ) - await response(scope, receive, send) - return - has_access = await api_key_service.check_endpoint_access( - api_key_obj, path + is_allowed, rate_info = await service.check_rate_limit(api_key_obj) + if not is_allowed: + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={"detail": "Rate limit dépassé"}, + headers={ + "X-RateLimit-Limit": str(rate_info["limit"]), + "X-RateLimit-Remaining": "0", + }, ) - if not has_access: - response = JSONResponse( - status_code=status.HTTP_403_FORBIDDEN, - content={ - "detail": "Accès non autorisé à cet endpoint", - "endpoint": path, - "api_key": api_key_obj.key_prefix + "...", - }, - ) - await response(scope, receive, send) - return - request.state.api_key = api_key_obj - request.state.authenticated_via = "api_key" + has_access = await service.check_endpoint_access(api_key_obj, path) + if not has_access: + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={ + "detail": "Accès non autorisé à cet endpoint", + "endpoint": path, + "api_key": api_key_obj.key_prefix + "...", + }, + ) - logger.info(f" API Key valide: {api_key_obj.name} → {path}") + request.state.api_key = api_key_obj + request.state.authenticated_via = "api_key" - await self.app(scope, receive, send) - return + logger.info(f"✓ API Key valide: {api_key_obj.name} → {method} {path}") - except Exception as e: - logger.error(f" Erreur validation API Key: {e}", exc_info=True) - response = JSONResponse( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={ - "detail": "Erreur interne lors de la validation de la clé" - }, - ) - await response(scope, receive, send) - return + return await call_next(request) - else: - response = JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise", - "hint": "Utilisez soit 'X-API-Key: sdk_live_xxx' soit 'Authorization: Bearer '", - }, - headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + except Exception as e: + logger.error(f"Erreur validation API Key: {e}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={"detail": "Erreur interne lors de la validation"}, ) - await response(scope, receive, send) - return -def get_api_key_from_request(request: Request) -> Optional: +ApiKeyMiddleware = ApiKeyMiddlewareHTTP + + +def get_api_key_from_request(request: Request): """Récupère l'objet ApiKey depuis la requête si présent""" return getattr(request.state, "api_key", None) def get_auth_method(request: Request) -> str: + """Retourne la méthode d'authentification utilisée""" return getattr(request.state, "authenticated_via", "none") + + +__all__ = [ + "SwaggerAuthMiddleware", + "ApiKeyMiddlewareHTTP", + "ApiKeyMiddleware", + "get_api_key_from_request", + "get_auth_method", +] diff --git a/security/auth.py b/security/auth.py index 3708708..e05b6a0 100644 --- a/security/auth.py +++ b/security/auth.py @@ -4,12 +4,13 @@ from typing import Optional, Dict import jwt import secrets import hashlib -import os -SECRET_KEY = os.getenv("JWT_SECRET") -ALGORITHM = os.getenv("JWT_ALGORITHM") -ACCESS_TOKEN_EXPIRE_MINUTES = os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES") -REFRESH_TOKEN_EXPIRE_DAYS = os.getenv("REFRESH_TOKEN_EXPIRE_DAYS") +from config.config import settings + +SECRET_KEY = settings.jwt_secret +ALGORITHM = settings.jwt_algorithm +ACCESS_TOKEN_EXPIRE_MINUTES = settings.access_token_expire_minutes +REFRESH_TOKEN_EXPIRE_DAYS = settings.refresh_token_expire_days pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") From 1a08894b4718e3d374056a9bc4f57a0068d06266 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:01:57 +0300 Subject: [PATCH 31/36] refactor(scripts): improve logging format and endpoint handling in security management --- middleware/security.py | 2 +- scripts/manage_security.py | 80 +++++++++++++++++++++++--------------- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 2bea533..493ed3e 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -4,7 +4,7 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp from sqlalchemy import select -from typing import Callable +from typing import Optional, Callable from datetime import datetime import logging import base64 diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 17df3af..52b21c7 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,7 +1,6 @@ import asyncio import sys from pathlib import Path - from database import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService @@ -31,7 +30,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): existing = result.scalar_one_or_none() if existing: - logger.error(f" L'utilisateur '{username}' existe déjà") + logger.error(f"❌ L'utilisateur '{username}' existe déjà") return swagger_user = SwaggerUser( @@ -44,7 +43,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): session.add(swagger_user) await session.commit() - logger.info(f" Utilisateur Swagger créé: {username}") + logger.info(f"✅ Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") logger.info(f" Actif: {swagger_user.is_active}") @@ -59,13 +58,13 @@ async def list_swagger_users(): users = result.scalars().all() if not users: - logger.info(" Aucun utilisateur Swagger") + logger.info("📭 Aucun utilisateur Swagger") break - logger.info(f" {len(users)} utilisateur(s) Swagger:\n") + logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = "" if user.is_active else "" + status = "✅" if user.is_active else "❌" logger.info(f" {status} {user.username}") logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") @@ -85,13 +84,13 @@ async def delete_swagger_user(username: str): user = result.scalar_one_or_none() if not user: - logger.error(f" Utilisateur '{username}' introuvable") + logger.error(f"❌ Utilisateur '{username}' introuvable") break await session.delete(user) await session.commit() - logger.info(f" Utilisateur Swagger supprimé: {username}") + logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}") break @@ -116,9 +115,9 @@ async def create_api_key( allowed_endpoints=endpoints, ) - logger.info("=" * 60) - logger.info(" Clé API créée avec succès") - logger.info("=" * 60) + logger.info("=" * 70) + logger.info("🔑 Clé API créée avec succès") + logger.info("=" * 70) logger.info(f" ID: {api_key_obj.id}") logger.info(f" Nom: {api_key_obj.name}") logger.info(f" Clé: {api_key_plain}") @@ -128,15 +127,16 @@ async def create_api_key( logger.info(f" Expire le: {api_key_obj.expires_at}") if api_key_obj.allowed_endpoints: - logger.info( - f" Endpoints autorisés: {', '.join(api_key_obj.allowed_endpoints)}" - ) + import json + + endpoints_list = json.loads(api_key_obj.allowed_endpoints) + logger.info(f" Endpoints autorisés: {', '.join(endpoints_list)}") else: logger.info(" Endpoints autorisés: Tous") - logger.info("=" * 60) - logger.info(" IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") - logger.info("=" * 60) + logger.info("=" * 70) + logger.info("⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info("=" * 70) break @@ -149,17 +149,17 @@ async def list_api_keys(): keys = await service.list_api_keys() if not keys: - logger.info(" Aucune clé API") + logger.info("📭 Aucune clé API") break - logger.info(f" {len(keys)} clé(s) API:\n") + logger.info(f"🔑 {len(keys)} clé(s) API:\n") for key in keys: status = ( - "" + "✅" if key.is_active and (not key.expires_at or key.expires_at > datetime.now()) - else "" + else "❌" ) logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") @@ -171,9 +171,15 @@ async def list_api_keys(): logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") if key.allowed_endpoints: - logger.info( - f" Endpoints: {', '.join(key.allowed_endpoints[:3])}..." - ) + import json + + try: + endpoints = json.loads(key.allowed_endpoints) + logger.info( + f" Endpoints: {', '.join(endpoints[:5])}{'...' if len(endpoints) > 5 else ''}" + ) + except: + pass logger.info("") @@ -190,13 +196,13 @@ async def revoke_api_key(key_id: str): key = result.scalar_one_or_none() if not key: - logger.error(f" Clé API '{key_id}' introuvable") + logger.error(f"❌ Clé API '{key_id}' introuvable") break key.is_active = False await session.commit() - logger.info(f" Clé API révoquée: {key.name}") + logger.info(f"🗑️ Clé API révoquée: {key.name}") logger.info(f" ID: {key.id}") logger.info(f" Préfixe: {key.key_prefix}") @@ -212,11 +218,11 @@ async def verify_api_key(api_key: str): key = await service.verify_api_key(api_key) if not key: - logger.error(" Clé API invalide ou expirée") + logger.error("❌ Clé API invalide ou expirée") break logger.info("=" * 60) - logger.info(" Clé API valide") + logger.info("✅ Clé API valide") logger.info("=" * 60) logger.info(f" Nom: {key.name}") logger.info(f" ID: {key.id}") @@ -224,6 +230,18 @@ async def verify_api_key(api_key: str): logger.info(f" Requêtes totales: {key.total_requests}") logger.info(f" Expire le: {key.expires_at or 'Jamais'}") logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + + if key.allowed_endpoints: + import json + + try: + endpoints = json.loads(key.allowed_endpoints) + logger.info(f" Endpoints autorisés: {endpoints}") + except: + pass + else: + logger.info(" Endpoints autorisés: Tous") + logger.info("=" * 60) break @@ -267,7 +285,7 @@ async def main(): create_parser.add_argument( "--endpoints", nargs="+", - help="Endpoints autorisés (ex: /clients /articles)", + help="Endpoints autorisés (ex: /clients /articles /devis/*)", ) apikey_subparsers.add_parser("list", help="Lister les clés API") @@ -317,10 +335,10 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - logger.info("\n Interrupted") + logger.info("\n⏹️ Interrupted") sys.exit(0) except Exception as e: - logger.error(f" Erreur: {e}") + logger.error(f"❌ Erreur: {e}") import traceback traceback.print_exc() From f8cec7ebc501b4587adca20a86b4ee211a6eccc5 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:13:19 +0300 Subject: [PATCH 32/36] refactor(security): improve security scripts and api documentation --- api.py | 12 ++- middleware/security.py | 4 +- scripts/manage_security.py | 178 ++++++++++++++++++------------------- 3 files changed, 99 insertions(+), 95 deletions(-) diff --git a/api.py b/api.py index 06f57e4..97d4425 100644 --- a/api.py +++ b/api.py @@ -1,5 +1,5 @@ from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body -from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.utils import get_openapi from fastapi.responses import StreamingResponse, HTMLResponse, Response from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr @@ -179,7 +179,12 @@ def custom_openapi(): if app.openapi_schema: return app.openapi_schema - openapi_schema = app.openapi() + openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) openapi_schema["components"]["securitySchemes"] = { "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, @@ -192,8 +197,7 @@ def custom_openapi(): return app.openapi_schema -""" # Après app = FastAPI(...), ajouter: -app.openapi = custom_openapi """ +app.openapi = custom_openapi setup_cors(app, mode="open") diff --git a/middleware/security.py b/middleware/security.py index 493ed3e..8b2d90a 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,3 +1,4 @@ + from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials @@ -15,6 +16,7 @@ security = HTTPBasic() class SwaggerAuthMiddleware: + PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"] def __init__(self, app: ASGIApp): @@ -241,7 +243,7 @@ def get_auth_method(request: Request) -> str: __all__ = [ "SwaggerAuthMiddleware", "ApiKeyMiddlewareHTTP", - "ApiKeyMiddleware", + "ApiKeyMiddleware", # Alias "get_api_key_from_request", "get_auth_method", ] diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 52b21c7..e2c0297 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,28 +1,44 @@ -import asyncio +#!/usr/bin/env python3 +""" +Script de gestion des utilisateurs Swagger et clés API +====================================================== + +Usage (depuis /app dans le container Docker): + python scripts/manage_security.py swagger add + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create --endpoints "/clients" "/devis" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify +""" + import sys from pathlib import Path -from database import get_session + +_script_dir = Path(__file__).resolve().parent +_app_dir = _script_dir.parent +if str(_app_dir) not in sys.path: + sys.path.insert(0, str(_app_dir)) + +import asyncio +import argparse +import logging +from datetime import datetime + +from sqlalchemy import select + +from database.db_config import get_session from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password -from sqlalchemy import select - -import argparse -from datetime import datetime -import logging - -current_dir = Path(__file__).resolve().parent -parent_dir = current_dir.parent -sys.path.insert(0, str(parent_dir)) - logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) + + async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" - async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) @@ -45,14 +61,11 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): logger.info(f"✅ Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") - logger.info(f" Actif: {swagger_user.is_active}") - break async def list_swagger_users(): """Lister tous les utilisateurs Swagger""" - async for session in get_session(): result = await session.execute(select(SwaggerUser)) users = result.scalars().all() @@ -62,21 +75,17 @@ async def list_swagger_users(): break logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") - for user in users: status = "✅" if user.is_active else "❌" logger.info(f" {status} {user.username}") logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") - logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}") - logger.info("") - + logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}\n") break async def delete_swagger_user(username: str): """Supprimer un utilisateur Swagger""" - async for session in get_session(): result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) @@ -89,11 +98,12 @@ async def delete_swagger_user(username: str): await session.delete(user) await session.commit() - logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}") break + + async def create_api_key( name: str, description: str = None, @@ -102,7 +112,6 @@ async def create_api_key( endpoints: list = None, ): """Créer une clé API""" - async for session in get_session(): service = ApiKeyService(session) @@ -123,27 +132,27 @@ async def create_api_key( logger.info(f" Clé: {api_key_plain}") logger.info(f" Préfixe: {api_key_obj.key_prefix}") logger.info(f" Rate limit: {api_key_obj.rate_limit_per_minute} req/min") - logger.info(f" Créée le: {api_key_obj.created_at}") logger.info(f" Expire le: {api_key_obj.expires_at}") if api_key_obj.allowed_endpoints: import json - endpoints_list = json.loads(api_key_obj.allowed_endpoints) - logger.info(f" Endpoints autorisés: {', '.join(endpoints_list)}") + try: + endpoints_list = json.loads(api_key_obj.allowed_endpoints) + logger.info(f" Endpoints: {', '.join(endpoints_list)}") + except: + logger.info(f" Endpoints: {api_key_obj.allowed_endpoints}") else: - logger.info(" Endpoints autorisés: Tous") + logger.info(" Endpoints: Tous (aucune restriction)") logger.info("=" * 70) - logger.info("⚠️ IMPORTANT: Sauvegardez cette clé, elle ne sera plus affichée !") + logger.info("⚠️ SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !") logger.info("=" * 70) - break async def list_api_keys(): """Lister toutes les clés API""" - async for session in get_session(): service = ApiKeyService(session) keys = await service.list_api_keys() @@ -155,19 +164,16 @@ async def list_api_keys(): logger.info(f"🔑 {len(keys)} clé(s) API:\n") for key in keys: - status = ( - "✅" - if key.is_active - and (not key.expires_at or key.expires_at > datetime.now()) - else "❌" + is_valid = key.is_active and ( + not key.expires_at or key.expires_at > datetime.now() ) + status = "✅" if is_valid else "❌" logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") logger.info(f" ID: {key.id}") logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") logger.info(f" Requêtes: {key.total_requests}") - logger.info(f" Créée le: {key.created_at}") - logger.info(f" Expire le: {key.expires_at or 'Jamais'}") + logger.info(f" Expire: {key.expires_at or 'Jamais'}") logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") if key.allowed_endpoints: @@ -175,23 +181,21 @@ async def list_api_keys(): try: endpoints = json.loads(key.allowed_endpoints) - logger.info( - f" Endpoints: {', '.join(endpoints[:5])}{'...' if len(endpoints) > 5 else ''}" - ) + display = ", ".join(endpoints[:4]) + if len(endpoints) > 4: + display += f"... (+{len(endpoints) - 4})" + logger.info(f" Endpoints: {display}") except: pass - + else: + logger.info(" Endpoints: Tous") logger.info("") - break async def revoke_api_key(key_id: str): """Révoquer une clé API""" - async for session in get_session(): - service = ApiKeyService(session) - result = await session.execute(select(ApiKey).where(ApiKey.id == key_id)) key = result.scalar_one_or_none() @@ -200,21 +204,18 @@ async def revoke_api_key(key_id: str): break key.is_active = False + key.revoked_at = datetime.now() await session.commit() logger.info(f"🗑️ Clé API révoquée: {key.name}") logger.info(f" ID: {key.id}") - logger.info(f" Préfixe: {key.key_prefix}") - break async def verify_api_key(api_key: str): """Vérifier une clé API""" - async for session in get_session(): service = ApiKeyService(session) - key = await service.verify_api_key(api_key) if not key: @@ -228,8 +229,7 @@ async def verify_api_key(api_key: str): logger.info(f" ID: {key.id}") logger.info(f" Rate limit: {key.rate_limit_per_minute} req/min") logger.info(f" Requêtes totales: {key.total_requests}") - logger.info(f" Expire le: {key.expires_at or 'Jamais'}") - logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}") + logger.info(f" Expire: {key.expires_at or 'Jamais'}") if key.allowed_endpoints: import json @@ -241,60 +241,58 @@ async def verify_api_key(api_key: str): pass else: logger.info(" Endpoints autorisés: Tous") - logger.info("=" * 60) - break + + async def main(): parser = argparse.ArgumentParser( - description="Gestion des utilisateurs Swagger et clés API" + description="Gestion des utilisateurs Swagger et clés API", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Exemples: + python scripts/manage_security.py swagger add admin MyP@ssw0rd + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 + python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify sdk_live_xxxxx + """, ) - subparsers = parser.add_subparsers(dest="command", help="Commandes disponibles") + subparsers = parser.add_subparsers(dest="command", help="Commandes") - swagger_parser = subparsers.add_parser( - "swagger", help="Gestion des utilisateurs Swagger" - ) - swagger_subparsers = swagger_parser.add_subparsers(dest="swagger_command") + swagger_parser = subparsers.add_parser("swagger", help="Gestion Swagger") + swagger_sub = swagger_parser.add_subparsers(dest="swagger_command") - add_parser = swagger_subparsers.add_parser("add", help="Ajouter un utilisateur") - add_parser.add_argument("username", help="Nom d'utilisateur") - add_parser.add_argument("password", help="Mot de passe") - add_parser.add_argument("--full-name", help="Nom complet (optionnel)") + add_p = swagger_sub.add_parser("add", help="Ajouter utilisateur") + add_p.add_argument("username", help="Nom d'utilisateur") + add_p.add_argument("password", help="Mot de passe") + add_p.add_argument("--full-name", help="Nom complet") - swagger_subparsers.add_parser("list", help="Lister les utilisateurs") + swagger_sub.add_parser("list", help="Lister utilisateurs") - delete_parser = swagger_subparsers.add_parser( - "delete", help="Supprimer un utilisateur" - ) - delete_parser.add_argument("username", help="Nom d'utilisateur") + del_p = swagger_sub.add_parser("delete", help="Supprimer utilisateur") + del_p.add_argument("username", help="Nom d'utilisateur") - apikey_parser = subparsers.add_parser("apikey", help="Gestion des clés API") - apikey_subparsers = apikey_parser.add_subparsers(dest="apikey_command") + apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API") + apikey_sub = apikey_parser.add_subparsers(dest="apikey_command") - create_parser = apikey_subparsers.add_parser("create", help="Créer une clé API") - create_parser.add_argument("name", help="Nom de la clé") - create_parser.add_argument("--description", help="Description (optionnel)") - create_parser.add_argument( - "--days", type=int, default=365, help="Jours avant expiration (défaut: 365)" - ) - create_parser.add_argument( - "--rate-limit", type=int, default=60, help="Requêtes par minute (défaut: 60)" - ) - create_parser.add_argument( - "--endpoints", - nargs="+", - help="Endpoints autorisés (ex: /clients /articles /devis/*)", - ) + create_p = apikey_sub.add_parser("create", help="Créer clé API") + create_p.add_argument("name", help="Nom de la clé") + create_p.add_argument("--description", help="Description") + create_p.add_argument("--days", type=int, default=365, help="Expiration (jours)") + create_p.add_argument("--rate-limit", type=int, default=60, help="Req/min") + create_p.add_argument("--endpoints", nargs="+", help="Endpoints autorisés") - apikey_subparsers.add_parser("list", help="Lister les clés API") + apikey_sub.add_parser("list", help="Lister clés") - revoke_parser = apikey_subparsers.add_parser("revoke", help="Révoquer une clé") - revoke_parser.add_argument("key_id", help="ID de la clé") + rev_p = apikey_sub.add_parser("revoke", help="Révoquer clé") + rev_p.add_argument("key_id", help="ID de la clé") - verify_parser = apikey_subparsers.add_parser("verify", help="Vérifier une clé") - verify_parser.add_argument("api_key", help="Clé API complète") + ver_p = apikey_sub.add_parser("verify", help="Vérifier clé") + ver_p.add_argument("api_key", help="Clé API complète") args = parser.parse_args() @@ -335,7 +333,7 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - logger.info("\n⏹️ Interrupted") + print("\n⏹️ Interrupted") sys.exit(0) except Exception as e: logger.error(f"❌ Erreur: {e}") From 28c8fb3008d004b9ecad3e94df3131c0ac7ddbcc Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:27:26 +0300 Subject: [PATCH 33/36] refactor(security): improve user management and session handling --- core/dependencies.py | 5 ++- scripts/manage_security.py | 85 ++++++++++++++------------------------ 2 files changed, 35 insertions(+), 55 deletions(-) diff --git a/core/dependencies.py b/core/dependencies.py index 76c85be..c1468dd 100644 --- a/core/dependencies.py +++ b/core/dependencies.py @@ -33,9 +33,12 @@ async def get_current_user_hybrid( virtual_user = User( id=f"api_key_{api_key_obj.id}", email=f"api_key_{api_key_obj.id}@virtual.local", - username=api_key_obj.name, + nom=api_key_obj.name, + prenom="API", + hashed_password="", role="api_client", is_active=True, + is_verified=True, ) virtual_user._is_api_key_user = True diff --git a/scripts/manage_security.py b/scripts/manage_security.py index e2c0297..cfeadcf 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,24 +1,6 @@ -#!/usr/bin/env python3 -""" -Script de gestion des utilisateurs Swagger et clés API -====================================================== - -Usage (depuis /app dans le container Docker): - python scripts/manage_security.py swagger add - python scripts/manage_security.py swagger list - python scripts/manage_security.py apikey create --endpoints "/clients" "/devis" - python scripts/manage_security.py apikey list - python scripts/manage_security.py apikey verify -""" - import sys +import os from pathlib import Path - -_script_dir = Path(__file__).resolve().parent -_app_dir = _script_dir.parent -if str(_app_dir) not in sys.path: - sys.path.insert(0, str(_app_dir)) - import asyncio import argparse import logging @@ -26,20 +8,26 @@ from datetime import datetime from sqlalchemy import select -from database.db_config import get_session +from database.db_config import async_session_factory +from database.models.user import User from database.models.api_key import SwaggerUser, ApiKey from services.api_key import ApiKeyService from security.auth import hash_password +_script_dir = Path(__file__).resolve().parent +_app_dir = _script_dir.parent + +sys.path.insert(0, str(_app_dir)) +os.chdir(str(_app_dir)) + + logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) - - async def add_swagger_user(username: str, password: str, full_name: str = None): """Ajouter un utilisateur Swagger""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) @@ -61,18 +49,17 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): logger.info(f"✅ Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") - break async def list_swagger_users(): """Lister tous les utilisateurs Swagger""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute(select(SwaggerUser)) users = result.scalars().all() if not users: - logger.info("📭 Aucun utilisateur Swagger") - break + logger.info("🔭 Aucun utilisateur Swagger") + return logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") for user in users: @@ -81,12 +68,11 @@ async def list_swagger_users(): logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") logger.info(f" Dernière connexion: {user.last_login or 'Jamais'}\n") - break async def delete_swagger_user(username: str): """Supprimer un utilisateur Swagger""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute( select(SwaggerUser).where(SwaggerUser.username == username) ) @@ -94,14 +80,11 @@ async def delete_swagger_user(username: str): if not user: logger.error(f"❌ Utilisateur '{username}' introuvable") - break + return await session.delete(user) await session.commit() logger.info(f"🗑️ Utilisateur Swagger supprimé: {username}") - break - - async def create_api_key( @@ -112,7 +95,7 @@ async def create_api_key( endpoints: list = None, ): """Créer une clé API""" - async for session in get_session(): + async with async_session_factory() as session: service = ApiKeyService(session) api_key_obj, api_key_plain = await service.create_api_key( @@ -148,18 +131,17 @@ async def create_api_key( logger.info("=" * 70) logger.info("⚠️ SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !") logger.info("=" * 70) - break async def list_api_keys(): """Lister toutes les clés API""" - async for session in get_session(): + async with async_session_factory() as session: service = ApiKeyService(session) keys = await service.list_api_keys() if not keys: - logger.info("📭 Aucune clé API") - break + logger.info("🔭 Aucune clé API") + return logger.info(f"🔑 {len(keys)} clé(s) API:\n") @@ -190,18 +172,17 @@ async def list_api_keys(): else: logger.info(" Endpoints: Tous") logger.info("") - break async def revoke_api_key(key_id: str): """Révoquer une clé API""" - async for session in get_session(): + async with async_session_factory() as session: result = await session.execute(select(ApiKey).where(ApiKey.id == key_id)) key = result.scalar_one_or_none() if not key: logger.error(f"❌ Clé API '{key_id}' introuvable") - break + return key.is_active = False key.revoked_at = datetime.now() @@ -209,18 +190,17 @@ async def revoke_api_key(key_id: str): logger.info(f"🗑️ Clé API révoquée: {key.name}") logger.info(f" ID: {key.id}") - break async def verify_api_key(api_key: str): """Vérifier une clé API""" - async for session in get_session(): + async with async_session_factory() as session: service = ApiKeyService(session) key = await service.verify_api_key(api_key) if not key: logger.error("❌ Clé API invalide ou expirée") - break + return logger.info("=" * 60) logger.info("✅ Clé API valide") @@ -242,9 +222,6 @@ async def verify_api_key(api_key: str): else: logger.info(" Endpoints autorisés: Tous") logger.info("=" * 60) - break - - async def main(): @@ -253,12 +230,12 @@ async def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Exemples: - python scripts/manage_security.py swagger add admin MyP@ssw0rd - python scripts/manage_security.py swagger list - python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 - python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" - python scripts/manage_security.py apikey list - python scripts/manage_security.py apikey verify sdk_live_xxxxx + python scripts/manage_security.py swagger add admin MyP@ssw0rd + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 + python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify sdk_live_xxxxx """, ) subparsers = parser.add_subparsers(dest="command", help="Commandes") @@ -333,7 +310,7 @@ if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: - print("\n⏹️ Interrupted") + print("\nℹ️ Interrupted") sys.exit(0) except Exception as e: logger.error(f"❌ Erreur: {e}") From 82d1d92e587a5ee9325aace77345cea4124c443d Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 15:35:06 +0300 Subject: [PATCH 34/36] refactor(scripts): improve import handling and path management --- scripts/manage_security.py | 69 ++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 18 deletions(-) diff --git a/scripts/manage_security.py b/scripts/manage_security.py index cfeadcf..59f7ee2 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -1,6 +1,40 @@ import sys import os from pathlib import Path + +_current_file = Path(__file__).resolve() +_script_dir = _current_file.parent +_app_dir = _script_dir.parent + +print(f"DEBUG: Script path: {_current_file}") +print(f"DEBUG: App dir: {_app_dir}") +print(f"DEBUG: Current working dir: {os.getcwd()}") + +if str(_app_dir) in sys.path: + sys.path.remove(str(_app_dir)) +sys.path.insert(0, str(_app_dir)) + +os.chdir(str(_app_dir)) + +print(f"DEBUG: sys.path[0]: {sys.path[0]}") +print(f"DEBUG: New working dir: {os.getcwd()}") + +_test_imports = [ + "database", + "database.db_config", + "database.models", + "services", + "security", +] + +print("\nDEBUG: Vérification des imports...") +for module in _test_imports: + try: + __import__(module) + print(f" ✅ {module}") + except ImportError as e: + print(f" ❌ {module}: {e}") + import asyncio import argparse import logging @@ -8,18 +42,17 @@ from datetime import datetime from sqlalchemy import select -from database.db_config import async_session_factory -from database.models.user import User -from database.models.api_key import SwaggerUser, ApiKey -from services.api_key import ApiKeyService -from security.auth import hash_password - -_script_dir = Path(__file__).resolve().parent -_app_dir = _script_dir.parent - -sys.path.insert(0, str(_app_dir)) -os.chdir(str(_app_dir)) - +try: + from database.db_config import async_session_factory + from database.models.user import User + from database.models.api_key import SwaggerUser, ApiKey + from services.api_key import ApiKeyService + from security.auth import hash_password +except ImportError as e: + print(f"\n❌ ERREUR D'IMPORT: {e}") + print(f" Vérifiez que vous êtes dans /app") + print(f" Commande correcte: cd /app && python scripts/manage_security.py ...") + sys.exit(1) logging.basicConfig(level=logging.INFO, format="%(levelname)s - %(message)s") logger = logging.getLogger(__name__) @@ -230,12 +263,12 @@ async def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Exemples: - python scripts/manage_security.py swagger add admin MyP@ssw0rd - python scripts/manage_security.py swagger list - python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 - python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" - python scripts/manage_security.py apikey list - python scripts/manage_security.py apikey verify sdk_live_xxxxx + python scripts/manage_security.py swagger add admin MyP@ssw0rd + python scripts/manage_security.py swagger list + python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100 + python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*" + python scripts/manage_security.py apikey list + python scripts/manage_security.py apikey verify sdk_live_xxxxx """, ) subparsers = parser.add_subparsers(dest="command", help="Commandes") From 67ef83c4e332f4c40983b879581e08df94c01575 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 16:01:54 +0300 Subject: [PATCH 35/36] refactor(security): improve authentication logging and endpoint checks --- middleware/security.py | 99 +++++++++++++++++++++----------------- scripts/manage_security.py | 24 ++++----- services/api_key.py | 34 ++++++++++--- 3 files changed, 92 insertions(+), 65 deletions(-) diff --git a/middleware/security.py b/middleware/security.py index 8b2d90a..e9ff831 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -1,11 +1,10 @@ - from fastapi import Request, status from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp from sqlalchemy import select -from typing import Optional, Callable +from typing import Callable from datetime import datetime import logging import base64 @@ -16,7 +15,6 @@ security = HTTPBasic() class SwaggerAuthMiddleware: - PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"] def __init__(self, app: ASGIApp): @@ -111,46 +109,11 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): "/redoc", "/openapi.json", "/", - "/auth/login", - "/auth/register", - "/auth/verify-email", - "/auth/reset-password", - "/auth/request-reset", - "/auth/refresh", + "/health", + "/auth", + "/api-keys/verify", ] - async def dispatch(self, request: Request, call_next: Callable): - path = request.url.path - method = request.method - - if self._is_excluded_path(path): - return await call_next(request) - - auth_header = request.headers.get("Authorization") - has_jwt = auth_header and auth_header.startswith("Bearer ") - - api_key = request.headers.get("X-API-Key") - has_api_key = bool(api_key) - - if has_jwt: - logger.debug(f"JWT détecté pour {method} {path}") - return await call_next(request) - - if has_api_key: - logger.debug(f"API Key détectée pour {method} {path}") - return await self._handle_api_key_auth( - request, api_key, path, method, call_next - ) - - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "detail": "Authentification requise", - "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", - }, - headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, - ) - def _is_excluded_path(self, path: str) -> bool: """Vérifie si le chemin est exclu de l'authentification""" if path == "/": @@ -164,6 +127,41 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): return False + async def dispatch(self, request: Request, call_next: Callable): + path = request.url.path + method = request.method + + if self._is_excluded_path(path): + logger.debug(f" Route publique: {method} {path}") + return await call_next(request) + + auth_header = request.headers.get("Authorization") + has_jwt = auth_header and auth_header.startswith("Bearer ") + + api_key = request.headers.get("X-API-Key") + has_api_key = bool(api_key) + + if has_jwt: + logger.debug(f" JWT détecté pour {method} {path}") + return await call_next(request) + + if has_api_key: + logger.debug(f" API Key détectée pour {method} {path}") + return await self._handle_api_key_auth( + request, api_key, path, method, call_next + ) + + logger.warning(f" Aucune authentification pour {method} {path}") + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={ + "detail": "Authentification requise", + "hint": "Utilisez 'X-API-Key: sdk_live_xxx' ou 'Authorization: Bearer '", + "path": path, + }, + headers={"WWW-Authenticate": 'Bearer realm="API", charset="UTF-8"'}, + ) + async def _handle_api_key_auth( self, request: Request, @@ -179,9 +177,11 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): async with async_session_factory() as session: service = ApiKeyService(session) + api_key_obj = await service.verify_api_key(api_key) if not api_key_obj: + logger.warning(f" Clé API invalide pour {method} {path}") return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -192,6 +192,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): is_allowed, rate_info = await service.check_rate_limit(api_key_obj) if not is_allowed: + logger.warning(f"⚠️ Rate limit dépassé: {api_key_obj.name}") return JSONResponse( status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={"detail": "Rate limit dépassé"}, @@ -203,24 +204,32 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): has_access = await service.check_endpoint_access(api_key_obj, path) if not has_access: + logger.warning( + f"Accès refusé: {api_key_obj.name} → {method} {path}" + ) return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={ "detail": "Accès non autorisé à cet endpoint", "endpoint": path, - "api_key": api_key_obj.key_prefix + "...", + "api_key_name": api_key_obj.name, + "allowed_endpoints": ( + api_key_obj.allowed_endpoints + if api_key_obj.allowed_endpoints + else "Tous" + ), }, ) request.state.api_key = api_key_obj request.state.authenticated_via = "api_key" - logger.info(f"✓ API Key valide: {api_key_obj.name} → {method} {path}") + logger.info(f"✅ API Key valide: {api_key_obj.name} → {method} {path}") return await call_next(request) except Exception as e: - logger.error(f"Erreur validation API Key: {e}", exc_info=True) + logger.error(f" Erreur validation API Key: {e}", exc_info=True) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={"detail": "Erreur interne lors de la validation"}, @@ -243,7 +252,7 @@ def get_auth_method(request: Request) -> str: __all__ = [ "SwaggerAuthMiddleware", "ApiKeyMiddlewareHTTP", - "ApiKeyMiddleware", # Alias + "ApiKeyMiddleware", "get_api_key_from_request", "get_auth_method", ] diff --git a/scripts/manage_security.py b/scripts/manage_security.py index 59f7ee2..6c5ac01 100644 --- a/scripts/manage_security.py +++ b/scripts/manage_security.py @@ -31,9 +31,9 @@ print("\nDEBUG: Vérification des imports...") for module in _test_imports: try: __import__(module) - print(f" ✅ {module}") + print(f" {module}") except ImportError as e: - print(f" ❌ {module}: {e}") + print(f" {module}: {e}") import asyncio import argparse @@ -49,7 +49,7 @@ try: from services.api_key import ApiKeyService from security.auth import hash_password except ImportError as e: - print(f"\n❌ ERREUR D'IMPORT: {e}") + print(f"\n ERREUR D'IMPORT: {e}") print(f" Vérifiez que vous êtes dans /app") print(f" Commande correcte: cd /app && python scripts/manage_security.py ...") sys.exit(1) @@ -67,7 +67,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): existing = result.scalar_one_or_none() if existing: - logger.error(f"❌ L'utilisateur '{username}' existe déjà") + logger.error(f" L'utilisateur '{username}' existe déjà") return swagger_user = SwaggerUser( @@ -80,7 +80,7 @@ async def add_swagger_user(username: str, password: str, full_name: str = None): session.add(swagger_user) await session.commit() - logger.info(f"✅ Utilisateur Swagger créé: {username}") + logger.info(f" Utilisateur Swagger créé: {username}") logger.info(f" Nom complet: {swagger_user.full_name}") @@ -96,7 +96,7 @@ async def list_swagger_users(): logger.info(f"👥 {len(users)} utilisateur(s) Swagger:\n") for user in users: - status = "✅" if user.is_active else "❌" + status = "" if user.is_active else "" logger.info(f" {status} {user.username}") logger.info(f" Nom: {user.full_name}") logger.info(f" Créé: {user.created_at}") @@ -112,7 +112,7 @@ async def delete_swagger_user(username: str): user = result.scalar_one_or_none() if not user: - logger.error(f"❌ Utilisateur '{username}' introuvable") + logger.error(f" Utilisateur '{username}' introuvable") return await session.delete(user) @@ -182,7 +182,7 @@ async def list_api_keys(): is_valid = key.is_active and ( not key.expires_at or key.expires_at > datetime.now() ) - status = "✅" if is_valid else "❌" + status = "" if is_valid else "" logger.info(f" {status} {key.name:<30} ({key.key_prefix}...)") logger.info(f" ID: {key.id}") @@ -214,7 +214,7 @@ async def revoke_api_key(key_id: str): key = result.scalar_one_or_none() if not key: - logger.error(f"❌ Clé API '{key_id}' introuvable") + logger.error(f" Clé API '{key_id}' introuvable") return key.is_active = False @@ -232,11 +232,11 @@ async def verify_api_key(api_key: str): key = await service.verify_api_key(api_key) if not key: - logger.error("❌ Clé API invalide ou expirée") + logger.error(" Clé API invalide ou expirée") return logger.info("=" * 60) - logger.info("✅ Clé API valide") + logger.info(" Clé API valide") logger.info("=" * 60) logger.info(f" Nom: {key.name}") logger.info(f" ID: {key.id}") @@ -346,7 +346,7 @@ if __name__ == "__main__": print("\nℹ️ Interrupted") sys.exit(0) except Exception as e: - logger.error(f"❌ Erreur: {e}") + logger.error(f" Erreur: {e}") import traceback traceback.print_exc() diff --git a/services/api_key.py b/services/api_key.py index ad3cf6f..04e271e 100644 --- a/services/api_key.py +++ b/services/api_key.py @@ -134,7 +134,7 @@ class ApiKeyService: api_key_obj.revoked_at = datetime.now() await self.session.commit() - logger.info(f" Clé API révoquée: {api_key_obj.name}") + logger.info(f"🗑️ Clé API révoquée: {api_key_obj.name}") return True async def get_by_id(self, key_id: str) -> Optional[ApiKey]: @@ -150,24 +150,42 @@ class ApiKeyService: } async def check_endpoint_access(self, api_key_obj: ApiKey, endpoint: str) -> bool: - """Vérifie si la clé a accès à un endpoint spécifique""" if not api_key_obj.allowed_endpoints: + logger.debug( + f"🔓 API Key {api_key_obj.name}: Aucune restriction d'endpoint" + ) return True try: allowed = json.loads(api_key_obj.allowed_endpoints) + if "*" in allowed or "/*" in allowed: + logger.debug(f"🔓 API Key {api_key_obj.name}: Accès global autorisé") + return True + for pattern in allowed: - if pattern == "*": - return True - if pattern.endswith("*"): - prefix = pattern[:-1] - if endpoint.startswith(prefix): - return True if pattern == endpoint: + logger.debug(f" Match exact: {pattern} == {endpoint}") return True + if pattern.endswith("/*"): + base = pattern[:-2] # "/clients/*" → "/clients" + if endpoint == base or endpoint.startswith(base + "/"): + logger.debug(f" Match wildcard: {pattern} ↔ {endpoint}") + return True + + elif pattern.endswith("*"): + base = pattern[:-1] # "/clients*" → "/clients" + if endpoint.startswith(base): + logger.debug(f" Match prefix: {pattern} ↔ {endpoint}") + return True + + logger.warning( + f" API Key {api_key_obj.name}: Accès refusé à {endpoint}\n" + f" Endpoints autorisés: {allowed}" + ) return False + except json.JSONDecodeError: logger.error(f" Erreur parsing allowed_endpoints pour {api_key_obj.id}") return False From 211dd4fd23025e3835af6564ac72345102c8285e Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Tue, 20 Jan 2026 16:18:04 +0300 Subject: [PATCH 36/36] fix(security): improve api key authentication and error handling --- api.py | 14 ++++++++-- middleware/security.py | 59 ++++++++++++++++++++++++++---------------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/api.py b/api.py index 97d4425..7eb5984 100644 --- a/api.py +++ b/api.py @@ -187,8 +187,18 @@ def custom_openapi(): ) openapi_schema["components"]["securitySchemes"] = { - "HTTPBearer": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, - "ApiKeyAuth": {"type": "apiKey", "in": "header", "name": "X-API-Key"}, + "HTTPBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Authentification JWT pour utilisateurs (POST /auth/login)", + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Clé API pour intégrations externes (format: sdk_live_xxx)", + }, } openapi_schema["security"] = [{"HTTPBearer": []}, {"ApiKeyAuth": []}] diff --git a/middleware/security.py b/middleware/security.py index e9ff831..98801dc 100644 --- a/middleware/security.py +++ b/middleware/security.py @@ -132,26 +132,30 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): method = request.method if self._is_excluded_path(path): - logger.debug(f" Route publique: {method} {path}") return await call_next(request) auth_header = request.headers.get("Authorization") - has_jwt = auth_header and auth_header.startswith("Bearer ") + api_key_header = request.headers.get("X-API-Key") - api_key = request.headers.get("X-API-Key") - has_api_key = bool(api_key) + if auth_header and auth_header.startswith("Bearer "): + token = auth_header.split(" ")[1] - if has_jwt: - logger.debug(f" JWT détecté pour {method} {path}") - return await call_next(request) + if token.startswith("sdk_live_"): + logger.warning( + " API Key envoyée dans Authorization au lieu de X-API-Key" + ) + api_key_header = token + else: + logger.debug(f" JWT détecté pour {method} {path}") + return await call_next(request) - if has_api_key: + if api_key_header: logger.debug(f" API Key détectée pour {method} {path}") return await self._handle_api_key_auth( - request, api_key, path, method, call_next + request, api_key_header, path, method, call_next ) - logger.warning(f" Aucune authentification pour {method} {path}") + logger.warning(f" Aucune authentification: {method} {path}") return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -170,7 +174,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): method: str, call_next: Callable, ): - """Gère l'authentification par API Key""" + """Gère l'authentification par API Key avec vérification STRICTE""" try: from database.db_config import async_session_factory from services.api_key import ApiKeyService @@ -181,7 +185,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): api_key_obj = await service.verify_api_key(api_key) if not api_key_obj: - logger.warning(f" Clé API invalide pour {method} {path}") + logger.warning(f" Clé API invalide: {method} {path}") return JSONResponse( status_code=status.HTTP_401_UNAUTHORIZED, content={ @@ -192,7 +196,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): is_allowed, rate_info = await service.check_rate_limit(api_key_obj) if not is_allowed: - logger.warning(f"⚠️ Rate limit dépassé: {api_key_obj.name}") + logger.warning(f"⚠️ Rate limit: {api_key_obj.name}") return JSONResponse( status_code=status.HTTP_429_TOO_MANY_REQUESTS, content={"detail": "Rate limit dépassé"}, @@ -203,28 +207,37 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): ) has_access = await service.check_endpoint_access(api_key_obj, path) + if not has_access: - logger.warning( - f"Accès refusé: {api_key_obj.name} → {method} {path}" + import json + + allowed = ( + json.loads(api_key_obj.allowed_endpoints) + if api_key_obj.allowed_endpoints + else ["Tous"] ) + + logger.warning( + f" ACCÈS REFUSÉ: {api_key_obj.name}\n" + f" Endpoint demandé: {path}\n" + f" Endpoints autorisés: {allowed}" + ) + return JSONResponse( status_code=status.HTTP_403_FORBIDDEN, content={ "detail": "Accès non autorisé à cet endpoint", - "endpoint": path, + "endpoint_requested": path, "api_key_name": api_key_obj.name, - "allowed_endpoints": ( - api_key_obj.allowed_endpoints - if api_key_obj.allowed_endpoints - else "Tous" - ), + "allowed_endpoints": allowed, + "hint": "Cette clé API n'a pas accès à cet endpoint. Contactez l'administrateur.", }, ) request.state.api_key = api_key_obj request.state.authenticated_via = "api_key" - logger.info(f"✅ API Key valide: {api_key_obj.name} → {method} {path}") + logger.info(f" ACCÈS AUTORISÉ: {api_key_obj.name} → {method} {path}") return await call_next(request) @@ -232,7 +245,7 @@ class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware): logger.error(f" Erreur validation API Key: {e}", exc_info=True) return JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - content={"detail": "Erreur interne lors de la validation"}, + content={"detail": f"Erreur interne: {str(e)}"}, )