fix(briefs): fix Project/Assign-To dropdowns and expand Requested Outputs
Projects: - PM now sees all active projects (same as admin/production) — was filtering to empty when pm_client_ids and org memberships were both unset Assign To: - Replaced useOrganizations()+useOrgMembers() with a new GET /admin/brief-assignees endpoint accessible to all authenticated users — returns active admin/PM/production users sorted by name; shows role next to name in dropdown Requested Outputs: - Added SDH Captions (VTT), Descriptive Transcript, Accessible Video (MP4) - Accessible Video shows Pause Insert / Voice Overlay radio selector - Added descriptive_transcript field to RequestedOutputs model (backend + frontend) Access: - Brief routes now open to 'client' role in addition to admin/PM/production Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a14444d61c
commit
68ac65ac05
9 changed files with 122 additions and 22 deletions
11
.gitignore
vendored
11
.gitignore
vendored
|
|
@ -99,3 +99,14 @@ docs/*.pdf
|
|||
/var/www/html/video-accessibility.backup.*
|
||||
|
||||
backend/.env
|
||||
|
||||
# Node / npm artifacts at repo root (Playwright MCP installs these)
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# Playwright MCP session snapshots
|
||||
.playwright-mcp/
|
||||
|
||||
# Test videos
|
||||
test-video.mp4
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase
|
|||
|
||||
from ...core.authz import MembershipContext, get_membership_context
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import require_roles
|
||||
from ...core.dependencies import get_current_user, require_roles
|
||||
from ...core.logging import get_logger
|
||||
from ...core.security import get_password_hash
|
||||
from ...models.audit_log import AuditAction, AuditLogQuery, AuditLogResponse
|
||||
|
|
@ -96,6 +96,32 @@ async def list_users(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/brief-assignees", response_model=list[UserResponse])
|
||||
async def list_brief_assignees(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Return users who can be assigned a brief (PM, production, admin). Accessible to all brief-creating roles."""
|
||||
docs = await db.users.find(
|
||||
{
|
||||
"role": {"$in": [UserRole.ADMIN.value, UserRole.PROJECT_MANAGER.value, UserRole.PRODUCTION.value]},
|
||||
"is_active": True,
|
||||
},
|
||||
{"hashed_password": 0},
|
||||
).sort("full_name", 1).to_list(None)
|
||||
return [UserResponse(
|
||||
id=str(d["_id"]),
|
||||
email=d["email"],
|
||||
full_name=d["full_name"],
|
||||
role=d["role"],
|
||||
auth_provider=d.get("auth_provider", "local"),
|
||||
is_active=d["is_active"],
|
||||
created_at=d.get("created_at", datetime.utcnow()).isoformat() if d.get("created_at") else None,
|
||||
pm_client_ids=d.get("pm_client_ids", []),
|
||||
languages=d.get("languages", []),
|
||||
) for d in docs]
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=UserResponse)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
|
|
|
|||
|
|
@ -379,7 +379,7 @@ async def list_all_projects(
|
|||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
"""Return all active projects accessible to the current user (across all clients)."""
|
||||
if current_user.role in (UserRole.ADMIN, UserRole.PRODUCTION):
|
||||
if current_user.role in (UserRole.ADMIN, UserRole.PRODUCTION, UserRole.PROJECT_MANAGER):
|
||||
docs = await db.projects.find({"is_active": True}).to_list(None)
|
||||
else:
|
||||
accessible_client_ids = await _get_accessible_client_ids(current_user, db)
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ class RequestedOutputs(BaseModel):
|
|||
accessible_video_mp4: bool = False # Rendered video with embedded audio descriptions
|
||||
accessible_video_method: Literal["overlay", "pause_insert"] | None = None # User-selected method
|
||||
sdh_vtt: bool = False # SDH (Subtitles for Deaf and Hard of Hearing) captions with speaker labels, sound effects, music notation
|
||||
descriptive_transcript: bool = False # WCAG-compliant combined speech+description transcript text file
|
||||
languages: list[str] = []
|
||||
transcreation: list[str] = []
|
||||
tts_preferences: TTSPreferences | None = None
|
||||
|
|
|
|||
|
|
@ -198,21 +198,21 @@ function AppContent() {
|
|||
} />
|
||||
<Route path="/briefs" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
|
||||
<RoleGate allowedRoles={['client', 'project_manager', 'admin', 'production']}>
|
||||
<BriefsList />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/briefs/new" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
|
||||
<RoleGate allowedRoles={['client', 'project_manager', 'admin', 'production']}>
|
||||
<NewBrief />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/briefs/:id" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['project_manager', 'admin', 'production']}>
|
||||
<RoleGate allowedRoles={['client', 'project_manager', 'admin', 'production']}>
|
||||
<BriefDetail />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
|
|
|
|||
|
|
@ -302,6 +302,13 @@ export function useMyMemberships() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useBriefAssignees() {
|
||||
return useQuery({
|
||||
queryKey: ['brief-assignees'],
|
||||
queryFn: () => apiClient.listBriefAssignees(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Invitations ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function useInvitations(orgId: string) {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,11 @@ class ApiClient {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
async listBriefAssignees(): Promise<import('../types/api').User[]> {
|
||||
const response = await this.client.get('/admin/brief-assignees');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createBrief(data: import('../types/api').JobBriefCreate): Promise<import('../types/api').JobBriefResponse> {
|
||||
const response = await this.client.post('/briefs', data);
|
||||
return response.data;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useCreateBrief } from '../../hooks/useJob';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import { useAllProjects, useOrganizations, useOrgMembers } from '../../hooks/useClients';
|
||||
import { useAllProjects, useBriefAssignees } from '../../hooks/useClients';
|
||||
|
||||
const ALL_LANGUAGES: { code: string; label: string }[] = [
|
||||
{ code: 'en', label: 'EN' },
|
||||
|
|
@ -68,14 +68,18 @@ export function NewBrief() {
|
|||
const [deadline, setDeadline] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [assigneeId, setAssigneeId] = useState('');
|
||||
|
||||
// Requested outputs
|
||||
const [captionsVtt, setCaptionsVtt] = useState(true);
|
||||
const [adVtt, setAdVtt] = useState(true);
|
||||
const [adMp3, setAdMp3] = useState(true);
|
||||
const [accessibleMp4, setAccessibleMp4] = useState(false);
|
||||
const [accessibleMethod, setAccessibleMethod] = useState<'overlay' | 'pause_insert'>('pause_insert');
|
||||
const [sdhVtt, setSdhVtt] = useState(false);
|
||||
const [descriptiveTranscript, setDescriptiveTranscript] = useState(false);
|
||||
|
||||
const { data: projects = [] } = useAllProjects();
|
||||
const { data: organizations = [] } = useOrganizations();
|
||||
const firstOrgId = organizations[0]?.id ?? '';
|
||||
const { data: members = [] } = useOrgMembers(firstOrgId);
|
||||
const { data: assignees = [] } = useBriefAssignees();
|
||||
|
||||
const toggleLang = (lang: string) => {
|
||||
setLanguages(prev =>
|
||||
|
|
@ -97,8 +101,10 @@ export function NewBrief() {
|
|||
captions_vtt: captionsVtt,
|
||||
audio_description_vtt: adVtt,
|
||||
audio_description_mp3: adMp3,
|
||||
accessible_video_mp4: false,
|
||||
sdh_vtt: false,
|
||||
accessible_video_mp4: accessibleMp4,
|
||||
accessible_video_method: accessibleMp4 ? accessibleMethod : undefined,
|
||||
sdh_vtt: sdhVtt,
|
||||
descriptive_transcript: descriptiveTranscript,
|
||||
languages,
|
||||
transcreation: [],
|
||||
translation_mode: 'video_native',
|
||||
|
|
@ -167,9 +173,9 @@ export function NewBrief() {
|
|||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— Unassigned —</option>
|
||||
{members.map(m => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.full_name || m.email}
|
||||
{assignees.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.full_name || u.email} ({u.role.replace('_', ' ')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -177,17 +183,60 @@ export function NewBrief() {
|
|||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Requested Outputs</label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
{([
|
||||
[captionsVtt, setCaptionsVtt, 'Captions (VTT)'],
|
||||
[adVtt, setAdVtt, 'Audio Descriptions (VTT)'],
|
||||
[adMp3, setAdMp3, 'Audio Descriptions (MP3)'],
|
||||
] as [boolean, (v: boolean) => void, string][]).map(([val, setter, label]) => (
|
||||
<label key={label} className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" checked={val} onChange={e => setter(e.target.checked)} className="rounded" />
|
||||
{label}
|
||||
[captionsVtt, setCaptionsVtt, 'Captions (VTT)', 'Closed captions in WebVTT format'],
|
||||
[adVtt, setAdVtt, 'Audio Descriptions (VTT)', 'Audio description cues in WebVTT format'],
|
||||
[adMp3, setAdMp3, 'Audio Descriptions (MP3)', 'Synthesised audio description track'],
|
||||
[sdhVtt, setSdhVtt, 'SDH Captions (VTT)', 'Subtitles for Deaf and Hard of Hearing — includes speaker labels and sound effects'],
|
||||
[descriptiveTranscript, setDescriptiveTranscript, 'Descriptive Transcript', 'Full text transcript with integrated audio descriptions'],
|
||||
] as [boolean, (v: boolean) => void, string, string][]).map(([val, setter, label, hint]) => (
|
||||
<label key={label} className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={val}
|
||||
onChange={e => setter(e.target.checked)}
|
||||
className="rounded mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="text-gray-400 ml-1">— {hint}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Accessible Video — with method selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accessibleMp4}
|
||||
onChange={e => setAccessibleMp4(e.target.checked)}
|
||||
className="rounded mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Accessible Video (MP4)</span>
|
||||
<span className="text-gray-400 ml-1">— Video with embedded audio descriptions</span>
|
||||
</span>
|
||||
</label>
|
||||
{accessibleMp4 && (
|
||||
<div className="ml-6 flex gap-4">
|
||||
{(['pause_insert', 'overlay'] as const).map(method => (
|
||||
<label key={method} className="flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="accessible_method"
|
||||
value={method}
|
||||
checked={accessibleMethod === method}
|
||||
onChange={() => setAccessibleMethod(method)}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span>{method === 'pause_insert' ? 'Pause Insert' : 'Voice Overlay'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface RequestedOutputs {
|
|||
accessible_video_mp4: boolean; // Rendered video with embedded audio descriptions
|
||||
accessible_video_method?: AccessibleVideoMethod; // User-selected method for accessible video
|
||||
sdh_vtt?: boolean; // SDH captions with speaker labels, sound effects, music notation
|
||||
descriptive_transcript?: boolean; // WCAG-compliant combined speech+description transcript
|
||||
languages: string[];
|
||||
transcreation: string[];
|
||||
tts_preferences?: TTSPreferences;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue