fix(qa): approval queue, rubric UX, structure enforcement, upload/publish limits
Addresses the QA notes on the end-to-end approval flow. Highlights:
Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
submitted exams actually reach the approver queue (previously only the
workflow id was stored on the exam, leaving approvers with an empty inbox).
Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
* /api/approval-users filters out students (user_type='student',
op_student_id set, share=True) so the approver dropdown only lists staff.
* _ser_request enriches requests with target_name / target_status and the
current stage approver for UI badges.
* list_requests supports mine=1 / requester=1 so approvers only see queue
items awaiting their action.
* approve_request / reject_request now transition the underlying
encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
match the approval workflow transitions.
Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
now carry explicit help text explaining None / Line / Space / Page Break
and where to write prompts. Selecting an official exam structure
auto-populates the required tasks/sections/parts, the delete button is
hidden for essential tasks, and submit is blocked if the user dropped
below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
backend error messages (413/504/401) instead of a generic toast, read the
exam id from the correct response field, and avoid marking Publish as
failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".
Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
limit_time_real to 900s, limit_request to 128 MB, and memory caps so
/api/exam/generation/submit and multipart resource uploads stop hitting
504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
proxy_read_timeout / proxy_send_timeout to 900s, and disable request
buffering so large AI/resource payloads stream through the proxy.
Made-with: Cursor
This commit is contained in:
@@ -709,8 +709,51 @@ export default function GenerationPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModule, moduleStates]);
|
||||
|
||||
// Compute the minimum required counts from the selected exam structure
|
||||
// so the submit mutation can block under-fulfilled modules instead of
|
||||
// silently dropping tasks/sections (QA: "if structure has Task 1 + Task 2
|
||||
// the user should not be able to submit with only Task 1").
|
||||
const requiredWritingTaskCount = (() => {
|
||||
const cfg = selectedStructureConfig;
|
||||
if (!cfg?.writing) return 0;
|
||||
return Math.max(
|
||||
cfg.writing.tasks?.length ?? 0,
|
||||
cfg.writing.task2 ? 2 : cfg.writing.task1 ? 1 : 0,
|
||||
);
|
||||
})();
|
||||
const requiredListeningPartCount = selectedStructureConfig?.listening?.parts?.length ?? 0;
|
||||
const requiredReadingPassageCount = selectedStructureConfig?.reading?.passages?.length ?? 0;
|
||||
const requiredSpeakingPartCount = selectedStructureConfig?.speaking?.parts?.length ?? 0;
|
||||
|
||||
const submitMut = useMutation({
|
||||
mutationFn: (skipApproval: boolean) => {
|
||||
// Enforce structure-defined task/section/part counts before submission.
|
||||
if (examMode === "official" && selectedStructure) {
|
||||
const w = getModuleState("writing");
|
||||
if (selectedModules.has("writing") && requiredWritingTaskCount > 0 && w.writingTasks.length < requiredWritingTaskCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredWritingTaskCount} writing tasks — you currently have ${w.writingTasks.length}. Please add the missing task before submitting.`,
|
||||
);
|
||||
}
|
||||
const l = getModuleState("listening");
|
||||
if (selectedModules.has("listening") && requiredListeningPartCount > 0 && l.listeningSections.length < requiredListeningPartCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredListeningPartCount} listening parts — you currently have ${l.listeningSections.length}.`,
|
||||
);
|
||||
}
|
||||
const r = getModuleState("reading");
|
||||
if (selectedModules.has("reading") && requiredReadingPassageCount > 0 && r.passages.length < requiredReadingPassageCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredReadingPassageCount} reading passages — you currently have ${r.passages.length}.`,
|
||||
);
|
||||
}
|
||||
const sp = getModuleState("speaking");
|
||||
if (selectedModules.has("speaking") && requiredSpeakingPartCount > 0 && sp.speakingParts.length < requiredSpeakingPartCount) {
|
||||
throw new Error(
|
||||
`This structure defines ${requiredSpeakingPartCount} speaking parts — you currently have ${sp.speakingParts.length}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const modulesPayload: Record<string, unknown> = {};
|
||||
for (const mod of selectedModules) {
|
||||
const st = getModuleState(mod);
|
||||
@@ -773,6 +816,11 @@ export default function GenerationPage() {
|
||||
|
||||
const renderCommonConfig = (mod: ModuleKey) => {
|
||||
const st = getModuleState(mod);
|
||||
// Rubrics only apply to subjective skills (Writing & Speaking).
|
||||
// Listening & Reading are auto-graded against a correct_answer, so
|
||||
// surfacing the rubric picker here is misleading — QA asked to either
|
||||
// grey it out or mark it "Not applicable".
|
||||
const rubricApplies = mod === "writing" || mod === "speaking";
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||
<div className="space-y-1">
|
||||
@@ -781,30 +829,44 @@ export default function GenerationPage() {
|
||||
</div>
|
||||
{examMode === "official" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Rubric</Label>
|
||||
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{rubrics.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||
)}
|
||||
{rubrics.map((r) => (
|
||||
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||
))}
|
||||
{rubricGroups.length > 0 && (
|
||||
<>
|
||||
<div className="border-t my-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||
</>
|
||||
)}
|
||||
{rubricGroups.map((g) => (
|
||||
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||
))}
|
||||
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label className="text-xs flex items-center gap-1">
|
||||
Rubric
|
||||
{!rubricApplies && (
|
||||
<span className="text-[10px] font-normal text-muted-foreground">(Not applicable)</span>
|
||||
)}
|
||||
</Label>
|
||||
{rubricApplies ? (
|
||||
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{rubrics.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||
)}
|
||||
{rubrics.map((r) => (
|
||||
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||
))}
|
||||
{rubricGroups.length > 0 && (
|
||||
<>
|
||||
<div className="border-t my-1" />
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||
</>
|
||||
)}
|
||||
{rubricGroups.map((g) => (
|
||||
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
||||
))}
|
||||
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div
|
||||
className="h-8 text-xs flex items-center px-3 rounded-md border border-input bg-muted/40 text-muted-foreground select-none"
|
||||
title="Reading and Listening are auto-graded from a correct-answer key; rubrics only apply to Writing and Speaking."
|
||||
>
|
||||
Auto-graded — no rubric
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
@@ -1202,6 +1264,12 @@ export default function GenerationPage() {
|
||||
<SelectItem value="page_break">Page Break</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Controls how this section is visually separated from the next in the student view: <b>None</b> — no separator; <b>Line</b> — horizontal rule; <b>Space</b> — extra vertical gap; <b>Page Break</b> — section starts on a new page (paginated delivery only).
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Write instructions in the box above the audio (or the <i>Generate Instructions</i> card). Use the imperative voice, e.g. “Listen to the conversation and answer the questions below.”
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Collapsible>
|
||||
@@ -1407,7 +1475,7 @@ export default function GenerationPage() {
|
||||
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||
<Checkbox checked id={`wt-${i}`} />
|
||||
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
||||
{st.writingTasks.length > 1 && (
|
||||
{st.writingTasks.length > Math.max(1, requiredWritingTaskCount) && (
|
||||
<button
|
||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||
onClick={() => {
|
||||
@@ -1428,6 +1496,11 @@ export default function GenerationPage() {
|
||||
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
|
||||
)}
|
||||
</div>
|
||||
{requiredWritingTaskCount > 0 && st.writingTasks.length < requiredWritingTaskCount && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
This structure requires {requiredWritingTaskCount} tasks. Add the missing task to submit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, writing: defaultModuleState("writing") }))} className="text-xs">
|
||||
@@ -1485,6 +1558,12 @@ export default function GenerationPage() {
|
||||
<SelectItem value="page_break">Page Break</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Visual separator between Task 1 and Task 2 in the student exam: <b>None</b> — no gap; <b>Line</b> — horizontal rule; <b>Space</b> — extra padding; <b>Page Break</b> — Task 2 opens on a new page.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
|
||||
Instructions are written in the <i>Generate Instructions</i> box below. Use the imperative voice and name the genre + audience, e.g. “Write a 250-word academic essay arguing…”.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Collapsible>
|
||||
@@ -2196,6 +2275,54 @@ export default function GenerationPage() {
|
||||
setSelectedModules(next);
|
||||
if (next.size > 0) setActiveModule([...next][0]);
|
||||
}
|
||||
// Pre-populate writingTasks / listeningSections / readingPassages
|
||||
// / speakingParts to match the structure. QA flagged that selecting
|
||||
// a structure with "Task 1 + Task 2" still let you submit with only
|
||||
// Task 1 because the UI defaulted to a single task.
|
||||
const cfg = (s.config && typeof s.config === "object" ? s.config : null) as ExamStructureConfig | null;
|
||||
if (cfg) {
|
||||
const writingTaskCount = Math.max(
|
||||
cfg.writing?.tasks?.length ?? 0,
|
||||
cfg.writing?.task2 ? 2 : cfg.writing?.task1 ? 1 : 0,
|
||||
);
|
||||
if (writingTaskCount > 0) {
|
||||
updateModuleState("writing", {
|
||||
writingTasks: Array.from({ length: writingTaskCount }, (_, i) => {
|
||||
const base = defaultWritingTask();
|
||||
if (i === 1) {
|
||||
base.type = "essay";
|
||||
base.wordLimit = 250;
|
||||
}
|
||||
const taskCfg = cfg.writing?.tasks?.[i];
|
||||
if (taskCfg) {
|
||||
if (taskCfg.type) base.type = taskCfg.type;
|
||||
if (taskCfg.min_words) base.wordLimit = taskCfg.min_words;
|
||||
}
|
||||
return base;
|
||||
}),
|
||||
});
|
||||
}
|
||||
const listeningPartCount = cfg.listening?.parts?.length ?? 0;
|
||||
if (listeningPartCount > 0) {
|
||||
updateModuleState("listening", {
|
||||
listeningSections: Array.from({ length: listeningPartCount }, () => defaultListeningSection()),
|
||||
});
|
||||
}
|
||||
const readingPassageCount = cfg.reading?.passages?.length ?? 0;
|
||||
if (readingPassageCount > 0) {
|
||||
updateModuleState("reading", {
|
||||
passages: Array.from({ length: readingPassageCount }, () => defaultPassage()),
|
||||
});
|
||||
}
|
||||
const speakingPartCount = cfg.speaking?.parts?.length ?? 0;
|
||||
if (speakingPartCount > 0) {
|
||||
updateModuleState("speaking", {
|
||||
speakingParts: Array.from({ length: speakingPartCount }, (_, i) =>
|
||||
defaultSpeakingPart(i === 2 ? "interactive" : `speaking_${i + 1}`),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user