feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
977
routes/admin_routes.py
Normal file
977
routes/admin_routes.py
Normal 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
330
routes/api_key_routes.py
Normal 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
25
routes/api_v1_router.py
Normal 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"])
|
||||
@@ -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
106
routes/deps.py
Normal 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
625
routes/glossary_routes.py
Normal 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
661
routes/legacy_routes.py
Normal 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
377
routes/prompt_routes.py
Normal 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
1314
routes/translate_routes.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user