Compare commits

..

2 commits

Author SHA1 Message Date
Fanilo-Nantenaina
e97ff73e16 feat(auth): implement comprehensive security enhancements 2026-01-02 17:56:28 +03:00
Fanilo-Nantenaina
81843dfaee Better file consistency 2026-01-02 14:44:24 +03:00
86 changed files with 5767 additions and 9532 deletions

View file

@ -1,32 +1,97 @@
# ============================================
# Configuration Linux VPS - API Principale
# ============================================
# === Environment ===
ENVIRONMENT=development
# Options: development, staging, production
# === Sage Gateway Windows ===
SAGE_GATEWAY_URL=http://192.168.1.50:8100
SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f
# === JWT & Authentication ===
# IMPORTANT: Generer des secrets uniques et forts en production
# python -c "import secrets; print(secrets.token_urlsafe(64))"
JWT_SECRET=CHANGE_ME_IN_PRODUCTION_USE_STRONG_SECRET_64_CHARS_MIN
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
CSRF_TOKEN_EXPIRE_MINUTES=60
# === Base de données ===
# === Cookie Settings ===
COOKIE_DOMAIN=
# Laisser vide pour localhost, sinon ".example.com" pour sous-domaines
COOKIE_SECURE=false
# Mettre true en production avec HTTPS
COOKIE_SAMESITE=strict
# Options: strict, lax, none
COOKIE_HTTPONLY=true
COOKIE_ACCESS_TOKEN_NAME=access_token
COOKIE_REFRESH_TOKEN_NAME=refresh_token
COOKIE_CSRF_TOKEN_NAME=csrf_token
# === Redis (Token Blacklist & Rate Limiting) ===
REDIS_URL=redis://localhost:6379/0
REDIS_PASSWORD=
REDIS_SSL=false
TOKEN_BLACKLIST_PREFIX=blacklist:
RATE_LIMIT_PREFIX=ratelimit:
# === Rate Limiting ===
RATE_LIMIT_LOGIN_ATTEMPTS=5
RATE_LIMIT_LOGIN_WINDOW_MINUTES=15
RATE_LIMIT_API_REQUESTS=100
RATE_LIMIT_API_WINDOW_SECONDS=60
# === Password Security ===
PASSWORD_MIN_LENGTH=8
PASSWORD_REQUIRE_UPPERCASE=true
PASSWORD_REQUIRE_LOWERCASE=true
PASSWORD_REQUIRE_DIGIT=true
PASSWORD_REQUIRE_SPECIAL=true
ACCOUNT_LOCKOUT_THRESHOLD=5
ACCOUNT_LOCKOUT_DURATION_MINUTES=30
# === Device Fingerprint ===
FINGERPRINT_SECRET=
# Si vide, utilise JWT_SECRET
FINGERPRINT_COMPONENTS=user_agent,accept_language,accept_encoding
# === Refresh Token Rotation ===
REFRESH_TOKEN_ROTATION_ENABLED=true
REFRESH_TOKEN_REUSE_WINDOW_SECONDS=10
# === Database ===
DATABASE_URL=sqlite+aiosqlite:///./data/sage_dataven.db
# PostgreSQL: postgresql+asyncpg://user:password@localhost:5432/dbname
# === SMTP ===
SMTP_HOST=smtp.office365.com
# === Sage Gateway (Windows) ===
SAGE_GATEWAY_URL=http://windows-server:5000
SAGE_GATEWAY_TOKEN=your_gateway_token
# === Frontend ===
FRONTEND_URL=http://localhost:3000
# === SMTP (Email) ===
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=commercial@monentreprise.fr
SMTP_PASSWORD=MonMotDePasseEmail123!
SMTP_FROM=commercial@monentreprise.fr
SMTP_USER=noreply@example.com
SMTP_PASSWORD=your_smtp_password
SMTP_FROM=noreply@example.com
SMTP_USE_TLS=true
# === Universign ===
UNIVERSIGN_API_KEY=your_real_universign_key_here
# === Universign (Signature electronique) ===
UNIVERSIGN_API_KEY=your_universign_api_key
UNIVERSIGN_API_URL=https://api.universign.com/v1
# === API ===
# === API Server ===
API_HOST=0.0.0.0
API_PORT=8002
API_RELOAD=False
API_PORT=8000
API_RELOAD=true
# Mettre false en production
# === Email Queue ===
MAX_EMAIL_WORKERS=3
# === CORS ===
# Liste separee par virgules des origines autorisees
CORS_ORIGINS=["*"]
# === Logs ===
LOG_LEVEL=INFO
# === Sage Document Types ===
SAGE_TYPE_DEVIS=0
SAGE_TYPE_BON_COMMANDE=10
SAGE_TYPE_PREPARATION=20
SAGE_TYPE_BON_LIVRAISON=30
SAGE_TYPE_BON_RETOUR=40
SAGE_TYPE_BON_AVOIR=50
SAGE_TYPE_FACTURE=60

9
.gitignore vendored
View file

