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:
@@ -1372,7 +1372,7 @@ class AIController(http.Controller):
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return _json_response({"error": "encoach.exam.custom model not available"}, 500)
|
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 = {
|
exam_vals = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"label": label,
|
"label": label,
|
||||||
@@ -1558,11 +1558,46 @@ class AIController(http.Controller):
|
|||||||
quality_summary["failed"], quality_summary["warned"],
|
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({
|
return _json_response({
|
||||||
"exam_id": exam.id,
|
"exam_id": exam.id,
|
||||||
"status": exam.status,
|
"status": exam.status,
|
||||||
"template_id": template_id,
|
"template_id": template_id,
|
||||||
"total_questions": total_questions,
|
"total_questions": total_questions,
|
||||||
|
"approval_request_id": approval_request_id,
|
||||||
"quality": quality_summary,
|
"quality": quality_summary,
|
||||||
"schema_validation": {
|
"schema_validation": {
|
||||||
"verdict": schema_report["verdict"],
|
"verdict": schema_report["verdict"],
|
||||||
|
|||||||
@@ -57,17 +57,33 @@ def _ser_workflow(wf):
|
|||||||
|
|
||||||
def _ser_request(req):
|
def _ser_request(req):
|
||||||
stage = req.current_stage_id
|
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 {
|
return {
|
||||||
'id': req.id,
|
'id': req.id,
|
||||||
'workflow_id': req.workflow_id.id or None,
|
'workflow_id': req.workflow_id.id or None,
|
||||||
'workflow_name': req.workflow_id.name if req.workflow_id else '',
|
'workflow_name': req.workflow_id.name if req.workflow_id else '',
|
||||||
'res_model': req.res_model or '',
|
'res_model': req.res_model or '',
|
||||||
'res_id': req.res_id or 0,
|
'res_id': req.res_id or 0,
|
||||||
|
'target_name': target_name,
|
||||||
|
'target_status': target_status,
|
||||||
'state': req.state or 'draft',
|
'state': req.state or 'draft',
|
||||||
'requester_id': req.requester_id.id or None,
|
'requester_id': req.requester_id.id or None,
|
||||||
'requester_name': req.requester_id.name if req.requester_id else '',
|
'requester_name': req.requester_id.name if req.requester_id else '',
|
||||||
'current_stage_id': stage.id or None,
|
'current_stage_id': stage.id or None,
|
||||||
'current_stage_sequence': stage.sequence if stage else 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 '',
|
'bypass_reason': req.bypass_reason or '',
|
||||||
'created_at': req.created_at.isoformat() if req.created_at else None,
|
'created_at': req.created_at.isoformat() if req.created_at else None,
|
||||||
}
|
}
|
||||||
@@ -234,6 +250,16 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
methods=['GET'], csrf=False)
|
methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def list_requests(self, **kw):
|
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:
|
try:
|
||||||
M = request.env['encoach.approval.request'].sudo()
|
M = request.env['encoach.approval.request'].sudo()
|
||||||
domain = []
|
domain = []
|
||||||
@@ -241,6 +267,14 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
domain.append(('state', '=', kw['state']))
|
domain.append(('state', '=', kw['state']))
|
||||||
if kw.get('workflow_id'):
|
if kw.get('workflow_id'):
|
||||||
domain.append(('workflow_id', '=', int(kw['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')
|
recs = M.search(domain, order='created_at desc, id desc')
|
||||||
items = [_ser_request(r) for r in recs]
|
items = [_ser_request(r) for r in recs]
|
||||||
return _json_response({
|
return _json_response({
|
||||||
@@ -296,6 +330,16 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
req_rec.write({'state': 'approved'})
|
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,
|
return _json_response({'success': True, 'id': req_id,
|
||||||
'state': req_rec.state})
|
'state': req_rec.state})
|
||||||
@@ -327,6 +371,14 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
'acted_at': datetime.now(),
|
'acted_at': datetime.now(),
|
||||||
})
|
})
|
||||||
req_rec.write({'state': 'rejected'})
|
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,
|
return _json_response({'success': True, 'id': req_id,
|
||||||
'state': req_rec.state})
|
'state': req_rec.state})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -339,18 +391,33 @@ class ApprovalWorkflowController(http.Controller):
|
|||||||
methods=['GET'], csrf=False)
|
methods=['GET'], csrf=False)
|
||||||
@jwt_required
|
@jwt_required
|
||||||
def list_users(self, **kw):
|
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:
|
try:
|
||||||
Users = request.env['res.users'].sudo()
|
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()
|
search = (kw.get('search') or '').strip()
|
||||||
if search:
|
if search:
|
||||||
domain += ['|', ('name', 'ilike', search), ('login', 'ilike', 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 = [{
|
items = [{
|
||||||
'id': u.id,
|
'id': u.id,
|
||||||
'name': u.name or u.login,
|
'name': u.name or u.login,
|
||||||
'login': u.login or '',
|
'login': u.login or '',
|
||||||
'email': u.email or '',
|
'email': u.email or '',
|
||||||
|
'user_type': u.user_type or '',
|
||||||
} for u in recs]
|
} for u in recs]
|
||||||
return _json_response({'items': items, 'results': items, 'total': len(items)})
|
return _json_response({'items': items, 'results': items, 'total': len(items)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class EncoachExamCustom(models.Model):
|
|||||||
status = fields.Selection([
|
status = fields.Selection([
|
||||||
('draft', 'Draft'),
|
('draft', 'Draft'),
|
||||||
('pending_review', 'Pending Review'),
|
('pending_review', 'Pending Review'),
|
||||||
|
('pending_approval', 'Pending Approval'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
('published', 'Published'),
|
('published', 'Published'),
|
||||||
('archived', 'Archived'),
|
('archived', 'Archived'),
|
||||||
], default='draft', required=True)
|
], default='draft', required=True)
|
||||||
|
|||||||
@@ -18,13 +18,25 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8069/api/;
|
proxy_pass http://backend:8069/api/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
|
|||||||
@@ -709,8 +709,51 @@ export default function GenerationPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [activeModule, moduleStates]);
|
}, [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({
|
const submitMut = useMutation({
|
||||||
mutationFn: (skipApproval: boolean) => {
|
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> = {};
|
const modulesPayload: Record<string, unknown> = {};
|
||||||
for (const mod of selectedModules) {
|
for (const mod of selectedModules) {
|
||||||
const st = getModuleState(mod);
|
const st = getModuleState(mod);
|
||||||
@@ -773,6 +816,11 @@ export default function GenerationPage() {
|
|||||||
|
|
||||||
const renderCommonConfig = (mod: ModuleKey) => {
|
const renderCommonConfig = (mod: ModuleKey) => {
|
||||||
const st = getModuleState(mod);
|
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 (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -781,30 +829,44 @@ export default function GenerationPage() {
|
|||||||
</div>
|
</div>
|
||||||
{examMode === "official" && (
|
{examMode === "official" && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs">Rubric</Label>
|
<Label className="text-xs flex items-center gap-1">
|
||||||
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
Rubric
|
||||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
{!rubricApplies && (
|
||||||
<SelectContent>
|
<span className="text-[10px] font-normal text-muted-foreground">(Not applicable)</span>
|
||||||
{rubrics.length > 0 && (
|
)}
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
</Label>
|
||||||
)}
|
{rubricApplies ? (
|
||||||
{rubrics.map((r) => (
|
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
|
||||||
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
|
||||||
))}
|
<SelectContent>
|
||||||
{rubricGroups.length > 0 && (
|
{rubrics.length > 0 && (
|
||||||
<>
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
|
||||||
<div className="border-t my-1" />
|
)}
|
||||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
{rubrics.map((r) => (
|
||||||
</>
|
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
|
||||||
)}
|
))}
|
||||||
{rubricGroups.map((g) => (
|
{rubricGroups.length > 0 && (
|
||||||
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
|
<>
|
||||||
))}
|
<div className="border-t my-1" />
|
||||||
{rubrics.length === 0 && rubricGroups.length === 0 && (
|
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
|
||||||
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
|
</>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
{rubricGroups.map((g) => (
|
||||||
</Select>
|
<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>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -1202,6 +1264,12 @@ export default function GenerationPage() {
|
|||||||
<SelectItem value="page_break">Page Break</SelectItem>
|
<SelectItem value="page_break">Page Break</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<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">
|
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
|
||||||
<Checkbox checked id={`wt-${i}`} />
|
<Checkbox checked id={`wt-${i}`} />
|
||||||
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
|
||||||
{st.writingTasks.length > 1 && (
|
{st.writingTasks.length > Math.max(1, requiredWritingTaskCount) && (
|
||||||
<button
|
<button
|
||||||
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1428,6 +1496,11 @@ export default function GenerationPage() {
|
|||||||
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
|
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, writing: defaultModuleState("writing") }))} className="text-xs">
|
<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>
|
<SelectItem value="page_break">Page Break</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<Collapsible>
|
<Collapsible>
|
||||||
@@ -2196,6 +2275,54 @@ export default function GenerationPage() {
|
|||||||
setSelectedModules(next);
|
setSelectedModules(next);
|
||||||
if (next.size > 0) setActiveModule([...next][0]);
|
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,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} 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 { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||||
@@ -81,12 +89,70 @@ interface RubricFormState {
|
|||||||
levels: Set<string>;
|
levels: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
|
const emptyCriterion = (levels: Set<string>, name = "", weight = 0): CriterionEntry => ({
|
||||||
name: "",
|
name,
|
||||||
weight: 0,
|
weight,
|
||||||
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
|
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 => ({
|
const emptyForm = (): RubricFormState => ({
|
||||||
name: "",
|
name: "",
|
||||||
skill: "writing",
|
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">
|
<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" />
|
<Sparkles className="h-5 w-5 text-violet-400" />
|
||||||
<p className="text-sm">No criteria yet.</p>
|
<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>
|
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or pick a preset below.</p>
|
||||||
<Button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger asChild>
|
||||||
variant="outline"
|
<Button type="button" variant="outline" size="sm" className="mt-1">
|
||||||
size="sm"
|
<Plus className="h-3 w-3 mr-1" /> Add Manually <ChevronDown className="h-3 w-3 ml-1" />
|
||||||
className="mt-1"
|
</Button>
|
||||||
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
</DropdownMenuTrigger>
|
||||||
>
|
<DropdownMenuContent align="start" className="w-72">
|
||||||
<Plus className="h-3 w-3 mr-1" /> Add Manually
|
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
|
||||||
</Button>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -507,15 +598,40 @@ export default function RubricsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
))}
|
))}
|
||||||
<Button
|
<DropdownMenu>
|
||||||
type="button"
|
<DropdownMenuTrigger asChild>
|
||||||
variant="outline"
|
<Button type="button" variant="outline" size="sm" className="w-full">
|
||||||
size="sm"
|
<Plus className="h-3 w-3 mr-1" /> Add Criterion <ChevronDown className="h-3 w-3 ml-1" />
|
||||||
className="w-full"
|
</Button>
|
||||||
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
|
</DropdownMenuTrigger>
|
||||||
>
|
<DropdownMenuContent align="start" className="w-72">
|
||||||
<Plus className="h-3 w-3 mr-1" /> Add Criterion
|
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
|
||||||
</Button>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -253,7 +253,13 @@ function CourseFormDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Select
|
||||||
value={form.difficulty_level || "none"}
|
value={form.difficulty_level || "none"}
|
||||||
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
|
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
|
||||||
@@ -266,9 +272,18 @@ function CourseFormDialog({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
A plain-language tag (Beginner / Intermediate / Advanced).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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
|
<Select
|
||||||
value={form.cefr_level || "none"}
|
value={form.cefr_level || "none"}
|
||||||
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
|
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
|
||||||
@@ -281,6 +296,9 @@ function CourseFormDialog({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Standard proficiency band (A1-C2); drives placement & rubrics.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -114,7 +114,11 @@ export default function CustomExamCreate() {
|
|||||||
total_time_min: v.total_time_min,
|
total_time_min: v.total_time_min,
|
||||||
pass_threshold: v.pass_threshold,
|
pass_threshold: v.pass_threshold,
|
||||||
results_release_mode: v.results_release_mode,
|
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: v.randomize,
|
||||||
|
randomize_questions: v.randomize,
|
||||||
sections: v.sections.map((sec, i) => ({
|
sections: v.sections.map((sec, i) => ({
|
||||||
title: sec.title,
|
title: sec.title,
|
||||||
skill: sec.skill,
|
skill: sec.skill,
|
||||||
@@ -124,26 +128,64 @@ export default function CustomExamCreate() {
|
|||||||
sequence: i,
|
sequence: i,
|
||||||
question_ids: sec.question_ids,
|
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 () => {
|
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 {
|
try {
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
await createExam.mutateAsync(payload);
|
await createExam.mutateAsync(payload);
|
||||||
toast.success("Draft saved.");
|
toast.success("Draft saved.");
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("Could not save.");
|
toast.error(describeError(err, "Could not save draft"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPublish = async () => {
|
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 {
|
try {
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
const res = await createExam.mutateAsync(payload);
|
const res = await createExam.mutateAsync(payload);
|
||||||
const raw = res as { exam_id?: number; data?: { exam_id?: number } };
|
examId = pickExamId(res);
|
||||||
const id = raw.exam_id ?? raw.data?.exam_id;
|
} catch (err) {
|
||||||
if (form.getValues("save_as_template")) {
|
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");
|
const name = form.getValues("template_name")?.trim() || form.getValues("title");
|
||||||
await saveTemplate.mutateAsync({
|
await saveTemplate.mutateAsync({
|
||||||
name,
|
name,
|
||||||
@@ -151,12 +193,13 @@ export default function CustomExamCreate() {
|
|||||||
editable: true,
|
editable: true,
|
||||||
type: "custom",
|
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 (
|
return (
|
||||||
|
|||||||
@@ -274,7 +274,18 @@ export default function ResourceManager() {
|
|||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
mutationFn: (formData: FormData) => resourcesService.upload(formData),
|
||||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
|
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({
|
const createTagMutation = useMutation({
|
||||||
|
|||||||
@@ -116,7 +116,15 @@ export default function TeacherLibrary() {
|
|||||||
setUploadTopicIds([]);
|
setUploadTopicIds([]);
|
||||||
setUploadObjectiveIds([]);
|
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({
|
const deleteMutation = useMutation({
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface CustomExamCreateRequest {
|
|||||||
pass_threshold?: number;
|
pass_threshold?: number;
|
||||||
results_release_mode: "auto" | "manual_approval";
|
results_release_mode: "auto" | "manual_approval";
|
||||||
randomize: boolean;
|
randomize: boolean;
|
||||||
|
// Backend model field; also accepted by the /api/exam/custom/create controller.
|
||||||
|
randomize_questions?: boolean;
|
||||||
sections: CustomSection[];
|
sections: CustomSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ http_port = 8069
|
|||||||
proxy_mode = True
|
proxy_mode = True
|
||||||
workers = 4
|
workers = 4
|
||||||
max_cron_threads = 2
|
max_cron_threads = 2
|
||||||
limit_time_cpu = 180
|
; QA hit repeated HTTP 504 on /api/exam/generation/submit when OpenAI took
|
||||||
limit_time_real = 360
|
; >3 minutes per module. Raised CPU/wall caps accordingly.
|
||||||
limit_request = 65535
|
limit_time_cpu = 600
|
||||||
limit_memory_soft = 671088640
|
limit_time_real = 900
|
||||||
limit_memory_hard = 805306368
|
; 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
|
log_level = info
|
||||||
logfile = None
|
logfile = None
|
||||||
; See P0.7 in docs/PROJECT_SUMMARY.md §21 — the container mounts the project
|
; 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
|
admin_passwd = $pbkdf2-sha512$600000$W4ux9h5DyDmnFIIQ4hxDaA$bF8qJJWZLTs2IC8T74YWv1my44u4vsqvLXUfexx2I1kGvPXMwHJiZOMhaYxmC3GAuIxQI1/8HPvdQhqB8OoVMQ
|
||||||
workers = 0
|
workers = 0
|
||||||
max_cron_threads = 1
|
max_cron_threads = 1
|
||||||
limit_time_cpu = 120
|
; AI generation + module-submit can take >2 minutes on slow upstream calls.
|
||||||
limit_time_real = 300
|
; 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
|
db_maxconn = 32
|
||||||
data_dir = /tmp/odoo-data
|
data_dir = /tmp/odoo-data
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user