fix(relecture): un travail vide ou oublie restait affiche et impossible a retirer
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m29s

Un travail echoue ou expire cote serveur restait dans « Traductions
recentes » : sa page de relecture n'affichait rien (0 segments) avec une
erreur brute non traduite, et aucune suppression n'existait.

- serveur : nouvelle route DELETE /api/v1/translations/{job_id} —
  retire le travail de la memoire et de Redis, supprime ses segments de
  relecture ; idempotente (200 meme si le serveur avait deja oublie le
  travail) ; refuse les travaux en cours (annuler d'abord) ; proprietaire
  seul (404 pour le travail d'autrui, sans reveler son existence)
- interface : bouton corbeille sur chaque ligne des traductions recentes
  (deux clics pour confirmer) qui nettoie la liste locale ET le serveur ;
  l'historique serveur ne liste plus les travaux non termines
- page de relecture : message d'erreur traduit (fini le message brut) et
  etat « rien a relire » explicite avec explication
- libelles ajoutes dans les 13 langues de l'interface
- 5 tests backend nouveaux (tests/test_delete_translation_history.py)
This commit is contained in:
2026-09-01 21:22:05 +02:00
parent b3b5454f6f
commit 2abd0c0b26
21 changed files with 380 additions and 27 deletions

View File

@@ -132,3 +132,21 @@ async def get_job_status_async(job_id: str) -> dict | None:
except Exception as e: except Exception as e:
logger.warning("Redis get_job_status_async failed for %s: %s", job_id, e) logger.warning("Redis get_job_status_async failed for %s: %s", job_id, e)
return None return None
async def delete_job_status_async(job_id: str) -> bool:
"""Remove a job's status key from Redis (user deleted it from history).
Best-effort like the other job-status helpers: never raises, returns
True when the key was deleted, False when Redis is unavailable or the
key did not exist.
"""
client = get_async_redis()
if not client:
return False
try:
key = f"{JOB_STATUS_KEY_PREFIX}:{job_id}"
return bool(await client.delete(key))
except Exception as e:
logger.warning("Redis delete_job_status_async failed for %s: %s", job_id, e)
return False

View File

@@ -106,7 +106,13 @@ export default function ReviewPage() {
setSegments(res.data.segments); setSegments(res.data.segments);
setFileName(res.data.file_name); setFileName(res.data.file_name);
} catch (err) { } catch (err) {
setLoadError(err instanceof Error ? err.message : t('reviews.error.load')); setLoadError(
friendlyReviewError(
err instanceof Error ? err.message : undefined,
t,
'reviews.error.load'
) ?? t('reviews.error.load')
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -406,6 +412,15 @@ export default function ReviewPage() {
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" /> <Loader2 className="size-6 animate-spin text-muted-foreground" />
</div> </div>
) : segments.length === 0 && !loadError ? (
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#141414] px-6 py-14 text-center shadow-sm">
<p className="text-sm font-semibold text-brand-dark dark:text-white">
{t('reviews.empty.title')}
</p>
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-brand-dark/60 dark:text-white/60">
{t('reviews.empty.desc')}
</p>
</div>
) : ( ) : (
<div className="overflow-hidden rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#141414] shadow-sm"> <div className="overflow-hidden rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-[#141414] shadow-sm">
<table className="w-full text-sm"> <table className="w-full text-sm">

View File

@@ -8,11 +8,14 @@ import {
Zap, CheckCircle2, Zap, CheckCircle2,
Search, Languages, Wrench, Activity, Search, Languages, Wrench, Activity,
Download, AlertTriangle, FileType, Download, AlertTriangle, FileType,
Image as ImageIcon, Image as ImageIcon, Trash2,
} from 'lucide-react'; } from 'lucide-react';
import { useFileUpload } from './useFileUpload'; import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig'; import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob, type RecentJob } from './useTranslationSubmit'; import {
useTranslationSubmit, getRecentJobs, fetchServerHistory, pushRecentJob,
removeRecentJob, deleteServerJob, type RecentJob,
} from './useTranslationSubmit';
import { runTranslationJob } from './translationRunner'; import { runTranslationJob } from './translationRunner';
import type { BatchItem } from './types'; import type { BatchItem } from './types';
import LanguageSelector from './LanguageSelector'; import LanguageSelector from './LanguageSelector';
@@ -83,6 +86,8 @@ export default function TranslatePage() {
const batchJobRef = useRef<string | null>(null); const batchJobRef = useRef<string | null>(null);
const [elapsed, setElapsed] = useState(0); const [elapsed, setElapsed] = useState(0);
const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]); const [recentJobs, setRecentJobs] = useState<RecentJob[]>([]);
// Job whose delete button is armed (first click) — cleared after 4s or on delete.
const [armedRemoveId, setArmedRemoveId] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false; const isPdf = upload.file?.name.toLowerCase().endsWith('.pdf') ?? false;
@@ -238,6 +243,20 @@ export default function TranslatePage() {
}; };
const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); }; const handleNewTranslation = () => { batchAbortRef.current = true; setBatch(null); submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
/** Two-step delete (arm, then confirm): removes the entry locally and on the server. */
const handleRemoveJob = (jobId: string) => {
if (armedRemoveId !== jobId) {
setArmedRemoveId(jobId);
setTimeout(() => setArmedRemoveId((cur) => (cur === jobId ? null : cur)), 4000);
return;
}
setArmedRemoveId(null);
removeRecentJob(jobId);
setRecentJobs((jobs) => jobs.filter((j) => j.jobId !== jobId));
// Server-side cleanup is best-effort: a 404 just means it already forgot the job.
void deleteServerJob(jobId);
};
const handleDownload = async (jobId: string = submit.jobId ?? '') => { const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return; if (!jobId) return;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -543,7 +562,7 @@ export default function TranslatePage() {
onClick={() => handleDownload(job.jobId)} onClick={() => handleDownload(job.jobId)}
className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors" className="rounded-lg px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:text-white/60 border border-black/10 dark:border-white/10 hover:bg-brand-muted/50 transition-colors"
> >
<Download size={11} className="inline" /> <Download size={11} />
</button> </button>
<Link <Link
href={`/dashboard/reviews/${job.jobId}`} href={`/dashboard/reviews/${job.jobId}`}
@@ -551,6 +570,20 @@ export default function TranslatePage() {
> >
{t('translate.recent.review')} {t('translate.recent.review')}
</Link> </Link>
<button
type="button"
onClick={() => handleRemoveJob(job.jobId)}
aria-label={t('translate.recent.remove')}
title={armedRemoveId === job.jobId ? t('translate.recent.removeConfirm') : t('translate.recent.remove')}
className={cn(
'rounded-lg px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-wider border transition-colors',
armedRemoveId === job.jobId
? 'border-red-400/60 text-red-500 bg-red-500/10'
: 'text-brand-dark/40 dark:text-white/40 border-transparent hover:text-red-500 hover:border-red-300/40'
)}
>
<Trash2 size={11} />
</button>
</span> </span>
</li> </li>
))} ))}

