v4 #1

Merged
yamen merged 19 commits from v4 into main 2026-04-26 00:38:14 +02:00
13 changed files with 525 additions and 72 deletions
Showing only changes of commit a554ef5d42 - Show all commits

View File

@@ -1372,7 +1372,7 @@ class AIController(http.Controller):
except KeyError:
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
initial_status = "published" if skip_approval else "draft"
initial_status = "published" if skip_approval else "pending_approval"
exam_vals = {
"title": title,
"label": label,
@@ -1558,11 +1558,46 @@ class AIController(http.Controller):
quality_summary["failed"], quality_summary["warned"],
)
# ── Route through the approval workflow ────────────────────────
# If the admin selected an approval workflow AND did not click
# "skip approval", create a real ``encoach.approval.request`` that
# lands in the first stage approver's queue. QA flagged that
# submitted modules were invisible to the assigned approver —
# before this block the exam was just left in ``draft`` with
# ``approval_workflow_id`` set but no request record routing it.
approval_request_id = False
if not skip_approval and workflow_id:
try:
Workflow = request.env["encoach.approval.workflow"].sudo()
workflow = Workflow.browse(workflow_id)
if workflow.exists() and workflow.stage_ids:
first_stage = workflow.stage_ids.sorted("sequence")[:1]
approval_req = request.env["encoach.approval.request"].sudo().create({
"workflow_id": workflow.id,
"res_model": "encoach.exam.custom",
"res_id": exam.id,
"state": "in_progress" if first_stage else "draft",
"requester_id": request.env.user.id,
"current_stage_id": first_stage.id if first_stage else False,
})
approval_request_id = approval_req.id
_logger.info(
"created approval.request %s for exam %s (workflow %s, stage %s)",
approval_req.id, exam.id, workflow.id,
first_stage.id if first_stage else None,
)
except Exception:
_logger.exception(
"failed to create approval request for exam %s workflow %s",
exam.id, workflow_id,
)
return _json_response({
"exam_id": exam.id,
"status": exam.status,
"template_id": template_id,
"total_questions": total_questions,
"approval_request_id": approval_request_id,
"quality": quality_summary,
"schema_validation": {
"verdict": schema_report["verdict"],

View File

@@ -57,17 +57,33 @@ def _ser_workflow(wf):
def _ser_request(req):
stage = req.current_stage_id
# Resolve target record so the UI can show "what am I approving?"
# rather than an opaque id.
target_name = ''
target_status = ''
if req.res_model and req.res_id:
try:
target = request.env[req.res_model].sudo().browse(req.res_id)
if target.exists():
target_name = getattr(target, 'title', '') or getattr(target, 'name', '') or ''
target_status = getattr(target, 'status', '') or ''
except (KeyError, AttributeError):
pass
return {
'id': req.id,
'workflow_id': req.workflow_id.id or None,
'workflow_name': req.workflow_id.name if req.workflow_id else '',
'res_model': req.res_model or '',
'res_id': req.res_id or 0,
'target_name': target_name,
'target_status': target_status,
'state': req.state or 'draft',
'requester_id': req.requester_id.id or None,
'requester_name': req.requester_id.name if req.requester_id else '',
'current_stage_id': stage.id or None,
'current_stage_sequence': stage.sequence if stage else None,
'current_stage_approver_id': stage.approver_id.id if stage and stage.approver_id else None,
'current_stage_approver_name': stage.approver_id.name if stage and stage.approver_id else '',
'bypass_reason': req.bypass_reason or '',
'created_at': req.created_at.isoformat() if req.created_at else None,
}
@@ -234,6 +250,16 @@ class ApprovalWorkflowController(http.Controller):
methods=['GET'], csrf=False)
@jwt_required
def list_requests(self, **kw):
"""List approval requests.
Query params:
* ``state`` — filter by request state
* ``workflow_id`` — filter by workflow
* ``mine=1`` — only requests currently awaiting the calling user
(current_stage.approver_id == me). Used by the "My approvals"
queue so approvers see exactly what's on their plate.
* ``requester=1`` — only requests the calling user submitted
"""
try:
M = request.env['encoach.approval.request'].sudo()
domain = []
@@ -241,6 +267,14 @@ class ApprovalWorkflowController(http.Controller):
domain.append(('state', '=', kw['state']))
if kw.get('workflow_id'):
domain.append(('workflow_id', '=', int(kw['workflow_id'])))
if kw.get('mine') in ('1', 'true', True):
uid = request.env.user.id
domain += [
('current_stage_id.approver_id', '=', uid),
('state', 'in', ('draft', 'in_progress')),
]
if kw.get('requester') in ('1', 'true', True):
domain.append(('requester_id', '=', request.env.user.id))
recs = M.search(domain, order='created_at desc, id desc')
items = [_ser_request(r) for r in recs]
return _json_response({
@@ -296,6 +330,16 @@ class ApprovalWorkflowController(http.Controller):
})
else:
req_rec.write({'state': 'approved'})
# Last stage passed — publish the underlying record.
# Today this is always an exam; guard defensively so
# other res_models don't raise.
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
try:
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
if target.exists() and target.status in ('draft', 'pending_review', 'pending_approval'):
target.write({'status': 'published'})
except Exception:
_logger.exception('failed to publish exam %s on final approval', req_rec.res_id)
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
@@ -327,6 +371,14 @@ class ApprovalWorkflowController(http.Controller):
'acted_at': datetime.now(),
})
req_rec.write({'state': 'rejected'})
# Mark the underlying exam rejected so the author can see it.
if req_rec.res_model == 'encoach.exam.custom' and req_rec.res_id:
try:
target = request.env['encoach.exam.custom'].sudo().browse(req_rec.res_id)
if target.exists():
target.write({'status': 'rejected'})
except Exception:
_logger.exception('failed to mark exam %s rejected', req_rec.res_id)
return _json_response({'success': True, 'id': req_id,
'state': req_rec.state})
except Exception as e:
@@ -339,18 +391,33 @@ class ApprovalWorkflowController(http.Controller):
methods=['GET'], csrf=False)
@jwt_required
def list_users(self, **kw):
"""Return users eligible to act as approvers.
Explicitly excludes:
* inactive users, ``__system__`` (id=1), the portal template user, and
any ``op.student`` mirrored account (``op_student_id`` set)
* users with ``user_type='student'`` — students must never be
approvers; QA flagged student logins appearing in the picker.
"""
try:
Users = request.env['res.users'].sudo()
domain = [('active', '=', True), ('id', '>', 1)]
domain = [
('active', '=', True),
('id', '>', 1),
('share', '=', False),
'|', ('user_type', '=', False), ('user_type', '!=', 'student'),
('op_student_id', '=', False),
]
search = (kw.get('search') or '').strip()
if search:
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', search)]
recs = Users.search(domain, order='name', limit=100)
recs = Users.search(domain, order='name', limit=200)
items = [{
'id': u.id,
'name': u.name or u.login,
'login': u.login or '',
'email': u.email or '',
'user_type': u.user_type or '',
} for u in recs]
return _json_response({'items': items, 'results': items, 'total': len(items)})
except Exception as e:

