feat: homelab deployment - NPM + IONOS DNS + monitoring + NAS backup
- Restructured docker-compose for Nginx Proxy Manager (no custom nginx) - Added domain wordly.art configuration - Added Prometheus + Grafana monitoring stack with pre-configured dashboards - Added PostgreSQL backup script to NAS (daily/weekly/monthly rotation) - Added alert rules for backend, system, and Docker metrics - Updated deployment guide for NPM + IONOS DNS homelab setup - Added marketing plan document - PDF translator and watermark support - Enhanced middleware, routes, and translator modules Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -592,6 +592,34 @@ async def get_tracked_files(is_admin: bool = Depends(require_admin)):
|
||||
return {"count": len(tracked), "files": tracked}
|
||||
|
||||
|
||||
@router.post("/quota/reset")
|
||||
async def reset_translation_quotas(is_admin: bool = Depends(require_admin)):
|
||||
"""Reset monthly translation quotas for all free-tier users.
|
||||
Clears Redis keys matching quota:monthly:*
|
||||
"""
|
||||
try:
|
||||
from core.redis import get_async_redis
|
||||
redis_client = get_async_redis()
|
||||
if not redis_client:
|
||||
return {"status": "skipped", "message": "Redis not available, quotas are in-memory only"}
|
||||
|
||||
# Find all monthly quota keys
|
||||
keys = []
|
||||
async for key in redis_client.scan_iter(match="quota:monthly:*"):
|
||||
keys.append(key)
|
||||
|
||||
if keys:
|
||||
deleted = await redis_client.delete(*keys)
|
||||
logger.info("admin_quota_reset", keys_deleted=deleted)
|
||||
return {"status": "success", "keys_deleted": deleted, "message": f"Reset {deleted} quota counters"}
|
||||
else:
|
||||
return {"status": "success", "keys_deleted": 0, "message": "No active quotas to reset"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Admin quota reset failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Quota reset failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/config/provider")
|
||||
async def update_default_provider(
|
||||
provider: str = Form(...),
|
||||
|
||||
@@ -11,17 +11,15 @@ from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
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 routes.deps import ProUser, require_pro_user
|
||||
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
|
||||
|
||||
@@ -38,95 +36,6 @@ class ApiKeyResponse(BaseModel):
|
||||
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.
|
||||
@@ -146,7 +55,7 @@ def _generate_api_key() -> tuple[str, str, str]:
|
||||
async def create_api_key(
|
||||
request: Request,
|
||||
body: Optional[ApiKeyCreateRequest] = None,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
user: ProUser = Depends(require_pro_user),
|
||||
):
|
||||
"""
|
||||
Create a new API key for the authenticated Pro user.
|
||||
@@ -213,7 +122,7 @@ async def create_api_key(
|
||||
@router.get("")
|
||||
async def list_api_keys(
|
||||
request: Request,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
user: ProUser = Depends(require_pro_user),
|
||||
):
|
||||
"""
|
||||
List all API keys for the authenticated Pro user.
|
||||
@@ -260,7 +169,7 @@ async def list_api_keys(
|
||||
@router.delete("/{key_id}")
|
||||
async def revoke_api_key(
|
||||
key_id: str,
|
||||
user: ProUser = Depends(_require_pro_user),
|
||||
user: ProUser = Depends(require_pro_user),
|
||||
):
|
||||
"""
|
||||
Revoke an API key for the authenticated Pro user.
|
||||
|
||||
@@ -161,7 +161,7 @@ Créer un nouveau compte utilisateur.
|
||||
- Le `tier` par défaut est "free"
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `INVALID_EMAIL`: Format d'email invalide
|
||||
- `INVALID_EMAIL`: Invalid email format
|
||||
- `EMAIL_EXISTS`: Un compte existe déjà avec cet email
|
||||
- `INVALID_REQUEST`: Données d'inscription invalides
|
||||
""",
|
||||
@@ -187,17 +187,17 @@ Créer un nouveau compte utilisateur.
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"INVALID_EMAIL": {
|
||||
"summary": "Format d'email invalide",
|
||||
"summary": "Invalid email format",
|
||||
"value": {
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
},
|
||||
"EMAIL_EXISTS": {
|
||||
"summary": "Email déjà utilisé",
|
||||
"summary": "Email already used",
|
||||
"value": {
|
||||
"error": "EMAIL_EXISTS",
|
||||
"message": "Un compte existe déjà avec cette adresse email",
|
||||
"message": "An account already exists with this email address",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -215,7 +215,7 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -227,7 +227,7 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
)
|
||||
# Check if it's a password validation error
|
||||
@@ -241,7 +241,7 @@ async def register_v1(request: Request):
|
||||
# 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"
|
||||
msg = "Password must be at least 8 characters long"
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
@@ -253,7 +253,7 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Données d'inscription invalides",
|
||||
"message": "Invalid registration data",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -266,7 +266,7 @@ async def register_v1(request: Request):
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "AUTH_HASHING_UNAVAILABLE",
|
||||
"message": "Service d'inscription temporairement indisponible",
|
||||
"message": "Registration service temporarily unavailable",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -279,14 +279,14 @@ async def register_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "EMAIL_EXISTS",
|
||||
"message": "Un compte existe déjà avec cette adresse email",
|
||||
"message": "An account already exists with this email address",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "REGISTRATION_FAILED",
|
||||
"message": "Impossible de créer le compte avec les données fournies",
|
||||
"message": "Unable to create account with the provided data",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -318,7 +318,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
- HTTP 200 avec message de confirmation
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `TOKEN_MISSING`: Token d'authentification manquant
|
||||
- `TOKEN_MISSING`: Authentication token missing
|
||||
- `TOKEN_INVALID`: Token invalide ou expiré
|
||||
""",
|
||||
responses={
|
||||
@@ -326,7 +326,7 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
"description": "Déconnexion réussie",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"example": {"data": {"message": "Déconnexion réussie"}, "meta": {}}
|
||||
"example": {"data": {"message": "Logged out successfully"}, "meta": {}}
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -336,17 +336,17 @@ Déconnecte l'utilisateur en révoquant son token d'accès.
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"TOKEN_MISSING": {
|
||||
"summary": "Token manquant",
|
||||
"summary": "Missing token",
|
||||
"value": {
|
||||
"error": "TOKEN_MISSING",
|
||||
"message": "Token d'authentification requis",
|
||||
"message": "Authentication token required",
|
||||
},
|
||||
},
|
||||
"TOKEN_INVALID": {
|
||||
"summary": "Token invalide",
|
||||
"summary": "Invalid token",
|
||||
"value": {
|
||||
"error": "TOKEN_INVALID",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -363,7 +363,7 @@ async def logout_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_MISSING",
|
||||
"message": "Token d'authentification requis",
|
||||
"message": "Authentication token required",
|
||||
},
|
||||
)
|
||||
access_token = auth_header[7:]
|
||||
@@ -374,7 +374,7 @@ async def logout_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_INVALID",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -397,7 +397,7 @@ async def logout_v1(request: Request):
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"message": "Déconnexion réussie"}, "meta": {}},
|
||||
content={"data": {"message": "Logged out successfully"}, "meta": {}},
|
||||
)
|
||||
|
||||
|
||||
@@ -416,7 +416,7 @@ Authentifie un utilisateur et retourne les tokens JWT.
|
||||
|
||||
**Codes d'erreur:**
|
||||
- `INVALID_REQUEST`: Corps de requête JSON invalide
|
||||
- `INVALID_EMAIL`: Format d'email invalide
|
||||
- `INVALID_EMAIL`: Invalid email format
|
||||
- `INVALID_CREDENTIALS`: Email ou mot de passe incorrect
|
||||
""",
|
||||
responses={
|
||||
@@ -441,17 +441,17 @@ Authentifie un utilisateur et retourne les tokens JWT.
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"INVALID_REQUEST": {
|
||||
"summary": "Corps invalide",
|
||||
"summary": "Invalid body",
|
||||
"value": {
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
},
|
||||
"INVALID_EMAIL": {
|
||||
"summary": "Email invalide",
|
||||
"summary": "Invalid email",
|
||||
"value": {
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -464,7 +464,7 @@ Authentifie un utilisateur et retourne les tokens JWT.
|
||||
"application/json": {
|
||||
"example": {
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
"message": "Incorrect email or password",
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -480,7 +480,7 @@ async def login_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -492,14 +492,14 @@ async def login_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_EMAIL",
|
||||
"message": "Format d'email invalide",
|
||||
"message": "Invalid email format",
|
||||
},
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Données d'inscription invalides",
|
||||
"message": "Invalid registration data",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -511,7 +511,7 @@ async def login_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
"message": "Incorrect email or password",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -520,7 +520,7 @@ async def login_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "INVALID_CREDENTIALS",
|
||||
"message": "Email ou mot de passe incorrect",
|
||||
"message": "Incorrect email or password",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -552,7 +552,7 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
||||
if not body.credential and not body.access_token:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "MISSING_TOKEN", "message": "credential ou access_token requis."},
|
||||
content={"error": "MISSING_TOKEN", "message": "credential or access_token required."},
|
||||
)
|
||||
|
||||
google_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
@@ -574,13 +574,13 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "GOOGLE_UNREACHABLE", "message": "Impossible de contacter les serveurs Google."},
|
||||
content={"error": "GOOGLE_UNREACHABLE", "message": "Unable to contact Google servers."},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Token Google invalide ou expiré."},
|
||||
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Invalid or expired Google token."},
|
||||
)
|
||||
|
||||
google_data = resp.json()
|
||||
@@ -589,14 +589,14 @@ async def google_auth_v1(body: GoogleAuthRequest):
|
||||
if body.credential and google_client_id and google_data.get("aud") != google_client_id:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Token Google non destiné à cette application."},
|
||||
content={"error": "INVALID_TOKEN_AUDIENCE", "message": "Google token not intended for this application."},
|
||||
)
|
||||
|
||||
email = google_data.get("email")
|
||||
if not email:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "MISSING_EMAIL", "message": "Email non fourni par Google."},
|
||||
content={"error": "MISSING_EMAIL", "message": "Email not provided by Google."},
|
||||
)
|
||||
|
||||
name = google_data.get("name") or google_data.get("given_name") or email.split("@")[0]
|
||||
@@ -636,7 +636,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -645,7 +645,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -659,7 +659,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Refresh token requis",
|
||||
"message": "Refresh token required",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -669,7 +669,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -678,7 +678,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -688,7 +688,7 @@ async def refresh_v1(request: Request):
|
||||
status_code=401,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token invalide ou expiré",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -893,7 +893,7 @@ async def get_billing_portal_v1(user=Depends(require_user)):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "BILLING_UNAVAILABLE",
|
||||
"message": "Portail de facturation non disponible",
|
||||
"message": "Billing portal unavailable",
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": {"url": url}, "meta": {}})
|
||||
@@ -928,7 +928,7 @@ async def forgot_password(request: Request):
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "EMAIL_SERVICE_UNAVAILABLE",
|
||||
"message": "Service email non configure",
|
||||
"message": "Email service not configured",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -939,7 +939,7 @@ async def forgot_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -949,7 +949,7 @@ async def forgot_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Email requis",
|
||||
"message": "Email required",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1015,7 +1015,7 @@ async def forgot_password(request: Request):
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"message": "Si un compte existe avec cette adresse, un email de reinitialisation a ete envoye."
|
||||
"message": "If an account exists with this email, a reset email has been sent."
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
@@ -1041,7 +1041,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Corps de requete JSON invalide",
|
||||
"message": "Invalid JSON request body",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1053,7 +1053,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_REQUEST",
|
||||
"message": "Token et mot de passe requis",
|
||||
"message": "Token and password required",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1063,7 +1063,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "WEAK_PASSWORD",
|
||||
"message": "Le mot de passe doit contenir au moins 8 caracteres, une majuscule, une minuscule et un chiffre",
|
||||
"message": "Password must be at least 8 characters with at least one uppercase letter, one lowercase letter, and one digit",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1085,7 +1085,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_TOKEN",
|
||||
"message": "Token invalide ou expire",
|
||||
"message": "Invalid or expired token",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1099,7 +1099,7 @@ async def reset_password(request: Request):
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "TOKEN_EXPIRED",
|
||||
"message": "Token expire, veuillez redemander une reinitialisation",
|
||||
"message": "Token expired, please request a new reset",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1116,14 +1116,14 @@ async def reset_password(request: Request):
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "RESET_FAILED",
|
||||
"message": "Erreur lors de la reinitialisation",
|
||||
"message": "Error during password reset",
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {"message": "Mot de passe reinitialise avec succes"},
|
||||
"data": {"message": "Password reset successfully"},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -138,7 +138,7 @@ async def create_glossary(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la création du glossaire.",
|
||||
"message": "An error occurred while creating the glossary.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ async def list_glossaries(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la récupération des glossaires.",
|
||||
"message": "An error occurred while retrieving glossaries.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -232,7 +232,7 @@ async def get_glossary(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_GLOSSARY_ID",
|
||||
"message": "Format d'identifiant de glossaire invalide.",
|
||||
"message": "Invalid glossary ID format.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -250,7 +250,7 @@ async def get_glossary(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "GLOSSARY_NOT_FOUND",
|
||||
"message": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
"message": "Glossary not found or you do not have access to this resource.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -267,7 +267,7 @@ async def get_glossary(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue.",
|
||||
"message": "An error occurred.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -294,7 +294,7 @@ async def update_glossary(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_GLOSSARY_ID",
|
||||
"message": "Format d'identifiant de glossaire invalide.",
|
||||
"message": "Invalid glossary ID format.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -322,7 +322,7 @@ async def update_glossary(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "GLOSSARY_NOT_FOUND",
|
||||
"message": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
"message": "Glossary not found or you do not have access to this resource.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -369,7 +369,7 @@ async def update_glossary(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la mise à jour.",
|
||||
"message": "An error occurred while updating.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -395,7 +395,7 @@ async def delete_glossary(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_GLOSSARY_ID",
|
||||
"message": "Format d'identifiant de glossaire invalide.",
|
||||
"message": "Invalid glossary ID format.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -412,7 +412,7 @@ async def delete_glossary(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "GLOSSARY_NOT_FOUND",
|
||||
"message": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
"message": "Glossary not found or you do not have access to this resource.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -435,7 +435,7 @@ async def delete_glossary(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la suppression.",
|
||||
"message": "An error occurred while deleting.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -460,7 +460,7 @@ async def list_glossary_templates():
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "TEMPLATES_NOT_FOUND",
|
||||
"message": "Les templates de glossaires ne sont pas disponibles.",
|
||||
"message": "Glossary templates are not available.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -494,7 +494,7 @@ async def list_glossary_templates():
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "TEMPLATES_ERROR",
|
||||
"message": "Une erreur est survenue lors de la récupération des templates.",
|
||||
"message": "An error occurred while retrieving templates.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -533,7 +533,7 @@ async def import_glossary_template(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "TEMPLATES_NOT_FOUND",
|
||||
"message": "Les templates de glossaires ne sont pas disponibles.",
|
||||
"message": "Glossary templates are not available.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -546,7 +546,7 @@ async def import_glossary_template(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "TEMPLATE_NOT_FOUND",
|
||||
"message": f"Template '{template_id}' introuvable. Templates disponibles: {', '.join(categories.keys())}",
|
||||
"message": f"Template '{template_id}' not found. Available templates: {', '.join(categories.keys())}",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -559,7 +559,7 @@ async def import_glossary_template(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "TEMPLATE_FILE_NOT_FOUND",
|
||||
"message": f"Le fichier de template '{template_file}' est introuvable.",
|
||||
"message": f"Template file '{template_file}' not found.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -572,7 +572,7 @@ async def import_glossary_template(
|
||||
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}.",
|
||||
"message": f"The template contains {len(terms)} terms, exceeding the limit of {MAX_TERMS_PER_GLOSSARY}.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -620,6 +620,6 @@ async def import_glossary_template(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "IMPORT_ERROR",
|
||||
"message": "Une erreur est survenue lors de l'import du template.",
|
||||
"message": "An error occurred while importing the template.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,13 +7,14 @@ Story 3.5: API Versioning
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Request
|
||||
from fastapi import APIRouter, File, Form, UploadFile, HTTPException, Request, Depends
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
|
||||
from config import config
|
||||
from utils import file_handler
|
||||
from middleware.api_key_auth import get_authenticated_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -249,6 +250,7 @@ async def translate_batch_documents(
|
||||
),
|
||||
target_language: str = Form(..., description="Target language code"),
|
||||
source_language: str = Form(default="auto", description="Source language code"),
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""Translate multiple documents in batch"""
|
||||
from translators import excel_translator, word_translator, pptx_translator
|
||||
@@ -354,6 +356,7 @@ async def cleanup_translated_file(filename: str):
|
||||
@router.post("/extract-texts")
|
||||
async def extract_texts_from_document(
|
||||
file: UploadFile = File(..., description="Document file to extract texts from"),
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""Extract all translatable texts from a document for client-side translation"""
|
||||
import uuid
|
||||
@@ -470,7 +473,7 @@ async def reconstruct_document(
|
||||
translations: str = Form(
|
||||
..., description="JSON array of {id, translated_text} objects"
|
||||
),
|
||||
target_language: str = Form(..., description="Target language code"),
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""Reconstruct a document with translated texts. session_id must be a valid UUID."""
|
||||
import json
|
||||
@@ -625,7 +628,9 @@ async def configure_ollama(base_url: str = Form(...), model: str = Form(...)):
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def get_metrics():
|
||||
async def get_metrics(
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""Get system metrics and statistics for monitoring"""
|
||||
from middleware.cleanup import create_cleanup_manager
|
||||
from middleware.rate_limiting import RateLimitManager, RateLimitConfig
|
||||
|
||||
@@ -40,7 +40,7 @@ def _validate_uuid(prompt_id: str) -> tuple[bool, dict | None]:
|
||||
except ValueError:
|
||||
return False, {
|
||||
"error": "INVALID_PROMPT_ID",
|
||||
"message": "Format d'identifiant de prompt invalide.",
|
||||
"message": "Invalid prompt ID format.",
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ async def create_prompt(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la création du prompt.",
|
||||
"message": "An error occurred while creating the prompt.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -190,7 +190,7 @@ async def list_prompts(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la récupération des prompts.",
|
||||
"message": "An error occurred while retrieving prompts.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -223,7 +223,7 @@ async def get_prompt(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "PROMPT_NOT_FOUND",
|
||||
"message": "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
"message": "Prompt not found or you do not have access to this resource.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -240,7 +240,7 @@ async def get_prompt(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue.",
|
||||
"message": "An error occurred.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -262,7 +262,7 @@ async def update_prompt(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "NO_UPDATE_FIELDS",
|
||||
"message": "Au moins un champ (name ou content) doit être fourni.",
|
||||
"message": "At least one field (name or content) must be provided.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -283,7 +283,7 @@ async def update_prompt(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "PROMPT_NOT_FOUND",
|
||||
"message": "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
"message": "Prompt not found or you do not have access to this resource.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -317,7 +317,7 @@ async def update_prompt(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la mise à jour.",
|
||||
"message": "An error occurred while updating.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -350,7 +350,7 @@ async def delete_prompt(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "PROMPT_NOT_FOUND",
|
||||
"message": "Prompt introuvable ou vous n'avez pas accès à cette ressource.",
|
||||
"message": "Prompt not found or you do not have access to this resource.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -372,6 +372,6 @@ async def delete_prompt(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "DATABASE_ERROR",
|
||||
"message": "Une erreur est survenue lors de la suppression.",
|
||||
"message": "An error occurred while deleting.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -72,10 +72,11 @@ logger = logging.getLogger(__name__)
|
||||
router_v1 = APIRouter(prefix="/api/v1", tags=["Translation v1"])
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
MAX_FILE_SIZE_MB = 50
|
||||
# Reference config for file constraints (avoids duplicating values)
|
||||
MAX_FILE_SIZE_MB = config.MAX_FILE_SIZE_MB
|
||||
OFFICE_MAGIC_BYTES = b"PK\x03\x04"
|
||||
ACCEPTED_EXTENSIONS = {".xlsx", ".docx", ".pptx"}
|
||||
PDF_MAGIC_BYTES = b"%PDF"
|
||||
ACCEPTED_EXTENSIONS = config.SUPPORTED_EXTENSIONS
|
||||
|
||||
|
||||
class TranslateEndpointError(Exception):
|
||||
@@ -92,22 +93,22 @@ class TranslateEndpointError(Exception):
|
||||
PRO_FEATURE_REQUIRED = "PRO_FEATURE_REQUIRED"
|
||||
|
||||
ERROR_MESSAGES = {
|
||||
INVALID_FORMAT: "Format de fichier non supporte. Formats acceptes : .xlsx, .docx, .pptx",
|
||||
CORRUPTED_FILE: "Le fichier semble corrompu ou n'est pas un document Office valide.",
|
||||
FILE_TOO_LARGE: f"Le fichier est trop volumineux (max {MAX_FILE_SIZE_MB} Mo).",
|
||||
QUOTA_EXCEEDED: "Limite quotidienne atteinte.",
|
||||
URL_DOWNLOAD_FAILED: "Impossible de telecharger le fichier depuis l'URL.",
|
||||
URL_UNREACHABLE: "URL inaccessible.",
|
||||
UNAUTHORIZED: "Authentification requise.",
|
||||
MISSING_FILE: "Fichier ou URL requis.",
|
||||
PRO_FEATURE_REQUIRED: "Cette fonctionnalite necessite un abonnement Pro.",
|
||||
INVALID_FORMAT: "Unsupported file format. Accepted formats: .xlsx, .docx, .pptx",
|
||||
CORRUPTED_FILE: "The file appears corrupted or is not a valid Office document.",
|
||||
FILE_TOO_LARGE: f"File is too large (max {MAX_FILE_SIZE_MB} MB).",
|
||||
QUOTA_EXCEEDED: "Monthly translation limit reached.",
|
||||
URL_DOWNLOAD_FAILED: "Failed to download file from URL.",
|
||||
URL_UNREACHABLE: "URL unreachable.",
|
||||
UNAUTHORIZED: "Authentication required.",
|
||||
MISSING_FILE: "File or URL required.",
|
||||
PRO_FEATURE_REQUIRED: "This feature requires a Pro subscription.",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self, code: str, message: Optional[str] = None, details: Optional[dict] = None
|
||||
):
|
||||
self.code = code
|
||||
self.message = message or self.ERROR_MESSAGES.get(code, "Erreur inconnue")
|
||||
self.message = message or self.ERROR_MESSAGES.get(code, "Unknown error")
|
||||
self.details = details or {}
|
||||
super().__init__(self.message)
|
||||
|
||||
@@ -159,18 +160,29 @@ async def validate_file_content(content: bytes, extension: str) -> None:
|
||||
if len(content) < 4:
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.CORRUPTED_FILE,
|
||||
message="Le fichier est trop petit pour etre un document Office valide.",
|
||||
message="File is too small to be a valid document.",
|
||||
details={"reason": "File is too small"},
|
||||
)
|
||||
|
||||
header = content[:4]
|
||||
if header != OFFICE_MAGIC_BYTES:
|
||||
header = content[:5]
|
||||
# PDF files start with %PDF
|
||||
if extension.lower() == ".pdf":
|
||||
if not header[:4] == PDF_MAGIC_BYTES:
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.CORRUPTED_FILE,
|
||||
message="File is not a valid PDF.",
|
||||
details={"reason": "Invalid PDF header"},
|
||||
)
|
||||
return
|
||||
|
||||
# Office files (xlsx, docx, pptx) are ZIP archives
|
||||
if header[:4] != OFFICE_MAGIC_BYTES:
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.CORRUPTED_FILE,
|
||||
message="Le fichier n'est pas un document Office valide ou est corrompu.",
|
||||
message="File is not a valid Office document.",
|
||||
details={
|
||||
"accepted_formats": list(ACCEPTED_EXTENSIONS),
|
||||
"hint": "Les fichiers .xlsx, .docx, .pptx doivent etre des archives ZIP valides.",
|
||||
"hint": "Office files (.xlsx, .docx, .pptx) must be valid ZIP archives.",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -227,7 +239,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
if parsed_url.scheme not in ("http", "https"):
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.URL_UNREACHABLE,
|
||||
message="Seules les URLs HTTP/HTTPS sont acceptees.",
|
||||
message="Only HTTP/HTTPS URLs are accepted.",
|
||||
details={"scheme": parsed_url.scheme or "none"},
|
||||
)
|
||||
|
||||
@@ -235,7 +247,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
if not hostname or _is_ssrf_risk(hostname):
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.URL_UNREACHABLE,
|
||||
message="L'URL pointe vers une adresse interdite (adresse privee ou interne).",
|
||||
message="The URL points to a blocked address (private or internal network).",
|
||||
details={"reason": "ssrf_blocked"},
|
||||
)
|
||||
|
||||
@@ -247,7 +259,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
if response.status_code != 200:
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.URL_UNREACHABLE,
|
||||
message=f"URL inaccessible (HTTP {response.status_code})",
|
||||
message=f"URL unreachable (HTTP {response.status_code})",
|
||||
details={"status_code": response.status_code, "url": url[:100]},
|
||||
)
|
||||
|
||||
@@ -259,7 +271,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
if file_size > max_size_bytes:
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.FILE_TOO_LARGE,
|
||||
message=f"Le fichier est trop volumineux ({round(file_size / (1024 * 1024), 2)} Mo, max {MAX_FILE_SIZE_MB} Mo).",
|
||||
message=f"File is too large ({round(file_size / (1024 * 1024), 2)} MB, max {MAX_FILE_SIZE_MB} MB).",
|
||||
details={
|
||||
"size_mb": round(file_size / (1024 * 1024), 2),
|
||||
"max_mb": MAX_FILE_SIZE_MB,
|
||||
@@ -326,7 +338,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
temp_path.unlink()
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.URL_UNREACHABLE,
|
||||
message="Timeout lors du telechargement.",
|
||||
message="Download timed out.",
|
||||
details={"timeout_seconds": timeout},
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
@@ -334,7 +346,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
temp_path.unlink()
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.URL_DOWNLOAD_FAILED,
|
||||
message=f"Erreur de telechargement: {str(e)}",
|
||||
message=f"Download error: {str(e)}",
|
||||
details={"error": str(e)},
|
||||
)
|
||||
except TranslateEndpointError:
|
||||
@@ -346,7 +358,7 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
|
||||
temp_path.unlink()
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.URL_DOWNLOAD_FAILED,
|
||||
message=f"Erreur inattendue lors du telechargement: {str(e)}",
|
||||
message=f"Unexpected download error: {str(e)}",
|
||||
details={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
@@ -423,6 +435,9 @@ async def translate_document_v1(
|
||||
glossary_id: Optional[str] = Form(None, description="Glossary ID (Pro only)"),
|
||||
custom_prompt: Optional[str] = Form(None, description="Custom prompt (Pro only)"),
|
||||
prompt_id: Optional[str] = Form(None, description="Prompt ID from saved prompts (Pro only)"),
|
||||
pdf_mode: Optional[Literal["layout", "text_only"]] = Form(
|
||||
default=None, description="PDF translation mode: 'layout' (preserve layout) or 'text_only' (clean text output). PDF only."
|
||||
),
|
||||
current_user: Optional[Any] = Depends(get_authenticated_user),
|
||||
):
|
||||
"""
|
||||
@@ -514,7 +529,7 @@ async def translate_document_v1(
|
||||
if tier == "free":
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
|
||||
message="L'ingestion par URL est reservee aux utilisateurs Pro.",
|
||||
message="URL ingestion is reserved for Pro users.",
|
||||
details={"feature": "file_url", "tier": tier},
|
||||
)
|
||||
|
||||
@@ -522,7 +537,7 @@ async def translate_document_v1(
|
||||
if (glossary_id or custom_prompt or prompt_id) and tier == "free":
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.PRO_FEATURE_REQUIRED,
|
||||
message="Les glossaires et prompts personnalises sont reserves aux utilisateurs Pro.",
|
||||
message="Glossaries and custom prompts are reserved for Pro users.",
|
||||
details={"feature": "glossary_id, custom_prompt, or prompt_id", "tier": tier},
|
||||
)
|
||||
|
||||
@@ -560,12 +575,12 @@ async def translate_document_v1(
|
||||
if current_user:
|
||||
quota = await tier_quota_service.check_quota(user_id, tier)
|
||||
if not quota.allowed:
|
||||
retry_after = _seconds_until_midnight_utc()
|
||||
retry_after = tier_quota_service.seconds_until_reset()
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail={
|
||||
"error": "QUOTA_EXCEEDED",
|
||||
"message": f"Limite quotidienne atteinte ({quota.current_usage}/{quota.limit} fichiers). Reessayez apres minuit UTC.",
|
||||
"message": f"Monthly limit reached ({quota.current_usage}/{quota.limit} documents). Upgrade your plan for more.",
|
||||
"details": {
|
||||
"current_usage": quota.current_usage,
|
||||
"limit": quota.limit,
|
||||
@@ -584,7 +599,7 @@ async def translate_document_v1(
|
||||
except ValidationError as e:
|
||||
raise TranslateEndpointError(
|
||||
code="INVALID_FORMAT",
|
||||
message=f"Code langue cible invalide: {target_lang}",
|
||||
message=f"Invalid target language code: {target_lang}",
|
||||
details={"field": "target_lang"},
|
||||
)
|
||||
|
||||
@@ -594,7 +609,7 @@ async def translate_document_v1(
|
||||
except ValidationError:
|
||||
raise TranslateEndpointError(
|
||||
code="INVALID_FORMAT",
|
||||
message=f"Code langue source invalide: {source_lang}",
|
||||
message=f"Invalid source language code: {source_lang}",
|
||||
details={"field": "source_lang"},
|
||||
)
|
||||
|
||||
@@ -647,7 +662,7 @@ async def translate_document_v1(
|
||||
file_handler_util.cleanup_file(input_path)
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.CORRUPTED_FILE,
|
||||
message="Impossible de calculer le hash du fichier. Fichier potentiellement corrompu.",
|
||||
message="Failed to calculate file hash. File may be corrupted.",
|
||||
details={"error": "sha256_calculation_failed"},
|
||||
)
|
||||
|
||||
@@ -660,7 +675,7 @@ async def translate_document_v1(
|
||||
file_handler_util.cleanup_file(input_path)
|
||||
raise TranslateEndpointError(
|
||||
code=TranslateEndpointError.CORRUPTED_FILE,
|
||||
message="Impossible de calculer le hash du fichier telecharge.",
|
||||
message="Failed to calculate downloaded file hash.",
|
||||
details={"error": "sha256_calculation_failed"},
|
||||
)
|
||||
|
||||
@@ -701,6 +716,7 @@ async def translate_document_v1(
|
||||
"custom_prompt": custom_prompt,
|
||||
"glossary_id": glossary_id,
|
||||
"prompt_id": prompt_id, # Story 3.12: Store prompt_id
|
||||
"pdf_mode": pdf_mode, # PDF translation mode
|
||||
}
|
||||
await set_job_status_async(job_id, _translation_jobs[job_id])
|
||||
|
||||
@@ -730,8 +746,10 @@ async def translate_document_v1(
|
||||
user_id=user_id,
|
||||
custom_prompt=custom_prompt,
|
||||
glossary_id=glossary_id,
|
||||
prompt_id=prompt_id, # Story 3.12: Pass prompt_id
|
||||
prompt_id=prompt_id,
|
||||
webhook_url=webhook_url,
|
||||
user_plan=str(current_user.plan) if current_user else "free",
|
||||
pdf_mode=pdf_mode,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -777,7 +795,7 @@ async def translate_document_v1(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "PROCESSING_ERROR",
|
||||
"message": "Erreur lors du traitement de la requete.",
|
||||
"message": "Error processing the request.",
|
||||
"details": {"error_type": type(e).__name__},
|
||||
},
|
||||
)
|
||||
@@ -825,6 +843,8 @@ async def _run_translation_job(
|
||||
glossary_id: Optional[str],
|
||||
prompt_id: Optional[str] = None, # Story 3.12: Add prompt_id parameter
|
||||
webhook_url: Optional[str] = None,
|
||||
user_plan: Optional[str] = None, # Plan name for watermark decision
|
||||
pdf_mode: Optional[str] = None, # PDF translation mode: "layout" or "text_only"
|
||||
) -> None:
|
||||
"""
|
||||
Run translation job in background with progress tracking.
|
||||
@@ -1065,6 +1085,21 @@ async def _run_translation_job(
|
||||
source_lang,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
elif file_extension == ".pdf":
|
||||
from translators.pdf_translator import PDFTranslator
|
||||
job_translator = PDFTranslator(provider=translation_provider)
|
||||
actual_output = await asyncio.to_thread(
|
||||
job_translator.translate_file,
|
||||
input_path,
|
||||
output_path,
|
||||
target_lang,
|
||||
source_lang,
|
||||
progress_callback=progress_callback,
|
||||
pdf_mode=pdf_mode or "layout",
|
||||
)
|
||||
# PDF translation may output .docx (if no LibreOffice); use actual path
|
||||
if actual_output and Path(actual_output).exists():
|
||||
output_path = Path(actual_output)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {file_extension}")
|
||||
|
||||
@@ -1077,6 +1112,17 @@ async def _run_translation_job(
|
||||
await asyncio.to_thread(record_usage, user_id, pages)
|
||||
logger.info(f"Job {job_id}: usage recorded — {pages} page(s)")
|
||||
|
||||
# Apply watermark for Free-tier users
|
||||
plan_name = (user_plan or "free").lower()
|
||||
if plan_name in ("free", "plantype.free"):
|
||||
try:
|
||||
from translators.watermark import add_watermark
|
||||
actual_ext = output_path.suffix.lower()
|
||||
await asyncio.to_thread(add_watermark, output_path, actual_ext)
|
||||
logger.info(f"Job {job_id}: watermark applied (free plan)")
|
||||
except Exception as wm_err:
|
||||
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
|
||||
|
||||
tracker.set_completed(str(output_path))
|
||||
logger.info(f"Job {job_id}: Completed successfully")
|
||||
|
||||
@@ -1193,7 +1239,7 @@ async def get_translation_status(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "NOT_FOUND",
|
||||
"message": "Job de traduction non trouve.",
|
||||
"message": "Translation job not found.",
|
||||
"details": {"job_id": job_id},
|
||||
},
|
||||
)
|
||||
@@ -1249,6 +1295,7 @@ MIME_TYPES = {
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".pdf": "application/pdf",
|
||||
}
|
||||
|
||||
|
||||
@@ -1317,7 +1364,7 @@ async def download_translated_file(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "INVALID_JOB_ID",
|
||||
"message": "Format d'identifiant de travail invalide.",
|
||||
"message": "Invalid job ID format.",
|
||||
"details": {"job_id": job_id, "expected_format": "tr_xxxxxxxxxxxx"},
|
||||
},
|
||||
)
|
||||
@@ -1331,7 +1378,7 @@ async def download_translated_file(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "FILE_EXPIRED",
|
||||
"message": "Le fichier traduit n'est plus disponible ou a expire.",
|
||||
"message": "The translated file is no longer available or has expired.",
|
||||
"details": {"job_id": job_id, "status": "not_found"},
|
||||
},
|
||||
)
|
||||
@@ -1342,7 +1389,7 @@ async def download_translated_file(
|
||||
status_code=403,
|
||||
content={
|
||||
"error": "ACCESS_DENIED",
|
||||
"message": "Vous n'avez pas acces a ce fichier.",
|
||||
"message": "You do not have access to this file.",
|
||||
"details": {"job_id": job_id},
|
||||
},
|
||||
)
|
||||
@@ -1352,7 +1399,7 @@ async def download_translated_file(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "NOT_READY",
|
||||
"message": "La traduction est encore en cours.",
|
||||
"message": "Translation is still in progress.",
|
||||
"details": {
|
||||
"job_id": job_id,
|
||||
"status": job.get("status"),
|
||||
@@ -1367,7 +1414,7 @@ async def download_translated_file(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "FILE_EXPIRED",
|
||||
"message": "Le fichier traduit n'est plus disponible ou a expire.",
|
||||
"message": "The translated file is no longer available or has expired.",
|
||||
"details": {"job_id": job_id, "status": "no_output_path"},
|
||||
},
|
||||
)
|
||||
@@ -1378,22 +1425,21 @@ async def download_translated_file(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "FILE_EXPIRED",
|
||||
"message": "Le fichier traduit n'est plus disponible ou a expire.",
|
||||
"message": "The translated file is no longer available or has expired.",
|
||||
"details": {"job_id": job_id, "status": "file_deleted"},
|
||||
},
|
||||
)
|
||||
|
||||
original_filename = job.get("file_name", "document")
|
||||
# Use the actual output file extension (PDF→DOCX conversion changes extension)
|
||||
actual_extension = output_path.suffix.lower()
|
||||
if original_filename:
|
||||
name_without_ext = Path(original_filename).stem
|
||||
extension = Path(original_filename).suffix.lower()
|
||||
download_filename = f"{name_without_ext}_translated{extension}"
|
||||
download_filename = f"{name_without_ext}_translated{actual_extension}"
|
||||
else:
|
||||
file_extension = job.get("file_extension", ".xlsx")
|
||||
download_filename = f"document_translated{file_extension}"
|
||||
extension = file_extension
|
||||
download_filename = f"document_translated{actual_extension}"
|
||||
|
||||
mime_type = MIME_TYPES.get(extension, "application/octet-stream")
|
||||
mime_type = MIME_TYPES.get(actual_extension, "application/octet-stream")
|
||||
|
||||
input_path_str = job.get("input_path")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user