fix(assignments): resolve op.student → res.users + student search

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
This commit is contained in:
Yamen Ahmad
2026-04-20 18:29:16 +04:00
parent eef3edf7e8
commit d34180e107
2 changed files with 89 additions and 5 deletions

View File

@@ -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:

View File

@@ -114,6 +114,7 @@ export default function AssignmentsPage() {
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<FormState>(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" && (
<div className="space-y-1">
<Label className="text-xs">Select Students</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{students.map((s) => (
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Select Students</Label>
<span className="text-[11px] text-muted-foreground">
{form.student_ids.size} selected · {students.length} total
</span>
</div>
{/* 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. */}
<Input
placeholder="Search students by name or email…"
value={studentQuery}
onChange={(e) => setStudentQuery(e.target.value)}
className="h-8 text-xs"
/>
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
{filteredStudents.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.student_ids.has(s.id)}
@@ -487,6 +510,11 @@ export default function AssignmentsPage() {
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
</label>
))}
{filteredStudents.length === 0 && students.length > 0 && (
<p className="text-xs text-muted-foreground italic text-center py-2">
No students match {studentQuery}.
</p>
)}
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
</div>
</div>