From f1a9e6ee4699eac8bc5c502a5bc4ef0e487385e9 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Wed, 29 Apr 2026 18:53:14 +0100 Subject: [PATCH] feat(pm7): bulk assign linguist/reviewer to all languages in one click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /jobs/{job_id}/languages/bulk-assign — assigns linguist (required) and reviewer (optional) across all or selected languages; supports only_unassigned flag and optional deadline - bulkAssignLanguages() added to API client - QCDetail: "Assign all languages" button in Languages header; opens modal with linguist/reviewer dropdowns, deadline, and skip-already-assigned checkbox Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/v1/routes_language_qc.py | 76 ++++++++++++++ frontend/src/lib/api.ts | 11 ++ frontend/src/routes/admin/QCDetail.tsx | 125 +++++++++++++++++++++++ 3 files changed, 212 insertions(+) diff --git a/backend/app/api/v1/routes_language_qc.py b/backend/app/api/v1/routes_language_qc.py index 7d9d541..14532e2 100644 --- a/backend/app/api/v1/routes_language_qc.py +++ b/backend/app/api/v1/routes_language_qc.py @@ -84,6 +84,20 @@ class QueueResponse(BaseModel): total: int +class BulkAssignRequest(BaseModel): + linguist_user_id: str + reviewer_user_id: Optional[str] = None + languages: Optional[list[str]] = None # None = all available languages + only_unassigned: bool = False # skip languages that already have an assignment + deadline: Optional[datetime] = None + + +class BulkAssignResponse(BaseModel): + assigned: list[str] + skipped: list[str] + errors: dict[str, str] + + # ── Routes ──────────────────────────────────────────────────────────────────── @router.get("/jobs/{job_id}/language-qc", response_model=LanguageQCMapResponse) @@ -175,6 +189,68 @@ async def reassign_reviewer( return LanguageQCStateResponse(lang=lang, state=state) +# ── Bulk assignment ─────────────────────────────────────────────────────────── + +@router.post("/jobs/{job_id}/languages/bulk-assign", response_model=BulkAssignResponse) +async def bulk_assign_languages( + job_id: str, + request: BulkAssignRequest, + http_request: Request, + current_user: User = Depends(require_roles( + UserRole.PROJECT_MANAGER, UserRole.PRODUCTION, UserRole.ADMIN, + )), + db: AsyncIOMotorDatabase = Depends(get_database), +): + """Assign one linguist (and optionally one reviewer) to multiple languages in one call.""" + job_doc = await db["jobs"].find_one({"_id": job_id}) + if not job_doc: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Job not found") + + available = list((job_doc.get("outputs") or {}).keys()) + target_langs = request.languages if request.languages else available + + assigned: list[str] = [] + skipped: list[str] = [] + errors: dict[str, str] = {} + + language_qc = job_doc.get("language_qc") or {} + + for lang in target_langs: + if lang not in available: + skipped.append(lang) + continue + + lang_state = language_qc.get(lang) or {} + already_assigned = bool(lang_state.get("assigned_linguist_id")) + + if request.only_unassigned and already_assigned: + skipped.append(lang) + continue + + try: + await lqc.assign_linguist( + db, job_id, lang, request.linguist_user_id, current_user, + http_request=http_request, deadline=request.deadline, + ) + except Exception as exc: + errors[lang] = str(exc) + continue + + if request.reviewer_user_id: + try: + await lqc.assign_reviewer( + db, job_id, lang, request.reviewer_user_id, current_user, + http_request=http_request, deadline=request.deadline, + ) + except Exception as exc: + errors[f"{lang}:reviewer"] = str(exc) + + assigned.append(lang) + + return BulkAssignResponse(assigned=assigned, skipped=skipped, errors=errors) + + # ── Workflow transitions ────────────────────────────────────────────────────── @router.post("/jobs/{job_id}/languages/{lang}/start-work", response_model=LanguageQCStateResponse) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ad73b0f..7a1ed30 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -788,6 +788,17 @@ class ApiClient { return r.data; } + async bulkAssignLanguages(jobId: string, payload: { + linguist_user_id: string; + reviewer_user_id?: string; + languages?: string[]; + only_unassigned?: boolean; + deadline?: string; + }): Promise<{ assigned: string[]; skipped: string[]; errors: Record }> { + const r = await this.client.post(`/jobs/${jobId}/languages/bulk-assign`, payload); + return r.data; + } + async reopenLanguageQC(jobId: string, lang: string, notes?: string): Promise { const r = await this.client.post(`/jobs/${jobId}/languages/${lang}/reopen`, { notes }); return r.data; diff --git a/frontend/src/routes/admin/QCDetail.tsx b/frontend/src/routes/admin/QCDetail.tsx index 36bd289..bc5c384 100644 --- a/frontend/src/routes/admin/QCDetail.tsx +++ b/frontend/src/routes/admin/QCDetail.tsx @@ -120,6 +120,13 @@ export function QCDetail() { const [assigningUserId, setAssigningUserId] = useState(''); const [assignDeadline, setAssignDeadline] = useState(''); + // Bulk assign modal state + const [showBulkAssignModal, setShowBulkAssignModal] = useState(false); + const [bulkLinguistId, setBulkLinguistId] = useState(''); + const [bulkReviewerId, setBulkReviewerId] = useState(''); + const [bulkOnlyUnassigned, setBulkOnlyUnassigned] = useState(true); + const [bulkDeadline, setBulkDeadline] = useState(''); + // Comments panel per language const [openCommentLang, setOpenCommentLang] = useState(null); const [commentDraft, setCommentDraft] = useState(''); @@ -135,6 +142,19 @@ export function QCDetail() { }); const assignableUsers = assignableUsersData?.users ?? []; + const { data: bulkLinguistsData } = useQuery({ + queryKey: ['users-list', 'linguist'], + queryFn: () => apiClient.listUsers({ role: 'linguist', active_only: true, size: 100 }), + enabled: showBulkAssignModal, + }); + const { data: bulkReviewersData } = useQuery({ + queryKey: ['users-list', 'reviewer'], + queryFn: () => apiClient.listUsers({ role: 'reviewer', active_only: true, size: 100 }), + enabled: showBulkAssignModal, + }); + const bulkLinguists = bulkLinguistsData?.users ?? []; + const bulkReviewers = bulkReviewersData?.users ?? []; + const approveLanguageMutation = useMutation({ mutationFn: ({ lang, notes }: { lang: string; notes?: string }) => apiClient.approveLanguageQC(id!, lang, notes), @@ -178,6 +198,30 @@ export function QCDetail() { onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Assignment failed'), }); + const bulkAssignMutation = useMutation({ + mutationFn: () => apiClient.bulkAssignLanguages(id!, { + linguist_user_id: bulkLinguistId, + reviewer_user_id: bulkReviewerId || undefined, + only_unassigned: bulkOnlyUnassigned, + deadline: bulkDeadline || undefined, + }), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ['language-qc', id] }); + refetchLangQc(); + setShowBulkAssignModal(false); + setBulkLinguistId(''); + setBulkReviewerId(''); + setBulkDeadline(''); + const errCount = Object.keys(result.errors).length; + if (errCount > 0) { + toast.toastOnly.error(`Assigned ${result.assigned.length} language(s); ${errCount} error(s)`); + } else { + toast.toastOnly.success(`Assigned ${result.assigned.length} language(s)${result.skipped.length ? `, skipped ${result.skipped.length}` : ''}`); + } + }, + onError: (e: any) => toast.toastOnly.error(e?.response?.data?.detail || 'Bulk assignment failed'), + }); + const submitForReviewMutation = useMutation({ mutationFn: (lang: string) => apiClient.submitForReview(id!, lang), onSuccess: () => { @@ -793,6 +837,14 @@ export function QCDetail() { )} + {canAssign && totalLangs > 1 && ( + + )} {/* Progress bar */} @@ -1124,6 +1176,79 @@ export function QCDetail() { )} + {/* Bulk assign modal */} + {showBulkAssignModal && ( +
+
+

Assign all languages

+ +
+ + +
+ +
+ + +
+ +
+ + setBulkDeadline(e.target.value)} + className="w-full text-sm border border-gray-300 rounded px-2 py-1.5" + /> +
+ + + +
+ + +
+
+
+ )} + {/* Rendering Status Banner */} {isRendering && (