Files
office_translator/routes/legacy_routes.py
sepehr 67365918ae
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s
feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.

Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).

Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).

Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.

Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).

API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.

DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.

Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
2026-08-30 22:42:29 +02:00

663 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
settings = load_settings()
is_dev = os.getenv("APP_ENV", "production").lower() == "development"
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"):
or_cfg = getattr(settings, "openrouter", None)
model = _resolve_model(
or_cfg.model if or_cfg else None,
"OPENROUTER_MODEL",
"google/gemini-3.5-flash",
)
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"):
orp_cfg = getattr(settings, "openrouter_premium", None)
model = _resolve_model(
orp_cfg.model if orp_cfg else None,
"OPENROUTER_PREMIUM_MODEL",
"anthropic/claude-sonnet-4.6",
)
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,
}