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:
2026-05-10 11:43:28 +02:00
parent 16ac7ca2b9
commit ce8e150a61
110 changed files with 6935 additions and 4301 deletions

View File

@@ -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(...),

View File

@@ -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.

View File

@@ -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": {},
},
)

View File

@@ -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.",
},
)

View File

@@ -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

View File

@@ -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.",
},
)

View File

@@ -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")