feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s

Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.

Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).

Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).

Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.

Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).

API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.

DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.

Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
This commit is contained in:
2026-08-30 22:42:29 +02:00
parent 52748ee653
commit 67365918ae
35 changed files with 398 additions and 1572 deletions

View File

@@ -337,7 +337,6 @@ async def get_admin_dashboard(admin_id: str = Depends(require_admin)):
"label": label,
}
_engine_status("deepl", "DEEPL_API_KEY", "DeepL")
_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")
@@ -712,7 +711,6 @@ async def update_default_provider(
valid_providers = [
"google",
"google_cloud",
"deepl",
"openai",
"openrouter",
"openrouter_premium",
@@ -916,7 +914,6 @@ class SmtpSettings(BaseModel):
class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True)
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
deepl: ProviderSettings = ProviderSettings()
openai: ProviderSettings = ProviderSettings()
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
@@ -926,8 +923,8 @@ class SettingsConfig(BaseModel):
zai: ProviderSettings = ProviderSettings()
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
smtp: SmtpSettings = SmtpSettings()
fallback_chain: str = "google,google_cloud,deepl,openrouter,openrouter_premium,openai,deepseek,zai"
fallback_chain_classic: str = "google,google_cloud,deepl"
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"
@@ -991,7 +988,6 @@ async def get_settings(admin_id: str = Depends(require_admin)):
# 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")
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
payload["deepl"] = _merge_env(settings.deepl, key_env="DEEPL_API_KEY")
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["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")
@@ -1023,7 +1019,6 @@ async def get_settings(admin_id: str = Depends(require_admin)):
# (boolean only — never expose actual values)
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
env_info = {
"deepl": bool(os.getenv("DEEPL_API_KEY", "").strip()),
"openai": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"openrouter": has_openrouter,
"openrouter_premium": has_openrouter, # same key, different model
@@ -1193,20 +1188,6 @@ async def test_provider(
},
)
elif provider == "deepl":
api_key = _key(provider_config.api_key, "DEEPL_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API DeepL trouvée (JSON ou .env)"},
)
import deepl
translator = deepl.Translator(api_key)
usage = translator.get_usage()
return JSONResponse(
status_code=200, content={"available": True, "usage": str(usage)}
)
elif provider == "openrouter_premium":
current.openrouter_premium = _update_provider(current.openrouter_premium, update_data)

View File

@@ -82,16 +82,6 @@ async def get_available_providers(
"tier": "free",
})
# DeepL — if configured
if _is_enabled("deepl", key_var="DEEPL_API_KEY"):
available.append({
"id": "deepl",
"label": "DeepL",
"description": "Traduction professionnelle haute qualité (langues européennes)",
"mode": "classic",
"tier": "pro",
})
# AI Essentielle (OpenRouter — cheap model / Eco)
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
or_cfg = getattr(settings, "openrouter", None)

View File

