feat(abonnements): paliers LLM Essentielle/Premium, admin modeles par forfait, statistiques revenus, emails marketing
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m51s
- Gamme officielle : Essentielle (Pro) = deepseek-v4-flash, glm-5.3-flash, minimax-m3 ; Premium (Business) = claude-sonnet-5, deepseek-v4-pro, glm-5.3 - Routage reel : le modele employe suit le palier IA du forfait (reglages admin > defaut du plan), garde anti-croisement de palier - Nouvelle page admin « Modeles & abonnements » (matrice forfaits/paliers, modele par defaut, catalogue OpenRouter) - Statistiques enrichies : revenus (30 j + total), MRR estime, credits, liste d'attente, paliers utilises - Nouvelle page admin « Marketing » : audiences avec compteurs, apercu, envoi test obligatoire, historique, desabonnement public + en-tetes List-Unsubscribe ; .gitignore pour les donnees d'execution - Interface (accueil + tarifs) alignee sur la gamme, 13 langues completes - Tests : routage par palier (fonction + tache), marketing, revenus en base
This commit is contained in:
@@ -5,19 +5,31 @@ 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
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Literal
|
||||
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
|
||||
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
|
||||
from models.subscription import (
|
||||
PlanType,
|
||||
PLANS,
|
||||
DEFAULT_AI_MODELS_ESSENTIAL,
|
||||
DEFAULT_AI_MODELS_PREMIUM,
|
||||
)
|
||||
from services import pricing_config as pricing_cfg
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
@@ -619,7 +631,122 @@ async def get_admin_stats(admin_id: str = Depends(require_admin)):
|
||||
if docs > 0:
|
||||
active_users += 1
|
||||
|
||||
cache_stats = _translation_cache.get_stats()
|
||||
# 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": {
|
||||
@@ -631,6 +758,10 @@ async def get_admin_stats(admin_id: str = Depends(require_admin)):
|
||||
"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,
|
||||
@@ -911,6 +1042,32 @@ 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.
|
||||
|
||||
``default_model`` vide → premier de ``models`` → défaut du plan (PLANS).
|
||||
Source des valeurs par défaut : la gamme officielle de ``models/subscription.py``.
|
||||
"""
|
||||
models: List[str] = Field(default_factory=list)
|
||||
default_model: str = ""
|
||||
|
||||
|
||||
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)
|
||||
@@ -923,11 +1080,47 @@ class SettingsConfig(BaseModel):
|
||||
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.
|
||||
|
||||
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.
|
||||
"""
|
||||
essential_official = list(DEFAULT_AI_MODELS_ESSENTIAL)
|
||||
premium_official = list(DEFAULT_AI_MODELS_PREMIUM)
|
||||
|
||||
def _fill(tier: AiTierSettings, official: List[str], other_official: List[str],
|
||||
tier_name: str, other_name: str) -> AiTierSettings:
|
||||
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:
|
||||
models = list(official)
|
||||
default = (tier.default_model or "").strip()
|
||||
if default and default not in models:
|
||||
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 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
|
||||
@@ -983,10 +1176,10 @@ async def get_settings(admin_id: str = Depends(require_admin)):
|
||||
return d
|
||||
|
||||
payload = settings.model_dump()
|
||||
# Essentielle : Gemini 3.5 Flash / DeepSeek Chat — meilleur rapport qualité/prix (juin 2026)
|
||||
payload["openrouter"] = _merge_env(settings.openrouter, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_MODEL", default_model="google/gemini-3.5-flash")
|
||||
# Premium : Claude Sonnet 4.6 — 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-4.6")
|
||||
# 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["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat")
|
||||
@@ -1036,6 +1229,20 @@ 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é)."""
|
||||
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()
|
||||
|
||||
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)
|
||||
@@ -1048,6 +1255,23 @@ async def update_settings(
|
||||
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(
|
||||
@@ -1971,3 +2195,579 @@ async def setup_stripe_webhook(request: Request, admin_id: str = Depends(require
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user