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

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