View File

@@ -22,7 +22,7 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number } export interface RecentJob { jobId: string; fileName: string; completedAt: number }
/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */ /** Server history (last completed jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> { export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
@@ -33,14 +33,16 @@ export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
}); });
if (!res.ok) return []; if (!res.ok) return [];
const json = await res.json(); const json = await res.json();
return (json.data ?? []).map((j: { return (json.data ?? [])
id: string; file_name?: string | null; .filter((j: { status?: string | null }) => (j.status ?? 'completed') === 'completed')
completed_at?: string | null; created_at?: string | null; .map((j: {
}) => ({ id: string; file_name?: string | null; status?: string | null;
jobId: j.id, completed_at?: string | null; created_at?: string | null;
fileName: j.file_name ?? '', }) => ({
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(), jobId: j.id,
})); fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
} catch { } catch {
return []; return [];
} }
@@ -56,6 +58,31 @@ export function getRecentJobs(): RecentJob[] {
} }
} }
/** Remove a job from this browser's local history. */
export function removeRecentJob(jobId: string) {
try {
const list = getRecentJobs().filter(j => j.jobId !== jobId);
localStorage.setItem(RECENT_JOBS_KEY, JSON.stringify(list));
} catch { /* ignore */ }
}
/**
* Delete a job on the server (history entry + review segments).
* Silent by design: a 404 means the server already forgot the job, which
* is exactly the state the caller wants.
*/
export async function deleteServerJob(jobId: string): Promise<void> {
if (typeof window === 'undefined') return;
const token = localStorage.getItem('token');
if (!token) return;
try {
await fetch(`${API_BASE}/api/v1/translations/${jobId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
} catch { /* offline — the local entry is removed regardless */ }
}
function persistActiveJob(job: StoredJob | null) { function persistActiveJob(job: StoredJob | null) {
try { try {
if (!job) localStorage.removeItem(ACTIVE_JOB_KEY); if (!job) localStorage.removeItem(ACTIVE_JOB_KEY);

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "لا توجد أجزاء لمراجعتها في هذه المهمة",
"reviews.empty.desc": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Für diesen Auftrag gibt es nichts zu prüfen",
"reviews.empty.desc": "Diese Übersetzung enthält keine gespeicherten Segmente: Entweder ist sie vor der Textübersetzung fehlgeschlagen, oder der Server bewahrt sie nicht mehr auf. Sie können sie aus der Liste der letzten Übersetzungen entfernen."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nothing to review for this job",
"reviews.empty.desc": "This translation has no stored segments: either it failed before any text was translated, or it is no longer retained by the server. You can remove it from your recent translations list."
} }

View File

@@ -96,6 +96,8 @@
"translate.downloadFailed": "The download failed. Please try again.", "translate.downloadFailed": "The download failed. Please try again.",
"translate.recent.title": "Recent translations", "translate.recent.title": "Recent translations",
"translate.recent.review": "Review", "translate.recent.review": "Review",
"translate.recent.remove": "Remove from history",
"translate.recent.removeConfirm": "Click again to confirm",
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files", "translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files",
"translate.cancelAction": "Cancel translation", "translate.cancelAction": "Cancel translation",
"translate.cancelledTitle": "Translation cancelled", "translate.cancelledTitle": "Translation cancelled",

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nada que revisar en este trabajo",
"reviews.empty.desc": "Esta traducción no tiene segmentos guardados: o falló antes de traducir el texto, o el servidor ya no la conserva. Puede quitarla de la lista de traducciones recientes."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "بخشی برای بازبینی در این کار وجود ندارد",
"reviews.empty.desc": "این ترجمه بخش ذخیره‌شده‌ای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمی‌دارد. می‌توانید آن را از فهرست ترجمه‌های اخیر حذف کنید."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Rechercher dans les segments...", "reviews.searchPlaceholder": "Rechercher dans les segments...",
"reviews.shortcutHint": "Ctrl + Entrée pour enregistrer", "reviews.shortcutHint": "Ctrl + Entrée pour enregistrer",
"reviews.approvingProgress": "Approbation ({current}/{total})…", "reviews.approvingProgress": "Approbation ({current}/{total})…",
"reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre." "reviews.noMatchingSegments": "Aucun segment ne correspond à votre filtre.",
"reviews.empty.title": "Rien à relire pour ce travail",
"reviews.empty.desc": "Cette traduction n'a aucun segment enregistré : soit elle a échoué avant la traduction du texte, soit elle n'est plus conservée par le serveur. Vous pouvez la retirer de la liste des traductions récentes."
} }

View File

@@ -96,6 +96,8 @@
"translate.downloadFailed": "Le téléchargement a échoué. Réessayez.", "translate.downloadFailed": "Le téléchargement a échoué. Réessayez.",
"translate.recent.title": "Traductions récentes", "translate.recent.title": "Traductions récentes",
"translate.recent.review": "Relire", "translate.recent.review": "Relire",
"translate.recent.remove": "Retirer de l'historique",
"translate.recent.removeConfirm": "Cliquez encore pour confirmer",
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir", "translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir",
"translate.cancelAction": "Annuler la traduction", "translate.cancelAction": "Annuler la traduction",
"translate.cancelledTitle": "Traduction annulée", "translate.cancelledTitle": "Traduction annulée",

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Niente da rivedere per questo lavoro",
"reviews.empty.desc": "Questa traduzione non ha segmenti salvati: o è fallita prima di tradurre il testo, o il server non la conserva più. Puoi rimuoverla dall'elenco delle traduzioni recenti."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "このジョブに校正するセグメントはありません",
"reviews.empty.desc": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。"
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "이 작업에 검토할 세그먼트가 없습니다",
"reviews.empty.desc": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Niets te controleren voor deze opdracht",
"reviews.empty.desc": "Deze vertaling heeft geen opgeslagen segmenten: of hij mislukte voordat er tekst werd vertaald, of de server bewaart hem niet meer. U kunt hem verwijderen uit de lijst met recente vertalingen."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Nada a revisar neste trabalho",
"reviews.empty.desc": "Esta tradução não tem segmentos armazenados: ou falhou antes de qualquer texto ser traduzido, ou o servidor já não a conserva. Pode removê-la da lista de traduções recentes."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "Для этого задания нет сегментов для проверки",
"reviews.empty.desc": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов."
} }

View File

@@ -47,5 +47,7 @@
"reviews.searchPlaceholder": "Search in segments...", "reviews.searchPlaceholder": "Search in segments...",
"reviews.shortcutHint": "Ctrl + Enter to save", "reviews.shortcutHint": "Ctrl + Enter to save",
"reviews.approvingProgress": "Approving ({current}/{total})…", "reviews.approvingProgress": "Approving ({current}/{total})…",
"reviews.noMatchingSegments": "No segments match your filter." "reviews.noMatchingSegments": "No segments match your filter.",
"reviews.empty.title": "此任务没有可校对的段落",
"reviews.empty.desc": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。"
} }

View File

@@ -71,7 +71,7 @@ from utils.file_handler import FileHandler
from middleware.metrics import record_translation, record_file_size from middleware.metrics import record_translation, record_file_size
from services.progress_tracker import ProgressTracker from services.progress_tracker import ProgressTracker
from services.storage_tracker import storage_tracker from services.storage_tracker import storage_tracker
from core.redis import set_job_status_async, get_job_status_async from core.redis import set_job_status_async, get_job_status_async, delete_job_status_async
from services.glossary_service import get_glossary_terms, validate_glossary_access, build_full_prompt from services.glossary_service import get_glossary_terms, validate_glossary_access, build_full_prompt
from services.prompt_service import get_prompt_content, validate_prompt_access from services.prompt_service import get_prompt_content, validate_prompt_access
from utils.exceptions import GlossaryNotFoundError, PromptNotFoundError from utils.exceptions import GlossaryNotFoundError, PromptNotFoundError
@@ -2257,6 +2257,92 @@ async def cancel_translation(
return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}} return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}}
@router_v1.delete(
"/translations/{job_id}",
responses={
200: {"description": "Job removed from the user's history (idempotent)"},
401: {"description": "Authentication required"},
404: {"description": "Job belongs to another user"},
409: {"description": "Job is queued or processing — cancel it first"},
},
)
async def delete_translation(
job_id: str,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
Remove a translation job from the user's history.
Deletes the in-memory job record, its Redis status key and its stored
review segments. Idempotent: a job the server has already forgotten
(retention expiry, restart) still returns 200 so the client can clean
its own list. Only the owner may delete — the share token does not
grant deletion rights. Files on disk are left to the TTL cleanup.
"""
if current_user is None:
return JSONResponse(
status_code=401,
content={"error": "AUTH_REQUIRED", "message": "Authentication required."},
)
user_id = str(getattr(current_user, "id", ""))
job = _translation_jobs.get(job_id)
if not job:
job = await get_job_status_async(job_id)
if job:
# Do not reveal whether someone else's job exists: same 404 body.
if str(job.get("user_id", "")) != user_id:
return JSONResponse(
status_code=404,
content={
"error": "NOT_FOUND",
"message": "Translation job not found.",
"details": {"job_id": job_id},
},
)
if job.get("status") in ("queued", "processing"):
return JSONResponse(
status_code=409,
content={
"error": "JOB_IN_PROGRESS",
"message": "Cancel the job before deleting it.",
"details": {"job_id": job_id, "status": job.get("status")},
},
)
_translation_jobs.pop(job_id, None)
await delete_job_status_async(job_id)
def _delete_segments():
from database.connection import get_sync_session
from database.models import TranslationSegment
with get_sync_session() as session:
deleted = (
session.query(TranslationSegment)
.filter(TranslationSegment.job_id == job_id)
.delete()
)
session.commit()
return deleted
segments_deleted = 0
try:
segments_deleted = await asyncio.to_thread(_delete_segments)
except Exception as seg_err:
logger.warning(f"Job {job_id}: segment deletion failed: {seg_err}")
logger.info(
f"Job {job_id}: deleted from history by user "
f"(segments removed: {segments_deleted})"
)
return {
"data": {"id": job_id, "deleted": True, "segments_removed": segments_deleted},
"meta": {},
}
@router_v1.get("/translate/health") @router_v1.get("/translate/health")
async def translate_health(): async def translate_health():
"""Health check for translation endpoint.""" """Health check for translation endpoint."""

View File

@@ -0,0 +1,144 @@
"""DELETE /api/v1/translations/{job_id} — retrait d'un travail de l'historique.
Contexte : un travail échoué ou oublié par le serveur (rétention 24 h en
mémoire) restait affiché dans « Traductions récentes » sans aucun moyen de
le retirer, et sa page de relecture était vide. La route supprime le
travail de la mémoire, sa clé Redis et ses segments de relecture.
"""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
from main import app
from routes.translate_routes import get_authenticated_user, _translation_jobs
@pytest.fixture()
def client(monkeypatch):
from middleware.rate_limiting import RateLimitMiddleware
async def _dispatch(self, request, call_next):
return await call_next(request)
monkeypatch.setattr(RateLimitMiddleware, "dispatch", _dispatch)
return TestClient(app)
class MockUser:
def __init__(self, user_id="user_123"):
self.id = user_id
self.plan = "free"
self.docs_translated_this_month = 0
self.pages_translated_this_month = 0
self.extra_credits = 0
def _make_job(job_id="tr_del1", user_id="user_123", status="completed"):
return {
"id": job_id,
"user_id": user_id,
"status": status,
"file_name": "doc.docx",
"created_at": "2026-09-01T10:00:00",
"progress_percent": 100,
}
@pytest.fixture(autouse=True)
def _clean_jobs():
saved = dict(_translation_jobs)
_translation_jobs.clear()
yield
_translation_jobs.clear()
_translation_jobs.update(saved)
def _seed_segments(job_id, user_id="user_123", count=2):
from database.models import Base, TranslationSegment
from database.connection import sync_engine, get_sync_session
Base.metadata.create_all(bind=sync_engine)
with get_sync_session() as session:
for i in range(count):
session.add(
TranslationSegment(
job_id=job_id,
user_id=user_id,
segment_index=i,
source_text=f"Hello {i}",
translated_text=f"Bonjour {i}",
status="pending",
)
)
session.commit()
class TestDeleteTranslationHistory:
def test_owner_deletes_completed_job(self, client):
app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
_translation_jobs["tr_del1"] = _make_job()
_seed_segments("tr_del1")
res = client.delete("/api/v1/translations/tr_del1")
assert res.status_code == 200
body = res.json()["data"]
assert body["deleted"] is True
assert body["segments_removed"] == 2
assert "tr_del1" not in _translation_jobs
from database.connection import get_sync_session
from database.models import TranslationSegment
with get_sync_session() as session:
remaining = (
session.query(TranslationSegment)
.filter(TranslationSegment.job_id == "tr_del1")
.count()
)
assert remaining == 0
app.dependency_overrides.clear()
def test_delete_is_idempotent_when_job_already_gone(self, client):
app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
# Le serveur a oublié le travail (rétention expirée / redémarrage) :
# le client veut quand même nettoyer sa liste locale.
res = client.delete("/api/v1/translations/tr_unknown")
assert res.status_code == 200
assert res.json()["data"]["deleted"] is True
app.dependency_overrides.clear()
def test_other_users_job_is_404(self, client):
app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
_translation_jobs["tr_someone"] = _make_job("tr_someone", user_id="user_999")
res = client.delete("/api/v1/translations/tr_someone")
assert res.status_code == 404
# Le travail reste intact
assert "tr_someone" in _translation_jobs
app.dependency_overrides.clear()
def test_running_job_requires_cancel_first(self, client):
app.dependency_overrides[get_authenticated_user] = _async_user(MockUser())
_translation_jobs["tr_run"] = _make_job("tr_run", status="processing")
res = client.delete("/api/v1/translations/tr_run")
assert res.status_code == 409
assert res.json()["error"] == "JOB_IN_PROGRESS"
assert "tr_run" in _translation_jobs
app.dependency_overrides.clear()
def test_unauthenticated_is_401(self, client):
async def _anonymous():
return None
app.dependency_overrides[get_authenticated_user] = _anonymous
_translation_jobs["tr_anon"] = _make_job()
res = client.delete("/api/v1/translations/tr_anon")
assert res.status_code == 401
app.dependency_overrides.clear()
def _async_user(user):
async def _auth():
return user
return _auth