From d34180e107483afc39abe864458ab6d73d250082 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Mon, 20 Apr 2026 18:29:16 +0400 Subject: [PATCH] =?UTF-8?q?fix(assignments):=20resolve=20op.student=20?= =?UTF-8?q?=E2=86=92=20res.users=20+=20student=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the frontend's student picker sends `op.student.id` values (that's what /api/students returns), but `encoach.exam.schedule.student_ids` is a Many2many → res.users. The controller was writing the op.student id straight through, so schedules linked to whichever res.users row happened to share that integer id (OdooBot, other staff, etc.) — the target student got 0 assignments and never saw the exam. - exam_schedules.py: add `_resolve_student_user_ids` and use it in the create and update endpoints. Falls back to treating unmatched ids as direct res.users ids for back-office callers. - AssignmentsPage.tsx: add a search input + larger list to the Individual Students picker; long lists were leaving Sarah unreachable inside the inner scroll container during QA. Made-with: Cursor --- .../controllers/exam_schedules.py | 60 ++++++++++++++++++- frontend/src/pages/AssignmentsPage.tsx | 34 ++++++++++- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py b/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py index 42be7d20..9a252b3e 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py +++ b/backend/custom_addons/encoach_exam_template/controllers/exam_schedules.py @@ -142,9 +142,23 @@ class EncoachExamScheduleController(http.Controller): if batch_ids: vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])] + # IMPORTANT: the frontend's student picker consumes /api/students + # which returns `op.student.id` values. `schedule.student_ids` is a + # Many2many → res.users, so we must resolve op.student → user_id + # before writing. Skipping this step silently linked schedules to + # whatever `res.users` row happened to share the same integer id + # (e.g. OdooBot), producing "0 assignees" and no visible exam for + # the student (bug surfaced during the Apr-2026 QA E2E run). student_ids = body.get('student_ids', []) if student_ids: - vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])] + resolved_user_ids = self._resolve_student_user_ids(student_ids) + if resolved_user_ids: + vals['student_ids'] = [(6, 0, resolved_user_ids)] + else: + _logger.warning( + 'exam-schedule create: student_ids=%s resolved to 0 users', + student_ids, + ) rec = request.env['encoach.exam.schedule'].sudo().create(vals) @@ -155,6 +169,46 @@ class EncoachExamScheduleController(http.Controller): _logger.exception('exam-schedule create failed') return _json_response({'error': str(e)}, 500) + def _resolve_student_user_ids(self, raw_ids): + """Accept a list of ``op.student`` ids from the UI and return the + matching ``res.users`` ids — dropping entries that don't link to a + portal user (e.g. a student record created without an Odoo user). + + Falls back to treating any id that doesn't match an op.student row as + a raw res.users id, so direct back-office callers posting user ids + keep working. + """ + try: + ints = [int(s) for s in raw_ids if s is not None and str(s).strip() != ''] + except (TypeError, ValueError): + _logger.warning('exam-schedule: non-integer student_ids payload %r', raw_ids) + return [] + if not ints: + return [] + + Student = request.env['op.student'].sudo() + Users = request.env['res.users'].sudo() + student_recs = Student.search([('id', 'in', ints)]) + resolved = set() + matched_student_ids = set() + for s in student_recs: + matched_student_ids.add(s.id) + user = s.user_id if hasattr(s, 'user_id') else None + if user and user.id: + resolved.add(user.id) + # Any id we couldn't match as an op.student: treat as a direct + # res.users id but verify the user exists and is not a system user. + leftover = [i for i in ints if i not in matched_student_ids] + if leftover: + fallback_users = Users.search([ + ('id', 'in', leftover), + ('share', '=', False), + ('active', '=', True), + ]) + for u in fallback_users: + resolved.add(u.id) + return sorted(resolved) + def _create_individual_assignments(self, schedule): """Create individual assignment records for each targeted student.""" Assignment = request.env['encoach.exam.assignment'].sudo() @@ -233,7 +287,9 @@ class EncoachExamScheduleController(http.Controller): if 'batch_ids' in body: vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])] if 'student_ids' in body: - vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])] + # Resolve op.student.id → res.users.id (same reason as the + # create endpoint — see _resolve_student_user_ids docstring). + vals['student_ids'] = [(6, 0, self._resolve_student_user_ids(body['student_ids']))] if 'state' in body: vals['state'] = body['state'] if vals: diff --git a/frontend/src/pages/AssignmentsPage.tsx b/frontend/src/pages/AssignmentsPage.tsx index 4f6db8e0..ca06cb8c 100644 --- a/frontend/src/pages/AssignmentsPage.tsx +++ b/frontend/src/pages/AssignmentsPage.tsx @@ -114,6 +114,7 @@ export default function AssignmentsPage() { const [stateFilter, setStateFilter] = useState("all"); const [createOpen, setCreateOpen] = useState(false); const [form, setForm] = useState(emptyForm()); + const [studentQuery, setStudentQuery] = useState(""); const { toast } = useToast(); const qc = useQueryClient(); @@ -144,6 +145,14 @@ export default function AssignmentsPage() { const studentsQ = useStudents({ size: 200 }); const students = studentsQ.data?.items ?? []; + const filteredStudents = useMemo(() => { + const q = studentQuery.trim().toLowerCase(); + if (!q) return students; + return students.filter((s) => + (s.name || "").toLowerCase().includes(q) || + (s.email || "").toLowerCase().includes(q), + ); + }, [students, studentQuery]); const stateCounts = useMemo(() => { const all = schedulesQ.data?.items ?? []; @@ -468,9 +477,23 @@ export default function AssignmentsPage() { {/* Individual student selection */} {form.assign_mode === "individual" && (
- -
- {students.map((s) => ( +
+ + + {form.student_ids.size} selected · {students.length} total + +
+ {/* Search box — needed because QA reported long student lists + made bottom entries unreachable inside the inner scroll + container. Filtering keeps the list short and predictable. */} + setStudentQuery(e.target.value)} + className="h-8 text-xs" + /> +
+ {filteredStudents.map((s) => ( ))} + {filteredStudents.length === 0 && students.length > 0 && ( +

+ No students match “{studentQuery}”. +

+ )} {students.length === 0 &&

No students available

}