feat(workflow): PR-2 workflow blockers — PM/Production dashboards, two-stage QC, role routing
Changes: - Dashboard: add project_manager case (final review / QC counts / new job widgets) and production case (AI pipeline / failures widgets) - Sidebar: add project_manager to Final Review and Audit Log nav items; live badge counts for QC Queue (pending_qc) and Final Review (pending_final_review) - App.tsx: add project_manager to Final Review and Audit Log RoleGates (W-10, PM-18) - Login: role-based redirect after login — linguist/reviewer → /qc/queue, others → / - language_qc._assert_can_approve: enforce two-stage QC; remove linguist self-approve fallback; require reviewer assignment + submitted_for_review_at (W-6) - routes_jobs.complete_job: allow project_manager to complete jobs (W-9) - notify.py: re-enable email notifications (W-7) - Fix 400 on cue save: treat empty-string audio_description_vtt/captions_vtt as absent both in backend (truthy check) and frontend (|| undefined) — root cause was adVtt initialising to '' when job has no AD track Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a168af1aa7
commit
c7a6f13b10
8 changed files with 164 additions and 29 deletions
|
|
@ -859,7 +859,7 @@ async def complete_job(
|
|||
job_id: str,
|
||||
request: CompleteJobRequest,
|
||||
http_request: Request = None,
|
||||
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN)),
|
||||
current_user: User = Depends(require_roles(UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION, UserRole.ADMIN, UserRole.PROJECT_MANAGER)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
# Get job for validation
|
||||
|
|
@ -1300,7 +1300,7 @@ async def update_job_vtt_content(
|
|||
lang_output = outputs.get(target_language, {})
|
||||
|
||||
# Validate and update captions VTT
|
||||
if request.captions_vtt is not None:
|
||||
if request.captions_vtt: # treat empty string same as None — nothing to update
|
||||
# Validate VTT format
|
||||
is_valid, errors = VTTEditor.validate_vtt(request.captions_vtt)
|
||||
if not is_valid:
|
||||
|
|
@ -1322,7 +1322,7 @@ async def update_job_vtt_content(
|
|||
lang_output["captions_vtt_gcs"] = new_captions_uri
|
||||
|
||||
# Validate and update audio description VTT
|
||||
if request.audio_description_vtt is not None:
|
||||
if request.audio_description_vtt: # treat empty string same as None — nothing to update
|
||||
# Validate VTT format
|
||||
is_valid, errors = VTTEditor.validate_vtt(request.audio_description_vtt)
|
||||
if not is_valid:
|
||||
|
|
|
|||
|
|
@ -963,23 +963,35 @@ async def reset_all_for_return_to_qc(db: AsyncIOMotorDatabase, job_id: str) -> N
|
|||
# ── Internal ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _assert_can_approve(job_doc: dict, lang: str, actor: User) -> None:
|
||||
"""Raise 403 if actor is not the assigned reviewer (or PROD/ADMIN)."""
|
||||
"""Raise 403 if actor cannot approve this language.
|
||||
|
||||
Two-stage QC is enforced: linguist must submit before reviewer can approve.
|
||||
PRODUCTION and ADMIN may override (explicit admin action, logged separately).
|
||||
"""
|
||||
if actor.role in (UserRole.PRODUCTION, UserRole.ADMIN):
|
||||
return
|
||||
|
||||
state = (job_doc.get("language_qc") or {}).get(lang, {})
|
||||
assigned_reviewer = state.get("assigned_reviewer_id") if isinstance(state, dict) else None
|
||||
if not isinstance(state, dict):
|
||||
state = {}
|
||||
|
||||
assigned_reviewer = state.get("assigned_reviewer_id")
|
||||
if assigned_reviewer is None:
|
||||
# Fallback: allow assigned linguist to approve if no reviewer assigned (backward compat)
|
||||
assigned_linguist = state.get("assigned_linguist_id") if isinstance(state, dict) else None
|
||||
if assigned_linguist == str(actor.id):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail=f"Language '{lang}' has no assigned reviewer")
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Language '{lang}' has no assigned reviewer — a reviewer must be assigned before approving",
|
||||
)
|
||||
|
||||
if assigned_reviewer != str(actor.id):
|
||||
raise HTTPException(status_code=403, detail=f"You are not the assigned reviewer for language '{lang}'")
|
||||
|
||||
submitted_at = state.get("submitted_for_review_at")
|
||||
if not submitted_at:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Language '{lang}' has not been submitted for review by the linguist yet",
|
||||
)
|
||||
|
||||
|
||||
# Keep old name for any remaining callers
|
||||
_assert_can_act = _assert_can_approve
|
||||
|
|
|
|||
|
|
@ -106,9 +106,7 @@ class NotifyClientTask(Task):
|
|||
if lang_downloads:
|
||||
download_links[language] = lang_downloads
|
||||
|
||||
# Send completion email (temporarily disabled)
|
||||
# TODO: Re-enable emails once authentication is configured
|
||||
email_enabled = False # Set to True to re-enable emails
|
||||
email_enabled = True
|
||||
|
||||
if email_enabled:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -112,14 +112,14 @@ function AppContent() {
|
|||
} />
|
||||
<Route path="/admin/final" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
|
||||
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin', 'project_manager']}>
|
||||
<FinalList />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/admin/final/:id" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin']}>
|
||||
<RoleGate allowedRoles={['reviewer', 'linguist', 'production', 'admin', 'project_manager']}>
|
||||
<FinalDetail />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
|
|
@ -175,7 +175,7 @@ function AppContent() {
|
|||
} />
|
||||
<Route path="/admin/audit-log" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['production', 'admin']}>
|
||||
<RoleGate allowedRoles={['production', 'admin', 'project_manager']}>
|
||||
<AuditLog />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../lib/auth';
|
||||
import { useMyMemberships } from '../../hooks/useClients';
|
||||
import { useJobs } from '../../hooks/useJob';
|
||||
|
||||
interface SidebarItem {
|
||||
label: string;
|
||||
|
|
@ -20,6 +21,21 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
|
|||
const params = useParams<{ orgSlug?: string }>();
|
||||
const { data: memberships = [] } = useMyMemberships();
|
||||
|
||||
const isQCRole = ['linguist', 'reviewer', 'production', 'admin'].includes(user?.role || '');
|
||||
const isPMOrAdmin = ['project_manager', 'admin'].includes(user?.role || '');
|
||||
|
||||
const { data: qcData } = useJobs(
|
||||
{ status: 'pending_qc', size: 1 },
|
||||
{ enabled: isQCRole }
|
||||
);
|
||||
const { data: finalData } = useJobs(
|
||||
{ status: 'pending_final_review', size: 1 },
|
||||
{ enabled: isPMOrAdmin }
|
||||
);
|
||||
|
||||
const qcBadge = isQCRole ? (qcData?.total || 0) : 0;
|
||||
const finalBadge = isPMOrAdmin ? (finalData?.total || 0) : 0;
|
||||
|
||||
// Determine current org from route params or first membership
|
||||
const currentOrgSlug =
|
||||
params.orgSlug ||
|
||||
|
|
@ -40,13 +56,14 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
|
|||
label: 'Upload Video',
|
||||
href: '/jobs/new',
|
||||
icon: '📤',
|
||||
roles: ['client', 'production', 'admin', 'member', 'manager'],
|
||||
roles: ['client', 'production', 'admin', 'project_manager'],
|
||||
},
|
||||
{
|
||||
label: 'My QC Queue',
|
||||
href: '/qc/queue',
|
||||
icon: '📝',
|
||||
roles: ['linguist', 'reviewer', 'production', 'admin'],
|
||||
badge: qcBadge || undefined,
|
||||
},
|
||||
{
|
||||
label: 'QC Review',
|
||||
|
|
@ -58,7 +75,8 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
|
|||
label: 'Final Review',
|
||||
href: '/admin/final',
|
||||
icon: '✅',
|
||||
roles: ['reviewer', 'linguist', 'production', 'admin'],
|
||||
roles: ['reviewer', 'linguist', 'production', 'admin', 'project_manager'],
|
||||
badge: finalBadge || undefined,
|
||||
},
|
||||
{
|
||||
label: 'User Management',
|
||||
|
|
@ -76,7 +94,7 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
|
|||
label: 'Audit Log',
|
||||
href: '/admin/audit-log',
|
||||
icon: '📋',
|
||||
roles: ['production', 'admin'],
|
||||
roles: ['production', 'admin', 'project_manager'],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ export function Dashboard() {
|
|||
|
||||
const jobs = jobsData?.jobs || [];
|
||||
|
||||
// Stats for display
|
||||
const stats = {
|
||||
total: jobs.length,
|
||||
pending: jobs.filter((j: Job) => ['created', 'ingesting', 'ai_processing'].includes(j.status)).length,
|
||||
inQC: jobs.filter((j: Job) => ['pending_qc', 'qc_feedback'].includes(j.status)).length,
|
||||
completed: jobs.filter((j: Job) => j.status === 'completed').length,
|
||||
finalReview: jobs.filter((j: Job) => j.status === 'pending_final_review').length,
|
||||
aiProcessing: jobs.filter((j: Job) => ['ingesting', 'ai_processing', 'translating', 'tts_generating', 'rendering_video'].includes(j.status)).length,
|
||||
failed: jobs.filter((j: Job) => ['tts_failed', 'render_failed'].includes(j.status)).length,
|
||||
};
|
||||
|
||||
const renderRoleSpecificContent = () => {
|
||||
|
|
@ -83,6 +85,108 @@ export function Dashboard() {
|
|||
</div>
|
||||
);
|
||||
|
||||
case 'project_manager':
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Link
|
||||
to="/admin/final"
|
||||
className="group bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-xl">✅</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">Final Review</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold mb-1">{stats.finalReview}</p>
|
||||
<p className="text-indigo-100 text-sm mb-4">awaiting your approval</p>
|
||||
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Review now →</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/admin/qc"
|
||||
className="group bg-gradient-to-br from-amber-400 to-orange-500 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-xl">🔍</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">In QC Review</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold mb-1">{stats.inQC}</p>
|
||||
<p className="text-amber-100 text-sm mb-4">languages being reviewed</p>
|
||||
<p className="text-sm font-semibold text-white/80 group-hover:text-white">View QC queue →</p>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/jobs/new"
|
||||
className="group bg-gradient-to-br from-green-400 to-emerald-600 rounded-2xl p-6 text-white hover:shadow-xl transition-all duration-200 transform hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="flex items-center mb-3">
|
||||
<div className="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-xl">📤</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold">New Job</h3>
|
||||
</div>
|
||||
<p className="text-green-100 text-sm mb-4">
|
||||
Upload a new video and configure accessibility outputs
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-white/80 group-hover:text-white">Upload now →</p>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'production':
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-gradient-to-br from-blue-500 via-blue-600 to-indigo-700 rounded-2xl p-8 text-white">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">
|
||||
<span className="text-2xl">⚙️</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">AI Pipeline</h2>
|
||||
</div>
|
||||
<p className="text-blue-100 mb-2 text-lg font-semibold">
|
||||
{stats.aiProcessing} jobs processing
|
||||
</p>
|
||||
<p className="text-blue-100 mb-6 leading-relaxed">
|
||||
Monitor ingestion, transcription, translation, and rendering across all active jobs.
|
||||
</p>
|
||||
<Link
|
||||
to="/jobs?status=ingesting,ai_processing,translating,tts_generating,rendering_video"
|
||||
className="inline-flex items-center bg-white text-blue-600 px-6 py-3 rounded-lg hover:bg-blue-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||
>
|
||||
View pipeline →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={`bg-gradient-to-br ${stats.failed > 0 ? 'from-red-500 to-red-700' : 'from-gray-400 to-gray-500'} rounded-2xl p-8 text-white`}>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-12 h-12 bg-white/20 rounded-lg flex items-center justify-center mr-4">
|
||||
<span className="text-2xl">{stats.failed > 0 ? '🚨' : '✓'}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">Failures</h2>
|
||||
</div>
|
||||
<p className="text-white/80 mb-2 text-lg font-semibold">
|
||||
{stats.failed} jobs need attention
|
||||
</p>
|
||||
<p className="text-white/70 mb-6 leading-relaxed">
|
||||
{stats.failed > 0
|
||||
? 'Jobs failed during processing. Click to retry or investigate.'
|
||||
: 'No failures. All systems running normally.'}
|
||||
</p>
|
||||
{stats.failed > 0 && (
|
||||
<Link
|
||||
to="/jobs?status=tts_failed,render_failed"
|
||||
className="inline-flex items-center bg-white text-red-600 px-6 py-3 rounded-lg hover:bg-red-50 transition-all duration-200 font-semibold shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||
>
|
||||
Investigate →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'reviewer':
|
||||
case 'linguist':
|
||||
case 'admin':
|
||||
|
|
|
|||
|
|
@ -6,6 +6,11 @@ import { loginRequest } from '../lib/msalConfig';
|
|||
import { apiClient } from '../lib/api';
|
||||
import type { UserRole } from '../types/api';
|
||||
|
||||
function getRoleRedirect(role: string): string {
|
||||
if (role === 'linguist' || role === 'reviewer') return '/qc/queue';
|
||||
return '/';
|
||||
}
|
||||
|
||||
export function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
|
@ -22,9 +27,8 @@ export function Login() {
|
|||
|
||||
try {
|
||||
await login(email, password);
|
||||
console.log('Login successful');
|
||||
// Navigate to dashboard after successful login
|
||||
navigate('/');
|
||||
const { user: loggedInUser } = useAuthStore.getState();
|
||||
navigate(getRoleRedirect(loggedInUser?.role || ''));
|
||||
} catch (err: unknown) {
|
||||
console.error('Login failed:', err);
|
||||
const error = err as { code?: string; response?: { data?: { message?: string } }; message?: string };
|
||||
|
|
@ -61,8 +65,7 @@ export function Login() {
|
|||
created_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log('Microsoft login successful');
|
||||
navigate('/');
|
||||
navigate(getRoleRedirect(loginResponse.role));
|
||||
} catch (err: unknown) {
|
||||
console.error('Microsoft login failed:', err);
|
||||
const error = err as { code?: string; response?: { data?: { detail?: string; message?: string } }; message?: string };
|
||||
|
|
|
|||
|
|
@ -386,14 +386,14 @@ export function QCDetail() {
|
|||
if (type === 'captions') {
|
||||
await updateVttMutation.mutateAsync({
|
||||
id,
|
||||
data: { captions_vtt: content, audio_description_vtt: adVtt, language: selectedLanguage },
|
||||
data: { captions_vtt: content, audio_description_vtt: adVtt || undefined, language: selectedLanguage },
|
||||
});
|
||||
setCaptionsVtt(content);
|
||||
toast.toastOnly.success('Captions VTT replaced from file');
|
||||
} else {
|
||||
await updateVttMutation.mutateAsync({
|
||||
id,
|
||||
data: { captions_vtt: captionsVtt, audio_description_vtt: content, language: selectedLanguage },
|
||||
data: { captions_vtt: captionsVtt || undefined, audio_description_vtt: content, language: selectedLanguage },
|
||||
});
|
||||
setAdVtt(content);
|
||||
setAdVttUploaded(true);
|
||||
|
|
@ -414,7 +414,7 @@ export function QCDetail() {
|
|||
id,
|
||||
data: {
|
||||
captions_vtt: vttContent,
|
||||
audio_description_vtt: adVtt,
|
||||
audio_description_vtt: adVtt || undefined,
|
||||
language: selectedLanguage
|
||||
}
|
||||
});
|
||||
|
|
@ -430,7 +430,7 @@ export function QCDetail() {
|
|||
await updateVttMutation.mutateAsync({
|
||||
id,
|
||||
data: {
|
||||
captions_vtt: captionsVtt,
|
||||
captions_vtt: captionsVtt || undefined,
|
||||
audio_description_vtt: vttContent,
|
||||
language: selectedLanguage
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue