feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-03-07 11:42:58 +01:00
parent 3d37ce4582
commit 473b3e26c7
181 changed files with 30617 additions and 7170 deletions

977
routes/admin_routes.py Normal file
View File

@@ -0,0 +1,977 @@
"""
Admin API v1 Endpoints
All admin endpoints under /api/v1/admin/
Story 3.5: API Versioning - Migrated from main.py
"""
import os
import secrets
import time
import logging
from datetime import datetime, timezone
from typing import Optional, Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Form, Request, Query
from fastapi.responses import JSONResponse
from passlib.context import CryptContext
from pydantic import BaseModel
from config import config
from models.subscription import PlanType, PLANS
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
ADMIN_PASSWORD = os.getenv("ADMIN_DEV_DEFAULT")
_admin_token_secret = os.getenv("ADMIN_TOKEN_SECRET")
if not _admin_token_secret:
_admin_token_secret = secrets.token_hex(32)
logger.critical(
"SECURITY: ADMIN_TOKEN_SECRET is not configured! Using an ephemeral random key. "
"ALL ADMIN SESSIONS WILL BE INVALIDATED ON EVERY RESTART. "
"Set ADMIN_TOKEN_SECRET in your .env file immediately."
)
ADMIN_TOKEN_SECRET = _admin_token_secret
REDIS_URL = os.getenv("REDIS_URL", "")
_redis_client = None
_memory_sessions: dict = {}
# Brute-force protection: IP → (failed_count, first_fail_ts)
_login_attempts: dict[str, tuple[int, float]] = {}
_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_SECONDS = 300 # 5 minutes
def get_redis_client():
global _redis_client
if _redis_client is None and REDIS_URL:
try:
import redis
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
_redis_client.ping()
logger.info("Connected to Redis for session storage")
except Exception as e:
logger.warning(f"Redis connection failed: {e}. Using in-memory sessions.")
_redis_client = False
return _redis_client if _redis_client else None
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_admin_password(password: str) -> bool:
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
return False
p = (password or "").strip()
if ADMIN_PASSWORD_HASH:
try:
return pwd_context.verify(p, ADMIN_PASSWORD_HASH)
except Exception:
return False
return p == (ADMIN_PASSWORD or "").strip()
def _get_session_key(token: str) -> str:
return f"admin_session:{token}"
def create_admin_token() -> str:
token = secrets.token_urlsafe(32)
expiry = int(time.time()) + (24 * 60 * 60)
redis_client = get_redis_client()
if redis_client:
try:
redis_client.setex(_get_session_key(token), 24 * 60 * 60, str(expiry))
except Exception as e:
logger.warning(f"Redis session save failed: {e}")
_memory_sessions[token] = expiry
else:
_memory_sessions[token] = expiry
return token
def verify_admin_token(token: str) -> bool:
redis_client = get_redis_client()
if redis_client:
try:
expiry = redis_client.get(_get_session_key(token))
if expiry and int(expiry) > time.time():
return True
return False
except Exception as e:
logger.warning(f"Redis session check failed: {e}")
if token not in _memory_sessions:
return False
if time.time() > _memory_sessions[token]:
del _memory_sessions[token]
return False
return True
def delete_admin_token(token: str):
redis_client = get_redis_client()
if redis_client:
try:
redis_client.delete(_get_session_key(token))
except Exception:
pass
if token in _memory_sessions:
del _memory_sessions[token]
async def require_admin(authorization: Optional[str] = Header(None)) -> str:
if not ADMIN_USERNAME or (not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD):
raise HTTPException(
status_code=503, detail="Admin authentication not configured"
)
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header required")
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=401, detail="Invalid authorization format. Use: Bearer <token>"
)
token = parts[1]
if not verify_admin_token(token):
raise HTTPException(status_code=401, detail="Invalid or expired token")
return ADMIN_USERNAME
class AdminLoginRequest(BaseModel):
password: str
class AdminUpdateUserTierRequest(BaseModel):
plan: Literal["free", "starter", "pro", "business", "enterprise"]
@router.post("/login")
async def admin_login(request: AdminLoginRequest, req: Request):
"""Admin login endpoint - Returns a bearer token for authenticated admin access"""
client_ip = req.client.host if req.client else "unknown"
# Brute-force protection
now = time.time()
attempts, first_fail = _login_attempts.get(client_ip, (0, now))
if attempts >= _MAX_LOGIN_ATTEMPTS:
elapsed = now - first_fail
if elapsed < _LOCKOUT_SECONDS:
remaining = int(_LOCKOUT_SECONDS - elapsed)
logger.warning(f"Admin login blocked (brute-force) for IP {client_ip}")
raise HTTPException(
status_code=429,
detail=f"Too many failed attempts. Try again in {remaining}s.",
headers={"Retry-After": str(remaining)},
)
else:
_login_attempts.pop(client_ip, None)
if not verify_admin_password(request.password):
count, first = _login_attempts.get(client_ip, (0, now))
_login_attempts[client_ip] = (count + 1, first if count > 0 else now)
logger.warning(f"Failed admin login attempt from {client_ip} ({count + 1}/{_MAX_LOGIN_ATTEMPTS})")
raise HTTPException(status_code=401, detail="Invalid credentials")
_login_attempts.pop(client_ip, None)
token = create_admin_token()
logger.info(f"Admin login successful from {client_ip}")
return {
"status": "success",
"access_token": token,
"token_type": "bearer",
"expires_in": 86400,
"message": "Login successful",
}
@router.post("/logout")
async def admin_logout(authorization: Optional[str] = Header(None)):
"""Logout and invalidate admin token"""
if authorization:
parts = authorization.split(" ")
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1]
delete_admin_token(token)
logger.info("Admin logout successful")
return {"status": "success", "message": "Logged out"}
@router.get("/verify")
async def verify_admin_session(is_admin: bool = Depends(require_admin)):
"""Verify admin token is still valid"""
return {"status": "valid", "authenticated": True}
@router.get("/dashboard")
async def get_admin_dashboard(is_admin: bool = Depends(require_admin)):
"""Get comprehensive admin dashboard data"""
from middleware.cleanup import create_cleanup_manager
from middleware.rate_limiting import RateLimitManager, RateLimitConfig
from services.translation_service import _translation_cache
cleanup_manager = create_cleanup_manager(config)
rate_limit_config = RateLimitConfig(
requests_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "30")),
requests_per_hour=int(os.getenv("RATE_LIMIT_PER_HOUR", "200")),
translations_per_minute=int(os.getenv("TRANSLATIONS_PER_MINUTE", "10")),
translations_per_hour=int(os.getenv("TRANSLATIONS_PER_HOUR", "50")),
max_concurrent_translations=int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5")),
)
rate_limit_manager = RateLimitManager(rate_limit_config)
health_status = {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
cleanup_stats = cleanup_manager.get_stats()
rate_limit_stats = rate_limit_manager.get_stats()
tracked_files = cleanup_manager.get_tracked_files()
providers_status = {}
try:
from services.providers.google_provider import get_google_provider
google_health = get_google_provider().health_check()
providers_status["google"] = google_health.model_dump()
except Exception as e:
providers_status["google"] = {
"name": "google",
"available": False,
"error": str(e)[:100],
"last_check": None,
}
return {
"timestamp": health_status.get("timestamp"),
"status": health_status.get("status"),
"system": {"memory": {}, "disk": {}},
"providers": providers_status,
"cleanup": {**cleanup_stats, "tracked_files_count": len(tracked_files)},
"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("/users")
async def get_admin_users(is_admin: bool = Depends(require_admin)):
"""Get all users with their usage stats"""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
from database.connection import get_sync_session
from database.models import ApiKey
users_list = []
with get_sync_session() as session:
if USE_DATABASE and DATABASE_AVAILABLE:
from database.models import User as DBUser
db_users = session.query(DBUser).order_by(DBUser.created_at.desc()).all()
for db_user in db_users:
plan = db_user.plan or "free"
plan_info = PLANS.get(plan, PLANS["free"])
active_api_keys = (
session.query(ApiKey)
.filter(ApiKey.user_id == db_user.id, ApiKey.is_active == True)
.all()
)
users_list.append(
{
"id": str(db_user.id),
"email": db_user.email or "",
"name": db_user.name or "",
"plan": plan,
"subscription_status": db_user.subscription_status or "active",
"docs_translated_this_month": db_user.docs_translated_this_month or 0,
"pages_translated_this_month": db_user.pages_translated_this_month or 0,
"extra_credits": db_user.extra_credits or 0,
"created_at": db_user.created_at.isoformat() if db_user.created_at else "",
"plan_limits": {
"docs_per_month": plan_info.get("docs_per_month", 0),
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
},
"api_keys_count": len(active_api_keys),
"api_key_ids": [key.id for key in active_api_keys],
}
)
else:
users_data = load_users()
for user_id, user_data in users_data.items():
plan = user_data.get("plan", "free")
plan_info = PLANS.get(plan, PLANS["free"])
active_api_keys = (
session.query(ApiKey)
.filter(ApiKey.user_id == user_id, ApiKey.is_active == True)
.all()
)
users_list.append(
{
"id": user_id,
"email": user_data.get("email", ""),
"name": user_data.get("name", ""),
"plan": plan,
"subscription_status": user_data.get("subscription_status", "active"),
"docs_translated_this_month": user_data.get("docs_translated_this_month", 0),
"pages_translated_this_month": user_data.get("pages_translated_this_month", 0),
"extra_credits": user_data.get("extra_credits", 0),
"created_at": user_data.get("created_at", ""),
"plan_limits": {
"docs_per_month": plan_info.get("docs_per_month", 0),
"max_pages_per_doc": plan_info.get("max_pages_per_doc", 0),
},
"api_keys_count": len(active_api_keys),
"api_key_ids": [key.id for key in active_api_keys],
}
)
users_list.sort(key=lambda x: x.get("created_at", ""), reverse=True)
return {"total": len(users_list), "users": users_list}
@router.patch("/users/{user_id}")
async def patch_admin_user_tier(
user_id: str,
body: AdminUpdateUserTierRequest,
is_admin: bool = Depends(require_admin),
):
"""Update a user's plan/tier - Admin only"""
from services.auth_service import get_user_by_id, update_user_plan
user = get_user_by_id(user_id)
if not user:
return JSONResponse(
status_code=404,
content={"error": "NOT_FOUND", "message": "User not found"},
)
updated = update_user_plan(user_id, body.plan)
if not updated:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PLAN",
"message": "Invalid plan. Allowed: free, starter, pro, business, enterprise",
"details": {
"allowed": ["free", "starter", "pro", "business", "enterprise"]
},
},
)
plan_value = (
updated.plan.value if hasattr(updated.plan, "value") else str(updated.plan)
)
new_tier = (
"pro"
if updated.plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
else "free"
)
logger.info(
"admin_tier_change",
extra={
"event": "admin_tier_change",
"target_user_id": user_id,
"new_tier": new_tier,
"new_plan": plan_value,
"admin_id": "admin_session",
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return {
"data": {
"id": updated.id,
"email": updated.email,
"name": getattr(updated, "name", ""),
"plan": plan_value,
"tier": new_tier,
},
"meta": {},
}
@router.get("/stats")
async def get_admin_stats(is_admin: bool = Depends(require_admin)):
"""Get comprehensive admin statistics"""
from services.auth_service import load_users
from services.translation_service import _translation_cache
users_data = load_users()
total_users = len(users_data)
plan_distribution = {}
total_docs_translated = 0
total_pages_translated = 0
active_users = 0
for user_data in users_data.values():
plan = user_data.get("plan", "free")
plan_distribution[plan] = plan_distribution.get(plan, 0) + 1
docs = user_data.get("docs_translated_this_month", 0)
pages = user_data.get("pages_translated_this_month", 0)
total_docs_translated += docs
total_pages_translated += pages
if docs > 0:
active_users += 1
cache_stats = _translation_cache.get_stats()
return {
"users": {
"total": total_users,
"active_this_month": active_users,
"by_plan": plan_distribution,
},
"translations": {
"docs_this_month": total_docs_translated,
"pages_this_month": total_pages_translated,
},
"cache": cache_stats,
"config": {
"translation_service": config.TRANSLATION_SERVICE,
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
},
}
@router.post("/cleanup/trigger")
async def trigger_cleanup(is_admin: bool = Depends(require_admin)):
"""Trigger manual cleanup of expired files"""
from middleware.cleanup import create_cleanup_manager
cleanup_manager = create_cleanup_manager(config)
try:
cleaned = await cleanup_manager.cleanup_expired()
return {
"status": "success",
"files_cleaned": cleaned,
"message": f"Cleaned up {cleaned} expired files",
}
except Exception as e:
logger.error(f"Manual cleanup failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Cleanup failed: {str(e)}")
@router.get("/files/tracked")
async def get_tracked_files(is_admin: bool = Depends(require_admin)):
"""Get list of currently tracked files"""
from middleware.cleanup import create_cleanup_manager
cleanup_manager = create_cleanup_manager(config)
tracked = cleanup_manager.get_tracked_files()
return {"count": len(tracked), "files": tracked}
@router.post("/config/provider")
async def update_default_provider(
provider: str = Form(...),
is_admin: bool = Depends(require_admin),
):
"""Update the default translation provider"""
valid_providers = [
"google",
"deepl",
"openai",
"ollama",
"openrouter",
"zai",
"libre",
"classic",
"llm",
]
if provider not in valid_providers:
raise HTTPException(
status_code=400,
detail=f"Invalid provider. Must be one of: {valid_providers}",
)
config.TRANSLATION_SERVICE = provider
return {
"status": "success",
"message": f"Default provider updated to {provider}",
"provider": provider,
}
class AdminRevokeApiKeyRequest(BaseModel):
reason: Optional[str] = None
@router.delete("/api-keys/{key_id}")
async def admin_revoke_api_key(
key_id: str,
body: Optional[AdminRevokeApiKeyRequest] = None,
admin_id: str = Depends(require_admin),
):
"""Revoke any user's API key - Admin only"""
from database.connection import get_sync_session
from database.models import ApiKey
revoke_reason = body.reason if body else None
with get_sync_session() as session:
api_key = (
session.query(ApiKey)
.filter(ApiKey.id == key_id, ApiKey.is_active == True)
.first()
)
if not api_key:
return JSONResponse(
status_code=404,
content={
"error": "API_KEY_NOT_FOUND",
"message": "Clé API non trouvée ou déjà révoquée",
},
)
owner_user_id = api_key.user_id
api_key.is_active = False
api_key.revoked_at = datetime.now(timezone.utc)
session.commit()
logger.info(
"admin_api_key_revoked",
extra={
"admin_id": admin_id,
"key_id": key_id,
"owner_user_id": owner_user_id,
"reason": revoke_reason,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return JSONResponse(
status_code=200,
content={
"data": {
"id": api_key.id,
"revoked": True,
"revoked_at": datetime.now(timezone.utc).isoformat(),
"owner_user_id": owner_user_id,
"reason": revoke_reason,
},
"meta": {},
},
)
def _extract_error_code(error_message: Optional[str]) -> Optional[str]:
"""Extract a short error code from error message for log display (NFR: no document content)."""
if not error_message or not error_message.strip():
return None
import re
m = re.search(r"\b([A-Z][A-Z0-9_]{2,})\b", error_message)
if m:
return m.group(1)
first = error_message.strip().split()[0] if error_message.strip() else ""
if first:
return first.upper()[:20]
return None
@router.get("/logs")
def get_admin_logs(
is_admin: str = Depends(require_admin),
level: str = Query(default="all", pattern="^(all|error|warning|info)$"),
search: str = Query(default="", max_length=200),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
):
"""Get admin error logs from failed translations. No document content or original_filename exposed (NFR11, NFR16).
Search matches user_id and error_message (error codes typically appear in error_message)."""
from database.connection import get_sync_session
from database.models import Translation
from sqlalchemy import or_, desc
if level == "warning" or level == "info":
return {
"data": {
"logs": [],
"total": 0,
"page": page,
"per_page": per_page,
},
"meta": {"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
}
with get_sync_session() as session:
base = session.query(Translation).filter(Translation.status == "failed")
if search and search.strip():
term = f"%{search.strip()}%"
base = base.filter(
or_(
Translation.user_id.ilike(term),
Translation.error_message.ilike(term),
)
)
total = base.count()
rows = (
base.order_by(desc(Translation.created_at))
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
def _ts(created_at):
if not created_at:
return ""
s = created_at.isoformat()
return s.replace("+00:00", "Z") if "+00:00" in s else s + "Z"
logs = [
{
"timestamp": _ts(t.created_at),
"level": "error",
"message": (t.error_message or "Translation failed").strip()[:500],
"user_id": t.user_id,
"error_code": _extract_error_code(t.error_message),
"provider": t.provider,
"file_type": t.file_type,
}
for t in rows
]
return {
"data": {
"logs": logs,
"total": total,
"page": page,
"per_page": per_page,
},
"meta": {"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
}
SETTINGS_FILE = "data/provider_settings.json"
class ProviderSettings(BaseModel):
enabled: bool = False
api_key: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
timeout: int = 30
max_retries: int = 3
class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True)
deepl: ProviderSettings = ProviderSettings()
openai: ProviderSettings = ProviderSettings()
ollama: ProviderSettings = ProviderSettings() # dev-only in UI
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
zai: ProviderSettings = ProviderSettings()
fallback_chain: str = "google,deepl,openai,ollama,openrouter,openrouter_premium,zai"
fallback_chain_classic: str = "google,deepl"
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,zai,ollama"
def load_settings() -> SettingsConfig:
try:
import json
from pathlib import Path
settings_path = Path(SETTINGS_FILE)
if settings_path.exists():
with open(settings_path) as f:
data = json.load(f)
return SettingsConfig(**data)
except Exception as e:
logger.warning(f"Failed to load settings: {e}")
return SettingsConfig()
def save_settings(settings: SettingsConfig):
import json
from pathlib import Path
settings_path = Path(SETTINGS_FILE)
settings_path.parent.mkdir(exist_ok=True)
with open(settings_path, "w") as f:
json.dump(settings.model_dump(), f, indent=2)
@router.get("/settings")
async def get_settings(admin_id: str = Depends(require_admin)):
settings = load_settings()
# Merge env-var values into provider configs when JSON has no value.
# Env vars fill models/URLs; API keys are never exposed (only hinted via env_info).
# If an admin explicitly saves a value in the UI, JSON takes priority.
def _merge_env(
provider_settings: ProviderSettings,
key_env: str = "",
model_env: str = "",
url_env: str = "",
default_model: str = "",
default_url: str = "",
) -> dict:
d = provider_settings.model_dump()
# Model: env var > JSON null > code default
if model_env and not d.get("model"):
d["model"] = os.getenv(model_env, "").strip() or default_model or None
elif not d.get("model") and default_model:
d["model"] = default_model
# Base URL: env var > JSON null > code default
if url_env and not d.get("base_url"):
d["base_url"] = os.getenv(url_env, "").strip() or default_url or None
elif not d.get("base_url") and default_url:
d["base_url"] = default_url
# API key: never expose; leave empty (UI shows "clé dans .env" badge via env_info)
return d
payload = settings.model_dump()
# Essentielle : DeepSeek V3.2 — meilleur rapport qualité/prix (mars 2026)
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="deepseek/deepseek-v3.2")
# Premium : Claude 3.5 Haiku — précision maximale sur documents complexes
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-3.5-haiku")
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
payload["deepl"] = _merge_env(settings.deepl, key_env="DEEPL_API_KEY")
payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
payload["ollama"] = _merge_env(settings.ollama, url_env="OLLAMA_BASE_URL", model_env="OLLAMA_MODEL", default_url="http://localhost:11434", default_model="llama3")
# Inform the frontend which providers have API keys configured via env vars
# (boolean only — never expose actual values)
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
env_info = {
"deepl": bool(os.getenv("DEEPL_API_KEY", "").strip()),
"openai": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"openrouter": has_openrouter,
"openrouter_premium": has_openrouter, # same key, different model
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
}
return JSONResponse(
status_code=200,
content={"data": payload, "env_info": env_info, "meta": {}},
)
@router.put("/settings")
async def update_settings(
settings: SettingsConfig, admin_id: str = Depends(require_admin)
):
save_settings(settings)
logger.info(f"admin_settings_updated by {admin_id}")
return JSONResponse(
status_code=200, content={"data": settings.model_dump(), "meta": {}}
)
@router.post("/providers/{provider}/test")
async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
"""Test a provider connection. Works even when provider is disabled.
Always falls back to env vars when the JSON api_key is empty."""
settings = load_settings()
provider_config = getattr(settings, provider, None)
if not provider_config:
return JSONResponse(
status_code=404,
content={
"error": "PROVIDER_NOT_FOUND",
"message": f"Provider {provider} not found",
},
)
# Helper: resolve api_key from JSON config first, then env var
def _key(json_val: Optional[str], env_var: str) -> str:
return (json_val or "").strip() or os.getenv(env_var, "").strip()
try:
if provider == "google":
from deep_translator import GoogleTranslator
result = GoogleTranslator(source="auto", target="en").translate("bonjour")
return JSONResponse(
status_code=200, content={"available": True, "test_result": result}
)
elif provider == "deepl":
api_key = _key(provider_config.api_key, "DEEPL_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API DeepL trouvée (JSON ou .env)"},
)
import deepl
translator = deepl.Translator(api_key)
usage = translator.get_usage()
return JSONResponse(
status_code=200, content={"available": True, "usage": str(usage)}
)
elif provider == "openai":
api_key = _key(provider_config.api_key, "OPENAI_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API OpenAI trouvée (JSON ou .env)"},
)
import openai as _openai
client = _openai.OpenAI(api_key=api_key)
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={"available": True, "models_count": len(models)},
)
elif provider == "ollama":
import requests as _requests
base_url = (provider_config.base_url or "").strip() or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
resp = _requests.get(f"{base_url}/api/tags", timeout=5)
if resp.ok:
return JSONResponse(
status_code=200,
content={
"available": True,
"models": resp.json().get("models", []),
},
)
return JSONResponse(
status_code=500, content={"available": False, "error": str(resp.text)}
)
elif provider in ("openrouter", "openrouter_premium"):
api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY")
if not api_key:
# openrouter_premium shares the same key as openrouter
api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API OpenRouter trouvée (JSON ou .env)"},
)
import requests as _requests
resp = _requests.get(
"https://openrouter.ai/api/v1/auth/key",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if resp.ok:
data = resp.json()
return JSONResponse(
status_code=200,
content={"available": True, "label": data.get("data", {}).get("label", "OK")},
)
return JSONResponse(
status_code=500, content={"available": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
)
elif provider == "zai":
api_key = _key(provider_config.api_key, "ZAI_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API xAI trouvée (JSON ou .env)"},
)
import openai as _openai
base_url = (provider_config.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
client = _openai.OpenAI(api_key=api_key, base_url=base_url)
try:
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={
"available": True,
"models_count": len(models),
"sample_models": [m.id for m in models[:5]],
},
)
except _openai.AuthenticationError:
return JSONResponse(
status_code=401,
content={"available": False, "error": "Clé xAI invalide"},
)
else:
return JSONResponse(
status_code=404,
content={"available": False, "error": "Provider inconnu"},
)
except Exception as e:
logger.error(f"Provider test failed: {e}")
return JSONResponse(
status_code=500, content={"available": False, "error": str(e)}
)
@router.get("/providers/ollama/models")
async def list_ollama_models(admin_id: str = Depends(require_admin)):
"""List available models from Ollama server"""
import requests
from config import config as app_config
settings = load_settings()
base_url = (
settings.ollama.base_url
or app_config.OLLAMA_BASE_URL
or "http://localhost:11434"
)
try:
response = requests.get(f"{base_url}/api/tags", timeout=5)
if response.ok:
data = response.json()
models = []
for model in data.get("models", []):
models.append(
{
"name": model.get("name", ""),
"size": model.get("size", 0),
"modified_at": model.get("modified_at", ""),
}
)
return JSONResponse(status_code=200, content={"data": models, "meta": {}})
return JSONResponse(
status_code=500,
content={
"error": "OLLAMA_UNAVAILABLE",
"message": f"Ollama returned: {response.text}",
},
)
except requests.exceptions.ConnectionError:
return JSONResponse(
status_code=503,
content={
"error": "OLLAMA_CONNECTION_ERROR",
"message": f"Cannot connect to Ollama at {base_url}",
},
)
except Exception as e:
logger.error(f"List Ollama models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)

330
routes/api_key_routes.py Normal file
View File

@@ -0,0 +1,330 @@
"""
API Key management routes for Pro users
Story 3.1: Modèle API Key & Génération
"""
import hashlib
import logging
import secrets
from datetime import datetime, timezone
from typing import Optional
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field
from services.auth_service import verify_token, get_user_by_id
from database.connection import get_sync_session
from database.models import ApiKey
router = APIRouter(prefix="/api/v1/api-keys", tags=["API Keys v1"])
security = HTTPBearer(auto_error=False)
MAX_API_KEYS_PER_USER = 10
class ApiKeyCreateRequest(BaseModel):
name: Optional[str] = Field(default="Default API Key", max_length=100)
class ApiKeyResponse(BaseModel):
id: str
key: str
name: str
key_prefix: str
created_at: str
class ProUser:
"""Wrapper for authenticated Pro user with tier info"""
def __init__(self, user):
self._user = user
self.id = user.id
self.email = getattr(user, "email", None)
self._tier = None
@property
def tier(self) -> str:
if self._tier is None:
user_tier = getattr(self._user, "tier", None)
if user_tier:
self._tier = user_tier
else:
plan_value = getattr(self._user, "plan", None)
if plan_value and hasattr(plan_value, "value"):
if plan_value.value in ("pro", "business", "enterprise"):
self._tier = "pro"
else:
self._tier = "free"
else:
self._tier = "free"
return self._tier
def _require_auth(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
):
"""Dependency that requires a valid JWT token (any authenticated user)"""
if not credentials:
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Authentification requise",
},
)
payload = verify_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Token invalide ou expiré",
},
)
sub = payload.get("sub")
if not sub or not isinstance(sub, str):
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Token invalide",
},
)
user = get_user_by_id(sub)
if not user:
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Utilisateur non trouvé",
},
)
return user
def _require_pro_user(user=Depends(_require_auth)) -> ProUser:
"""Dependency that requires a valid Pro user JWT token"""
pro_user = ProUser(user)
if pro_user.tier != "pro":
raise HTTPException(
status_code=403,
detail={
"error": "PRO_FEATURE_REQUIRED",
"message": "Cette fonctionnalité nécessite un abonnement Pro",
},
)
return pro_user
def _generate_api_key() -> tuple[str, str, str]:
"""
Generate a secure API key with sk_live_ prefix.
Returns:
tuple: (raw_key, key_hash, key_prefix)
"""
raw_random = secrets.token_urlsafe(32)
raw_key = f"sk_live_{raw_random}"
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
key_prefix = raw_key[:8]
return raw_key, key_hash, key_prefix
@router.post("")
async def create_api_key(
request: Request,
body: Optional[ApiKeyCreateRequest] = None,
user: ProUser = Depends(_require_pro_user),
):
"""
Create a new API key for the authenticated Pro user.
Returns:
201: API key created successfully (key shown ONCE)
401: Authentication required
403: Pro subscription required
429: Maximum API keys reached
"""
key_name = body.name if body and body.name else "Default API Key"
raw_key, key_hash, key_prefix = _generate_api_key()
with get_sync_session() as session:
existing_count = (
session.query(ApiKey)
.filter(
ApiKey.user_id == user.id,
ApiKey.is_active == True,
)
.count()
)
if existing_count >= MAX_API_KEYS_PER_USER:
return JSONResponse(
status_code=429,
content={
"error": "API_KEY_LIMIT_REACHED",
"message": f"Maximum de {MAX_API_KEYS_PER_USER} clés API atteint. Supprimez une clé existante.",
},
)
api_key = ApiKey(
user_id=user.id,
name=key_name,
key_hash=key_hash,
key_prefix=key_prefix,
is_active=True,
scopes=["translate"],
created_at=datetime.now(timezone.utc),
)
session.add(api_key)
session.commit()
session.refresh(api_key)
return JSONResponse(
status_code=201,
content={
"data": {
"id": api_key.id,
"key": raw_key,
"name": api_key.name,
"key_prefix": api_key.key_prefix,
"created_at": api_key.created_at.isoformat()
if api_key.created_at
else None,
},
"meta": {},
},
)
@router.get("")
async def list_api_keys(
request: Request,
user: ProUser = Depends(_require_pro_user),
):
"""
List all API keys for the authenticated Pro user.
Note: Keys are returned without the secret (only prefix visible).
Returns:
200: List of API keys
401: Authentication required
403: Pro subscription required
"""
with get_sync_session() as session:
keys = (
session.query(ApiKey)
.filter(ApiKey.user_id == user.id)
.order_by(ApiKey.created_at.desc())
.all()
)
return JSONResponse(
status_code=200,
content={
"data": [
{
"id": key.id,
"name": key.name,
"key_prefix": key.key_prefix,
"is_active": key.is_active,
"last_used_at": key.last_used_at.isoformat()
if key.last_used_at
else None,
"usage_count": key.usage_count,
"created_at": key.created_at.isoformat()
if key.created_at
else None,
}
for key in keys
],
"meta": {"total": len(keys)},
},
)
@router.delete("/{key_id}")
async def revoke_api_key(
key_id: str,
user: ProUser = Depends(_require_pro_user),
):
"""
Revoke an API key for the authenticated Pro user.
This performs a soft delete by setting is_active=False.
Only the owner of the key can revoke it.
Only active keys can be revoked.
Returns:
200: API key revoked successfully
401: Authentication required
403: Pro subscription required
404: API key not found or already revoked
"""
# Validate key_id format (UUID)
try:
import uuid as uuid_module
uuid_module.UUID(key_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_KEY_ID",
"message": "Format d'identifiant de clé API invalide.",
},
)
with get_sync_session() as session:
# Security: Filter by user_id AND is_active so only the owner can revoke active keys
api_key = (
session.query(ApiKey)
.filter(
ApiKey.id == key_id,
ApiKey.user_id == user.id,
ApiKey.is_active == True, # Only active keys can be revoked
)
.first()
)
if not api_key:
return JSONResponse(
status_code=404,
content={
"error": "API_KEY_NOT_FOUND",
"message": "Clé API non trouvée, n'appartient pas à l'utilisateur ou déjà révoquée.",
},
)
# Soft delete - mark as inactive and record revocation timestamp
revoked_at = datetime.now(timezone.utc)
api_key.is_active = False
api_key.revoked_at = revoked_at
session.commit()
logger.info(f"API key {key_id} revoked by user {user.id}")
return JSONResponse(
status_code=200,
content={
"data": {
"id": api_key.id,
"revoked": True,
"revoked_at": revoked_at.isoformat(),
},
"meta": {},
},
)

25
routes/api_v1_router.py Normal file
View File

@@ -0,0 +1,25 @@
"""
Main API v1 Router
Aggregates all v1 sub-routers under /api/v1 prefix
Story 3.5: API Versioning
"""
from fastapi import APIRouter
router = APIRouter(tags=["API v1"])
from routes.translate_routes import router_v1 as translate_router
from routes.auth_routes import router_v1 as auth_router
from routes.api_key_routes import router as api_key_router
from routes.admin_routes import router as admin_router
from routes.legacy_routes import router as legacy_router
from routes.glossary_routes import router as glossary_router
from routes.prompt_routes import router as prompt_router
router.include_router(translate_router, tags=["Translation"])
router.include_router(auth_router, tags=["Authentication"])
router.include_router(api_key_router, tags=["API Keys"])
router.include_router(admin_router, tags=["Admin"])
router.include_router(legacy_router, tags=["Legacy"])
router.include_router(glossary_router, tags=["Glossaries"])
router.include_router(prompt_router, tags=["Prompts"])

View File

@@ -1,25 +1,48 @@
"""
Authentication and User API routes
Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
"""
import os
from datetime import timedelta
from fastapi import APIRouter, HTTPException, Depends, Header, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, ValidationError as PydanticValidationError
from typing import Optional, Dict, Any
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
from models.subscription import (
UserCreate,
UserLogin,
UserResponse,
PlanType,
PLANS,
CREDIT_PACKAGES,
)
from services.auth_service import (
create_user, authenticate_user, get_user_by_id,
create_access_token, create_refresh_token, verify_token,
check_usage_limits, update_user
create_user,
authenticate_user,
get_user_by_id,
create_access_token,
create_refresh_token,
verify_token,
revoke_token_jti,
check_usage_limits,
update_user,
get_user_by_email,
verify_password,
PASSLIB_AVAILABLE,
)
from services.payment_service import (
create_checkout_session, create_credits_checkout,
cancel_subscription, get_billing_portal_url,
handle_webhook, is_stripe_configured
create_checkout_session,
create_credits_checkout,
cancel_subscription,
get_billing_portal_url,
handle_webhook,
is_stripe_configured,
)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
security = HTTPBearer(auto_error=False)
@@ -44,31 +67,26 @@ class CreditsCheckoutRequest(BaseModel):
package_index: int
# Dependency to get current user
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
):
if not credentials:
return None
payload = verify_token(credentials.credentials)
if not payload:
return None
user = get_user_by_id(payload.get("sub"))
return user
return get_user_by_id(payload.get("sub"))
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = get_user_by_id(payload.get("sub"))
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
@@ -81,6 +99,7 @@ def user_to_response(user) -> UserResponse:
name=user.name,
avatar_url=user.avatar_url,
plan=user.plan,
tier=user.plan,
subscription_status=user.subscription_status,
docs_translated_this_month=user.docs_translated_this_month,
pages_translated_this_month=user.pages_translated_this_month,
@@ -94,188 +113,610 @@ def user_to_response(user) -> UserResponse:
"providers": plan_limits["providers"],
"features": plan_limits["features"],
"api_access": plan_limits.get("api_access", False),
}
},
)
# Auth endpoints
@router.post("/register", response_model=TokenResponse)
async def register(user_create: UserCreate):
"""Register a new user"""
# ============== API v1 Router ==============
router_v1 = APIRouter(prefix="/api/v1/auth", tags=["Authentication v1"])
def _has_email_validation_error(exc: PydanticValidationError) -> bool:
"""Return True when pydantic validation errors include the email field."""
for err in exc.errors():
loc = err.get("loc", ())
if isinstance(loc, (list, tuple)) and "email" in loc:
return True
return False
@router_v1.post(
"/register",
status_code=201,
summary="Inscription d'un nouvel utilisateur",
description="""
Créer un nouveau compte utilisateur.
**Paramètres requis:**
- `email`: Adresse email valide (sera utilisée pour la connexion)
- `password`: Mot de passe (minimum 8 caractères)
- `name`: Nom complet (optionnel)
**Réponse:**
- HTTP 201 avec les données de l'utilisateur créé
- Le `tier` par défaut est "free"
**Codes d'erreur:**
- `INVALID_EMAIL`: Format d'email invalide
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
- `INVALID_REQUEST`: Données d'inscription invalides
""",
responses={
201: {
"description": "Utilisateur créé avec succès",
"content": {
"application/json": {
"example": {
"data": {
"id": "usr_abc123def456",
"email": "utilisateur@exemple.com",
"tier": "free",
},
"meta": {},
}
}
},
},
400: {
"description": "Erreur de validation",
"content": {
"application/json": {
"examples": {
"INVALID_EMAIL": {
"summary": "Format d'email invalide",
"value": {
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
},
"EMAIL_EXISTS": {
"summary": "Email déjà utilisé",
"value": {
"error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email",
},
},
}
}
},
},
},
)
async def register_v1(request: Request):
"""Inscription d'un nouvel utilisateur (API v1) — retourne 201 avec données utilisateur"""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
try:
user_create = UserCreate.model_validate(body)
except PydanticValidationError as exc:
if _has_email_validation_error(exc):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
)
# Check if it's a password validation error
for error in exc.errors():
loc = error.get("loc", ())
if "password" in loc:
msg = error.get("msg", "")
# If password is missing entirely, return INVALID_REQUEST
if "Field required" in msg or "required" in msg.lower():
break
# Otherwise it's a weak password
# Translate common pydantic messages to French
if "at least 8 characters" in msg.lower() or "8 caractères" in msg:
msg = "Le mot de passe doit contenir au moins 8 caractères"
return JSONResponse(
status_code=400,
content={
"error": "WEAK_PASSWORD",
"message": msg,
},
)
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Données d'inscription invalides",
},
)
# In production, registration must rely on passlib[bcrypt] hashing.
if (
os.getenv("ENVIRONMENT", "development").lower() == "production"
and not PASSLIB_AVAILABLE
):
return JSONResponse(
status_code=503,
content={
"error": "AUTH_HASHING_UNAVAILABLE",
"message": "Service d'inscription temporairement indisponible",
},
)
try:
user = create_user(user_create)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
except ValueError as exc:
msg = str(exc).strip().lower()
if "email already registered" in msg or "email already" in msg:
return JSONResponse(
status_code=400,
content={
"error": "EMAIL_EXISTS",
"message": "Un compte existe déjà avec cette adresse email",
},
)
return JSONResponse(
status_code=400,
content={
"error": "REGISTRATION_FAILED",
"message": "Impossible de créer le compte avec les données fournies",
},
)
return JSONResponse(
status_code=201,
content={
"data": {
"id": user.id,
"email": user.email,
"tier": user.plan.value,
},
"meta": {},
},
)
@router.post("/login", response_model=TokenResponse)
async def login(credentials: UserLogin):
"""Login with email and password"""
user = authenticate_user(credentials.email, credentials.password)
@router_v1.post(
"/logout",
summary="Déconnexion utilisateur",
description="""
Déconnecte l'utilisateur en révoquant son token d'accès.
**Authentification requise:** Bearer token dans le header Authorization
**Paramètres optionnels:**
- `refresh_token`: Peut être fourni pour révoquer également le refresh token
**Réponse:**
- HTTP 200 avec message de confirmation
**Codes d'erreur:**
- `TOKEN_MISSING`: Token d'authentification manquant
- `TOKEN_INVALID`: Token invalide ou expiré
""",
responses={
200: {
"description": "Déconnexion réussie",
"content": {
"application/json": {
"example": {"data": {"message": "Déconnexion réussie"}, "meta": {}}
}
},
},
401: {
"description": "Erreur d'authentification",
"content": {
"application/json": {
"examples": {
"TOKEN_MISSING": {
"summary": "Token manquant",
"value": {
"error": "TOKEN_MISSING",
"message": "Token d'authentification requis",
},
},
"TOKEN_INVALID": {
"summary": "Token invalide",
"value": {
"error": "TOKEN_INVALID",
"message": "Token invalide ou expiré",
},
},
}
}
},
},
},
)
async def logout_v1(request: Request):
"""Logout utilisateur (API v1) — révoque l'access token et optionnellement le refresh token"""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_MISSING",
"message": "Token d'authentification requis",
},
)
access_token = auth_header[7:]
payload = verify_token(access_token)
if not payload:
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_INVALID",
"message": "Token invalide ou expiré",
},
)
jti = payload.get("jti")
if jti:
revoke_token_jti(jti, float(payload.get("exp", 0)))
try:
body = await request.json()
refresh_token = body.get("refresh_token")
if refresh_token:
refresh_payload = verify_token(refresh_token)
if refresh_payload and refresh_payload.get("jti"):
revoke_token_jti(
refresh_payload["jti"],
float(refresh_payload.get("exp", 0)),
)
except Exception:
pass
return JSONResponse(
status_code=200,
content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
)
@router_v1.post(
"/login",
summary="Connexion utilisateur",
description="""
Authentifie un utilisateur et retourne les tokens JWT.
**Paramètres requis:**
- `email`: Adresse email de l'utilisateur
- `password`: Mot de passe
**Réponse:**
- HTTP 200 avec `access_token` (expire dans 15 min) et `refresh_token` (expire dans 7 jours)
**Codes d'erreur:**
- `INVALID_REQUEST`: Corps de requête JSON invalide
- `INVALID_EMAIL`: Format d'email invalide
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
""",
responses={
200: {
"description": "Connexion réussie",
"content": {
"application/json": {
"example": {
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
},
"meta": {},
}
}
},
},
400: {
"description": "Erreur de validation",
"content": {
"application/json": {
"examples": {
"INVALID_REQUEST": {
"summary": "Corps invalide",
"value": {
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
},
"INVALID_EMAIL": {
"summary": "Email invalide",
"value": {
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
},
}
}
},
},
401: {
"description": "Identifiants invalides",
"content": {
"application/json": {
"example": {
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
}
}
},
},
},
)
async def login_v1(request: Request):
"""Login utilisateur (API v1) — retourne access_token (15min) et refresh_token (7j)"""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
try:
user_login = UserLogin.model_validate(body)
except PydanticValidationError as exc:
if _has_email_validation_error(exc):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_EMAIL",
"message": "Format d'email invalide",
},
)
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Données d'inscription invalides",
},
)
user = get_user_by_email(user_login.email)
if not user:
raise HTTPException(status_code=401, detail="Invalid email or password")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
# Constant-time dummy verify to prevent user enumeration via response time
verify_password("__dummy__", "$2b$12$mBw4RxPJBaaS1FtEZcT/E.E35YUCk1Zx0ICzIzNUSdzHmQmko1.WW")
return JSONResponse(
status_code=401,
content={
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
},
)
if not verify_password(user_login.password, user.password_hash):
return JSONResponse(
status_code=401,
content={
"error": "INVALID_CREDENTIALS",
"message": "Email ou mot de passe incorrect",
},
)
access_token = create_access_token(
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
)
refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
return JSONResponse(
status_code=200,
content={
"data": {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
},
"meta": {},
},
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_tokens(request: RefreshRequest):
"""Refresh access token"""
payload = verify_token(request.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
@router_v1.post("/refresh")
async def refresh_v1(request: Request):
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
try:
body = await request.json()
except Exception:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
if not isinstance(body, dict):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Corps de requete JSON invalide",
},
)
refresh_token = body.get("refresh_token")
if (
not refresh_token
or not isinstance(refresh_token, str)
or not refresh_token.strip()
):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_REQUEST",
"message": "Refresh token requis",
},
)
payload = verify_token(refresh_token)
if not payload:
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
},
)
if payload.get("type") != "refresh":
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
},
)
user = get_user_by_id(payload.get("sub"))
if not user:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
return JSONResponse(
status_code=401,
content={
"error": "TOKEN_EXPIRED",
"message": "Token invalide ou expiré",
},
)
access_token = create_access_token(
user.id, tier=user.plan.value, expires_delta=timedelta(minutes=15)
)
new_refresh_token = create_refresh_token(user.id, expires_delta=timedelta(days=7))
return JSONResponse(
status_code=200,
content={
"data": {
"access_token": access_token,
"refresh_token": new_refresh_token,
"token_type": "bearer",
},
"meta": {},
},
)
@router.get("/me", response_model=UserResponse)
async def get_me(user=Depends(require_user)):
"""Get current user info"""
return user_to_response(user)
@router.get("/usage")
async def get_usage(user=Depends(require_user)):
"""Get current usage and limits"""
return check_usage_limits(user)
@router.put("/settings")
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
"""Update user settings"""
allowed_fields = [
"default_source_lang", "default_target_lang", "default_provider",
"ollama_endpoint", "ollama_model", "name"
]
updates = {k: v for k, v in settings.items() if k in allowed_fields}
updated_user = update_user(user.id, updates)
if not updated_user:
raise HTTPException(status_code=400, detail="Failed to update settings")
return user_to_response(updated_user)
# Plans endpoint (public)
@router.get("/plans")
async def get_plans():
"""Get all available plans"""
plans = []
for plan_type, config in PLANS.items():
plans.append({
"id": plan_type.value,
"name": config["name"],
"price_monthly": config["price_monthly"],
"price_yearly": config["price_yearly"],
"features": config["features"],
"docs_per_month": config["docs_per_month"],
"max_pages_per_doc": config["max_pages_per_doc"],
"max_file_size_mb": config["max_file_size_mb"],
"providers": config["providers"],
"api_access": config.get("api_access", False),
"popular": plan_type == PlanType.PRO,
})
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
# Payment endpoints
@router.post("/checkout/subscription")
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
"""Create Stripe checkout session for subscription"""
if not is_stripe_configured():
# Demo mode - just upgrade the user
update_user(user.id, {"plan": request.plan.value})
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
result = await create_checkout_session(
user.id,
request.plan,
request.billing_period
@router_v1.get(
"/me",
summary="Informations utilisateur",
description="Retourne les informations de l'utilisateur authentifié.",
)
async def get_me_v1(user=Depends(require_user)):
return JSONResponse(
status_code=200,
content={"data": user_to_response(user).model_dump(mode="json"), "meta": {}},
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/checkout/credits")
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
"""Create Stripe checkout session for credits"""
if not is_stripe_configured():
# Demo mode - add credits directly
from services.auth_service import add_credits
credits = CREDIT_PACKAGES[request.package_index]["credits"]
add_credits(user.id, credits)
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
result = await create_credits_checkout(user.id, request.package_index)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router_v1.get(
"/plans",
summary="Liste des forfaits disponibles",
description="Retourne tous les forfaits et packs de crédits disponibles (endpoint public).",
)
async def get_plans_v1():
from models.subscription import PLANS, CREDIT_PACKAGES
plans_list = []
for plan_type, plan in PLANS.items():
plans_list.append(
{
"id": plan_type.value,
"name": plan["name"],
"price_monthly": plan["price_monthly"],
"price_yearly": plan["price_yearly"],
"docs_per_month": plan["docs_per_month"],
"max_pages_per_doc": plan["max_pages_per_doc"],
"max_file_size_mb": plan["max_file_size_mb"],
"max_chars_per_month": plan.get("max_chars_per_month", -1),
"providers": plan["providers"],
"features": plan["features"],
"ai_translation": plan.get("ai_translation", False),
"ai_tier": plan.get("ai_tier"),
"api_access": plan.get("api_access", False),
"priority_processing": plan.get("priority_processing", False),
"team_seats": plan.get("team_seats"),
"highlight": plan.get("highlight"),
"description": plan.get("description"),
"badge": plan.get("badge"),
"popular": plan.get("badge") == "POPULAIRE",
}
)
packages_list = []
for pkg in CREDIT_PACKAGES:
packages_list.append(
{
"credits": pkg["credits"],
"price": pkg["price"],
"price_per_credit": round(pkg["price"] / pkg["credits"], 4),
"popular": pkg.get("popular", False),
}
)
return JSONResponse(
status_code=200,
content={"data": {"plans": plans_list, "credit_packages": packages_list}, "meta": {}},
)
@router.post("/subscription/cancel")
async def cancel_user_subscription(user=Depends(require_user)):
"""Cancel subscription"""
result = await cancel_subscription(user.id)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router_v1.get(
"/usage",
summary="Utilisation et limites",
description="Retourne l'utilisation actuelle et les limites du plan de l'utilisateur.",
)
async def get_usage_v1(user=Depends(require_user)):
return JSONResponse(
status_code=200,
content={"data": check_usage_limits(user), "meta": {}},
)
@router.get("/billing-portal")
async def get_billing_portal(user=Depends(require_user)):
"""Get Stripe billing portal URL"""
@router_v1.get(
"/billing-portal",
summary="Portail de facturation",
description="Retourne l'URL du portail de facturation Stripe.",
)
async def get_billing_portal_v1(user=Depends(require_user)):
url = await get_billing_portal_url(user.id)
if not url:
raise HTTPException(status_code=400, detail="Billing portal not available")
return {"url": url}
return JSONResponse(
status_code=400,
content={
"error": "BILLING_UNAVAILABLE",
"message": "Portail de facturation non disponible",
},
)
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
# Stripe webhook
@router.post("/webhook/stripe")
# ============== Stripe webhook (versioned) ==============
@router_v1.post("/webhook/stripe")
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
"""Handle Stripe webhooks"""
payload = await request.body()
result = await handle_webhook(payload, stripe_signature or "")
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result

106
routes/deps.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Shared authentication dependencies for routes.
Story 3.9: Extracted from api_key_routes.py and glossary_routes.py
"""
import logging
from typing import Optional
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from services.auth_service import verify_token, get_user_by_id
logger = logging.getLogger(__name__)
security = HTTPBearer(auto_error=False)
class ProUser:
"""Wrapper for authenticated user with tier info."""
def __init__(self, user):
self._user = user
self.id = user.id
self.email = getattr(user, "email", None)
self._tier = None
@property
def tier(self) -> str:
if self._tier is None:
user_tier = getattr(self._user, "tier", None)
if user_tier:
self._tier = user_tier
else:
plan_value = getattr(self._user, "plan", None)
if plan_value and hasattr(plan_value, "value"):
if plan_value.value in ("pro", "business", "enterprise"):
self._tier = "pro"
else:
self._tier = "free"
else:
self._tier = "free"
return self._tier
def require_auth(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
):
"""Dependency that requires a valid JWT token."""
if not credentials:
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Authentification requise",
},
)
payload = verify_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Token invalide ou expiré",
},
)
sub = payload.get("sub")
if not sub or not isinstance(sub, str):
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Token invalide",
},
)
user = get_user_by_id(sub)
if not user:
raise HTTPException(
status_code=401,
detail={
"error": "UNAUTHORIZED",
"message": "Utilisateur non trouvé",
},
)
return user
def require_pro_user(user=Depends(require_auth)) -> ProUser:
"""Dependency that requires a valid Pro user JWT token."""
pro_user = ProUser(user)
if pro_user.tier != "pro":
raise HTTPException(
status_code=403,
detail={
"error": "PRO_FEATURE_REQUIRED",
"message": "Cette fonctionnalité nécessite un abonnement Pro.",
"details": {"current_tier": pro_user.tier},
},
)
return pro_user

625
routes/glossary_routes.py Normal file
View File

@@ -0,0 +1,625 @@
"""
Glossary CRUD routes for Pro users
Story 3.9: Glossaires - Endpoint CRUD
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.orm import joinedload
from routes.deps import require_pro_user, ProUser
from services.auth_service import verify_token, get_user_by_id
from database.connection import get_sync_session
from database.models import Glossary, GlossaryTerm
from schemas.glossary_schemas import (
GlossaryCreate,
GlossaryUpdate,
GlossaryResponse,
GlossaryListItem,
GlossaryListResponse,
GlossaryDetailResponse,
)
logger = logging.getLogger(__name__)
GLOSSARIES_DIR = Path(__file__).parent.parent / "data/glossaries"
router = APIRouter(prefix="/api/v1/glossaries", tags=["Glossaries v1"])
# Maximum number of terms per glossary
MAX_TERMS_PER_GLOSSARY = 500
# Default pagination
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 100
def _format_term(term: GlossaryTerm) -> dict:
"""Format a GlossaryTerm for JSON response."""
return {
"id": term.id,
"source": term.source,
"target": term.target,
"created_at": term.created_at.isoformat() if term.created_at else None,
}
def _format_glossary(glossary: Glossary) -> dict:
"""Format a Glossary for JSON response."""
return {
"id": glossary.id,
"name": glossary.name,
"terms": [_format_term(t) for t in glossary.terms] if glossary.terms else [],
"created_at": glossary.created_at.isoformat() if glossary.created_at else None,
"updated_at": glossary.updated_at.isoformat() if glossary.updated_at else None,
}
@router.post(
"",
response_model=GlossaryDetailResponse,
status_code=201,
summary="Créer un glossaire",
description="""
Crée un nouveau glossaire avec une liste de termes source→cible.
**Restriction:** Uniquement disponible pour les utilisateurs Pro.
**Exemple de requête:**
```json
{
"name": "Glossaire Technique FR-EN",
"terms": [
{"source": "serveur", "target": "server"},
{"source": "base de données", "target": "database"}
]
}
```
""",
)
async def create_glossary(
body: GlossaryCreate,
user: ProUser = Depends(require_pro_user),
):
"""Create a new glossary for the authenticated Pro user."""
# Validate max terms
if len(body.terms) > MAX_TERMS_PER_GLOSSARY:
return JSONResponse(
status_code=400,
content={
"error": "TERMS_LIMIT_EXCEEDED",
"message": f"Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary allowed.",
},
)
try:
with get_sync_session() as session:
glossary = Glossary(
user_id=user.id,
name=body.name,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
for term_data in body.terms:
term = GlossaryTerm(
glossary=glossary,
source=term_data.source,
target=term_data.target,
created_at=datetime.now(timezone.utc),
)
session.add(term)
session.add(glossary)
session.commit()
session.refresh(glossary)
logger.info(
f"Glossary created: id={glossary.id}, user_id={user.id}, "
f"name={glossary.name}, terms_count={len(body.terms)}"
)
return JSONResponse(
status_code=201,
content={
"data": _format_glossary(glossary),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to create glossary for user {user.id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la création du glossaire.",
},
)
@router.get(
"",
response_model=GlossaryListResponse,
summary="Lister les glossaires",
description="Retourne la liste paginée des glossaires de l'utilisateur.",
)
async def list_glossaries(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(
DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="Items per page"
),
user: ProUser = Depends(require_pro_user),
):
"""List all glossaries for the authenticated Pro user with pagination."""
try:
with get_sync_session() as session:
# Get total count
total_count = (
session.query(Glossary).filter(Glossary.user_id == user.id).count()
)
# Get paginated results with eager loading of terms (fixes N+1)
offset = (page - 1) * per_page
glossaries = (
session.query(Glossary)
.options(joinedload(Glossary.terms))
.filter(Glossary.user_id == user.id)
.order_by(Glossary.created_at.desc())
.offset(offset)
.limit(per_page)
.all()
)
items = [
GlossaryListItem(
id=g.id,
name=g.name,
terms_count=len(g.terms) if g.terms else 0,
created_at=g.created_at,
)
for g in glossaries
]
total_pages = (total_count + per_page - 1) // per_page
return JSONResponse(
status_code=200,
content={
"data": [item.model_dump(mode="json") for item in items],
"meta": {
"total": total_count,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
},
},
)
except Exception as e:
logger.error(f"Failed to list glossaries for user {user.id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la récupération des glossaires.",
},
)
@router.get(
"/{glossary_id}",
response_model=GlossaryDetailResponse,
summary="Détail d'un glossaire",
description="Retourne les détails d'un glossaire avec tous ses termes.",
)
async def get_glossary(
glossary_id: str,
user: ProUser = Depends(require_pro_user),
):
"""Get a specific glossary by ID."""
# Validate UUID format
try:
import uuid
uuid.UUID(glossary_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_GLOSSARY_ID",
"message": "Format d'identifiant de glossaire invalide.",
},
)
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.options(joinedload(Glossary.terms))
.filter(Glossary.id == glossary_id, Glossary.user_id == user.id)
.first()
)
if not glossary:
return JSONResponse(
status_code=404,
content={
"error": "GLOSSARY_NOT_FOUND",
"message": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
},
)
return JSONResponse(
status_code=200,
content={
"data": _format_glossary(glossary),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to get glossary {glossary_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue.",
},
)
@router.patch(
"/{glossary_id}",
response_model=GlossaryDetailResponse,
summary="Mettre à jour un glossaire",
description="Met à jour le nom et/ou les termes d'un glossaire existant.",
)
async def update_glossary(
glossary_id: str,
body: GlossaryUpdate,
user: ProUser = Depends(require_pro_user),
):
"""Update a glossary's name and/or terms."""
# Validate UUID format
try:
import uuid
uuid.UUID(glossary_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_GLOSSARY_ID",
"message": "Format d'identifiant de glossaire invalide.",
},
)
# Validate max terms if provided
if body.terms is not None and len(body.terms) > MAX_TERMS_PER_GLOSSARY:
return JSONResponse(
status_code=400,
content={
"error": "TERMS_LIMIT_EXCEEDED",
"message": f"Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary allowed.",
},
)
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.options(joinedload(Glossary.terms))
.filter(Glossary.id == glossary_id, Glossary.user_id == user.id)
.first()
)
if not glossary:
return JSONResponse(
status_code=404,
content={
"error": "GLOSSARY_NOT_FOUND",
"message": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
},
)
old_name = glossary.name
if body.name is not None:
glossary.name = body.name
if body.terms is not None:
# Delete existing terms
session.query(GlossaryTerm).filter(
GlossaryTerm.glossary_id == glossary.id
).delete()
# Add new terms
for term_data in body.terms:
term = GlossaryTerm(
glossary_id=glossary.id,
source=term_data.source,
target=term_data.target,
created_at=datetime.now(timezone.utc),
)
session.add(term)
glossary.updated_at = datetime.now(timezone.utc)
session.commit()
session.refresh(glossary)
logger.info(
f"Glossary updated: id={glossary.id}, user_id={user.id}, "
f"old_name={old_name}, new_name={glossary.name}"
)
return JSONResponse(
status_code=200,
content={
"data": _format_glossary(glossary),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to update glossary {glossary_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la mise à jour.",
},
)
@router.delete(
"/{glossary_id}",
status_code=204,
summary="Supprimer un glossaire",
description="Supprime un glossaire et tous ses termes associés.",
)
async def delete_glossary(
glossary_id: str,
user: ProUser = Depends(require_pro_user),
):
"""Delete a glossary and all its terms."""
# Validate UUID format
try:
import uuid
uuid.UUID(glossary_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_GLOSSARY_ID",
"message": "Format d'identifiant de glossaire invalide.",
},
)
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.filter(Glossary.id == glossary_id, Glossary.user_id == user.id)
.first()
)
if not glossary:
return JSONResponse(
status_code=404,
content={
"error": "GLOSSARY_NOT_FOUND",
"message": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
},
)
glossary_name = glossary.name
session.delete(glossary)
session.commit()
logger.info(
f"Glossary deleted: id={glossary_id}, user_id={user.id}, "
f"name={glossary_name}"
)
return JSONResponse(
status_code=204,
content=None,
)
except Exception as e:
logger.error(f"Failed to delete glossary {glossary_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la suppression.",
},
)
@router.get(
"/templates/list",
summary="Lister les templates de glossaires",
description="""
Retourne la liste des glossaires pré-définis disponibles (templates).
Ces templates couvrent différents domaines : juridique, technologie, finance, médical, marketing, RH, scientifique, e-commerce.
Utilisez ensuite `POST /glossaries/import` pour importer un template dans votre compte.
""",
)
async def list_glossary_templates():
"""List all available glossary templates."""
try:
index_path = GLOSSARIES_DIR / "index.json"
if not index_path.exists():
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATES_NOT_FOUND",
"message": "Les templates de glossaires ne sont pas disponibles.",
},
)
with open(index_path, "r", encoding="utf-8") as f:
index_data = json.load(f)
templates = []
for category_id, category_data in index_data.get("categories", {}).items():
templates.append({
"id": category_id,
"name": category_data.get("name", category_id),
"description": category_data.get("description", ""),
"source_lang": category_data.get("source_lang", "fr"),
"target_lang": category_data.get("target_lang", "en"),
"terms_count": category_data.get("terms_count", 0),
"file": category_data.get("file", f"{category_id}_fr_en.json"),
})
return JSONResponse(
status_code=200,
content={
"data": templates,
"meta": {
"total": len(templates),
},
},
)
except Exception as e:
logger.error(f"Failed to list glossary templates: {e}")
return JSONResponse(
status_code=500,
content={
"error": "TEMPLATES_ERROR",
"message": "Une erreur est survenue lors de la récupération des templates.",
},
)
@router.post(
"/import",
response_model=GlossaryDetailResponse,
status_code=201,
summary="Importer un template de glossaire",
description="""
Importe un glossaire pré-défini dans votre compte.
**Paramètres:**
- `template_id`: L'identifiant du template (ex: "legal", "tech", "finance", "medical", "marketing", "hr", "scientific", "ecommerce")
- `name` (optionnel): Nom personnalisé pour le glossaire. Si non fourni, le nom du template sera utilisé.
**Exemple:**
```json
{
"template_id": "legal",
"name": "Mon glossaire juridique"
}
```
""",
)
async def import_glossary_template(
template_id: str = Query(..., description="ID du template à importer"),
name: Optional[str] = Query(None, description="Nom personnalisé pour le glossaire"),
user: ProUser = Depends(require_pro_user),
):
"""Import a pre-built glossary template into the user's account."""
try:
index_path = GLOSSARIES_DIR / "index.json"
if not index_path.exists():
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATES_NOT_FOUND",
"message": "Les templates de glossaires ne sont pas disponibles.",
},
)
with open(index_path, "r", encoding="utf-8") as f:
index_data = json.load(f)
categories = index_data.get("categories", {})
if template_id not in categories:
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATE_NOT_FOUND",
"message": f"Template '{template_id}' introuvable. Templates disponibles: {', '.join(categories.keys())}",
},
)
template_info = categories[template_id]
template_file = template_info.get("file", f"{template_id}_fr_en.json")
template_path = GLOSSARIES_DIR / template_file
if not template_path.exists():
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATE_FILE_NOT_FOUND",
"message": f"Le fichier de template '{template_file}' est introuvable.",
},
)
with open(template_path, "r", encoding="utf-8") as f:
template_data = json.load(f)
terms = template_data.get("terms", [])
if len(terms) > MAX_TERMS_PER_GLOSSARY:
return JSONResponse(
status_code=400,
content={
"error": "TERMS_LIMIT_EXCEEDED",
"message": f"Le template contient {len(terms)} termes, ce qui dépasse la limite de {MAX_TERMS_PER_GLOSSARY}.",
},
)
glossary_name = name or template_data.get("name", template_info.get("name", template_id))
with get_sync_session() as session:
glossary = Glossary(
user_id=user.id,
name=glossary_name,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
for term_data in terms:
term = GlossaryTerm(
glossary=glossary,
source=term_data.get("source", ""),
target=term_data.get("target", ""),
created_at=datetime.now(timezone.utc),
)
session.add(term)
session.add(glossary)
session.commit()
session.refresh(glossary)
logger.info(
f"Glossary template imported: id={glossary.id}, user_id={user.id}, "
f"template={template_id}, name={glossary_name}, terms_count={len(terms)}"
)
return JSONResponse(
status_code=201,
content={
"data": _format_glossary(glossary),
"meta": {
"template_id": template_id,
"imported_terms": len(terms),
},
},
)
except Exception as e:
logger.error(f"Failed to import glossary template {template_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "IMPORT_ERROR",
"message": "Une erreur est survenue lors de l'import du template.",
},
)

661
routes/legacy_routes.py Normal file
View File

@@ -0,0 +1,661 @@
"""
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
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from config import config
from utils import file_handler
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():
"""
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).
"""
from routes.admin_routes import load_settings
settings = load_settings()
is_dev = os.getenv("APP_ENV", "production").lower() == "development" or \
os.getenv("SHOW_OLLAMA", "false").lower() == "true"
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",
})
# DeepL — if configured
if _is_enabled("deepl", key_var="DEEPL_API_KEY"):
available.append({
"id": "deepl",
"label": "DeepL",
"description": "Traduction professionnelle haute qualité (langues européennes)",
"mode": "classic",
"tier": "pro",
})
# AI Essentielle (OpenRouter — cheap model)
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",
"deepseek/deepseek-v3.2",
)
available.append({
"id": "openrouter",
"label": "Traduction IA Essentielle",
"description": "IA rapide et économique — idéale pour documents techniques",
"mode": "llm",
"tier": "pro",
"model": model,
})
# AI Premium (OpenRouter — premium model)
if _is_enabled("openrouter_premium", 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-3.5-haiku",
)
available.append({
"id": "openrouter_premium",
"label": "Traduction IA Premium",
"description": "IA haute précision (GPT-4, Claude) — meilleure qualité littéraire",
"mode": "llm",
"tier": "business",
"model": model,
})
# OpenAI direct — if configured
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,
})
# z.AI / xAI Grok — if configured
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,
})
# Ollama — dev only
if is_dev and _is_enabled("ollama", url_var="OLLAMA_BASE_URL"):
oll_cfg = getattr(settings, "ollama", None)
model = _resolve_model(
oll_cfg.model if oll_cfg else None,
"OLLAMA_MODEL",
"llama3",
)
available.append({
"id": "ollama",
"label": "Ollama (Local)",
"description": "Modèle LLM local — développement uniquement",
"mode": "llm",
"tier": "dev",
"model": model,
})
return {"providers": available}
@router.get("/languages")
async def get_supported_languages():
"""Get list of supported language codes, ordered by internet popularity"""
return {
"supported_languages": {
# Top 5 — dominant on the internet
"en": "English",
"es": "Spanish",
"de": "German",
"fr": "French",
"ja": "Japanese",
# Top 6-15
"pt": "Portuguese",
"ru": "Russian",
"it": "Italian",
"zh-CN": "Chinese (Simplified)",
"zh-TW": "Chinese (Traditional)",
"pl": "Polish",
"nl": "Dutch",
"tr": "Turkish",
"ko": "Korean",
"ar": "Arabic",
# Top 16-25
"fa": "Persian (Farsi)",
"vi": "Vietnamese",
"id": "Indonesian",
"uk": "Ukrainian",
"sv": "Swedish",
"cs": "Czech",
"el": "Greek",
"he": "Hebrew",
"hi": "Hindi",
"ro": "Romanian",
# Others
"da": "Danish",
"fi": "Finnish",
"no": "Norwegian",
"hu": "Hungarian",
"th": "Thai",
"sk": "Slovak",
"bg": "Bulgarian",
"hr": "Croatian",
"ca": "Catalan",
"ms": "Malay",
},
"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"),
):
"""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)
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 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.get("/download/{filename}")
async def download_file(filename: str):
"""Download a translated file by filename"""
file_path = config.OUTPUT_DIR / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream",
)
@router.delete("/cleanup/{filename}")
async def cleanup_translated_file(filename: str):
"""Cleanup a translated file after download"""
try:
file_path = config.OUTPUT_DIR / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
file_handler.cleanup_file(file_path)
return {"message": f"File {filename} deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.exception("Cleanup error")
raise HTTPException(
status_code=500, detail="Erreur lors de la suppression du fichier."
)
@router.post("/extract-texts")
async def extract_texts_from_document(
file: UploadFile = File(..., description="Document file to extract texts from"),
):
"""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)
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 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"
),
target_language: str = Form(..., description="Target language code"),
):
"""Reconstruct a document with translated texts"""
import json
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"])
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("/ollama/models")
async def list_ollama_models(base_url: Optional[str] = None):
"""List available Ollama models"""
from services.translation_service import OllamaTranslationProvider
url = base_url or config.OLLAMA_BASE_URL
models = OllamaTranslationProvider.list_models(url)
return {"ollama_url": url, "models": models, "count": len(models)}
@router.post("/ollama/configure")
async def configure_ollama(base_url: str = Form(...), model: str = Form(...)):
"""Configure Ollama settings"""
config.OLLAMA_BASE_URL = base_url
config.OLLAMA_MODEL = model
return {
"status": "success",
"message": "Ollama configuration updated",
"ollama_url": base_url,
"model": model,
}
@router.get("/metrics")
async def get_metrics():
"""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,
}

377
routes/prompt_routes.py Normal file
View File

@@ -0,0 +1,377 @@
"""
Custom Prompt CRUD routes for Pro users
Story 3.11: Custom Prompts - Endpoint CRUD
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi.responses import JSONResponse
from routes.deps import require_pro_user, ProUser
from database.connection import get_sync_session
from database.models import CustomPrompt
from schemas.prompt_schemas import (
PromptCreate,
PromptUpdate,
PromptResponse,
PromptListItem,
PromptListResponse,
PromptDetailResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/prompts", tags=["Prompts v1"])
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 100
def _validate_uuid(prompt_id: str) -> tuple[bool, dict | None]:
"""Validate UUID format. Returns (is_valid, error_response)."""
import uuid as uuid_lib
try:
uuid_lib.UUID(prompt_id)
return True, None
except ValueError:
return False, {
"error": "INVALID_PROMPT_ID",
"message": "Format d'identifiant de prompt invalide.",
}
def _format_prompt(prompt: CustomPrompt) -> dict:
"""Format a CustomPrompt for JSON response."""
return {
"id": prompt.id,
"name": prompt.name,
"content": prompt.content,
"created_at": prompt.created_at.isoformat() if prompt.created_at else None,
"updated_at": prompt.updated_at.isoformat() if prompt.updated_at else None,
}
def _format_prompt_list_item(prompt: CustomPrompt) -> dict:
"""Format a CustomPrompt for list view with content preview."""
content_preview = prompt.content[:100] if prompt.content else ""
return {
"id": prompt.id,
"name": prompt.name,
"content_preview": content_preview,
"created_at": prompt.created_at.isoformat() if prompt.created_at else None,
"updated_at": prompt.updated_at.isoformat() if prompt.updated_at else None,
}
@router.post(
"",
response_model=PromptDetailResponse,
status_code=201,
summary="Créer un prompt",
description="""
Crée un nouveau prompt système personnalisé.
**Restriction:** Uniquement disponible pour les utilisateurs Pro.
**Exemple de requête:**
```json
{
"name": "Prompt Technique FR-EN",
"content": "Tu es un traducteur technique expert. Traduis en préservant la terminologie technique..."
}
```
""",
)
async def create_prompt(
body: PromptCreate,
user: ProUser = Depends(require_pro_user),
):
"""Create a new prompt for the authenticated Pro user."""
try:
with get_sync_session() as session:
prompt = CustomPrompt(
user_id=user.id,
name=body.name,
content=body.content,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
session.add(prompt)
session.commit()
session.refresh(prompt)
logger.info(
f"Prompt created: id={prompt.id}, user_id={user.id}, name={prompt.name}"
)
return JSONResponse(
status_code=201,
content={
"data": _format_prompt(prompt),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to create prompt for user {user.id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la création du prompt.",
},
)
@router.get(
"",
response_model=PromptListResponse,
summary="Lister les prompts",
description="Retourne la liste paginée des prompts de l'utilisateur.",
)
async def list_prompts(
page: int = Query(1, ge=1, description="Numéro de page"),
per_page: int = Query(
DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="Éléments par page"
),
user: ProUser = Depends(require_pro_user),
):
"""List all prompts for the authenticated Pro user with pagination."""
try:
with get_sync_session() as session:
total_count = (
session.query(CustomPrompt)
.filter(CustomPrompt.user_id == user.id)
.count()
)
offset = (page - 1) * per_page
prompts = (
session.query(CustomPrompt)
.filter(CustomPrompt.user_id == user.id)
.order_by(CustomPrompt.created_at.desc())
.offset(offset)
.limit(per_page)
.all()
)
items = [
PromptListItem(
id=p.id,
name=p.name,
content_preview=p.content[:100] if p.content else "",
created_at=p.created_at,
updated_at=p.updated_at,
)
for p in prompts
]
total_pages = (total_count + per_page - 1) // per_page
return JSONResponse(
status_code=200,
content={
"data": [item.model_dump(mode="json") for item in items],
"meta": {
"total": total_count,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
},
},
)
except Exception as e:
logger.error(f"Failed to list prompts for user {user.id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la récupération des prompts.",
},
)
@router.get(
"/{prompt_id}",
response_model=PromptDetailResponse,
summary="Détail d'un prompt",
description="Retourne les détails d'un prompt spécifique.",
)
async def get_prompt(
prompt_id: str,
user: ProUser = Depends(require_pro_user),
):
"""Get a specific prompt by ID."""
is_valid, error = _validate_uuid(prompt_id)
if not is_valid:
return JSONResponse(status_code=400, content=error)
try:
with get_sync_session() as session:
prompt = (
session.query(CustomPrompt)
.filter(CustomPrompt.id == prompt_id, CustomPrompt.user_id == user.id)
.first()
)
if not prompt:
return JSONResponse(
status_code=404,
content={
"error": "PROMPT_NOT_FOUND",
"message": "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
},
)
return JSONResponse(
status_code=200,
content={
"data": _format_prompt(prompt),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to get prompt {prompt_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue.",
},
)
@router.patch(
"/{prompt_id}",
response_model=PromptDetailResponse,
summary="Mettre à jour un prompt",
description="Met à jour le nom et/ou le contenu d'un prompt existant.",
)
async def update_prompt(
prompt_id: str,
body: PromptUpdate,
user: ProUser = Depends(require_pro_user),
):
"""Update a prompt's name and/or content."""
if not body.has_updates():
return JSONResponse(
status_code=400,
content={
"error": "NO_UPDATE_FIELDS",
"message": "Au moins un champ (name ou content) doit être fourni.",
},
)
is_valid, error = _validate_uuid(prompt_id)
if not is_valid:
return JSONResponse(status_code=400, content=error)
try:
with get_sync_session() as session:
prompt = (
session.query(CustomPrompt)
.filter(CustomPrompt.id == prompt_id, CustomPrompt.user_id == user.id)
.first()
)
if not prompt:
return JSONResponse(
status_code=404,
content={
"error": "PROMPT_NOT_FOUND",
"message": "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
},
)
old_name = prompt.name
if body.name is not None:
prompt.name = body.name
if body.content is not None:
prompt.content = body.content
prompt.updated_at = datetime.now(timezone.utc)
session.commit()
session.refresh(prompt)
logger.info(
f"Prompt updated: id={prompt.id}, user_id={user.id}, "
f"old_name={old_name}, new_name={prompt.name}"
)
return JSONResponse(
status_code=200,
content={
"data": _format_prompt(prompt),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to update prompt {prompt_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la mise à jour.",
},
)
@router.delete(
"/{prompt_id}",
status_code=204,
summary="Supprimer un prompt",
description="Supprime un prompt.",
)
async def delete_prompt(
prompt_id: str,
user: ProUser = Depends(require_pro_user),
):
"""Delete a prompt."""
is_valid, error = _validate_uuid(prompt_id)
if not is_valid:
return JSONResponse(status_code=400, content=error)
try:
with get_sync_session() as session:
prompt = (
session.query(CustomPrompt)
.filter(CustomPrompt.id == prompt_id, CustomPrompt.user_id == user.id)
.first()
)
if not prompt:
return JSONResponse(
status_code=404,
content={
"error": "PROMPT_NOT_FOUND",
"message": "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
},
)
prompt_name = prompt.name
session.delete(prompt)
session.commit()
logger.info(
f"Prompt deleted: id={prompt_id}, user_id={user.id}, name={prompt_name}"
)
return JSONResponse(
status_code=204,
content=None,
)
except Exception as e:
logger.error(f"Failed to delete prompt {prompt_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "Une erreur est survenue lors de la suppression.",
},
)

1314
routes/translate_routes.py Normal file

File diff suppressed because it is too large Load Diff