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:
Sepehr Ramezani
2026-04-25 15:01:47 +02:00
parent 2ba4fedfc8
commit 26bd096a06
1178 changed files with 136435 additions and 3047 deletions

View File

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

View File

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

View File

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

View File

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