import base64 import werkzeug from odoo import http, _ from odoo.http import request from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager from odoo.exceptions import AccessError, MissingError class StudentLMSController(CustomerPortal): def _get_student(self): """Helper to return the mapped student for the authenticated portal user.""" return request.env.user.student_id def _get_student_domain(self): """ Returns the LMS course eligibility domain matching Phase H security semantics exactly. A student sees published/closed courses matching their program and academic year, and optionally matching term and batch (or empty). """ student = self._get_student() if not student: return [('id', '=', 0)] # No access domain = [ ('state', 'in', ['published', 'closed']), ('program_id', '=', student.program_id.id), ('academic_year_id', '=', student.academic_year_id.id), '|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id), '|', ('batch_id', '=', False), ('batch_id', '=', student.batch_id.id) ] return domain @http.route(['/my/lms', '/my/lms/page/'], type='http', auth="user", website=True) def my_lms_courses(self, page=1, **kw): student = self._get_student() if not student: return request.render("education_portal.portal_no_student") domain = self._get_student_domain() Course = request.env['education.lms.course'].sudo() course_count = Course.search_count(domain) pager = portal_pager( url="/my/lms", total=course_count, page=page, step=12 ) courses = Course.search(domain, limit=12, offset=pager['offset'], order='id desc') values = { 'student': student, 'courses': courses, 'page_name': 'lms_courses', 'pager': pager, 'default_url': '/my/lms', } return request.render("education_lms.portal_my_lms_course_list", values) def _check_course_access(self, course_id): """ Server-side validation to prevent IDOR. Returns the course if the student is eligible to access it, else raises AccessError. """ student = self._get_student() if not student: raise AccessError(_("Student mapping not found.")) domain = self._get_student_domain() + [('id', '=', int(course_id))] course = request.env['education.lms.course'].sudo().search(domain, limit=1) if not course: raise AccessError(_("You do not have access to this course or it does not exist.")) return course @http.route(['/my/lms/course/'], type='http', auth="user", website=True) def my_lms_course_detail(self, course_id, **kw): try: course = self._check_course_access(course_id) except AccessError: return request.redirect('/my/lms') student = self._get_student() # Only fetch published sections and lessons # Using sudo is safe here since we manually filtered by course access above, # but we also explicitly filter sections to only this course. sections = request.env['education.lms.section'].sudo().search([ ('course_id', '=', course.id), ('published', '=', True) ], order='sequence, id') values = { 'student': student, 'course': course, 'sections': sections, 'page_name': 'lms_course_detail', } return request.render("education_lms.portal_my_lms_course_detail", values) @http.route(['/my/lms/course//lesson/'], type='http', auth="user", website=True) def my_lms_lesson_detail(self, course_id, lesson_id, **kw): try: course = self._check_course_access(course_id) except AccessError: return request.redirect('/my/lms') student = self._get_student() lesson = request.env['education.lms.lesson'].sudo().search([ ('id', '=', lesson_id), ('course_id', '=', course.id), ('published', '=', True), ('section_id.published', '=', True) ], limit=1) if not lesson: return request.redirect(f'/my/lms/course/{course.id}') # For navigation: Next/Prev Lesson logic all_lessons = request.env['education.lms.lesson'].sudo().search([ ('course_id', '=', course.id), ('published', '=', True), ('section_id.published', '=', True) ], order='sequence, id') lesson_ids = all_lessons.ids current_idx = lesson_ids.index(lesson.id) prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None next_lesson = all_lessons[current_idx + 1] if current_idx < len(lesson_ids) - 1 else None values = { 'student': student, 'course': course, 'lesson': lesson, 'prev_lesson': prev_lesson, 'next_lesson': next_lesson, 'page_name': 'lms_lesson_detail', } return request.render("education_lms.portal_my_lms_lesson_detail", values) @http.route(['/my/lms/material//download'], type='http', auth="user", website=True) def download_material(self, material_id, **kw): """ Secure attachment download for LMS materials. Validates the entire hierarchy down to course eligibility before returning binary. """ student = self._get_student() if not student: return request.render("education_portal.portal_no_student") material = request.env['education.lms.material'].sudo().search([ ('id', '=', material_id), ('published', '=', True), ('lesson_id.published', '=', True), ('lesson_id.section_id.published', '=', True) ], limit=1) if not material or material.material_type != 'file' or not material.attachment_id: raise werkzeug.exceptions.NotFound() # Re-check course access try: self._check_course_access(material.lesson_id.course_id.id) except AccessError: raise werkzeug.exceptions.Forbidden() attachment = material.attachment_id content = base64.b64decode(attachment.datas) if attachment.datas else b'' headers = [ ('Content-Type', attachment.mimetype or 'application/octet-stream'), ('Content-Length', len(content)), ('Content-Disposition', f'attachment; filename="{attachment.name}"') ] return request.make_response(content, headers=headers) @http.route(['/my/lms/assignments', '/my/lms/assignments/page/'], type='http', auth="user", website=True) def my_lms_assignments(self, page=1, **kw): student = self._get_student() if not student: return request.render("education_portal.portal_no_student") # To get assignments for eligible courses: domain = self._get_student_domain() eligible_courses = request.env['education.lms.course'].sudo().search(domain) assignment_domain = [ ('course_id', 'in', eligible_courses.ids), ('state', 'in', ['published', 'closed']) ] Assignment = request.env['education.lms.assignment'].sudo() assignment_count = Assignment.search_count(assignment_domain) pager = portal_pager( url="/my/lms/assignments", total=assignment_count, page=page, step=12 ) assignments = Assignment.search(assignment_domain, limit=12, offset=pager['offset'], order='due_date asc, id desc') # Get submissions for these assignments by this student submissions = request.env['education.lms.submission'].sudo().search([ ('student_id', '=', student.id), ('assignment_id', 'in', assignments.ids) ]) submission_map = {sub.assignment_id.id: sub for sub in submissions} values = { 'student': student, 'assignments': assignments, 'submission_map': submission_map, 'page_name': 'lms_assignments', 'pager': pager, 'default_url': '/my/lms/assignments', } return request.render("education_lms.portal_my_lms_assignment_list", values) def _check_assignment_access(self, assignment_id): student = self._get_student() if not student: raise AccessError(_("Student mapping not found.")) assignment = request.env['education.lms.assignment'].sudo().search([ ('id', '=', int(assignment_id)), ('state', 'in', ['published', 'closed']) ], limit=1) if not assignment: raise AccessError(_("Assignment not found or draft.")) # Re-check course eligibility self._check_course_access(assignment.course_id.id) return assignment @http.route(['/my/lms/assignment/'], type='http', auth="user", website=True) def my_lms_assignment_detail(self, assignment_id, **kw): try: assignment = self._check_assignment_access(assignment_id) except AccessError: return request.redirect('/my/lms/assignments') student = self._get_student() submission = request.env['education.lms.submission'].sudo().search([ ('student_id', '=', student.id), ('assignment_id', '=', assignment.id) ], limit=1) values = { 'student': student, 'assignment': assignment, 'submission': submission, 'page_name': 'lms_assignment_detail', 'error': kw.get('error'), } return request.render("education_lms.portal_my_lms_assignment_detail", values) @http.route(['/my/lms/assignment//submit'], type='http', auth="user", methods=['POST'], website=True, csrf=True) def my_lms_assignment_submit(self, assignment_id, **post): try: assignment = self._check_assignment_access(assignment_id) except AccessError: return request.redirect('/my/lms/assignments') student = self._get_student() if assignment.state == 'closed': return request.redirect(f'/my/lms/assignment/{assignment_id}?error=closed') # Verify no existing submission existing = request.env['education.lms.submission'].sudo().search([ ('student_id', '=', student.id), ('assignment_id', '=', assignment.id) ], limit=1) if existing: return request.redirect(f'/my/lms/assignment/{assignment_id}?error=exists') content = post.get('content', '').strip() attachment = post.get('attachment') if not content and (not attachment or not attachment.filename): return request.redirect(f'/my/lms/assignment/{assignment_id}?error=empty') submission_vals = { 'student_id': student.id, 'assignment_id': assignment.id, 'text_submission': content, } try: # We use sudo to create, but we explicitly hardcode the student_id to the authenticated student's ID. # IDOR is prevented by deriving the student_id safely. submission = request.env['education.lms.submission'].sudo().create(submission_vals) if attachment and attachment.filename: # Add attachment to submission attachment_val = { 'name': attachment.filename, 'type': 'binary', 'datas': base64.b64encode(attachment.read()), 'res_model': 'education.lms.submission', 'res_id': submission.id, # We leave it open to student read through normal IR rules, # or they can view it from the portal detail. } request.env['ir.attachment'].sudo().create(attachment_val) return request.redirect(f'/my/lms/submission/{submission.id}') except Exception as e: # Fallback error mapping return request.redirect(f'/my/lms/assignment/{assignment_id}?error=server') @http.route(['/my/lms/submission/'], type='http', auth="user", website=True) def my_lms_submission_detail(self, submission_id, **kw): student = self._get_student() if not student: return request.redirect('/my/lms/assignments') # Secure fetch of the submission to ensure it belongs to the authenticated student submission = request.env['education.lms.submission'].sudo().search([ ('id', '=', int(submission_id)), ('student_id', '=', student.id) ], limit=1) if not submission: return request.redirect('/my/lms/assignments') # Also ensure the student still has access to the course try: self._check_course_access(submission.assignment_id.course_id.id) except AccessError: return request.redirect('/my/lms/assignments') # Get submission attachments attachments = request.env['ir.attachment'].sudo().search([ ('res_model', '=', 'education.lms.submission'), ('res_id', '=', submission.id) ]) values = { 'student': student, 'submission': submission, 'assignment': submission.assignment_id, 'attachments': attachments, 'page_name': 'lms_submission_detail', } return request.render("education_lms.portal_my_lms_submission_detail", values) @http.route(['/my/lms/submission/attachment/'], type='http', auth="user", website=True) def download_submission_attachment(self, attachment_id, **kw): """ Secure download for submission attachments (so student can download their own). """ student = self._get_student() if not student: raise werkzeug.exceptions.Forbidden() attachment = request.env['ir.attachment'].sudo().browse(attachment_id) if not attachment.exists() or attachment.res_model != 'education.lms.submission': raise werkzeug.exceptions.NotFound() submission = request.env['education.lms.submission'].sudo().browse(attachment.res_id) if submission.student_id.id != student.id: raise werkzeug.exceptions.Forbidden() content = base64.b64decode(attachment.datas) if attachment.datas else b'' headers = [ ('Content-Type', attachment.mimetype or 'application/octet-stream'), ('Content-Length', len(content)), ('Content-Disposition', f'attachment; filename="{attachment.name}"') ] return request.make_response(content, headers=headers)