Files
office_translator/routes/admin_routes.py
sepehr 0a013c679d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s
feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
- Chaque modele d'un palier devient une route {canal, modele} : openrouter,
  deepseek direct, zhipu (z.ai, nouveau canal), minimax, openai, xai ;
  repli automatique sur la route suivante si la cle manque, compatibilite
  ascendante avec les anciens reglages a chaines
- Page Fournisseurs refondue en sections ; interrupteurs et chaine de
  secours retablis ; tests de connexion avec delai d'attente
- Emails de relance generes par IA (canal au choix, prompt construit depuis
  le plan marketing, HTML sane, RTL arabe/persan, jamais d'envoi automatique)
- Codes promo : validation locale avant Stripe, Coupon+PromotionCode avec
  double plafond, comptage par session (webhook+sync=1 fois), ecriture
  atomique, relier-a-Stripe, suppression, validation publique au checkout
- Page Tarifs : champ code promo (?promo=) avec verification traduite
- Tests : 1357 verts (facturation par palier testee dans le worker,
  point de contact providers/available, promos, generation)
2026-09-05 19:46:33 +02:00

3496 lines
132 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Admin API v1 Endpoints
All admin endpoints under /api/v1/admin/
Story 3.5: API Versioning - Migrated from main.py
"""
import os
import json
import re
import asyncio
import html as html_module
import secrets
import time
import logging
import hashlib
import threading
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional, Literal, List
from fastapi import APIRouter, Depends, Header, HTTPException, Form, Request, Query
from fastapi.responses import JSONResponse, HTMLResponse
from passlib.context import CryptContext
from pydantic import BaseModel, Field
from config import config
from models.subscription import (
PlanType,
PLANS,
DEFAULT_AI_MODELS_ESSENTIAL,
DEFAULT_AI_MODELS_PREMIUM,
)
from services import pricing_config as pricing_cfg
from services.payment_service import (
DuplicatePromoError,
PromoValidationError,
StripePromoCreationError,
create_promo_code,
deactivate_promo_code,
delete_promo_code,
link_promo_to_stripe,
load_promo_codes,
save_promo_codes,
)
from services.providers.config import LLM_CHANNELS
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME")
ADMIN_PASSWORD_HASH = os.getenv("ADMIN_PASSWORD_HASH")
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD")
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
ADMIN_PASSWORD = os.getenv("ADMIN_DEV_DEFAULT")
_admin_token_secret = os.getenv("ADMIN_TOKEN_SECRET")
if not _admin_token_secret:
_admin_token_secret = secrets.token_hex(32)
logger.critical(
"SECURITY: ADMIN_TOKEN_SECRET is not configured! Using an ephemeral random key. "
"ALL ADMIN SESSIONS WILL BE INVALIDATED ON EVERY RESTART. "
"Set ADMIN_TOKEN_SECRET in your .env file immediately."
)
ADMIN_TOKEN_SECRET = _admin_token_secret
REDIS_URL = os.getenv("REDIS_URL", "")
_redis_client = None
_memory_sessions: dict = {}
# Brute-force protection: IP → (failed_count, first_fail_ts)
_login_attempts: dict[str, tuple[int, float]] = {}
_MAX_LOGIN_ATTEMPTS = 5
_LOCKOUT_SECONDS = 300 # 5 minutes
def get_redis_client():
"""Return shared sync Redis client from core.redis, or None."""
from core.redis import get_sync_redis
return get_sync_redis()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_admin_password(password: str) -> bool:
if not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD:
return False
p = (password or "").strip()
if ADMIN_PASSWORD_HASH:
try:
return pwd_context.verify(p, ADMIN_PASSWORD_HASH)
except Exception:
return False
return p == (ADMIN_PASSWORD or "").strip()
def _get_session_key(token: str) -> str:
return f"admin_session:{token}"
def create_admin_token() -> str:
token = secrets.token_urlsafe(32)
expiry = int(time.time()) + (24 * 60 * 60)
redis_client = get_redis_client()
if redis_client:
try:
redis_client.setex(_get_session_key(token), 24 * 60 * 60, str(expiry))
except Exception as e:
logger.warning(f"Redis session save failed: {e}")
_memory_sessions[token] = expiry
else:
_memory_sessions[token] = expiry
return token
def verify_admin_token(token: str) -> bool:
redis_client = get_redis_client()
if redis_client:
try:
expiry = redis_client.get(_get_session_key(token))
if expiry and int(expiry) > time.time():
return True
return False
except Exception as e:
logger.warning(f"Redis session check failed: {e}")
if token not in _memory_sessions:
return False
if time.time() > _memory_sessions[token]:
del _memory_sessions[token]
return False
return True
def delete_admin_token(token: str):
redis_client = get_redis_client()
if redis_client:
try:
redis_client.delete(_get_session_key(token))
except Exception:
pass
if token in _memory_sessions:
del _memory_sessions[token]
async def require_admin(authorization: Optional[str] = Header(None)) -> str:
if not ADMIN_USERNAME or (not ADMIN_PASSWORD_HASH and not ADMIN_PASSWORD):
raise HTTPException(
status_code=503, detail="Admin authentication not configured"
)
if not authorization:
raise HTTPException(status_code=401, detail="Authorization header required")
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=401, detail="Invalid authorization format. Use: Bearer <token>"
)
token = parts[1]
if not verify_admin_token(token):
raise HTTPException(status_code=401, detail="Token invalide ou expiré")
return ADMIN_USERNAME
class AdminLoginRequest(BaseModel):
password: str
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)
def _get_client_ip(request: Request) -> str:
"""Get real client IP from headers or connection"""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip
if request.client:
return request.client.host
return "unknown"
@router.post("/login")
async def admin_login(request: AdminLoginRequest, req: Request):
"""Admin login endpoint - Returns a bearer token for authenticated admin access"""
client_ip = _get_client_ip(req)
# Brute-force protection
now = time.time()
attempts, first_fail = _login_attempts.get(client_ip, (0, now))
if attempts >= _MAX_LOGIN_ATTEMPTS:
elapsed = now - first_fail
if elapsed < _LOCKOUT_SECONDS:
remaining = int(_LOCKOUT_SECONDS - elapsed)
logger.warning(f"Admin login blocked (brute-force) for IP {client_ip}")
raise HTTPException(
status_code=429,
detail=f"Too many failed attempts. Try again in {remaining}s.",
headers={"Retry-After": str(remaining)},
)
else:
_login_attempts.pop(client_ip, None)
if not verify_admin_password(request.password):
count, first = _login_attempts.get(client_ip, (0, now))
_login_attempts[client_ip] = (count + 1, first if count > 0 else now)
logger.warning(f"Failed admin login attempt from {client_ip} ({count + 1}/{_MAX_LOGIN_ATTEMPTS})")
raise HTTPException(status_code=401, detail="Invalid credentials")
_login_attempts.pop(client_ip, None)
token = create_admin_token()
logger.info(f"Admin login successful from {client_ip}")
return {
"status": "success",
"access_token": token,
"token_type": "bearer",
"expires_in": 86400,
"message": "Login successful",
}
@router.post("/logout")
async def admin_logout(authorization: Optional[str] = Header(None)):
"""Logout and invalidate admin token"""
if authorization:
parts = authorization.split(" ")
if len(parts) == 2 and parts[0].lower() == "bearer":
token = parts[1]
delete_admin_token(token)
logger.info("Admin logout successful")
return {"status": "success", "message": "Logged out"}
@router.get("/verify")
async def verify_admin_session(admin_id: str = Depends(require_admin)):
"""Verify admin token is still valid"""
return {"status": "valid", "authenticated": True}
@router.get("/dashboard")
async def get_admin_dashboard(admin_id: str = Depends(require_admin)):
"""Get comprehensive admin dashboard data"""
from middleware.cleanup import create_cleanup_manager
from middleware.rate_limiting import RateLimitManager, RateLimitConfig
from services.translation_service import _translation_cache
cleanup_manager = create_cleanup_manager(config)
rate_limit_config = RateLimitConfig(
requests_per_minute=int(os.getenv("RATE_LIMIT_PER_MINUTE", "30")),
requests_per_hour=int(os.getenv("RATE_LIMIT_PER_HOUR", "200")),
translations_per_minute=int(os.getenv("TRANSLATIONS_PER_MINUTE", "10")),
translations_per_hour=int(os.getenv("TRANSLATIONS_PER_HOUR", "50")),
max_concurrent_translations=int(os.getenv("MAX_CONCURRENT_TRANSLATIONS", "5")),
)
rate_limit_manager = RateLimitManager(rate_limit_config)
health_status = {
"status": "healthy",
"timestamp": datetime.now(timezone.utc).isoformat(),
}
cleanup_stats = cleanup_manager.get_stats()
rate_limit_stats = rate_limit_manager.get_stats()
tracked_files = cleanup_manager.get_tracked_files()
providers_status = {}
try:
from services.providers.google_provider import get_google_provider
google_health = get_google_provider().health_check()
providers_status["google"] = google_health.model_dump()
except Exception as e:
providers_status["google"] = {
"name": "google",
"available": False,
"error": str(e)[:100],
"last_check": None,
}
# OCR status (scanned PDFs): configured via admin settings or env key.
try:
from services.mistral_ocr import MistralOCRClient
settings = load_settings()
mistral_key = (settings.mistral.api_key or "").strip() or os.getenv(
"MISTRAL_API_KEY", ""
).strip()
ocr_configured = bool(mistral_key) and (
settings.mistral.enabled
# enabled=False in a saved-but-untouched settings file must not
# hide an env-configured OCR (same rule as the translate route)
or not (settings.mistral.api_key or "").strip()
)
providers_status["mistral_ocr"] = {
"name": "mistral_ocr",
"available": ocr_configured,
"error": None
if ocr_configured
else "Non configuré — les PDF scannés seront refusés (MISTRAL_API_KEY)",
"last_check": None,
# No live call here: health is derived from configuration.
"config_only": True,
}
except Exception as e:
providers_status["mistral_ocr"] = {
"name": "mistral_ocr",
"available": False,
"error": str(e)[:100],
"last_check": None,
}
# LLM/translation engines configured via admin settings (key presence
# only — no live call, keys are never exposed).
try:
settings = load_settings()
def _engine_status(section_name: str, env_var: str, label: str):
section = getattr(settings, section_name, None)
key_set = bool(
((getattr(section, "api_key", None) or "").strip())
or os.getenv(env_var, "").strip()
)
providers_status[section_name] = {
"name": section_name,
"available": key_set,
"error": None if key_set else f"Clé absente ({env_var})",
"last_check": None,
"config_only": True,
"label": label,
}
_engine_status("openrouter", "OPENROUTER_API_KEY", "Traduction IA Éco")
_engine_status("openrouter_premium", "OPENROUTER_API_KEY", "Traduction IA Premium")
_engine_status("openai", "OPENAI_API_KEY", "OpenAI")
_engine_status("zai", "ZAI_API_KEY", "Grok (xAI)")
_engine_status("google_cloud", "GOOGLE_CLOUD_API_KEY", "Google Cloud")
except Exception as e:
logger.warning(f"admin dashboard engines status failed: {e}")
return {
"timestamp": health_status.get("timestamp"),
"status": health_status.get("status"),
"system": {"memory": {}, "disk": {}},
"providers": providers_status,
"cleanup": {**cleanup_stats, "tracked_files_count": len(tracked_files)},
"rate_limits": rate_limit_stats,
"config": {
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
"translation_service": config.TRANSLATION_SERVICE,
},
}
@router.get("/users")
async def get_admin_users(admin_id: str = Depends(require_admin)):
"""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:
from database.models import User as DBUser
db_users = session.query(DBUser).order_by(DBUser.created_at.desc()).all()
for db_user in db_users:
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()
)
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())
# 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}
@router.patch("/users/{user_id}")
async def patch_admin_user_tier(
user_id: str,
body: AdminUpdateUserTierRequest,
admin_id: str = Depends(require_admin),
):
"""Update a user's plan/tier - Admin only"""
from services.auth_service import get_user_by_id, update_user_plan
user = get_user_by_id(user_id)
if not user:
return JSONResponse(
status_code=404,
content={"error": "NOT_FOUND", "message": "User not found"},
)
updated = update_user_plan(user_id, body.plan)
if not updated:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PLAN",
"message": "Invalid plan. Allowed: free, starter, pro, business, enterprise",
"details": {
"allowed": ["free", "starter", "pro", "business", "enterprise"]
},
},
)
plan_value = (
updated.plan.value if hasattr(updated.plan, "value") else str(updated.plan)
)
new_tier = (
"pro"
if updated.plan in (PlanType.PRO, PlanType.BUSINESS, PlanType.ENTERPRISE)
else "free"
)
logger.info(
"admin_tier_change",
extra={
"event": "admin_tier_change",
"target_user_id": user_id,
"new_tier": new_tier,
"new_plan": plan_value,
"admin_id": "admin_session",
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return {
"data": {
"id": updated.id,
"email": updated.email,
"name": getattr(updated, "name", ""),
"plan": plan_value,
"tier": new_tier,
},
"meta": {},
}
@router.post("/users/{user_id}/password")
async def admin_reset_user_password(
user_id: str,
body: AdminResetPasswordRequest,
admin_id: str = 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(admin_id: str = Depends(require_admin)):
"""Get comprehensive admin statistics"""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
from services.translation_service import _translation_cache
users_data = load_users()
plan_distribution: dict = {}
total_docs_translated = 0
total_pages_translated = 0
active_users = 0
if USE_DATABASE and DATABASE_AVAILABLE:
from database.connection import get_sync_session
from database.models import User as DBUser
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
# Correction : TranslationCache expose stats() (get_stats n'a jamais existé —
# l'appel cassé renvoyait une erreur 500 sur GET /stats avant cette correction).
try:
cache_stats = _translation_cache.stats()
except Exception as e:
logger.warning("Cache stats unavailable: %s", e)
cache_stats = {}
# ------------------------------------------------------------------
# Statistiques enrichies (spec « Modèles & abonnements ») : revenus,
# MRR estimé, crédits achetés, liste d'attente, paliers IA utilisés.
# Toutes ces métriques sont best-effort : un échec ne casse pas /stats.
# ------------------------------------------------------------------
revenue = {
"collected_total": 0.0,
"collected_30d": 0.0,
"credits_purchased": 0.0,
"payments_30d": 0,
"currency": "EUR",
}
mrr_estimated = {"total": 0.0, "by_plan": {}}
ai_tier_usage = {"essential": 0, "premium": 0, "classic": 0, "other": 0}
waitlist_count = 0
# MRR estimé : pour chaque plan payé, abonnés × prix mensuel effectif
# (overrides admin inclus via get_effective_monthly_yearly).
for plan_value, count in plan_distribution.items():
try:
monthly, _yearly = pricing_cfg.get_effective_monthly_yearly(plan_value)
except Exception:
continue
if monthly and monthly > 0:
mrr_plan = round(monthly * count, 2)
mrr_estimated["by_plan"][plan_value] = mrr_plan
mrr_estimated["total"] = round(mrr_estimated["total"] + mrr_plan, 2)
try:
from routes.waitlist_routes import _load_waitlist
waitlist_count = len(_load_waitlist())
except Exception as e:
logger.debug("Waitlist count unavailable: %s", e)
if USE_DATABASE and DATABASE_AVAILABLE:
try:
from database.connection import get_sync_session
from database.models import PaymentHistory as DBPayment
from database.models import Translation as DBTranslation
from sqlalchemy import func as _sqlfunc
cutoff_30d = datetime.now(timezone.utc) - timedelta(days=30)
with get_sync_session() as session:
# Devise dominante (le site facture en EUR ; Stripe peut renvoyer
# une autre devise pour d'anciens paiements) — agrégation en SQL.
currency_totals = (
session.query(
DBPayment.currency,
_sqlfunc.sum(DBPayment.amount_cents),
)
.filter(DBPayment.status == "succeeded")
.group_by(DBPayment.currency)
.all()
)
if currency_totals:
main_currency, _main_total = max(
currency_totals, key=lambda row: (row[1] or 0)
)
def _sum_cents(extra_filters=()):
q = session.query(_sqlfunc.sum(DBPayment.amount_cents)).filter(
DBPayment.status == "succeeded",
DBPayment.currency == main_currency,
)
for f in extra_filters:
q = q.filter(f)
return (q.scalar() or 0) / 100.0
def _count_payments(extra_filters=()):
q = session.query(_sqlfunc.count(DBPayment.id)).filter(
DBPayment.status == "succeeded",
DBPayment.currency == main_currency,
)
for f in extra_filters:
q = q.filter(f)
return q.scalar() or 0
total = _sum_cents()
last30 = _sum_cents((DBPayment.created_at >= cutoff_30d,))
credits = _sum_cents((DBPayment.payment_type == "credits",))
n30 = _count_payments((DBPayment.created_at >= cutoff_30d,))
revenue.update(
collected_total=round(total, 2),
collected_30d=round(last30, 2),
credits_purchased=round(credits, 2),
payments_30d=n30,
currency=(main_currency or "eur").upper(),
)
# Répartition des paliers IA utilisés (traductions des 30 derniers jours).
tier_rows = (
session.query(DBTranslation.provider)
.filter(DBTranslation.created_at >= cutoff_30d)
.all()
)
for (prov,) in tier_rows:
p = (prov or "").strip().lower()
if p == "openrouter_premium":
ai_tier_usage["premium"] += 1
elif p in ("openrouter", "deepseek", "minimax", "zai"):
ai_tier_usage["essential"] += 1
elif p in ("google", "google_cloud"):
ai_tier_usage["classic"] += 1
else:
ai_tier_usage["other"] += 1
except Exception as e:
logger.warning("Revenue/tier stats unavailable: %s", e)
return {
"users": {
"total": total_users,
"active_this_month": active_users,
"by_plan": plan_distribution,
},
"translations": {
"docs_this_month": total_docs_translated,
"pages_this_month": total_pages_translated,
},
"revenue": revenue,
"mrr_estimated": mrr_estimated,
"ai_tier_usage": ai_tier_usage,
"waitlist_count": waitlist_count,
"cache": cache_stats,
"config": {
"translation_service": config.TRANSLATION_SERVICE,
"max_file_size_mb": config.MAX_FILE_SIZE_MB,
"supported_extensions": list(config.SUPPORTED_EXTENSIONS),
},
}
@router.post("/cleanup/trigger")
async def trigger_cleanup(admin_id: str = Depends(require_admin)):
"""Trigger manual cleanup of expired files"""
from middleware.cleanup import create_cleanup_manager
cleanup_manager = create_cleanup_manager(config)
try:
cleaned = await cleanup_manager.cleanup_expired()
return {
"status": "success",
"files_cleaned": cleaned,
"message": f"Cleaned up {cleaned} expired files",
}
except Exception as e:
logger.error(f"Manual cleanup failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Cleanup failed: {str(e)}")
@router.get("/files/tracked")
async def get_tracked_files(admin_id: str = Depends(require_admin)):
"""Get list of currently tracked files"""
from middleware.cleanup import create_cleanup_manager
cleanup_manager = create_cleanup_manager(config)
tracked = cleanup_manager.get_tracked_files()
return {"count": len(tracked), "files": tracked}
@router.post("/quota/reset")
async def reset_translation_quotas(admin_id: str = Depends(require_admin)):
"""Reset monthly translation quotas for all free-tier users.
Clears Redis keys matching quota:monthly:*
"""
try:
from core.redis import get_async_redis
redis_client = get_async_redis()
if not redis_client:
return {"status": "skipped", "message": "Redis not available, quotas are in-memory only"}
# Find all monthly quota keys
keys = []
async for key in redis_client.scan_iter(match="quota:monthly:*"):
keys.append(key)
if keys:
deleted = await redis_client.delete(*keys)
logger.info("admin_quota_reset", keys_deleted=deleted)
return {"status": "success", "keys_deleted": deleted, "message": f"Reset {deleted} quota counters"}
else:
return {"status": "success", "keys_deleted": 0, "message": "No active quotas to reset"}
except Exception as e:
logger.error(f"Admin quota reset failed: {str(e)}")
raise HTTPException(status_code=500, detail=f"Quota reset failed: {str(e)}")
@router.post("/config/provider")
async def update_default_provider(
provider: str = Form(...),
admin_id: str = Depends(require_admin),
):
"""Update the default translation provider.
The allowed list mirrors the providers actually wired in the translate
route (and declared in SettingsConfig) so the admin can set any of them as
the default — previously google_cloud, openrouter_premium, deepseek and
minimax were wrongly rejected here even though they are fully supported.
"""
valid_providers = [
"google",
"google_cloud",
"openai",
"openrouter",
"openrouter_premium",
"deepseek",
"minimax",
"zai",
# Mode aliases resolved at translation time.
"classic",
"llm",
]
if provider not in valid_providers:
raise HTTPException(
status_code=400,
detail=f"Invalid provider. Must be one of: {valid_providers}",
)
config.TRANSLATION_SERVICE = provider
return {
"status": "success",
"message": f"Default provider updated to {provider}",
"provider": provider,
}
class AdminRevokeApiKeyRequest(BaseModel):
reason: Optional[str] = None
@router.delete("/api-keys/{key_id}")
async def admin_revoke_api_key(
key_id: str,
body: Optional[AdminRevokeApiKeyRequest] = None,
admin_id: str = Depends(require_admin),
):
"""Revoke any user's API key - Admin only"""
from database.connection import get_sync_session
from database.models import ApiKey
revoke_reason = body.reason if body else None
with get_sync_session() as session:
api_key = (
session.query(ApiKey)
.filter(ApiKey.id == key_id, ApiKey.is_active == True)
.first()
)
if not api_key:
return JSONResponse(
status_code=404,
content={
"error": "API_KEY_NOT_FOUND",
"message": "Clé API non trouvée ou déjà révoquée",
},
)
owner_user_id = api_key.user_id
api_key.is_active = False
api_key.revoked_at = datetime.now(timezone.utc)
session.commit()
logger.info(
"admin_api_key_revoked",
extra={
"admin_id": admin_id,
"key_id": key_id,
"owner_user_id": owner_user_id,
"reason": revoke_reason,
"timestamp": datetime.now(timezone.utc).isoformat(),
},
)
return JSONResponse(
status_code=200,
content={
"data": {
"id": api_key.id,
"revoked": True,
"revoked_at": datetime.now(timezone.utc).isoformat(),
"owner_user_id": owner_user_id,
"reason": revoke_reason,
},
"meta": {},
},
)
def _extract_error_code(error_message: Optional[str]) -> Optional[str]:
"""Extract a short error code from error message for log display (NFR: no document content)."""
if not error_message or not error_message.strip():
return None
import re
m = re.search(r"\b([A-Z][A-Z0-9_]{2,})\b", error_message)
if m:
return m.group(1)
first = error_message.strip().split()[0] if error_message.strip() else ""
if first:
return first.upper()[:20]
return None
@router.get("/logs")
def get_admin_logs(
admin_id: str = Depends(require_admin),
level: str = Query(default="all", pattern="^(all|error|warning|info)$"),
search: str = Query(default="", max_length=200),
page: int = Query(default=1, ge=1),
per_page: int = Query(default=50, ge=1, le=200),
):
"""Get admin error logs from failed translations. No document content or original_filename exposed (NFR11, NFR16).
Search matches user_id and error_message (error codes typically appear in error_message)."""
from database.connection import get_sync_session
from database.models import Translation
from sqlalchemy import or_, desc
if level == "warning" or level == "info":
return {
"data": {
"logs": [],
"total": 0,
"page": page,
"per_page": per_page,
},
"meta": {"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
}
with get_sync_session() as session:
base = session.query(Translation).filter(Translation.status == "failed")
if search and search.strip():
term = f"%{search.strip()}%"
base = base.filter(
or_(
Translation.user_id.ilike(term),
Translation.error_message.ilike(term),
)
)
total = base.count()
rows = (
base.order_by(desc(Translation.created_at))
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
def _ts(created_at):
if not created_at:
return ""
s = created_at.isoformat()
return s.replace("+00:00", "Z") if "+00:00" in s else s + "Z"
logs = [
{
"timestamp": _ts(t.created_at),
"level": "error",
"message": (t.error_message or "Translation failed").strip()[:500],
"user_id": t.user_id,
"error_code": _extract_error_code(t.error_message),
"provider": t.provider,
"file_type": t.file_type,
}
for t in rows
]
return {
"data": {
"logs": logs,
"total": total,
"page": page,
"per_page": per_page,
},
"meta": {"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")},
}
SETTINGS_FILE = "data/provider_settings.json"
class ProviderSettings(BaseModel):
enabled: bool = False
api_key: Optional[str] = None
base_url: Optional[str] = None
model: Optional[str] = None
timeout: int = 30
max_retries: int = 3
class SmtpSettings(BaseModel):
enabled: bool = False
host: Optional[str] = None # SMTP_HOST
port: int = 587 # SMTP_PORT
username: Optional[str] = None # SMTP_USERNAME
password: Optional[str] = None # SMTP_PASSWORD
from_email: Optional[str] = None # SMTP_FROM_EMAIL
use_tls: bool = True
# Fournisseurs (canaux) utilisables dans une route de palier : source unique =
# LLM_CHANNELS (services.providers.config). Les canaux « openrouter »/
# « openrouter_premium » sont des passerelles multi-modèles ; les autres sont
# des accès directs compatibles OpenAI.
AI_TIER_PROVIDERS = tuple(LLM_CHANNELS.keys())
class AiTierRoute(BaseModel):
"""Route d'un palier : un modèle via un canal donné.
``provider`` = canal (openrouter, deepseek, zhipu, minimax, openai, zai…),
``model`` = identifiant du modèle chez ce canal, ``label`` = libellé libre.
"""
provider: str = "openrouter"
model: str = ""
label: str = ""
class AiTierSettings(BaseModel):
"""Palier IA : routes actives (l'ordre = priorité de secours) + modèle par défaut.
Deux écritures acceptées :
- ``routes`` : liste de routes ``{provider, model, label}`` (multi-canaux) ;
- ``models``/``default_model`` : ancien format (chaînes OpenRouter),
encore accepté et routé via openrouter comme auparavant.
``routes`` vide → repli sur ``default_model`` → premier de ``models`` →
défaut du plan (PLANS).
"""
models: List[str] = Field(default_factory=list)
default_model: str = ""
routes: List[AiTierRoute] = Field(default_factory=list)
class AiTiersSettings(BaseModel):
essential: AiTierSettings = Field(
default_factory=lambda: AiTierSettings(models=list(DEFAULT_AI_MODELS_ESSENTIAL))
)
premium: AiTierSettings = Field(
default_factory=lambda: AiTierSettings(models=list(DEFAULT_AI_MODELS_PREMIUM))
)
def _default_ai_tiers() -> AiTiersSettings:
return AiTiersSettings(
essential=AiTierSettings(models=list(DEFAULT_AI_MODELS_ESSENTIAL)),
premium=AiTierSettings(models=list(DEFAULT_AI_MODELS_PREMIUM)),
)
class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True)
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
openai: ProviderSettings = ProviderSettings()
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
openrouter_premium: ProviderSettings = ProviderSettings() # "Traduction IA Premium"
deepseek: ProviderSettings = ProviderSettings()
zhipu: ProviderSettings = ProviderSettings() # z.ai / Zhipu (GLM, direct)
minimax: ProviderSettings = ProviderSettings()
zai: ProviderSettings = ProviderSettings()
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
smtp: SmtpSettings = SmtpSettings()
ai_tiers: AiTiersSettings = Field(default_factory=_default_ai_tiers)
fallback_chain: str = "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai"
fallback_chain_classic: str = "google,google_cloud"
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,deepseek,zai"
def normalize_ai_tiers(ai_tiers: AiTiersSettings) -> AiTiersSettings:
"""Déduplique, nettoie et complète un bloc ai_tiers avec la gamme officielle.
Routes : fournisseur connu obligatoire, modèle non vide, dédoublonnage
(canal + modèle) — l'ordre est conservé (ordre = priorité de secours).
Lève ValueError si le défaut d'un palier appartient à la gamme de l'autre
palier (un défaut Essentielle doit rester un modèle Essentielle, et
inversement) : un utilisateur Pro ne doit jamais déclencher un modèle
Premium, et l'inverse serait une erreur de configuration. La même garde
s'applique aux routes openrouter (les modèles des autres canaux ont des
identifiants propres, le croisement n'a pas de sens pour eux).
"""
essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL)
premium_official = list(DEFAULT_AI_MODELS_PREMIUM)
def _clean_routes(tier: AiTierSettings, tier_name: str, other_official: List[str],
other_name: str) -> List[AiTierRoute]:
routes: List[AiTierRoute] = []
seen: set = set()
for route in (tier.routes or []):
provider = (route.provider or "").strip().lower()
model = (route.model or "").strip()
if not provider and not model:
continue
if provider not in AI_TIER_PROVIDERS:
raise ValueError(
f"ai_tiers.{tier_name}.routes : fournisseur « {provider} » inconnu "
f"(canaux acceptés : {', '.join(AI_TIER_PROVIDERS)})."
)
if not model:
raise ValueError(
f"ai_tiers.{tier_name}.routes : une route du canal « {provider} » "
"n'a pas de modèle."
)
key = (provider, model)
if key in seen:
continue
if provider == "openrouter" and model in other_official:
raise ValueError(
f"ai_tiers.{tier_name}.routes : « {model} » appartient à la gamme "
f"{other_name} : choisissez un modèle de la gamme {tier_name}."
)
seen.add(key)
routes.append(AiTierRoute(provider=provider, model=model,
label=(route.label or "").strip()))
return routes
def _fill(tier: AiTierSettings, official: List[str], other_official: List[str],
tier_name: str, other_name: str) -> AiTierSettings:
routes = _clean_routes(tier, tier_name, other_official, other_name)
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
models = list(dict.fromkeys(models)) # dédoublonnage, ordre conservé
if not models and not routes:
models = list(official)
default = (tier.default_model or "").strip()
if default and default not in models and not routes:
models.insert(0, default)
if default in other_official:
raise ValueError(
f"ai_tiers.{tier_name}.default_model « {default} » appartient à la "
f"gamme {other_name} : choisissez un modèle de la gamme {tier_name}."
)
return AiTierSettings(models=models, default_model=default, routes=routes)
return AiTiersSettings(
essential=_fill(ai_tiers.essential, essential_official, premium_official,
"essential", "Premium"),
premium=_fill(ai_tiers.premium, premium_official, essential_official,
"premium", "Essentielle"),
)
def load_settings() -> SettingsConfig:
try:
import json
from pathlib import Path
settings_path = Path(SETTINGS_FILE)
if settings_path.exists():
with open(settings_path) as f:
data = json.load(f)
return SettingsConfig(**data)
except Exception as e:
logger.warning(f"Failed to load settings: {e}")
return SettingsConfig()
def save_settings(settings: SettingsConfig):
import json
from pathlib import Path
settings_path = Path(SETTINGS_FILE)
settings_path.parent.mkdir(exist_ok=True)
with open(settings_path, "w") as f:
json.dump(settings.model_dump(), f, indent=2)
@router.get("/settings")
async def get_settings(admin_id: str = Depends(require_admin)):
settings = load_settings()
# Merge env-var values into provider configs when JSON has no value.
# Env vars fill models/URLs; API keys are never exposed (only hinted via env_info).
# If an admin explicitly saves a value in the UI, JSON takes priority.
def _merge_env(
provider_settings: ProviderSettings,
key_env: str = "",
model_env: str = "",
url_env: str = "",
default_model: str = "",
default_url: str = "",
) -> dict:
d = provider_settings.model_dump()
# Model: env var > JSON null > code default
if model_env and not d.get("model"):
d["model"] = os.getenv(model_env, "").strip() or default_model or None
elif not d.get("model") and default_model:
d["model"] = default_model
# Base URL: env var > JSON null > code default
if url_env and not d.get("base_url"):
d["base_url"] = os.getenv(url_env, "").strip() or default_url or None
elif not d.get("base_url") and default_url:
d["base_url"] = default_url
# API key: never expose; leave empty (UI shows "clé dans .env" badge via env_info)
return d
payload = settings.model_dump()
# Essentielle : DeepSeek V4 Flash / GLM-5.3 Flash / MiniMax M3 — meilleur rapport qualité/prix (sept. 2026)
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="deepseek/deepseek-v4-flash")
# Premium : Claude 5 (Sonnet) — précision maximale sur documents complexes
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-5")
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat")
payload["zhipu"] = _merge_env(settings.zhipu, key_env="ZHIPU_API_KEY", model_env="ZHIPU_MODEL", url_env="ZHIPU_BASE_URL", default_model="glm-5.3", default_url="https://api.z.ai/api/paas/v4")
payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="MiniMax-M3")
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["google_cloud"] = _merge_env(settings.google_cloud, key_env="GOOGLE_CLOUD_API_KEY")
payload["mistral"] = _merge_env(settings.mistral, key_env="MISTRAL_API_KEY", model_env="MISTRAL_OCR_MODEL", default_model="mistral-ocr-latest")
# SMTP: merge from env vars, but never expose password
smtp_data = settings.smtp.model_dump()
if not smtp_data["host"]:
smtp_data["host"] = os.getenv("SMTP_HOST", "").strip() or None
if not smtp_data["username"]:
smtp_data["username"] = os.getenv("SMTP_USERNAME", "").strip() or None
smtp_data["password"] = None # never expose
if not smtp_data["from_email"]:
smtp_data["from_email"] = os.getenv("SMTP_FROM_EMAIL", "").strip() or None
smtp_env_port = os.getenv("SMTP_PORT", "").strip()
if smtp_env_port and smtp_data["port"] == 587:
try:
smtp_data["port"] = int(smtp_env_port)
except ValueError:
pass
smtp_env_tls = os.getenv("SMTP_USE_TLS", "").strip().lower()
if smtp_env_tls in ("false", "0", "no"):
smtp_data["use_tls"] = False
payload["smtp"] = smtp_data
# Inform the frontend which providers have API keys configured via env vars
# (boolean only — never expose actual values)
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
env_info = {
"openai": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"openrouter": has_openrouter,
"openrouter_premium": has_openrouter, # same key, different model
"deepseek": bool(os.getenv("DEEPSEEK_API_KEY", "").strip()),
"zhipu": bool(os.getenv("ZHIPU_API_KEY", "").strip()),
"minimax": bool(os.getenv("MINIMAX_API_KEY", "").strip()),
"zai": bool(os.getenv("ZAI_API_KEY", "").strip()),
"google_cloud": bool(os.getenv("GOOGLE_CLOUD_API_KEY", "").strip()),
"mistral": bool(os.getenv("MISTRAL_API_KEY", "").strip()),
"smtp": bool(os.getenv("SMTP_HOST", "").strip()),
}
return JSONResponse(
status_code=200,
content={"data": payload, "env_info": env_info, "meta": {}},
)
def _ai_tiers_is_pristine(ai_tiers: AiTiersSettings) -> bool:
"""True si le bloc ai_tiers reçu est le défaut vierge (client qui ne gère
pas les paliers : liste vide/par défaut, aucune route et aucun modèle imposé)."""
def _is_default(tier: AiTierSettings, official: List[str]) -> bool:
models = [m.strip() for m in (tier.models or []) if m and m.strip()]
routes = [r for r in (tier.routes or []) if (r.model or "").strip()]
return (
(not models or models == list(official))
and not routes
and not (tier.default_model or "").strip()
)
return _is_default(ai_tiers.essential, list(DEFAULT_AI_MODELS_ESSENTIAL)) and _is_default(
ai_tiers.premium, list(DEFAULT_AI_MODELS_PREMIUM)
)
@router.put("/settings")
async def update_settings(
settings: SettingsConfig, admin_id: str = Depends(require_admin)
):
# Preserve SMTP password: frontend always sends null (never exposed via GET)
existing = load_settings()
if settings.smtp.password is None and existing.smtp.password:
settings.smtp.password = existing.smtp.password
# If frontend sends a new non-empty password, keep it; if empty string, clear it
if settings.smtp.password is not None:
settings.smtp.password = settings.smtp.password.strip() or None
# Paliers IA : un client qui renvoie le bloc par défaut vierge (liste vide ou
# gamme officielle, aucun défaut) n'écrase pas les paliers personnalisés
# existants — sinon chaque sauvegarde d'une autre page réinitialiserait la
# matrice « Modèles & abonnements ».
if _ai_tiers_is_pristine(settings.ai_tiers) and existing.ai_tiers != _default_ai_tiers():
settings.ai_tiers = existing.ai_tiers
# Paliers IA : dédoublonne et complète avec la gamme officielle (jamais vide).
# ValueError → 400 si un défaut appartient à la gamme du palier opposé.
try:
settings.ai_tiers = normalize_ai_tiers(settings.ai_tiers)
except ValueError as e:
return JSONResponse(
status_code=400,
content={"error": "INVALID_AI_TIER", "message": str(e)},
)
save_settings(settings)
logger.info(f"admin_settings_updated by {admin_id}")
return JSONResponse(
status_code=200, content={"data": settings.model_dump(), "meta": {}}
)
class SmtpTestRequest(BaseModel):
"""Optional body for SMTP test — allows testing unsaved form values."""
host: Optional[str] = None
port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
from_email: Optional[str] = None
use_tls: Optional[bool] = None
@router.post("/providers/{provider}/test")
async def test_provider(
provider: str,
admin_id: str = Depends(require_admin),
smtp_body: Optional[SmtpTestRequest] = None,
):
"""Test a provider connection. Works even when provider is disabled.
Always falls back to env vars when the JSON api_key is empty.
For SMTP, accepts an optional JSON body with current form values."""
settings = load_settings()
provider_config = getattr(settings, provider, None)
if not provider_config:
return JSONResponse(
status_code=404,
content={
"error": "PROVIDER_NOT_FOUND",
"message": f"Provider {provider} not found",
},
)
# Helper: resolve api_key from JSON config first, then env var
def _key(json_val: Optional[str], env_var: str) -> str:
return (json_val or "").strip() or os.getenv(env_var, "").strip()
try:
if provider == "google":
from deep_translator import GoogleTranslator
result = GoogleTranslator(source="auto", target="en").translate("bonjour")
return JSONResponse(
status_code=200, content={"available": True, "test_result": result}
)
elif provider == "mistral":
api_key = _key(provider_config.api_key, "MISTRAL_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={
"available": False,
"error": "Aucune clé API Mistral trouvée (JSON ou .env MISTRAL_API_KEY).",
},
)
import requests as _requests
resp = _requests.get(
"https://api.mistral.ai/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if resp.ok:
n_models = len(resp.json().get("data", []))
return JSONResponse(
status_code=200,
content={
"available": True,
"test_result": f"Clé valide — {n_models} modèles accessibles",
},
)
elif resp.status_code in (401, 403):
return JSONResponse(
status_code=resp.status_code,
content={
"available": False,
"error": f"Clé API Mistral invalide (HTTP {resp.status_code}).",
},
)
else:
return JSONResponse(
status_code=500,
content={
"available": False,
"error": f"Erreur Mistral HTTP {resp.status_code}: {resp.text[:200]}",
},
)
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 == "openai":
api_key = _key(provider_config.api_key, "OPENAI_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API OpenAI trouvée (JSON ou .env)"},
)
import openai as _openai
client = _openai.OpenAI(api_key=api_key, timeout=15)
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={"available": True, "models_count": len(models)},
)
elif provider in ("deepseek", "minimax", "zhipu"):
# Fournisseurs directs compatibles OpenAI : la liste des modèles
# sert de test de connexion (clé + URL de base).
key_env = {
"deepseek": "DEEPSEEK_API_KEY",
"minimax": "MINIMAX_API_KEY",
"zhipu": "ZHIPU_API_KEY",
}[provider]
api_key = _key(provider_config.api_key, key_env)
if not api_key:
return JSONResponse(
status_code=400,
content={
"available": False,
"error": f"Aucune clé API trouvée pour {provider} (JSON ou .env)",
},
)
import openai as _openai
base_url = (provider_config.base_url or "").strip()
if not base_url:
from services.providers.config import LLM_CHANNELS
url_env = LLM_CHANNELS.get(provider, {}).get("url_env", "")
base_url = (os.getenv(url_env, "").strip() if url_env else "") or LLM_CHANNELS[provider]["base_url"]
client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
try:
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={
"available": True,
"models_count": len(models),
"sample_models": [m.id for m in models[:5]],
},
)
except Exception as api_err:
return JSONResponse(
status_code=502,
content={
"available": False,
"error": f"{provider} injoignable ({str(api_err)[:200]})",
},
)
elif provider in ("openrouter", "openrouter_premium"):
api_key = _key(provider_config.api_key, "OPENROUTER_API_KEY")
if not api_key:
# openrouter_premium shares the same key as openrouter
api_key = os.getenv("OPENROUTER_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API OpenRouter trouvée (JSON ou .env)"},
)
import requests as _requests
resp = _requests.get(
"https://openrouter.ai/api/v1/auth/key",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
if resp.ok:
data = resp.json()
return JSONResponse(
status_code=200,
content={"available": True, "label": data.get("data", {}).get("label", "OK")},
)
return JSONResponse(
status_code=500, content={"available": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}"}
)
elif provider == "zai":
api_key = _key(provider_config.api_key, "ZAI_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API xAI trouvée (JSON ou .env)"},
)
import openai as _openai
base_url = (provider_config.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
try:
models = list(client.models.list())
return JSONResponse(
status_code=200,
content={
"available": True,
"models_count": len(models),
"sample_models": [m.id for m in models[:5]],
},
)
except _openai.AuthenticationError:
return JSONResponse(
status_code=401,
content={"available": False, "error": "Clé xAI invalide"},
)
elif provider == "smtp":
import smtplib as _smtplib
# Priority: request body (form values) > saved settings > env vars
host = ((smtp_body and smtp_body.host) or "").strip() or (provider_config.host or "").strip() or os.getenv("SMTP_HOST", "").strip()
if not host:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucun hôte SMTP configuré (JSON ou .env SMTP_HOST)"},
)
port = (smtp_body and smtp_body.port) or provider_config.port or 587
try:
port = int(os.getenv("SMTP_PORT", "").strip() or port)
except ValueError:
port = 587
username = ((smtp_body and smtp_body.username) or "").strip() or (provider_config.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
password = ((smtp_body and smtp_body.password) or "").strip() or (provider_config.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (provider_config.use_tls if provider_config.use_tls is not None else True)
try:
server = _smtplib.SMTP(host, port, timeout=10)
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.quit()
return JSONResponse(
status_code=200,
content={"available": True, "test_result": f"Connexion SMTP OK ({host}:{port})"},
)
except _smtplib.SMTPAuthenticationError as e:
return JSONResponse(
status_code=401,
content={"available": False, "error": f"Authentification SMTP échouée: {e}"},
)
except _smtplib.SMTPConnectError as e:
return JSONResponse(
status_code=502,
content={"available": False, "error": f"Connexion SMTP échouée: {e}"},
)
except Exception as e:
return JSONResponse(
status_code=500,
content={"available": False, "error": f"Erreur SMTP: {str(e)[:200]}"},
)
else:
return JSONResponse(
status_code=404,
content={"available": False, "error": "Provider inconnu"},
)
except Exception as e:
logger.error(f"Provider test failed: {e}")
return JSONResponse(
status_code=500, content={"available": False, "error": str(e)}
)
@router.post("/providers/smtp/test-send")
async def test_send_email(
smtp_body: Optional[SmtpTestRequest] = None,
admin_id: str = Depends(require_admin),
):
"""Envoie un email de test a l'adresse from_email configuree.
Accepte un body JSON optionnel avec les valeurs du formulaire en cours."""
import smtplib as _smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
settings = load_settings()
smtp = settings.smtp
# Priority: request body (form values) > saved settings > env vars
host = ((smtp_body and smtp_body.host) or "").strip() or (smtp.host or "").strip() or os.getenv("SMTP_HOST", "").strip()
if not host:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucun hôte SMTP configuré"},
)
port = (smtp_body and smtp_body.port) or smtp.port or 587
try:
port = int(os.getenv("SMTP_PORT", "").strip() or port)
except ValueError:
port = 587
username = ((smtp_body and smtp_body.username) or "").strip() or (smtp.username or "").strip() or os.getenv("SMTP_USERNAME", "").strip()
password = ((smtp_body and smtp_body.password) or "").strip() or (smtp.password or "").strip() or os.getenv("SMTP_PASSWORD", "").strip()
from_email = ((smtp_body and smtp_body.from_email) or "").strip() or (smtp.from_email or "").strip() or os.getenv("SMTP_FROM_EMAIL", "").strip() or username
if from_email:
from_email = from_email.replace("\r", "").replace("\n", "")
use_tls = (smtp_body and smtp_body.use_tls) if (smtp_body and smtp_body.use_tls is not None) else (smtp.use_tls if smtp.use_tls is not None else True)
if not from_email:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune adresse d'expédition configurée (from_email ou SMTP_FROM_EMAIL)"},
)
msg = MIMEMultipart()
msg["From"] = from_email
msg["To"] = from_email
msg["Subject"] = "Test Office Translator — Email SMTP"
msg.attach(MIMEText(
"Ceci est un email de test envoyé depuis la page d'administration Office Translator.\n\n"
"Si vous recevez cet email, la configuration SMTP est correcte.",
"plain", "utf-8"
))
try:
server = _smtplib.SMTP(host, port, timeout=15)
server.ehlo()
if use_tls:
server.starttls()
server.ehlo()
if username and password:
server.login(username, password)
server.sendmail(from_email, from_email, msg.as_string())
server.quit()
logger.info(f"admin_smtp_test_email sent to {from_email}")
return JSONResponse(
status_code=200,
content={"available": True, "test_result": f"Email de test envoyé à {from_email}"},
)
except _smtplib.SMTPAuthenticationError as e:
return JSONResponse(
status_code=401,
content={"available": False, "error": f"Authentification SMTP échouée: {e}"},
)
except Exception as e:
logger.error(f"SMTP test-send failed: {e}")
return JSONResponse(
status_code=500,
content={"available": False, "error": f"Erreur envoi email: {str(e)[:200]}"},
)
@router.get("/providers/openai/models")
async def list_openai_models(admin_id: str = Depends(require_admin)):
"""List available models from OpenAI API"""
settings = load_settings()
api_key = (settings.openai.api_key or "").strip() or os.getenv("OPENAI_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"error": "NO_API_KEY", "message": "Aucune clé API OpenAI trouvée (JSON ou .env)"},
)
try:
import openai as _openai
client = _openai.OpenAI(api_key=api_key)
raw_models = list(client.models.list())
models = [
{"id": m.id, "owned_by": m.owned_by, "created": m.created}
for m in raw_models
]
return JSONResponse(
status_code=200,
content={"data": models, "meta": {"total": len(models)}},
)
except Exception as e:
logger.error(f"List OpenAI models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)
@router.get("/providers/openrouter/models")
async def list_openrouter_models(admin_id: str = Depends(require_admin)):
"""List available models from OpenRouter (public endpoint, no API key needed)"""
try:
import requests as _requests
resp = _requests.get(
"https://openrouter.ai/api/v1/models",
timeout=15,
)
if not resp.ok:
return JSONResponse(
status_code=502,
content={"error": "OPENROUTER_ERROR", "message": f"OpenRouter returned HTTP {resp.status_code}"},
)
data = resp.json()
raw = data.get("data", [])
models = [
{
"id": m.get("id", ""),
"name": m.get("name", ""),
"context_length": m.get("context_length"),
"pricing": m.get("pricing", {}),
}
for m in raw
]
return JSONResponse(
status_code=200,
content={"data": models, "meta": {"total": len(models)}},
)
except Exception as e:
logger.error(f"List OpenRouter models failed: {e}")
return JSONResponse(
status_code=500,
content={"error": "INTERNAL_ERROR", "message": str(e)},
)
@router.get("/providers/zai/models")
async def list_zai_models(admin_id: str = Depends(require_admin)):
"""List available models from xAI / zAI (OpenAI-compatible API)"""
settings = load_settings()
api_key = (settings.zai.api_key or "").strip() or os.getenv("ZAI_API_KEY", "").strip()
if not api_key:
return JSONResponse(
status_code=400,
content={"error": "NO_API_KEY", "message": "Aucune clé API xAI trouvée (JSON ou .env)"},
)
try:
import openai as _openai
base_url = (settings.zai.base_url or "").strip() or os.getenv("ZAI_BASE_URL", "https://api.x.ai/v1")
client = _openai.OpenAI(api_key=api_key, base_url=base_url)
raw_models = list(client.models.list())
models = [
{"id": m.id, "owned_by": m.owned_by, "created": m.created}
for m in raw_models
]
return JSONResponse(
status_code=200,
content={"data": models, "meta": {"total": len(models)}},
)
except Exception as e:
logger.error(f"List xAI models failed: {e}")
return JSONResponse(
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_"))
mode = pricing_cfg.stripe_mode()
is_test_in_prod = pricing_cfg.is_test_mode_in_production()
return JSONResponse(status_code=200, content={
"data": result,
"stripe": {
"configured": stripe_configured,
"mode": mode, # "live" | "test" | "unknown"
"is_test_mode_in_production": is_test_in_prod,
"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,
"env": (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "development").lower(),
},
})
@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(request: Request, 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.",
})
# Block test-mode auto-setup in production unless the admin forces it.
# Otherwise we'd create TEST products in the LIVE account context,
# which silently fails for real users and corrupts the prod pricing.
body: dict = {}
try:
body = await request.json()
except Exception:
body = {}
force = bool(body.get("force", False))
if pricing_cfg.is_test_mode_in_production() and not force:
return JSONResponse(status_code=409, content={
"error": "TEST_MODE_IN_PRODUCTION",
"message": (
"Vous êtes en production (ENV=production) mais STRIPE_SECRET_KEY "
"est une clé de test (sk_test_…). Créez des produits TEST dans un "
"compte TEST ou basculez sur une clé sk_live_… . Pour forcer, "
"relancez avec {\"force\": true} (à vos risques)."
),
"mode": pricing_cfg.stripe_mode(),
})
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": {},
})
@router.post("/pricing/setup-webhook")
async def setup_stripe_webhook(request: Request, admin_id: str = Depends(require_admin)):
"""Auto-create the Stripe webhook endpoint and save its signing secret.
The webhook URL is derived from the request's host headers
(X-Forwarded-Proto + Host). In production, the URL must be HTTPS
and publicly reachable from Stripe.
Body (optional):
- ``url_override`` (str): explicit webhook URL if auto-detection
doesn't match your deployment (e.g. behind a custom reverse proxy)
- ``force`` (bool): if true, allow auto-setup in test mode even
when running in production (useful for staging)
"""
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.",
})
mode = pricing_cfg.stripe_mode()
is_test_in_prod = pricing_cfg.is_test_mode_in_production()
# Read optional body
body: dict = {}
try:
body = await request.json()
except Exception:
body = {}
force = bool(body.get("force", False))
url_override = (body.get("url_override") or "").strip()
if is_test_in_prod and not force:
return JSONResponse(status_code=409, content={
"error": "TEST_MODE_IN_PRODUCTION",
"message": (
"Vous êtes en production (ENV=production) mais STRIPE_SECRET_KEY "
"est une clé de test (sk_test_…). Basculez sur sk_live_… ou "
"relancez avec {\"force\": true} pour créer un webhook test "
"explicitement (à des fins de staging)."
),
"mode": mode,
})
# Build the webhook URL from the request (host + forwarded proto)
if url_override:
webhook_url = url_override
else:
xfp = request.headers.get("x-forwarded-proto", "")
scheme = xfp.split(",")[0].strip() if xfp else (request.url.scheme or "https")
host = (
request.headers.get("x-forwarded-host")
or request.headers.get("host")
or request.url.netloc
)
webhook_url = f"{scheme}://{host}/api/v1/webhook/stripe"
# Basic sanity check
if not webhook_url.startswith(("http://", "https://")):
return JSONResponse(status_code=400, content={
"error": "INVALID_URL",
"message": f"URL webhook invalide : {webhook_url!r}",
})
if mode == "live" and webhook_url.startswith("http://"):
return JSONResponse(status_code=400, content={
"error": "INSECURE_URL_FOR_LIVE_MODE",
"message": (
"En mode Live, l'URL du webhook doit être HTTPS. "
f"URL actuelle : {webhook_url}. Utilisez url_override=https://…"
),
})
try:
import stripe as _stripe
_stripe.api_key = secret_key
# The events the backend actually handles (see payment_service.handle_webhook).
# If we send more events than the backend expects, Stripe will retry on
# signature mismatches and waste API quota. Keep this list aligned with
# the handler.
events = [
"checkout.session.completed",
"customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.payment_failed",
]
# Avoid duplicates: list existing endpoints and reuse one that already
# points to the same URL.
existing_list = _stripe.WebhookEndpoint.list(limit=20)
existing = next(
(e for e in existing_list.data if e.url == webhook_url),
None,
)
if existing:
# Update the existing endpoint to make sure its events are in sync
webhook = _stripe.WebhookEndpoint.update(
existing.id,
enabled_events=events,
)
created = False
else:
webhook = _stripe.WebhookEndpoint.create(
url=webhook_url,
enabled_events=events,
description="Office Translator — auto-created by admin panel",
)
created = True
# ``webhook.secret`` is only returned at creation time by Stripe. On
# update it is None, so we re-use the existing STRIPE_WEBHOOK_SECRET
# if the endpoint already existed.
new_secret = webhook.get("secret") or os.getenv("STRIPE_WEBHOOK_SECRET", "").strip()
env_updates = {}
if new_secret:
pricing_cfg.validate_stripe_webhook_secret(new_secret)
env_updates["STRIPE_WEBHOOK_SECRET"] = new_secret
if env_updates:
_update_env_file(env_updates)
pricing_cfg.apply_runtime_config_after_admin_write()
logger.info(
f"admin_stripe_webhook_setup by {admin_id}: url={webhook_url} "
f"created={created} mode={mode}"
)
return JSONResponse(status_code=200, content={
"data": {
"id": webhook.id,
"url": webhook.url,
"enabled_events": webhook.enabled_events,
"status": webhook.status,
"created": created,
"secret_saved": bool(new_secret),
},
"meta": {
"mode": mode,
"env": (os.getenv("ENV", os.getenv("ENVIRONMENT", "development")) or "development").lower(),
},
})
except ImportError:
return JSONResponse(status_code=500, content={
"error": "STRIPE_NOT_INSTALLED",
"message": "Le module stripe n'est pas installé sur le serveur.",
})
except Exception as e:
logger.exception("admin_stripe_webhook_setup_failed")
return JSONResponse(status_code=500, content={
"error": "STRIPE_API_ERROR",
"message": str(e),
})
# ============================================================
# MARKETING (relances par email — admin + désabonnement public)
# Audiences : liste d'attente (data/waitlist.json), inactifs 30 j,
# par plan (plan:<id>) et tous les comptes. Historique et
# désabonnements dans des fichiers JSON (même motif que waitlist).
# ============================================================
MARKETING_HISTORY_FILE = config.BASE_DIR / "data" / "marketing_emails.json"
UNSUBSCRIBES_FILE = config.BASE_DIR / "data" / "unsubscribes.json"
_marketing_lock = threading.Lock()
# Empreintes des campagnes réelles en cours d'envoi (anti-simultanéité).
_active_campaign_hashes: set = set()
# Audiences officielles exposées à l'admin (« par plan » couvre tous les plans).
MARKETING_BASE_AUDIENCES = ["waitlist", "inactive_30", "plan:pro", "all_users"]
def _load_unsubscribes() -> List[dict]:
if not UNSUBSCRIBES_FILE.exists():
return []
try:
with open(UNSUBSCRIBES_FILE, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception as e:
logger.warning(f"Failed to load unsubscribes: {e}")
return []
def _save_unsubscribes(entries: List[dict]) -> None:
UNSUBSCRIBES_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(UNSUBSCRIBES_FILE, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
def _unsubscribed_emails() -> set:
return {
(e.get("email") or "").strip().lower()
for e in _load_unsubscribes()
if e.get("email")
}
def _load_marketing_history() -> List[dict]:
if not MARKETING_HISTORY_FILE.exists():
return []
try:
with open(MARKETING_HISTORY_FILE, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except Exception as e:
logger.warning(f"Failed to load marketing history: {e}")
return []
def _save_marketing_history(entries: List[dict]) -> None:
MARKETING_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(MARKETING_HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
def _all_accounts() -> List[dict]:
"""Comptes utilisateurs (email, plan, activité, création) — base de données ou users.json."""
from services.auth_service import USE_DATABASE, DATABASE_AVAILABLE, load_users
accounts: List[dict] = []
seen: set = set()
def _add(email: Optional[str], plan: str = "free", docs: int = 0, created_at=None):
em = (email or "").strip().lower()
if not em or em in seen:
return
seen.add(em)
accounts.append(
{
"email": em,
"plan": plan,
"docs_this_month": docs,
"created_at": created_at,
}
)
if USE_DATABASE and DATABASE_AVAILABLE:
try:
from database.connection import get_sync_session
from database.models import User as DBUser
with get_sync_session() as session:
for u in session.query(DBUser).all():
pv = u.plan.value if hasattr(u.plan, "value") else str(u.plan)
_add(
u.email,
pv,
int(u.docs_translated_this_month or 0),
u.created_at,
)
except Exception as e:
logger.warning("Marketing audiences: DB users unavailable: %s", e)
for ud in load_users().values():
pr = ud.get("plan", "free")
pv = pr.value if hasattr(pr, "value") else str(pr)
try:
docs = int(ud.get("docs_translated_this_month", 0) or 0)
except (TypeError, ValueError):
docs = 0
_add(ud.get("email"), pv, docs, ud.get("created_at"))
return accounts
def _parse_account_created_at(value) -> Optional[datetime]:
"""created_at d'un compte (ISO string ou datetime) en datetime UTC."""
if value is None:
return None
if isinstance(value, datetime):
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
try:
parsed = datetime.fromisoformat(str(value))
return parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed
except (TypeError, ValueError):
return None
def _is_inactive_30(account: dict, cutoff_30d: datetime) -> bool:
"""Inactif 30 j : aucune traduction ce mois ET compte créé il y a ≥ 30 jours
(un compte créé aujourd'hui n'est pas « inactif depuis 30 jours »).
Un compte sans date de création connue reste éligible (comportement prudent)."""
if account["docs_this_month"] != 0:
return False
created = _parse_account_created_at(account.get("created_at"))
return created is None or created <= cutoff_30d
def _resolve_audience(audience: str) -> List[dict]:
"""Destinataires d'une audience, désabonnés exclus et dédoublonnés."""
a = (audience or "").strip().lower()
unsub = _unsubscribed_emails()
recipients: List[dict] = []
if a == "waitlist":
from routes.waitlist_routes import _load_waitlist
for entry in _load_waitlist():
recipients.append({"email": (entry.get("email") or "").strip().lower()})
else:
accounts = _all_accounts()
if a == "all_users":
recipients = accounts
elif a == "inactive_30":
# Approximation « inactif » : aucune traduction ce mois-ci ET compte
# créé il y a au moins 30 jours (un nouveau compte n'est pas inactif).
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
recipients = [acc for acc in accounts if _is_inactive_30(acc, cutoff)]
elif a.startswith("plan:"):
plan_id = a.split(":", 1)[1].strip()
recipients = [acc for acc in accounts if acc["plan"] == plan_id]
else:
return []
# Dédoublonnage + exclusion des désabonnés (jamais d'envoi à un désabonné).
final: List[dict] = []
seen: set = set()
for r in recipients:
em = (r.get("email") or "").strip().lower()
if not em or em in seen or em in unsub:
continue
seen.add(em)
final.append(r)
return final
def _marketing_content_hash(subject: str, html: str) -> str:
return hashlib.sha256(f"{subject}\n{html}".encode("utf-8")).hexdigest()
def _unsubscribe_link(email: str) -> str:
base = (
os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
)
from urllib.parse import quote
return f"{base}/api/v1/marketing/unsubscribe?email={quote(email)}"
def _marketing_extra_headers(email: str) -> dict:
"""En-têtes de délivrabilité (RFC 2369 / RFC 8058) : désabonnement en un clic."""
return {
"List-Unsubscribe": f"<{_unsubscribe_link(email)}>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
def _append_unsubscribe_footer(html: str, email: str) -> str:
link = _unsubscribe_link(email)
footer = (
'<div style="margin-top:32px;padding-top:16px;border-top:1px solid #e2e8f0;'
'font-size:11px;color:#64748b;text-align:center;">'
"Vous recevez cet email car vous avez un compte Wordly.art. "
f'<a href="{link}" style="color:#64748b;">Me désabonner</a></div>'
)
match = re.search(r"</body>", html, re.IGNORECASE)
if match:
idx = match.start()
return html[:idx] + footer + html[idx:]
return html + footer
class MarketingEmailRequest(BaseModel):
audience: str
subject: str = Field(..., min_length=1, max_length=200)
html: str = Field(..., min_length=1)
test_mode: bool = True
test_email: Optional[str] = None
@router.get("/marketing/audiences")
async def marketing_audiences(admin_id: str = Depends(require_admin)):
"""Comptes disponibles par audience (désabonnés exclus des comptes)."""
unsub = _unsubscribed_emails()
accounts = _all_accounts()
try:
from routes.waitlist_routes import _load_waitlist
waitlist_emails = [
(e.get("email") or "").strip().lower() for e in _load_waitlist()
]
except Exception:
waitlist_emails = []
def _count(items: List[dict]) -> int:
return len(
[
r
for r in items
if r.get("email") and r["email"].lower() not in unsub
]
)
cutoff_inactive = datetime.now(timezone.utc) - timedelta(days=30)
audiences = {
"waitlist": len([e for e in waitlist_emails if e and e not in unsub]),
"inactive_30": _count(
[acc for acc in accounts if _is_inactive_30(acc, cutoff_inactive)]
),
"all_users": _count(accounts),
}
for pt in PlanType:
audiences[f"plan:{pt.value}"] = _count(
[a for a in accounts if a["plan"] == pt.value]
)
return JSONResponse(
status_code=200,
content={
"data": {
"audiences": audiences,
"unsubscribed_count": len(unsub),
},
"meta": {},
},
)
class MarketingAudienceRequest(BaseModel):
audience: str
@router.post("/marketing/audiences")
async def marketing_audience_preview(
body: MarketingAudienceRequest, admin_id: str = Depends(require_admin)
):
"""Aperçu d'une audience arbitraire (ex. plan:starter) : nombre de
destinataires après exclusion des désabonnés, jamais leurs adresses."""
a = (body.audience or "").strip().lower()
known = a in MARKETING_BASE_AUDIENCES or a.startswith("plan:")
if not known:
return JSONResponse(
status_code=400,
content={
"error": "UNKNOWN_AUDIENCE",
"message": "Audience inconnue (waitlist, inactive_30, plan:<id>, all_users).",
},
)
recipients = _resolve_audience(a)
return JSONResponse(
status_code=200,
content={"data": {"audience": a, "count": len(recipients)}, "meta": {}},
)
@router.get("/marketing/email/history")
async def marketing_email_history(admin_id: str = Depends(require_admin)):
"""Historique des envois marketing (les plus récents d'abord)."""
history = _load_marketing_history()
history = sorted(history, key=lambda e: e.get("sent_at", ""), reverse=True)[:100]
return JSONResponse(status_code=200, content={"data": history, "meta": {}})
@router.post("/marketing/email/send")
async def marketing_email_send(
body: MarketingEmailRequest, admin_id: str = Depends(require_admin)
):
"""Envoi d'un email de relance marketing.
- ``test_mode=True`` : envoi unique à l'adresse de test (admin ou expéditeur SMTP).
- ``test_mode=False`` : envoi réel à l'audience ; exige un envoi test
préalable au contenu identique (jamais d'envoi en masse « à l'aveugle »).
Les désabonnés sont exclus de toute audience. Les échecs SMTP individuels
n'interrompent pas l'envoi et jamais en erreur HTTP 500.
"""
from services.email_service import is_smtp_configured, send_email_async
if not is_smtp_configured():
return JSONResponse(
status_code=400,
content={
"error": "SMTP_NOT_CONFIGURED",
"message": "Aucun SMTP configuré (réglages Fournisseurs ou .env).",
},
)
subject = body.subject.strip()
html = body.html.strip()
if not subject or not html:
return JSONResponse(
status_code=400,
content={"error": "INVALID_CONTENT", "message": "Sujet et HTML requis."},
)
content_hash = _marketing_content_hash(subject, html)
# --- Envoi test : un seul destinataire (admin ou adresse fournie) ---
if body.test_mode:
settings = load_settings()
recipient = (
(body.test_email or "").strip()
or (settings.smtp.from_email or "").strip()
or os.getenv("SMTP_FROM_EMAIL", "").strip()
)
if not recipient:
return JSONResponse(
status_code=400,
content={
"error": "NO_TEST_RECIPIENT",
"message": "Indiquez une adresse de test ou configurez from_email.",
},
)
sent = await send_email_async(
recipient,
subject,
_append_unsubscribe_footer(html, recipient),
_marketing_extra_headers(recipient),
)
entry = {
"id": uuid.uuid4().hex,
"sent_at": datetime.now(timezone.utc).isoformat(),
"audience": body.audience,
"subject": subject,
"test_mode": True,
"test_recipient": recipient,
"recipients_total": 1,
"sent_count": 1 if sent else 0,
"failed_count": 0 if sent else 1,
"failed": [] if sent else [{"email": recipient, "error": "send_email_async a renvoyé False"}],
"content_hash": content_hash,
}
with _marketing_lock:
history = _load_marketing_history()
history.append(entry)
_save_marketing_history(history)
if not sent:
return JSONResponse(
status_code=502,
content={
"error": "TEST_SEND_FAILED",
"message": "L'envoi test a échoué (voir les logs SMTP).",
"data": entry,
},
)
return JSONResponse(
status_code=200,
content={
"data": {
"test_mode": True,
"sent": 1,
"failed": 0,
"recipient": recipient,
},
"meta": {},
},
)
# --- Envoi réel : un test au contenu identique est obligatoire ---
history = _load_marketing_history()
has_matching_test = any(
e.get("content_hash") == content_hash and e.get("test_mode") and e.get("sent_count", 0) > 0
for e in history
)
if not has_matching_test:
return JSONResponse(
status_code=400,
content={
"error": "TEST_SEND_REQUIRED",
"message": "Effectuez d'abord un envoi test de ce contenu exact (sujet + HTML).",
},
)
recipients = _resolve_audience(body.audience)
if not recipients:
return JSONResponse(
status_code=400,
content={
"error": "AUDIENCE_EMPTY",
"message": "Aucun destinataire dans cette audience (ou tous désabonnés).",
},
)
# Une seule campagne à la fois, même contenu ou non.
with _marketing_lock:
if content_hash in _active_campaign_hashes:
return JSONResponse(
status_code=409,
content={
"error": "CAMPAIGN_ALREADY_RUNNING",
"message": "Un envoi de cette campagne est déjà en cours.",
},
)
_active_campaign_hashes.add(content_hash)
entry = {
"id": uuid.uuid4().hex,
"sent_at": datetime.now(timezone.utc).isoformat(),
"audience": (body.audience or "").strip().lower(),
"subject": subject,
"test_mode": False,
"status": "running",
"recipients_total": len(recipients),
"sent_count": 0,
"failed_count": 0,
"failed": [],
"content_hash": content_hash,
}
# Journalise l'entrée « running » AVANT la boucle d'envoi : si le serveur
# s'arrête en pleine campagne, l'historique trace l'envoi lancé.
with _marketing_lock:
history_entries = _load_marketing_history()
history_entries.append(entry)
_save_marketing_history(history_entries)
asyncio.create_task(
_execute_bulk_send(subject, html, recipients, entry, admin_id)
)
return JSONResponse(
status_code=202,
content={
"data": {
"queued": len(recipients),
"audience": entry["audience"],
"test_mode": False,
},
"meta": {},
},
)
async def _execute_bulk_send(
subject: str,
html: str,
recipients: List[dict],
entry: dict,
admin_id: str = "",
) -> None:
"""Envoi séquentiel (0,2 s d'intervalle) — n'échoue jamais globalement.
Met à jour l'entrée d'historique (statut « running » → « completed ») et
revérifie les désabonnés à CHAQUE itération : un désabonnement pendant la
campagne est honoré immédiatement.
"""
from services.email_service import send_email_async
failures: List[dict] = []
sent = 0
skipped = 0
try:
for r in recipients:
email_addr = r["email"]
# Re-vérification à chaque itération (désabonnement en cours de campagne).
if email_addr.lower() in _unsubscribed_emails():
skipped += 1
continue
try:
ok = await send_email_async(
email_addr,
subject,
_append_unsubscribe_footer(html, email_addr),
_marketing_extra_headers(email_addr),
)
except Exception as send_err: # un échec individuel n'arrête rien
logger.warning("marketing send to %s failed: %s", email_addr, send_err)
ok = False
if ok:
sent += 1
else:
failures.append({"email": email_addr, "error": "échec SMTP"})
await asyncio.sleep(0.2)
entry["sent_count"] = sent
entry["failed_count"] = len(failures)
entry["failed"] = failures[:50]
entry["skipped_unsubscribed"] = skipped
entry["status"] = "completed"
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
except Exception as bulk_err: # la campagne ne doit jamais laisser un statut « running »
logger.exception("marketing_bulk_send crashed: %s", bulk_err)
entry["status"] = "failed"
entry["completed_at"] = datetime.now(timezone.utc).isoformat()
finally:
with _marketing_lock:
_active_campaign_hashes.discard(entry.get("content_hash"))
all_entries = _load_marketing_history()
for i, existing in enumerate(all_entries):
if existing.get("id") == entry.get("id"):
all_entries[i] = entry
break
else:
all_entries.append(entry)
_save_marketing_history(all_entries)
logger.info(
"marketing_bulk_send audience=%s sent=%s failed=%s skipped=%s by=%s",
entry["audience"], sent, len(failures), skipped, admin_id,
)
# ============================================================
# Génération d'email par IA (page Marketing) — jamais d'envoi
# automatique : le serveur ne fait que produire {subject, html}.
# ============================================================
MARKETING_PLAN_FILE = config.BASE_DIR / "MARKETING_PLAN.md"
MARKETING_PLAN_PROMPT_MAX_CHARS = 4000
GENERATED_SUBJECT_MAX_CHARS = 200
GENERATED_HTML_MAX_CHARS = 100_000
GENERATION_TIMEOUT_SECONDS = 90
def _load_marketing_plan_excerpt() -> str:
"""Extrait du plan marketing pour le prompt : positionnement (§1) et
forfaits/chiffres autorisés (§2), tronqué (le prompt doit rester léger)."""
try:
text = MARKETING_PLAN_FILE.read_text(encoding="utf-8")
except Exception as e:
logger.warning("Lecture MARKETING_PLAN.md impossible: %s", e)
return ""
match = re.search(r"^## 1\..*?(?=^## 3\.|\Z)", text, re.MULTILINE | re.DOTALL)
excerpt = (match.group(0) if match else text).strip()
if len(excerpt) > MARKETING_PLAN_PROMPT_MAX_CHARS:
excerpt = excerpt[:MARKETING_PLAN_PROMPT_MAX_CHARS] + "\n[…]"
return excerpt
def _generation_model_for(admin_cfg, provider: str, requested: str) -> str:
"""Modèle du canal : demande explicite > réglage admin > défaut du canal."""
requested = (requested or "").strip()
if requested:
return requested
provider_cfg = getattr(admin_cfg, provider, None)
admin_model = (getattr(provider_cfg, "model", "") or "").strip() if provider_cfg else ""
return admin_model or LLM_CHANNELS[provider]["default_model"]
async def _call_llm_chat(
admin_cfg, provider: str, model: str, system_prompt: str, user_prompt: str
) -> str:
"""Appel chat/completions (API compatible OpenAI) sur le canal choisi.
Délai d'attente et erreurs propagées : l'appelant renvoie une erreur 502
claire (jamais d'HTTP 500 nu).
"""
import httpx
from routes.translate_routes import _channel_api_key, _channel_base_url
api_key = _channel_api_key(admin_cfg, provider)
if not api_key:
raise RuntimeError(f"Aucune clé API configurée pour le canal « {provider} ».")
base_url = _channel_base_url(admin_cfg, provider).rstrip("/")
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.7,
"max_tokens": 4000,
}
async with httpx.AsyncClient(timeout=GENERATION_TIMEOUT_SECONDS) as client:
resp = await client.post(
f"{base_url}/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json=payload,
)
resp.raise_for_status()
data = resp.json()
try:
return data["choices"][0]["message"]["content"] or ""
except (KeyError, IndexError, TypeError) as e:
raise RuntimeError(f"Réponse LLM inattendue: {e}") from e
def _build_generation_system_prompt(
language: str, tone: str, promo_line: str, promo_code: str = ""
) -> str:
plan_excerpt = _load_marketing_plan_excerpt()
tone_line = f"Ton demandé : {tone}. " if tone else ""
promo_block = ""
if promo_line:
site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
promo_block = (
f"\nOffre à mettre en avant :\n{promo_line}\n"
f"Termine l'email par un bouton d'appel à l'action bien visible "
f"(styles en ligne) dont le lien pointe exactement vers "
f"{site_url}/pricing?promo={promo_code} "
"— utilise ce lien tel quel, sans en inventer un autre.\n"
)
if _language_is_rtl(language):
promo_block += (
"Cette langue se lit de droite à gauche : mets dir=\"rtl\" sur le "
"conteneur principal du HTML et aligne le texte à droite.\n"
)
return (
"Tu es le rédacteur marketing de Wordly.art (Office Translator), un "
"service de traduction de documents qui préserve la mise en page.\n"
"Rédige un email de relance en respectant STRICTEMENT le plan marketing "
"ci-dessous : n'invente AUCUN chiffre, prix, pourcentage ou date absent "
"du plan ; si une réduction est fournie, cite-la exactement.\n"
f"Langue imposée de l'email : {language}. {tone_line}"
"Le HTML doit rester simple : structure avec des div et des styles en "
"ligne, largeur maximale ~600px, pas d'image externe, pas de "
"JavaScript, pas de CSS externe. Ne mets ni pied de page de "
"désabonnement ni coordonnées serveur : ils sont ajoutés par le "
"système au moment de l'envoi.\n"
f"{promo_block}"
"\n--- Extrait du plan marketing (source des chiffres autorisés) ---\n"
f"{plan_excerpt}\n"
"--- Fin de l'extrait ---\n\n"
'Réponds UNIQUEMENT avec un objet JSON : {"subject": "<sujet court>", '
'"html": "<email HTML complet>"}'
)
def _parse_generated_email(raw: str) -> Optional[tuple]:
"""Analyse robuste de la sortie du modèle : JSON direct, JSON dans un bloc
de code, premier objet {...}, puis repli <title>/corps. Renvoie
(subject, html) ou None si rien d'exploitable."""
raw = (raw or "").strip()
if not raw:
return None
def _from_json(text: str) -> Optional[tuple]:
try:
data = json.loads(text)
except (ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
subject = str(data.get("subject") or "").strip()
html = str(data.get("html") or "").strip()
if subject and html:
return (subject, html)
return None
# 1. JSON direct, 2. bloc de code markdown, 3. premier objet {...}
parsed = _from_json(raw)
if parsed:
return parsed
stripped = re.sub(r"^```[a-zA-Z]*\s*|\s*```$", "", raw, flags=re.MULTILINE).strip()
parsed = _from_json(stripped)
if parsed:
return parsed
brace = re.search(r"\{.*\}", raw, re.DOTALL)
if brace:
parsed = _from_json(brace.group(0))
if parsed:
return parsed
# 4. Repli texte : <title> = sujet, le reste = HTML.
title_match = re.search(r"<title[^>]*>(.*?)</title>", raw, re.IGNORECASE | re.DOTALL)
if title_match:
subject = title_match.group(1).strip()
html = raw
if html.strip():
return (subject, html)
return None
def _sanitize_generated_html(html: str) -> str:
"""Nettoyage basique du HTML généré : pas de script (fermé ou non), pas
d'iframe/object/embed/form, pas de gestionnaires d'événements, pas d'URL
``javascript:`` ni ``data:`` (casse mixte et espaces intrusive neutralisés)."""
# Scripts : balise fermée, balise orpheline (pas de </script>) et casse mixte.
html = re.sub(
r"<script\b[^>]*>.*?(?:</script\s*>|$)",
"",
html,
flags=re.IGNORECASE | re.DOTALL,
)
html = re.sub(r"</?script\b[^>]*>", "", html, flags=re.IGNORECASE)
# Éléments à contenu retirés en entier (avec repli si la balise fermante manque).
for tag in ("iframe", "object", "embed", "form"):
html = re.sub(
rf"<{tag}\b[^>]*>.*?(?:</{tag}\s*>|$)",
"",
html,
flags=re.IGNORECASE | re.DOTALL,
)
html = re.sub(rf"</?{tag}\b[^>]*>", "", html, flags=re.IGNORECASE)
# Gestionnaires d'événements (onclick, onerror…).
html = re.sub(
r"\son\w+\s*=\s*(\"[^\"]*\"|'[^']*'|[^\s>]+)", "", html, flags=re.IGNORECASE
)
# URL dangereuses : javascript: quel que soit le style (espaces insérés,
# casse mixte) et data: (data:text/html…).
html = re.sub(r"(?i)j\s*a\s*v\s*a\s*s\s*c\s*r\s*i\s*p\s*t\s*:", "", html)
html = re.sub(r"(?i)d\s*a\s*t\s*a\s*:", "", html)
return html
_RTL_GENERATION_LANGUAGES = {
"ar", "fa", "arabe", "arabic", "العربية", "فارسی", "persan", "persian",
}
def _language_is_rtl(language: str) -> bool:
return (language or "").strip().lower() in _RTL_GENERATION_LANGUAGES
class MarketingEmailGenerateRequest(BaseModel):
provider: str = "openrouter" # canal LLM (voir LLM_CHANNELS)
model: str = Field(default="", max_length=120) # vide = modèle par défaut du canal
brief: str = Field(..., min_length=1, max_length=4000)
language: str = Field(default="Français", max_length=50)
tone: str = Field(default="", max_length=50)
promo_code: str = Field(default="", max_length=24)
@router.post("/marketing/email/generate")
async def marketing_email_generate(
body: MarketingEmailGenerateRequest, admin_id: str = Depends(require_admin)
):
"""Génère un email de relance par IA (sujet + HTML), sans aucun envoi.
Le prompt système est construit depuis le plan marketing (positionnement,
forfaits, chiffres autorisés) ; la sortie du modèle est analysée avec
repli (JSON → <title>/corps) et nettoyée (pas de script). Une sortie non
analysable renvoie 502 AI_GENERATION_FAILED — l'admin relit et envoie
depuis la page Marketing, rien n'est envoyé automatiquement.
"""
provider = (body.provider or "").strip().lower()
if provider not in LLM_CHANNELS:
return JSONResponse(
status_code=400,
content={
"error": "UNKNOWN_CHANNEL",
"message": (
f"Canal « {provider} » inconnu (canaux : "
f"{', '.join(LLM_CHANNELS)})."
),
},
)
admin_cfg = load_settings()
from routes.translate_routes import _channel_has_key
if not _channel_has_key(admin_cfg, provider):
return JSONResponse(
status_code=400,
content={
"error": "PROVIDER_NOT_CONFIGURED",
"message": (
f"Aucune clé API configurée pour le canal « {provider} » "
"(réglages Fournisseurs ou .env)."
),
},
)
# Sans plan marketing lisible, l'IA inventerait des chiffres : on refuse.
if not _load_marketing_plan_excerpt().strip():
return JSONResponse(
status_code=503,
content={
"error": "MARKETING_PLAN_MISSING",
"message": (
"MARKETING_PLAN.md est introuvable ou vide à la racine du "
"projet : la génération est refusée pour ne pas diffuser de "
"chiffres inventés."
),
},
)
promo_line = ""
promo_wanted = (body.promo_code or "").strip().upper()
if promo_wanted:
promo = next(
(p for p in load_promo_codes() if (p.get("code") or "").upper() == promo_wanted),
None,
)
if not promo:
return JSONResponse(
status_code=400,
content={
"error": "PROMO_NOT_FOUND",
"message": f"Le code promo « {promo_wanted} » n'existe pas : "
"corrigez ou videz le sélecteur de réduction.",
},
)
if not promo.get("active", False):
return JSONResponse(
status_code=400,
content={
"error": "PROMO_NOT_FOUND",
"message": f"Le code promo « {promo_wanted} » n'est plus actif : "
"choisissez une autre réduction.",
},
)
if promo.get("type") == "percent":
value_desc = f"-{promo.get('value')} %"
else:
value_desc = f"-{promo.get('value')}"
promo_line = (
f"Code {promo.get('code')} ({value_desc}) — à citer exactement, "
f"valable pour : {', '.join(promo.get('plans') or ['all'])}."
)
system_prompt = _build_generation_system_prompt(
(body.language or "Français").strip() or "Français",
(body.tone or "").strip(),
promo_line,
promo_wanted,
)
user_prompt = (
f"Brief de l'email :\n{body.brief.strip()}\n\n"
"Produis l'objet et le HTML de l'email au format JSON demandé."
)
model = _generation_model_for(admin_cfg, provider, body.model)
try:
raw = await _call_llm_chat(admin_cfg, provider, model, system_prompt, user_prompt)
except Exception as e:
logger.error("marketing email generation failed (%s/%s): %s", provider, model, e)
return JSONResponse(
status_code=502,
content={
"error": "AI_GENERATION_FAILED",
"message": f"La génération a échoué ({str(e)[:300]}). Aucun email envoyé.",
},
)
parsed = _parse_generated_email(raw)
if not parsed:
logger.warning("marketing email generation unparseable output (%s/%s)", provider, model)
return JSONResponse(
status_code=502,
content={
"error": "AI_GENERATION_FAILED",
"message": (
"La réponse du modèle n'a pas pu être analysée (sujet + HTML "
"attendus). Aucun email envoyé — réessayez."
),
},
)
subject, html = parsed
subject = re.sub(r"\s+", " ", subject).strip()[:GENERATED_SUBJECT_MAX_CHARS]
html = _sanitize_generated_html(html).strip()
# Langues de droite à gauche : on garantit l'enveloppe dir="rtl" même si
# le modèle l'a oubliée.
if _language_is_rtl((body.language or "").strip()) and 'dir="rtl"' not in html.lower():
html = f'<div dir="rtl">{html}</div>'
if not subject or not html or len(html) > GENERATED_HTML_MAX_CHARS:
return JSONResponse(
status_code=502,
content={
"error": "AI_GENERATION_FAILED",
"message": (
"Le contenu généré est vide ou dépasse la longueur maximale. "
"Aucun email envoyé — réessayez."
),
},
)
logger.info(
"marketing email generated channel=%s model=%s by=%s", provider, model, admin_id
)
return JSONResponse(
status_code=200,
content={
"data": {
"subject": subject,
"html": html,
"provider": provider,
"model": model,
"language": body.language,
},
"meta": {},
},
)
# ============================================================
# Réductions : codes promo (data/promo_codes.json) + Stripe
# ============================================================
PROMO_CODE_PATTERN = re.compile(r"^[A-Z0-9-]{3,24}$")
_VALID_PROMO_PLANS = {"all"} | {pt.value for pt in PlanType}
class PromoCreateRequest(BaseModel):
code: str
type: Literal["percent", "amount"] = "percent"
value: float
plans: List[str] = Field(default_factory=lambda: ["all"])
max_redemptions: Optional[int] = None
expires_at: Optional[str] = None # ISO (date ou date-heure), futur obligatoire
@router.get("/marketing/promos")
async def marketing_promos_list(admin_id: str = Depends(require_admin)):
"""Liste des codes promo (compteur d'utilisations inclus, clés Stripe
internes incluses : identifiants d'objets, jamais de valeur sensible)."""
return JSONResponse(
status_code=200,
content={"data": {"promos": load_promo_codes()}, "meta": {}},
)
@router.post("/marketing/promos")
async def marketing_promos_create(
body: PromoCreateRequest, admin_id: str = Depends(require_admin)
):
"""Crée un code promo : Coupon + Promotion Code Stripe si Stripe est
configuré, sinon un code « local_only » (refusé au checkout avec un
message clair). Plafonds posés côté local ET côté Stripe."""
code = (body.code or "").strip().upper()
if not PROMO_CODE_PATTERN.match(code):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_CODE",
"message": "Le code doit contenir 3 à 24 caractères : lettres majuscules, chiffres ou tirets.",
},
)
if any((p.get("code") or "").upper() == code for p in load_promo_codes()):
return JSONResponse(
status_code=409,
content={
"error": "PROMO_ALREADY_EXISTS",
"message": f"Le code promo « {code} » existe déjà.",
},
)
if body.type == "percent":
if not (0 < float(body.value) <= 100):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_VALUE",
"message": "Pour un pourcentage, la valeur doit être entre 1 et 100.",
},
)
else:
if not (0.01 <= float(body.value) <= 10000):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_VALUE",
"message": "Pour un montant, la valeur doit être entre 0,01 et 10 000 EUR.",
},
)
plans = [p.strip().lower() for p in (body.plans or []) if p and p.strip()]
if not plans:
plans = ["all"]
if "all" in plans:
plans = ["all"]
invalid = [p for p in plans if p not in _VALID_PROMO_PLANS]
if invalid:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_PLAN",
"message": f"Forfaits inconnus : {', '.join(invalid)}.",
},
)
if "free" in plans:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_PLAN",
"message": "Le forfait Gratuit n'est pas éligible aux codes promo "
"(un code s'applique aux abonnements payants uniquement).",
},
)
if body.max_redemptions is not None and int(body.max_redemptions) < 1:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_LIMIT",
"message": "Le plafond d'utilisations doit être au moins 1.",
},
)
expires_at = None
if body.expires_at and body.expires_at.strip():
try:
exp = datetime.fromisoformat(body.expires_at.strip())
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if exp <= datetime.now(timezone.utc):
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_EXPIRY",
"message": "La date d'expiration doit être dans le futur.",
},
)
expires_at = exp.isoformat()
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_PROMO_EXPIRY",
"message": "Date d'expiration illisible (format ISO attendu).",
},
)
try:
entry = create_promo_code(
code=code,
type=body.type,
value=float(body.value),
plans=plans,
max_redemptions=body.max_redemptions,
expires_at=expires_at,
)
except DuplicatePromoError:
# Course perdue contre une création simultanée du même code : la
# vérification faisant foi est celle sous le verrou (service).
return JSONResponse(
status_code=409,
content={
"error": "PROMO_ALREADY_EXISTS",
"message": f"Le code promo « {code} » existe déjà.",
},
)
except StripePromoCreationError as e:
return JSONResponse(
status_code=502,
content={
"error": "STRIPE_PROMO_FAILED",
"message": f"Stripe a refusé la création du code « {code} » : {str(e)[:200]}. "
"Rien n'a été enregistré.",
},
)
logger.info("admin_promo_created code=%s by=%s", code, admin_id)
return JSONResponse(status_code=201, content={"data": entry, "meta": {}})
@router.delete("/marketing/promos/{code}")
async def marketing_promos_delete(code: str, admin_id: str = Depends(require_admin)):
"""Supprime un code promo (localement, et la promotion code chez Stripe)."""
promo = delete_promo_code(code)
if not promo:
return JSONResponse(
status_code=404,
content={
"error": "PROMO_NOT_FOUND",
"message": f"Le code promo « {code} » n'existe pas.",
},
)
logger.info("admin_promo_deleted code=%s by=%s", code, admin_id)
return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
@router.post("/marketing/promos/{code}/link-stripe")
async def marketing_promos_link_stripe(
code: str, admin_id: str = Depends(require_admin)
):
"""Relie un code « local_only » à Stripe : crée le Coupon + Promotion Code
et met à jour l'enregistrement (le code devient applicable au checkout)."""
try:
promo = link_promo_to_stripe(code)
except StripePromoCreationError as e:
return JSONResponse(
status_code=502,
content={
"error": "STRIPE_PROMO_FAILED",
"message": f"Stripe a refusé la création pour « {code} » : {str(e)[:200]}.",
},
)
except PromoValidationError as e:
return JSONResponse(
status_code=400,
content={"error": "PROMO_INACTIVE", "message": e.message},
)
except RuntimeError as e:
return JSONResponse(
status_code=400,
content={"error": "STRIPE_NOT_CONFIGURED", "message": str(e)},
)
if not promo:
return JSONResponse(
status_code=404,
content={
"error": "PROMO_NOT_FOUND",
"message": f"Le code promo « {code} » n'existe pas.",
},
)
logger.info("admin_promo_linked_stripe code=%s by=%s", code, admin_id)
return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
@router.post("/marketing/promos/{code}/deactivate")
async def marketing_promos_deactivate(
code: str, admin_id: str = Depends(require_admin)
):
"""Désactive un code promo localement ET chez Stripe (la réduction
n'est plus applicable au checkout)."""
promo = deactivate_promo_code(code)
if not promo:
return JSONResponse(
status_code=404,
content={
"error": "PROMO_NOT_FOUND",
"message": f"Le code promo « {code} » n'existe pas.",
},
)
logger.info("admin_promo_deactivated code=%s by=%s", code, admin_id)
return JSONResponse(status_code=200, content={"data": promo, "meta": {}})
marketing_public_router = APIRouter(prefix="/api/v1/marketing", tags=["Marketing"])
@marketing_public_router.get("/unsubscribe", response_class=HTMLResponse)
async def marketing_unsubscribe(email: str, request: Request):
"""Désabonnement public : ajoute l'email à data/unsubscribes.json.
Idempotent ; aucune information sensible n'est renvoyée.
"""
from urllib.parse import unquote
em = unquote(email or "").strip().lower()
em_display = html_module.escape(em)
site_url = os.getenv("FRONTEND_URL", "http://localhost:3000").rstrip("/")
if not em or "@" not in em or len(em) > 320:
return HTMLResponse(
"<html><body style='font-family:sans-serif;text-align:center;padding:48px;'>"
"<p>Lien de désabonnement invalide.</p></body></html>",
status_code=400,
)
with _marketing_lock:
entries = _load_unsubscribes()
if not any((e.get("email") or "").lower() == em for e in entries):
entries.append(
{
"email": em,
"unsubscribed_at": datetime.now(timezone.utc).isoformat(),
}
)
_save_unsubscribes(entries)
logger.info("marketing_unsubscribe email=%s", em)
return HTMLResponse(
"<html><body style='font-family:sans-serif;text-align:center;padding:48px;'>"
"<h2>C'est bien noté.</h2>"
f"<p>L'adresse <strong>{em_display}</strong> ne recevra plus d'email marketing.</p>"
f"<p><a href='{site_url}'>Retour sur Wordly.art</a></p></body></html>",
status_code=200,
)