978 lines
35 KiB
Python
978 lines
35 KiB
Python
"""
|
|
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)},
|
|
)
|