View File

@@ -40,6 +40,8 @@ class EncoachExamCustom(models.Model):
status = fields.Selection([
('draft', 'Draft'),
('pending_review', 'Pending Review'),
('pending_approval', 'Pending Approval'),
('rejected', 'Rejected'),
('published', 'Published'),
('archived', 'Archived'),
], default='draft', required=True)

View File

@@ -18,13 +18,25 @@ server {
root /usr/share/nginx/html;
index index.html;
# Default nginx body cap is 1 MB which causes HTTP 413 on:
# * resource uploads (PDF / audio / video)
# * /api/exam/generation/submit (whole exam with generated questions)
# * /api/approval-requests (attachments)
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
client_max_body_size 128m;
client_body_buffer_size 1m;
location /api/ {
proxy_pass http://backend:8069/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
# OpenAI streaming calls + multi-module AI generation can run >3 min.
# Keep read/send timeouts above Odoo's limit_time_real (900s).
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_request_buffering off;
}
location / {

View File

@@ -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&nbsp;1 and Task&nbsp;2 in the student exam: <b>None</b> — no gap; <b>Line</b> — horizontal rule; <b>Space</b> — extra padding; <b>Page&nbsp;Break</b> — Task&nbsp;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}`),
),
});
}
}
}
};

View File

@@ -37,6 +37,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
@@ -81,12 +89,70 @@ interface RubricFormState {
levels: Set<string>;
}
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
name: "",
weight: 0,
const emptyCriterion = (levels: Set<string>, name = "", weight = 0): CriterionEntry => ({
name,
weight,
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
});
/**
* Canonical criterion catalog by skill. QA asked the "Add Criterion" button
* to offer a dropdown of predefined names instead of a blank row.
* Source: official IELTS public band descriptors for Writing & Speaking.
* "Custom…" keeps the old "add empty row and type it yourself" behaviour.
*/
const CRITERION_PRESETS: Record<string, { label: string; items: { name: string; weight: number }[] }[]> = {
writing: [
{
label: "IELTS Writing — Task 1",
items: [
{ name: "Task Achievement", weight: 25 },
{ name: "Coherence and Cohesion", weight: 25 },
{ name: "Lexical Resource", weight: 25 },
{ name: "Grammatical Range and Accuracy", weight: 25 },
],
},
{
label: "IELTS Writing — Task 2",
items: [
{ name: "Task Response", weight: 25 },
{ name: "Coherence and Cohesion", weight: 25 },
{ name: "Lexical Resource", weight: 25 },
{ name: "Grammatical Range and Accuracy", weight: 25 },
],
},
{
label: "Generic academic writing",
items: [
{ name: "Content & Ideas", weight: 25 },
{ name: "Organization", weight: 25 },
{ name: "Language Use", weight: 25 },
{ name: "Mechanics", weight: 25 },
],
},
],
speaking: [
{
label: "IELTS Speaking",
items: [
{ name: "Fluency and Coherence", weight: 25 },
{ name: "Lexical Resource", weight: 25 },
{ name: "Grammatical Range and Accuracy", weight: 25 },
{ name: "Pronunciation", weight: 25 },
],
},
{
label: "Generic oral skills",
items: [
{ name: "Content", weight: 25 },
{ name: "Delivery", weight: 25 },
{ name: "Interaction", weight: 25 },
{ name: "Language", weight: 25 },
],
},
],
};
const emptyForm = (): RubricFormState => ({
name: "",
skill: "writing",
@@ -437,16 +503,41 @@ export default function RubricsPage() {
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
<Sparkles className="h-5 w-5 text-violet-400" />
<p className="text-sm">No criteria yet.</p>
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or add manually.</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Manually
</Button>
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or pick a preset below.</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="mt-1">
<Plus className="h-3 w-3 mr-1" /> Add Manually <ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
<div key={group.label}>
<DropdownMenuLabel className="text-[11px] text-muted-foreground">{group.label}</DropdownMenuLabel>
{group.items.map((item) => (
<DropdownMenuItem
key={`${group.label}-${item.name}`}
onClick={() =>
setForm((f) => ({
...f,
criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)],
}))
}
>
{item.name}
<span className="ml-auto text-[11px] text-muted-foreground">{item.weight}%</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
<DropdownMenuItem
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
Custom (blank row)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="space-y-3">
@@ -507,15 +598,40 @@ export default function RubricsPage() {
</div>
</Collapsible>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Criterion
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="w-full">
<Plus className="h-3 w-3 mr-1" /> Add Criterion <ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
<div key={group.label}>
<DropdownMenuLabel className="text-[11px] text-muted-foreground">{group.label}</DropdownMenuLabel>
{group.items.map((item) => (
<DropdownMenuItem
key={`${group.label}-${item.name}`}
onClick={() =>
setForm((f) => ({
...f,
criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)],
}))
}
>
{item.name}
<span className="ml-auto text-[11px] text-muted-foreground">{item.weight}%</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
<DropdownMenuItem
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
Custom (blank row)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>

View File

@@ -253,7 +253,13 @@ function CourseFormDialog({
/>
</div>
<div className="space-y-2">
<Label>Difficulty</Label>
<Label className="flex items-center gap-1">
Difficulty
<span
className="text-[11px] text-muted-foreground cursor-help"
title="General hardness tag shown to students when browsing courses (Beginner / Intermediate / Advanced). Use this to set expectations."
>(?)</span>
</Label>
<Select
value={form.difficulty_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
@@ -266,9 +272,18 @@ function CourseFormDialog({
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
A plain-language tag (Beginner / Intermediate / Advanced).
</p>
</div>
<div className="space-y-2">
<Label>CEFR Level</Label>
<Label className="flex items-center gap-1">
CEFR Level
<span
className="text-[11px] text-muted-foreground cursor-help"
title="Common European Framework of Reference level (A1-C2). Used by placement logic, adaptive engine and rubric mapping."
>(?)</span>
</Label>
<Select
value={form.cefr_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
@@ -281,6 +296,9 @@ function CourseFormDialog({
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Standard proficiency band (A1-C2); drives placement &amp; rubrics.
</p>
</div>
</div>

View File

@@ -114,7 +114,11 @@ export default function CustomExamCreate() {
total_time_min: v.total_time_min,
pass_threshold: v.pass_threshold,
results_release_mode: v.results_release_mode,
// Backend model field is `randomize_questions`; the controller reads both
// `randomize` and `randomize_questions`, but prefer the real field name
// so future serialisation roundtrips don't silently drop the flag.
randomize: v.randomize,
randomize_questions: v.randomize,
sections: v.sections.map((sec, i) => ({
title: sec.title,
skill: sec.skill,
@@ -124,26 +128,64 @@ export default function CustomExamCreate() {
sequence: i,
question_ids: sec.question_ids,
})),
};
} as CustomExamCreateRequest;
};
// Extract the id the backend actually returns. `POST /api/exam/custom/create`
// returns the serialised exam dict with `id`, not `exam_id`, so the old
// `raw.exam_id` read was always undefined (preventing navigation after publish).
const pickExamId = (res: unknown): number | undefined => {
const raw = res as { id?: number; exam_id?: number; data?: { id?: number; exam_id?: number } };
return raw?.id ?? raw?.exam_id ?? raw?.data?.id ?? raw?.data?.exam_id;
};
const describeError = (err: unknown, fallback: string): string => {
const e = err as { status?: number; message?: string };
if (e?.status === 413) return "Payload too large (HTTP 413). Please reduce the number of questions per section and try again.";
if (e?.status === 504) return "Server took too long to respond (HTTP 504). Please retry in a moment.";
if (e?.status === 401) return "Session expired — please sign in again.";
if (e?.message) return `${fallback}: ${e.message}`;
return fallback;
};
const onSaveDraft = async () => {
// Validate everything before firing the request — previously the Save
// button POSTed with whatever was in the form and silently swallowed the
// 400/500 as a generic toast.
const ok = await form.trigger();
if (!ok) {
toast.error("Please fix the highlighted fields before saving.");
return;
}
try {
const payload = buildPayload();
await createExam.mutateAsync(payload);
toast.success("Draft saved.");
} catch {
toast.error("Could not save.");
} catch (err) {
toast.error(describeError(err, "Could not save draft"));
}
};
const onPublish = async () => {
const ok = await form.trigger();
if (!ok) {
toast.error("Please fix the highlighted fields before publishing.");
return;
}
let examId: number | undefined;
try {
const payload = buildPayload();
const res = await createExam.mutateAsync(payload);
const raw = res as { exam_id?: number; data?: { exam_id?: number } };
const id = raw.exam_id ?? raw.data?.exam_id;
if (form.getValues("save_as_template")) {
examId = pickExamId(res);
} catch (err) {
toast.error(describeError(err, "Publish failed"));
return;
}
// Template save is additive — if it fails, the exam has already been
// created. Don't show a scary "Publish failed" toast for a template issue.
if (form.getValues("save_as_template")) {
try {
const name = form.getValues("template_name")?.trim() || form.getValues("title");
await saveTemplate.mutateAsync({
name,
@@ -151,12 +193,13 @@ export default function CustomExamCreate() {
editable: true,
type: "custom",
});
} catch (err) {
toast.warning(describeError(err, "Exam saved, but template save failed"));
}
toast.success("Exam published.");
if (id != null) navigate(`/admin/exam`);
} catch {
toast.error("Publish failed.");
}
toast.success("Exam published.");
if (examId != null) navigate(`/admin/exam`);
};
return (

View File

@@ -274,7 +274,18 @@ export default function ResourceManager() {
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => resourcesService.upload(formData),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
onError: (err: unknown) => {
// Surface status / server message so users can tell 413 (file too big)
// from 401 (session expired) from 500 (server error) without opening
// devtools. Default "Upload failed" hid real failures during QA.
const e = err as { status?: number; message?: string };
const status = e?.status;
let description = e?.message || "Upload failed";
if (status === 413) description = "File is too large for the server (HTTP 413). Ask an admin to raise nginx/odoo upload limits.";
else if (status === 401) description = "Session expired — please sign in again.";
else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry.";
toast({ title: "Upload failed", description, variant: "destructive" });
},
});
const createTagMutation = useMutation({

View File

@@ -116,7 +116,15 @@ export default function TeacherLibrary() {
setUploadTopicIds([]);
setUploadObjectiveIds([]);
},
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
onError: (err: unknown) => {
const e = err as { status?: number; message?: string };
const status = e?.status;
let description = e?.message || "Upload failed";
if (status === 413) description = "File is too large for the server (HTTP 413). Try a smaller file or ask an admin to raise the upload limit.";
else if (status === 401) description = "Session expired — please sign in again.";
else if (status === 504) description = "Server timed out while saving the file (HTTP 504). Try a smaller file or retry.";
toast({ title: "Upload failed", description, variant: "destructive" });
},
});
const deleteMutation = useMutation({

View File

@@ -18,6 +18,8 @@ export interface CustomExamCreateRequest {
pass_threshold?: number;
results_release_mode: "auto" | "manual_approval";
randomize: boolean;
// Backend model field; also accepted by the /api/exam/custom/create controller.
randomize_questions?: boolean;
sections: CustomSection[];
}

View File

@@ -12,11 +12,15 @@ http_port = 8069
proxy_mode = True
workers = 4
max_cron_threads = 2
limit_time_cpu = 180
limit_time_real = 360
limit_request = 65535
limit_memory_soft = 671088640
limit_memory_hard = 805306368
; QA hit repeated HTTP 504 on /api/exam/generation/submit when OpenAI took
; >3 minutes per module. Raised CPU/wall caps accordingly.
limit_time_cpu = 600
limit_time_real = 900
; Default 4 MB body ceiling caused HTTP 413 on resource uploads (PDFs / audio)
; and on module-submit payloads carrying all generated questions. Bump to 128 MB.
limit_request = 134217728
limit_memory_soft = 2147483648
limit_memory_hard = 2684354560
log_level = info
logfile = None
; See P0.7 in docs/PROJECT_SUMMARY.md §21 — the container mounts the project

View File

@@ -11,8 +11,16 @@ addons_path = /Users/yamenahmad/projects2026/odoo/odoo19/backend/custom_addons,/
admin_passwd = $pbkdf2-sha512$600000$W4ux9h5DyDmnFIIQ4hxDaA$bF8qJJWZLTs2IC8T74YWv1my44u4vsqvLXUfexx2I1kGvPXMwHJiZOMhaYxmC3GAuIxQI1/8HPvdQhqB8OoVMQ
workers = 0
max_cron_threads = 1
limit_time_cpu = 120
limit_time_real = 300
; AI generation + module-submit can take >2 minutes on slow upstream calls.
; Bumped to 10 min CPU / 15 min wall-clock so we stop hitting 504 on /api/exam/generation/submit.
limit_time_cpu = 600
limit_time_real = 900
; Raise the multipart body ceiling so PDF/image resources and
; "submit exam with all generated questions" (≈2-5 MB JSON) no longer fail
; with HTTP 413. Default is 4 MB; 128 MB covers videos + bulk question JSON.
limit_request = 134217728
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
db_maxconn = 32
data_dir = /tmp/odoo-data