Initial backend FastAPI WS

This commit is contained in:
Fanilo-Nantenaina 2025-11-26 11:08:57 +03:00
parent 9e243e28aa
commit e98dce03b8
6 changed files with 1636 additions and 24 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# ============================================================================
# SAGE 100 CLOUD - CONNEXION BOI/COM
# ============================================================================
CHEMIN_BASE=<CHEMIN_VERS_LE_FICHIER_GCM>
UTILISATEUR=<UTILISATEUR_SAGE100>
MOT_DE_PASSE=<MOT_DE_PASSE_SAGE100>
SAGE_GATEWAY_TOKEN=<TOKEN_SAGE_GATEWAY>
# ============================================================================
# API - CONFIGURATION SERVEUR
# ============================================================================
API_HOST=0.0.0.0
API_PORT=8000

51
.gitignore vendored
View file

@ -1,35 +1,38 @@
# Python # ================================
__pycache__/ # Python / FastAPI
*.pyc # ================================
*.pyo
*.pyd
# Environnements virtuels # Environnements virtuels
venv/ venv/
.env/ .env
.env.*
*.env *.env
*.local.env
# Outils # caches
.idea/ __pycache__/
.vscode/ *.py[cod]
.cache/ *.pyo
.mypy_cache/
pytest_cache/
# Logs # logs
*.log *.log
# SQLite databases # Compilations
*.db *.so
*.sqlite3 *.dll
# Outils Python
.mypy_cache/
.pytest_cache/
.coverage
htmlcov/
# VSCode
.vscode/
# PyCharm
.idea/
# Docker # Docker
**/__pycache__/ *~
docker-data/ .build/
dist/ dist/
build/
# Systèmes
.DS_Store
Thumbs.db

42
config.py Normal file
View file

@ -0,0 +1,42 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Optional, List
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)
# === SAGE 100c (Windows uniquement) ===
chemin_base: str
utilisateur: str = "Administrateur"
mot_de_passe: str
# === Sécurité Gateway ===
sage_gateway_token: str # Token partagé avec le VPS Linux
# === SMTP (optionnel sur Windows) ===
smtp_host: Optional[str] = None
smtp_port: int = 587
smtp_user: Optional[str] = None
smtp_password: Optional[str] = None
smtp_from: Optional[str] = None
# === API Windows ===
api_host: str = "0.0.0.0"
api_port: int = 8000
# === CORS ===
cors_origins: List[str] = ["*"]
settings = Settings()
def validate_settings():
"""Validation au démarrage"""
if not settings.chemin_base or not settings.mot_de_passe:
raise ValueError("❌ CHEMIN_BASE et MOT_DE_PASSE requis dans .env")
if not settings.sage_gateway_token:
raise ValueError("❌ SAGE_GATEWAY_TOKEN requis (doit être identique sur Linux)")
return True

340
main.py Normal file
View file