@@ -464,6 +464,8 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
_translation_jobs: dict[str, dict] = {}
_JOB_TTL_SECONDS = 3600
# Completed jobs are kept longer so the history endpoint has depth.
_JOB_HISTORY_TTL_SECONDS = 24 * 3600
_last_cleanup_ts: float = 0.0
# Google Cloud API key validity cache — avoids probing the API on every request.
@@ -516,10 +518,14 @@ def _cleanup_old_jobs() -> None:
expired_job_ids = [
job_id
for job_id, job in list(_translation_jobs.items())
if job.get("status") in ("completed", "failed")
if job.get("status") in ("completed", "failed", "cancelled")
and (
(ts := job.get("completed_at") or job.get("failed_at"))
and _job_age_seconds(ts) > _JOB_TTL_SECONDS
(ts := job.get("completed_at") or job.get("failed_at") or job.get("cancelled_at"))
and _job_age_seconds(ts) > (
_JOB_HISTORY_TTL_SECONDS
if job.get("status") == "completed"
else _JOB_TTL_SECONDS
)
)
]
@@ -659,7 +665,7 @@ async def translate_document_v1(
- `source_lang`: Source language code (default: auto-detect)
- `target_lang`: Target language code (required)
- `mode`: Translation mode - "classic" or "llm" (default: classic)
- `provider`: Provider override (google, deepl, ollama, openai, openrouter)
- `provider`: Provider override (google, ollama, openai, openrouter)
- `webhook_url`: URL to receive POST notification when complete
- `glossary_id`: Glossary ID for LLM translation (Pro only)
- `custom_prompt`: Custom system prompt (Pro only)
@@ -1233,6 +1239,10 @@ async def _run_translation_job(
await set_job_status_async(job_id, dict(job))
tracker.update(10, "Validating file")
if job.get("status") == "cancelled":
logger.info(f"Job {job_id}: cancelled by user before dispatch — aborting")
return
async def _sync_job_to_redis():
"""Sync job status to Redis every 0.5s until completed/failed or job removed."""
while True:
@@ -1311,7 +1321,6 @@ async def _run_translation_job(
from services.providers.google_provider import GoogleTranslationProvider
from services.providers.google_cloud_provider import GoogleCloudTranslationProvider
from services.providers.deepl_provider import DeepLTranslationProvider
from services.providers.openai_provider import OpenAITranslationProvider
from services.providers.deepseek_provider import DeepSeekTranslationProvider
from services.providers.minimax_provider import MinimaxTranslationProvider
@@ -1398,13 +1407,6 @@ async def _run_translation_job(
model=mm_model,
timeout=int(os.getenv("MINIMAX_TIMEOUT", "60")),
)
elif _p == "deepl":
deepl_key = _cfg(_admin_cfg.deepl.api_key, "DEEPL_API_KEY")
if deepl_key:
translation_provider = DeepLTranslationProvider(
api_key=deepl_key,
timeout=int(os.getenv("DEEPL_TIMEOUT", "30")),
)
elif _p == "zai":
zai_key = _cfg(_admin_cfg.zai.api_key, "ZAI_API_KEY")
zai_model = _cfg(_admin_cfg.zai.model, "ZAI_MODEL", "grok-2-1212")
@@ -1887,6 +1889,10 @@ async def _run_translation_job(
except Exception as wm_err:
logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
if job.get("status") == "cancelled":
logger.info(f"Job {job_id}: cancelled by user mid-flight — discarding result")
return
tracker.set_completed(str(output_path))
# Record translation metric
duration = _compute_duration_seconds(job.get("created_at", ""))
@@ -2123,6 +2129,134 @@ async def get_translation_status(
}
@router_v1.get(
"/translations",
responses={
200: {"description": "Translation job history for the current user"},
401: {"description": "Authentication required"},
},
)
async def list_translation_history(
page: int = 1,
per_page: int = 20,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
List the current user's translation jobs, newest first.
Jobs are kept in memory: completed jobs for 24 hours, other states for 1 hour.
Pagination via ``page`` / ``per_page`` (max 50).
"""
if current_user is None:
return JSONResponse(
status_code=401,
content={"error": "AUTH_REQUIRED", "message": "Authentication required."},
)
user_id = str(getattr(current_user, "id", ""))
per_page = max(1, min(per_page, 50))
page = max(1, page)
jobs = [
{
"id": job.get("id"),
"status": job.get("status"),
"progress_percent": job.get("progress_percent", 0),
"file_name": job.get("file_name"),
"source_lang": job.get("source_lang"),
"target_lang": job.get("target_lang"),
"provider": job.get("provider"),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at"),
"failed_at": job.get("failed_at"),
"cancelled_at": job.get("cancelled_at"),
}
for job in _translation_jobs.values()
if str(job.get("user_id", "")) == user_id
]
jobs.sort(key=lambda j: j.get("created_at") or "", reverse=True)
total = len(jobs)
start = (page - 1) * per_page
return {
"data": jobs[start : start + per_page],
"meta": {"total": total, "page": page, "per_page": per_page},
}
@router_v1.post(
"/translations/{job_id}/cancel",
responses={
200: {"description": "Job cancelled"},
401: {"description": "Authentication required"},
404: {"description": "Job not found"},
409: {"description": "Job already finished"},
},
)
async def cancel_translation(
job_id: str,
token: Optional[str] = None,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
Cancel a queued or processing translation job.
Cancellation is cooperative: the worker aborts at its next checkpoint
(before dispatch, or before finalisation for in-flight jobs) and the
reserved quota is released immediately.
"""
job = await get_job_status_async(job_id)
if not job:
job = _translation_jobs.get(job_id)
if not job:
return JSONResponse(
status_code=404,
content={
"error": "NOT_FOUND",
"message": "Translation job not found.",
"details": {"job_id": job_id},
},
)
denied = _check_job_access(job, current_user, token)
if denied:
return denied
if job.get("status") in ("completed", "failed", "cancelled"):
return JSONResponse(
status_code=409,
content={
"error": "ALREADY_FINISHED",
"message": f"Job already {job.get('status')}.",
"details": {"job_id": job_id, "status": job.get("status")},
},
)
mem_job = _translation_jobs.get(job_id)
if mem_job is None:
# Redis-only copy (e.g. after a worker restart): cancel that view.
mem_job = job
_translation_jobs[job_id] = dict(job)
mem_job["status"] = "cancelled"
mem_job["cancelled_at"] = datetime.now(timezone.utc).isoformat()
mem_job["current_step"] = "Cancelled by user"
await set_job_status_async(job_id, dict(mem_job))
# Release the reserved document slot right away unless usage was recorded.
job_user_id = mem_job.get("user_id")
if job_user_id and not mem_job.get("usage_recorded"):
try:
await asyncio.to_thread(release_translation_quota, str(job_user_id))
logger.info(f"Job {job_id}: released reserved quota after user cancellation")
except Exception as release_err:
logger.exception(f"Job {job_id}: failed to release quota on cancel: {release_err}")
return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}}
@router_v1.get("/translate/health")
async def translate_health():
"""Health check for translation endpoint."""