diff --git a/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py index fdfeca8b..316c8fb0 100644 --- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/ai_controller.py @@ -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"], diff --git a/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py b/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py index 22237ef7..68f9aa32 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py +++ b/backend/custom_addons/encoach_exam_template/controllers/approval_workflows.py @@ -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: diff --git a/backend/custom_addons/encoach_exam_template/models/exam_custom.py b/backend/custom_addons/encoach_exam_template/models/exam_custom.py index a56c74b2..575142a3 100644 --- a/backend/custom_addons/encoach_exam_template/models/exam_custom.py +++ b/backend/custom_addons/encoach_exam_template/models/exam_custom.py @@ -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) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 5545dd49..cae190b9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 / { diff --git a/frontend/src/pages/GenerationPage.tsx b/frontend/src/pages/GenerationPage.tsx index 1e8526e9..28a260e2 100644 --- a/frontend/src/pages/GenerationPage.tsx +++ b/frontend/src/pages/GenerationPage.tsx @@ -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 = {}; 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 (
@@ -781,30 +829,44 @@ export default function GenerationPage() {
{examMode === "official" && (
- - + + {rubricApplies ? ( + + ) : ( +
+ Auto-graded — no rubric +
+ )}
)}
@@ -1202,6 +1264,12 @@ export default function GenerationPage() { Page Break +

+ Controls how this section is visually separated from the next in the student view: None — no separator; Line — horizontal rule; Space — extra vertical gap; Page Break — section starts on a new page (paginated delivery only). +

+

+ Write instructions in the box above the audio (or the Generate Instructions card). Use the imperative voice, e.g. “Listen to the conversation and answer the questions below.” +

@@ -1407,7 +1475,7 @@ export default function GenerationPage() {
- {st.writingTasks.length > 1 && ( + {st.writingTasks.length > Math.max(1, requiredWritingTaskCount) && ( )}
+ {requiredWritingTaskCount > 0 && st.writingTasks.length < requiredWritingTaskCount && ( +

+ This structure requires {requiredWritingTaskCount} tasks. Add the missing task to submit. +

+ )}
+

Click Generate with AI to create criteria automatically, or pick a preset below.

+ + + + + + {(CRITERION_PRESETS[form.skill] ?? []).map((group) => ( +
+ {group.label} + {group.items.map((item) => ( + + setForm((f) => ({ + ...f, + criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)], + })) + } + > + {item.name} + {item.weight}% + + ))} + +
+ ))} + setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))} + > + Custom (blank row) + +
+
) : (
@@ -507,15 +598,40 @@ export default function RubricsPage() {
))} - + + + + + + {(CRITERION_PRESETS[form.skill] ?? []).map((group) => ( +
+ {group.label} + {group.items.map((item) => ( + + setForm((f) => ({ + ...f, + criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)], + })) + } + > + {item.name} + {item.weight}% + + ))} + +
+ ))} + setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))} + > + Custom (blank row) + +
+
)} diff --git a/frontend/src/pages/admin/AdminCourses.tsx b/frontend/src/pages/admin/AdminCourses.tsx index c6e1947c..66d83ba4 100644 --- a/frontend/src/pages/admin/AdminCourses.tsx +++ b/frontend/src/pages/admin/AdminCourses.tsx @@ -253,7 +253,13 @@ function CourseFormDialog({ />
- + +

+ A plain-language tag (Beginner / Intermediate / Advanced). +

- + +

+ Standard proficiency band (A1-C2); drives placement & rubrics. +

diff --git a/frontend/src/pages/admin/CustomExamCreate.tsx b/frontend/src/pages/admin/CustomExamCreate.tsx index 811c244a..7828d42d 100644 --- a/frontend/src/pages/admin/CustomExamCreate.tsx +++ b/frontend/src/pages/admin/CustomExamCreate.tsx @@ -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 ( diff --git a/frontend/src/pages/admin/ResourceManager.tsx b/frontend/src/pages/admin/ResourceManager.tsx index b14bf3ed..2e3d4048 100644 --- a/frontend/src/pages/admin/ResourceManager.tsx +++ b/frontend/src/pages/admin/ResourceManager.tsx @@ -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({ diff --git a/frontend/src/pages/teacher/TeacherLibrary.tsx b/frontend/src/pages/teacher/TeacherLibrary.tsx index d3385a3e..78303ff1 100644 --- a/frontend/src/pages/teacher/TeacherLibrary.tsx +++ b/frontend/src/pages/teacher/TeacherLibrary.tsx @@ -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({ diff --git a/frontend/src/types/custom-exam.ts b/frontend/src/types/custom-exam.ts index 57e9f49a..2f7219b9 100644 --- a/frontend/src/types/custom-exam.ts +++ b/frontend/src/types/custom-exam.ts @@ -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[]; } diff --git a/odoo-docker.conf b/odoo-docker.conf index ff2be59f..6c11872f 100644 --- a/odoo-docker.conf +++ b/odoo-docker.conf @@ -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 diff --git a/odoo.conf b/odoo.conf index 497aff16..4e61b7e0 100644 --- a/odoo.conf +++ b/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