@ -39,12 +39,3 @@ data/*.db.bak
*.db
tools/
.trunk
.env.staging
.env.production
.trunk
*clean*.py

9
.trunk/.gitignore vendored
View file

@ -1,9 +0,0 @@
*out
*logs
*actions
*notifications
*tools
plugins
user_trunk.yaml
user.yaml
tmp

View file

@ -1,32 +0,0 @@
# 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

View file

@ -1,78 +1,23 @@
# ================================
# Base
# ================================
FROM python:3.12-slim AS base
# Backend Dockerfile
FROM python:3.12-slim
WORKDIR /app
# 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/*
# Copier et installer les dépendances
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
RUN pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# 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"]
# ================================
# STAGING
# ================================
FROM base AS staging
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=staging
# Lancer l'API et initialiser la DB au démarrage
# CMD ["sh", "-c", "uvicorn api:app --host 0.0.0.0 --port 8000"]
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"]
CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000"]

2370
api.py

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,12 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
from typing import List, Optional
from enum import Enum
class Environment(str, Enum):
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
class Settings(BaseSettings):
@ -7,11 +14,60 @@ class Settings(BaseSettings):
env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore"
)
jwt_secret: str
jwt_algorithm: str
access_token_expire_minutes: int
refresh_token_expire_days: int
# === Environment ===
environment: Environment = Environment.DEVELOPMENT
# === JWT & Auth ===
jwt_secret: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
csrf_token_expire_minutes: int = 60
# === Cookie Settings ===
cookie_domain: Optional[str] = None
cookie_secure: bool = True
cookie_samesite: str = "strict"
cookie_httponly: bool = True
cookie_access_token_name: str = "access_token"
cookie_refresh_token_name: str = "refresh_token"
cookie_csrf_token_name: str = "csrf_token"
# === Redis (Token Blacklist & Rate Limiting) ===
redis_url: str = "redis://localhost:6379/0"
redis_password: Optional[str] = None
redis_ssl: bool = False
token_blacklist_prefix: str = "blacklist:"
rate_limit_prefix: str = "ratelimit:"
# === Rate Limiting ===
rate_limit_login_attempts: int = 5
rate_limit_login_window_minutes: int = 15
rate_limit_api_requests: int = 100
rate_limit_api_window_seconds: int = 60
# === Security ===
password_min_length: int = 8
password_require_uppercase: bool = True
password_require_lowercase: bool = True
password_require_digit: bool = True
password_require_special: bool = True
account_lockout_threshold: int = 5
account_lockout_duration_minutes: int = 30
# === Fingerprint ===
fingerprint_secret: str = ""
fingerprint_components: List[str] = [
"user_agent",
"accept_language",
"accept_encoding",
]
# === Refresh Token Rotation ===
refresh_token_rotation_enabled: bool = True
refresh_token_reuse_window_seconds: int = 10
# === Sage Types ===
SAGE_TYPE_DEVIS: int = 0
SAGE_TYPE_BON_COMMANDE: int = 10
SAGE_TYPE_PREPARATION: int = 20
@ -20,12 +76,15 @@ class Settings(BaseSettings):
SAGE_TYPE_BON_AVOIR: int = 50
SAGE_TYPE_FACTURE: int = 60
# === Sage Gateway ===
sage_gateway_url: str
sage_gateway_token: str
frontend_url: str
# === Database ===
database_url: str = "sqlite+aiosqlite:///./data/sage_dataven.db"
# === SMTP ===
smtp_host: str
smtp_port: int = 587
smtp_user: str
@ -33,18 +92,30 @@ class Settings(BaseSettings):
smtp_from: str
smtp_use_tls: bool = True
# === Universign ===
universign_api_key: str
universign_api_url: str
api_host: str
api_port: int
api_reload: bool = False
# === API ===
api_host: str = "0.0.0.0"
api_port: int = 8000
api_reload: bool = True
# === Email Queue ===
max_email_workers: int = 3
max_retry_attempts: int = 3
retry_delay_seconds: int = 3
# === CORS ===
cors_origins: List[str] = ["*"]
@property
def is_production(self) -> bool:
return self.environment == Environment.PRODUCTION
@property
def is_development(self) -> bool:
return self.environment == Environment.DEVELOPMENT
settings = Settings()

View file

@ -1,125 +0,0 @@
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)

View file

@ -1,75 +1,56 @@
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 typing import Optional, Tuple
from datetime import datetime
import logging
from database import get_session, User
from security.auth import decode_token
from database import get_session
from database import User, AuditEventType
from services.token_service import TokenService
from services.audit_service import AuditService
from security.cookies import CookieManager
from security.fingerprint import DeviceFingerprint, get_client_ip
from security.csrf import CSRFProtection
security = HTTPBearer(auto_error=False)
logger = logging.getLogger(__name__)
async def get_current_user_hybrid(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
session: AsyncSession = Depends(get_session),
async def get_current_user(
request: Request, session: AsyncSession = Depends(get_session)
) -> User:
api_key_obj = getattr(request.state, "api_key", None)
token = CookieManager.get_access_token(request)
if api_key_obj:
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:
user._is_api_key_user = True
user._api_key_obj = api_key_obj
return user
virtual_user = User(
id=f"api_key_{api_key_obj.id}",
email=f"api_key_{api_key_obj.id}@virtual.local",
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
virtual_user._api_key_obj = api_key_obj
return virtual_user
if not credentials:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentification requise (JWT ou API Key)",
detail="Authentification requise",
headers={"WWW-Authenticate": "Bearer"},
)
token = credentials.credentials
fingerprint_hash = DeviceFingerprint.generate_hash(request)
try:
payload = decode_token(token)
user_id: str = payload.get("sub")
payload = await TokenService.validate_access_token(token, fingerprint_hash)
if user_id is None:
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token invalide: user_id manquant",
detail="Token invalide ou expire",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token malformed",
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:
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Utilisateur introuvable",
@ -78,41 +59,133 @@ async def get_current_user_hybrid(
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Utilisateur inactif",
status_code=status.HTTP_403_FORBIDDEN, detail="Compte desactive"
)
if not user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Email non verifie"
)
if user.locked_until and user.locked_until > datetime.now():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte temporairement verrouille",
)
request.state.user = user
request.state.session_id = payload.get("sid")
return user
except InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token invalide: {str(e)}",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user_optional(
request: Request, session: AsyncSession = Depends(get_session)
) -> Optional[User]:
try:
return await get_current_user(request, session)
except HTTPException:
return None
def require_role_hybrid(*allowed_roles: str):
async def role_checker(user: User = Depends(get_current_user_hybrid)) -> User:
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 interdit. Rôles autorisés: {', '.join(allowed_roles)}",
detail=f"Acces refuse. Roles requis: {', '.join(allowed_roles)}",
)
return user
return role_checker
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 require_verified_email():
async def email_checker(user: User = Depends(get_current_user)) -> User:
if not user.is_verified:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verification email requise",
)
return user
return email_checker
def get_api_key_from_user(user: User):
"""Récupère l'objet ApiKey depuis un utilisateur (si applicable)"""
return getattr(user, "_api_key_obj", None)
async def verify_csrf_token(
request: Request, user: User = Depends(get_current_user)
) -> None:
if CSRFProtection.is_exempt(request):
return
session_id = getattr(request.state, "session_id", None)
if not CSRFProtection.validate_request(request, session_id):
logger.warning(
f"CSRF validation echouee pour user {user.id} "
f"sur {request.method} {request.url.path}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Verification CSRF echouee"
)
get_current_user = get_current_user_hybrid
require_role = require_role_hybrid
async def get_auth_context(
request: Request, session: AsyncSession = Depends(get_session)
) -> Tuple[Optional[User], str, str]:
ip_address = get_client_ip(request)
fingerprint_hash = DeviceFingerprint.generate_hash(request)
try:
user = await get_current_user(request, session)
except HTTPException:
user = None
return user, ip_address, fingerprint_hash
class AuthenticatedRoute:
def __init__(
self,
require_csrf: bool = True,
allowed_roles: Optional[Tuple[str, ...]] = None,
audit_event: Optional[AuditEventType] = None,
):
self.require_csrf = require_csrf
self.allowed_roles = allowed_roles
self.audit_event = audit_event
async def __call__(
self, request: Request, session: AsyncSession = Depends(get_session)
) -> User:
user = await get_current_user(request, session)
if self.allowed_roles and user.role not in self.allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Acces refuse pour ce role",
)
if self.require_csrf and not CSRFProtection.is_exempt(request):
session_id = getattr(request.state, "session_id", None)
if not CSRFProtection.validate_request(request, session_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Verification CSRF echouee",
)
if self.audit_event:
await AuditService.log_event(
session=session,
event_type=self.audit_event,
request=request,
user_id=user.id,
success=True,
)
return user
require_admin = require_role("admin")
require_manager = require_role("admin", "manager")
require_user = require_role("admin", "manager", "user")

View file

@ -5,7 +5,7 @@ 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 sage.sage_client import SageGatewayClient
from config.config import settings
import logging

View file

@ -152,7 +152,7 @@ templates_signature_email = {
</table>
<p style="color: #718096; font-size: 13px; line-height: 1.5; margin: 0;">
<strong> Signature électronique sécurisée</strong><br>
<strong>🔒 Signature électronique sécurisée</strong><br>
Votre signature est protégée par notre partenaire de confiance <strong>Universign</strong>,
certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera
horodaté de manière infalsifiable.

View file

@ -1,18 +0,0 @@
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"

View file

@ -5,30 +5,26 @@ from database.db_config import (
get_session,
close_db,
)
from database.models.generic_model import (
CacheMetadata,
AuditLog,
from database.models.generic_model import Base
from database.models.auth_models import (
User,
RefreshToken,
AuditLog,
AuditEventType,
LoginAttempt,
UserSession,
)
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 (
from database.Enum.status import (
StatutEmail,
StatutSignature,
)
from database.models.workflow import WorkflowLog
from database.models.universign import (
UniversignTransaction,
UniversignSigner,
UniversignSyncLog,
UniversignTransactionStatus,
LocalDocumentStatus,
UniversignSignerStatus,
SageDocumentType
)
__all__ = [
"engine",
@ -37,22 +33,16 @@ __all__ = [
"get_session",
"close_db",
"Base",
"User",
"RefreshToken",
"AuditLog",
"AuditEventType",
"LoginAttempt",
"UserSession",
"EmailLog",
"SignatureLog",
"WorkflowLog",
"CacheMetadata",
"AuditLog",
"StatutEmail",
"StatutSignature",
"User",
"RefreshToken",
"LoginAttempt",
"SageGatewayConfig",
"UniversignTransaction",
"UniversignSigner",
"UniversignSyncLog",
"UniversignTransactionStatus",
"LocalDocumentStatus",
"UniversignSignerStatus",
"SageDocumentType"
]

View file

@ -1,49 +1,20 @@
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 = settings.database_url
def _configure_sqlite_connection(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=30000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA cache_size=-64000") # 64MB
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA locking_mode=NORMAL")
cursor.close()
logger.debug("SQLite configuré avec WAL mode et busy_timeout=30s")
engine_kwargs = {
"echo": False,
"future": True,
"poolclass": NullPool,
}
if DATABASE_URL and "sqlite" in DATABASE_URL:
engine_kwargs["connect_args"] = {
"check_same_thread": False,
"timeout": 30,
}
engine = create_async_engine(DATABASE_URL, **engine_kwargs)
if DATABASE_URL and "sqlite" in DATABASE_URL:
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
_configure_sqlite_connection(dbapi_connection, connection_record)
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db")
engine = create_async_engine(
DATABASE_URL,
echo=False,
future=True,
poolclass=NullPool,
)
async_session_factory = async_sessionmaker(
engine,
@ -59,12 +30,6 @@ async def init_db():
logger.info("Tentative de connexion")
async with engine.begin() as conn:
logger.info("Connexion etablie")
if DATABASE_URL and "sqlite" in DATABASE_URL:
result = await conn.execute(text("PRAGMA journal_mode"))
journal_mode = result.scalar()
logger.info(f"SQLite journal_mode: {journal_mode}")
await conn.run_sync(Base.metadata.create_all)
logger.info("create_all execute")
@ -84,57 +49,3 @@ async def get_session() -> AsyncSession:
async def close_db():
await engine.dispose()
logger.info("Connexions DB fermées")
async def execute_with_sqlite_retry(
session: AsyncSession, statement, max_retries: int = 5, base_delay: float = 0.1
):
import asyncio
from sqlalchemy.exc import OperationalError
last_error = None
for attempt in range(max_retries):
try:
result = await session.execute(statement)
return result
except OperationalError as e:
last_error = e
if "database is locked" in str(e).lower():
delay = base_delay * (2**attempt)
logger.warning(
f"SQLite locked, tentative {attempt + 1}/{max_retries}, "
f"retry dans {delay:.2f}s"
)
await asyncio.sleep(delay)
else:
raise
raise last_error
async def commit_with_retry(
session: AsyncSession, max_retries: int = 5, base_delay: float = 0.1
):
import asyncio
from sqlalchemy.exc import OperationalError
last_error = None
for attempt in range(max_retries):
try:
await session.commit()
return
except OperationalError as e:
last_error = e
if "database is locked" in str(e).lower():
delay = base_delay * (2**attempt)
logger.warning(
f"SQLite locked lors du commit, tentative {attempt + 1}/{max_retries}, "
f"retry dans {delay:.2f}s"
)
await asyncio.sleep(delay)
else:
raise
raise last_error

54
database/init_db.py Normal file
View file

@ -0,0 +1,54 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from database import init_db
import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
print("\n" + "=" * 60)
print("Initialisation de la base de données délocalisée")
print("=" * 60 + "\n")
try:
logger.info("Debut de l'initialisation")
await init_db()
logger.info("Initialisation terminee")
print("\nBase de données créée avec succès !")
print("Fichier: sage_dataven.db")
print("\nTables 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("\nProchaines étapes:")
print(" 1. Configurer le fichier .env avec les 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://IP_DU_VPS:8000/docs")
print("\n" + "=" * 60 + "\n")
return True
except Exception as 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)

View file

@ -1,73 +0,0 @@
from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text
from typing import Optional, List
import json
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"<ApiKey(name='{self.name}', prefix='{self.key_prefix}', active={self.is_active})>"
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)
allowed_tags = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
last_login = Column(DateTime, nullable=True)
@property
def allowed_tags_list(self) -> Optional[List[str]]:
if self.allowed_tags:
try:
return json.loads(self.allowed_tags)
except json.JSONDecodeError:
return None
return None
@allowed_tags_list.setter
def allowed_tags_list(self, tags: Optional[List[str]]):
self.allowed_tags = json.dumps(tags) if tags is not None else None
def __repr__(self):
return f"<SwaggerUser(username='{self.username}', active={self.is_active})>"

View file

@ -0,0 +1,214 @@
from sqlalchemy import (
Column,
Integer,
String,
DateTime,
Boolean,
Text,
ForeignKey,
Index,
Enum as SQLEnum,
)
from sqlalchemy.orm import relationship
from datetime import datetime
from enum import Enum
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, index=True)
verification_token = Column(String(255), nullable=True, unique=True, index=True)
verification_token_expires = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, index=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)
password_changed_at = Column(DateTime, nullable=True)
must_change_password = Column(Boolean, default=False)
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)
last_login_ip = Column(String(45), nullable=True)
refresh_tokens = relationship(
"RefreshToken", back_populates="user", cascade="all, delete-orphan"
)
audit_logs = relationship(
"AuditLog", back_populates="user", cascade="all, delete-orphan"
)
def __repr__(self):
return f"<User {self.email} verified={self.is_verified}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True)
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
token_hash = Column(String(64), unique=True, nullable=False, index=True)
token_id = Column(String(32), unique=True, nullable=False, index=True)
fingerprint_hash = Column(String(64), nullable=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
is_revoked = Column(Boolean, default=False, index=True)
revoked_at = Column(DateTime, nullable=True)
revoked_reason = Column(String(100), nullable=True)
is_used = Column(Boolean, default=False)
used_at = Column(DateTime, nullable=True)
replaced_by = Column(String(36), nullable=True)
expires_at = Column(DateTime, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
last_used_at = Column(DateTime, nullable=True)
user = relationship("User", back_populates="refresh_tokens")
__table_args__ = (
Index("ix_refresh_tokens_user_valid", "user_id", "is_revoked", "expires_at"),
)
def __repr__(self):
return f"<RefreshToken {self.token_id[:8]}... user={self.user_id}>"
class AuditEventType(str, Enum):
LOGIN_SUCCESS = "login_success"
LOGIN_FAILED = "login_failed"
LOGOUT = "logout"
PASSWORD_CHANGE = "password_change"
PASSWORD_RESET_REQUEST = "password_reset_request"
PASSWORD_RESET_COMPLETE = "password_reset_complete"
EMAIL_VERIFICATION = "email_verification"
ACCOUNT_LOCKED = "account_locked"
ACCOUNT_UNLOCKED = "account_unlocked"
TOKEN_REFRESH = "token_refresh"
TOKEN_REVOKED = "token_revoked"
SUSPICIOUS_ACTIVITY = "suspicious_activity"
SESSION_CREATED = "session_created"
SESSION_TERMINATED = "session_terminated"
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(String(36), primary_key=True)
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
event_type = Column(SQLEnum(AuditEventType), nullable=False, index=True)
event_description = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True, index=True)
user_agent = Column(String(500), nullable=True)
fingerprint_hash = Column(String(64), nullable=True)
resource_type = Column(String(50), nullable=True)
resource_id = Column(String(100), nullable=True)
request_method = Column(String(10), nullable=True)
request_path = Column(String(500), nullable=True)
meta = Column("metadata", Text, nullable=True)
success = Column(Boolean, default=True)
failure_reason = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False, index=True)
user = relationship("User", back_populates="audit_logs")
__table_args__ = (
Index("ix_audit_logs_user_event", "user_id", "event_type", "created_at"),
Index("ix_audit_logs_ip_event", "ip_address", "event_type", "created_at"),
)
def __repr__(self):
return f"<AuditLog {self.event_type.value} user={self.user_id}>"
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=True, index=True)
user_agent = Column(String(500), nullable=True)
fingerprint_hash = Column(String(64), nullable=True)
success = Column(Boolean, default=False, index=True)
failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
__table_args__ = (
Index("ix_login_attempts_email_time", "email", "timestamp"),
Index("ix_login_attempts_ip_time", "ip_address", "timestamp"),
)
def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>"
class UserSession(Base):
__tablename__ = "user_sessions"
id = Column(String(36), primary_key=True)
user_id = Column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
session_token_hash = Column(String(64), unique=True, nullable=False, index=True)
refresh_token_id = Column(String(36), nullable=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
fingerprint_hash = Column(String(64), nullable=True)
location = Column(String(255), nullable=True)
is_active = Column(Boolean, default=True, index=True)
terminated_at = Column(DateTime, nullable=True)
termination_reason = Column(String(100), nullable=True)
created_at = Column(DateTime, default=datetime.now, nullable=False)
last_activity = Column(DateTime, default=datetime.now, nullable=False)
expires_at = Column(DateTime, nullable=False)
__table_args__ = (Index("ix_user_sessions_user_active", "user_id", "is_active"),)
def __repr__(self):
return f"<UserSession {self.id[:8]}... user={self.user_id}>"

View file

@ -8,7 +8,7 @@ from sqlalchemy import (
)
from datetime import datetime
from database.models.generic_model import Base
from database.enum.status import StatutEmail
from database.Enum.status import StatutEmail
class EmailLog(Base):

View file

@ -5,7 +5,6 @@ from sqlalchemy import (
DateTime,
Float,
Text,
Boolean,
)
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
@ -29,63 +28,3 @@ class CacheMetadata(Base):
def __repr__(self):
return f"<CacheMetadata type={self.cache_type} items={self.item_count}>"
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
action = Column(String(100), nullable=False, index=True)
ressource_type = Column(String(50), nullable=True)
ressource_id = Column(String(100), nullable=True, index=True)
utilisateur = Column(String(100), nullable=True)
ip_address = Column(String(45), nullable=True)
succes = Column(Boolean, default=True)
details = Column(Text, nullable=True)
erreur = Column(Text, nullable=True)
date_action = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<AuditLog {self.action} on {self.ressource_type}/{self.ressource_id}>"
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id = Column(String(36), primary_key=True)
user_id = Column(String(36), nullable=False, index=True)
token_hash = Column(String(255), nullable=False, unique=True, index=True)
device_info = Column(String(500), nullable=True)
ip_address = Column(String(45), nullable=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False)
is_revoked = Column(Boolean, default=False)
revoked_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<RefreshToken user={self.user_id} revoked={self.is_revoked}>"
class LoginAttempt(Base):
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, autoincrement=True)
email = Column(String(255), nullable=False, index=True)
ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False)
failure_reason = Column(String(255), nullable=True)
timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
def __repr__(self):
return f"<LoginAttempt {self.email} success={self.success}>"

View file

@ -22,9 +22,6 @@ class SageGatewayConfig(Base):
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)

View file

@ -9,7 +9,7 @@ from sqlalchemy import (
)
from datetime import datetime
from database.models.generic_model import Base
from database.enum.status import StatutSignature
from database.Enum.status import StatutSignature
class SignatureLog(Base):

View file

@ -1,272 +0,0 @@
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"
id = Column(String(36), primary_key=True)
transaction_id = Column(
String(255),
unique=True,
nullable=False,
index=True,
comment="ID Universign (ex: tr_abc123)",
)
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"
)
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"
)
local_status = Column(
SQLEnum(LocalDocumentStatus),
nullable=False,
default=LocalDocumentStatus.PENDING,
index=True,
comment="Statut métier simplifié pour l'UI",
)
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")
signers_data = Column(
Text, nullable=True, comment="JSON des signataires (snapshot)"
)
requester_email = Column(String(255), nullable=True)
requester_name = Column(String(255), nullable=True)
document_name = Column(String(500), nullable=True)
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)
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)
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")
signers = relationship(
"UniversignSigner", back_populates="transaction", cascade="all, delete-orphan"
)
sync_logs = relationship(
"UniversignSyncLog", back_populates="transaction", cascade="all, delete-orphan"
)
__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"<UniversignTransaction {self.transaction_id} "
f"sage={self.sage_document_id} "
f"status={self.universign_status.value}>"
)
class UniversignSigner(Base):
__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,
)
email = Column(String(255), nullable=False, index=True)
name = Column(String(255), nullable=True)
phone = Column(String(50), nullable=True)
status = Column(
SQLEnum(UniversignSignerStatus),
default=UniversignSignerStatus.WAITING,
nullable=False,
)
viewed_at = Column(DateTime, nullable=True)
signed_at = Column(DateTime, nullable=True)
refused_at = Column(DateTime, nullable=True)
refusal_reason = Column(Text, nullable=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
signature_method = Column(String(50), nullable=True)
order_index = Column(Integer, default=0)
transaction = relationship("UniversignTransaction", back_populates="signers")
def __repr__(self):
return f"<UniversignSigner {self.email} status={self.status.value}>"
class UniversignSyncLog(Base):
__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_type = Column(String(50), nullable=False, comment="webhook, polling, manual")
sync_timestamp = Column(DateTime, default=datetime.now, nullable=False, index=True)
previous_status = Column(String(50), nullable=True)
new_status = Column(String(50), nullable=True)
changes_detected = Column(Text, nullable=True, comment="JSON des changements")
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)
transaction = relationship("UniversignTransaction", back_populates="sync_logs")
def __repr__(self):
return f"<SyncLog {self.sync_type} at {self.sync_timestamp}>"
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")
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"<UniversignConfig {self.environment}>"

View file

@ -1,24 +0,0 @@
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/"]
interval: 30s
timeout: 10s
retries: 3

View file

@ -1,23 +0,0 @@
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/"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s

View file

@ -1,22 +0,0 @@
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/"]
interval: 30s
timeout: 10s
retries: 3

View file

@ -1,4 +1,11 @@
services:
backend:
build:
context: .
vps-sage-api:
build: .
container_name: vps-sage-api
env_file: .env
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8000:8000"
restart: unless-stopped

File diff suppressed because it is too large Load diff

View file

@ -1,35 +0,0 @@
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from database import init_db
import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
try:
logger.info("Debut de l'initialisation")
await init_db()
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"\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)

View file

@ -1,295 +1,181 @@
from fastapi import Request, status
from fastapi import Request, Response, status
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.types import ASGIApp, Receive, Send
from sqlalchemy import select
from typing import Callable, Optional
from datetime import datetime
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from typing import Set
import logging
import base64
import json
import time
from config.config import settings
from security.csrf import CSRFProtection
from security.fingerprint import get_client_ip
from services.redis_service import redis_service
logger = logging.getLogger(__name__)
security = HTTPBasic()
class SwaggerAuthMiddleware:
PROTECTED_PATHS = ["/docs", "/redoc", "/openapi.json"]
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope, receive: Receive, send: Send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope, receive=receive)
path = request.url.path
if not any(path.startswith(p) for p in self.PROTECTED_PATHS):
await self.app(scope, receive, send)
return
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 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)
swagger_user = await self._verify_credentials(credentials)
if not swagger_user:
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
if "state" not in scope:
scope["state"] = {}
scope["state"]["swagger_user"] = swagger_user
logger.info(
f"✓ Swagger auth: {swagger_user['username']} - tags: {swagger_user.get('allowed_tags', 'ALL')}"
)
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
) -> Optional[dict]:
from database.db_config import async_session_factory
from database.models.api_key import SwaggerUser
from security.auth import verify_password
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()
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 {
"id": swagger_user.id,
"username": swagger_user.username,
"allowed_tags": swagger_user.allowed_tags_list,
"is_active": swagger_user.is_active,
}
logger.warning(f"✗ Accès Swagger refusé: {credentials.username}")
return None
except Exception as e:
logger.error(f" Erreur vérification credentials: {e}", exc_info=True)
return None
class ApiKeyMiddlewareHTTP(BaseHTTPMiddleware):
EXCLUDED_PATHS = [
RATE_LIMIT_EXEMPT_PATHS: Set[str] = {
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/",
"/health",
"/auth",
"/api-keys/verify",
"/universign/webhook",
]
}
def _is_excluded_path(self, path: str) -> bool:
"""Vérifie si le chemin est exclu de l'authentification API Key"""
if path == "/":
return True
for excluded in self.EXCLUDED_PATHS:
if excluded == "/":
continue
if path == excluded or path.startswith(excluded + "/"):
return True
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
response = await call_next(request)
return False
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
async def dispatch(self, request: Request, call_next: Callable):
path = request.url.path
method = request.method
if settings.is_production:
response.headers["Strict-Transport-Security"] = (
"max-age=31536000; includeSubDomains"
)
if self._is_excluded_path(path):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Permissions-Policy"] = (
"geolocation=(), microphone=(), camera=()"
)
return response
class CSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if CSRFProtection.is_exempt(request):
return await call_next(request)
auth_header = request.headers.get("Authorization")
api_key_header = request.headers.get("X-API-Key")
if api_key_header:
api_key_header = api_key_header.strip()
if not api_key_header or api_key_header == "":
api_key_header = None
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ", 1)[1].strip()
if token.startswith("sdk_live_"):
if not CSRFProtection.validate_double_submit(request):
logger.warning(
" API Key envoyée dans Authorization au lieu de X-API-Key"
f"CSRF validation echouee: {request.method} {request.url.path} "
f"depuis {get_client_ip(request)}"
)
return await self._handle_api_key_auth(
request, token, path, method, call_next
)
logger.debug(f"JWT détecté pour {method} {path} → délégation à FastAPI")
request.state.authenticated_via = "jwt"
return await call_next(request)
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_header, path, method, call_next
)
logger.debug(f" Aucune auth pour {method} {path} → délégation à FastAPI")
return await call_next(request)
async def _handle_api_key_auth(
self,
request: Request,
api_key: str,
path: str,
method: str,
call_next: Callable,
):
try:
from database.db_config import async_session_factory
from services.api_key import ApiKeyService
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: {method} {path}")
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"detail": "Clé API invalide ou expirée",
"hint": "Vérifiez votre clé X-API-Key",
},
)
is_allowed, rate_info = await service.check_rate_limit(api_key_obj)
if not is_allowed:
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é"},
headers={
"X-RateLimit-Limit": str(rate_info["limit"]),
"X-RateLimit-Remaining": "0",
},
)
has_access = await service.check_endpoint_access(api_key_obj, path)
if not has_access:
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_requested": path,
"api_key_name": api_key_obj.name,
"allowed_endpoints": allowed,
"hint": "Cette clé API n'a pas accès à cet endpoint.",
},
content={"detail": "Verification CSRF echouee"},
)
request.state.api_key = api_key_obj
request.state.authenticated_via = "api_key"
logger.info(f" ACCÈS AUTORISÉ: {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)
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
path = request.url.path.rstrip("/")
if path in RATE_LIMIT_EXEMPT_PATHS:
return await call_next(request)
ip = get_client_ip(request)
key = f"api:{ip}"
window_seconds = settings.rate_limit_api_window_seconds
max_requests = settings.rate_limit_api_requests
try:
count = await redis_service.increment_rate_limit(key, window_seconds)
remaining = max(0, max_requests - count)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(max_requests)
response.headers["X-RateLimit-Remaining"] = str(remaining)
response.headers["X-RateLimit-Reset"] = str(window_seconds)
if count > max_requests:
logger.warning(
f"Rate limit depasse pour IP {ip}: {count}/{max_requests}"
)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": f"Erreur interne: {str(e)}"},
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": "Limite de requetes atteinte"},
headers={
"X-RateLimit-Limit": str(max_requests),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(window_seconds),
"Retry-After": str(window_seconds),
},
)
return response
ApiKeyMiddleware = ApiKeyMiddlewareHTTP
except Exception as e:
logger.error(f"Erreur rate limiting: {e}")
return await call_next(request)
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)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
start_time = time.time()
ip = get_client_ip(request)
method = request.method
path = request.url.path
response = await call_next(request)
duration_ms = (time.time() - start_time) * 1000
log_level = logging.INFO
if response.status_code >= 500:
log_level = logging.ERROR
elif response.status_code >= 400:
log_level = logging.WARNING
logger.log(
log_level,
f"{method} {path} - {response.status_code} - {duration_ms:.2f}ms - {ip}",
)
return response
def get_auth_method(request: Request) -> str:
"""Retourne la méthode d'authentification utilisée"""
return getattr(request.state, "authenticated_via", "none")
class FingerprintValidationMiddleware(BaseHTTPMiddleware):
VALIDATION_PATHS: Set[str] = {
"/auth/refresh",
"/auth/logout",
}
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
path = request.url.path.rstrip("/")
if path not in self.VALIDATION_PATHS:
return await call_next(request)
return await call_next(request)
def get_swagger_user_from_request(request: Request) -> Optional[dict]:
"""Récupère l'utilisateur Swagger depuis la requête"""
return getattr(request.state, "swagger_user", None)
def setup_security_middleware(app) -> None:
app.add_middleware(RequestLoggingMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(FingerprintValidationMiddleware)
__all__ = [
"SwaggerAuthMiddleware",
"ApiKeyMiddlewareHTTP",
"ApiKeyMiddleware",
"get_api_key_from_request",
"get_auth_method",
"get_swagger_user_from_request",
]
async def init_security_services() -> None:
try:
await redis_service.connect()
logger.info("Services de securite initialises")
except Exception as e:
logger.warning(f"Redis non disponible, fonctionnement en mode degrade: {e}")
async def shutdown_security_services() -> None:
try:
await redis_service.disconnect()
logger.info("Services de securite arretes")
except Exception as e:
logger.error(f"Erreur arret services securite: {e}")

View file

@ -1,10 +1,14 @@
fastapi
uvicorn[standard]
starlette
structlog
pydantic
pydantic-settings
reportlab
requests
msal
aiosmtplib
python-multipart
email-validator
@ -13,9 +17,13 @@ python-dotenv
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.2.0
PyJWT
sqlalchemy
sqlalchemy[asyncio]
aiosqlite
tenacity
asyncpg
httpx
redis[hiredis]

View file

@ -1,154 +0,0 @@
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,
}

View file

@ -1,27 +1,29 @@
from fastapi import APIRouter, Depends, HTTPException, status, Request
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from sqlalchemy import false, select
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime, timedelta
from typing import Optional
from typing import Optional, List
import uuid
import logging
from database import get_session, User, RefreshToken, LoginAttempt
from core.dependencies import get_current_user
from config.config import settings
from database import get_session
from database import User, RefreshToken, AuditEventType
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 security.cookies import CookieManager, set_auth_cookies
from security.fingerprint import DeviceFingerprint, get_client_ip
from security.rate_limiter import RateLimiter
from services.token_service import TokenService
from services.audit_service import AuditService
from services.email_service import AuthEmailService
from config.config import settings
import logging
from core.dependencies import get_current_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
@ -29,82 +31,50 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8)
password: str = Field(..., min_length=8, max_length=128)
nom: str = Field(..., min_length=2, max_length=100)
prenom: str = Field(..., min_length=2, max_length=100)
class Login(BaseModel):
class LoginRequest(BaseModel):
email: EmailStr
password: str
password: str = Field(..., min_length=1, max_length=128)
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = 86400
message: str
user: dict
expires_in: int
class RefreshTokenRequest(BaseModel):
refresh_token: str
class ForgotPassword(BaseModel):
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPassword(BaseModel):
class ResetPasswordRequest(BaseModel):
token: str
new_password: str = Field(..., min_length=8)
new_password: str = Field(..., min_length=8, max_length=128)
class VerifyEmail(BaseModel):
class VerifyEmailRequest(BaseModel):
token: str
class ResendVerification(BaseModel):
class ResendVerificationRequest(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()
class ChangePasswordRequest(BaseModel):
current_password: str
new_password: str = Field(..., min_length=8, max_length=128)
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) >= 15:
return False, "Trop de tentatives échouées. Réessayez dans 15 minutes."
return True, ""
class SessionResponse(BaseModel):
id: str
device_info: Optional[str]
ip_address: Optional[str]
created_at: str
last_used_at: Optional[str]
@router.post("/register", status_code=status.HTTP_201_CREATED)
@ -113,12 +83,18 @@ async def register(
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()
ip = get_client_ip(request)
if existing_user:
allowed, error_msg = await RateLimiter.check_registration_rate_limit(ip)
if not allowed:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est déjà utilisé"
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
)
result = await session.execute(select(User).where(User.email == data.email.lower()))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est deja utilise"
)
is_valid, error_msg = validate_password_strength(data.password)
@ -143,129 +119,33 @@ async def register(
await session.commit()
base_url = str(request.base_url).rstrip("/")
email_sent = AuthEmailService.send_verification_email(
data.email, verification_token, base_url
)
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})")
logger.info(f"Nouvel utilisateur inscrit: {data.email}")
return {
"success": True,
"message": "Inscription réussie ! Consultez votre email pour vérifier votre compte.",
"message": "Inscription reussie. Consultez votre email pour verifier 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,
@router.post("/login")
async def login(
data: LoginRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(User).where(User.email == data.email.lower()))
user = result.scalar_one_or_none()
ip = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
fingerprint_hash = DeviceFingerprint.generate_hash(request)
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é"
allowed, error_msg, _ = await RateLimiter.check_login_rate_limit(
data.email.lower(), ip
)
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:
if not allowed:
await AuditService.log_login_failed(session, request, data.email, "rate_limit")
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg
)
@ -274,24 +154,26 @@ async def login(
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",
await RateLimiter.record_login_attempt(data.email.lower(), ip, success=False)
await AuditService.record_login_attempt(
session, request, data.email, False, "invalid_credentials"
)
if user:
user.failed_login_attempts += 1
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
if user.failed_login_attempts >= 15:
user.locked_until = datetime.now() + timedelta(minutes=15)
if user.failed_login_attempts >= settings.account_lockout_threshold:
user.locked_until = datetime.now() + timedelta(
minutes=settings.account_lockout_duration_minutes
)
await AuditService.log_account_locked(
session, request, user.id, "too_many_failed_attempts"
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte verrouillé suite à trop de tentatives. Réessayez dans 15 minutes.",
detail=f"Compte verrouille. Reessayez dans {settings.account_lockout_duration_minutes} minutes.",
)
await session.commit()
@ -302,122 +184,250 @@ async def login(
)
if not user.is_active:
await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Compte désactivé"
await AuditService.log_login_failed(
session, request, data.email, "account_disabled", user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Compte désactivé"
status_code=status.HTTP_403_FORBIDDEN, detail="Compte desactive"
)
if not user.is_verified:
await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Email non vérifié"
await AuditService.log_login_failed(
session, request, data.email, "email_not_verified", user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Email non vérifié. Consultez votre boîte de réception.",
detail="Email non verifie. Consultez votre boite de reception.",
)
if user.locked_until and user.locked_until > datetime.now():
await log_login_attempt(
session, data.email.lower(), ip, user_agent, False, "Compte verrouillé"
await AuditService.log_login_failed(
session, request, data.email, "account_locked", user.id
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Compte temporairement verrouillé",
detail="Compte temporairement verrouille",
)
user.failed_login_attempts = 0
user.locked_until = None
user.last_login = datetime.now()
user.last_login_ip = ip
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],
(
access_token,
refresh_token,
csrf_token,
session_id,
) = await TokenService.create_token_pair(
session=session,
user=user,
fingerprint_hash=fingerprint_hash,
device_info=user_agent,
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)
await RateLimiter.record_login_attempt(data.email.lower(), ip, success=True)
await AuditService.log_login_success(session, request, user.id, user.email)
logger.info(f" Connexion réussie: {user.email}")
set_auth_cookies(response, access_token, refresh_token, csrf_token)
logger.info(f"Connexion reussie: {user.email} depuis {ip}")
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_jwt,
expires_in=86400,
message="Connexion reussie",
user={
"id": user.id,
"email": user.email,
"nom": user.nom,
"prenom": user.prenom,
"role": user.role,
},
expires_in=settings.access_token_expire_minutes * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_access_token(
data: RefreshTokenRequest, session: AsyncSession = Depends(get_session)
@router.post("/refresh")
async def refresh_tokens(
request: Request, response: Response, session: AsyncSession = Depends(get_session)
):
payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh":
refresh_token = CookieManager.get_refresh_token(request)
if not refresh_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide"
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token manquant"
)
user_id = payload.get("sub")
token_hash = hash_token(data.refresh_token)
ip = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")
fingerprint_hash = DeviceFingerprint.generate_hash(request)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.user_id == user_id,
RefreshToken.token_hash == token_hash,
not RefreshToken.is_revoked,
result = await TokenService.refresh_tokens(
session=session,
refresh_token=refresh_token,
fingerprint_hash=fingerprint_hash,
device_info=user_agent,
ip_address=ip,
)
)
token_record = result.scalar_one_or_none()
if not token_record:
if not result:
CookieManager.clear_all_auth_cookies(response)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token révoqué ou introuvable",
detail="Refresh token invalide ou expire",
)
if token_record.expires_at < datetime.now():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expiré"
new_access, new_refresh, new_csrf, session_id = result
await session.commit()
set_auth_cookies(response, new_access, new_refresh, new_csrf)
logger.debug("Tokens rafraichis avec succes")
return {
"message": "Tokens rafraichis",
"expires_in": settings.access_token_expire_minutes * 60,
}
@router.post("/logout")
async def logout(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
refresh_token = CookieManager.get_refresh_token(request)
if refresh_token:
await TokenService.revoke_token(
session=session, refresh_token=refresh_token, reason="user_logout"
)
result = await session.execute(select(User).where(User.id == user_id))
await AuditService.log_logout(session, request, user.id)
await session.commit()
CookieManager.clear_all_auth_cookies(response)
logger.info(f"Deconnexion: {user.email}")
return {"success": True, "message": "Deconnexion reussie"}
@router.post("/logout-all")
async def logout_all_sessions(
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
count = await TokenService.revoke_all_user_tokens(
session=session, user_id=user.id, reason="user_logout_all"
)
await AuditService.log_event(
session=session,
event_type=AuditEventType.SESSION_TERMINATED,
request=request,
user_id=user.id,
description=f"Toutes les sessions terminees ({count} tokens revoques)",
)
await session.commit()
CookieManager.clear_all_auth_cookies(response)
logger.info(f"Toutes les sessions terminees pour {user.email}: {count} tokens")
return {"success": True, "message": f"{count} session(s) terminee(s)"}
@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 or not user.is_active:
if not user:
return {
"success": False,
"message": "Token de verification invalide ou deja utilise.",
}
if (
user.verification_token_expires
and user.verification_token_expires < datetime.now()
):
return {
"success": False,
"message": "Token expire. Demandez un nouveau lien de verification.",
"expired": True,
}
user.is_verified = True
user.verification_token = None
user.verification_token_expires = None
await session.commit()
logger.info(f"Email verifie: {user.email}")
return {
"success": True,
"message": "Email verifie avec succes. Vous pouvez maintenant vous connecter.",
}
@router.post("/verify-email")
async def verify_email_post(
data: VerifyEmailRequest,
request: Request,
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_401_UNAUTHORIZED,
detail="Utilisateur introuvable ou désactivé",
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token de verification invalide",
)
new_access_token = create_access_token(
{"sub": user.id, "email": user.email, "role": user.role}
if (
user.verification_token_expires
and user.verification_token_expires < datetime.now()
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token expire. Demandez un nouveau lien de verification.",
)
logger.info(f" Token rafraîchi: {user.email}")
user.is_verified = True
user.verification_token = None
user.verification_token_expires = None
return TokenResponse(
access_token=new_access_token,
refresh_token=data.refresh_token,
expires_in=86400,
await AuditService.log_event(
session=session,
event_type=AuditEventType.EMAIL_VERIFICATION,
request=request,
user_id=user.id,
description="Email verifie avec succes",
)
await session.commit()
@router.post("/forgot-password")
async def forgot_password(
data: ForgotPassword,
logger.info(f"Email verifie: {user.email}")
return {"success": True, "message": "Email verifie avec succes."}
@router.post("/resend-verification")
async def resend_verification(
data: ResendVerificationRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
@ -427,7 +437,52 @@ async def forgot_password(
if not user:
return {
"success": True,
"message": "Si cet email existe, un lien de réinitialisation a été envoyé.",
"message": "Si cet email existe, un nouveau lien a ete envoye.",
}
if user.is_verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est deja verifie"
)
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 verification a ete envoye."}
@router.post("/forgot-password")
async def forgot_password(
data: ForgotPasswordRequest,
request: Request,
session: AsyncSession = Depends(get_session),
):
ip = get_client_ip(request)
allowed, error_msg = await RateLimiter.check_password_reset_rate_limit(
data.email.lower(), ip
)
if not 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()
await AuditService.log_password_reset_request(
session, request, data.email, user.id if user else None
)
if not user:
return {
"success": True,
"message": "Si cet email existe, un lien de reinitialisation a ete envoye.",
}
reset_token = generate_reset_token()
@ -435,24 +490,23 @@ async def forgot_password(
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("/")
)
frontend_url = settings.frontend_url or str(request.base_url).rstrip("/")
AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url)
logger.info(f" Reset password demandé: {user.email}")
logger.info(f"Reset password demande: {user.email}")
return {
"success": True,
"message": "Si cet email existe, un lien de réinitialisation a été envoyé.",
"message": "Si cet email existe, un lien de reinitialisation a ete envoye.",
}
@router.post("/reset-password")
async def reset_password(
data: ResetPassword, session: AsyncSession = Depends(get_session)
data: ResetPasswordRequest,
request: Request,
response: Response,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(select(User).where(User.reset_token == data.token))
user = result.scalar_one_or_none()
@ -460,13 +514,13 @@ async def reset_password(
if not user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Token de réinitialisation invalide",
detail="Token de reinitialisation invalide",
)
if user.reset_token_expires < datetime.now():
if user.reset_token_expires and 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.",
detail="Token expire. Demandez un nouveau lien.",
)
is_valid, error_msg = validate_password_strength(data.new_password)
@ -478,41 +532,67 @@ async def reset_password(
user.reset_token_expires = None
user.failed_login_attempts = 0
user.locked_until = None
user.password_changed_at = datetime.now()
await TokenService.revoke_all_user_tokens(
session=session, user_id=user.id, reason="password_reset"
)
await AuditService.log_password_change(session, request, user.id, "reset")
await session.commit()
CookieManager.clear_all_auth_cookies(response)
AuthEmailService.send_password_changed_notification(user.email)
logger.info(f" Mot de passe réinitialisé: {user.email}")
logger.info(f"Mot de passe reinitialise: {user.email}")
return {
"success": True,
"message": "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.",
"message": "Mot de passe reinitialise. Vous pouvez maintenant vous connecter.",
}
@router.post("/logout")
async def logout(
data: RefreshTokenRequest,
@router.post("/change-password")
async def change_password(
data: ChangePasswordRequest,
request: Request,
response: Response,
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
if not verify_password(data.current_password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Mot de passe actuel incorrect",
)
)
token_record = result.scalar_one_or_none()
if token_record:
token_record.is_revoked = True
token_record.revoked_at = datetime.now()
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.password_changed_at = datetime.now()
await TokenService.revoke_all_user_tokens(
session=session, user_id=user.id, reason="password_change"
)
await AuditService.log_password_change(session, request, user.id, "user_initiated")
await session.commit()
logger.info(f" Déconnexion: {user.email}")
CookieManager.clear_all_auth_cookies(response)
return {"success": True, "message": "Déconnexion réussie"}
AuthEmailService.send_password_changed_notification(user.email)
logger.info(f"Mot de passe change: {user.email}")
return {
"success": True,
"message": "Mot de passe modifie. Veuillez vous reconnecter.",
}
@router.get("/me")
@ -524,6 +604,69 @@ async def get_current_user_info(user: User = Depends(get_current_user)):
"prenom": user.prenom,
"role": user.role,
"is_verified": user.is_verified,
"created_at": user.created_at.isoformat(),
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login": user.last_login.isoformat() if user.last_login else None,
}
@router.get("/sessions", response_model=List[SessionResponse])
async def get_active_sessions(
session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user)
):
sessions = await TokenService.get_user_active_sessions(session, user.id)
return [SessionResponse(**s) for s in sessions]
@router.delete("/sessions/{session_id}")
async def revoke_session(
session_id: str,
request: Request,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
):
result = await session.execute(
select(RefreshToken).where(
RefreshToken.id == session_id,
RefreshToken.user_id == user.id,
RefreshToken.is_revoked.is_(false()),
)
)
token_record = result.scalar_one_or_none()
if not token_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session introuvable"
)
token_record.is_revoked = True
token_record.revoked_at = datetime.now()
token_record.revoked_reason = "user_revoked"
await AuditService.log_event(
session=session,
event_type=AuditEventType.SESSION_TERMINATED,
request=request,
user_id=user.id,
description=f"Session {session_id[:8]}... revoquee",
)
await session.commit()
return {"success": True, "message": "Session revoquee"}
@router.get("/csrf-token")
async def get_csrf_token(
request: Request, response: Response, user: User = Depends(get_current_user)
):
from security.auth import generate_session_id, create_csrf_token
session_id = getattr(request.state, "session_id", None)
if not session_id:
session_id = generate_session_id()
csrf_token = create_csrf_token(session_id)
CookieManager.set_csrf_token(response, csrf_token)
return {"csrf_token": csrf_token}

View file

@ -1,158 +0,0 @@
from fastapi import APIRouter, HTTPException, Query, Path
import httpx
import logging
from datetime import datetime
from schemas import EntrepriseSearch, EntrepriseSearchResponse
from utils.enterprise import (
calculer_tva_intracommunautaire,
mapper_resultat_api,
rechercher_entreprise_api,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/entreprises", tags=["Entreprises"])
@router.get("/search", response_model=EntrepriseSearchResponse)
async def rechercher_entreprise(
q: str = Query(..., min_length=2, description="Nom d'entreprise, SIREN ou SIRET"),
per_page: int = Query(5, ge=1, le=25, description="Nombre de résultats (max 25)"),
):
try:
logger.info(f" Recherche entreprise: '{q}'")
api_response = await rechercher_entreprise_api(q, per_page)
resultats_api = api_response.get("results", [])
if not resultats_api:
logger.info(f"Aucun résultat pour: {q}")
return EntrepriseSearchResponse(total_results=0, results=[], query=q)
entreprises = []
for data in resultats_api:
entreprise = mapper_resultat_api(data)
if entreprise:
entreprises.append(entreprise)
logger.info(f" {len(entreprises)} résultat(s) trouvé(s)")
return EntrepriseSearchResponse(
total_results=len(entreprises), results=entreprises, query=q
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur recherche entreprise: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Erreur lors de la recherche: {str(e)}"
)
@router.get("/siren/{siren}", response_model=EntrepriseSearch)
async def lire_entreprise_par_siren(
siren: str = Path(
...,
min_length=9,
max_length=9,
pattern=r"^\d{9}$",
description="Numéro SIREN (9 chiffres)",
),
):
try:
logger.info(f"Lecture entreprise SIREN: {siren}")
api_response = await rechercher_entreprise_api(siren, per_page=1)
resultats = api_response.get("results", [])
if not resultats:
raise HTTPException(
status_code=404,
detail=f"Aucune entreprise trouvée pour le SIREN {siren}",
)
entreprise_data = resultats[0]
if entreprise_data.get("siren") != siren:
raise HTTPException(status_code=404, detail=f"SIREN {siren} introuvable")
entreprise = mapper_resultat_api(entreprise_data)
if not entreprise:
raise HTTPException(
status_code=500,
detail="Erreur lors du traitement des données entreprise",
)
if not entreprise.is_active:
logger.warning(f" Entreprise CESSÉE: {siren}")
return entreprise
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture SIREN {siren}: {e}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Erreur lors de la récupération: {str(e)}"
)
@router.get("/tva/{siren}")
async def calculer_tva(
siren: str = Path(
...,
min_length=9,
max_length=9,
pattern=r"^\d{9}$",
description="Numéro SIREN (9 chiffres)",
),
):
tva_number = calculer_tva_intracommunautaire(siren)
if not tva_number:
raise HTTPException(status_code=400, detail=f"SIREN invalide: {siren}")
return {
"siren": siren,
"vat_number": tva_number,
"format": "FR + Clé (2 chiffres) + SIREN (9 chiffres)",
}
@router.get("/health")
async def health_check_api_sirene():
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
"https://recherche-entreprises.api.gouv.fr/search",
params={"q": "test", "per_page": 1},
)
if response.status_code == 200:
return {
"status": "healthy",
"api_sirene": "disponible",
"response_time_ms": response.elapsed.total_seconds() * 1000,
"timestamp": datetime.now().isoformat(),
}
else:
return {
"status": "degraded",
"api_sirene": f"statut {response.status_code}",
"timestamp": datetime.now().isoformat(),
}
except Exception as e:
logger.error(f"Health check failed: {e}")
return {
"status": "unhealthy",
"api_sirene": "indisponible",
"error": str(e),
"timestamp": datetime.now().isoformat(),
}

View file

@ -12,9 +12,9 @@ from schemas import (
SageGatewayCreate,
SageGatewayUpdate,
SageGatewayResponse,
SageGatewayList,
SageGatewayListResponse,
SageGatewayHealthCheck,
SageGatewayTest,
SageGatewayTestRequest,
SageGatewayStatsResponse,
CurrentGatewayInfo,
)
@ -41,7 +41,7 @@ async def create_gateway(
return SageGatewayResponse(**gateway_response_from_model(gateway))
@router.get("", response_model=SageGatewayList)
@router.get("", response_model=SageGatewayListResponse)
async def list_gateways(
include_deleted: bool = Query(False, description="Inclure les gateways supprimées"),
session: AsyncSession = Depends(get_session),
@ -54,7 +54,7 @@ async def list_gateways(
items = [SageGatewayResponse(**gateway_response_from_model(g)) for g in gateways]
return SageGatewayList(
return SageGatewayListResponse(
items=items,
total=len(items),
active_gateway=SageGatewayResponse(**gateway_response_from_model(active))
@ -268,7 +268,7 @@ async def check_gateway_health(
@router.post("/test", response_model=dict)
async def test_gateway_config(
data: SageGatewayTest,
data: SageGatewayTestRequest,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View file

@ -1,3 +1,4 @@
# sage_client.py
import requests
from typing import Dict, List, Optional
from config.config import settings
@ -400,181 +401,6 @@ class SageGatewayClient:
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 "",
"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 lire_informations_societe(self) -> Optional[Dict]:
"""Lit les informations de la société depuis P_DOSSIER"""
return self._get("/sage/societe/info").get("data")
def valider_facture(self, numero_facture: str) -> dict:
response = self._post(f"/sage/factures/{numero_facture}/valider", {})
return response.get("data", {})
def devalider_facture(self, numero_facture: str) -> dict:
response = self._post(f"/sage/factures/{numero_facture}/devalider", {})
return response.get("data", {})
def get_statut_validation(self, numero_facture: str) -> dict:
response = self._get(f"/sage/factures/{numero_facture}/statut-validation")
return response.get("data", {})
def regler_facture(
self,
numero_facture: str,
montant: float,
mode_reglement: int = 0,
date_reglement: str = None,
reference: str = "",
libelle: str = "",
code_journal: str = None,
devise_code: int = 0,
cours_devise: float = 1.0,
tva_encaissement: bool = False,
compte_general: str = None,
) -> dict:
"""Règle une facture"""
payload = {
"montant": montant,
"mode_reglement": mode_reglement,
"reference": reference,
"libelle": libelle,
"devise_code": devise_code,
"cours_devise": cours_devise,
"tva_encaissement": tva_encaissement,
}
if date_reglement:
payload["date_reglement"] = date_reglement
if code_journal:
payload["code_journal"] = code_journal
if compte_general:
payload["compte_general"] = compte_general
return self._post(f"/sage/factures/{numero_facture}/regler", payload).get(
"data", {}
)
def regler_factures_client(
self,
client_code: str,
montant_total: float,
mode_reglement: int = 0,
date_reglement: str = None,
reference: str = "",
libelle: str = "",
code_journal: str = None,
numeros_factures: list = None,
devise_code: int = 0,
cours_devise: float = 1.0,
tva_encaissement: bool = False,
) -> dict:
"""Règle plusieurs factures d'un client"""
payload = {
"client_code": client_code,
"montant_total": montant_total,
"mode_reglement": mode_reglement,
"reference": reference,
"libelle": libelle,
"devise_code": devise_code,
"cours_devise": cours_devise,
"tva_encaissement": tva_encaissement,
}
if date_reglement:
payload["date_reglement"] = date_reglement
if code_journal:
payload["code_journal"] = code_journal
if numeros_factures:
payload["numeros_factures"] = numeros_factures
return self._post("/sage/reglements/multiple", payload).get("data", {})
def get_reglements_facture(self, numero_facture: str) -> dict:
"""Récupère les règlements d'une facture"""
return self._get(f"/sage/factures/{numero_facture}/reglements").get("data", {})
def get_reglements_client(
self,
client_code: str,
date_debut: str = None,
date_fin: str = None,
inclure_soldees: bool = True,
) -> dict:
"""Récupère les règlements d'un client"""
params = {"inclure_soldees": inclure_soldees}
if date_debut:
params["date_debut"] = date_debut
if date_fin:
params["date_fin"] = date_fin
return self._get(f"/sage/clients/{client_code}/reglements", params=params).get(
"data", {}
)
def get_journaux_banque(self) -> dict:
return self._get("/sage/journaux/banque").get("data", {})
def get_modes_reglement(self) -> List[dict]:
"""Récupère les modes de règlement depuis Sage"""
return self._get("/sage/reglements/modes").get("data", {}).get("modes", [])
def get_devises(self) -> List[dict]:
"""Récupère les devises disponibles"""
return self._get("/sage/devises").get("data", {}).get("devises", [])
def get_journaux_tresorerie(self) -> List[dict]:
"""Récupère les journaux de trésorerie (banque + caisse)"""
return (
self._get("/sage/journaux/tresorerie").get("data", {}).get("journaux", [])
)
def get_comptes_generaux(
self, prefixe: str = None, type_compte: str = None
) -> List[dict]:
params = {}
if prefixe:
params["prefixe"] = prefixe
if type_compte:
params["type_compte"] = type_compte
return (
self._get("/sage/comptes-generaux", params=params)
.get("data", {})
.get("comptes", [])
)
def get_tva_taux(self) -> List[dict]:
"""Récupère les taux de TVA"""
return self._get("/sage/tva/taux").get("data", {}).get("taux", [])
def get_parametres_encaissement(self) -> dict:
"""Récupère les paramètres TVA sur encaissement"""
return self._get("/sage/parametres/encaissement").get("data", {})
def refresh_cache(self) -> Dict:
return self._post("/sage/cache/refresh")
@ -588,14 +414,5 @@ class SageGatewayClient:
except Exception:
return {"status": "down"}
def get_tous_reglements(self, params=None):
return self._get("/sage/reglements", params=params)
def get_reglement_facture_detail(self, facture_no):
return self._get(f"/sage/reglements/facture/{facture_no}")
def get_reglement_detail(self, rg_no):
return self._get(f"/sage/reglements/{rg_no}")
sage_client = SageGatewayClient()

View file

@ -1,124 +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.user import UserResponse
from schemas.tiers.clients import (
ClientCreate,
ClientCreateRequest,
ClientDetails,
ClientResponse,
ClientUpdate,
ClientUpdateRequest,
)
from schemas.tiers.contact import Contact, ContactCreate, ContactUpdate
from schemas.tiers.fournisseurs import (
FournisseurCreate,
FournisseurCreateAPIRequest,
FournisseurDetails,
FournisseurUpdate,
FournisseurUpdateRequest,
)
from schemas.documents.avoirs import AvoirCreate, AvoirUpdate
from schemas.documents.commandes import CommandeCreate, CommandeUpdate
from schemas.documents.avoirs import AvoirCreateRequest, AvoirUpdateRequest
from schemas.documents.commandes import CommandeCreateRequest, CommandeUpdateRequest
from schemas.documents.devis import (
DevisRequest,
Devis,
DevisUpdate,
RelanceDevis,
DevisResponse,
DevisUpdateRequest,
RelanceDevisRequest,
)
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,
SyncStatsResponse,
CreateSignatureRequest,
TransactionResponse,
)
from schemas.documents.email import StatutEmail, EmailEnvoiRequest
from schemas.documents.factures import FactureCreateRequest, FactureUpdateRequest
from schemas.documents.livraisons import LivraisonCreateRequest, LivraisonUpdateRequest
from schemas.documents.universign import SignatureRequest, StatutSignature
from schemas.articles.articles import (
ArticleCreate,
Article,
ArticleUpdate,
ArticleList,
EntreeStock,
SortieStock,
MouvementStock,
ArticleCreateRequest,
ArticleResponse,
ArticleUpdateRequest,
ArticleListResponse,
EntreeStockRequest,
SortieStockRequest,
MouvementStockResponse,
)
from schemas.articles.famille_article import (
Familles,
FamilleCreate,
FamilleList,
FamilleResponse,
FamilleCreateRequest,
FamilleListResponse,
)
from schemas.sage.sage_gateway import (
SageGatewayCreate,
SageGatewayUpdate,
SageGatewayResponse,
SageGatewayList,
SageGatewayListResponse,
SageGatewayHealthCheck,
SageGatewayTest,
SageGatewayTestRequest,
SageGatewayStatsResponse,
CurrentGatewayInfo,
)
from schemas.society.societe import SocieteInfo
from schemas.society.enterprise import EntrepriseSearch, EntrepriseSearchResponse
__all__ = [
"TiersDetails",
"TypeTiers",
"BaremeRemiseResponse",
"Users",
"ClientCreate",
"UserResponse",
"ClientCreateRequest",
"ClientDetails",
"ClientResponse",
"ClientUpdate",
"FournisseurCreate",
"ClientUpdateRequest",
"FournisseurCreateAPIRequest",
"FournisseurDetails",
"FournisseurUpdate",
"FournisseurUpdateRequest",
"Contact",
"AvoirCreate",
"AvoirUpdate",
"CommandeCreate",
"CommandeUpdate",
"AvoirCreateRequest",
"AvoirUpdateRequest",
"CommandeCreateRequest",
"CommandeUpdateRequest",
"DevisRequest",
"Devis",
"DevisUpdate",
"DevisResponse",
"DevisUpdateRequest",
"TypeDocument",
"TypeDocumentSQL",
"StatutEmail",
"EmailEnvoi",
"FactureCreate",
"FactureUpdate",
"LivraisonCreate",
"LivraisonUpdate",
"Signature",
"EmailEnvoiRequest",
"FactureCreateRequest",
"FactureUpdateRequest",
"LivraisonCreateRequest",
"LivraisonUpdateRequest",
"SignatureRequest",
"StatutSignature",
"TypeTiersInt",
"ArticleCreate",
"Article",
"ArticleUpdate",
"ArticleList",
"EntreeStock",
"SortieStock",
"MouvementStock",
"RelanceDevis",
"Familles",
"FamilleCreate",
"FamilleList",
"ArticleCreateRequest",
"ArticleResponse",
"ArticleUpdateRequest",
"ArticleListResponse",
"EntreeStockRequest",
"SortieStockRequest",
"MouvementStockResponse",
"RelanceDevisRequest",
"FamilleResponse",
"FamilleCreateRequest",
"FamilleListResponse",
"ContactCreate",
"ContactUpdate",
"SageGatewayCreate",
"SageGatewayUpdate",
"SageGatewayResponse",
"SageGatewayList",
"SageGatewayListResponse",
"SageGatewayHealthCheck",
"SageGatewayTest",
"SageGatewayTestRequest",
"SageGatewayStatsResponse",
"CurrentGatewayInfo",
"SyncStatsResponse",
"CreateSignatureRequest",
"TransactionResponse",
"SocieteInfo",
"EntrepriseSearch",
"EntrepriseSearchResponse",
]

View file

@ -1,77 +0,0 @@
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

View file

@ -1,6 +1,6 @@
from pydantic import BaseModel, Field, validator, field_validator
from typing import List, Optional
from datetime import date
from datetime import date, datetime
from utils import (
NomenclatureType,
@ -11,7 +11,363 @@ from utils import (
)
class Article(BaseModel):
class EmplacementStockModel(BaseModel):
"""Détail du stock dans un emplacement spécifique"""
depot: str = Field(..., description="Numéro du dépôt (DE_No)")
emplacement: str = Field(..., description="Code emplacement (DP_No)")
qte_stockee: float = Field(0.0, description="Quantité stockée (AE_QteSto)")
qte_preparee: float = Field(0.0, description="Quantité préparée (AE_QtePrepa)")
qte_a_controler: float = Field(
0.0, description="Quantité à contrôler (AE_QteAControler)"
)
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
depot_num: Optional[str] = Field(None, description="Numéro dépôt")
depot_nom: Optional[str] = Field(None, description="Nom du dépôt (DE_Intitule)")
depot_code: Optional[str] = Field(None, description="Code dépôt (DE_Code)")
depot_adresse: Optional[str] = Field(None, description="Adresse (DE_Adresse)")
depot_complement: Optional[str] = Field(None, description="Complément adresse")
depot_code_postal: Optional[str] = Field(None, description="Code postal")
depot_ville: Optional[str] = Field(None, description="Ville")
depot_contact: Optional[str] = Field(None, description="Contact")
depot_est_principal: Optional[bool] = Field(
None, description="Dépôt principal (DE_Principal)"
)
depot_categorie_compta: Optional[int] = Field(
None, description="Catégorie comptable"
)
depot_region: Optional[str] = Field(None, description="Région")
depot_pays: Optional[str] = Field(None, description="Pays")
depot_email: Optional[str] = Field(None, description="Email")
depot_telephone: Optional[str] = Field(None, description="Téléphone")
depot_fax: Optional[str] = Field(None, description="Fax")
depot_emplacement_defaut: Optional[str] = Field(
None, description="Emplacement par défaut"
)
depot_exclu: Optional[bool] = Field(None, description="Dépôt exclu")
emplacement_code: Optional[str] = Field(
None, description="Code emplacement (DP_Code)"
)
emplacement_libelle: Optional[str] = Field(
None, description="Libellé emplacement (DP_Intitule)"
)
emplacement_zone: Optional[str] = Field(None, description="Zone (DP_Zone)")
emplacement_type: Optional[int] = Field(
None, description="Type emplacement (DP_Type)"
)
class Config:
json_schema_extra = {
"example": {
"depot": "01",
"emplacement": "A1-01",
"qte_stockee": 100.0,
"qte_preparee": 5.0,
"depot_nom": "Dépôt principal",
"depot_ville": "Paris",
"emplacement_libelle": "Allée A, Niveau 1, Case 01",
"emplacement_zone": "Zone A",
}
}
class GammeArticleModel(BaseModel):
"""Gamme d'un article (taille, couleur, etc.)"""
numero_gamme: int = Field(..., description="Numéro de gamme (AG_No)")
enumere: str = Field(..., description="Code énuméré (EG_Enumere)")
type_gamme: int = Field(0, description="Type de gamme (AG_Type)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
ligne: Optional[int] = Field(None, description="Ligne énuméré (EG_Ligne)")
borne_sup: Optional[float] = Field(
None, description="Borne supérieure (EG_BorneSup)"
)
gamme_nom: Optional[str] = Field(
None, description="Nom de la gamme (P_GAMME.G_Intitule)"
)
class Config:
json_schema_extra = {
"example": {
"numero_gamme": 1,
"enumere": "001",
"type_gamme": 0,
"ligne": 1,
"gamme_nom": "Taille",
}
}
class TarifClientModel(BaseModel):
"""Tarif spécifique pour un client ou catégorie tarifaire"""
categorie: int = Field(..., description="Catégorie tarifaire (AC_Categorie)")
client_num: Optional[str] = Field(None, description="Numéro client (CT_Num)")
prix_vente: float = Field(0.0, description="Prix de vente HT (AC_PrixVen)")
coefficient: float = Field(0.0, description="Coefficient (AC_Coef)")
prix_ttc: float = Field(0.0, description="Prix TTC (AC_PrixTTC)")
arrondi: float = Field(0.0, description="Arrondi (AC_Arrondi)")
qte_montant: float = Field(0.0, description="Quantité montant (AC_QteMont)")
enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)")
prix_devise: float = Field(0.0, description="Prix en devise (AC_PrixDev)")
devise: int = Field(0, description="Code devise (AC_Devise)")
remise: float = Field(0.0, description="Remise (AC_Remise)")
mode_calcul: int = Field(0, description="Mode de calcul (AC_Calcul)")
type_remise: int = Field(0, description="Type de remise (AC_TypeRem)")
ref_client: Optional[str] = Field(
None, description="Référence client (AC_RefClient)"
)
coef_nouveau: float = Field(0.0, description="Nouveau coefficient (AC_CoefNouv)")
prix_vente_nouveau: float = Field(
0.0, description="Nouveau prix vente (AC_PrixVenNouv)"
)
prix_devise_nouveau: float = Field(
0.0, description="Nouveau prix devise (AC_PrixDevNouv)"
)
remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AC_RemiseNouv)")
date_application: Optional[datetime] = Field(
None, description="Date application (AC_DateApplication)"
)
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"categorie": 1,
"client_num": "CLI001",
"prix_vente": 110.00,
"coefficient": 1.294,
"remise": 12.0,
}
}
class ComposantModel(BaseModel):
"""Composant/Opération de nomenclature"""
operation: str = Field(..., description="Code opération (AT_Operation)")
code_ressource: Optional[str] = Field(None, description="Code ressource (RP_Code)")
temps: float = Field(0.0, description="Temps nécessaire (AT_Temps)")
type: int = Field(0, description="Type composant (AT_Type)")
description: Optional[str] = Field(None, description="Description (AT_Description)")
ordre: int = Field(0, description="Ordre d'exécution (AT_Ordre)")
gamme_1_comp: int = Field(0, description="Gamme 1 composant (AG_No1Comp)")
gamme_2_comp: int = Field(0, description="Gamme 2 composant (AG_No2Comp)")
type_ressource: int = Field(0, description="Type ressource (AT_TypeRessource)")
chevauche: int = Field(0, description="Chevauchement (AT_Chevauche)")
demarre: int = Field(0, description="Démarrage (AT_Demarre)")
operation_chevauche: Optional[str] = Field(
None, description="Opération chevauchée (AT_OperationChevauche)"
)
valeur_chevauche: float = Field(
0.0, description="Valeur chevauchement (AT_ValeurChevauche)"
)
type_chevauche: int = Field(0, description="Type chevauchement (AT_TypeChevauche)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"operation": "OP010",
"code_ressource": "RES01",
"temps": 15.5,
"description": "Montage pièce A",
"ordre": 10,
}
}
class ComptaArticleModel(BaseModel):
"""Comptabilité spécifique d'un article"""
champ: int = Field(..., description="Champ (ACP_Champ)")
compte_general: Optional[str] = Field(
None, description="Compte général (ACP_ComptaCPT_CompteG)"
)
compte_auxiliaire: Optional[str] = Field(
None, description="Compte auxiliaire (ACP_ComptaCPT_CompteA)"
)
taxe_1: Optional[str] = Field(None, description="Taxe 1 (ACP_ComptaCPT_Taxe1)")
taxe_2: Optional[str] = Field(None, description="Taxe 2 (ACP_ComptaCPT_Taxe2)")
taxe_3: Optional[str] = Field(None, description="Taxe 3 (ACP_ComptaCPT_Taxe3)")
taxe_date_1: Optional[datetime] = Field(None, description="Date taxe 1")
taxe_date_2: Optional[datetime] = Field(None, description="Date taxe 2")
taxe_date_3: Optional[datetime] = Field(None, description="Date taxe 3")
taxe_anc_1: Optional[str] = Field(None, description="Ancienne taxe 1")
taxe_anc_2: Optional[str] = Field(None, description="Ancienne taxe 2")
taxe_anc_3: Optional[str] = Field(None, description="Ancienne taxe 3")
type_facture: int = Field(0, description="Type de facture (ACP_TypeFacture)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"champ": 1,
"compte_general": "707100",
"taxe_1": "TVA20",
"type_facture": 0,
}
}
class FournisseurArticleModel(BaseModel):
"""Fournisseur d'un article"""
fournisseur_num: str = Field(..., description="Numéro fournisseur (CT_Num)")
ref_fournisseur: Optional[str] = Field(
None, description="Référence fournisseur (AF_RefFourniss)"
)
prix_achat: float = Field(0.0, description="Prix d'achat (AF_PrixAch)")
unite: Optional[str] = Field(None, description="Unité (AF_Unite)")
conversion: float = Field(0.0, description="Conversion (AF_Conversion)")
delai_appro: int = Field(0, description="Délai approvisionnement (AF_DelaiAppro)")
garantie: int = Field(0, description="Garantie (AF_Garantie)")
colisage: int = Field(0, description="Colisage (AF_Colisage)")
qte_mini: float = Field(0.0, description="Quantité minimum (AF_QteMini)")
qte_montant: float = Field(0.0, description="Quantité montant (AF_QteMont)")
enumere_gamme: int = Field(0, description="Énuméré gamme (EG_Champ)")
est_principal: bool = Field(
False, description="Fournisseur principal (AF_Principal)"
)
prix_devise: float = Field(0.0, description="Prix devise (AF_PrixDev)")
devise: int = Field(0, description="Code devise (AF_Devise)")
remise: float = Field(0.0, description="Remise (AF_Remise)")
conversion_devise: float = Field(0.0, description="Conversion devise (AF_ConvDiv)")
type_remise: int = Field(0, description="Type remise (AF_TypeRem)")
code_barre_fournisseur: Optional[str] = Field(
None, description="Code-barres fournisseur (AF_CodeBarre)"
)
prix_achat_nouveau: float = Field(
0.0, description="Nouveau prix achat (AF_PrixAchNouv)"
)
prix_devise_nouveau: float = Field(
0.0, description="Nouveau prix devise (AF_PrixDevNouv)"
)
remise_nouvelle: float = Field(0.0, description="Nouvelle remise (AF_RemiseNouv)")
date_application: Optional[datetime] = Field(
None, description="Date application (AF_DateApplication)"
)
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"fournisseur_num": "F001",
"ref_fournisseur": "REF-FOURN-001",
"prix_achat": 85.00,
"delai_appro": 15,
"est_principal": True,
}
}
class ReferenceEnumereeModel(BaseModel):
"""Référence énumérée (article avec gammes)"""
gamme_1: int = Field(0, description="Gamme 1 (AG_No1)")
gamme_2: int = Field(0, description="Gamme 2 (AG_No2)")
reference_enumeree: str = Field(..., description="Référence énumérée (AE_Ref)")
prix_achat: float = Field(0.0, description="Prix achat (AE_PrixAch)")
code_barre: Optional[str] = Field(None, description="Code-barres (AE_CodeBarre)")
prix_achat_nouveau: float = Field(
0.0, description="Nouveau prix achat (AE_PrixAchNouv)"
)
edi_code: Optional[str] = Field(None, description="Code EDI (AE_EdiCode)")
en_sommeil: bool = Field(False, description="En sommeil (AE_Sommeil)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"gamme_1": 1,
"gamme_2": 3,
"reference_enumeree": "ART001-T1-C3",
"prix_achat": 85.00,
}
}
class MediaArticleModel(BaseModel):
"""Média attaché à un article (photo, document, etc.)"""
commentaire: Optional[str] = Field(None, description="Commentaire (ME_Commentaire)")
fichier: Optional[str] = Field(None, description="Nom fichier (ME_Fichier)")
type_mime: Optional[str] = Field(None, description="Type MIME (ME_TypeMIME)")
origine: int = Field(0, description="Origine (ME_Origine)")
ged_id: Optional[str] = Field(None, description="ID GED (ME_GedId)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"commentaire": "Photo produit principale",
"fichier": "ART001_photo1.jpg",
"type_mime": "image/jpeg",
}
}
class PrixGammeModel(BaseModel):
"""Prix spécifique par combinaison de gammes"""
gamme_1: int = Field(0, description="Gamme 1 (AG_No1)")
gamme_2: int = Field(0, description="Gamme 2 (AG_No2)")
prix_net: float = Field(0.0, description="Prix net (AR_PUNet)")
cout_standard: float = Field(0.0, description="Coût standard (AR_CoutStd)")
date_creation: Optional[datetime] = Field(None, description="Date création")
date_modification: Optional[datetime] = Field(None, description="Date modification")
class Config:
json_schema_extra = {
"example": {
"gamme_1": 1,
"gamme_2": 3,
"prix_net": 125.50,
"cout_standard": 82.30,
}
}
class ArticleResponse(BaseModel):
"""Article complet avec tous les enrichissements disponibles"""
reference: str = Field(..., description="Référence article (AR_Ref)")
@ -76,6 +432,7 @@ 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é",
@ -355,20 +712,7 @@ class Article(BaseModel):
)
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
# ===== VALIDATEURS =====
@field_validator(
"unite_vente",
@ -419,11 +763,11 @@ class Article(BaseModel):
}
class ArticleList(BaseModel):
class ArticleListResponse(BaseModel):
"""Réponse pour une liste d'articles"""
total: int = Field(..., description="Nombre total d'articles")
articles: List[Article] = Field(..., description="Liste des articles")
articles: List[ArticleResponse] = Field(..., description="Liste des articles")
filtre_applique: Optional[str] = Field(
None, description="Filtre de recherche appliqué"
)
@ -436,85 +780,37 @@ class ArticleList(BaseModel):
)
class ArticleCreate(BaseModel):
class ArticleCreateRequest(BaseModel):
"""Schéma pour création d'article"""
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")
unite_vente: Optional[str] = Field("UN", max_length=4, description="Unité")
tva_code: Optional[str] = Field(None, max_length=5, description="Code TVA")
description: Optional[str] = Field(None, description="Description")
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"
class ArticleUpdateRequest(BaseModel):
"""Schéma pour modification d'article"""
designation: Optional[str] = Field(None, max_length=69)
prix_vente: Optional[float] = Field(None, ge=0)
prix_achat: Optional[float] = Field(None, ge=0)
stock_reel: Optional[float] = Field(
None, ge=0, description="Critique pour erreur 2881"
)
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")
stock_mini: Optional[float] = Field(None, ge=0)
code_ean: Optional[str] = Field(None, max_length=13)
description: Optional[str] = Field(None)
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):
class MouvementStockLigneRequest(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')")
@ -568,7 +864,7 @@ class MouvementStockLigne(BaseModel):
return v
class EntreeStock(BaseModel):
class EntreeStockRequest(BaseModel):
"""Création d'un bon d'entrée en stock"""
date_entree: Optional[date] = Field(
@ -578,7 +874,7 @@ class EntreeStock(BaseModel):
depot_code: Optional[str] = Field(
None, description="Dépôt principal (si applicable)"
)
lignes: List[MouvementStockLigne] = Field(
lignes: List[MouvementStockLigneRequest] = Field(
..., min_items=1, description="Lignes du mouvement"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
@ -603,7 +899,7 @@ class EntreeStock(BaseModel):
}
class SortieStock(BaseModel):
class SortieStockRequest(BaseModel):
"""Création d'un bon de sortie de stock"""
date_sortie: Optional[date] = Field(
@ -613,7 +909,7 @@ class SortieStock(BaseModel):
depot_code: Optional[str] = Field(
None, description="Dépôt principal (si applicable)"
)
lignes: List[MouvementStockLigne] = Field(
lignes: List[MouvementStockLigneRequest] = Field(
..., min_items=1, description="Lignes du mouvement"
)
commentaire: Optional[str] = Field(None, description="Commentaire général")
@ -637,7 +933,7 @@ class SortieStock(BaseModel):
}
class MouvementStock(BaseModel):
class MouvementStockResponse(BaseModel):
"""Réponse pour un mouvement de stock"""
article_ref: str = Field(..., description="Numéro d'article")

View file

@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
from typing import Optional
class FamilleCreate(BaseModel):
class FamilleCreateRequest(BaseModel):
"""Schéma pour création de famille d'articles"""
code: str = Field(..., max_length=18, description="Code famille (max 18 car)")
@ -27,7 +27,7 @@ class FamilleCreate(BaseModel):
}
class Familles(BaseModel):
class FamilleResponse(BaseModel):
"""Modèle complet d'une famille avec données comptables et fournisseur"""
code: str = Field(..., description="Code famille")
@ -236,10 +236,10 @@ class Familles(BaseModel):
}
class FamilleList(BaseModel):
class FamilleListResponse(BaseModel):
"""Réponse pour la liste des familles"""
familles: list[Familles]
familles: list[FamilleResponse]
total: int
filtre: Optional[str] = None
inclure_totaux: bool = True

View file

@ -1,22 +1,30 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import datetime
from datetime import date
from schemas.documents.ligne_document import LigneDocument
class AvoirCreate(BaseModel):
class LigneAvoir(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class AvoirCreateRequest(BaseModel):
client_id: str
date_avoir: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[LigneDocument]
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[LigneAvoir]
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",
"date_avoir": "2024-01-15",
"reference": "AV-EXT-001",
"lignes": [
{
@ -30,18 +38,18 @@ class AvoirCreate(BaseModel):
}
class AvoirUpdate(BaseModel):
date_avoir: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
class AvoirUpdateRequest(BaseModel):
date_avoir: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneAvoir]] = 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",
"date_avoir": "2024-01-15",
"date_livraison": "2024-01-15",
"reference": "AV-EXT-001",
"lignes": [
{

View file

@ -1,21 +1,30 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import datetime
from datetime import date
from schemas.documents.ligne_document import LigneDocument
class CommandeCreate(BaseModel):
class LigneCommande(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class CommandeCreateRequest(BaseModel):
client_id: str
date_commande: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[LigneDocument]
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[LigneCommande]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_commande": "2024-01-15T10:00:00",
"date_commande": "2024-01-15",
"reference": "CMD-EXT-001",
"lignes": [
{
@ -29,18 +38,18 @@ class CommandeCreate(BaseModel):
}
class CommandeUpdate(BaseModel):
date_commande: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
class CommandeUpdateRequest(BaseModel):
date_commande: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneCommande]] = 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",
"date_commande": "2024-01-15",
"date_livraison": "2024-01-15",
"reference": "CMD-EXT-001",
"lignes": [
{

View file

@ -1,19 +1,27 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import datetime
from datetime import date
from schemas.documents.ligne_document import LigneDocument
class LigneDevis(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class DevisRequest(BaseModel):
client_id: str
date_devis: Optional[datetime] = None
date_livraison: Optional[datetime] = None
date_devis: Optional[date] = None
date_livraison: Optional[date] = None
reference: Optional[str] = None
lignes: List[LigneDocument]
lignes: List[LigneDevis]
class Devis(BaseModel):
class DevisResponse(BaseModel):
id: str
client_id: str
date_devis: str
@ -22,20 +30,20 @@ class Devis(BaseModel):
nb_lignes: int
class DevisUpdate(BaseModel):
class DevisUpdateRequest(BaseModel):
"""Modèle pour modification d'un devis existant"""
date_devis: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
date_devis: Optional[date] = None
date_livraison: Optional[date] = None
reference: Optional[str] = None
lignes: Optional[List[LigneDevis]] = 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",
"date_devis": "2024-01-15",
"date_livraison": "2024-01-15",
"reference": "DEV-001",
"lignes": [
{
@ -50,6 +58,6 @@ class DevisUpdate(BaseModel):
}
class RelanceDevis(BaseModel):
class RelanceDevisRequest(BaseModel):
doc_id: str
message_personnalise: Optional[str] = None

View file

@ -13,7 +13,7 @@ class StatutEmail(str, Enum):
BOUNCE = "BOUNCE"
class EmailEnvoi(BaseModel):
class EmailEnvoiRequest(BaseModel):
destinataire: EmailStr
cc: Optional[List[EmailStr]] = []
cci: Optional[List[EmailStr]] = []

View file

@ -1,22 +1,30 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import datetime
from schemas.documents.ligne_document import LigneDocument
from datetime import date
class FactureCreate(BaseModel):
class LigneFacture(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class FactureCreateRequest(BaseModel):
client_id: str
date_facture: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: List[LigneDocument]
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
lignes: List[LigneFacture]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_facture": "2024-01-15T10:00:00",
"date_facture": "2024-01-15",
"reference": "FA-EXT-001",
"lignes": [
{
@ -30,18 +38,18 @@ class FactureCreate(BaseModel):
}
class FactureUpdate(BaseModel):
date_facture: Optional[datetime] = None
date_livraison: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
class FactureUpdateRequest(BaseModel):
date_facture: Optional[date] = None
date_livraison: Optional[date] = None
lignes: Optional[List[LigneFacture]] = 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",
"date_facture": "2024-01-15",
"date_livraison": "2024-01-15",
"lignes": [
{
"article_code": "ART001",

View file

@ -1,25 +0,0 @@
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

View file

@ -1,22 +1,30 @@
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
from datetime import datetime
from schemas.documents.ligne_document import LigneDocument
from datetime import date
class LivraisonCreate(BaseModel):
class LigneLivraison(BaseModel):
article_code: str
quantite: float
remise_pourcentage: Optional[float] = 0.0
@field_validator("article_code", mode="before")
def strip_insecables(cls, v):
return v.replace("\xa0", "").strip()
class LivraisonCreateRequest(BaseModel):
client_id: str
date_livraison: Optional[datetime] = None
date_livraison_prevue: Optional[datetime] = None
lignes: List[LigneDocument]
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
lignes: List[LigneLivraison]
reference: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"date_livraison": "2024-01-15T10:00:00",
"date_livraison": "2024-01-15",
"reference": "BL-EXT-001",
"lignes": [
{
@ -30,18 +38,18 @@ class LivraisonCreate(BaseModel):
}
class LivraisonUpdate(BaseModel):
date_livraison: Optional[datetime] = None
date_livraison_prevue: Optional[datetime] = None
lignes: Optional[List[LigneDocument]] = None
class LivraisonUpdateRequest(BaseModel):
date_livraison: Optional[date] = None
date_livraison_prevue: Optional[date] = None
lignes: Optional[List[LigneLivraison]] = 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",
"date_livraison": "2024-01-15",
"date_livraison_prevue": "2024-01-15",
"reference": "BL-EXT-001",
"lignes": [
{

View file

@ -1,109 +0,0 @@
from pydantic import BaseModel, Field, field_validator
from typing import List, Optional
import logging
from decimal import Decimal
from datetime import date
logger = logging.getLogger(__name__)
class ReglementFactureCreate(BaseModel):
"""Requête de règlement d'une facture côté VPS"""
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_reglement: int = Field(
..., ge=0, description="Code mode règlement depuis /reglements/modes"
)
code_journal: str = Field(
..., min_length=1, description="Code journal depuis /journaux/tresorerie"
)
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")
reference: Optional[str] = Field(
"", max_length=17, description="Référence pièce règlement"
)
libelle: Optional[str] = Field(
"", max_length=35, description="Libellé du règlement"
)
tva_encaissement: Optional[bool] = Field(
False, description="Appliquer TVA sur encaissement"
)
compte_general: Optional[str] = Field(None)
@field_validator("montant")
def validate_montant(cls, v):
if v <= 0:
raise ValueError("Le montant doit être positif")
return round(v, 2)
class Config:
json_schema_extra = {
"example": {
"montant": 375.12,
"mode_reglement": 2,
"reference": "CHQ-001",
"code_journal": "BEU",
"date_reglement": "2024-01-01",
"libelle": "Règlement multiple",
"tva_encaissement": False,
"devise_code": 0,
"cours_devise": 1.0,
"date_echeance": "2024-01-31",
}
}
class ReglementMultipleCreate(BaseModel):
"""Requête de règlement multiple côté VPS"""
client_id: str = Field(..., description="Code client")
montant_total: Decimal = Field(..., gt=0)
devise_code: Optional[int] = Field(0)
cours_devise: Optional[Decimal] = Field(1.0)
mode_reglement: int = Field(...)
code_journal: str = Field(...)
date_reglement: Optional[date] = None
reference: Optional[str] = Field("")
libelle: Optional[str] = Field("")
tva_encaissement: Optional[bool] = Field(False)
numeros_factures: Optional[List[str]] = Field(
None, description="Si vide, règle les plus anciennes en premier"
)
@field_validator("client_id", mode="before")
def strip_client_id(cls, v):
return v.replace("\xa0", "").strip() if v else v
@field_validator("montant_total")
def validate_montant(cls, v):
if v <= 0:
raise ValueError("Le montant doit être positif")
return round(v, 2)
class Config:
json_schema_extra = {
"example": {
"client_id": "CLI000001",
"montant_total": 1000.00,
"mode_reglement": 2,
"numeros_factures": ["FA00081", "FA00082"],
"reference": "CHQ-001",
"code_journal": "BEU",
"date_reglement": "2024-01-01",
"libelle": "Règlement multiple",
"tva_encaissement": False,
"devise_code": 0,
"cours_devise": 1.0,
"date_echeance": "2024-01-31",
}
}

View file

@ -1,12 +1,6 @@
from pydantic import BaseModel, EmailStr
from enum import Enum
from schemas.documents.documents import TypeDocument
from database import (
SageDocumentType,
)
from typing import List, Optional
from datetime import datetime
class StatutSignature(str, Enum):
@ -17,54 +11,8 @@ class StatutSignature(str, Enum):
EXPIRE = "EXPIRE"
class Signature(BaseModel):
class SignatureRequest(BaseModel):
doc_id: str
type_doc: TypeDocument
email_signataire: EmailStr
nom_signataire: str
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]

View file

@ -10,8 +10,8 @@ class GatewayHealthStatus(str, Enum):
UNKNOWN = "unknown"
# === CREATE ===
class SageGatewayCreate(BaseModel):
name: str = Field(
..., min_length=2, max_length=100, description="Nom de la gateway"
)
@ -23,8 +23,6 @@ class SageGatewayCreate(BaseModel):
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")
@ -53,9 +51,6 @@ class SageGatewayUpdate(BaseModel):
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)
@ -70,8 +65,8 @@ class SageGatewayUpdate(BaseModel):
return v.rstrip("/") if v else v
# === RESPONSE ===
class SageGatewayResponse(BaseModel):
id: str
user_id: str
@ -81,9 +76,6 @@ class SageGatewayResponse(BaseModel):
gateway_url: str
token_preview: str
sage_database: Optional[str] = None
sage_company: Optional[str] = None
is_active: bool
is_default: bool
priority: int
@ -108,8 +100,7 @@ class SageGatewayResponse(BaseModel):
from_attributes = True
class SageGatewayList(BaseModel):
class SageGatewayListResponse(BaseModel):
items: List[SageGatewayResponse]
total: int
active_gateway: Optional[SageGatewayResponse] = None
@ -130,7 +121,7 @@ class SageGatewayActivateRequest(BaseModel):
gateway_id: str
class SageGatewayTest(BaseModel):
class SageGatewayTestRequest(BaseModel):
gateway_url: str
gateway_token: str

View file

@ -1,24 +0,0 @@
from pydantic import BaseModel, Field
from typing import Optional, List
class EntrepriseSearch(BaseModel):
"""Modèle de réponse pour une entreprise trouvée"""
company_name: str = Field(..., description="Raison sociale complète")
siren: str = Field(..., description="Numéro SIREN (9 chiffres)")
vat_number: str = Field(..., description="Numéro de TVA intracommunautaire")
address: str = Field(..., description="Adresse complète du siège")
naf_code: str = Field(..., description="Code NAF/APE")
is_active: bool = Field(..., description="True si entreprise active")
siret_siege: Optional[str] = Field(None, description="SIRET du siège")
code_postal: Optional[str] = None
ville: Optional[str] = None
class EntrepriseSearchResponse(BaseModel):
"""Réponse globale de la recherche"""
total_results: int
results: List[EntrepriseSearch]
query: str

View file

@ -1,46 +0,0 @@
from pydantic import BaseModel
from typing import Optional, List
class ExerciceComptable(BaseModel):
numero: int
debut: str
fin: Optional[str] = None
class SocieteInfo(BaseModel):
raison_sociale: str
numero_dossier: str
siret: Optional[str] = None
code_ape: Optional[str] = None
numero_tva: Optional[str] = None
adresse: Optional[str] = None
complement_adresse: Optional[str] = None
code_postal: Optional[str] = None
ville: Optional[str] = None
code_region: Optional[str] = None
pays: Optional[str] = None
telephone: Optional[str] = None
telecopie: Optional[str] = None
email: Optional[str] = None
email_societe: Optional[str] = None
site_web: Optional[str] = None
capital: float = 0.0
forme_juridique: Optional[str] = None
exercices: List[ExerciceComptable] = []
devise_compte: int = 0
devise_equivalent: int = 0
longueur_compte_general: int = 0
longueur_compte_analytique: int = 0
regime_fec: int = 0
base_modele: Optional[str] = None
marqueur: int = 0
logo_base64: Optional[str] = None
logo_content_type: Optional[str] = None

View file

@ -1,6 +1,6 @@
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from schemas.tiers.tiers import TiersDetails
from typing import List, Optional
from schemas.tiers.contact import Contact
class ClientResponse(BaseModel):
@ -13,25 +13,271 @@ class ClientResponse(BaseModel):
telephone: Optional[str] = None
class ClientDetails(TiersDetails):
class ClientDetails(BaseModel):
numero: Optional[str] = Field(None, description="Code client (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)")
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)")
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)")
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)")
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)"
)
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)"
)
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)"
)
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: Optional[str] = Field(
None, description="Commentaire libre (CT_Commentaire)"
)
section_analytique: Optional[str] = Field(
None, description="Section analytique (CA_Num)"
)
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: 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: Optional[List[Contact]] = Field(
default_factory=list, description="Liste des contacts du client"
)
class Config:
json_schema_extra = {
"example": {
"numero": "CLI000001",
"intitule": "SARL EXEMPLE",
"type_tiers": 0,
"qualite": "CLI",
"classement": "A",
"raccourci": "EXEMPL",
"siret": "12345678901234",
"tva_intra": "FR12345678901",
"code_naf": "6201Z",
"contact": "Jean Dupont",
"adresse": "123 Rue de la Paix",
"complement": "Bâtiment B",
"code_postal": "75001",
"ville": "Paris",
"region": "Île-de-France",
"pays": "France",
"telephone": "0123456789",
"telecopie": "0123456788",
"email": "contact@exemple.fr",
"site_web": "https://www.exemple.fr",
"facebook": "https://facebook.com/exemple",
"linkedin": "https://linkedin.com/company/exemple",
"taux01": 0.0,
"taux02": 0.0,
"taux03": 0.0,
"taux04": 0.0,
"statistique01": "Informatique",
"statistique02": "",
"statistique03": "",
"statistique04": "",
"statistique05": "",
"statistique06": "",
"statistique07": "",
"statistique08": "",
"statistique09": "",
"statistique10": "",
"encours_autorise": 50000.0,
"assurance_credit": 40000.0,
"langue": 0,
"commercial_code": 1,
"commercial": {
"numero": 1,
"nom": "DUPONT",
"prenom": "Jean",
"email": "j.dupont@entreprise.fr",
},
"lettrage_auto": True,
"est_actif": True,
"type_facture": 1,
"est_prospect": False,
"bl_en_facture": 0,
"saut_page": 0,
"validation_echeance": 0,
"controle_encours": 1,
"exclure_relance": False,
"exclure_penalites": False,
"bon_a_payer": 0,
"priorite_livraison": 1,
"livraison_partielle": 1,
"delai_transport": 2,
"delai_appro": 0,
"commentaire": "Client important",
"section_analytique": "",
"mode_reglement_code": 1,
"surveillance_active": True,
"coface": "COF12345",
"forme_juridique": "SARL",
"effectif": "50-99",
"sv_regularite": "",
"sv_cotation": "",
"sv_objet_maj": "",
"sv_chiffre_affaires": 2500000.0,
"sv_resultat": 150000.0,
"compte_general": "4110000",
"categorie_tarif": 0,
"categorie_compta": 0,
}
}
class ClientCreate(BaseModel):
class ClientCreateRequest(BaseModel):
intitule: str = Field(
..., max_length=69, description="Nom du client (CT_Intitule) - OBLIGATOIRE"
)
@ -433,7 +679,7 @@ class ClientCreate(BaseModel):
}
class ClientUpdate(BaseModel):
class ClientUpdateRequest(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)

View file

@ -1,111 +0,0 @@
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: 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: 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
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: 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,
}
}

View file

@ -1,27 +1,273 @@
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from schemas.tiers.tiers import TiersDetails
from typing import List, Optional
from schemas.tiers.contact import Contact
class FournisseurDetails(TiersDetails):
class FournisseurDetails(BaseModel):
numero: Optional[str] = Field(None, description="Code fournisseur (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)")
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)")
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)")
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)")
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)"
)
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)"
)
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)"
)
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: Optional[str] = Field(
None, description="Commentaire libre (CT_Commentaire)"
)
section_analytique: Optional[str] = Field(
None, description="Section analytique (CA_Num)"
)
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: 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: Optional[List[Contact]] = Field(
default_factory=list, description="Liste des contacts du fournisseur"
)
class Config:
json_schema_extra = {
"example": {
"numero": "FOU000001",
"intitule": "SARL FOURNISSEUR",
"intitule": "SARL FOURNISSEUR EXEMPLE",
"type_tiers": 1,
"qualite": "FOU",
"classement": "A",
"raccourci": "EXEMPL",
"siret": "12345678901234",
"tva_intra": "FR12345678901",
"code_naf": "6201Z",
"contact": "Jean Dupont",
"adresse": "123 Rue de la Paix",
"complement": "Bâtiment B",
"code_postal": "75001",
"ville": "Paris",
"region": "Île-de-France",
"pays": "France",
"telephone": "0123456789",
"telecopie": "0123456788",
"email": "contact@exemple.fr",
"site_web": "https://www.exemple.fr",
"facebook": "https://facebook.com/exemple",
"linkedin": "https://linkedin.com/company/exemple",
"taux01": 0.0,
"taux02": 0.0,
"taux03": 0.0,
"taux04": 0.0,
"statistique01": "Informatique",
"statistique02": "",
"statistique03": "",
"statistique04": "",
"statistique05": "",
"statistique06": "",
"statistique07": "",
"statistique08": "",
"statistique09": "",
"statistique10": "",
"encours_autorise": 50000.0,
"assurance_credit": 40000.0,
"langue": 0,
"commercial_code": 1,
"commercial": {
"numero": 1,
"nom": "MARTIN",
"prenom": "Sophie",
"email": "s.martin@entreprise.fr",
},
"lettrage_auto": True,
"est_actif": True,
"type_facture": 1,
"est_prospect": False,
"bl_en_facture": 0,
"saut_page": 0,
"validation_echeance": 0,
"controle_encours": 1,
"exclure_relance": False,
"exclure_penalites": False,
"bon_a_payer": 0,
"priorite_livraison": 1,
"livraison_partielle": 1,
"delai_transport": 2,
"delai_appro": 0,
"commentaire": "Client important",
"section_analytique": "",
"mode_reglement_code": 1,
"surveillance_active": True,
"coface": "COF12345",
"forme_juridique": "SARL",
"effectif": "50-99",
"sv_regularite": "",
"sv_cotation": "",
"sv_objet_maj": "",
"sv_chiffre_affaires": 2500000.0,
"sv_resultat": 150000.0,
"compte_general": "4110000",
"categorie_tarif": 0,
"categorie_compta": 0,
}
}
class FournisseurCreate(BaseModel):
class FournisseurCreateAPIRequest(BaseModel):
intitule: str = Field(
..., min_length=1, max_length=69, description="Raison sociale du fournisseur"
)
@ -58,7 +304,7 @@ class FournisseurCreate(BaseModel):
}
class FournisseurUpdate(BaseModel):
class FournisseurUpdateRequest(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)

View file

@ -3,8 +3,6 @@ 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
@ -14,6 +12,7 @@ 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)"
@ -36,6 +35,7 @@ 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)"
)
@ -48,6 +48,7 @@ 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)")
@ -55,11 +56,13 @@ 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)"
)
@ -91,6 +94,7 @@ class TiersDetails(BaseModel):
None, description="Statistique 10 (CT_Statistique10)"
)
# COMMERCIAL
encours_autorise: Optional[float] = Field(
None, description="Encours maximum autorisé (CT_Encours)"
)
@ -103,10 +107,8 @@ class TiersDetails(BaseModel):
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)"
)
@ -139,6 +141,7 @@ class TiersDetails(BaseModel):
None, description="Bon à payer obligatoire (CT_BonAPayer)"
)
# LOGISTIQUE
priorite_livraison: Optional[int] = Field(
None, description="Priorité livraison (CT_PrioriteLivr)"
)
@ -152,14 +155,17 @@ 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)"
)
@ -189,6 +195,7 @@ 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)"
)
@ -199,6 +206,7 @@ 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"
)

View file

@ -1,54 +0,0 @@
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,
}
}

View file

@ -2,7 +2,7 @@ from pydantic import BaseModel
from typing import Optional
class Users(BaseModel):
class UserResponse(BaseModel):
id: str
email: str
nom: str

View file

@ -1,651 +0,0 @@
import sys
import os
from pathlib import Path
import asyncio
import argparse
import logging
from datetime import datetime
from typing import Optional, List
import json
from sqlalchemy import select
_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}")
try:
from database.db_config import async_session_factory
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(" Vérifiez que vous êtes dans /app")
print(" 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__)
AVAILABLE_TAGS = {
"Authentication": " Authentification et gestion des comptes",
"API Keys Management": "🔑 Gestion des clés API",
"Clients": "👥 Gestion des clients",
"Fournisseurs": "🏭 Gestion des fournisseurs",
"Prospects": "🎯 Gestion des prospects",
"Tiers": "📋 Gestion générale des tiers",
"Contacts": "📞 Contacts des tiers",
"Articles": "📦 Catalogue articles",
"Familles": "🏷️ Familles d'articles",
"Stock": "📊 Mouvements de stock",
"Devis": "📄 Devis",
"Commandes": "🛒 Commandes",
"Livraisons": "🚚 Bons de livraison",
"Factures": "💰 Factures",
"Avoirs": "↩️ Avoirs",
"Règlements": "💳 Règlements et encaissements",
"Workflows": " Transformations de documents",
"Documents": "📑 Gestion documents (PDF)",
"Emails": "📧 Envoi d'emails",
"Validation": " Validations métier",
"Collaborateurs": "👔 Collaborateurs internes",
"Société": "🏢 Informations société",
"Référentiels": "📚 Données de référence",
"System": "⚙️ Système et santé",
"Admin": "🛠️ Administration",
"Debug": "🐛 Debug et diagnostics",
}
PRESET_PROFILES = {
"commercial": [
"Clients",
"Contacts",
"Devis",
"Commandes",
"Factures",
"Articles",
"Documents",
"Emails",
],
"comptable": [
"Clients",
"Fournisseurs",
"Factures",
"Avoirs",
"Règlements",
"Documents",
"Emails",
],
"logistique": [
"Articles",
"Stock",
"Commandes",
"Livraisons",
"Fournisseurs",
"Documents",
],
"readonly": ["Clients", "Articles", "Devis", "Commandes", "Factures", "Documents"],
"developer": [
"Authentication",
"API Keys Management",
"System",
"Clients",
"Articles",
"Devis",
"Commandes",
"Factures",
],
}
async def add_swagger_user(
username: str,
password: str,
full_name: str = None,
tags: Optional[List[str]] = None,
preset: Optional[str] = None,
):
"""Ajouter un utilisateur Swagger avec configuration avancée"""
async with async_session_factory() 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
if preset:
if preset not in PRESET_PROFILES:
logger.error(
f" Preset '{preset}' inconnu. Disponibles: {list(PRESET_PROFILES.keys())}"
)
return
tags = PRESET_PROFILES[preset]
logger.info(f"📋 Application du preset '{preset}': {len(tags)} tags")
swagger_user = SwaggerUser(
username=username,
hashed_password=hash_password(password),
full_name=full_name or username,
is_active=True,
allowed_tags=json.dumps(tags) if tags else None,
)
session.add(swagger_user)
await session.commit()
logger.info(f" Utilisateur Swagger créé: {username}")
logger.info(f" Nom complet: {swagger_user.full_name}")
if tags:
logger.info(f" 🏷️ Tags autorisés ({len(tags)}):")
for tag in tags:
desc = AVAILABLE_TAGS.get(tag, "")
logger.info(f"{tag} {desc}")
else:
logger.info(" 👑 Accès ADMIN COMPLET (tous les tags)")
async def list_swagger_users():
"""Lister tous les utilisateurs Swagger avec détails"""
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")
return
logger.info(f"\n👥 {len(users)} utilisateur(s) Swagger:\n")
logger.info("=" * 80)
for user in users:
status = " ACTIF" if user.is_active else " NON ACTIF"
logger.info(f"\n{status} {user.username}")
logger.info(f"📛 Nom: {user.full_name}")
logger.info(f"🆔 ID: {user.id}")
logger.info(f"📅 Créé: {user.created_at}")
logger.info(f"🕐 Dernière connexion: {user.last_login or 'Jamais'}")
if user.allowed_tags:
try:
tags = json.loads(user.allowed_tags)
if tags:
logger.info(f"🏷️ Tags autorisés ({len(tags)}):")
for tag in tags:
desc = AVAILABLE_TAGS.get(tag, "")
logger.info(f"{tag} {desc}")
auth_schemes = []
if "Authentication" in tags:
auth_schemes.append("JWT (Bearer)")
if "API Keys Management" in tags or len(tags) > 3:
auth_schemes.append("X-API-Key")
if not auth_schemes:
auth_schemes.append("JWT (Bearer)")
logger.info(
f" Authentification autorisée: {', '.join(auth_schemes)}"
)
else:
logger.info("👑 Tags autorisés: ADMIN COMPLET (tous)")
logger.info(" Authentification: JWT + X-API-Key (tout)")
except json.JSONDecodeError:
logger.info(" Tags: Erreur format")
else:
logger.info("👑 Tags autorisés: ADMIN COMPLET (tous)")
logger.info(" Authentification: JWT + X-API-Key (tout)")
logger.info("\n" + "=" * 80)
async def update_swagger_user(
username: str,
add_tags: Optional[List[str]] = None,
remove_tags: Optional[List[str]] = None,
set_tags: Optional[List[str]] = None,
preset: Optional[str] = None,
active: Optional[bool] = None,
):
"""Mettre à jour un utilisateur Swagger"""
async with async_session_factory() 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
modified = False
if preset:
if preset not in PRESET_PROFILES:
logger.error(f" Preset '{preset}' inconnu")
return
user.allowed_tags = json.dumps(PRESET_PROFILES[preset])
logger.info(f"📋 Preset '{preset}' appliqué")
modified = True
elif set_tags is not None:
user.allowed_tags = json.dumps(set_tags) if set_tags else None
logger.info(f" Tags remplacés: {len(set_tags) if set_tags else 0}")
modified = True
elif add_tags or remove_tags:
current_tags = []
if user.allowed_tags:
try:
current_tags = json.loads(user.allowed_tags)
except json.JSONDecodeError:
current_tags = []
if add_tags:
for tag in add_tags:
if tag not in current_tags:
current_tags.append(tag)
logger.info(f" Tag ajouté: {tag}")
modified = True
if remove_tags:
for tag in remove_tags:
if tag in current_tags:
current_tags.remove(tag)
logger.info(f" Tag retiré: {tag}")
modified = True
user.allowed_tags = json.dumps(current_tags) if current_tags else None
if active is not None:
user.is_active = active
logger.info(f" Statut: {'ACTIF' if active else 'INACTIF'}")
modified = True
if modified:
await session.commit()
logger.info(f" Utilisateur '{username}' mis à jour")
else:
logger.info(" Aucune modification effectuée")
async def delete_swagger_user(username: str):
"""Supprimer un utilisateur Swagger"""
async with async_session_factory() 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 Swagger supprimé: {username}")
async def list_available_tags():
"""Liste tous les tags disponibles avec description"""
logger.info("\n🏷️ TAGS DISPONIBLES:\n")
logger.info("=" * 80)
for tag, desc in AVAILABLE_TAGS.items():
logger.info(f" {desc}")
logger.info(f" Nom: {tag}\n")
logger.info("=" * 80)
logger.info("\n📦 PRESETS DISPONIBLES:\n")
for preset_name, tags in PRESET_PROFILES.items():
logger.info(f" {preset_name}:")
logger.info(f" {', '.join(tags)}\n")
logger.info("=" * 80)
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 async_session_factory() 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,
)
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}")
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" Expire le: {api_key_obj.expires_at}")
if api_key_obj.allowed_endpoints:
try:
endpoints_list = json.loads(api_key_obj.allowed_endpoints)
logger.info(f" Endpoints: {', '.join(endpoints_list)}")
except Exception:
logger.info(f" Endpoints: {api_key_obj.allowed_endpoints}")
else:
logger.info(" Endpoints: Tous (aucune restriction)")
logger.info("=" * 70)
logger.info(" SAUVEGARDEZ CETTE CLÉ - Elle ne sera plus affichée !")
logger.info("=" * 70)
async def list_api_keys():
"""Lister toutes les clés API"""
async with async_session_factory() as session:
service = ApiKeyService(session)
keys = await service.list_api_keys()
if not keys:
logger.info("🔭 Aucune clé API")
return
logger.info(f"🔑 {len(keys)} clé(s) API:\n")
for key in keys:
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" Expire: {key.expires_at or 'Jamais'}")
logger.info(f" Dernière utilisation: {key.last_used_at or 'Jamais'}")
if key.allowed_endpoints:
try:
endpoints = json.loads(key.allowed_endpoints)
display = ", ".join(endpoints[:4])
if len(endpoints) > 4:
display += f"... (+{len(endpoints) - 4})"
logger.info(f" Endpoints: {display}")
except Exception:
pass
else:
logger.info(" Endpoints: Tous")
logger.info("")
async def revoke_api_key(key_id: str):
"""Révoquer une clé API"""
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")
return
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}")
async def verify_api_key(api_key: str):
"""Vérifier une clé API"""
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")
return
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: {key.expires_at or 'Jamais'}")
if key.allowed_endpoints:
try:
endpoints = json.loads(key.allowed_endpoints)
logger.info(f" Endpoints autorisés: {endpoints}")
except Exception:
pass
else:
logger.info(" Endpoints autorisés: Tous")
logger.info("=" * 60)
async def main():
parser = argparse.ArgumentParser(
description="Gestion avancée des utilisateurs Swagger et clés API",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
EXEMPLES D'UTILISATION:
=== UTILISATEURS SWAGGER ===
1. Créer un utilisateur avec preset:
python scripts/manage_security.py swagger add commercial Pass123! --preset commercial
2. Créer un admin complet:
python scripts/manage_security.py swagger add admin AdminPass
3. Créer avec tags spécifiques:
python scripts/manage_security.py swagger add client Pass123! --tags Clients Devis Factures
4. Mettre à jour un utilisateur (ajouter des tags):
python scripts/manage_security.py swagger update client --add-tags Commandes Livraisons
5. Changer complètement les tags:
python scripts/manage_security.py swagger update client --set-tags Clients Articles
6. Appliquer un preset:
python scripts/manage_security.py swagger update client --preset comptable
7. Lister les tags disponibles:
python scripts/manage_security.py swagger tags
8. Désactiver temporairement:
python scripts/manage_security.py swagger update client --inactive
=== CLÉS API ===
9. Créer une clé API:
python scripts/manage_security.py apikey create "Mon App" --days 365 --rate-limit 100
10. Créer avec endpoints restreints:
python scripts/manage_security.py apikey create "SDK-ReadOnly" --endpoints "/clients" "/clients/*" "/devis" "/devis/*"
11. Lister les clés:
python scripts/manage_security.py apikey list
12. Vérifier une clé:
python scripts/manage_security.py apikey verify sdk_live_xxxxx
13. Révoquer une clé:
python scripts/manage_security.py apikey revoke <key_id>
""",
)
subparsers = parser.add_subparsers(dest="command", help="Commandes")
swagger_parser = subparsers.add_parser("swagger", help="Gestion Swagger")
swagger_sub = swagger_parser.add_subparsers(dest="swagger_command")
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", default=None)
add_p.add_argument(
"--tags",
nargs="*",
help="Tags autorisés. Vide = admin complet",
default=None,
)
add_p.add_argument(
"--preset",
choices=list(PRESET_PROFILES.keys()),
help="Appliquer un preset de tags",
)
update_p = swagger_sub.add_parser("update", help="Mettre à jour utilisateur")
update_p.add_argument("username", help="Nom d'utilisateur")
update_p.add_argument("--add-tags", nargs="+", help="Ajouter des tags")
update_p.add_argument("--remove-tags", nargs="+", help="Retirer des tags")
update_p.add_argument("--set-tags", nargs="*", help="Définir les tags (remplace)")
update_p.add_argument(
"--preset", choices=list(PRESET_PROFILES.keys()), help="Appliquer preset"
)
update_p.add_argument("--active", action="store_true", help="Activer l'utilisateur")
update_p.add_argument(
"--inactive", action="store_true", help="Désactiver l'utilisateur"
)
swagger_sub.add_parser("list", help="Lister utilisateurs")
del_p = swagger_sub.add_parser("delete", help="Supprimer utilisateur")
del_p.add_argument("username", help="Nom d'utilisateur")
swagger_sub.add_parser("tags", help="Lister les tags disponibles")
apikey_parser = subparsers.add_parser("apikey", help="Gestion clés API")
apikey_sub = apikey_parser.add_subparsers(dest="apikey_command")
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_sub.add_parser("list", help="Lister clés")
rev_p = apikey_sub.add_parser("revoke", help="Révoquer clé")
rev_p.add_argument("key_id", help="ID de la clé")
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()
if not args.command:
parser.print_help()
return
if args.command == "swagger":
if args.swagger_command == "add":
await add_swagger_user(
args.username,
args.password,
args.full_name,
args.tags,
args.preset,
)
elif args.swagger_command == "update":
active = None
if args.active:
active = True
elif args.inactive:
active = False
await update_swagger_user(
args.username,
add_tags=args.add_tags,
remove_tags=args.remove_tags,
set_tags=args.set_tags,
preset=args.preset,
active=active,
)
elif args.swagger_command == "list":
await list_swagger_users()
elif args.swagger_command == "delete":
await delete_swagger_user(args.username)
elif args.swagger_command == "tags":
await list_available_tags()
else:
swagger_parser.print_help()
elif args.command == "apikey":
if args.apikey_command == "create":
await create_api_key(
name=args.name,
description=args.description,
expires_in_days=args.days,
rate_limit=args.rate_limit,
endpoints=args.endpoints,
)
elif args.apikey_command == "list":
await list_api_keys()
elif args.apikey_command == "revoke":
await revoke_api_key(args.key_id)
elif args.apikey_command == "verify":
await verify_api_key(args.api_key)
else:
apikey_parser.print_help()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("\n Interrupted")
sys.exit(0)
except Exception as e:
logger.error(f" Erreur: {e}")
import traceback
traceback.print_exc()
sys.exit(1)

View file

@ -1,354 +0,0 @@
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()

55
security/__init__.py Normal file
View file

@ -0,0 +1,55 @@
from security.auth import (
hash_password,
verify_password,
validate_password_strength,
generate_verification_token,
generate_reset_token,
generate_csrf_token,
generate_secure_token,
hash_token,
constant_time_compare,
create_access_token,
create_refresh_token,
decode_token,
generate_session_id,
)
from security.cookies import CookieManager, set_auth_cookies
from security.fingerprint import (
DeviceFingerprint,
get_fingerprint_hash,
validate_fingerprint,
get_client_ip,
)
from security.csrf import CSRFProtection, verify_csrf, generate_csrf_for_session
from security.rate_limiter import RateLimiter, check_rate_limit_dependency
__all__ = [
"hash_password",
"verify_password",
"validate_password_strength",
"generate_verification_token",
"generate_reset_token",
"generate_csrf_token",
"generate_secure_token",
"hash_token",
"constant_time_compare",
"create_access_token",
"create_refresh_token",
"decode_token",
"generate_session_id",
"CookieManager",
"set_auth_cookies",
"DeviceFingerprint",
"get_fingerprint_hash",
"validate_fingerprint",
"get_client_ip",
"CSRFProtection",
"verify_csrf",
"generate_csrf_for_session",
"RateLimiter",
"check_rate_limit_dependency",
]

View file

@ -1,18 +1,17 @@
from passlib.context import CryptContext
from datetime import datetime, timedelta
from typing import Optional, Dict
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, Any, Tuple
import jwt
import secrets
import hashlib
import hmac
import logging
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
logger = logging.getLogger(__name__)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12)
def hash_password(password: str) -> str:
@ -20,79 +19,192 @@ def hash_password(password: str) -> str:
def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return pwd_context.verify(plain_password, hashed_password)
except Exception as e:
logger.warning(f"Erreur verification mot de passe: {e}")
return False
def generate_secure_token(length: int = 32) -> str:
return secrets.token_urlsafe(length)
def generate_verification_token() -> str:
return secrets.token_urlsafe(32)
return generate_secure_token(32)
def generate_reset_token() -> str:
return secrets.token_urlsafe(32)
return generate_secure_token(32)
def generate_csrf_token() -> str:
return generate_secure_token(32)
def generate_refresh_token_id() -> str:
return generate_secure_token(16)
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def create_access_token(data: Dict, expires_delta: Optional[timedelta] = None) -> str:
def constant_time_compare(val1: str, val2: str) -> bool:
return hmac.compare_digest(val1.encode(), val2.encode())
def create_access_token(
data: Dict[str, Any],
expires_delta: Optional[timedelta] = None,
fingerprint_hash: Optional[str] = None,
) -> str:
to_encode = data.copy()
now = datetime.now(timezone.utc)
if expires_delta:
expire = datetime.utcnow() + expires_delta
expire = now + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
expire = now + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire, "iat": datetime.utcnow(), "type": "access"})
to_encode.update(
{
"exp": expire,
"iat": now,
"nbf": now,
"type": "access",
"jti": generate_secure_token(8),
}
)
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
if fingerprint_hash:
to_encode["fph"] = fingerprint_hash
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def create_refresh_token(user_id: str) -> str:
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
def create_refresh_token(
user_id: str,
token_id: Optional[str] = None,
fingerprint_hash: Optional[str] = None,
expires_delta: Optional[timedelta] = None,
) -> Tuple[str, str]:
now = datetime.now(timezone.utc)
if expires_delta:
expire = now + expires_delta
else:
expire = now + timedelta(days=settings.refresh_token_expire_days)
if not token_id:
token_id = generate_refresh_token_id()
to_encode = {
"sub": user_id,
"exp": expire,
"iat": datetime.utcnow(),
"iat": now,
"nbf": now,
"type": "refresh",
"jti": secrets.token_urlsafe(16), # Unique ID
"jti": token_id,
}
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
if fingerprint_hash:
to_encode["fph"] = fingerprint_hash
token = jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
return token, token_id
def decode_token(token: str) -> Optional[Dict]:
def create_csrf_token(session_id: str) -> str:
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=settings.csrf_token_expire_minutes)
to_encode = {
"sid": session_id,
"exp": expire,
"iat": now,
"type": "csrf",
"jti": generate_secure_token(8),
}
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(
token: str, expected_type: Optional[str] = None
) -> Optional[Dict[str, Any]]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
payload = jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
options={
"require": ["exp", "iat", "type"],
"verify_exp": True,
"verify_iat": True,
"verify_nbf": True,
},
)
if expected_type and payload.get("type") != expected_type:
logger.warning(
f"Type de token incorrect: attendu={expected_type}, recu={payload.get('type')}"
)
return None
return payload
except jwt.ExpiredSignatureError:
raise jwt.InvalidTokenError("Token expiré")
except jwt.DecodeError:
raise jwt.InvalidTokenError("Token invalide (format incorrect)")
logger.debug("Token expire")
return None
except jwt.InvalidTokenError as e:
raise jwt.InvalidTokenError(f"Token invalide: {str(e)}")
logger.warning(f"Token invalide: {e}")
return None
except Exception as e:
raise jwt.InvalidTokenError(f"Erreur lors du décodage du token: {str(e)}")
logger.error(f"Erreur decodage token: {e}")
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"
def validate_password_strength(password: str) -> Tuple[bool, str]:
if len(password) < settings.password_min_length:
return (
False,
f"Le mot de passe doit contenir au moins {settings.password_min_length} caracteres",
)
if not any(c.isupper() for c in password):
if settings.password_require_uppercase and 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):
if settings.password_require_lowercase and 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):
if settings.password_require_digit and not any(c.isdigit() for c in password):
return False, "Le mot de passe doit contenir au moins un chiffre"
special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"
if settings.password_require_special:
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 False, "Le mot de passe doit contenir au moins un caractere special"
common_passwords = [
"password",
"123456",
"qwerty",
"admin",
"letmein",
"welcome",
"monkey",
"dragon",
"master",
"login",
]
if password.lower() in common_passwords:
return False, "Ce mot de passe est trop courant"
return True, ""
def generate_session_id() -> str:
"""Genere un identifiant de session unique."""
return generate_secure_token(24)

157
security/cookies.py Normal file
View file

@ -0,0 +1,157 @@
from fastapi import Response, Request
from typing import Optional
import logging
from config.config import settings
logger = logging.getLogger(__name__)
class CookieManager:
@staticmethod
def _get_samesite_value() -> str:
value = settings.cookie_samesite.lower()
if value in ("strict", "lax", "none"):
return value
return "strict"
@staticmethod
def _should_be_secure() -> bool:
if settings.is_development and not settings.cookie_secure:
return False
return True
@classmethod
def set_access_token(
cls, response: Response, token: str, max_age: Optional[int] = None
) -> None:
if max_age is None:
max_age = settings.access_token_expire_minutes * 60
response.set_cookie(
key=settings.cookie_access_token_name,
value=token,
max_age=max_age,
expires=max_age,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie access_token defini")
@classmethod
def set_refresh_token(
cls, response: Response, token: str, max_age: Optional[int] = None
) -> None:
if max_age is None:
max_age = settings.refresh_token_expire_days * 24 * 60 * 60
response.set_cookie(
key=settings.cookie_refresh_token_name,
value=token,
max_age=max_age,
expires=max_age,
path="/auth",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie refresh_token defini")
@classmethod
def set_csrf_token(
cls, response: Response, token: str, max_age: Optional[int] = None
) -> None:
if max_age is None:
max_age = settings.csrf_token_expire_minutes * 60
response.set_cookie(
key=settings.cookie_csrf_token_name,
value=token,
max_age=max_age,
expires=max_age,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=False,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie csrf_token defini")
@classmethod
def clear_access_token(cls, response: Response) -> None:
response.delete_cookie(
key=settings.cookie_access_token_name,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie access_token supprime")
@classmethod
def clear_refresh_token(cls, response: Response) -> None:
response.delete_cookie(
key=settings.cookie_refresh_token_name,
path="/auth",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=settings.cookie_httponly,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie refresh_token supprime")
@classmethod
def clear_csrf_token(cls, response: Response) -> None:
response.delete_cookie(
key=settings.cookie_csrf_token_name,
path="/",
domain=settings.cookie_domain,
secure=cls._should_be_secure(),
httponly=False,
samesite=cls._get_samesite_value(),
)
logger.debug("Cookie csrf_token supprime")
@classmethod
def clear_all_auth_cookies(cls, response: Response) -> None:
cls.clear_access_token(response)
cls.clear_refresh_token(response)
cls.clear_csrf_token(response)
logger.debug("Tous les cookies auth supprimes")
@classmethod
def get_access_token(cls, request: Request) -> Optional[str]:
token = request.cookies.get(settings.cookie_access_token_name)
if token:
return token
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
return auth_header[7:]
return None
@classmethod
def get_refresh_token(cls, request: Request) -> Optional[str]:
return request.cookies.get(settings.cookie_refresh_token_name)
@classmethod
def get_csrf_token(cls, request: Request) -> Optional[str]:
csrf_header = request.headers.get("X-CSRF-Token")
if csrf_header:
return csrf_header
return request.cookies.get(settings.cookie_csrf_token_name)
def set_auth_cookies(
response: Response, access_token: str, refresh_token: str, csrf_token: str
) -> None:
CookieManager.set_access_token(response, access_token)
CookieManager.set_refresh_token(response, refresh_token)
CookieManager.set_csrf_token(response, csrf_token)

117
security/csrf.py Normal file
View file

@ -0,0 +1,117 @@
"""
security/csrf.py - Protection contre les attaques Cross-Site Request Forgery
"""
from fastapi import Request, HTTPException, status
from typing import Optional, Set
import logging
from config.config import settings
from security.auth import decode_token, create_csrf_token, constant_time_compare
logger = logging.getLogger(__name__)
SAFE_METHODS: Set[str] = {"GET", "HEAD", "OPTIONS", "TRACE"}
CSRF_EXEMPT_PATHS: Set[str] = {
"/auth/login",
"/auth/register",
"/auth/forgot-password",
"/auth/verify-email",
"/auth/resend-verification",
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/webhooks/universign",
}
class CSRFProtection:
@classmethod
def is_exempt(cls, request: Request) -> bool:
if request.method in SAFE_METHODS:
return True
path = request.url.path.rstrip("/")
if path in CSRF_EXEMPT_PATHS:
return True
for exempt_path in CSRF_EXEMPT_PATHS:
if path.startswith(exempt_path):
return True
return False
@classmethod
def generate_token(cls, session_id: str) -> str:
return create_csrf_token(session_id)
@classmethod
def validate_token(cls, request: Request, session_id: Optional[str] = None) -> bool:
csrf_header = request.headers.get("X-CSRF-Token")
if not csrf_header:
logger.warning("Token CSRF manquant dans le header")
return False
payload = decode_token(csrf_header, expected_type="csrf")
if not payload:
logger.warning("Token CSRF invalide ou expire")
return False
if session_id and payload.get("sid") != session_id:
logger.warning("Token CSRF ne correspond pas a la session")
return False
return True
@classmethod
def validate_double_submit(cls, request: Request) -> bool:
header_token = request.headers.get("X-CSRF-Token")
cookie_token = request.cookies.get(settings.cookie_csrf_token_name)
if not header_token or not cookie_token:
logger.warning("Token CSRF manquant (header ou cookie)")
return False
if not constant_time_compare(header_token, cookie_token):
logger.warning("Tokens CSRF ne correspondent pas")
return False
return True
@classmethod
def validate_request(
cls,
request: Request,
session_id: Optional[str] = None,
use_double_submit: bool = True,
) -> bool:
if cls.is_exempt(request):
return True
if use_double_submit:
if not cls.validate_double_submit(request):
return False
return cls.validate_token(request, session_id)
async def verify_csrf(request: Request, session_id: Optional[str] = None) -> None:
if CSRFProtection.is_exempt(request):
return
if not CSRFProtection.validate_request(request, session_id):
logger.warning(
f"Verification CSRF echouee pour {request.method} {request.url.path}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Verification CSRF echouee"
)
def generate_csrf_for_session(session_id: str) -> str:
return CSRFProtection.generate_token(session_id)

122
security/fingerprint.py Normal file
View file

@ -0,0 +1,122 @@
from fastapi import Request
from typing import Dict
import hashlib
import hmac
import logging
from config.config import settings
logger = logging.getLogger(__name__)
class DeviceFingerprint:
COMPONENT_EXTRACTORS = {
"user_agent": lambda r: r.headers.get("User-Agent", ""),
"accept_language": lambda r: r.headers.get("Accept-Language", ""),
"accept_encoding": lambda r: r.headers.get("Accept-Encoding", ""),
"accept": lambda r: r.headers.get("Accept", ""),
"connection": lambda r: r.headers.get("Connection", ""),
"cache_control": lambda r: r.headers.get("Cache-Control", ""),
"client_ip": lambda r: DeviceFingerprint._get_client_ip(r),
"sec_ch_ua": lambda r: r.headers.get("Sec-CH-UA", ""),
"sec_ch_ua_platform": lambda r: r.headers.get("Sec-CH-UA-Platform", ""),
"sec_ch_ua_mobile": lambda r: r.headers.get("Sec-CH-UA-Mobile", ""),
}
@staticmethod
def _get_client_ip(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
if request.client:
return request.client.host
return ""
@classmethod
def extract_components(cls, request: Request) -> Dict[str, str]:
components = {}
for component_name in settings.fingerprint_components:
extractor = cls.COMPONENT_EXTRACTORS.get(component_name)
if extractor:
try:
value = extractor(request)
components[component_name] = value if value else ""
except Exception as e:
logger.warning(f"Erreur extraction composant {component_name}: {e}")
components[component_name] = ""
else:
logger.warning(f"Extracteur inconnu pour composant: {component_name}")
return components
@classmethod
def generate_hash(cls, request: Request, include_ip: bool = False) -> str:
components = cls.extract_components(request)
if not include_ip and "client_ip" in components:
del components["client_ip"]
sorted_keys = sorted(components.keys())
fingerprint_data = "|".join(f"{k}:{components[k]}" for k in sorted_keys)
secret = settings.fingerprint_secret or settings.jwt_secret
signature = hmac.new(
secret.encode(), fingerprint_data.encode(), hashlib.sha256
).hexdigest()
return signature
@classmethod
def generate_from_components(cls, components: Dict[str, str]) -> str:
sorted_keys = sorted(components.keys())
fingerprint_data = "|".join(f"{k}:{components.get(k, '')}" for k in sorted_keys)
secret = settings.fingerprint_secret or settings.jwt_secret
signature = hmac.new(
secret.encode(), fingerprint_data.encode(), hashlib.sha256
).hexdigest()
return signature
@classmethod
def validate(
cls, request: Request, stored_hash: str, include_ip: bool = False
) -> bool:
if not stored_hash:
return True
current_hash = cls.generate_hash(request, include_ip=include_ip)
return hmac.compare_digest(current_hash, stored_hash)
@classmethod
def get_device_info(cls, request: Request) -> Dict[str, str]:
user_agent = request.headers.get("User-Agent", "")
return {
"user_agent": user_agent[:500] if user_agent else "",
"ip_address": cls._get_client_ip(request),
"accept_language": request.headers.get("Accept-Language", "")[:100],
"fingerprint_hash": cls.generate_hash(request),
}
def get_fingerprint_hash(request: Request) -> str:
return DeviceFingerprint.generate_hash(request)
def validate_fingerprint(request: Request, stored_hash: str) -> bool:
return DeviceFingerprint.validate(request, stored_hash)
def get_client_ip(request: Request) -> str:
return DeviceFingerprint._get_client_ip(request)

147
security/rate_limiter.py Normal file
View file

@ -0,0 +1,147 @@
from fastapi import Request, HTTPException, status
from typing import Optional, Tuple
import logging
from config.config import settings
from services.redis_service import redis_service
from security.fingerprint import get_client_ip
logger = logging.getLogger(__name__)
class RateLimiter:
@staticmethod
def _make_key(identifier: str, action: str) -> str:
return f"{action}:{identifier}"
@classmethod
async def check_login_rate_limit(
cls, email: str, ip_address: str
) -> Tuple[bool, Optional[str], int]:
window_seconds = settings.rate_limit_login_window_minutes * 60
max_attempts = settings.rate_limit_login_attempts
email_key = cls._make_key(email.lower(), "login_email")
email_count = await redis_service.get_rate_limit_count(email_key)
if email_count >= max_attempts:
return (
False,
f"Trop de tentatives pour cet email. Reessayez dans {settings.rate_limit_login_window_minutes} minutes.",
window_seconds,
)
ip_key = cls._make_key(ip_address, "login_ip")
ip_count = await redis_service.get_rate_limit_count(ip_key)
ip_limit = max_attempts * 3
if ip_count >= ip_limit:
return (
False,
window_seconds,
)
return (True, None, 0)
@classmethod
async def record_login_attempt(
cls, email: str, ip_address: str, success: bool
) -> None:
window_seconds = settings.rate_limit_login_window_minutes * 60
if success:
email_key = cls._make_key(email.lower(), "login_email")
await redis_service.reset_rate_limit(email_key)
logger.debug(f"Rate limit reinitialise pour {email}")
else:
email_key = cls._make_key(email.lower(), "login_email")
await redis_service.increment_rate_limit(email_key, window_seconds)
ip_key = cls._make_key(ip_address, "login_ip")
await redis_service.increment_rate_limit(ip_key, window_seconds)
logger.debug(
f"Tentative echouee enregistree pour {email} depuis {ip_address}"
)
@classmethod
async def check_api_rate_limit(
cls, identifier: str, endpoint: Optional[str] = None
) -> Tuple[bool, int, int]:
window_seconds = settings.rate_limit_api_window_seconds
max_requests = settings.rate_limit_api_requests
if endpoint:
key = cls._make_key(f"{identifier}:{endpoint}", "api")
else:
key = cls._make_key(identifier, "api")
count = await redis_service.increment_rate_limit(key, window_seconds)
remaining = max(0, max_requests - count)
if count > max_requests:
return (False, remaining, window_seconds)
return (True, remaining, window_seconds)
@classmethod
async def check_password_reset_rate_limit(
cls, email: str, ip_address: str
) -> Tuple[bool, Optional[str]]:
window_seconds = 3600
max_attempts_email = 3
max_attempts_ip = 10
email_key = cls._make_key(email.lower(), "reset_email")
email_count = await redis_service.get_rate_limit_count(email_key)
if email_count >= max_attempts_email:
return (False, "Trop de demandes de reinitialisation pour cet email.")
ip_key = cls._make_key(ip_address, "reset_ip")
ip_count = await redis_service.get_rate_limit_count(ip_key)
if ip_count >= max_attempts_ip:
return (False, "Trop de demandes depuis cette adresse IP.")
await redis_service.increment_rate_limit(email_key, window_seconds)
await redis_service.increment_rate_limit(ip_key, window_seconds)
return (True, None)
@classmethod
async def check_registration_rate_limit(
cls, ip_address: str
) -> Tuple[bool, Optional[str]]:
window_seconds = 3600
max_registrations = 5
key = cls._make_key(ip_address, "register_ip")
count = await redis_service.get_rate_limit_count(key)
if count >= max_registrations:
return (False, "Trop d'inscriptions depuis cette adresse IP.")
await redis_service.increment_rate_limit(key, window_seconds)
return (True, None)
async def check_rate_limit_dependency(request: Request) -> None:
ip = get_client_ip(request)
allowed, remaining, reset_seconds = await RateLimiter.check_api_rate_limit(ip)
request.state.rate_limit_remaining = remaining
request.state.rate_limit_reset = reset_seconds
if not allowed:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Limite de requetes atteinte. Reessayez plus tard.",
headers={
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": str(reset_seconds),
"Retry-After": str(reset_seconds),
},
)

View file

@ -1,223 +0,0 @@
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:
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 == 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
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,
}

318
services/audit_service.py Normal file
View file

@ -0,0 +1,318 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false, select, and_
from datetime import datetime, timedelta
from typing import Optional, Dict, Any, List
from fastapi import Request
import uuid
import json
import logging
from database import AuditLog, AuditEventType, LoginAttempt
from security.fingerprint import DeviceFingerprint, get_client_ip
logger = logging.getLogger(__name__)
class AuditService:
@classmethod
async def log_event(
cls,
session: AsyncSession,
event_type: AuditEventType,
request: Optional[Request] = None,
user_id: Optional[str] = None,
description: Optional[str] = None,
success: bool = True,
failure_reason: Optional[str] = None,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> AuditLog:
ip_address = None
user_agent = None
fingerprint_hash = None
request_method = None
request_path = None
if request:
ip_address = get_client_ip(request)
user_agent = request.headers.get("User-Agent", "")[:500]
fingerprint_hash = DeviceFingerprint.generate_hash(request)
request_method = request.method
request_path = str(request.url.path)[:500]
metadata_json = None
if metadata:
try:
metadata_json = json.dumps(metadata, default=str)
except Exception as e:
logger.warning(f"Erreur serialisation metadata audit: {e}")
audit_log = AuditLog(
id=str(uuid.uuid4()),
user_id=user_id,
event_type=event_type,
event_description=description,
ip_address=ip_address,
user_agent=user_agent,
fingerprint_hash=fingerprint_hash,
resource_type=resource_type,
resource_id=resource_id,
request_method=request_method,
request_path=request_path,
metadata=metadata_json,
success=success,
failure_reason=failure_reason,
created_at=datetime.now(),
)
session.add(audit_log)
await session.flush()
log_level = logging.INFO if success else logging.WARNING
logger.log(
log_level,
f"Audit: {event_type.value} user={user_id} success={success} ip={ip_address}",
)
return audit_log
@classmethod
async def log_login_success(
cls, session: AsyncSession, request: Request, user_id: str, email: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.LOGIN_SUCCESS,
request=request,
user_id=user_id,
description=f"Connexion reussie pour {email}",
success=True,
metadata={"email": email},
)
@classmethod
async def log_login_failed(
cls,
session: AsyncSession,
request: Request,
email: str,
reason: str,
user_id: Optional[str] = None,
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.LOGIN_FAILED,
request=request,
user_id=user_id,
description=f"Echec connexion pour {email}: {reason}",
success=False,
failure_reason=reason,
metadata={"email": email},
)
@classmethod
async def log_logout(
cls, session: AsyncSession, request: Request, user_id: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.LOGOUT,
request=request,
user_id=user_id,
description="Deconnexion utilisateur",
success=True,
)
@classmethod
async def log_password_change(
cls,
session: AsyncSession,
request: Request,
user_id: str,
method: str = "user_initiated",
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.PASSWORD_CHANGE,
request=request,
user_id=user_id,
description=f"Mot de passe modifie ({method})",
success=True,
metadata={"method": method},
)
@classmethod
async def log_password_reset_request(
cls,
session: AsyncSession,
request: Request,
email: str,
user_id: Optional[str] = None,
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.PASSWORD_RESET_REQUEST,
request=request,
user_id=user_id,
description=f"Demande reset mot de passe pour {email}",
success=True,
metadata={"email": email},
)
@classmethod
async def log_account_locked(
cls, session: AsyncSession, request: Request, user_id: str, reason: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.ACCOUNT_LOCKED,
request=request,
user_id=user_id,
description=f"Compte verrouille: {reason}",
success=True,
metadata={"reason": reason},
)
@classmethod
async def log_token_refresh(
cls, session: AsyncSession, request: Request, user_id: str
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.TOKEN_REFRESH,
request=request,
user_id=user_id,
description="Token rafraichi",
success=True,
)
@classmethod
async def log_suspicious_activity(
cls,
session: AsyncSession,
request: Request,
user_id: Optional[str],
activity_type: str,
details: str,
) -> AuditLog:
return await cls.log_event(
session=session,
event_type=AuditEventType.SUSPICIOUS_ACTIVITY,
request=request,
user_id=user_id,
description=f"Activite suspecte: {activity_type} - {details}",
success=False,
failure_reason=activity_type,
metadata={"activity_type": activity_type, "details": details},
)
@classmethod
async def record_login_attempt(
cls,
session: AsyncSession,
request: Request,
email: str,
success: bool,
failure_reason: Optional[str] = None,
) -> LoginAttempt:
attempt = LoginAttempt(
email=email.lower(),
ip_address=get_client_ip(request),
user_agent=request.headers.get("User-Agent", "")[:500],
fingerprint_hash=DeviceFingerprint.generate_hash(request),
success=success,
failure_reason=failure_reason,
timestamp=datetime.now(),
)
session.add(attempt)
await session.flush()
return attempt
@classmethod
async def get_recent_failed_attempts(
cls, session: AsyncSession, email: str, window_minutes: int = 15
) -> int:
time_threshold = datetime.now() - timedelta(minutes=window_minutes)
result = await session.execute(
select(LoginAttempt).where(
and_(
LoginAttempt.email == email.lower(),
LoginAttempt.success.is_(false()),
LoginAttempt.timestamp >= time_threshold,
)
)
)
return len(result.scalars().all())
@classmethod
async def get_user_audit_history(
cls,
session: AsyncSession,
user_id: str,
limit: int = 50,
event_types: Optional[List[AuditEventType]] = None,
) -> List[AuditLog]:
query = select(AuditLog).where(AuditLog.user_id == user_id)
if event_types:
query = query.where(AuditLog.event_type.in_(event_types))
query = query.order_by(AuditLog.created_at.desc()).limit(limit)
result = await session.execute(query)
return list(result.scalars().all())
@classmethod
async def detect_suspicious_patterns(
cls, session: AsyncSession, user_id: str
) -> Dict[str, Any]:
one_hour_ago = datetime.now() - timedelta(hours=1)
one_day_ago = datetime.now() - timedelta(days=1)
result = await session.execute(
select(AuditLog).where(
and_(
AuditLog.user_id == user_id,
AuditLog.event_type == AuditEventType.LOGIN_FAILED,
AuditLog.created_at >= one_hour_ago,
)
)
)
failed_logins_hour = len(result.scalars().all())
result = await session.execute(
select(AuditLog).where(
and_(
AuditLog.user_id == user_id,
AuditLog.event_type == AuditEventType.LOGIN_SUCCESS,
AuditLog.created_at >= one_day_ago,
)
)
)
login_logs = result.scalars().all()
unique_ips = set(log.ip_address for log in login_logs if log.ip_address)
result = await session.execute(
select(AuditLog).where(
and_(
AuditLog.user_id == user_id,
AuditLog.event_type == AuditEventType.PASSWORD_RESET_REQUEST,
AuditLog.created_at >= one_day_ago,
)
)
)
password_resets = len(result.scalars().all())
return {
"failed_logins_last_hour": failed_logins_hour,
"unique_ips_last_day": len(unique_ips),
"password_reset_requests_last_day": password_resets,
"is_suspicious": (
failed_logins_hour >= 5 or len(unique_ips) >= 5 or password_resets >= 3
),
}

571
services/email_queue.py Normal file
View file

@ -0,0 +1,571 @@
import threading
import queue
import time
import asyncio
from datetime import datetime, timedelta
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.config import settings
import logging
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import cm
from io import BytesIO
logger = logging.getLogger(__name__)
ULTRA_DEBUG = True
def debug_log(message: str, level: str = "INFO"):
if ULTRA_DEBUG:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
prefix = {
"INFO": "[INFO]",
"SUCCESS": "[SUCCESS]",
"ERROR": "[ERROR]",
"WARN": "[WARN]",
"STEP": "[STEP]",
"DATA": "[DATA]",
}.get(level, "")
logger.info(f"{prefix} [{timestamp}] {message}")
class EmailQueue:
def __init__(self):
self.queue = queue.Queue()
self.workers = []
self.running = False
self.session_factory = None
self.sage_client = None
def start(self, num_workers: int = 3):
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
)
worker.start()
self.workers.append(worker)
logger.info(f" Queue email démarrée avec {num_workers} worker(s)")
def stop(self):
logger.info("Arrêt de la queue email...")
self.running = False
try:
self.queue.join()
logger.info(" Queue email arrêtée proprement")
except Exception:
logger.warning(" Timeout lors de l'arrêt de la queue")
def enqueue(self, email_log_id: str):
self.queue.put(email_log_id)
debug_log(
f"Email {email_log_id} ajouté à la queue (taille: {self.queue.qsize()})"
)
def _worker(self):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
worker_name = threading.current_thread().name
debug_log(f"Worker {worker_name} démarré", "SUCCESS")
try:
while self.running:
try:
email_log_id = self.queue.get(timeout=1)
debug_log(
f"[{worker_name}] Traitement email {email_log_id}", "STEP"
)
loop.run_until_complete(self._process_email(email_log_id))
self.queue.task_done()
except queue.Empty:
continue
except Exception as e:
logger.error(f" Erreur worker {worker_name}: {e}", exc_info=True)
try:
self.queue.task_done()
except Exception:
pass
finally:
loop.close()
debug_log(f"Worker {worker_name} arrêté", "WARN")
async def _process_email(self, email_log_id: str):
from database import EmailLog, StatutEmail
from sqlalchemy import select
debug_log(f"═══ DÉBUT TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
if not self.session_factory:
logger.error(" session_factory non configuré")
return
async with self.session_factory() as session:
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 en DB")
return
debug_log("Email trouvé en DB:", "DATA")
debug_log(f" → Destinataire: {email_log.destinataire}")
debug_log(f" → Sujet: {email_log.sujet[:50]}...")
debug_log(f" → Tentative: {email_log.nb_tentatives + 1}")
debug_log(f" → Documents: {email_log.document_ids}")
email_log.statut = StatutEmail.EN_COURS
email_log.nb_tentatives += 1
await session.commit()
try:
await self._send_with_retry(email_log)
email_log.statut = StatutEmail.ENVOYE
email_log.date_envoi = datetime.now()
email_log.derniere_erreur = None
debug_log(
f"Email envoyé avec succès: {email_log.destinataire}", "SUCCESS"
)
except Exception as e:
error_msg = str(e)
debug_log(f"ÉCHEC ENVOI: {error_msg}", "ERROR")
email_log.statut = StatutEmail.ERREUR
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)
)
email_log.prochain_retry = datetime.now() + timedelta(seconds=delay)
timer = threading.Timer(delay, self.enqueue, args=[email_log_id])
timer.daemon = True
timer.start()
debug_log(
f"Retry #{email_log.nb_tentatives + 1} planifié dans {delay}s",
"WARN",
)
else:
debug_log(
f"ÉCHEC DÉFINITIF après {email_log.nb_tentatives} tentatives",
"ERROR",
)
await session.commit()
debug_log(f"═══ FIN TRAITEMENT EMAIL {email_log_id} ═══", "STEP")
async def _send_with_retry(self, email_log):
debug_log("Construction du message MIME...", "STEP")
msg = MIMEMultipart()
msg["From"] = settings.smtp_from
msg["To"] = email_log.destinataire
msg["Subject"] = email_log.sujet
debug_log("Headers configurés:", "DATA")
debug_log(f" → From: {settings.smtp_from}")
debug_log(f" → To: {email_log.destinataire}")
debug_log(f" → Subject: {email_log.sujet}")
msg.attach(MIMEText(email_log.corps_html, "html"))
debug_log(f"Corps HTML attaché ({len(email_log.corps_html)} chars)")
# Attachement des PDFs
if email_log.document_ids:
document_ids = email_log.document_ids.split(",")
type_doc = email_log.type_document
debug_log(f"Documents à attacher: {document_ids}")
for doc_id in document_ids:
doc_id = doc_id.strip()
if not doc_id:
continue
try:
debug_log(f"Génération PDF pour {doc_id}...")
pdf_bytes = await asyncio.to_thread(
self._generate_pdf, doc_id, type_doc
)
if pdf_bytes:
part = MIMEApplication(pdf_bytes, Name=f"{doc_id}.pdf")
part["Content-Disposition"] = (
f'attachment; filename="{doc_id}.pdf"'
)
msg.attach(part)
debug_log(
f"PDF attaché: {doc_id}.pdf ({len(pdf_bytes)} bytes)",
"SUCCESS",
)
except Exception as e:
debug_log(f"Erreur génération PDF {doc_id}: {e}", "ERROR")
# Envoi SMTP
debug_log("Lancement envoi SMTP...", "STEP")
await asyncio.to_thread(self._send_smtp, msg)
def _send_smtp(self, msg):
debug_log("═══════════════════════════════════════════", "STEP")
debug_log(" DÉBUT ENVOI SMTP ULTRA DEBUG", "STEP")
debug_log("═══════════════════════════════════════════", "STEP")
# ═══ CONFIGURATION ═══
debug_log("CONFIGURATION SMTP:", "DATA")
debug_log(f" → Host: {settings.smtp_host}")
debug_log(f" → Port: {settings.smtp_port}")
debug_log(f" → User: {settings.smtp_user}")
debug_log(
f" → Password: {'*' * len(settings.smtp_password) if settings.smtp_password else 'NON DÉFINI'}"
)
debug_log(f" → From: {settings.smtp_from}")
debug_log(f" → TLS: {settings.smtp_use_tls}")
debug_log(f" → To: {msg['To']}")
server = None
try:
# ═══ ÉTAPE 1: RÉSOLUTION DNS ═══
debug_log("ÉTAPE 1/7: Résolution DNS...", "STEP")
try:
ip_addresses = socket.getaddrinfo(
settings.smtp_host, settings.smtp_port
)
debug_log(f" → DNS résolu: {ip_addresses[0][4]}", "SUCCESS")
except socket.gaierror as e:
debug_log(f" → ÉCHEC DNS: {e}", "ERROR")
raise Exception(
f"Résolution DNS échouée pour {settings.smtp_host}: {e}"
)
# ═══ ÉTAPE 2: CONNEXION TCP ═══
debug_log("ÉTAPE 2/7: Connexion TCP...", "STEP")
start_time = time.time()
try:
server = smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30
)
server.set_debuglevel(
2 if ULTRA_DEBUG else 0
) # Active le debug SMTP natif
connect_time = time.time() - start_time
debug_log(f" → Connexion établie en {connect_time:.2f}s", "SUCCESS")
except socket.timeout:
debug_log(" → TIMEOUT connexion (>30s)", "ERROR")
raise Exception(
f"Timeout connexion TCP vers {settings.smtp_host}:{settings.smtp_port}"
)
except ConnectionRefusedError:
debug_log(" → CONNEXION REFUSÉE", "ERROR")
raise Exception(
f"Connexion refusée par {settings.smtp_host}:{settings.smtp_port}"
)
except Exception as e:
debug_log(f" → ERREUR CONNEXION: {type(e).__name__}: {e}", "ERROR")
raise
# ═══ ÉTAPE 3: EHLO ═══
debug_log("ÉTAPE 3/7: Envoi EHLO...", "STEP")
try:
ehlo_code, ehlo_msg = server.ehlo()
debug_log(f" → EHLO Response: {ehlo_code}", "SUCCESS")
debug_log(f" → Capabilities: {ehlo_msg.decode()[:200]}...")
except Exception as e:
debug_log(f" → ÉCHEC EHLO: {e}", "ERROR")
raise
# ═══ ÉTAPE 4: STARTTLS ═══
if settings.smtp_use_tls:
debug_log("ÉTAPE 4/7: Négociation STARTTLS...", "STEP")
try:
# Vérifier si le serveur supporte STARTTLS
if server.has_extn("STARTTLS"):
debug_log(" → Serveur supporte STARTTLS", "SUCCESS")
# Créer un contexte SSL
context = ssl.create_default_context()
debug_log(
f" → Contexte SSL créé (protocole: {context.protocol})"
)
tls_code, tls_msg = server.starttls(context=context)
debug_log(
f" → STARTTLS Response: {tls_code} - {tls_msg}", "SUCCESS"
)
# Re-EHLO après STARTTLS
server.ehlo()
debug_log(" → Re-EHLO après TLS: OK", "SUCCESS")
else:
debug_log(" → Serveur ne supporte PAS STARTTLS!", "WARN")
except smtplib.SMTPNotSupportedError:
debug_log(" → STARTTLS non supporté par le serveur", "WARN")
except ssl.SSLError as e:
debug_log(f" → ERREUR SSL: {e}", "ERROR")
raise Exception(f"Erreur SSL/TLS: {e}")
except Exception as e:
debug_log(f" → ÉCHEC STARTTLS: {type(e).__name__}: {e}", "ERROR")
raise
else:
debug_log("ÉTAPE 4/7: STARTTLS désactivé (smtp_use_tls=False)", "WARN")
# ═══ ÉTAPE 5: AUTHENTIFICATION ═══
debug_log("ÉTAPE 5/7: Authentification...", "STEP")
if settings.smtp_user and settings.smtp_password:
debug_log(f" → Tentative login avec: {settings.smtp_user}")
try:
# Lister les méthodes d'auth supportées
if server.has_extn("AUTH"):
auth_methods = server.esmtp_features.get("auth", "")
debug_log(f" → Méthodes AUTH supportées: {auth_methods}")
server.login(settings.smtp_user, settings.smtp_password)
debug_log(" → Authentification RÉUSSIE", "SUCCESS")
except smtplib.SMTPAuthenticationError as e:
debug_log(
f" → ÉCHEC AUTHENTIFICATION: {e.smtp_code} - {e.smtp_error}",
"ERROR",
)
debug_log(f" → Code: {e.smtp_code}", "ERROR")
debug_log(
f" → Message: {e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else e.smtp_error}",
"ERROR",
)
# Diagnostic spécifique selon le code d'erreur
if e.smtp_code == 535:
debug_log(
" → 535 = Identifiants incorrects ou app password requis",
"ERROR",
)
elif e.smtp_code == 534:
debug_log(
" → 534 = 2FA requis, utiliser un App Password", "ERROR"
)
elif e.smtp_code == 530:
debug_log(
" → 530 = Authentification requise mais échouée", "ERROR"
)
raise Exception(
f"Authentification SMTP échouée: {e.smtp_code} - {e.smtp_error}"
)
except smtplib.SMTPException as e:
debug_log(f" → ERREUR SMTP AUTH: {e}", "ERROR")
raise
else:
debug_log(" → Pas d'authentification configurée", "WARN")
# ═══ ÉTAPE 6: ENVOI DU MESSAGE ═══
debug_log("ÉTAPE 6/7: Envoi du message...", "STEP")
debug_log(f" → From: {msg['From']}")
debug_log(f" → To: {msg['To']}")
debug_log(f" → Subject: {msg['Subject']}")
debug_log(f" → Taille message: {len(msg.as_string())} bytes")
try:
# send_message retourne un dict des destinataires refusés
refused = server.send_message(msg)
if refused:
debug_log(f" → DESTINATAIRES REFUSÉS: {refused}", "ERROR")
raise Exception(f"Destinataires refusés: {refused}")
else:
debug_log(" → Message envoyé avec succès!", "SUCCESS")
except smtplib.SMTPRecipientsRefused as e:
debug_log(f" → DESTINATAIRE REFUSÉ: {e.recipients}", "ERROR")
raise Exception(f"Destinataire refusé: {e.recipients}")
except smtplib.SMTPSenderRefused as e:
debug_log(
f" → EXPÉDITEUR REFUSÉ: {e.smtp_code} - {e.smtp_error}", "ERROR"
)
debug_log(
f" → L'adresse '{msg['From']}' n'est pas autorisée à envoyer",
"ERROR",
)
raise Exception(f"Expéditeur refusé: {e.smtp_code} - {e.smtp_error}")
except smtplib.SMTPDataError as e:
debug_log(f" → ERREUR DATA: {e.smtp_code} - {e.smtp_error}", "ERROR")
raise Exception(f"Erreur DATA SMTP: {e.smtp_code} - {e.smtp_error}")
# ═══ ÉTAPE 7: QUIT ═══
debug_log("ÉTAPE 7/7: Fermeture connexion...", "STEP")
try:
server.quit()
debug_log(" → Connexion fermée proprement", "SUCCESS")
except Exception:
pass
debug_log("═══════════════════════════════════════════", "SUCCESS")
debug_log(" ENVOI SMTP TERMINÉ AVEC SUCCÈS", "SUCCESS")
debug_log("═══════════════════════════════════════════", "SUCCESS")
except Exception as e:
debug_log("═══════════════════════════════════════════", "ERROR")
debug_log(f" ÉCHEC ENVOI SMTP: {type(e).__name__}", "ERROR")
debug_log(f" Message: {str(e)}", "ERROR")
debug_log("═══════════════════════════════════════════", "ERROR")
# Fermer la connexion si elle existe
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:
if not self.sage_client:
logger.error(" sage_client non configuré")
raise Exception("sage_client non disponible")
try:
doc = self.sage_client.lire_document(doc_id, type_doc)
except Exception as e:
logger.error(f" Erreur récupération document {doc_id}: {e}")
raise Exception(f"Document {doc_id} inaccessible")
if not doc:
raise Exception(f"Document {doc_id} introuvable")
buffer = BytesIO()
pdf = canvas.Canvas(buffer, pagesize=A4)
width, height = A4
pdf.setFont("Helvetica-Bold", 20)
pdf.drawString(2 * cm, height - 3 * cm, f"Document N° {doc_id}")
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}")
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') or ''}")
y -= 0.6 * cm
pdf.drawString(2 * cm, y, f"Nom: {doc.get('client_intitule') or ''}")
y -= 0.6 * cm
pdf.drawString(2 * cm, y, f"Date: {doc.get('date') or ''}")
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
pdf.setFont("Helvetica", 9)
for ligne in doc.get("lignes", []):
if y < 3 * cm:
pdf.showPage()
y = height - 3 * cm
pdf.setFont("Helvetica", 9)
# FIX: Gérer les valeurs None correctement
designation = (
ligne.get("designation") or ligne.get("designation_article") or ""
)
if designation:
designation = str(designation)[:50]
else:
designation = ""
pdf.drawString(2 * cm, y, designation)
pdf.drawString(10 * cm, y, str(ligne.get("quantite") or 0))
pdf.drawString(
12 * cm,
y,
f"{ligne.get('prix_unitaire_ht') or ligne.get('prix_unitaire', 0):.2f}",
)
pdf.drawString(
15 * cm,
y,
f"{ligne.get('montant_ligne_ht') or ligne.get('montant_ht', 0):.2f}",
)
y -= 0.6 * cm
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') or 0:.2f}")
y -= 0.6 * cm
pdf.drawString(12 * cm, y, "TVA (20%):")
tva = (doc.get("total_ttc") or 0) - (doc.get("total_ht") or 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') or 0:.2f}")
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")
pdf.save()
buffer.seek(0)
logger.info(f" PDF généré: {doc_id}.pdf")
return buffer.read()
email_queue = EmailQueue()

View file

@ -1,22 +1,39 @@
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from config.config import settings
from typing import Optional, List
import logging
from config.config import settings
logger = logging.getLogger(__name__)
class AuthEmailService:
@staticmethod
def _send_email(to: str, subject: str, html_body: str) -> bool:
def _send_email(
to: str,
subject: str,
html_body: str,
cc: Optional[List[str]] = None,
bcc: Optional[List[str]] = None,
) -> bool:
try:
msg = MIMEMultipart()
msg = MIMEMultipart("alternative")
msg["From"] = settings.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.attach(MIMEText(html_body, "html"))
if cc:
msg["Cc"] = ", ".join(cc)
msg.attach(MIMEText(html_body, "html", "utf-8"))
recipients = [to]
if cc:
recipients.extend(cc)
if bcc:
recipients.extend(bcc)
with smtplib.SMTP(
settings.smtp_host, settings.smtp_port, timeout=30
@ -27,176 +44,263 @@ class AuthEmailService:
if settings.smtp_user and settings.smtp_password:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
server.sendmail(settings.smtp_from, recipients, msg.as_string())
logger.info(f" Email envoyé: {subject} {to}")
logger.info(f"Email envoye: {subject} vers {to}")
return True
except smtplib.SMTPException as e:
logger.error(f"Erreur SMTP envoi email: {e}")
return False
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:
@classmethod
def send_verification_email(cls, email: str, token: str, base_url: str) -> bool:
verification_link = f"{base_url}/auth/verify-email?token={token}"
html_body = f"""
<!DOCTYPE html>
<html>
<html lang="fr">
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #4F46E5; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{
display: inline-block;
background: #4F46E5;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verification de votre email</title>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎉 Bienvenue sur Sage Dataven</h1>
</div>
<div class="content">
<h2>Vérifiez votre adresse email</h2>
<p>Merci de vous être inscrit ! Pour activer votre compte, veuillez cliquer sur le bouton ci-dessous :</p>
<div style="text-align: center;">
<a href="{verification_link}" class="button">Vérifier mon email</a>
</div>
<p style="margin-top: 30px;">Ou copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; background: #e5e7eb; padding: 10px; border-radius: 4px;">
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #4F46E5; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Verification de votre email</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Bienvenue sur Sage Dataven. Pour activer votre compte, veuillez verifier votre adresse email en cliquant sur le bouton ci-dessous.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 24px 0;">
<a href="{verification_link}" style="display: inline-block; background-color: #4F46E5; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-size: 16px; font-weight: 500;">Verifier mon email</a>
</td>
</tr>
</table>
<p style="color: #6B7280; font-size: 14px; line-height: 1.6; margin: 24px 0 0;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
</p>
<p style="color: #4F46E5; font-size: 14px; word-break: break-all; background-color: #F3F4F6; padding: 12px; border-radius: 4px; margin: 12px 0 24px;">
{verification_link}
</p>
<p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>24 heures</strong>
<p style="color: #EF4444; font-size: 14px; margin: 0;">
Ce lien expire dans 24 heures.
</p>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
Si vous n'avez pas créé de compte, ignorez cet email.
</td>
</tr>
<tr>
<td style="background-color: #F9FAFB; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #E5E7EB;">
<p style="color: #9CA3AF; font-size: 12px; margin: 0; text-align: center;">
Si vous n'avez pas cree de compte, ignorez cet email.
</p>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
return AuthEmailService._send_email(
email, "rifiez votre adresse email - Sage Dataven", html_body
return cls._send_email(
email, "Verifiez 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}"
@classmethod
def send_password_reset_email(
cls, email: str, token: str, frontend_url: str
) -> bool:
reset_link = f"{frontend_url}/reset-password?token={token}"
html_body = f"""
<!DOCTYPE html>
<html>
<html lang="fr">
<head>
<style>
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: #EF4444; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }}
.content {{ background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }}
.button {{
display: inline-block;
background: #EF4444;
color: white;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}}
.footer {{ text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reinitialisation de mot de passe</title>
</head>
<body>
<div class="container">
<div class="header">
<h1> Réinitialisation de mot de passe</h1>
</div>
<div class="content">
<h2>Demande de réinitialisation</h2>
<p>Vous avez demandé à réinitialiser votre mot de passe. Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<div style="text-align: center;">
<a href="{reset_link}" class="button">Réinitialiser mon mot de passe</a>
</div>
<p style="margin-top: 30px;">Ou copiez ce lien dans votre navigateur :</p>
<p style="word-break: break-all; background: #e5e7eb; padding: 10px; border-radius: 4px;">
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #DC2626; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Reinitialisation du mot de passe</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Vous avez demande la reinitialisation de votre mot de passe. Cliquez sur le bouton ci-dessous pour creer un nouveau mot de passe.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 24px 0;">
<a href="{reset_link}" style="display: inline-block; background-color: #DC2626; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 6px; font-size: 16px; font-weight: 500;">Reinitialiser mon mot de passe</a>
</td>
</tr>
</table>
<p style="color: #6B7280; font-size: 14px; line-height: 1.6; margin: 24px 0 0;">
Si le bouton ne fonctionne pas, copiez ce lien :
</p>
<p style="color: #DC2626; font-size: 14px; word-break: break-all; background-color: #FEF2F2; padding: 12px; border-radius: 4px; margin: 12px 0 24px;">
{reset_link}
</p>
<p style="margin-top: 30px; color: #ef4444;">
Ce lien expire dans <strong>1 heure</strong>
<p style="color: #EF4444; font-size: 14px; margin: 0;">
Ce lien expire dans 1 heure.
</p>
<p style="margin-top: 30px; font-size: 14px; color: #6b7280;">
Si vous n'avez pas demandé cette réinitialisation, ignorez cet email. Votre mot de passe actuel reste inchangé.
</td>
</tr>
<tr>
<td style="background-color: #FEF2F2; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #FECACA;">
<p style="color: #991B1B; font-size: 12px; margin: 0; text-align: center;">
Si vous n'avez pas demande cette reinitialisation, ignorez cet email. Votre mot de passe restera inchange.
</p>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
return AuthEmailService._send_email(
email, "initialisation de votre mot de passe - Sage Dataven", html_body
return cls._send_email(
email, "Reinitialisation de votre mot de passe - Sage Dataven", html_body
)
@staticmethod
def send_password_changed_notification(email: str) -> bool:
@classmethod
def send_password_changed_notification(cls, email: str) -> bool:
html_body = """
<!DOCTYPE html>
<html>
<html lang="fr">
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #10B981; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f9fafb; padding: 30px; border-radius: 0 0 8px 8px; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #6b7280; }
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mot de passe modifie</title>
</head>
<body>
<div class="container">
<div class="header">
<h1> Mot de passe modifié</h1>
</div>
<div class="content">
<h2>Votre mot de passe a été changé avec succès</h2>
<p>Ce message confirme que le mot de passe de votre compte Sage Dataven a été modifié.</p>
<p style="margin-top: 30px; padding: 15px; background: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;">
Si vous n'êtes pas à l'origine de ce changement, contactez immédiatement notre support.
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #059669; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Mot de passe modifie</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Votre mot de passe a ete modifie avec succes.
</p>
</div>
<div class="footer">
<p>© 2024 Sage Dataven - API de gestion commerciale</p>
</div>
</div>
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
Si vous n'etes pas a l'origine de ce changement, contactez immediatement notre support.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; border-radius: 4px;">
<tr>
<td style="padding: 16px;">
<p style="color: #92400E; font-size: 14px; margin: 0;">
<strong>Securite :</strong> Toutes vos sessions actives ont ete deconnectees. Vous devrez vous reconnecter sur tous vos appareils.
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background-color: #F9FAFB; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #E5E7EB;">
<p style="color: #9CA3AF; font-size: 12px; margin: 0; text-align: center;">
Sage Dataven - Notification de securite
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
return AuthEmailService._send_email(
email, " Votre mot de passe a été modifié - Sage Dataven", html_body
return cls._send_email(
email, "Votre mot de passe a ete modifie - Sage Dataven", html_body
)
@classmethod
def send_security_alert(
cls, email: str, alert_type: str, details: str, ip_address: Optional[str] = None
) -> bool:
ip_info = (
f"<p style='color: #6B7280; font-size: 14px;'>Adresse IP : {ip_address}</p>"
if ip_address
else ""
)
html_body = f"""
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alerte de securite</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: #f5f5f5; padding: 40px 20px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #B91C1C; padding: 32px; text-align: center; border-radius: 8px 8px 0 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 600;">Alerte de securite</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 32px;">
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 16px;">
<strong>{alert_type}</strong>
</p>
<p style="color: #374151; font-size: 16px; line-height: 1.6; margin: 0 0 24px;">
{details}
</p>
{ip_info}
<p style="color: #6B7280; font-size: 14px; margin: 24px 0 0;">
Si vous reconnaissez cette activite, vous pouvez ignorer ce message. Sinon, nous vous recommandons de changer votre mot de passe immediatement.
</p>
</td>
</tr>
<tr>
<td style="background-color: #FEF2F2; padding: 24px 32px; border-radius: 0 0 8px 8px; border-top: 1px solid #FECACA;">
<p style="color: #991B1B; font-size: 12px; margin: 0; text-align: center;">
Sage Dataven - Alerte de securite automatique
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
"""
return cls._send_email(
email, f"Alerte de securite : {alert_type} - Sage Dataven", html_body
)

200
services/redis_service.py Normal file
View file

@ -0,0 +1,200 @@
import redis.asyncio as redis
from typing import Optional
import logging
import json
from config.config import settings
logger = logging.getLogger(__name__)
class RedisService:
_instance: Optional["RedisService"] = None
_client: Optional[redis.Redis] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def connect(self) -> None:
if self._client is not None:
return
try:
self._client = redis.from_url(
settings.redis_url,
password=settings.redis_password,
encoding="utf-8",
decode_responses=True,
socket_timeout=5.0,
socket_connect_timeout=5.0,
)
await self._client.ping()
logger.info("Connexion Redis etablie")
except Exception as e:
logger.error(f"Erreur connexion Redis: {e}")
self._client = None
raise
async def disconnect(self) -> None:
if self._client:
await self._client.close()
self._client = None
logger.info("Connexion Redis fermee")
async def is_connected(self) -> bool:
if not self._client:
return False
try:
await self._client.ping()
return True
except Exception:
return False
@property
def client(self) -> redis.Redis:
if not self._client:
raise RuntimeError("Redis non connecte. Appelez connect() d'abord.")
return self._client
async def blacklist_token(self, token_id: str, ttl_seconds: int) -> bool:
try:
key = f"{settings.token_blacklist_prefix}{token_id}"
await self.client.setex(key, ttl_seconds, "1")
logger.debug(f"Token {token_id[:8]}... ajoute a la blacklist")
return True
except Exception as e:
logger.error(f"Erreur blacklist token: {e}")
return False
async def is_token_blacklisted(self, token_id: str) -> bool:
try:
key = f"{settings.token_blacklist_prefix}{token_id}"
result = await self.client.exists(key)
return result > 0
except Exception as e:
logger.error(f"Erreur verification blacklist: {e}")
return False
async def blacklist_user_tokens(
self, user_id: str, ttl_seconds: int = 86400
) -> bool:
try:
key = f"{settings.token_blacklist_prefix}user:{user_id}"
import time
await self.client.setex(key, ttl_seconds, str(int(time.time())))
logger.info(f"Tokens utilisateur {user_id} invalides")
return True
except Exception as e:
logger.error(f"Erreur invalidation tokens utilisateur: {e}")
return False
async def get_user_token_invalidation_time(self, user_id: str) -> Optional[int]:
try:
key = f"{settings.token_blacklist_prefix}user:{user_id}"
result = await self.client.get(key)
return int(result) if result else None
except Exception as e:
logger.error(f"Erreur lecture invalidation: {e}")
return None
async def increment_rate_limit(self, key: str, window_seconds: int) -> int:
try:
full_key = f"{settings.rate_limit_prefix}{key}"
pipe = self.client.pipeline()
pipe.incr(full_key)
pipe.expire(full_key, window_seconds)
results = await pipe.execute()
return results[0]
except Exception as e:
logger.error(f"Erreur increment rate limit: {e}")
return 0
async def get_rate_limit_count(self, key: str) -> int:
try:
full_key = f"{settings.rate_limit_prefix}{key}"
result = await self.client.get(full_key)
return int(result) if result else 0
except Exception as e:
logger.error(f"Erreur lecture rate limit: {e}")
return 0
async def reset_rate_limit(self, key: str) -> bool:
try:
full_key = f"{settings.rate_limit_prefix}{key}"
await self.client.delete(full_key)
return True
except Exception as e:
logger.error(f"Erreur reset rate limit: {e}")
return False
async def store_refresh_token_metadata(
self, token_id: str, user_id: str, fingerprint_hash: str, ttl_seconds: int
) -> bool:
try:
key = f"refresh_token:{token_id}"
data = json.dumps(
{
"user_id": user_id,
"fingerprint_hash": fingerprint_hash,
"used": False,
}
)
await self.client.setex(key, ttl_seconds, data)
return True
except Exception as e:
logger.error(f"Erreur stockage metadata refresh token: {e}")
return False
async def get_refresh_token_metadata(self, token_id: str) -> Optional[dict]:
try:
key = f"refresh_token:{token_id}"
data = await self.client.get(key)
return json.loads(data) if data else None
except Exception as e:
logger.error(f"Erreur lecture metadata refresh token: {e}")
return None
async def mark_refresh_token_used(self, token_id: str) -> bool:
try:
key = f"refresh_token:{token_id}"
data = await self.client.get(key)
if not data:
return False
metadata = json.loads(data)
metadata["used"] = True
metadata["used_at"] = int(__import__("time").time())
ttl = await self.client.ttl(key)
if ttl > 0:
await self.client.setex(key, ttl, json.dumps(metadata))
return True
except Exception as e:
logger.error(f"Erreur marquage refresh token: {e}")
return False
async def delete_refresh_token(self, token_id: str) -> bool:
try:
key = f"refresh_token:{token_id}"
result = await self.client.delete(key)
return result > 0
except Exception as e:
logger.error(f"Erreur suppression refresh token: {e}")
return False
redis_service = RedisService()
async def get_redis() -> RedisService:
if not await redis_service.is_connected():
await redis_service.connect()
return redis_service

View file

@ -20,8 +20,6 @@ class SageGatewayService:
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)
@ -55,7 +53,6 @@ class SageGatewayService:
and_(
SageGatewayConfig.id == gateway_id,
SageGatewayConfig.user_id == user_id,
SageGatewayConfig.is_deleted == false(),
)
)
)
@ -67,7 +64,7 @@ class SageGatewayService:
query = select(SageGatewayConfig).where(SageGatewayConfig.user_id == user_id)
if not include_deleted:
query = query.where(SageGatewayConfig.is_deleted == false())
query = query.where(SageGatewayConfig.is_deleted.is_(false()))
query = query.order_by(
SageGatewayConfig.is_active.desc(),
@ -81,8 +78,6 @@ class SageGatewayService:
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
@ -131,7 +126,6 @@ class SageGatewayService:
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
@ -167,7 +161,7 @@ class SageGatewayService:
and_(
SageGatewayConfig.user_id == user_id,
SageGatewayConfig.is_active,
SageGatewayConfig.is_deleted == false(),
SageGatewayConfig.is_deleted.is_(false()),
)
)
)
@ -277,8 +271,6 @@ class SageGatewayService:
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
@ -297,7 +289,6 @@ class SageGatewayService:
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)
@ -323,8 +314,6 @@ class SageGatewayService:
}
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)
@ -332,8 +321,6 @@ class SageGatewayService:
)
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)
@ -342,8 +329,6 @@ class SageGatewayService:
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 "****"
)
@ -380,8 +365,6 @@ def gateway_response_from_model(gateway: SageGatewayConfig) -> dict:
"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,

357
services/token_service.py Normal file
View file

@ -0,0 +1,357 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false, select, and_, or_, delete, true
from datetime import datetime, timedelta
from typing import Optional, Tuple, Dict, Any
import uuid
import logging
import time
from config.config import settings
from database import RefreshToken, User
from services.redis_service import redis_service
from security.auth import (
create_access_token,
create_refresh_token,
create_csrf_token,
decode_token,
hash_token,
generate_session_id,
)
logger = logging.getLogger(__name__)
class TokenService:
@classmethod
async def create_token_pair(
cls,
session: AsyncSession,
user: User,
fingerprint_hash: str,
device_info: str,
ip_address: str,
) -> Tuple[str, str, str, str]:
session_id = generate_session_id()
access_token = create_access_token(
data={
"sub": user.id,
"email": user.email,
"role": user.role,
"sid": session_id,
},
fingerprint_hash=fingerprint_hash,
)
refresh_token_jwt, token_id = create_refresh_token(
user_id=user.id, fingerprint_hash=fingerprint_hash
)
csrf_token = create_csrf_token(session_id)
token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_token(refresh_token_jwt),
token_id=token_id,
fingerprint_hash=fingerprint_hash,
device_info=device_info[:500] if device_info else None,
ip_address=ip_address,
expires_at=datetime.now()
+ timedelta(days=settings.refresh_token_expire_days),
created_at=datetime.now(),
)
session.add(token_record)
await session.flush()
await redis_service.store_refresh_token_metadata(
token_id=token_id,
user_id=user.id,
fingerprint_hash=fingerprint_hash,
ttl_seconds=settings.refresh_token_expire_days * 24 * 60 * 60,
)
logger.info(f"Token pair cree pour utilisateur {user.email}")
return access_token, refresh_token_jwt, csrf_token, session_id
@classmethod
async def refresh_tokens(
cls,
session: AsyncSession,
refresh_token: str,
fingerprint_hash: str,
device_info: str,
ip_address: str,
) -> Optional[Tuple[str, str, str, str]]:
payload = decode_token(refresh_token, expected_type="refresh")
if not payload:
logger.warning("Refresh token invalide ou expire")
return None
user_id = payload.get("sub")
token_id = payload.get("jti")
stored_fingerprint = payload.get("fph")
if not user_id or not token_id:
logger.warning("Refresh token malformed")
return None
if await redis_service.is_token_blacklisted(token_id):
logger.warning(f"Refresh token {token_id[:8]}... est blackliste")
return None
token_hash = hash_token(refresh_token)
result = await session.execute(
select(RefreshToken).where(
and_(
RefreshToken.token_hash == token_hash,
RefreshToken.user_id == user_id,
RefreshToken.is_revoked.is_(false()),
RefreshToken.expires_at > datetime.now(),
)
)
)
token_record = result.scalar_one_or_none()
if not token_record:
logger.warning(f"Refresh token non trouve en DB pour user {user_id}")
await cls._handle_potential_token_theft(session, user_id, token_id)
return None
if settings.refresh_token_rotation_enabled and token_record.is_used:
used_at = token_record.used_at
if used_at:
time_since_use = (datetime.now() - used_at).total_seconds()
if time_since_use > settings.refresh_token_reuse_window_seconds:
logger.warning(
f"Reutilisation de refresh token detectee pour user {user_id}"
)
await cls._handle_potential_token_theft(session, user_id, token_id)
return None
if stored_fingerprint and fingerprint_hash:
if stored_fingerprint != fingerprint_hash:
logger.warning(f"Fingerprint mismatch pour user {user_id}")
return None
result = await session.execute(
select(User).where(and_(User.id == user_id, User.is_active.is_(true())))
)
user = result.scalar_one_or_none()
if not user:
logger.warning(f"Utilisateur {user_id} introuvable ou inactif")
return None
session_id = generate_session_id()
new_access_token = create_access_token(
data={
"sub": user.id,
"email": user.email,
"role": user.role,
"sid": session_id,
},
fingerprint_hash=fingerprint_hash,
)
new_csrf_token = create_csrf_token(session_id)
if settings.refresh_token_rotation_enabled:
token_record.is_used = True
token_record.used_at = datetime.now()
new_refresh_jwt, new_token_id = create_refresh_token(
user_id=user.id, fingerprint_hash=fingerprint_hash
)
new_token_record = RefreshToken(
id=str(uuid.uuid4()),
user_id=user.id,
token_hash=hash_token(new_refresh_jwt),
token_id=new_token_id,
fingerprint_hash=fingerprint_hash,
device_info=device_info[:500] if device_info else None,
ip_address=ip_address,
expires_at=datetime.now()
+ timedelta(days=settings.refresh_token_expire_days),
created_at=datetime.now(),
)
token_record.replaced_by = new_token_record.id
session.add(new_token_record)
await redis_service.mark_refresh_token_used(token_id)
await redis_service.store_refresh_token_metadata(
token_id=new_token_id,
user_id=user.id,
fingerprint_hash=fingerprint_hash,
ttl_seconds=settings.refresh_token_expire_days * 24 * 60 * 60,
)
logger.info(f"Refresh token rotation pour user {user.email}")
return new_access_token, new_refresh_jwt, new_csrf_token, session_id
else:
token_record.last_used_at = datetime.now()
return new_access_token, refresh_token, new_csrf_token, session_id
@classmethod
async def revoke_token(
cls, session: AsyncSession, refresh_token: str, reason: str = "user_logout"
) -> bool:
payload = decode_token(refresh_token, expected_type="refresh")
if not payload:
return False
token_id = payload.get("jti")
user_id = payload.get("sub")
exp = payload.get("exp", 0)
ttl_seconds = max(0, exp - int(time.time()))
await redis_service.blacklist_token(token_id, ttl_seconds)
token_hash = hash_token(refresh_token)
result = await session.execute(
select(RefreshToken).where(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()
token_record.revoked_reason = reason
await redis_service.delete_refresh_token(token_id)
logger.info(f"Token revoque pour user {user_id}: {reason}")
return True
@classmethod
async def revoke_all_user_tokens(
cls, session: AsyncSession, user_id: str, reason: str = "security_action"
) -> int:
result = await session.execute(
select(RefreshToken).where(
and_(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked.is_(false()),
)
)
)
tokens = result.scalars().all()
count = 0
for token in tokens:
token.is_revoked = True
token.revoked_at = datetime.now()
token.revoked_reason = reason
await redis_service.blacklist_token(
token.token_id, settings.refresh_token_expire_days * 24 * 60 * 60
)
await redis_service.delete_refresh_token(token.token_id)
count += 1
await redis_service.blacklist_user_tokens(
user_id, settings.refresh_token_expire_days * 24 * 60 * 60
)
logger.info(f"{count} tokens revoques pour user {user_id}: {reason}")
return count
@classmethod
async def _handle_potential_token_theft(
cls, session: AsyncSession, user_id: str, token_id: str
) -> None:
logger.warning(
f"Potentiel vol de token detecte pour user {user_id}, token {token_id[:8]}..."
)
await cls.revoke_all_user_tokens(
session, user_id, reason="potential_token_theft"
)
@classmethod
async def validate_access_token(
cls, token: str, fingerprint_hash: Optional[str] = None
) -> Optional[Dict[str, Any]]:
payload = decode_token(token, expected_type="access")
if not payload:
return None
token_id = payload.get("jti")
if token_id and await redis_service.is_token_blacklisted(token_id):
logger.debug(f"Access token {token_id[:8]}... est blackliste")
return None
user_id = payload.get("sub")
if user_id:
invalidation_time = await redis_service.get_user_token_invalidation_time(
user_id
)
if invalidation_time:
token_iat = payload.get("iat", 0)
if token_iat < invalidation_time:
logger.debug("Access token emis avant invalidation globale")
return None
if fingerprint_hash:
stored_fingerprint = payload.get("fph")
if stored_fingerprint and stored_fingerprint != fingerprint_hash:
logger.warning("Fingerprint mismatch sur access token")
return None
return payload
@classmethod
async def cleanup_expired_tokens(cls, session: AsyncSession) -> int:
result = await session.execute(
delete(RefreshToken).where(
or_(
RefreshToken.expires_at < datetime.now(),
and_(
RefreshToken.is_revoked.is_(true()),
RefreshToken.revoked_at < datetime.now() - timedelta(days=7),
),
)
)
)
count = result.rowcount
logger.info(f"{count} tokens expires nettoyes")
return count
@classmethod
async def get_user_active_sessions(
cls, session: AsyncSession, user_id: str
) -> list:
result = await session.execute(
select(RefreshToken)
.where(
and_(
RefreshToken.user_id == user_id,
RefreshToken.is_revoked.is_(false()),
RefreshToken.expires_at > datetime.now(),
)
)
.order_by(RefreshToken.created_at.desc())
)
tokens = result.scalars().all()
return [
{
"id": t.id,
"device_info": t.device_info,
"ip_address": t.ip_address,
"created_at": t.created_at.isoformat(),
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
}
for t in tokens
]

View file

@ -1,361 +0,0 @@
import os
import logging
import requests
from pathlib import Path
from datetime import datetime
from typing import Optional, Tuple, Dict, List
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 - VERSION CORRIGÉE"""
def __init__(self, api_url: str, api_key: str, timeout: int = 60):
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.auth = (api_key, "")
def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]:
try:
logger.info(f" Récupération documents pour transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
headers={"Accept": "application/json"},
)
if response.status_code == 200:
data = response.json()
documents = data.get("documents", [])
logger.info(f"{len(documents)} document(s) trouvé(s)")
for idx, doc in enumerate(documents):
logger.debug(
f" Document {idx}: id={doc.get('id')}, "
f"name={doc.get('name')}, status={doc.get('status')}"
)
return documents
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}: "
f"{response.text[:500]}"
)
return None
except requests.exceptions.Timeout:
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)
return None
def download_signed_document(
self, transaction_id: str, document_id: str
) -> Optional[bytes]:
try:
download_url = (
f"{self.api_url}/transactions/{transaction_id}"
f"/documents/{document_id}/download"
)
logger.info(f"Téléchargement depuis: {download_url}")
response = requests.get(
download_url,
auth=self.auth,
timeout=self.timeout,
stream=True,
)
if response.status_code == 200:
content_type = response.headers.get("Content-Type", "")
content_length = response.headers.get("Content-Length", "unknown")
logger.info(
f"Téléchargement réussi: "
f"Content-Type={content_type}, Size={content_length}"
)
if (
"pdf" not in content_type.lower()
and "octet-stream" not in content_type.lower()
):
logger.warning(
f"Type de contenu inattendu: {content_type}. "
f"Tentative de lecture quand même..."
)
content = response.content
if len(content) < 1024:
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}"
)
return None
elif response.status_code == 403:
logger.error(
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]}"
)
return None
except requests.exceptions.Timeout:
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)
return None
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
transaction.download_attempts += 1
try:
logger.info(
f"Récupération document signé pour: {transaction.transaction_id}"
)
documents = self.fetch_transaction_documents(transaction.transaction_id)
if not documents:
error = "Aucun document trouvé dans la transaction Universign"
logger.warning(f"{error}")
transaction.download_error = error
await session.commit()
return False, error
document_id = None
for doc in documents:
doc_id = doc.get("id")
doc_status = doc.get("status", "").lower()
if doc_status in ["signed", "completed", "closed"]:
document_id = doc_id
logger.info(
f"Document signé trouvé: {doc_id} (status: {doc_status})"
)
break
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}")
transaction.download_error = error
await session.commit()
return False, error
if hasattr(transaction, "universign_document_id"):
transaction.universign_document_id = document_id
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}")
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:
f.write(pdf_content)
file_size = os.path.getsize(file_path)
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
transaction.document_url = (
f"{self.api_url}/transactions/{transaction.transaction_id}"
f"/documents/{document_id}/download"
)
await session.commit()
logger.info(
f"Document signé téléchargé: {filename} ({file_size / 1024:.1f} KB)"
)
return True, None
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:
"""Génère un nom de fichier unique pour le document signé"""
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}_signed.pdf"
return filename
def get_document_path(self, transaction) -> Optional[Path]:
"""Retourne le chemin du document signé s'il existe"""
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]:
"""Supprime les anciens documents signés"""
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)
def diagnose_transaction(self, transaction_id: str) -> Dict:
"""
Diagnostic complet d'une transaction pour debug
"""
result = {
"transaction_id": transaction_id,
"api_url": self.api_url,
"timestamp": datetime.now().isoformat(),
"checks": {},
}
try:
logger.info(f"Diagnostic transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
)
result["checks"]["transaction_fetch"] = {
"status_code": response.status_code,
"success": response.status_code == 200,
}
if response.status_code != 200:
result["checks"]["transaction_fetch"]["error"] = response.text[:500]
return result
data = response.json()
result["checks"]["transaction_data"] = {
"state": data.get("state"),
"documents_count": len(data.get("documents", [])),
"participants_count": len(data.get("participants", [])),
}
documents = data.get("documents", [])
result["checks"]["documents"] = []
for doc in documents:
doc_info = {
"id": doc.get("id"),
"name": doc.get("name"),
"status": doc.get("status"),
}
if doc.get("id"):
download_url = (
f"{self.api_url}/transactions/{transaction_id}"
f"/documents/{doc['id']}/download"
)
try:
dl_response = requests.head(
download_url,
auth=self.auth,
timeout=10,
)
doc_info["download_check"] = {
"url": download_url,
"status_code": dl_response.status_code,
"accessible": dl_response.status_code in [200, 302],
"content_type": dl_response.headers.get("Content-Type"),
}
except Exception as e:
doc_info["download_check"] = {"error": str(e)}
result["checks"]["documents"].append(doc_info)
result["success"] = True
except Exception as e:
result["success"] = False
result["error"] = str(e)
return result

View file

@ -1,714 +0,0 @@
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_url=api_url, 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
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:
if not transaction_id:
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}"
)
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"
transaction.webhook_received = True
old_status = transaction.local_status.value
success, error = await self.sync_transaction(
session, transaction, force=True
)
if success and transaction.local_status.value != old_status:
logger.info(
f"Webhook traité: {transaction_id} | "
f"{old_status}{transaction.local_status.value}"
)
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)
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
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:
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}")
async def sync_transaction(
self,
session,
transaction,
force: bool = False,
):
import json
if is_final_status(transaction.local_status.value) and not force:
logger.debug(
f"⏭️ Skip {transaction.transaction_id}: statut final "
f"{transaction.local_status.value}"
)
transaction.needs_sync = False
await session.commit()
return True, None
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}")
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}")
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}"
)
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}"
)
try:
transaction.universign_status = UniversignTransactionStatus(
universign_status_raw
)
except ValueError:
logger.warning(f"Statut Universign inconnu: {universign_status_raw}")
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
transaction.local_status = LocalDocumentStatus(new_local_status)
transaction.universign_status_updated_at = datetime.now()
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")
documents = universign_data.get("documents", [])
if documents:
first_doc = documents[0]
logger.info(
f"Document Universign trouvé: id={first_doc.get('id')}, "
f"status={first_doc.get('status')}"
)
if new_local_status == "SIGNE" and not transaction.signed_document_path:
logger.info("Déclenchement téléchargement document signé...")
try:
(
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é et stocké")
else:
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)
await self._sync_signers(session, transaction, universign_data)
transaction.last_synced_at = datetime.now()
transaction.sync_attempts += 1
transaction.needs_sync = not is_final_status(new_local_status)
transaction.sync_error = None
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,
"documents_count": len(documents),
"response_time_ms": result.get("response_time_ms"),
},
default=str,
),
)
await session.commit()
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]
transaction.sync_attempts += 1
await self._log_sync_attempt(
session, transaction, "polling", False, error_msg
)
await session.commit()
return False, error_msg
async def _sync_transaction_documents_corrected(
self, session, transaction, universign_data: dict, new_local_status: str
):
documents = universign_data.get("documents", [])
if documents:
first_doc = documents[0]
first_doc_id = first_doc.get("id")
if first_doc_id:
if hasattr(transaction, "universign_document_id"):
transaction.universign_document_id = first_doc_id
logger.info(
f"Document Universign: id={first_doc_id}, "
f"name={first_doc.get('name')}, status={first_doc.get('status')}"
)
else:
logger.debug("Aucun document dans la réponse Universign")
if new_local_status == "SIGNE":
if not transaction.signed_document_path:
logger.info("Déclenchement téléchargement document signé...")
try:
(
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}")
except Exception as e:
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}"
)
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")

View file

@ -1,15 +0,0 @@
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)

View file

@ -1,52 +0,0 @@
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)
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}")
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")

View file

@ -1,3 +1,12 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script de création du premier utilisateur administrateur
Usage:
python create_admin.py
"""
import asyncio
import sys
from pathlib import Path
@ -19,6 +28,7 @@ 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")
@ -31,6 +41,7 @@ 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): "
@ -56,6 +67,7 @@ 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,

View file

@ -1,136 +0,0 @@
from fastapi import HTTPException
from typing import Optional
import httpx
import logging
from schemas import EntrepriseSearch
logger = logging.getLogger(__name__)
def calculer_tva_intracommunautaire(siren: str) -> Optional[str]:
try:
siren_clean = siren.replace(" ", "").strip()
if not siren_clean.isdigit() or len(siren_clean) != 9:
logger.warning(f"SIREN invalide: {siren}")
return None
siren_int = int(siren_clean)
cle = (12 + 3 * (siren_int % 97)) % 97
cle_str = f"{cle:02d}"
return f"FR{cle_str}{siren_clean}"
except Exception as e:
logger.error(f"Erreur calcul TVA pour SIREN {siren}: {e}")
return None
def formater_adresse(siege_data: dict) -> str:
try:
adresse_parts = []
if siege_data.get("numero_voie"):
adresse_parts.append(siege_data["numero_voie"])
if siege_data.get("type_voie"):
adresse_parts.append(siege_data["type_voie"])
if siege_data.get("libelle_voie"):
adresse_parts.append(siege_data["libelle_voie"])
if siege_data.get("code_postal"):
adresse_parts.append(siege_data["code_postal"])
if siege_data.get("libelle_commune"):
adresse_parts.append(siege_data["libelle_commune"].upper())
return " ".join(adresse_parts)
except Exception as e:
logger.error(f"Erreur formatage adresse: {e}")
return ""
async def rechercher_entreprise_api(query: str, per_page: int = 5) -> dict:
api_url = "https://recherche-entreprises.api.gouv.fr/search"
params = {
"q": query,
"per_page": per_page,
"limite_etablissements": 5,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(api_url, params=params)
if response.status_code == 429:
logger.warning("Rate limit atteint (7 req/s)")
raise HTTPException(
status_code=429,
detail="Trop de requêtes. Veuillez réessayer dans 1 seconde.",
)
if response.status_code == 503:
logger.error("API Sirene indisponible (503)")
raise HTTPException(
status_code=503,
detail="Service de recherche momentanément indisponible.",
)
response.raise_for_status()
return response.json()
except httpx.TimeoutException:
logger.error(f"Timeout lors de la recherche: {query}")
raise HTTPException(
status_code=504, detail="Délai d'attente dépassé pour l'API de recherche."
)
except httpx.HTTPError as e:
logger.error(f"Erreur HTTP API Sirene: {e}")
raise HTTPException(
status_code=500,
detail=f"Erreur lors de la communication avec l'API: {str(e)}",
)
def mapper_resultat_api(entreprise_data: dict) -> Optional[EntrepriseSearch]:
try:
siren = entreprise_data.get("siren")
if not siren:
logger.warning("Entreprise sans SIREN, ignorée")
return None
tva_number = calculer_tva_intracommunautaire(siren)
if not tva_number:
logger.warning(f"Impossible de calculer TVA pour SIREN: {siren}")
return None
siege = entreprise_data.get("siege", {})
etat_admin = entreprise_data.get("etat_administratif", "A")
is_active = etat_admin == "A"
return EntrepriseSearch(
company_name=entreprise_data.get("nom_complet", ""),
siren=siren,
vat_number=tva_number,
address=formater_adresse(siege),
naf_code=entreprise_data.get("activite_principale", ""),
is_active=is_active,
siret_siege=siege.get("siret"),
code_postal=siege.get("code_postal"),
ville=siege.get("libelle_commune"),
)
except Exception as e:
logger.error(f"Erreur mapping entreprise: {e}", exc_info=True)
return None

View file

@ -1,4 +1,4 @@
from typing import Dict, List
from typing import Dict
from config.config import settings
import logging
@ -7,7 +7,7 @@ import uuid
import requests
from sqlalchemy.ext.asyncio import AsyncSession
from services.email_queue import email_queue
from data.data import templates_signature_email
from database import EmailLog, StatutEmail as StatutEmailEnum
@ -22,8 +22,6 @@ async def universign_envoyer(
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
@ -264,201 +262,3 @@ async def universign_statut(transaction_id: str) -> Dict:
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] = {
"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]] = {
"""
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"]

View file

@ -1,165 +0,0 @@
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}"