377 lines
15 KiB
Python
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)
|