@ -0,0 +1,340 @@
from fastapi import FastAPI, HTTPException, Header, Depends, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import Optional, List, Dict
from datetime import datetime, date
from enum import Enum
import uvicorn
import logging
from config import settings, validate_settings
from sage_connector import SageConnector
# =====================================================
# LOGGING
# =====================================================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("sage_gateway.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# =====================================================
# ENUMS
# =====================================================
class TypeDocument(int, Enum):
DEVIS = 0
BON_LIVRAISON = 1
BON_RETOUR = 2
COMMANDE = 3
PREPARATION = 4
FACTURE = 5
# =====================================================
# MODÈLES
# =====================================================
class FiltreRequest(BaseModel):
filtre: Optional[str] = ""
class CodeRequest(BaseModel):
code: str
class ChampLibreRequest(BaseModel):
doc_id: str
type_doc: int
nom_champ: str
valeur: str
class DevisRequest(BaseModel):
client_id: str
date_devis: Optional[date] = None
lignes: List[Dict] # Format: {article_code, quantite, prix_unitaire_ht, remise_pourcentage}
class TransformationRequest(BaseModel):
numero_source: str
type_source: int
type_cible: int
class StatutRequest(BaseModel):
nouveau_statut: int
# =====================================================
# SÉCURITÉ
# =====================================================
def verify_token(x_sage_token: str = Header(...)):
"""Vérification du token d'authentification"""
if x_sage_token != settings.sage_gateway_token:
logger.warning(f"❌ Token invalide reçu: {x_sage_token[:20]}...")
raise HTTPException(401, "Token invalide")
return True
# =====================================================
# APPLICATION
# =====================================================
app = FastAPI(
title="Sage Gateway - Windows Server",
version="1.0.0",
description="Passerelle d'accès à Sage 100c pour VPS Linux"
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_methods=["*"],
allow_headers=["*"],
allow_credentials=True
)
sage: Optional[SageConnector] = None
# =====================================================
# LIFECYCLE
# =====================================================
@app.on_event("startup")
def startup():
global sage
logger.info("🚀 Démarrage Sage Gateway Windows...")
# Validation config
try:
validate_settings()
logger.info("✅ Configuration validée")
except ValueError as e:
logger.error(f"❌ Configuration invalide: {e}")
raise
# Connexion Sage
sage = SageConnector(
settings.chemin_base,
settings.utilisateur,
settings.mot_de_passe
)
if not sage.connecter():
raise RuntimeError("❌ Impossible de se connecter à Sage 100c")
logger.info("✅ Sage Gateway démarré et connecté")
@app.on_event("shutdown")
def shutdown():
if sage:
sage.deconnecter()
logger.info("👋 Sage Gateway arrêté")
# =====================================================
# ENDPOINTS - SYSTÈME
# =====================================================
@app.get("/health")
def health():
"""Health check"""
return {
"status": "ok",
"sage_connected": sage is not None and sage.cial is not None,
"cache_info": sage.get_cache_info() if sage else None,
"timestamp": datetime.now().isoformat()
}
# =====================================================
# ENDPOINTS - CLIENTS
# =====================================================
@app.post("/sage/clients/list", dependencies=[Depends(verify_token)])
def clients_list(req: FiltreRequest):
"""Liste des clients avec filtre optionnel"""
try:
clients = sage.lister_tous_clients(req.filtre)
return {"success": True, "data": clients}
except Exception as e:
logger.error(f"Erreur liste clients: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/clients/get", dependencies=[Depends(verify_token)])
def client_get(req: CodeRequest):
"""Lecture d'un client par code"""
try:
client = sage.lire_client(req.code)
if not client:
raise HTTPException(404, f"Client {req.code} non trouvé")
return {"success": True, "data": client}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture client: {e}")
raise HTTPException(500, str(e))
# =====================================================
# ENDPOINTS - ARTICLES
# =====================================================
@app.post("/sage/articles/list", dependencies=[Depends(verify_token)])
def articles_list(req: FiltreRequest):
"""Liste des articles avec filtre optionnel"""
try:
articles = sage.lister_tous_articles(req.filtre)
return {"success": True, "data": articles}
except Exception as e:
logger.error(f"Erreur liste articles: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/articles/get", dependencies=[Depends(verify_token)])
def article_get(req: CodeRequest):
"""Lecture d'un article par référence"""
try:
article = sage.lire_article(req.code)
if not article:
raise HTTPException(404, f"Article {req.code} non trouvé")
return {"success": True, "data": article}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture article: {e}")
raise HTTPException(500, str(e))
# =====================================================
# ENDPOINTS - DEVIS
# =====================================================
@app.post("/sage/devis/create", dependencies=[Depends(verify_token)])
def creer_devis(req: DevisRequest):
"""Création d'un devis"""
try:
# Transformer en format attendu par sage_connector
devis_data = {
"client": {"code": req.client_id, "intitule": ""},
"date_devis": req.date_devis or date.today(),
"lignes": req.lignes
}
resultat = sage.creer_devis_enrichi(devis_data)
return {"success": True, "data": resultat}
except Exception as e:
logger.error(f"Erreur création devis: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/devis/get", dependencies=[Depends(verify_token)])
def lire_devis(req: CodeRequest):
"""Lecture d'un devis"""
try:
devis = sage.lire_devis(req.code)
if not devis:
raise HTTPException(404, f"Devis {req.code} non trouvé")
return {"success": True, "data": devis}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture devis: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/devis/statut", dependencies=[Depends(verify_token)])
def changer_statut_devis(doc_id: str, req: StatutRequest):
"""Changement de statut d'un devis"""
try:
# Implémenter via sage_connector
# (À ajouter dans sage_connector si manquant)
return {"success": True, "message": "Statut mis à jour"}
except Exception as e:
logger.error(f"Erreur MAJ statut: {e}")
raise HTTPException(500, str(e))
# =====================================================
# ENDPOINTS - DOCUMENTS
# =====================================================
@app.post("/sage/documents/get", dependencies=[Depends(verify_token)])
def lire_document(numero: str, type_doc: int):
"""Lecture d'un document (commande, facture, etc.)"""
try:
doc = sage.lire_document(numero, type_doc)
if not doc:
raise HTTPException(404, f"Document {numero} non trouvé")
return {"success": True, "data": doc}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture document: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/documents/transform", dependencies=[Depends(verify_token)])
def transformer_document(req: TransformationRequest):
"""Transformation de document (devis → commande, etc.)"""
try:
resultat = sage.transformer_document(
req.numero_source,
req.type_source,
req.type_cible
)
return {"success": True, "data": resultat}
except Exception as e:
logger.error(f"Erreur transformation: {e}")
raise HTTPException(500, str(e))
@app.post("/sage/documents/champ-libre", dependencies=[Depends(verify_token)])
def maj_champ_libre(req: ChampLibreRequest):
"""Mise à jour d'un champ libre"""
try:
success = sage.mettre_a_jour_champ_libre(
req.doc_id,
req.type_doc,
req.nom_champ,
req.valeur
)
return {"success": success}
except Exception as e:
logger.error(f"Erreur MAJ champ libre: {e}")
raise HTTPException(500, str(e))
# =====================================================
# ENDPOINTS - CONTACTS
# =====================================================
@app.post("/sage/contact/read", dependencies=[Depends(verify_token)])
def contact_read(req: CodeRequest):
"""Lecture du contact principal d'un client"""
try:
contact = sage.lire_contact_principal_client(req.code)
if not contact:
raise HTTPException(404, f"Contact non trouvé pour client {req.code}")
return {"success": True, "data": contact}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur lecture contact: {e}")
raise HTTPException(500, str(e))
# =====================================================
# ENDPOINTS - ADMIN
# =====================================================
@app.post("/sage/cache/refresh", dependencies=[Depends(verify_token)])
def refresh_cache():
"""Force le rafraîchissement du cache"""
try:
sage.forcer_actualisation_cache()
return {
"success": True,
"message": "Cache actualisé",
"info": sage.get_cache_info()
}
except Exception as e:
logger.error(f"Erreur refresh cache: {e}")
raise HTTPException(500, str(e))
@app.get("/sage/cache/info", dependencies=[Depends(verify_token)])
def cache_info():
"""Informations sur le cache"""
try:
return {"success": True, "data": sage.get_cache_info()}
except Exception as e:
logger.error(f"Erreur info cache: {e}")
raise HTTPException(500, str(e))
# =====================================================
# LANCEMENT
# =====================================================
if __name__ == "__main__":
uvicorn.run(
"main:app",
host=settings.api_host,
port=settings.api_port,
reload=False, # Pas de reload en production
log_level="info"
)

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
fastapi
uvicorn[standard]
pydantic
pydantic-settings
python-multipart
python-dotenv
pywin32

1206
sage_connector.py Normal file

File diff suppressed because it is too large Load diff