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
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:
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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": "لا تحتوي هذه الترجمة على أجزاء محفوظة: إما أنها فشلت قبل ترجمة النص، أو لم يعد الخادم يحتفظ بها. يمكنك إزالتها من قائمة الترجمات الأخيرة."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "این ترجمه بخش ذخیرهشدهای ندارد: یا پیش از ترجمهٔ متن ناموفق بوده، یا سرور دیگر آن را نگه نمیدارد. میتوانید آن را از فهرست ترجمههای اخیر حذف کنید."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "この翻訳には保存されたセグメントがありません。テキストの翻訳前に失敗したか、サーバーに保持されていません。最近の翻訳リストから削除できます。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "이 번역에는 저장된 세그먼트가 없습니다. 텍스트 번역 전에 실패했거나 서버에 더 이상 보관되지 않습니다. 최근 번역 목록에서 제거할 수 있습니다."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "У этого перевода нет сохранённых сегментов: либо он завершился ошибкой до перевода текста, либо сервер его больше не хранит. Вы можете удалить его из списка недавних переводов."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "此翻译没有已存储的段落:可能是在翻译文本之前失败,或服务器已不再保留。您可以将其从最近翻译列表中移除。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
144
tests/test_delete_translation_history.py
Normal file
144
tests/test_delete_translation_history.py
Normal 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
|
||||||
Reference in New Issue
Block a user