Files
office_translator/routes/legacy_routes.py
sepehr 0a013c679d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
- Chaque modele d'un palier devient une route {canal, modele} : openrouter,
  deepseek direct, zhipu (z.ai, nouveau canal), minimax, openai, xai ;
  repli automatique sur la route suivante si la cle manque, compatibilite
  ascendante avec les anciens reglages a chaines
- Page Fournisseurs refondue en sections ; interrupteurs et chaine de
  secours retablis ; tests de connexion avec delai d'attente
- Emails de relance generes par IA (canal au choix, prompt construit depuis
  le plan marketing, HTML sane, RTL arabe/persan, jamais d'envoi automatique)
- Codes promo : validation locale avant Stripe, Coupon+PromotionCode avec
  double plafond, comptage par session (webhook+sync=1 fois), ecriture
  atomique, relier-a-Stripe, suppression, validation publique au checkout
- Page Tarifs : champ code promo (?promo=) avec verification traduite
- Tests : 1357 verts (facturation par palier testee dans le worker,
  point de contact providers/available, promos, generation)
2026-09-05 19:46:33 +02:00

659 lines
24 KiB
Python

"""
Legacy API v1 Endpoints
Endpoints migrated from main.py that don't fit in other routers
Story 3.5: API Versioning
"""
import logging
import os
from pathlib import Path
from typing import Optional, Any
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Request, Depends
from fastapi.responses import FileResponse, JSONResponse
from config import config
from utils import file_handler
from utils.file_handler import validate_zip_safety
from middleware.api_key_auth import get_authenticated_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1", tags=["Legacy"])
def _resolve_model(
cfg_model: Optional[str],
model_env: str,
default: str,
) -> str:
"""Resolve effective model: JSON config > env var > default."""
v = (cfg_model or "").strip() or os.getenv(model_env, "").strip()
return v or default
@router.get("/providers/available")
async def get_available_providers(
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
Return every provider that is enabled — checking BOTH the admin settings JSON
AND environment variables (env vars act as a fallback / override).
Rules:
- Google Translate is always shown.
- Ollama is only shown in DEV mode (APP_ENV=development or SHOW_OLLAMA=true).
- openrouter → shown as "Traduction IA Essentielle" (cheap models).
- openrouter_premium → shown as "Traduction IA Premium" (premium models).
- Filtered to the engines included in the caller's plan
(PLANS[plan]["providers"]) so the UI never offers an engine the
translate endpoint would reject.
"""
from routes.admin_routes import load_settings
from models.subscription import PlanType, PLANS
from routes.translate_routes import _resolve_tier_model
settings = load_settings()
is_dev = os.getenv("APP_ENV", "production").lower() == "development"
# Le modèle affiché est celui réellement routé pour le plan de l'appelant
# (réglages admin « ai_tiers » > défaut du plan — gamme officielle).
user_plan = str(current_user.plan) if current_user is not None else None
def _key_ready(key_var: str) -> bool:
return bool(os.getenv(key_var, "").strip())
def _url_ready(url_var: str) -> bool:
return bool(os.getenv(url_var, "").strip())
def _is_enabled(name: str, key_var: str = "", url_var: str = "") -> bool:
cfg = getattr(settings, name, None)
if cfg and cfg.enabled:
return True
if key_var and _key_ready(key_var):
return True
if url_var and _url_ready(url_var):
return True
return False
available = []
# Google Translate — always available
available.append({
"id": "google",
"label": "Google Traduction",
"description": "Traduction rapide, 130+ langues, fiable",
"mode": "classic",
"tier": "free",
})
# AI Essentielle (OpenRouter — cheap model / Eco)
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
# La résolution renvoie la route complète (canal, modèle) — le canal
# réel (z.ai direct, DeepSeek direct…) peut différer d'openrouter.
_prov, model = _resolve_tier_model(settings, user_plan, premium=False)
available.append({
"id": "openrouter",
"label": "Traduction IA Éco",
"description": "IA rapide, économique et complète — supporte les images",
"mode": "llm",
"tier": "pro",
"model": model,
})
# AI Standard (DeepSeek via OpenRouter or direct DeepSeek API)
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY") or _is_enabled("deepseek", key_var="DEEPSEEK_API_KEY"):
ds_cfg = getattr(settings, "deepseek", None)
model = _resolve_model(
ds_cfg.model if ds_cfg else None,
"DEEPSEEK_MODEL",
"deepseek/deepseek-chat",
)
available.append({
"id": "deepseek",
"label": "Traduction IA Standard",
"description": "IA ultra-précise pour le texte (ne traduit pas les images)",
"mode": "llm",
"tier": "pro",
"model": model,
})
# AI Premium (OpenRouter — premium model)
if _is_enabled("openrouter_premium", key_var="OPENROUTER_API_KEY") or _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
_prov, model = _resolve_tier_model(settings, user_plan, premium=True)
available.append({
"id": "openrouter_premium",
"label": "Traduction IA Premium",
"description": "IA haut de gamme — excellente qualité littéraire et multimodal",
"mode": "llm",
"tier": "business",
"model": model,
})
# OpenAI direct — if configured with direct API key
if _is_enabled("openai", key_var="OPENAI_API_KEY"):
oai_cfg = getattr(settings, "openai", None)
model = _resolve_model(
oai_cfg.model if oai_cfg else None,
"OPENAI_MODEL",
"gpt-4o-mini",
)
available.append({
"id": "openai",
"label": "OpenAI GPT",
"description": "Traduction IA via OpenAI directement",
"mode": "llm",
"tier": "business",
"model": model,
})
# MiniMax direct — if configured with direct API key
if _is_enabled("minimax", key_var="MINIMAX_API_KEY"):
mm_cfg = getattr(settings, "minimax", None)
model = _resolve_model(
mm_cfg.model if mm_cfg else None,
"MINIMAX_MODEL",
"abab6.5s-chat",
)
available.append({
"id": "minimax",
"label": "Traduction IA Avancée",
"description": "Traduction IA haute performance",
"mode": "llm",
"tier": "pro",
"model": model,
})
# z.AI / xAI Grok — if configured with direct API key
if _is_enabled("zai", key_var="ZAI_API_KEY"):
zai_cfg = getattr(settings, "zai", None)
model = _resolve_model(
zai_cfg.model if zai_cfg else None,
"ZAI_MODEL",
"grok-2-1212",
)
available.append({
"id": "zai",
"label": "Grok (xAI)",
"description": "IA Grok par xAI — traduction avancée",
"mode": "llm",
"tier": "business",
"model": model,
})
# Filter to the engines included in the caller's plan (anonymous → Free).
user_plan = PlanType.FREE
if current_user is not None:
try:
user_plan = PlanType(getattr(current_user, "plan", PlanType.FREE))
except ValueError:
user_plan = PlanType.FREE
allowed = set((PLANS.get(user_plan) or PLANS[PlanType.FREE]).get("providers", []))
available = [p for p in available if p.get("id") in allowed]
return JSONResponse(
status_code=200,
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
content={"providers": available}
)
@router.get("/languages")
async def get_supported_languages():
"""Get list of supported language codes, ordered by internet popularity.
Served from LanguageValidator (single source of truth): the 35 most
requested languages first, then the rest of the validated ISO 639-1 set
alphabetically. "auto" is excluded — it is a source-only value handled
by the source_lang parameter.
"""
from middleware.validation import LanguageValidator
popular_order = [
# Top 5 — dominant on the internet
"en", "es", "de", "fr", "ja",
# Top 6-15
"pt", "ru", "it", "zh-CN", "zh-TW", "pl", "nl", "tr", "ko", "ar",
# Top 16-25
"fa", "vi", "id", "uk", "sv", "cs", "el", "he", "hi", "ro",
# Next most requested
"da", "fi", "no", "hu", "th", "sk", "bg", "hr", "ca", "ms", "zh",
]
names = LanguageValidator.LANGUAGE_NAMES
supported = [
c for c in LanguageValidator.SUPPORTED_LANGUAGES if c != "auto"
]
ordered = [c for c in popular_order if c in supported]
ordered += sorted(c for c in supported if c not in ordered)
return {
"supported_languages": {code: names.get(code, code.upper()) for code in ordered},
"count": len(ordered),
"note": "Supported languages may vary depending on the translation service configured",
}
@router.post("/translate-batch")
async def translate_batch_documents(
files: list[UploadFile] = File(
..., description="Multiple document files to translate"
),
target_language: str = Form(..., description="Target language code"),
source_language: str = Form(default="auto", description="Source language code"),
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""Translate multiple documents in batch"""
from translators import excel_translator, word_translator, pptx_translator
results = []
for file in files:
try:
file_extension = file_handler.validate_file_extension(file.filename)
file_handler.validate_file_size(file)
input_filename = file_handler.generate_unique_filename(
file.filename, "input"
)
output_filename = file_handler.generate_unique_filename(
file.filename, "translated"
)
input_path = config.UPLOAD_DIR / input_filename
output_path = config.OUTPUT_DIR / output_filename
file_handler.save_upload_file(file, input_path)
# Zip bomb protection: Office files are ZIP archives
if file_extension != ".pdf":
validate_zip_safety(input_path)
if file_extension == ".xlsx":
excel_translator.translate_file(
input_path, output_path, target_language, source_language
)
elif file_extension == ".docx":
word_translator.translate_file(
input_path, output_path, target_language, source_language
)
elif file_extension == ".pptx":
pptx_translator.translate_file(
input_path, output_path, target_language, source_language
)
file_handler.cleanup_file(input_path)
results.append(
{
"filename": file.filename,
"status": "success",
"output_file": output_filename,
"download_url": f"/api/v1/download/{output_filename}",
}
)
except ValueError as e:
file_handler.cleanup_file(input_path)
logger.warning(f"Rejected unsafe or invalid archive: {file.filename}: {e}")
results.append(
{
"filename": file.filename,
"status": "error",
"error": "CORRUPTED_FILE",
"message": "Le fichier semble corrompu ou n'est pas un document Office valide.",
"details": {"reason": "unsafe_archive", "detail": str(e)[:200]},
}
)
except Exception as e:
logger.exception(f"Error processing {file.filename}")
results.append(
{
"filename": file.filename,
"status": "error",
"error": "INTERNAL_ERROR",
"message": "Erreur lors du traitement du fichier.",
"details": {},
}
)
return {
"total_files": len(files),
"successful": len([r for r in results if r["status"] == "success"]),
"failed": len([r for r in results if r["status"] == "error"]),
"results": results,
}
@router.post("/extract-texts")
async def extract_texts_from_document(
file: UploadFile = File(..., description="Document file to extract texts from"),
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""Extract all translatable texts from a document for client-side translation"""
import uuid
import json
try:
file_extension = file_handler.validate_file_extension(file.filename)
logger.info(f"Extracting texts from {file_extension} file: {file.filename}")
file_handler.validate_file_size(file)
session_id = str(uuid.uuid4())
input_filename = f"session_{session_id}{file_extension}"
input_path = config.UPLOAD_DIR / input_filename
file_handler.save_upload_file(file, input_path)
if file_extension != ".pdf":
validate_zip_safety(input_path)
texts = []
if file_extension == ".xlsx":
from openpyxl import load_workbook
wb = load_workbook(input_path)
for sheet in wb.worksheets:
for row in sheet.iter_rows():
for cell in row:
if (
cell.value
and isinstance(cell.value, str)
and cell.value.strip()
):
texts.append(
{
"id": f"{sheet.title}!{cell.coordinate}",
"text": cell.value,
}
)
wb.close()
elif file_extension == ".docx":
from docx import Document
doc = Document(input_path)
para_idx = 0
for para in doc.paragraphs:
if para.text.strip():
texts.append({"id": f"para_{para_idx}", "text": para.text})
para_idx += 1
table_idx = 0
for table in doc.tables:
for row_idx, row in enumerate(table.rows):
for cell_idx, cell in enumerate(row.cells):
if cell.text.strip():
texts.append(
{
"id": f"table_{table_idx}_r{row_idx}_c{cell_idx}",
"text": cell.text,
}
)
table_idx += 1
elif file_extension == ".pptx":
from pptx import Presentation
prs = Presentation(input_path)
for slide_idx, slide in enumerate(prs.slides):
for shape_idx, shape in enumerate(slide.shapes):
if shape.has_text_frame:
for para_idx, para in enumerate(shape.text_frame.paragraphs):
for run_idx, run in enumerate(para.runs):
if run.text.strip():
texts.append(
{
"id": f"slide_{slide_idx}_shape_{shape_idx}_para_{para_idx}_run_{run_idx}",
"text": run.text,
}
)
session_data = {
"original_filename": file.filename,
"file_extension": file_extension,
"input_path": str(input_path),
"text_count": len(texts),
}
session_file = config.UPLOAD_DIR / f"session_{session_id}.json"
with open(session_file, "w", encoding="utf-8") as f:
json.dump(session_data, f)
logger.info(
f"Extracted {len(texts)} texts from {file.filename}, session: {session_id}"
)
return {
"session_id": session_id,
"texts": texts,
"file_type": file_extension,
"text_count": len(texts),
}
except HTTPException:
raise
except ValueError as e:
file_handler.cleanup_file(input_path)
logger.warning(f"Text extraction rejected unsafe or invalid archive: {file.filename}: {e}")
return JSONResponse(
status_code=400,
content={
"error": "CORRUPTED_FILE",
"message": "Le fichier semble corrompu ou n'est pas un document Office valide.",
"details": {"reason": "unsafe_archive", "detail": str(e)[:200]},
},
)
except Exception as e:
logger.exception("Text extraction error")
return JSONResponse(
status_code=500,
content={
"error": "INTERNAL_ERROR",
"message": "Erreur lors de l'extraction des textes. Veuillez reessayer.",
},
)
@router.post("/reconstruct-document")
async def reconstruct_document(
session_id: str = Form(..., description="Session ID from extract-texts"),
translations: str = Form(
..., description="JSON array of {id, translated_text} objects"
),
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""Reconstruct a document with translated texts. session_id must be a valid UUID."""
import json
import uuid
try:
uuid.UUID(session_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid session ID")
try:
session_file = config.UPLOAD_DIR / f"session_{session_id}.json"
if not session_file.exists():
raise HTTPException(status_code=404, detail="Session not found or expired")
with open(session_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
input_path = Path(session_data["input_path"]).resolve()
upload_dir_resolved = config.UPLOAD_DIR.resolve()
if not input_path.is_relative_to(upload_dir_resolved):
raise HTTPException(status_code=400, detail="Invalid session data")
file_extension = session_data["file_extension"]
original_filename = session_data["original_filename"]
if not input_path.exists():
raise HTTPException(
status_code=404, detail="Source file not found or expired"
)
translation_list = json.loads(translations)
translation_map = {t["id"]: t["translated_text"] for t in translation_list}
output_filename = file_handler.generate_unique_filename(
original_filename, "translated"
)
output_path = config.OUTPUT_DIR / output_filename
if file_extension == ".xlsx":
from openpyxl import load_workbook
import shutil
shutil.copy(input_path, output_path)
wb = load_workbook(output_path)
for sheet in wb.worksheets:
for row in sheet.iter_rows():
for cell in row:
cell_id = f"{sheet.title}!{cell.coordinate}"
if cell_id in translation_map:
cell.value = translation_map[cell_id]
wb.save(output_path)
wb.close()
elif file_extension == ".docx":
from docx import Document
import shutil
shutil.copy(input_path, output_path)
doc = Document(output_path)
para_idx = 0
for para in doc.paragraphs:
para_id = f"para_{para_idx}"
if para_id in translation_map and para.text.strip():
for run in para.runs:
run.text = ""
if para.runs:
para.runs[0].text = translation_map[para_id]
else:
para.text = translation_map[para_id]
para_idx += 1
table_idx = 0
for table in doc.tables:
for row_idx, row in enumerate(table.rows):
for cell_idx, cell in enumerate(row.cells):
cell_id = f"table_{table_idx}_r{row_idx}_c{cell_idx}"
if cell_id in translation_map:
for para in cell.paragraphs:
for run in para.runs:
run.text = ""
if cell.paragraphs and cell.paragraphs[0].runs:
cell.paragraphs[0].runs[0].text = translation_map[
cell_id
]
elif cell.paragraphs:
cell.paragraphs[0].text = translation_map[cell_id]
table_idx += 1
doc.save(output_path)
elif file_extension == ".pptx":
from pptx import Presentation
import shutil
shutil.copy(input_path, output_path)
prs = Presentation(output_path)
for slide_idx, slide in enumerate(prs.slides):
for shape_idx, shape in enumerate(slide.shapes):
if shape.has_text_frame:
for para_idx, para in enumerate(shape.text_frame.paragraphs):
for run_idx, run in enumerate(para.runs):
run_id = f"slide_{slide_idx}_shape_{shape_idx}_para_{para_idx}_run_{run_idx}"
if run_id in translation_map:
run.text = translation_map[run_id]
prs.save(output_path)
file_handler.cleanup_file(input_path)
file_handler.cleanup_file(session_file)
logger.info(f"Reconstructed document: {output_path}")
return FileResponse(
path=output_path,
filename=f"translated_{original_filename}",
media_type="application/octet-stream",
)
except HTTPException:
raise
except Exception as e:
logger.exception("Reconstruction error")
return JSONResponse(
status_code=500,
content={
"error": "INTERNAL_ERROR",
"message": "Erreur lors de la reconstruction du document. Veuillez reessayer.",
},
)
@router.get("/metrics")
async def get_metrics(
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""Get system metrics and statistics for monitoring"""
from middleware.cleanup import create_cleanup_manager
from middleware.rate_limiting import RateLimitManager, RateLimitConfig
cleanup_manager = create_cleanup_manager(config)
rate_limit_config = RateLimitConfig(
requests_per_minute=config.RATE_LIMIT_PER_MINUTE,
requests_per_hour=config.RATE_LIMIT_PER_HOUR,
translations_per_minute=config.TRANSLATIONS_PER_MINUTE,
translations_per_hour=config.TRANSLATIONS_PER_HOUR,
max_concurrent_translations=config.MAX_CONCURRENT_TRANSLATIONS,
)
rate_limit_manager = RateLimitManager(rate_limit_config)
cleanup_stats = cleanup_manager.get_stats()
rate_limit_stats = rate_limit_manager.get_stats()
return {
"system": {
"memory": {},
"disk": {},
"status": "healthy",
},
"cleanup": cleanup_stats,
"rate_limits": rate_limit_stats,
"config": {
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
"translation_service": config.TRANSLATION_SERVICE,
},
}
@router.get("/rate-limit/status")
async def get_rate_limit_status(request: Request):
"""Get current rate limit status for the requesting client"""
from middleware.rate_limiting import RateLimitManager, RateLimitConfig
rate_limit_config = RateLimitConfig(
requests_per_minute=config.RATE_LIMIT_PER_MINUTE,
requests_per_hour=config.RATE_LIMIT_PER_HOUR,
translations_per_minute=config.TRANSLATIONS_PER_MINUTE,
translations_per_hour=config.TRANSLATIONS_PER_HOUR,
max_concurrent_translations=config.MAX_CONCURRENT_TRANSLATIONS,
)
rate_limit_manager = RateLimitManager(rate_limit_config)
client_ip = request.client.host if request.client else "unknown"
status = await rate_limit_manager.get_client_status(client_ip)
return {
"client_ip": client_ip,
"limits": {
"requests_per_minute": rate_limit_config.requests_per_minute,
"requests_per_hour": rate_limit_config.requests_per_hour,
"translations_per_minute": rate_limit_config.translations_per_minute,
"translations_per_hour": rate_limit_config.translations_per_hour,
},
"current_usage": status,
}