2026-09-25 18:00:58 +05:30

377 lines
15 KiB
Python

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/<int: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/<int:course_id>'], 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/<int:course_id>/lesson/<int:lesson_id>'], 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/<int:material_id>/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/<int: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/<int:assignment_id>'], 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/<int:assignment_id>/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/<int:submission_id>'], 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/<int:attachment_id>'], 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)