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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user