v4 #1
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
@@ -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}`),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & rubrics.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
12
odoo.conf
12
odoo.conf
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user