feat(admin): canaux IA par palier (z.ai/DeepSeek directs), emails generes par IA, reductions Stripe
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m40s

- 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)
This commit is contained in:
2026-09-05 19:46:33 +02:00
parent 6c26712687
commit 0a013c679d
47 changed files with 7210 additions and 679 deletions

View File

@@ -31,6 +31,18 @@ from models.subscription import (
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")
@@ -1042,14 +1054,37 @@ class SmtpSettings(BaseModel):
use_tls: bool = True
class AiTierSettings(BaseModel):
"""Palier IA : modèles actifs (l'ordre = priorité de secours) + modèle par défaut.
# 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())
``default_model`` vide → premier de ``models`` → défaut du plan (PLANS).
Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``.
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):
@@ -1076,6 +1111,7 @@ class SettingsConfig(BaseModel):
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)
@@ -1089,29 +1125,66 @@ class SettingsConfig(BaseModel):
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.
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:
if not models and not routes:
models = list(official)
default = (tier.default_model or "").strip()
if default and default not in models:
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)
return AiTierSettings(models=models, default_model=default, routes=routes)
return AiTiersSettings(
essential=_fill(ai_tiers.essential, essential_official, premium_official,
@@ -1182,7 +1255,8 @@ async def get_settings(admin_id: str = Depends(require_admin)):
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["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-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")
@@ -1216,6 +1290,7 @@ async def get_settings(admin_id: str = Depends(require_admin)):
"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()),
@@ -1231,12 +1306,15 @@ async def get_settings(admin_id: str = Depends(require_admin)):
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 et aucun modèle imposé)."""
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()]
return (not models or models == list(official)) and not (
tier.default_model or ""
).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)
@@ -1413,14 +1491,6 @@ async def test_provider(
)
elif provider == "openrouter_premium":
current.openrouter_premium = _update_provider(current.openrouter_premium, update_data)
elif provider == "deepseek":
current.deepseek = _update_provider(current.deepseek, update_data)
elif provider == "minimax":
current.minimax = _update_provider(current.minimax, update_data)
elif provider == "zai":
current.zai = _update_provider(current.zai, update_data)
elif provider == "openai":
api_key = _key(provider_config.api_key, "OPENAI_API_KEY")
if not api_key:
@@ -1430,13 +1500,57 @@ async def test_provider(
)
import openai as _openai
client = _openai.OpenAI(api_key=api_key)
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")
@@ -1475,7 +1589,7 @@ async def test_provider(
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)
client = _openai.OpenAI(api_key=api_key, base_url=base_url, timeout=15)
try:
models = list(client.models.list())
return JSONResponse(
@@ -2731,6 +2845,614 @@ async def _execute_bulk_send(
)
# ============================================================
# 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"])