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:
@@ -142,9 +142,23 @@ class EncoachExamScheduleController(http.Controller):
|
|||||||
if batch_ids:
|
if batch_ids:
|
||||||
vals['batch_ids'] = [(6, 0, [int(b) for b in 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', [])
|
student_ids = body.get('student_ids', [])
|
||||||
if 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)
|
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
|
||||||
|
|
||||||
@@ -155,6 +169,46 @@ class EncoachExamScheduleController(http.Controller):
|
|||||||
_logger.exception('exam-schedule create failed')
|
_logger.exception('exam-schedule create failed')
|
||||||
return _json_response({'error': str(e)}, 500)
|
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):
|
def _create_individual_assignments(self, schedule):
|
||||||
"""Create individual assignment records for each targeted student."""
|
"""Create individual assignment records for each targeted student."""
|
||||||
Assignment = request.env['encoach.exam.assignment'].sudo()
|
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||||
@@ -233,7 +287,9 @@ class EncoachExamScheduleController(http.Controller):
|
|||||||
if 'batch_ids' in body:
|
if 'batch_ids' in body:
|
||||||
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
|
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
|
||||||
if 'student_ids' in body:
|
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:
|
if 'state' in body:
|
||||||
vals['state'] = body['state']
|
vals['state'] = body['state']
|
||||||
if vals:
|
if vals:
|
||||||
|
|||||||
Reference in New Issue
Block a user