feat: production deployment - full update with providers, admin, glossaries, pricing, tests
Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,10 +14,11 @@ 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 pydantic import BaseModel, Field
|
||||
|
||||
from config import config
|
||||
from models.subscription import PlanType, PLANS
|
||||
from services import pricing_config as pricing_cfg
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
@@ -52,18 +53,10 @@ _LOCKOUT_SECONDS = 300 # 5 minutes
|
||||
|
||||
|
||||
def get_redis_client():
|
||||
global _redis_client
|
||||
if _redis_client is None and REDIS_URL:
|
||||
try:
|
||||
import redis
|
||||
"""Return shared sync Redis client from core.redis, or None."""
|
||||
from core.redis import get_sync_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
|
||||
return get_sync_redis()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
@@ -156,6 +149,30 @@ class AdminUpdateUserTierRequest(BaseModel):
|
||||
plan: Literal["free", "starter", "pro", "business", "enterprise"]
|
||||
|
||||
|
||||
class AdminResetPasswordRequest(BaseModel):
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
def _plan_info_for_admin(plan_raw) -> dict:
|
||||
"""Résout PLANS[...] à partir d'une valeur SQLAlchemy, string ou enum."""
|
||||
try:
|
||||
if isinstance(plan_raw, str):
|
||||
pt = PlanType(plan_raw.lower())
|
||||
elif hasattr(plan_raw, "value"):
|
||||
pt = PlanType(plan_raw.value)
|
||||
else:
|
||||
pt = PlanType(plan_raw)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
pt = PlanType.FREE
|
||||
return PLANS.get(pt, PLANS[PlanType.FREE])
|
||||
|
||||
|
||||
def _subscription_status_str(raw) -> str:
|
||||
if raw is None:
|
||||
return "active"
|
||||
return raw.value if hasattr(raw, "value") else str(raw)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def admin_login(request: AdminLoginRequest, req: Request):
|
||||
"""Admin login endpoint - Returns a bearer token for authenticated admin access"""
|
||||
@@ -269,12 +286,14 @@ async def get_admin_dashboard(is_admin: bool = Depends(require_admin)):
|
||||
|
||||
@router.get("/users")
|
||||
async def get_admin_users(is_admin: bool = Depends(require_admin)):
|
||||
"""Get all users with their usage stats"""
|
||||
"""Liste tous les utilisateurs (base SQLite/PostgreSQL + comptes uniquement dans users.json)."""
|
||||
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 = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_emails: set[str] = set()
|
||||
|
||||
with get_sync_session() as session:
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
@@ -282,64 +301,94 @@ async def get_admin_users(is_admin: bool = Depends(require_admin)):
|
||||
|
||||
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"])
|
||||
plan_val = db_user.plan
|
||||
plan_str = (
|
||||
plan_val.value
|
||||
if hasattr(plan_val, "value")
|
||||
else (str(plan_val) if plan_val else "free")
|
||||
)
|
||||
plan_info = _plan_info_for_admin(plan_val)
|
||||
|
||||
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"])
|
||||
row = {
|
||||
"id": str(db_user.id),
|
||||
"email": db_user.email or "",
|
||||
"name": db_user.name or "",
|
||||
"plan": plan_str,
|
||||
"subscription_status": _subscription_status_str(
|
||||
db_user.subscription_status
|
||||
),
|
||||
"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],
|
||||
"storage": "database",
|
||||
}
|
||||
users_list.append(row)
|
||||
seen_ids.add(row["id"])
|
||||
if row["email"]:
|
||||
seen_emails.add(row["email"].lower())
|
||||
|
||||
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)
|
||||
# Comptes uniquement dans data/users.json (legacy) — absents de la table users
|
||||
users_data = load_users()
|
||||
for user_id, user_data in users_data.items():
|
||||
email = (user_data.get("email") or "").strip().lower()
|
||||
if user_id in seen_ids or (email and email in seen_emails):
|
||||
continue
|
||||
plan_raw = user_data.get("plan", "free")
|
||||
plan_str = (
|
||||
plan_raw.value
|
||||
if hasattr(plan_raw, "value")
|
||||
else str(plan_raw)
|
||||
)
|
||||
plan_info = _plan_info_for_admin(plan_str)
|
||||
|
||||
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_str,
|
||||
"subscription_status": str(
|
||||
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],
|
||||
"storage": "json_file",
|
||||
}
|
||||
)
|
||||
seen_ids.add(user_id)
|
||||
if email:
|
||||
seen_emails.add(email)
|
||||
|
||||
users_list.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
||||
|
||||
return {"total": len(users_list), "users": users_list}
|
||||
|
||||
@@ -406,31 +455,93 @@ async def patch_admin_user_tier(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/password")
|
||||
async def admin_reset_user_password(
|
||||
user_id: str,
|
||||
body: AdminResetPasswordRequest,
|
||||
is_admin: bool = Depends(require_admin),
|
||||
):
|
||||
"""Définit un nouveau mot de passe pour un utilisateur (sans email de réinitialisation)."""
|
||||
from services.auth_service import admin_set_user_password
|
||||
|
||||
try:
|
||||
user = admin_set_user_password(user_id, body.new_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"error": "NOT_FOUND",
|
||||
"message": "Utilisateur introuvable (id inconnu en base et dans users.json).",
|
||||
},
|
||||
)
|
||||
logger.info("admin_password_reset for user_id=%s", user_id)
|
||||
return {
|
||||
"data": {"id": user.id, "email": user.email},
|
||||
"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.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
|
||||
from services.translation_service import _translation_cache
|
||||
|
||||
users_data = load_users()
|
||||
|
||||
total_users = len(users_data)
|
||||
plan_distribution = {}
|
||||
plan_distribution: dict = {}
|
||||
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
|
||||
if USE_DATABASE and DATABASE_AVAILABLE:
|
||||
from database.connection import get_sync_session
|
||||
from database.models import User as DBUser
|
||||
|
||||
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
|
||||
with get_sync_session() as session:
|
||||
db_rows = session.query(DBUser).all()
|
||||
db_ids = {str(u.id) for u in db_rows}
|
||||
db_emails = {(u.email or "").strip().lower() for u in db_rows}
|
||||
for u in db_rows:
|
||||
pv = u.plan.value if hasattr(u.plan, "value") else str(u.plan)
|
||||
plan_distribution[pv] = plan_distribution.get(pv, 0) + 1
|
||||
docs = u.docs_translated_this_month or 0
|
||||
pages = u.pages_translated_this_month or 0
|
||||
total_docs_translated += docs
|
||||
total_pages_translated += pages
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
json_only = 0
|
||||
for uid, ud in users_data.items():
|
||||
if uid in db_ids:
|
||||
continue
|
||||
em = (ud.get("email") or "").strip().lower()
|
||||
if em and em in db_emails:
|
||||
continue
|
||||
json_only += 1
|
||||
pr = ud.get("plan", "free")
|
||||
pv = pr.value if hasattr(pr, "value") else str(pr)
|
||||
plan_distribution[pv] = plan_distribution.get(pv, 0) + 1
|
||||
docs = int(ud.get("docs_translated_this_month", 0) or 0)
|
||||
pages = int(ud.get("pages_translated_this_month", 0) or 0)
|
||||
total_docs_translated += docs
|
||||
total_pages_translated += pages
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
total_users = len(db_ids) + json_only
|
||||
else:
|
||||
total_users = len(users_data)
|
||||
for user_data in users_data.values():
|
||||
plan = user_data.get("plan", "free")
|
||||
pv = plan.value if hasattr(plan, "value") else str(plan)
|
||||
plan_distribution[pv] = plan_distribution.get(pv, 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()
|
||||
|
||||
@@ -680,13 +791,14 @@ class ProviderSettings(BaseModel):
|
||||
|
||||
class SettingsConfig(BaseModel):
|
||||
google: ProviderSettings = ProviderSettings(enabled=True)
|
||||
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
|
||||
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: str = "google,google_cloud,deepl,openai,ollama,openrouter,openrouter_premium,zai"
|
||||
fallback_chain_classic: str = "google,deepl"
|
||||
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,zai,ollama"
|
||||
|
||||
@@ -754,6 +866,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
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")
|
||||
payload["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
|
||||
|
||||
# Inform the frontend which providers have API keys configured via env vars
|
||||
# (boolean only — never expose actual values)
|
||||
@@ -765,6 +878,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
"openrouter_premium": has_openrouter, # same key, different model
|
||||
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
|
||||
"ollama": bool(os.getenv("OLLAMA_BASE_URL", "").strip()),
|
||||
"google_cloud": bool(os.getenv("GOOGLE_CLOUD_API_KEY", "").strip()),
|
||||
}
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
@@ -812,6 +926,52 @@ async def test_provider(provider: str, admin_id: str = Depends(require_admin)):
|
||||
status_code=200, content={"available": True, "test_result": result}
|
||||
)
|
||||
|
||||
elif provider == "google_cloud":
|
||||
api_key = _key(provider_config.api_key, "GOOGLE_CLOUD_API_KEY")
|
||||
if not api_key:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"available": False,
|
||||
"error": "Aucune clé API Google Cloud trouvée (JSON ou .env GOOGLE_CLOUD_API_KEY).",
|
||||
},
|
||||
)
|
||||
import requests as _requests
|
||||
|
||||
resp = _requests.post(
|
||||
"https://translation.googleapis.com/language/translate/v2",
|
||||
params={"key": api_key},
|
||||
json={"q": "bonjour", "target": "en", "format": "text"},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
translated = (
|
||||
resp.json()
|
||||
.get("data", {})
|
||||
.get("translations", [{}])[0]
|
||||
.get("translatedText", "")
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"available": True, "test_result": translated},
|
||||
)
|
||||
elif resp.status_code in (401, 403):
|
||||
return JSONResponse(
|
||||
status_code=resp.status_code,
|
||||
content={
|
||||
"available": False,
|
||||
"error": f"Clé API invalide ou API non activée dans Google Cloud Console (HTTP {resp.status_code}).",
|
||||
},
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"available": False,
|
||||
"error": f"Erreur Google Cloud Translation HTTP {resp.status_code}: {resp.text[:200]}",
|
||||
},
|
||||
)
|
||||
|
||||
elif provider == "deepl":
|
||||
api_key = _key(provider_config.api_key, "DEEPL_API_KEY")
|
||||
if not api_key:
|
||||
@@ -975,3 +1135,284 @@ async def list_ollama_models(admin_id: str = Depends(require_admin)):
|
||||
status_code=500,
|
||||
content={"error": "INTERNAL_ERROR", "message": str(e)},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# PRICING MANAGEMENT (Admin only)
|
||||
# ============================================================
|
||||
|
||||
|
||||
class PlanPricingUpdate(BaseModel):
|
||||
price_monthly: Optional[float] = None
|
||||
price_yearly: Optional[float] = None # ignoré : calculé serveur (cohérence −20 % annuel)
|
||||
stripe_price_id_monthly: Optional[str] = None
|
||||
stripe_price_id_yearly: Optional[str] = None
|
||||
|
||||
|
||||
class PricingConfig(BaseModel):
|
||||
starter: PlanPricingUpdate = PlanPricingUpdate()
|
||||
pro: PlanPricingUpdate = PlanPricingUpdate()
|
||||
business: PlanPricingUpdate = PlanPricingUpdate()
|
||||
stripe_secret_key: Optional[str] = None
|
||||
stripe_publishable_key: Optional[str] = None
|
||||
stripe_webhook_secret: Optional[str] = None
|
||||
|
||||
|
||||
def _update_env_file(updates: dict):
|
||||
"""Persist key=value pairs into .env file (in-place)."""
|
||||
env_path = config.BASE_DIR / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
lines = env_path.read_text().splitlines()
|
||||
updated_keys = set()
|
||||
new_lines = []
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if "=" in stripped and not stripped.startswith("#"):
|
||||
key = stripped.split("=", 1)[0]
|
||||
if key in updates:
|
||||
new_lines.append(f"{key}={updates[key]}")
|
||||
updated_keys.add(key)
|
||||
continue
|
||||
new_lines.append(line)
|
||||
# Append any keys not already in .env
|
||||
for key, val in updates.items():
|
||||
if key not in updated_keys:
|
||||
new_lines.append(f"{key}={val}")
|
||||
env_path.write_text("\n".join(new_lines))
|
||||
|
||||
|
||||
@router.get("/pricing")
|
||||
async def get_pricing_config(admin_id: str = Depends(require_admin)):
|
||||
"""Return current pricing config (plans + Stripe IDs)."""
|
||||
result = {}
|
||||
for plan_id in ("starter", "pro", "business"):
|
||||
try:
|
||||
pt = PlanType(plan_id)
|
||||
plan = PLANS[pt]
|
||||
except Exception:
|
||||
continue
|
||||
monthly, yearly = pricing_cfg.get_effective_monthly_yearly(plan_id)
|
||||
sm, sy = pricing_cfg.stripe_price_ids_for_plan(plan_id)
|
||||
result[plan_id] = {
|
||||
"name": plan["name"],
|
||||
"price_monthly": monthly,
|
||||
"price_yearly": yearly,
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
"stripe_price_id_monthly": sm,
|
||||
"stripe_price_id_yearly": sy,
|
||||
}
|
||||
|
||||
stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY", "").strip().startswith("sk_"))
|
||||
return JSONResponse(status_code=200, content={
|
||||
"data": result,
|
||||
"stripe": {
|
||||
"configured": stripe_configured,
|
||||
"has_secret_key": bool(os.getenv("STRIPE_SECRET_KEY", "").strip()),
|
||||
"has_publishable_key": bool(os.getenv("STRIPE_PUBLISHABLE_KEY", "").strip()),
|
||||
"has_webhook_secret": bool(os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()),
|
||||
"publishable_key": os.getenv("STRIPE_PUBLISHABLE_KEY", ""),
|
||||
},
|
||||
"meta": {
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
"yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@router.put("/pricing")
|
||||
async def update_pricing_config(
|
||||
config_update: PricingConfig,
|
||||
admin_id: str = Depends(require_admin),
|
||||
):
|
||||
"""Update pricing (prices + Stripe IDs). Persists to JSON and .env."""
|
||||
try:
|
||||
overrides = pricing_cfg.load_pricing_overrides()
|
||||
env_updates = {}
|
||||
|
||||
for plan_id in ("starter", "pro", "business"):
|
||||
plan_update = getattr(config_update, plan_id)
|
||||
ov = dict(overrides.get(plan_id, {}))
|
||||
|
||||
if plan_update.price_monthly is not None:
|
||||
pricing_cfg.validate_monthly_price_eur(plan_update.price_monthly)
|
||||
ov["price_monthly"] = float(plan_update.price_monthly)
|
||||
ov = pricing_cfg.normalize_plan_override_block(plan_id, ov)
|
||||
|
||||
if plan_update.stripe_price_id_monthly is not None:
|
||||
mid = pricing_cfg.validate_stripe_price_id(
|
||||
plan_update.stripe_price_id_monthly,
|
||||
f"{plan_id} stripe_price_id_monthly",
|
||||
)
|
||||
ov["stripe_price_id_monthly"] = mid
|
||||
if mid:
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_MONTHLY"] = mid
|
||||
|
||||
if plan_update.stripe_price_id_yearly is not None:
|
||||
yid = pricing_cfg.validate_stripe_price_id(
|
||||
plan_update.stripe_price_id_yearly,
|
||||
f"{plan_id} stripe_price_id_yearly",
|
||||
)
|
||||
ov["stripe_price_id_yearly"] = yid
|
||||
if yid:
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_YEARLY"] = yid
|
||||
|
||||
overrides[plan_id] = ov
|
||||
|
||||
if config_update.stripe_secret_key:
|
||||
pricing_cfg.validate_stripe_secret_key(config_update.stripe_secret_key)
|
||||
env_updates["STRIPE_SECRET_KEY"] = config_update.stripe_secret_key.strip()
|
||||
try:
|
||||
import stripe as _stripe
|
||||
|
||||
_stripe.api_key = env_updates["STRIPE_SECRET_KEY"]
|
||||
except Exception:
|
||||
pass
|
||||
if config_update.stripe_publishable_key:
|
||||
pricing_cfg.validate_stripe_publishable_key(
|
||||
config_update.stripe_publishable_key
|
||||
)
|
||||
env_updates["STRIPE_PUBLISHABLE_KEY"] = (
|
||||
config_update.stripe_publishable_key.strip()
|
||||
)
|
||||
if config_update.stripe_webhook_secret:
|
||||
pricing_cfg.validate_stripe_webhook_secret(
|
||||
config_update.stripe_webhook_secret
|
||||
)
|
||||
env_updates["STRIPE_WEBHOOK_SECRET"] = (
|
||||
config_update.stripe_webhook_secret.strip()
|
||||
)
|
||||
|
||||
for plan_id in ("starter", "pro", "business"):
|
||||
overrides[plan_id] = pricing_cfg.normalize_plan_override_block(
|
||||
plan_id, overrides.get(plan_id, {})
|
||||
)
|
||||
|
||||
try:
|
||||
pricing_cfg.save_pricing_overrides(overrides)
|
||||
except OSError as e:
|
||||
logger.exception("admin_pricing_save_failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Impossible d'écrire data/pricing_overrides.json (droits disque ou chemin). "
|
||||
f"Détail : {e}"
|
||||
),
|
||||
) from e
|
||||
if env_updates:
|
||||
_update_env_file(env_updates)
|
||||
pricing_cfg.apply_runtime_config_after_admin_write()
|
||||
|
||||
logger.info("admin_pricing_updated by %s keys=%s", admin_id, list(env_updates.keys()))
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": overrides,
|
||||
"meta": {
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
},
|
||||
},
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.post("/pricing/setup-stripe")
|
||||
async def setup_stripe_products(admin_id: str = Depends(require_admin)):
|
||||
"""Auto-create Stripe products & prices from backend, then save IDs to .env."""
|
||||
secret_key = os.getenv("STRIPE_SECRET_KEY", "").strip()
|
||||
if not secret_key or not secret_key.startswith("sk_"):
|
||||
return JSONResponse(status_code=400, content={
|
||||
"error": "STRIPE_NOT_CONFIGURED",
|
||||
"message": "STRIPE_SECRET_KEY manquante ou invalide. Configurez-la d'abord.",
|
||||
})
|
||||
|
||||
try:
|
||||
import stripe as _stripe
|
||||
_stripe.api_key = secret_key
|
||||
except ImportError:
|
||||
return JSONResponse(status_code=500, content={
|
||||
"error": "STRIPE_NOT_INSTALLED",
|
||||
"message": "Le module stripe n'est pas installé sur le serveur.",
|
||||
})
|
||||
|
||||
plans_meta = [
|
||||
("starter", "Office Translator Starter"),
|
||||
("pro", "Office Translator Pro"),
|
||||
("business", "Office Translator Business"),
|
||||
]
|
||||
|
||||
results = {}
|
||||
env_updates = {}
|
||||
errors = []
|
||||
|
||||
for plan_id, product_name in plans_meta:
|
||||
monthly, yearly = pricing_cfg.get_effective_monthly_yearly(plan_id)
|
||||
monthly_cents = int(round(monthly * 100))
|
||||
yearly_cents = int(round(yearly * 100))
|
||||
try:
|
||||
# Find or create product
|
||||
existing = _stripe.Product.search(query=f"name:\"{product_name}\"")
|
||||
if existing.data:
|
||||
product = existing.data[0]
|
||||
else:
|
||||
product = _stripe.Product.create(
|
||||
name=product_name,
|
||||
description=f"Abonnement Office Translator — Forfait {plan_id.capitalize()}",
|
||||
metadata={"plan": plan_id},
|
||||
)
|
||||
|
||||
# Find or create monthly price
|
||||
prices = _stripe.Price.list(product=product.id, active=True, limit=20)
|
||||
monthly_price = next((
|
||||
p for p in prices.data
|
||||
if p.recurring and p.recurring.interval == "month"
|
||||
and p.unit_amount == monthly_cents and p.currency == "eur"
|
||||
), None)
|
||||
yearly_price = next((
|
||||
p for p in prices.data
|
||||
if p.recurring and p.recurring.interval == "year"
|
||||
and p.unit_amount == yearly_cents and p.currency == "eur"
|
||||
), None)
|
||||
|
||||
if not monthly_price:
|
||||
monthly_price = _stripe.Price.create(
|
||||
product=product.id, unit_amount=monthly_cents, currency="eur",
|
||||
recurring={"interval": "month"}, metadata={"plan": plan_id},
|
||||
)
|
||||
if not yearly_price:
|
||||
yearly_price = _stripe.Price.create(
|
||||
product=product.id, unit_amount=yearly_cents, currency="eur",
|
||||
recurring={"interval": "year"}, metadata={"plan": plan_id},
|
||||
)
|
||||
|
||||
results[plan_id] = {
|
||||
"product_id": product.id,
|
||||
"monthly_price_id": monthly_price.id,
|
||||
"yearly_price_id": yearly_price.id,
|
||||
"created": True,
|
||||
}
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_MONTHLY"] = monthly_price.id
|
||||
env_updates[f"STRIPE_PRICE_{plan_id.upper()}_YEARLY"] = yearly_price.id
|
||||
|
||||
except Exception as e:
|
||||
errors.append({"plan": plan_id, "error": str(e)})
|
||||
|
||||
if env_updates:
|
||||
_update_env_file(env_updates)
|
||||
overrides = pricing_cfg.load_pricing_overrides()
|
||||
for plan_id, ids in results.items():
|
||||
ov = overrides.get(plan_id, {})
|
||||
ov["stripe_price_id_monthly"] = ids["monthly_price_id"]
|
||||
ov["stripe_price_id_yearly"] = ids["yearly_price_id"]
|
||||
overrides[plan_id] = ov
|
||||
pricing_cfg.save_pricing_overrides(overrides)
|
||||
pricing_cfg.apply_runtime_config_after_admin_write()
|
||||
|
||||
logger.info(f"admin_stripe_setup by {admin_id}: {list(results.keys())}")
|
||||
return JSONResponse(status_code=200, content={
|
||||
"data": results,
|
||||
"errors": errors,
|
||||
"env_updated": list(env_updates.keys()),
|
||||
"meta": {},
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ Story 3.6: Documentation OpenAPI complète avec exemples et codes d'erreur
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request
|
||||
from fastapi import APIRouter, HTTPException, Depends, Header, Request, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel, EmailStr, ValidationError as PydanticValidationError
|
||||
@@ -31,10 +31,12 @@ from services.auth_service import (
|
||||
update_user,
|
||||
get_user_by_email,
|
||||
verify_password,
|
||||
get_or_create_google_user,
|
||||
PASSLIB_AVAILABLE,
|
||||
)
|
||||
from services.payment_service import (
|
||||
create_checkout_session,
|
||||
sync_checkout_session,
|
||||
create_credits_checkout,
|
||||
cancel_subscription,
|
||||
get_billing_portal_url,
|
||||
@@ -58,6 +60,11 @@ class RefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class GoogleAuthRequest(BaseModel):
|
||||
credential: Optional[str] = None # Google ID token (from GoogleLogin component)
|
||||
access_token: Optional[str] = None # Google OAuth2 access token (from useGoogleLogin)
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
plan: PlanType
|
||||
billing_period: str = "monthly"
|
||||
@@ -101,6 +108,8 @@ def user_to_response(user) -> UserResponse:
|
||||
plan=user.plan,
|
||||
tier=user.plan,
|
||||
subscription_status=user.subscription_status,
|
||||
subscription_ends_at=getattr(user, 'subscription_ends_at', None),
|
||||
cancel_at_period_end=getattr(user, 'cancel_at_period_end', False),
|
||||
docs_translated_this_month=user.docs_translated_this_month,
|
||||
pages_translated_this_month=user.pages_translated_this_month,
|
||||
api_calls_this_month=user.api_calls_this_month,
|
||||
@@ -529,6 +538,90 @@ async def login_v1(request: Request):
|
||||
)
|
||||
|
||||
|
||||
@router_v1.post("/google")
|
||||
async def google_auth_v1(body: GoogleAuthRequest):
|
||||
"""Authentification via Google OAuth.
|
||||
Accepte soit un credential (ID token) soit un access_token Google,
|
||||
vérifie avec l'API Google, puis crée ou connecte l'utilisateur."""
|
||||
import httpx as _httpx
|
||||
|
||||
if not body.credential and not body.access_token:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "MISSING_TOKEN", "message": "credential ou access_token requis."},
|
||||
)
|
||||
|
||||
google_client_id = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=10.0) as client:
|
||||
if body.credential:
|
||||
# Verify ID token via tokeninfo
|
||||
resp = await client.get(
|
||||
"https://oauth2.googleapis.com/tokeninfo",
|
||||
params={"id_token": body.credential},
|
||||
)
|
||||
else:
|
||||
# Verify access token via userinfo
|
||||
resp = await client.get(
|
||||
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
headers={"Authorization": f"Bearer {body.access_token}"},
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={"error": "GOOGLE_UNREACHABLE", "message": "Impossible de contacter les serveurs Google."},
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "INVALID_GOOGLE_TOKEN", "message": "Token Google invalide ou expiré."},
|
||||
)
|
||||
|
||||
google_data = resp.json()
|
||||
|
||||
# Validate audience only for ID tokens (not for access_token flow)
|
||||
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."},
|
||||
)
|
||||
|
||||
email = google_data.get("email")
|
||||
if not email:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "MISSING_EMAIL", "message": "Email non fourni par Google."},
|
||||
)
|
||||
|
||||
name = google_data.get("name") or google_data.get("given_name") or email.split("@")[0]
|
||||
avatar_url = google_data.get("picture")
|
||||
|
||||
try:
|
||||
user = get_or_create_google_user(email=email, name=name, avatar_url=avatar_url)
|
||||
except Exception as exc:
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "USER_CREATE_FAILED", "message": str(exc)},
|
||||
)
|
||||
|
||||
access_token = create_access_token(user.id)
|
||||
refresh_token = create_refresh_token(user.id)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"data": {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "bearer",
|
||||
},
|
||||
"meta": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router_v1.post("/refresh")
|
||||
async def refresh_v1(request: Request):
|
||||
"""Refresh tokens (API v1) — accepte refresh_token en corps, retourne nouvel access_token et refresh_token."""
|
||||
@@ -632,15 +725,19 @@ async def get_me_v1(user=Depends(require_user)):
|
||||
)
|
||||
async def get_plans_v1():
|
||||
from models.subscription import PLANS, CREDIT_PACKAGES
|
||||
from services import pricing_config as pricing_cfg
|
||||
|
||||
plans_list = []
|
||||
for plan_type, plan in PLANS.items():
|
||||
plan_id = plan_type.value
|
||||
monthly, yearly = pricing_cfg.get_effective_monthly_yearly(plan_id)
|
||||
plans_list.append(
|
||||
{
|
||||
"id": plan_type.value,
|
||||
"id": plan_id,
|
||||
"name": plan["name"],
|
||||
"price_monthly": plan["price_monthly"],
|
||||
"price_yearly": plan["price_yearly"],
|
||||
"price_monthly": monthly,
|
||||
"price_yearly": yearly,
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
"docs_per_month": plan["docs_per_month"],
|
||||
"max_pages_per_doc": plan["max_pages_per_doc"],
|
||||
"max_file_size_mb": plan["max_file_size_mb"],
|
||||
@@ -672,10 +769,20 @@ async def get_plans_v1():
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"data": {"plans": plans_list, "credit_packages": packages_list}, "meta": {}},
|
||||
headers={
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
},
|
||||
content={
|
||||
"data": {"plans": plans_list, "credit_packages": packages_list},
|
||||
"meta": {
|
||||
"annual_discount_percent": pricing_cfg.ANNUAL_DISCOUNT_PERCENT,
|
||||
"yearly_discount_factor": pricing_cfg.YEARLY_DISCOUNT_FACTOR,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router_v1.get(
|
||||
"/usage",
|
||||
summary="Utilisation et limites",
|
||||
@@ -688,6 +795,88 @@ async def get_usage_v1(user=Depends(require_user)):
|
||||
)
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/create-checkout",
|
||||
summary="Créer une session de paiement",
|
||||
description="Crée une session Stripe Checkout pour souscrire à un forfait.",
|
||||
)
|
||||
async def create_checkout_v1(request: CheckoutRequest, user=Depends(require_user)):
|
||||
result = await create_checkout_session(
|
||||
user_id=user.id,
|
||||
plan=request.plan,
|
||||
billing_period=request.billing_period,
|
||||
)
|
||||
if "error" in result and not result.get("demo_mode"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "CHECKOUT_FAILED",
|
||||
"message": result["error"],
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
||||
|
||||
|
||||
@router_v1.get(
|
||||
"/checkout/sync",
|
||||
summary="Synchroniser un checkout Stripe",
|
||||
description=(
|
||||
"Synchronise un paiement Stripe finalisé via session_id "
|
||||
"(utile en dev/local quand le webhook n'est pas reçu)."
|
||||
),
|
||||
)
|
||||
async def sync_checkout_v1(
|
||||
session_id: str = Query(..., min_length=5),
|
||||
user=Depends(require_user),
|
||||
):
|
||||
result = await sync_checkout_session(user_id=user.id, session_id=session_id)
|
||||
if "error" in result:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "CHECKOUT_SYNC_FAILED",
|
||||
"message": result["error"],
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/create-credits-checkout",
|
||||
summary="Créer une session de paiement pour crédits",
|
||||
description="Crée une session Stripe Checkout pour l'achat de crédits supplémentaires.",
|
||||
)
|
||||
async def create_credits_checkout_v1(request: CreditsCheckoutRequest, user=Depends(require_user)):
|
||||
result = await create_credits_checkout(
|
||||
user_id=user.id,
|
||||
package_index=request.package_index,
|
||||
)
|
||||
if "error" in result and not result.get("demo_mode"):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "CHECKOUT_FAILED",
|
||||
"message": result["error"],
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
||||
|
||||
|
||||
@router_v1.post(
|
||||
"/cancel-subscription",
|
||||
summary="Annuler l'abonnement",
|
||||
description="Annule l'abonnement Stripe de l'utilisateur à la fin de la période en cours.",
|
||||
)
|
||||
async def cancel_subscription_v1(user=Depends(require_user)):
|
||||
result = await cancel_subscription(user.id)
|
||||
if "error" in result:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"error": "CANCEL_FAILED", "message": result["error"]},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"data": result, "meta": {}})
|
||||
|
||||
|
||||
@router_v1.get(
|
||||
"/billing-portal",
|
||||
summary="Portail de facturation",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Shared authentication dependencies for routes.
|
||||
Story 3.9: Extracted from api_key_routes.py and glossary_routes.py
|
||||
Story 6.4: Bind user_id to structlog context when user is resolved.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -86,6 +87,11 @@ def require_auth(
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
from core.logging import bind_request_context
|
||||
bind_request_context(user_id=str(getattr(user, "id", user)))
|
||||
except Exception:
|
||||
pass
|
||||
return user
|
||||
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ from typing_extensions import Annotated
|
||||
from config import config
|
||||
from models.subscription import PlanType
|
||||
from middleware.tier_quota import tier_quota_service
|
||||
from services.auth_service import record_usage
|
||||
from middleware.validation import FileValidator, ValidationError, LanguageValidator, webhook_validator
|
||||
from middleware.api_key_auth import get_authenticated_user, get_user_from_api_key
|
||||
from utils import file_handler
|
||||
@@ -61,6 +62,7 @@ from schemas.errors import ErrorResponse
|
||||
from utils.file_handler import FileHandler
|
||||
from services.progress_tracker import ProgressTracker
|
||||
from services.storage_tracker import storage_tracker
|
||||
from core.redis import set_job_status_async, get_job_status_async
|
||||
from services.glossary_service import get_glossary_terms, validate_glossary_access, build_full_prompt
|
||||
from services.prompt_service import get_prompt_content, validate_prompt_access
|
||||
from utils.exceptions import GlossaryNotFoundError, PromptNotFoundError
|
||||
@@ -700,9 +702,23 @@ async def translate_document_v1(
|
||||
"glossary_id": glossary_id,
|
||||
"prompt_id": prompt_id, # Story 3.12: Store prompt_id
|
||||
}
|
||||
await set_job_status_async(job_id, _translation_jobs[job_id])
|
||||
|
||||
provider_to_use = provider or ("openrouter" if mode == "llm" else "google")
|
||||
|
||||
# google_cloud (API officielle payante) est réservé aux forfaits Pro et supérieurs.
|
||||
# Les plans free/starter sont silencieusement redirigés vers le Google Translate gratuit.
|
||||
if provider_to_use == "google_cloud":
|
||||
_paid_plans = (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
|
||||
if not current_user or current_user.plan not in _paid_plans:
|
||||
logger.info(
|
||||
"google_cloud_downgraded_to_google",
|
||||
reason="plan_restriction",
|
||||
plan=str(current_user.plan) if current_user else "anonymous",
|
||||
user_id=str(current_user.id) if current_user else None,
|
||||
)
|
||||
provider_to_use = "google"
|
||||
|
||||
asyncio.create_task(
|
||||
_run_translation_job(
|
||||
job_id=job_id,
|
||||
@@ -767,6 +783,36 @@ async def translate_document_v1(
|
||||
)
|
||||
|
||||
|
||||
def _estimate_pages(file_path: Path, file_extension: str) -> int:
|
||||
"""
|
||||
Lightweight page-count estimate for usage accounting.
|
||||
- .pptx : number of slides (exact)
|
||||
- .xlsx : number of visible sheets
|
||||
- .docx : rough estimate (~2 500 chars per page)
|
||||
Returns at least 1.
|
||||
"""
|
||||
try:
|
||||
ext = file_extension.lower()
|
||||
if ext == ".pptx":
|
||||
from pptx import Presentation # already a dep
|
||||
prs = Presentation(str(file_path))
|
||||
return max(1, len(prs.slides))
|
||||
elif ext == ".xlsx":
|
||||
import openpyxl # already a dep
|
||||
wb = openpyxl.load_workbook(str(file_path), read_only=True, data_only=True)
|
||||
count = len(wb.sheetnames)
|
||||
wb.close()
|
||||
return max(1, count)
|
||||
elif ext == ".docx":
|
||||
import docx # already a dep
|
||||
doc = docx.Document(str(file_path))
|
||||
char_count = sum(len(p.text) for p in doc.paragraphs)
|
||||
return max(1, round(char_count / 2500))
|
||||
except Exception as exc:
|
||||
logger.warning(f"_estimate_pages failed for {file_extension}: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
async def _run_translation_job(
|
||||
job_id: str,
|
||||
input_path: Path,
|
||||
@@ -804,14 +850,28 @@ async def _run_translation_job(
|
||||
|
||||
try:
|
||||
job["status"] = "processing"
|
||||
await set_job_status_async(job_id, dict(job))
|
||||
tracker.update(10, "Validating file")
|
||||
|
||||
async def _sync_job_to_redis():
|
||||
"""Sync job status to Redis every 0.5s until completed/failed or job removed."""
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
j = _translation_jobs.get(job_id)
|
||||
if not j:
|
||||
break
|
||||
await set_job_status_async(job_id, dict(j))
|
||||
if j.get("status") in ("completed", "failed"):
|
||||
break
|
||||
|
||||
asyncio.create_task(_sync_job_to_redis())
|
||||
|
||||
output_filename = file_handler_util.generate_unique_filename(
|
||||
input_path.name.replace("input_", "translated_"), "translated"
|
||||
)
|
||||
output_path = config.OUTPUT_DIR / output_filename
|
||||
|
||||
from translators import excel_translator, word_translator, pptx_translator
|
||||
from translators import ExcelTranslator, WordTranslator, PowerPointTranslator
|
||||
from services.translation_service import (
|
||||
OpenRouterTranslationProvider,
|
||||
OllamaTranslationProvider,
|
||||
@@ -887,7 +947,7 @@ async def _run_translation_job(
|
||||
deepl_key = _cfg(_admin_cfg.deepl.api_key, "DEEPL_API_KEY")
|
||||
if deepl_key:
|
||||
from services.translation_service import DeepLTranslationProvider
|
||||
translation_provider = DeepLTranslationProvider(deepl_key, full_prompt)
|
||||
translation_provider = DeepLTranslationProvider(deepl_key)
|
||||
elif _p == "zai":
|
||||
from services.translation_service import OpenAITranslationProvider as _OAI
|
||||
zai_key = _cfg(_admin_cfg.zai.api_key, "ZAI_API_KEY")
|
||||
@@ -909,6 +969,29 @@ async def _run_translation_job(
|
||||
ollama_model,
|
||||
full_prompt,
|
||||
)
|
||||
elif _p == "google_cloud":
|
||||
from services.providers.google_cloud_provider import GoogleCloudTranslationProvider
|
||||
gc_key = _cfg(
|
||||
getattr(_admin_cfg.google_cloud, "api_key", None),
|
||||
"GOOGLE_CLOUD_API_KEY",
|
||||
)
|
||||
if gc_key:
|
||||
translation_provider = GoogleCloudTranslationProvider(
|
||||
api_key=gc_key,
|
||||
timeout=int(os.getenv("GOOGLE_CLOUD_TIMEOUT", "30")),
|
||||
max_retries=int(os.getenv("GOOGLE_CLOUD_MAX_RETRIES", "3")),
|
||||
retry_delay=float(os.getenv("GOOGLE_CLOUD_RETRY_DELAY", "1.0")),
|
||||
)
|
||||
logger.info(
|
||||
"google_cloud_provider_selected",
|
||||
job_id=job_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"google_cloud_key_missing_fallback_to_google",
|
||||
job_id=job_id,
|
||||
)
|
||||
# translation_provider reste None → legacy Google gratuit
|
||||
|
||||
tracker.update(20, "Preparing translation")
|
||||
|
||||
@@ -950,12 +1033,12 @@ async def _run_translation_job(
|
||||
# Run synchronous translators in a thread pool to avoid blocking the event loop.
|
||||
# Without this, status polling requests from the frontend would time out during
|
||||
# translation, causing the "Connection lost" error and frozen progress bar.
|
||||
# Always call set_provider (even with None) to reset any previously-set
|
||||
# provider on the singleton translator instances between jobs.
|
||||
# One translator instance per job so concurrent jobs never share mutable
|
||||
# provider state (singleton set_provider was racy under parallel translations).
|
||||
if file_extension == ".xlsx":
|
||||
excel_translator.set_provider(translation_provider)
|
||||
job_translator = ExcelTranslator(provider=translation_provider)
|
||||
await asyncio.to_thread(
|
||||
excel_translator.translate_file,
|
||||
job_translator.translate_file,
|
||||
input_path,
|
||||
output_path,
|
||||
target_lang,
|
||||
@@ -963,9 +1046,9 @@ async def _run_translation_job(
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
elif file_extension == ".docx":
|
||||
word_translator.set_provider(translation_provider)
|
||||
job_translator = WordTranslator(provider=translation_provider)
|
||||
await asyncio.to_thread(
|
||||
word_translator.translate_file,
|
||||
job_translator.translate_file,
|
||||
input_path,
|
||||
output_path,
|
||||
target_lang,
|
||||
@@ -973,9 +1056,9 @@ async def _run_translation_job(
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
elif file_extension == ".pptx":
|
||||
pptx_translator.set_provider(translation_provider)
|
||||
job_translator = PowerPointTranslator(provider=translation_provider)
|
||||
await asyncio.to_thread(
|
||||
pptx_translator.translate_file,
|
||||
job_translator.translate_file,
|
||||
input_path,
|
||||
output_path,
|
||||
target_lang,
|
||||
@@ -987,6 +1070,12 @@ async def _run_translation_job(
|
||||
|
||||
if user_id:
|
||||
await tier_quota_service.increment_on_success(user_id)
|
||||
# Persist monthly usage counters in PostgreSQL (docs + pages)
|
||||
pages = await asyncio.to_thread(
|
||||
_estimate_pages, input_path, file_extension
|
||||
)
|
||||
await asyncio.to_thread(record_usage, user_id, pages)
|
||||
logger.info(f"Job {job_id}: usage recorded — {pages} page(s)")
|
||||
|
||||
tracker.set_completed(str(output_path))
|
||||
logger.info(f"Job {job_id}: Completed successfully")
|
||||
@@ -1095,7 +1184,9 @@ async def get_translation_status(
|
||||
}
|
||||
```
|
||||
"""
|
||||
job = _translation_jobs.get(job_id)
|
||||
job = await get_job_status_async(job_id)
|
||||
if not job:
|
||||
job = _translation_jobs.get(job_id)
|
||||
|
||||
if not job:
|
||||
return JSONResponse(
|
||||
@@ -1231,7 +1322,9 @@ async def download_translated_file(
|
||||
},
|
||||
)
|
||||
|
||||
job = _translation_jobs.get(job_id)
|
||||
job = await get_job_status_async(job_id)
|
||||
if not job:
|
||||
job = _translation_jobs.get(job_id)
|
||||
|
||||
if not job:
|
||||
return JSONResponse(
|
||||
|
||||
Reference in New Issue
Block a user