diff --git a/.DS_Store b/.DS_Store index 792f7b9..7e7bb1d 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc b/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index be2e903..0000000 Binary files a/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 03e96b1..0000000 Binary files a/addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc b/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc deleted file mode 100644 index a4d7c5e..0000000 Binary files a/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc and /dev/null differ diff --git a/addons/education_base/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_base/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 9d3071d..0000000 Binary files a/addons/education_base/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc deleted file mode 100644 index 28348d8..0000000 Binary files a/addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc and /dev/null differ diff --git a/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc deleted file mode 100644 index 591d717..0000000 Binary files a/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc and /dev/null differ diff --git a/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc deleted file mode 100644 index 289e518..0000000 Binary files a/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc and /dev/null differ diff --git a/addons/education_base/tests/__pycache__/test_security.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_security.cpython-310.pyc deleted file mode 100644 index 59959a9..0000000 Binary files a/addons/education_base/tests/__pycache__/test_security.cpython-310.pyc and /dev/null differ diff --git a/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc b/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index 6de4951..0000000 Binary files a/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index eff2b80..0000000 Binary files a/addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc b/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc deleted file mode 100644 index 3a7465d..0000000 Binary files a/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc and /dev/null differ diff --git a/addons/education_exam/views/portal_exam_templates.xml b/addons/education_exam/views/portal_exam_templates.xml index 0652d80..5e8e599 100644 --- a/addons/education_exam/views/portal_exam_templates.xml +++ b/addons/education_exam/views/portal_exam_templates.xml @@ -1,5 +1,668 @@ + + + - - - - + + + + + + diff --git a/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc b/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index c6267ca..0000000 Binary files a/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index b324482..0000000 Binary files a/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc b/addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc deleted file mode 100644 index d910b22..0000000 Binary files a/addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc and /dev/null differ diff --git a/addons/education_lms/__init__.py b/addons/education_lms/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_lms/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_lms/__manifest__.py b/addons/education_lms/__manifest__.py new file mode 100644 index 0000000..7691e61 --- /dev/null +++ b/addons/education_lms/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'Education LMS', + 'version': '17.0.1.0.0', + 'category': 'Education', + 'summary': 'Learning Management System Foundation for Education', + 'description': """ + Education LMS Module for managing courses, sections, lessons and materials. + """, + 'author': 'Metatron', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'education_base', + 'education_student', + 'education_exam', + 'education_portal' + ], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/education_lms_assignment_views.xml', + 'views/education_lms_submission_views.xml', + 'views/education_lms_course_views.xml', + 'views/education_lms_content_views.xml', + 'views/menu_views.xml', + 'views/portal_lms_templates.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/education_lms/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2d79b03 Binary files /dev/null and b/addons/education_lms/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_lms/controllers/__init__.py b/addons/education_lms/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/education_lms/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/education_lms/controllers/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..3c4d9dc Binary files /dev/null and b/addons/education_lms/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_lms/controllers/__pycache__/main.cpython-310.pyc b/addons/education_lms/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..ef7becf Binary files /dev/null and b/addons/education_lms/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/education_lms/controllers/main.py b/addons/education_lms/controllers/main.py new file mode 100644 index 0000000..10a4172 --- /dev/null +++ b/addons/education_lms/controllers/main.py @@ -0,0 +1,376 @@ +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) diff --git a/addons/education_lms/models/__init__.py b/addons/education_lms/models/__init__.py new file mode 100644 index 0000000..7052cf8 --- /dev/null +++ b/addons/education_lms/models/__init__.py @@ -0,0 +1,4 @@ +from . import education_lms_course +from . import education_lms_content +from . import education_lms_assignment +from . import education_lms_submission diff --git a/addons/education_lms/models/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..3f132a3 Binary files /dev/null and b/addons/education_lms/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_lms/models/__pycache__/education_lms_assignment.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_assignment.cpython-310.pyc new file mode 100644 index 0000000..43a4c45 Binary files /dev/null and b/addons/education_lms/models/__pycache__/education_lms_assignment.cpython-310.pyc differ diff --git a/addons/education_lms/models/__pycache__/education_lms_content.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_content.cpython-310.pyc new file mode 100644 index 0000000..6d1f748 Binary files /dev/null and b/addons/education_lms/models/__pycache__/education_lms_content.cpython-310.pyc differ diff --git a/addons/education_lms/models/__pycache__/education_lms_course.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_course.cpython-310.pyc new file mode 100644 index 0000000..c4b2039 Binary files /dev/null and b/addons/education_lms/models/__pycache__/education_lms_course.cpython-310.pyc differ diff --git a/addons/education_lms/models/__pycache__/education_lms_submission.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_submission.cpython-310.pyc new file mode 100644 index 0000000..70d4442 Binary files /dev/null and b/addons/education_lms/models/__pycache__/education_lms_submission.cpython-310.pyc differ diff --git a/addons/education_lms/models/education_lms_assignment.py b/addons/education_lms/models/education_lms_assignment.py new file mode 100644 index 0000000..4d06e19 --- /dev/null +++ b/addons/education_lms/models/education_lms_assignment.py @@ -0,0 +1,104 @@ +# pyrefly: ignore [missing-import] +from odoo import models, fields, api +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError, AccessError + +class EducationLMSAssignment(models.Model): + _name = 'education.lms.assignment' + _description = 'LMS Assignment' + + course_id = fields.Many2one('education.lms.course', string='Course', required=True, ondelete='cascade') + section_id = fields.Many2one('education.lms.section', string='Section', ondelete='set null') + lesson_id = fields.Many2one('education.lms.lesson', string='Lesson', ondelete='set null') + + subject_id = fields.Many2one('education.subject', related='course_id.subject_id', store=True, string='Subject') + faculty_id = fields.Many2one('res.users', related='course_id.faculty_id', store=True, string='Faculty') + + title = fields.Char(string='Title', required=True) + instructions = fields.Html(string='Instructions') + + assigned_date = fields.Date(string='Assigned Date', required=True, default=fields.Date.context_today) + due_date = fields.Date(string='Due Date', required=True) + + maximum_marks = fields.Float(string='Maximum Marks', required=True, default=100.0) + attachment_ids = fields.Many2many('ir.attachment', string='Attachments') + + active = fields.Boolean(default=True) + published = fields.Boolean(default=False) + + state = fields.Selection([ + ('draft', 'Draft'), + ('published', 'Published'), + ('closed', 'Closed') + ], string='State', default='draft', required=True) + + @api.constrains('assigned_date', 'due_date') + def _check_dates(self): + for record in self: + if record.assigned_date and record.due_date and record.due_date < record.assigned_date: + raise ValidationError("Due Date cannot be before Assigned Date.") + + @api.constrains('maximum_marks') + def _check_marks(self): + for record in self: + if record.maximum_marks <= 0: + raise ValidationError("Maximum marks must be greater than zero.") + + @api.constrains('course_id', 'section_id', 'lesson_id') + def _check_hierarchy(self): + for record in self: + if record.section_id and record.section_id.course_id.id != record.course_id.id: + raise ValidationError("The selected Section does not belong to the selected Course.") + if record.lesson_id and record.lesson_id.section_id.id != record.section_id.id: + raise ValidationError("The selected Lesson does not belong to the selected Section.") + + @api.constrains('state', 'course_id') + def _check_publishing(self): + for record in self: + if record.state in ['published', 'closed'] or record.published: + if record.course_id.state != 'published': + raise ValidationError("Cannot publish or close an assignment for an unpublished course.") + if record.state == 'closed' and not record.published: + raise ValidationError("An assignment must be published before it can be closed.") + + @api.constrains('course_id') + def _check_faculty_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("Faculty members can only create assignments for their own courses.") + + def action_publish(self): + for record in self: + record.write({'state': 'published', 'published': True}) + + def action_close(self): + for record in self: + record.write({'state': 'closed'}) + + def write(self, vals): + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + + for record in self: + # Lifecycle protections + if record.state == 'closed' and vals.get('state') in ['draft', 'published']: + raise ValidationError("Closed assignments cannot return to Draft or Published state.") + + # Faculty restrictions + if not is_admin: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only edit assignments for courses assigned to you.") + + # Prevent bypassing lifecycle by changing course + if 'course_id' in vals and record.state != 'draft' and vals['course_id'] != record.course_id.id: + raise AccessError("Cannot change the course of an assignment after it has been published.") + + return super().write(vals) + + def unlink(self): + if not self.env.su and not self.env.user.has_group('education_lms.group_education_lms_admin'): + for record in self: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only delete assignments for courses assigned to you.") + return super().unlink() diff --git a/addons/education_lms/models/education_lms_content.py b/addons/education_lms/models/education_lms_content.py new file mode 100644 index 0000000..d14c056 --- /dev/null +++ b/addons/education_lms/models/education_lms_content.py @@ -0,0 +1,109 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError, AccessError + +class EducationLMSSection(models.Model): + _name = 'education.lms.section' + _description = 'LMS Section' + _order = 'sequence, id' + + name = fields.Char(string='Name', required=True) + course_id = fields.Many2one('education.lms.course', string='Course', required=True, ondelete='cascade') + sequence = fields.Integer(string='Sequence', default=10) + description = fields.Text(string='Description') + + published = fields.Boolean(string='Published', default=False) + active = fields.Boolean(default=True) + + lesson_ids = fields.One2many('education.lms.lesson', 'section_id', string='Lessons') + + @api.constrains('published', 'course_id') + def _check_published_state(self): + for record in self: + if record.published and record.course_id.state != 'published': + raise ValidationError("Cannot publish a section if the parent course is not published.") + + @api.constrains('course_id') + def _check_section_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only create or modify sections for your own courses.") + + +class EducationLMSLesson(models.Model): + _name = 'education.lms.lesson' + _description = 'LMS Lesson' + _order = 'sequence, id' + + name = fields.Char(string='Name', required=True) + section_id = fields.Many2one('education.lms.section', string='Section', required=True, ondelete='cascade') + course_id = fields.Many2one('education.lms.course', related='section_id.course_id', store=True, string='Course') + + sequence = fields.Integer(string='Sequence', default=10) + description = fields.Html(string='Content') + + published = fields.Boolean(string='Published', default=False) + active = fields.Boolean(default=True) + + material_ids = fields.One2many('education.lms.material', 'lesson_id', string='Materials') + + @api.constrains('published', 'section_id') + def _check_published_state(self): + for record in self: + if record.published and not record.section_id.published: + raise ValidationError("Cannot publish a lesson if the parent section is not published.") + + @api.constrains('section_id') + def _check_lesson_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.section_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only create or modify lessons for your own courses.") + + +class EducationLMSMaterial(models.Model): + _name = 'education.lms.material' + _description = 'LMS Material' + _order = 'sequence, id' + + name = fields.Char(string='Name', required=True) + lesson_id = fields.Many2one('education.lms.lesson', string='Lesson', required=True, ondelete='cascade') + + material_type = fields.Selection([ + ('file', 'File/Document'), + ('url', 'URL/Link'), + ('video', 'Video Link') + ], string='Type', required=True, default='file') + + attachment_id = fields.Many2one('ir.attachment', string='Attachment') + url = fields.Char(string='URL') + + sequence = fields.Integer(string='Sequence', default=10) + description = fields.Text(string='Description') + + published = fields.Boolean(string='Published', default=False) + active = fields.Boolean(default=True) + + @api.constrains('material_type', 'attachment_id', 'url') + def _check_material_content(self): + for record in self: + if record.material_type == 'file' and not record.attachment_id: + raise ValidationError("File material must have an attachment.") + elif record.material_type in ['url', 'video'] and not record.url: + raise ValidationError("URL or Video material must have a URL provided.") + + @api.constrains('published', 'lesson_id') + def _check_published_state(self): + for record in self: + if record.published and not record.lesson_id.published: + raise ValidationError("Cannot publish a material if the parent lesson is not published.") + + @api.constrains('lesson_id') + def _check_material_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.lesson_id.section_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only create or modify materials for your own courses.") diff --git a/addons/education_lms/models/education_lms_course.py b/addons/education_lms/models/education_lms_course.py new file mode 100644 index 0000000..f8acfda --- /dev/null +++ b/addons/education_lms/models/education_lms_course.py @@ -0,0 +1,76 @@ +from odoo import models, fields, api +from odoo.exceptions import AccessError + +class EducationLMSCourse(models.Model): + _name = 'education.lms.course' + _description = 'LMS Course' + + name = fields.Char(string='Name', required=True) + subject_id = fields.Many2one('education.subject', string='Subject', required=True) + program_id = fields.Many2one('education.program', string='Program', required=True) + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True) + term_id = fields.Many2one('education.academic.term', string='Term') + batch_id = fields.Many2one('education.batch', string='Batch') + + faculty_id = fields.Many2one('res.users', string='Faculty / Instructor', required=True, default=lambda self: self.env.user) + + description = fields.Html(string='Description') + + state = fields.Selection([ + ('draft', 'Draft'), + ('published', 'Published'), + ('archived', 'Archived') + ], string='State', default='draft', required=True) + + active = fields.Boolean(default=True) + + section_ids = fields.One2many('education.lms.section', 'course_id', string='Sections') + assignment_ids = fields.One2many('education.lms.assignment', 'course_id', string='Assignments') + + assignment_count = fields.Integer(string='Assignment Count', compute='_compute_assignment_count') + submission_count = fields.Integer(string='Submission Count', compute='_compute_submission_count') + + @api.depends('assignment_ids') + def _compute_assignment_count(self): + for record in self: + record.assignment_count = len(record.assignment_ids) + + def _compute_submission_count(self): + for record in self: + submissions = self.env['education.lms.submission'].search([('assignment_id.course_id', '=', record.id)]) + record.submission_count = len(submissions) + + def action_publish(self): + for record in self: + record.state = 'published' + + def action_draft(self): + for record in self: + record.state = 'draft' + + def action_archive(self): + for record in self: + record.state = 'archived' + + @api.constrains('faculty_id') + def _check_faculty_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.faculty_id.id != self.env.user.id: + raise AccessError("Faculty members can only manage courses assigned to themselves. Ownership cannot be transferred or spoofed.") + + def write(self, vals): + if not self.env.su and not self.env.user.has_group('education_lms.group_education_lms_admin'): + for record in self: + if record.faculty_id.id != self.env.user.id: + raise AccessError("You can only edit courses assigned to you.") + return super().write(vals) + + def unlink(self): + if not self.env.su and not self.env.user.has_group('education_lms.group_education_lms_admin'): + for record in self: + if record.faculty_id.id != self.env.user.id: + raise AccessError("You can only delete courses assigned to you.") + return super().unlink() + diff --git a/addons/education_lms/models/education_lms_submission.py b/addons/education_lms/models/education_lms_submission.py new file mode 100644 index 0000000..ade74dc --- /dev/null +++ b/addons/education_lms/models/education_lms_submission.py @@ -0,0 +1,197 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError, AccessError + +class EducationLMSSubmission(models.Model): + _name = 'education.lms.submission' + _description = 'LMS Submission' + + assignment_id = fields.Many2one('education.lms.assignment', string='Assignment', required=True, ondelete='cascade') + student_id = fields.Many2one('education.student', string='Student', required=True) + + submission_date = fields.Datetime(string='Submission Date', readonly=True) + + attachment_ids = fields.Many2many('ir.attachment', string='Attachments') + text_submission = fields.Html(string='Text Submission') + + status = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted'), + ('evaluated', 'Evaluated'), + ('late', 'Late') + ], string='Status', default='draft', required=True) + + marks = fields.Float(string='Marks') + feedback = fields.Html(string='Feedback') + + evaluation_date = fields.Datetime(string='Evaluation Date') + evaluated_by = fields.Many2one('res.users', string='Evaluated By', readonly=True) + + is_late = fields.Boolean(string='Is Late', compute='_compute_is_late', store=True) + + _sql_constraints = [ + ('unique_assignment_student', 'unique(assignment_id, student_id)', 'A student can only have one submission per assignment.') + ] + + @api.depends('submission_date', 'assignment_id.due_date') + def _compute_is_late(self): + for record in self: + if record.submission_date and record.assignment_id.due_date: + # Compare Datetime to Date (convert due_date to end of day conceptually) + record.is_late = record.submission_date.date() > record.assignment_id.due_date + else: + record.is_late = False + + def action_submit(self): + for record in self: + if record.status != 'draft': + raise ValidationError("Only draft submissions can be submitted.") + # validation for content + if not record.attachment_ids and not record.text_submission: + raise ValidationError("You must provide either a text submission or an attachment to submit.") + record.write({'status': 'submitted'}) + + def action_evaluate(self): + for record in self: + if record.status not in ['submitted', 'late', 'evaluated']: + raise ValidationError("Can only evaluate submitted or late assignments.") + record.write({'status': 'evaluated'}) + + @api.constrains('status', 'attachment_ids', 'text_submission') + def _check_content(self): + for record in self: + if record.status in ['submitted', 'late', 'evaluated'] and not record.attachment_ids and not record.text_submission: + raise ValidationError("You must provide either a text submission or an attachment to submit.") + + @api.constrains('marks', 'status') + def _check_marks(self): + for record in self: + if record.status == 'evaluated': + # To distinguish empty marks from 0.0, we can check if it's False, but Odoo Float often returns 0.0. + # However, if we literally need to reject "empty" marks, we check if it is literally False. + # In Odoo, Float field without default=0.0 will return 0.0. If we must reject empty, how? + # Actually, in Odoo 17, if a Float field is null in the database, it is 0.0 in ORM. + # But let's check if we can verify it's provided in vals when evaluated. + pass + if record.status == 'evaluated': + if record.marks < 0: + raise ValidationError("Marks cannot be negative.") + if record.marks > record.assignment_id.maximum_marks: + raise ValidationError(f"Marks cannot exceed the maximum allowed ({record.assignment_id.maximum_marks}).") + + @api.constrains('student_id', 'assignment_id') + def _check_eligibility(self): + for record in self: + student = record.student_id.sudo() + course = record.assignment_id.course_id + + if course.program_id != student.program_id: + raise ValidationError("Student program does not match the course program.") + if course.academic_year_id != student.academic_year_id: + raise ValidationError("Student academic year does not match the course academic year.") + if course.term_id and course.term_id != student.academic_term_id: + raise ValidationError("Student term does not match the course term.") + if course.batch_id and course.batch_id != student.batch_id: + raise ValidationError("Student batch does not match the course batch.") + + @api.constrains('student_id') + def _check_student_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_faculty'): + return + if self.env.user.has_group('education_lms.group_education_lms_student'): + for record in self: + if record.student_id.sudo().portal_user_id.id != self.env.user.id: + raise AccessError("You can only create or edit submissions for yourself.") + + @api.constrains('assignment_id') + def _check_assignment_closed(self): + for record in self: + if record.assignment_id.state == 'closed': + raise ValidationError("Cannot create or modify submissions for a closed assignment.") + + @api.model_create_multi + def create(self, vals_list): + is_faculty = self.env.user.has_group('education_lms.group_education_lms_faculty') + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + is_student = self.env.user.has_group('education_lms.group_education_lms_student') + + if is_student and not (is_admin or is_faculty): + for vals in vals_list: + if 'marks' in vals or 'feedback' in vals: + raise AccessError("Students cannot set marks or feedback.") + if 'status' in vals and vals['status'] not in ['draft', 'submitted']: + raise AccessError("Students cannot set submission status to this value.") + if 'evaluated_by' in vals or 'evaluation_date' in vals: + raise AccessError("Students cannot set evaluation metadata.") + return super().create(vals_list) + + def write(self, vals): + is_faculty = self.env.user.has_group('education_lms.group_education_lms_faculty') + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + is_student = self.env.user.has_group('education_lms.group_education_lms_student') + + for record in self: + if 'student_id' in vals and vals['student_id'] != record.student_id.id: + raise AccessError("Cannot change the student of an existing submission.") + if 'assignment_id' in vals and vals['assignment_id'] != record.assignment_id.id: + raise AccessError("Cannot change the assignment of an existing submission.") + + # Lifecycle block: cannot modify if closed + if record.assignment_id.state == 'closed' and not is_admin: + raise AccessError("Closed assignments cannot be modified.") + + if is_faculty and not is_admin: + if record.assignment_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only evaluate or modify submissions for assignments in your courses.") + if 'marks' in vals and vals['marks'] is False: + # If faculty explicitly clears marks while status is evaluated + if vals.get('status', record.status) == 'evaluated': + raise ValidationError("Marks cannot be empty for an evaluated submission.") + + if is_student and not (is_admin or is_faculty): + if record.status not in ['draft']: + raise AccessError("You cannot modify a submission once it has been submitted or evaluated.") + if 'marks' in vals or 'feedback' in vals: + raise AccessError("Students cannot modify marks or feedback.") + if 'status' in vals and vals['status'] not in ['draft', 'submitted']: + raise AccessError("Students cannot modify submission status directly.") + if 'evaluated_by' in vals or 'evaluation_date' in vals: + raise AccessError("Students cannot modify evaluation metadata.") + + # Check if status changes to evaluated and marks is empty/False + for record in self: + new_status = vals.get('status', record.status) + if new_status == 'evaluated': + new_marks = vals.get('marks', record.marks) + # In python, 0.0 is falsy, but we accept 0.0. We reject False or None. + if new_marks is False or new_marks is None: + raise ValidationError("Marks cannot be empty for an evaluated submission.") + + res = super().write(vals) + + for record in self: + updates = {} + if record.status == 'evaluated' and not record.evaluation_date: + updates.update({'evaluation_date': fields.Datetime.now(), 'evaluated_by': self.env.user.id}) + elif record.status == 'submitted' and not record.submission_date: + # Calculate if late + is_late = fields.Datetime.now().date() > record.assignment_id.due_date + updates.update({'submission_date': fields.Datetime.now(), 'status': 'late' if is_late else 'submitted'}) + if updates: + record.sudo().write(updates) + + return res + + def unlink(self): + is_faculty = self.env.user.has_group('education_lms.group_education_lms_faculty') + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + is_student = self.env.user.has_group('education_lms.group_education_lms_student') + + for record in self: + if is_faculty and not is_admin: + if record.assignment_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only delete submissions for assignments in your courses.") + if is_student and not (is_admin or is_faculty): + if record.status != 'draft': + raise AccessError("You can only delete submissions while they are in Draft state.") + + return super().unlink() diff --git a/addons/education_lms/security/ir.model.access.csv b/addons/education_lms/security/ir.model.access.csv new file mode 100644 index 0000000..6c3b327 --- /dev/null +++ b/addons/education_lms/security/ir.model.access.csv @@ -0,0 +1,19 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_education_lms_course_admin,education.lms.course.admin,model_education_lms_course,group_education_lms_admin,1,1,1,1 +access_education_lms_section_admin,education.lms.section.admin,model_education_lms_section,group_education_lms_admin,1,1,1,1 +access_education_lms_lesson_admin,education.lms.lesson.admin,model_education_lms_lesson,group_education_lms_admin,1,1,1,1 +access_education_lms_material_admin,education.lms.material.admin,model_education_lms_material,group_education_lms_admin,1,1,1,1 +access_education_lms_course_faculty,education.lms.course.faculty,model_education_lms_course,group_education_lms_faculty,1,1,1,1 +access_education_lms_section_faculty,education.lms.section.faculty,model_education_lms_section,group_education_lms_faculty,1,1,1,1 +access_education_lms_lesson_faculty,education.lms.lesson.faculty,model_education_lms_lesson,group_education_lms_faculty,1,1,1,1 +access_education_lms_material_faculty,education.lms.material.faculty,model_education_lms_material,group_education_lms_faculty,1,1,1,1 +access_education_lms_course_student,education.lms.course.student,model_education_lms_course,group_education_lms_student,1,0,0,0 +access_education_lms_section_student,education.lms.section.student,model_education_lms_section,group_education_lms_student,1,0,0,0 +access_education_lms_lesson_student,education.lms.lesson.student,model_education_lms_lesson,group_education_lms_student,1,0,0,0 +access_education_lms_material_student,education.lms.material.student,model_education_lms_material,group_education_lms_student,1,0,0,0 +access_education_lms_assignment_admin,education.lms.assignment.admin,model_education_lms_assignment,group_education_lms_admin,1,1,1,1 +access_education_lms_submission_admin,education.lms.submission.admin,model_education_lms_submission,group_education_lms_admin,1,1,1,1 +access_education_lms_assignment_faculty,education.lms.assignment.faculty,model_education_lms_assignment,group_education_lms_faculty,1,1,1,1 +access_education_lms_submission_faculty,education.lms.submission.faculty,model_education_lms_submission,group_education_lms_faculty,1,1,1,1 +access_education_lms_assignment_student,education.lms.assignment.student,model_education_lms_assignment,group_education_lms_student,1,0,0,0 +access_education_lms_submission_student,education.lms.submission.student,model_education_lms_submission,group_education_lms_student,1,1,1,1 diff --git a/addons/education_lms/security/security.xml b/addons/education_lms/security/security.xml new file mode 100644 index 0000000..49681d9 --- /dev/null +++ b/addons/education_lms/security/security.xml @@ -0,0 +1,257 @@ + + + + + Education LMS + Helps you manage LMS courses and content. + 20 + + + + + LMS Student + + + + + + + LMS Faculty + + + + + + + LMS Administrator + + + + + + + + + Faculty Own Courses + + [('faculty_id', '=', user.id)] + + + + + + + + + Faculty Own Sections + + [('course_id.faculty_id', '=', user.id)] + + + + + + + + + Faculty Own Lessons + + [('course_id.faculty_id', '=', user.id)] + + + + + + + + + Faculty Own Materials + + [('lesson_id.course_id.faculty_id', '=', user.id)] + + + + + + + + + + Admin All Courses + + [(1, '=', 1)] + + + + + + + + Admin All Sections + + [(1, '=', 1)] + + + + + + + + Admin All Lessons + + [(1, '=', 1)] + + + + + + + + Admin All Materials + + [(1, '=', 1)] + + + + + + + + + + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.program_id.ids), + ('academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + Student Section Access + + [ + ('course_id.state', 'in', ['published', 'closed']), + ('course_id.program_id', 'in', user.student_ids.program_id.ids), + ('course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('course_id.term_id', '=', False), ('course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('course_id.batch_id', '=', False), ('course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + Student Lesson Access + + [ + ('course_id.state', 'in', ['published', 'closed']), + ('course_id.program_id', 'in', user.student_ids.program_id.ids), + ('course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('course_id.term_id', '=', False), ('course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('course_id.batch_id', '=', False), ('course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + Student Material Access + + [ + ('lesson_id.course_id.state', 'in', ['published', 'closed']), + ('lesson_id.course_id.program_id', 'in', user.student_ids.program_id.ids), + ('lesson_id.course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('lesson_id.course_id.term_id', '=', False), ('lesson_id.course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('lesson_id.course_id.batch_id', '=', False), ('lesson_id.course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + + + Faculty Own Assignments + + [('course_id.faculty_id', '=', user.id)] + + + + + + + + Admin All Assignments + + [(1, '=', 1)] + + + + + + + + Student Assignment Access + + [ + ('state', 'in', ['published', 'closed']), + ('course_id.state', 'in', ['published', 'closed']), + ('course_id.program_id', 'in', user.student_ids.program_id.ids), + ('course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('course_id.term_id', '=', False), ('course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('course_id.batch_id', '=', False), ('course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + + + Faculty Own Submissions + + [('assignment_id.course_id.faculty_id', '=', user.id)] + + + + + + + + Admin All Submissions + + [(1, '=', 1)] + + + + + + + + Student Submission Sandbox + + [('student_id.portal_user_id', '=', user.id)] + + + + + + + + diff --git a/addons/education_lms/tests/__init__.py b/addons/education_lms/tests/__init__.py new file mode 100644 index 0000000..7f70c47 --- /dev/null +++ b/addons/education_lms/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_education_lms +from . import test_education_lms_portal diff --git a/addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..c69e504 Binary files /dev/null and b/addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_lms/tests/__pycache__/test_education_lms.cpython-310.pyc b/addons/education_lms/tests/__pycache__/test_education_lms.cpython-310.pyc new file mode 100644 index 0000000..e78f8db Binary files /dev/null and b/addons/education_lms/tests/__pycache__/test_education_lms.cpython-310.pyc differ diff --git a/addons/education_lms/tests/__pycache__/test_education_lms_portal.cpython-310.pyc b/addons/education_lms/tests/__pycache__/test_education_lms_portal.cpython-310.pyc new file mode 100644 index 0000000..a2b9996 Binary files /dev/null and b/addons/education_lms/tests/__pycache__/test_education_lms_portal.cpython-310.pyc differ diff --git a/addons/education_lms/tests/test_education_lms.py b/addons/education_lms/tests/test_education_lms.py new file mode 100644 index 0000000..4b45c2f --- /dev/null +++ b/addons/education_lms/tests/test_education_lms.py @@ -0,0 +1,1051 @@ +import base64 +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import AccessError, ValidationError +from datetime import date, timedelta +# pyrefly: ignore [missing-import] +from odoo import fields + +class TestEducationLMS(TransactionCase): + + def setUp(self): + super(TestEducationLMS, self).setUp() + self.env.user.tz = 'UTC' + + # Groups + self.group_faculty = self.env.ref('education_lms.group_education_lms_faculty') + self.group_student = self.env.ref('education_lms.group_education_lms_student') + self.group_admin = self.env.ref('education_lms.group_education_lms_admin') + + # Create Users + self.user_faculty_a = self.env['res.users'].create({ + 'name': 'Faculty A', + 'login': 'faculty_a', + 'groups_id': [(4, self.group_faculty.id)] + }) + self.user_faculty_b = self.env['res.users'].create({ + 'name': 'Faculty B', + 'login': 'faculty_b', + 'groups_id': [(4, self.group_faculty.id)] + }) + self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('education_student.group_education_student_user').id)] + }) + self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('education_student.group_education_student_user').id)] + }) + + # Academic structure + self.campus = self.env['education.campus'].create({'name': 'Campus A', 'code': 'CA'}) + self.department = self.env['education.department'].create({ + 'name': 'Dept A', 'code': 'DA', 'campus_id': self.campus.id + }) + self.level = self.env['education.academic.level'].create({'name': 'Level A', 'code': 'LA'}) + self.program = self.env['education.program'].create({ + 'name': 'Program A', 'code': 'PA', 'department_id': self.department.id, 'level_id': self.level.id + }) + self.academic_year = self.env['education.academic.year'].create({ + 'name': 'Year 2024', + 'code': 'Y24', + 'start_date': date(2024, 1, 1), + 'end_date': date(2024, 12, 31) + }) + self.term = self.env['education.academic.term'].create({ + 'name': 'Term 1', 'code': 'T1', 'academic_year_id': self.academic_year.id, + 'start_date': date(2024, 1, 1), 'end_date': date(2024, 6, 30) + }) + self.batch = self.env['education.batch'].create({ + 'name': 'Batch A', 'code': 'BA' + }) + self.semester = self.env['education.semester'].create({ + 'name': 'Sem 1', 'code': 'S1' + }) + self.section = self.env['education.section'].create({'name': 'Section A', 'code': 'SECA'}) + + self.subject = self.env['education.subject'].create({ + 'name': 'Subject A', 'code': 'SUB_A', 'program_id': self.program.id + }) + + # Create Students + self.student_a = self.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'A', + 'portal_user_id': self.user_student_a.id, + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.id, + 'level_id': self.level.id, + 'academic_year_id': self.academic_year.id, + 'academic_term_id': self.term.id, + 'batch_id': self.batch.id, + 'semester_id': self.semester.id, + 'section_id': self.section.id, + 'admission_type': 'regular' + }) + self.student_b = self.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'B', + 'portal_user_id': self.user_student_b.id, + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.id, + 'level_id': self.level.id, + 'academic_year_id': self.academic_year.id, + 'academic_term_id': self.term.id, + 'batch_id': self.batch.id, + 'semester_id': self.semester.id, + 'section_id': self.section.id, + 'admission_type': 'regular' + }) + + # Courses + self.course_faculty_a = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course A', + 'subject_id': self.subject.id, + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'draft' + }) + self.course_faculty_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'name': 'Course B', + 'subject_id': self.subject.id, + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'state': 'draft' + }) + self.course_faculty_a.write({'state': 'published'}) + self.course_faculty_b.write({'state': 'published'}) + + # 1. Faculty A creates assignment on Faculty A course -> PASS + def test_01_faculty_creates_own_assignment(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'assigned_date': fields.Date.today(), + 'due_date': fields.Date.today() + timedelta(days=5), + 'maximum_marks': 100 + }) + self.assertTrue(assignment.id) + + # 2. Faculty A creates assignment on Faculty B course -> REJECT + def test_02_faculty_creates_other_assignment(self): + with self.assertRaises(AccessError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign B', + 'course_id': self.course_faculty_b.id, + 'assigned_date': fields.Date.today(), + 'due_date': fields.Date.today() + timedelta(days=5) + }) + + # 3. Faculty B edits Faculty A assignment -> REJECT + def test_03_faculty_edits_other_assignment(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_faculty_b).write({'name': 'Hacked'}) + + # 4. Faculty B deletes Faculty A assignment -> REJECT + def test_04_faculty_deletes_other_assignment(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_faculty_b).unlink() + + # 5. Faculty A publishes assignment under Draft course -> REJECT + def test_05_publish_under_draft_course(self): + self.course_faculty_a.with_user(self.user_faculty_a).write({'state': 'draft'}) + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(ValidationError): + assignment.with_user(self.user_faculty_a).write({'state': 'published'}) + + # 6. Faculty A publishes assignment under Published course -> PASS + def test_06_publish_under_published_course(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.with_user(self.user_faculty_a).write({'state': 'published'}) + self.assertEqual(assignment.state, 'published') + + # 7. due_date before assigned_date -> REJECT + def test_07_invalid_dates(self): + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'assigned_date': fields.Date.today(), + 'due_date': fields.Date.today() - timedelta(days=1) + }) + + # 8. maximum_marks <= 0 -> REJECT + def test_08_invalid_marks(self): + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'maximum_marks': 0 + }) + + # 9. Section from another course selected -> REJECT + def test_09_invalid_section(self): + section_b = self.env['education.lms.section'].create({ + 'name': 'Section B', 'course_id': self.course_faculty_b.id + }) + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'section_id': section_b.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + + # 10. Lesson from another section/course selected -> REJECT + def test_10_invalid_lesson(self): + section_a = self.env['education.lms.section'].create({ + 'name': 'Section A', 'course_id': self.course_faculty_a.id + }) + section_b = self.env['education.lms.section'].create({ + 'name': 'Section B', 'course_id': self.course_faculty_b.id + }) + lesson_b = self.env['education.lms.lesson'].create({ + 'name': 'Lesson B', 'section_id': section_b.id, 'course_id': self.course_faculty_b.id + }) + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'section_id': section_a.id, + 'lesson_id': lesson_b.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + + # 11. Student A creates submission for eligible assignment -> PASS + def test_11_student_creates_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + self.assertTrue(submission.id) + + # 12. Student A creates submission for Student B -> REJECT + def test_12_student_creates_for_other(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id + }) + + # 13. Student A submits assignment for an ineligible course -> REJECT + def test_13_student_ineligible_course(self): + # Create course for different program + program2 = self.env['education.program'].create({ + 'name': 'Program 2', 'code': 'P2', 'department_id': self.department.id, 'level_id': self.level.id + }) + course_ineligible = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course 2', + 'subject_id': self.subject.id, + 'faculty_id': self.user_faculty_a.id, + 'program_id': program2.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'state': 'published' + }) + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 2', + 'course_id': course_ineligible.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + + # 14. Student A submits with no text and no attachment -> REJECT + def test_14_submit_without_content(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_student_a).write({'status': 'submitted'}) + + # 15. Student A submits before due date -> status Submitted + def test_15_submit_before_due_date(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + submission.with_user(self.user_student_a).write({'status': 'submitted', 'text_submission': 'Done'}) + self.assertEqual(submission.status, 'submitted') + self.assertFalse(submission.is_late) + + # 16. Student A submits after due date -> status Late + def test_16_submit_after_due_date(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'assigned_date': fields.Date.today() - timedelta(days=2), + 'due_date': fields.Date.today() - timedelta(days=1) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + submission.with_user(self.user_student_a).write({'status': 'submitted', 'text_submission': 'Done'}) + self.assertEqual(submission.status, 'late') + self.assertTrue(submission.is_late) + + # 17. Student A cannot edit another student's submission -> REJECT + def test_17_student_edit_other_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # 18. Faculty A can view/evaluate Student A submission for Faculty A course -> PASS + def test_18_faculty_evaluates_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': 90}) + self.assertEqual(submission.status, 'evaluated') + self.assertEqual(submission.marks, 90) + self.assertTrue(submission.evaluation_date) + + # 19. Faculty B cannot view/evaluate that submission -> REJECT + def test_19_faculty_b_evaluates_faculty_a_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'status': 'evaluated', 'marks': 90}) + + # 20. Faculty evaluates with marks = 0 -> PASS + def test_20_faculty_evaluate_zero_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': 0.0}) + self.assertEqual(submission.status, 'evaluated') + self.assertEqual(submission.marks, 0.0) + + # 21. Faculty evaluates with marks > maximum_marks -> REJECT + def test_21_faculty_evaluate_exceed_max_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'maximum_marks': 100 + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': 105}) + + # 22. Faculty evaluates with negative marks -> REJECT + def test_22_faculty_evaluate_negative_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': -5}) + + # 23. Empty marks remain unevaluated; never convert empty to 0. + def test_23_empty_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + self.assertFalse(submission.marks) + + # 24. Duplicate submission for same student + assignment -> REJECT + def test_24_duplicate_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + # Try to create another one + with self.assertRaises(Exception): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + + # 25. Admin can manage all assignments and submissions -> PASS + def test_25_admin_access(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User', + 'login': 'lms_admin', + 'groups_id': [(4, self.group_admin.id)] + }) + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.with_user(user_admin).write({'title': 'Admin Edited'}) + self.assertEqual(assignment.title, 'Admin Edited') + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(user_admin).write({'marks': 100, 'status': 'evaluated'}) + self.assertEqual(submission.marks, 100) + + + # PHASE G TESTS + + # 26. Draft -> Published + def test_26_draft_to_published(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + self.assertEqual(assignment.state, 'draft') + assignment.action_publish() + self.assertEqual(assignment.state, 'published') + + # 27. Published -> Closed + def test_27_published_to_closed(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + self.assertEqual(assignment.state, 'closed') + + # 28. Closed -> Published rejected + def test_28_closed_to_published_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + with self.assertRaises(ValidationError): + assignment.write({'state': 'published'}) + + # 29. Closed -> Draft rejected + def test_29_closed_to_draft_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + with self.assertRaises(ValidationError): + assignment.write({'state': 'draft'}) + + # 30. Closed assignment rejects new submission + def test_30_closed_assignment_rejects_new_submission(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + + # 31. Faculty evaluation succeeds on own course + def test_31_faculty_evaluation_own_course(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'marks': 10, 'status': 'evaluated'}) + self.assertEqual(submission.status, 'evaluated') + + # 32. Cross-faculty evaluation rejected + def test_32_cross_faculty_evaluation_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'marks': 10, 'status': 'evaluated'}) + + # 33. Student cannot modify evaluation fields + def test_33_student_cannot_modify_evaluation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 10}) + + # 34. Faculty cannot modify another faculty's evaluation + def test_34_faculty_cannot_modify_other_faculty_eval(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'marks': 10, 'status': 'evaluated'}) + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'marks': 15}) + + # 35. 0 marks accepted + def test_35_0_marks_accepted(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'marks': 0.0, 'status': 'evaluated'}) + self.assertEqual(submission.marks, 0.0) + + # 36. empty marks rejected for Evaluated status + def test_36_empty_marks_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_faculty_a).write({'marks': False, 'status': 'evaluated'}) + + + # ========================================== + # PHASE H SECURITY TESTS + # ========================================== + + # 37. Faculty A cannot read/modify Faculty B courses/content + def test_37_faculty_isolation(self): + # Faculty B course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'name': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + }) + # Faculty A tries to read -> AccessError + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).read(['name']) + + # Faculty A tries to modify + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).write({'name': 'Hacked'}) + + # Faculty A tries to delete + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).unlink() + + # Faculty A tries to create section under Course B -> AccessError + with self.assertRaises(AccessError): + self.env['education.lms.section'].with_user(self.user_faculty_a).create({ + 'name': 'Sec B', + 'course_id': course_b.id + }) + + # 38. Student Isolation (cannot read/modify other student submission) + def test_38_student_isolation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + # Student B tries to read + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).read(['text_submission']) + + # Student B tries to write + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # Student B tries to create submission for Student A + assignment_new = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H New', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment_new.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student B Work', + }) + + # 39. Course Eligibility Security + def test_39_course_eligibility(self): + # A. Matching + course_a = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course A', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + + # Student A matches program, year, term, batch + self.env.invalidate_all() + res = course_a.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_a.id)]))) + + # B. Different Program + program_2 = self.env['education.program'].create({ + 'name': 'Program 2', 'code': 'P2', 'department_id': self.department.id, 'level_id': self.level.id + }) + course_b = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course B', + 'faculty_id': self.user_faculty_a.id, + 'program_id': program_2.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_b.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_b.id)]))) + + # C. Different Year + year_2 = self.env['education.academic.year'].create({ + 'name': 'Year 2025', 'code': 'Y25', 'start_date': date(2025, 1, 1), 'end_date': date(2025, 12, 31) + }) + course_c = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course C', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': year_2.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_c.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_c.id)]))) + + # D. Different Term + term_2 = self.env['education.academic.term'].create({ + 'name': 'Term 2', 'code': 'T2', 'academic_year_id': self.academic_year.id, + 'start_date': date(2024, 7, 1), 'end_date': date(2024, 12, 31), + 'sequence': 2 + }) + course_d = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course D', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': term_2.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_d.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_d.id)]))) + + # E. Course with no term -> Matches + course_e = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course E', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + res = course_e.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_e.id)]))) + + # F. Different batch + batch_2 = self.env['education.batch'].create({'name': 'Batch B', 'code': 'BB'}) + course_f = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course F', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': batch_2.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_f.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_f.id)]))) + + # G. Course with no batch + course_g = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course G', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'state': 'published' + }) + res = course_g.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_g.id)]))) + + # H. Course with no term AND no batch (should match) + course_h = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course H', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'state': 'published' + }) + res = course_h.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_h.id)]))) + + + # 40. Assignment access and states + def test_40_assignment_access(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Draft', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'draft' + }) + self.env.invalidate_all() + # Draft assignment -> Student A cannot read + with self.assertRaises(AccessError): + assignment.with_user(self.user_student_a).read(['title']) + self.assertFalse(bool(self.env['education.lms.assignment'].with_user(self.user_student_a).search([('id', '=', assignment.id)]))) + + # Published -> Student A can read + assignment.with_user(self.user_faculty_a).action_publish() + self.env.invalidate_all() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.assignment'].with_user(self.user_student_a).search([('id', '=', assignment.id)]))) + + # Closed -> Student A can read, but cannot submit + assignment.with_user(self.user_faculty_a).action_close() + self.env.invalidate_all() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # 41. Submission edge cases + def test_41_submission_security_edge_cases(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H2', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # Spoof student_id + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'student_id': self.student_b.id}) + + # Spoof assignment_id + assignment_2 = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H3', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'assignment_id': assignment_2.id}) + + submission.with_user(self.user_student_a).action_submit() + + # Change marks + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 100}) + + # Change feedback + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'feedback': 'Good job me'}) + + # Change status + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'status': 'evaluated'}) + + # Change evaluation metadata + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'evaluated_by': self.user_student_a.id}) + + # Faculty B cannot evaluate Faculty A's submissions + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'marks': 50}) + + # Faculty A CAN evaluate + submission.with_user(self.user_faculty_a).write({'marks': 50, 'status': 'evaluated'}) + self.assertEqual(submission.marks, 50) + + # 42. Attachment Security Direct Test + def test_42_attachment_security(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H Attachments', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + + # Student A submission + sub_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'A', + }) + attach_a = self.env['ir.attachment'].with_user(self.user_student_a).create({ + 'name': 'file_a.txt', + 'datas': base64.b64encode(b'student A file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_a.id, + }) + + sub_b = self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id, + 'text_submission': 'B', + }) + attach_b = self.env['ir.attachment'].with_user(self.user_student_b).create({ + 'name': 'file_b.txt', + 'datas': base64.b64encode(b'student B file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_b.id, + }) + + # Verify A can read attach A binary data + res_a = attach_a.with_user(self.user_student_a).read(['raw', 'datas']) + self.assertTrue(len(res_a) > 0) + + # Verify A cannot read attach B binary data + with self.assertRaises(AccessError): + attach_b.with_user(self.user_student_a).read(['raw', 'datas']) + + # Verify Faculty A can read both + res_fa1 = attach_a.with_user(self.user_faculty_a).read(['raw']) + self.assertTrue(len(res_fa1) > 0) + res_fa2 = attach_b.with_user(self.user_faculty_a).read(['raw']) + self.assertTrue(len(res_fa2) > 0) + + # Verify Faculty B cannot read either + with self.assertRaises(AccessError): + attach_a.with_user(self.user_faculty_b).read(['raw']) + with self.assertRaises(AccessError): + attach_b.with_user(self.user_faculty_b).read(['raw']) + + # 43. Admin override + def test_43_admin_override(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User 2', + 'login': 'lms_admin2', + 'groups_id': [(4, self.group_admin.id)] + }) + # Admin can read faculty B's course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'name': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + }) + res = course_b.with_user(user_admin).read(['name']) + self.assertTrue(len(res) > 0) + + course_b.with_user(user_admin).write({'name': 'Admin changed'}) + self.assertEqual(course_b.name, 'Admin changed') + + # 44. Portal User Mapping + def test_44_portal_user_mapping(self): + # A user without student_id should not see Student A's courses/submissions in LMS Context + user_no_student = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'no_student', + 'groups_id': [(4, self.group_student.id)] + }) + self.course_faculty_a.state = 'published' + with self.assertRaises(AccessError): + self.course_faculty_a.with_user(user_no_student).read(['name']) diff --git a/addons/education_lms/tests/test_education_lms_phase_h.py b/addons/education_lms/tests/test_education_lms_phase_h.py new file mode 100644 index 0000000..70a1d85 --- /dev/null +++ b/addons/education_lms/tests/test_education_lms_phase_h.py @@ -0,0 +1,14 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import AccessError, ValidationError +from odoo import fields +from datetime import timedelta +import base64 + +class TestEducationLMSPhaseH(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Same setup as test_education_lms.py + # Actually it's easier to append to test_education_lms.py because setup is already there! + pass diff --git a/addons/education_lms/tests/test_education_lms_portal.py b/addons/education_lms/tests/test_education_lms_portal.py new file mode 100644 index 0000000..e52afc4 --- /dev/null +++ b/addons/education_lms/tests/test_education_lms_portal.py @@ -0,0 +1,242 @@ +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import AccessError +from unittest.mock import patch +# pyrefly: ignore [missing-import] +import werkzeug +# pyrefly: ignore [missing-import] +import odoo.addons.education_lms.controllers.main as main_controller +from contextlib import contextmanager + +# pyrefly: ignore [missing-import] +from odoo.http import Response + +class MockRequest: + def __init__(self, env): + self.env = env + self.httprequest = type('obj', (object,), {'path': '/my/lms'})() + def render(self, template, qcontext=None, **kwargs): + return Response(template=template, qcontext=qcontext) + def redirect(self, location, **kwargs): + return location + def make_response(self, content, headers=None): + return Response(content, headers=headers) + +@contextmanager +def mock_request(env): + old_req = main_controller.request + main_controller.request = MockRequest(env) + try: + yield main_controller.request + finally: + main_controller.request = old_req + +class TestEducationLMSPortal(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure we don't accidentally update record rules during test run + cls.env['ir.model.data'].search([ + ('module', '=', 'education_lms'), + ('model', '=', 'ir.rule') + ]).write({'noupdate': False}) + + # We need a student and a faculty + cls.company = cls.env.company + + cls.campus = cls.env['education.campus'].create({'name': 'Campus A', 'code': 'CA'}) + cls.department = cls.env['education.department'].create({'name': 'Test Dept', 'code': 'TD', 'campus_id': cls.campus.id}) + cls.level = cls.env['education.academic.level'].create({'name': 'Level A', 'code': 'LA'}) + cls.program = cls.env['education.program'].create({'name': 'Computer Science', 'code': 'CS', 'department_id': cls.department.id, 'level_id': cls.level.id}) + + from datetime import date + cls.academic_year = cls.env['education.academic.year'].create({'name': '2025-2026', 'code': 'AY25', 'start_date': date(2025,1,1), 'end_date': date(2025,12,31)}) + cls.term = cls.env['education.academic.term'].create({'name': 'Fall 2025', 'code': 'FT25', 'start_date': date(2025,1,1), 'end_date': date(2025,6,30), 'academic_year_id': cls.academic_year.id, 'sequence': 1}) + cls.batch = cls.env['education.batch'].create({'name': 'Batch 2025', 'code': 'B25'}) + cls.semester = cls.env['education.semester'].create({'name': 'Sem 1', 'code': 'S1'}) + cls.section = cls.env['education.section'].create({'name': 'Section A', 'code': 'SECA'}) + + # Create student user A + cls.student_user_a = cls.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a@test.com', + 'groups_id': [(4, cls.env.ref('base.group_portal').id)] + }) + cls.student_a = cls.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'A', + 'portal_user_id': cls.student_user_a.id, + 'campus_id': cls.campus.id, + 'department_id': cls.department.id, + 'program_id': cls.program.id, + 'level_id': cls.level.id, + 'academic_year_id': cls.academic_year.id, + 'academic_term_id': cls.term.id, + 'batch_id': cls.batch.id, + 'semester_id': cls.semester.id, + 'section_id': cls.section.id, + }) + + # Create student user B (Different Batch) + cls.batch_b = cls.env['education.batch'].create({'name': 'Batch 2024', 'code': 'B24'}) + cls.student_user_b = cls.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b@test.com', + 'groups_id': [(4, cls.env.ref('base.group_portal').id)] + }) + cls.student_b = cls.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'B', + 'portal_user_id': cls.student_user_b.id, + 'campus_id': cls.campus.id, + 'department_id': cls.department.id, + 'program_id': cls.program.id, + 'level_id': cls.level.id, + 'academic_year_id': cls.academic_year.id, + 'academic_term_id': cls.term.id, + 'batch_id': cls.batch_b.id, + 'semester_id': cls.semester.id, + 'section_id': cls.section.id, + }) + + # Create faculty + cls.faculty_user = cls.env['res.users'].create({ + 'name': 'Faculty User', + 'login': 'faculty@test.com', + 'groups_id': [(4, cls.env.ref('education_lms.group_education_lms_faculty').id)] + }) + + # Create Course A (Accessible to Student A) + cls.subject = cls.env['education.subject'].create({'name': 'Database Systems', 'code': 'DB1', 'program_id': cls.program.id}) + cls.course_a = cls.env['education.lms.course'].with_user(cls.faculty_user).create({ + 'name': 'CS 101', + 'subject_id': cls.subject.id, + 'program_id': cls.program.id, + 'academic_year_id': cls.academic_year.id, + 'term_id': cls.term.id, + 'batch_id': cls.batch.id, + 'state': 'published', + }) + + # Create Course B (Accessible only to Student B) + cls.course_b = cls.env['education.lms.course'].with_user(cls.faculty_user).create({ + 'name': 'CS 202', + 'subject_id': cls.subject.id, + 'program_id': cls.program.id, + 'academic_year_id': cls.academic_year.id, + 'term_id': cls.term.id, + 'batch_id': cls.batch_b.id, + 'state': 'published', + }) + + # Course A Content + cls.section_a = cls.env['education.lms.section'].with_user(cls.faculty_user).create({ + 'name': 'Section 1', + 'course_id': cls.course_a.id, + 'published': True, + }) + cls.lesson_a = cls.env['education.lms.lesson'].with_user(cls.faculty_user).create({ + 'name': 'Lesson 1', + 'section_id': cls.section_a.id, + 'published': True, + }) + + cls.attachment_a = cls.env['ir.attachment'].create({ + 'name': 'test.pdf', + 'type': 'binary', + 'datas': b'dGVzdA==', # 'test' + 'res_model': 'education.lms.material', + 'res_id': 0, # Will update after material creation + }) + cls.material_a = cls.env['education.lms.material'].with_user(cls.faculty_user).create({ + 'name': 'Material 1', + 'lesson_id': cls.lesson_a.id, + 'material_type': 'file', + 'attachment_id': cls.attachment_a.id, + 'published': True, + }) + cls.attachment_a.write({'res_id': cls.material_a.id}) + + # Assignment A + cls.assignment_a = cls.env['education.lms.assignment'].with_user(cls.faculty_user).create({ + 'title': 'Test Assignment A', + 'course_id': cls.course_a.id, + 'maximum_marks': 100, + 'state': 'published', + 'assigned_date': date(2025,1,1), + 'due_date': date(2025,1,10), + }) + + # Instantiate controller for tests + # pyrefly: ignore [missing-import] + from odoo.addons.education_lms.controllers.main import StudentLMSController + cls.controller = StudentLMSController() + + def test_01_course_access(self): + """Student A should only see Course A.""" + with mock_request(self.env(user=self.student_user_a)): + res = self.controller.my_lms_courses() + self.assertIn('courses', res.qcontext) + courses = res.qcontext['courses'] + self.assertEqual(len(courses), 1) + self.assertEqual(courses[0].id, self.course_a.id) + + # Test specific course access + res_course = self.controller.my_lms_course_detail(self.course_a.id) + self.assertEqual(res_course.qcontext['course'].id, self.course_a.id) + + with self.assertRaises(AccessError): + self.controller._check_course_access(self.course_b.id) + + def test_02_material_download_idor(self): + """Student B tries to download Student A's course material.""" + with mock_request(self.env(user=self.student_user_b)): + # B tries to access A's material (B is not enrolled in Course A) + with self.assertRaises(werkzeug.exceptions.Forbidden): + res = self.controller.download_material(self.material_a.id) + + def test_03_assignment_submission_idor(self): + """Student B tries to submit an assignment for Course A (which B shouldn't access).""" + with mock_request(self.env(user=self.student_user_b)): + # Simulate B submitting assignment for Course A + res = self.controller.my_lms_assignment_submit( + self.assignment_a.id, + submission_text="Hacked Submission" + ) + # Controller redirects to /my/lms/assignments on AccessError + # Since our mock returns the location string and Odoo's wrapper turns it into a Response, + # we check the response data. + self.assertEqual(res.data, b'/my/lms/assignments') + + submission = self.env['education.lms.submission'].search([ + ('assignment_id', '=', self.assignment_a.id) + ]) + self.assertEqual(len(submission), 0, "Student B should not have been able to submit.") + + # Try submitting properly with Student A + with mock_request(self.env(user=self.student_user_a)): + res2 = self.controller.my_lms_assignment_submit( + self.assignment_a.id, + content='My Second Answer' + ) + # Should render the redirect to assignments page or assignment detail + self.assertTrue(res2) + + submission = self.env['education.lms.submission'].search([ + ('assignment_id', '=', self.assignment_a.id) + ]) + self.assertEqual(len(submission), 1, "Student A should be able to submit.") + + def test_04_no_student_mapping(self): + """User without student mapping cannot access.""" + no_student_user = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'nostudent@test.com', + 'groups_id': [(4, self.env.ref('base.group_portal').id)] + }) + + with mock_request(self.env(user=no_student_user)): + res = self.controller.my_lms_courses() + self.assertEqual(res.template, "education_portal.portal_no_student") diff --git a/addons/education_lms/views/education_lms_assignment_views.xml b/addons/education_lms/views/education_lms_assignment_views.xml new file mode 100644 index 0000000..fff281d --- /dev/null +++ b/addons/education_lms/views/education_lms_assignment_views.xml @@ -0,0 +1,95 @@ + + + + + + education.lms.assignment.form + education.lms.assignment + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.assignment.tree + education.lms.assignment + + + + + + + + + + + + + + education.lms.assignment.search + education.lms.assignment + + + + + + + + + + + + + + + + + + Assignments + education.lms.assignment + tree,form + +

+ Create the first Assignment! +

+
+
+ +
diff --git a/addons/education_lms/views/education_lms_content_views.xml b/addons/education_lms/views/education_lms_content_views.xml new file mode 100644 index 0000000..a06220a --- /dev/null +++ b/addons/education_lms/views/education_lms_content_views.xml @@ -0,0 +1,114 @@ + + + + + + education.lms.section.form + education.lms.section + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.lesson.form + education.lms.lesson + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.material.form + education.lms.material + +
+ +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ +
diff --git a/addons/education_lms/views/education_lms_course_views.xml b/addons/education_lms/views/education_lms_course_views.xml new file mode 100644 index 0000000..bab2fcf --- /dev/null +++ b/addons/education_lms/views/education_lms_course_views.xml @@ -0,0 +1,120 @@ + + + + + + education.lms.course.form + education.lms.course + +
+
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.course.tree + education.lms.course + + + + + + + + + + + + + + education.lms.course.search + education.lms.course + + + + + + + + + + + + + + + + + + + + Courses + education.lms.course + tree,form + +

+ Create your first LMS Course! +

+
+
+ +
diff --git a/addons/education_lms/views/education_lms_submission_views.xml b/addons/education_lms/views/education_lms_submission_views.xml new file mode 100644 index 0000000..cdaf769 --- /dev/null +++ b/addons/education_lms/views/education_lms_submission_views.xml @@ -0,0 +1,92 @@ + + + + + + education.lms.submission.form + education.lms.submission + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + education.lms.submission.tree + education.lms.submission + + + + + + + + + + + + + + education.lms.submission.search + education.lms.submission + + + + + + + + + + + + + + + + + + + Submissions + education.lms.submission + tree,form + +

+ Manage Student Submissions! +

+
+
+ +
diff --git a/addons/education_lms/views/menu_views.xml b/addons/education_lms/views/menu_views.xml new file mode 100644 index 0000000..29bf1cd --- /dev/null +++ b/addons/education_lms/views/menu_views.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/addons/education_lms/views/portal_lms_templates.xml b/addons/education_lms/views/portal_lms_templates.xml new file mode 100644 index 0000000..e84d288 --- /dev/null +++ b/addons/education_lms/views/portal_lms_templates.xml @@ -0,0 +1,736 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc b/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index 82bf573..0000000 Binary files a/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc b/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc index 2c57d36..ccc59b8 100644 Binary files a/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc and b/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc differ diff --git a/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index e94c705..0000000 Binary files a/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc b/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc deleted file mode 100644 index cc1dd1a..0000000 Binary files a/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc and /dev/null differ diff --git a/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc b/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc deleted file mode 100644 index db983f7..0000000 Binary files a/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc and /dev/null differ diff --git a/addons/education_portal/views/portal_templates.xml b/addons/education_portal/views/portal_templates.xml index 4b877ea..0a6aa6a 100644 --- a/addons/education_portal/views/portal_templates.xml +++ b/addons/education_portal/views/portal_templates.xml @@ -881,6 +881,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -1725,6 +1733,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -2421,6 +2437,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -3323,6 +3347,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -4036,6 +4068,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -4189,6 +4229,12 @@ + + +
+ © 2026 My Company. All rights reserved. + Powered by odoo +
diff --git a/addons/education_reports/__pycache__/__manifest__.cpython-310.pyc b/addons/education_reports/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index 16a53f2..0000000 Binary files a/addons/education_reports/__pycache__/__manifest__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_reports/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_reports/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index d659f6e..0000000 Binary files a/addons/education_reports/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_reports/tests/__pycache__/test_reports.cpython-310.pyc b/addons/education_reports/tests/__pycache__/test_reports.cpython-310.pyc deleted file mode 100644 index 7377f33..0000000 Binary files a/addons/education_reports/tests/__pycache__/test_reports.cpython-310.pyc and /dev/null differ diff --git a/addons/education_student/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_student/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 2dc77ae..0000000 Binary files a/addons/education_student/tests/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/addons/education_student/tests/__pycache__/test_student.cpython-310.pyc b/addons/education_student/tests/__pycache__/test_student.cpython-310.pyc deleted file mode 100644 index 58e4862..0000000 Binary files a/addons/education_student/tests/__pycache__/test_student.cpython-310.pyc and /dev/null differ diff --git a/addons/education_timetable/__init__.py b/addons/education_timetable/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_timetable/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_timetable/__manifest__.py b/addons/education_timetable/__manifest__.py new file mode 100644 index 0000000..c0bd992 --- /dev/null +++ b/addons/education_timetable/__manifest__.py @@ -0,0 +1,30 @@ +{ + 'name': 'Education Timetable', + 'version': '17.0.1.0.0', + 'summary': 'Academic Timetable Management', + 'description': ''' + Native Timetable module integrated with the education base architecture. + Provides robust server-side validation for room, faculty, and student population conflicts. + ''', + 'category': 'Education', + 'author': 'Metatron', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'education_base', + 'education_student', + 'education_exam', + 'education_portal' + ], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/education_room_views.xml', + 'views/education_timetable_slot_views.xml', + 'views/menu_views.xml', + 'views/portal_timetable_templates.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/education_timetable/__pycache__/__init__.cpython-310.pyc b/addons/education_timetable/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..4125923 Binary files /dev/null and b/addons/education_timetable/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_timetable/controllers/__init__.py b/addons/education_timetable/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/education_timetable/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/education_timetable/controllers/__pycache__/__init__.cpython-310.pyc b/addons/education_timetable/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2ec9a71 Binary files /dev/null and b/addons/education_timetable/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_timetable/controllers/__pycache__/main.cpython-310.pyc b/addons/education_timetable/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..ed9e887 Binary files /dev/null and b/addons/education_timetable/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/education_timetable/controllers/main.py b/addons/education_timetable/controllers/main.py new file mode 100644 index 0000000..a75ba94 --- /dev/null +++ b/addons/education_timetable/controllers/main.py @@ -0,0 +1,113 @@ +import datetime +from odoo import http +from odoo.http import request +# Inherit existing StudentPortalController to reuse _get_student() +from odoo.addons.education_portal.controllers.main import StudentPortalController + +class TimetablePortalController(StudentPortalController): + + def _format_float_time(self, float_time): + """Convert float time (e.g. 9.5) to formatted string (09:30 AM)""" + hours = int(float_time) + minutes = int(round((float_time - hours) * 60)) + if minutes == 60: + minutes = 0 + hours += 1 + + # Handle 24-hour wrap around + hours = hours % 24 + + am_pm = 'AM' if hours < 12 else 'PM' + display_hours = hours % 12 + if display_hours == 0: + display_hours = 12 + + return f"{display_hours:02d}:{minutes:02d} {am_pm}" + + @http.route(['/my/timetable'], type='http', auth="user", website=True) + def student_timetable(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + # Determine eligibility exactly mapping to student's current academic placement + program_id = student.program_id.id if student.program_id else False + year_id = student.academic_year_id.id if student.academic_year_id else False + term_id = student.academic_term_id.id if student.academic_term_id else False + batch_id = student.batch_id.id if student.batch_id else False + + if not program_id or not year_id: + # If a student isn't actively placed in a program/year, they have no timetable. + return request.render("education_timetable.portal_student_timetable", { + 'student': student, + 'grouped_slots': {}, + 'page_name': 'timetable', + 'error': 'You must be assigned to an Academic Year and Program to view your timetable.' + }) + + # Build Domain: The portal query must match the student's actual academic placement. + # Respecting optional-field logic (established in Phase C): + # - term_id = False means it applies to all terms. + # - batch_id = False means it applies to all batches. + domain = [ + ('program_id', '=', program_id), + ('academic_year_id', '=', year_id), + ('active', '=', True) + ] + + # We must use sudo() since portal users don't have backend employee groups. + # The domain rigorously isolates records to ONLY those belonging to the student's exact academic profile. + slot_model = request.env['education.timetable.slot'].sudo() + + # Handle nullable term_id + if term_id: + domain += ['|', ('term_id', '=', False), ('term_id', '=', term_id)] + else: + domain += [('term_id', '=', False)] + + # Handle nullable batch_id + if batch_id: + domain += ['|', ('batch_id', '=', False), ('batch_id', '=', batch_id)] + else: + domain += [('batch_id', '=', False)] + + # Optional: Filter by date if provided in query params + filter_date = kw.get('date') + if filter_date: + try: + # Ensure it's a valid date string + parsed_date = datetime.datetime.strptime(filter_date, '%Y-%m-%d').date() + domain += [('date', '=', parsed_date)] + except ValueError: + pass + + slots = slot_model.search(domain, order="date asc, start_time asc") + + # Group records by Date for clean UI display + grouped_slots = {} + for slot in slots: + date_obj = slot.date + if date_obj not in grouped_slots: + grouped_slots[date_obj] = [] + + # Format time natively in Python so templates stay clean + formatted_start = self._format_float_time(slot.start_time) + formatted_end = self._format_float_time(slot.end_time) + + grouped_slots[date_obj].append({ + 'id': slot.id, + 'start_time': formatted_start, + 'end_time': formatted_end, + 'subject': slot.subject_id.name, + 'faculty': slot.faculty_id.name, + 'room': slot.room_id.name, + 'batch': slot.batch_id.name if slot.batch_id else 'All Batches', + 'term': slot.term_id.name if slot.term_id else 'All Terms', + }) + + values = { + 'student': student, + 'grouped_slots': grouped_slots, + 'page_name': 'timetable', + } + return request.render("education_timetable.portal_student_timetable", values) diff --git a/addons/education_timetable/models/__init__.py b/addons/education_timetable/models/__init__.py new file mode 100644 index 0000000..41371ec --- /dev/null +++ b/addons/education_timetable/models/__init__.py @@ -0,0 +1,2 @@ +from . import education_room +from . import education_timetable_slot diff --git a/addons/education_timetable/models/__pycache__/__init__.cpython-310.pyc b/addons/education_timetable/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..604decd Binary files /dev/null and b/addons/education_timetable/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_timetable/models/__pycache__/education_room.cpython-310.pyc b/addons/education_timetable/models/__pycache__/education_room.cpython-310.pyc new file mode 100644 index 0000000..2cbd49a Binary files /dev/null and b/addons/education_timetable/models/__pycache__/education_room.cpython-310.pyc differ diff --git a/addons/education_timetable/models/__pycache__/education_timetable_slot.cpython-310.pyc b/addons/education_timetable/models/__pycache__/education_timetable_slot.cpython-310.pyc new file mode 100644 index 0000000..5255bd0 Binary files /dev/null and b/addons/education_timetable/models/__pycache__/education_timetable_slot.cpython-310.pyc differ diff --git a/addons/education_timetable/models/education_room.py b/addons/education_timetable/models/education_room.py new file mode 100644 index 0000000..15f36be --- /dev/null +++ b/addons/education_timetable/models/education_room.py @@ -0,0 +1,17 @@ +from odoo import models, fields + +class EducationRoom(models.Model): + _name = 'education.room' + _description = 'Education Room/Venue' + _order = 'name' + + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code', required=True, index=True) + capacity = fields.Integer(string='Capacity', default=30) + building = fields.Char(string='Building / Location') + active = fields.Boolean(default=True) + description = fields.Text(string='Description') + + _sql_constraints = [ + ('code_unique', 'unique(code)', 'Room code must be unique!') + ] diff --git a/addons/education_timetable/models/education_timetable_slot.py b/addons/education_timetable/models/education_timetable_slot.py new file mode 100644 index 0000000..ecd1141 --- /dev/null +++ b/addons/education_timetable/models/education_timetable_slot.py @@ -0,0 +1,82 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +class EducationTimetableSlot(models.Model): + _name = 'education.timetable.slot' + _description = 'Timetable Slot' + _order = 'date desc, start_time' + + # Academic References (Student Population) + program_id = fields.Many2one('education.program', string='Program', required=True) + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True) + term_id = fields.Many2one('education.academic.term', string='Term') + batch_id = fields.Many2one('education.batch', string='Batch') + + # Slot Details + subject_id = fields.Many2one('education.subject', string='Subject', required=True) + faculty_id = fields.Many2one('res.users', string='Faculty / Instructor', required=True) + room_id = fields.Many2one('education.room', string='Room / Venue', required=True) + + # Schedule + date = fields.Date(string='Date', required=True) + start_time = fields.Float(string='Start Time', required=True) + end_time = fields.Float(string='End Time', required=True) + + active = fields.Boolean(default=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft') + + @api.constrains('start_time', 'end_time') + def _check_time(self): + for rec in self: + if rec.start_time >= rec.end_time: + raise ValidationError("Start time must be strictly earlier than end time.") + + @api.constrains('date', 'start_time', 'end_time', 'faculty_id', 'room_id', 'program_id', 'academic_year_id', 'term_id', 'batch_id') + def _check_overlap(self): + for rec in self: + domain_base = [ + ('date', '=', rec.date), + ('id', '!=', rec.id), + # Interval logic: existing_start < new_end AND existing_end > new_start + ('start_time', '<', rec.end_time), + ('end_time', '>', rec.start_time), + ] + + # 1. Check Faculty Conflict + faculty_conflict = self.search(domain_base + [('faculty_id', '=', rec.faculty_id.id)], limit=1) + if faculty_conflict: + raise ValidationError(f"Faculty conflict: {rec.faculty_id.name} already has a class overlapping this time on {rec.date}.") + + # 2. Check Room Conflict + room_conflict = self.search(domain_base + [('room_id', '=', rec.room_id.id)], limit=1) + if room_conflict: + raise ValidationError(f"Room conflict: {rec.room_id.name} is already booked for an overlapping time on {rec.date}.") + + # 3. Check Student Population Conflict + # Population is defined by Program + Academic Year + Term + Batch + # If a slot has NO term, it applies to the whole year, so it should conflict with ANY term in that year. + # So if either the new slot or the existing slot has no term (or they match), it's a conflict. + population_domain = domain_base + [ + ('program_id', '=', rec.program_id.id), + ('academic_year_id', '=', rec.academic_year_id.id), + ] + + # OR logic for term_id: either matches exactly, or one of them is False + if rec.term_id: + population_domain.append('|') + population_domain.append(('term_id', '=', False)) + population_domain.append(('term_id', '=', rec.term_id.id)) + + # OR logic for batch_id: either matches exactly, or one of them is False + if rec.batch_id: + population_domain.append('|') + population_domain.append(('batch_id', '=', False)) + population_domain.append(('batch_id', '=', rec.batch_id.id)) + + population_conflict = self.search(population_domain, limit=1) + if population_conflict: + raise ValidationError(f"Student Population conflict: This specific academic group already has a class overlapping this time on {rec.date}.") diff --git a/addons/education_timetable/security/ir.model.access.csv b/addons/education_timetable/security/ir.model.access.csv new file mode 100644 index 0000000..3295554 --- /dev/null +++ b/addons/education_timetable/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_education_room_admin,education.room.admin,model_education_room,group_education_timetable_admin,1,1,1,1 +access_education_room_faculty,education.room.faculty,model_education_room,group_education_timetable_faculty,1,0,0,0 +access_education_room_student,education.room.student,model_education_room,group_education_timetable_student,1,0,0,0 + +access_education_timetable_slot_admin,education.timetable.slot.admin,model_education_timetable_slot,group_education_timetable_admin,1,1,1,1 +access_education_timetable_slot_faculty,education.timetable.slot.faculty,model_education_timetable_slot,group_education_timetable_faculty,1,0,0,0 +access_education_timetable_slot_student,education.timetable.slot.student,model_education_timetable_slot,group_education_timetable_student,1,0,0,0 diff --git a/addons/education_timetable/security/security.xml b/addons/education_timetable/security/security.xml new file mode 100644 index 0000000..1b69afc --- /dev/null +++ b/addons/education_timetable/security/security.xml @@ -0,0 +1,21 @@ + + + + + Student / Portal + + + + + Faculty + + + + + + Timetable Administrator + + + + + diff --git a/addons/education_timetable/views/education_room_views.xml b/addons/education_timetable/views/education_room_views.xml new file mode 100644 index 0000000..c34b476 --- /dev/null +++ b/addons/education_timetable/views/education_room_views.xml @@ -0,0 +1,52 @@ + + + + education.room.tree + education.room + + + + + + + + + + + + education.room.form + education.room + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + Rooms / Venues + education.room + tree,form + +
diff --git a/addons/education_timetable/views/education_timetable_slot_views.xml b/addons/education_timetable/views/education_timetable_slot_views.xml new file mode 100644 index 0000000..b386168 --- /dev/null +++ b/addons/education_timetable/views/education_timetable_slot_views.xml @@ -0,0 +1,94 @@ + + + + education.timetable.slot.tree + education.timetable.slot + + + + + + + + + + + + + + + + education.timetable.slot.form + education.timetable.slot + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + education.timetable.slot.search + education.timetable.slot + + + + + + + + + + + + + + + + + + + + + + + education.timetable.slot.calendar + education.timetable.slot + + + + + + + + + + + + Timetable Slots + education.timetable.slot + tree,calendar,form + + +
diff --git a/addons/education_timetable/views/menu_views.xml b/addons/education_timetable/views/menu_views.xml new file mode 100644 index 0000000..1ec25d3 --- /dev/null +++ b/addons/education_timetable/views/menu_views.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/addons/education_timetable/views/portal_timetable_templates.xml b/addons/education_timetable/views/portal_timetable_templates.xml new file mode 100644 index 0000000..315c823 --- /dev/null +++ b/addons/education_timetable/views/portal_timetable_templates.xml @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/append_tests 2.py b/append_tests 2.py new file mode 100644 index 0000000..65c5e33 --- /dev/null +++ b/append_tests 2.py @@ -0,0 +1,259 @@ +import os + +with open('addons/education_lms/tests/test_education_lms.py', 'a') as f: + f.write(''' + # ========================================== + # PHASE H SECURITY TESTS + # ========================================== + + # 37. Faculty A cannot read/modify Faculty B courses/content + def test_37_faculty_isolation(self): + # Faculty B course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + # Faculty A tries to read -> AccessError + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).read(['title']) + + # Faculty A tries to modify + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).write({'title': 'Hacked'}) + + # Faculty A tries to delete + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).unlink() + + # Faculty A tries to create section under Course B -> AccessError + with self.assertRaises(AccessError): + self.env['education.lms.section'].with_user(self.user_faculty_a).create({ + 'title': 'Sec B', + 'course_id': course_b.id + }) + + # 38. Student Isolation (cannot read/modify other student submission) + def test_38_student_isolation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + # Student B tries to read + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).read(['text_submission']) + + # Student B tries to write + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # Student B tries to create submission for Student A + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student B Work', + }) + + # 39. Course Eligibility Security + def test_39_course_eligibility(self): + # Course A uses Program, Year, Term A, Batch A. Student A matches all. + # Course B uses no Term -> Student A matches. + course_b = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course No Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'state': 'published' + }) + # Student A can read course_b + res = course_b.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Course C uses Term B -> Student A does not match. + term_b = self.env['education.academic.term'].create({ + 'name': 'Term B', + 'code': 'TB', + 'academic_year_id': self.academic_year.id, + }) + course_c = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course Diff Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'term_id': term_b.id, + 'state': 'published' + }) + # Wait, the rule should block Student A + with self.assertRaises(AccessError): + course_c.with_user(self.user_student_a).read(['title']) + + # 40. Assignment access and states + def test_40_assignment_access(self): + # Draft assignment -> Student A cannot read + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Draft', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'draft' + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_student_a).read(['title']) + + # Published -> Student A can read + assignment.with_user(self.user_faculty_a).action_publish() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Closed -> Student A can read, but cannot submit + assignment.with_user(self.user_faculty_a).action_close() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # 41. Submission edge cases + def test_41_submission_security_edge_cases(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H2', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # change student_id -> AccessError/ValidationError? + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'student_id': self.student_b.id}) + + # change assignment_id + assignment_2 = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H3', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + # student a might not have access to change assignment if it violates unique rule or is blocked. + # actually students can edit assignment_id if in draft, but let's test. + # In Phase F we blocked changing assignment if submission is not draft. But it is draft here. + # wait, they can change it if it's draft and they own the new assignment's course. + # But the prompt says "assignment_id changed after submission -> REJECT". + pass + + submission.with_user(self.user_student_a).action_submit() + + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'assignment_id': assignment_2.id}) + + # change marks + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 100}) + + # change feedback + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'feedback': 'Good job me'}) + + # 42. Attachment Security Direct Test + import base64 + def test_42_attachment_security(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H Attachments', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + + # Student A submission + sub_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'A', + }) + attach_a = self.env['ir.attachment'].with_user(self.user_student_a).create({ + 'name': 'file_a.txt', + 'datas': base64.b64encode(b'student A file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_a.id, + }) + + sub_b = self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id, + 'text_submission': 'B', + }) + attach_b = self.env['ir.attachment'].with_user(self.user_student_b).create({ + 'name': 'file_b.txt', + 'datas': base64.b64encode(b'student B file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_b.id, + }) + + # Verify A can read attach A + self.assertTrue(attach_a.with_user(self.user_student_a).read(['name'])) + + # Verify A cannot read attach B + with self.assertRaises(AccessError): + attach_b.with_user(self.user_student_a).read(['name']) + + # Verify Faculty A can read both + self.assertTrue(attach_a.with_user(self.user_faculty_a).read(['name'])) + self.assertTrue(attach_b.with_user(self.user_faculty_a).read(['name'])) + + # Verify Faculty B cannot read either + with self.assertRaises(AccessError): + attach_a.with_user(self.user_faculty_b).read(['name']) + + # 43. Admin override + def test_43_admin_override(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User 2', + 'login': 'lms_admin2', + 'groups_id': [(4, self.group_admin.id)] + }) + # Admin can read faculty B's course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + res = course_b.with_user(user_admin).read(['title']) + self.assertTrue(len(res) > 0) + + course_b.with_user(user_admin).write({'title': 'Admin changed'}) + self.assertEqual(course_b.title, 'Admin changed') + + # 44. Portal User Mapping + def test_44_portal_user_mapping(self): + # A user without student_id should not see Student A's courses/submissions in LMS Context + user_no_student = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'no_student', + 'groups_id': [(4, self.group_student.id)] + }) + self.course_faculty_a.state = 'published' + with self.assertRaises(AccessError): + self.course_faculty_a.with_user(user_no_student).read(['title']) +''') diff --git a/append_tests.py b/append_tests.py new file mode 100644 index 0000000..65c5e33 --- /dev/null +++ b/append_tests.py @@ -0,0 +1,259 @@ +import os + +with open('addons/education_lms/tests/test_education_lms.py', 'a') as f: + f.write(''' + # ========================================== + # PHASE H SECURITY TESTS + # ========================================== + + # 37. Faculty A cannot read/modify Faculty B courses/content + def test_37_faculty_isolation(self): + # Faculty B course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + # Faculty A tries to read -> AccessError + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).read(['title']) + + # Faculty A tries to modify + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).write({'title': 'Hacked'}) + + # Faculty A tries to delete + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).unlink() + + # Faculty A tries to create section under Course B -> AccessError + with self.assertRaises(AccessError): + self.env['education.lms.section'].with_user(self.user_faculty_a).create({ + 'title': 'Sec B', + 'course_id': course_b.id + }) + + # 38. Student Isolation (cannot read/modify other student submission) + def test_38_student_isolation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + # Student B tries to read + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).read(['text_submission']) + + # Student B tries to write + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # Student B tries to create submission for Student A + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student B Work', + }) + + # 39. Course Eligibility Security + def test_39_course_eligibility(self): + # Course A uses Program, Year, Term A, Batch A. Student A matches all. + # Course B uses no Term -> Student A matches. + course_b = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course No Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'state': 'published' + }) + # Student A can read course_b + res = course_b.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Course C uses Term B -> Student A does not match. + term_b = self.env['education.academic.term'].create({ + 'name': 'Term B', + 'code': 'TB', + 'academic_year_id': self.academic_year.id, + }) + course_c = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course Diff Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'term_id': term_b.id, + 'state': 'published' + }) + # Wait, the rule should block Student A + with self.assertRaises(AccessError): + course_c.with_user(self.user_student_a).read(['title']) + + # 40. Assignment access and states + def test_40_assignment_access(self): + # Draft assignment -> Student A cannot read + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Draft', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'draft' + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_student_a).read(['title']) + + # Published -> Student A can read + assignment.with_user(self.user_faculty_a).action_publish() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Closed -> Student A can read, but cannot submit + assignment.with_user(self.user_faculty_a).action_close() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # 41. Submission edge cases + def test_41_submission_security_edge_cases(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H2', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # change student_id -> AccessError/ValidationError? + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'student_id': self.student_b.id}) + + # change assignment_id + assignment_2 = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H3', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + # student a might not have access to change assignment if it violates unique rule or is blocked. + # actually students can edit assignment_id if in draft, but let's test. + # In Phase F we blocked changing assignment if submission is not draft. But it is draft here. + # wait, they can change it if it's draft and they own the new assignment's course. + # But the prompt says "assignment_id changed after submission -> REJECT". + pass + + submission.with_user(self.user_student_a).action_submit() + + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'assignment_id': assignment_2.id}) + + # change marks + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 100}) + + # change feedback + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'feedback': 'Good job me'}) + + # 42. Attachment Security Direct Test + import base64 + def test_42_attachment_security(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H Attachments', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + + # Student A submission + sub_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'A', + }) + attach_a = self.env['ir.attachment'].with_user(self.user_student_a).create({ + 'name': 'file_a.txt', + 'datas': base64.b64encode(b'student A file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_a.id, + }) + + sub_b = self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id, + 'text_submission': 'B', + }) + attach_b = self.env['ir.attachment'].with_user(self.user_student_b).create({ + 'name': 'file_b.txt', + 'datas': base64.b64encode(b'student B file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_b.id, + }) + + # Verify A can read attach A + self.assertTrue(attach_a.with_user(self.user_student_a).read(['name'])) + + # Verify A cannot read attach B + with self.assertRaises(AccessError): + attach_b.with_user(self.user_student_a).read(['name']) + + # Verify Faculty A can read both + self.assertTrue(attach_a.with_user(self.user_faculty_a).read(['name'])) + self.assertTrue(attach_b.with_user(self.user_faculty_a).read(['name'])) + + # Verify Faculty B cannot read either + with self.assertRaises(AccessError): + attach_a.with_user(self.user_faculty_b).read(['name']) + + # 43. Admin override + def test_43_admin_override(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User 2', + 'login': 'lms_admin2', + 'groups_id': [(4, self.group_admin.id)] + }) + # Admin can read faculty B's course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + res = course_b.with_user(user_admin).read(['title']) + self.assertTrue(len(res) > 0) + + course_b.with_user(user_admin).write({'title': 'Admin changed'}) + self.assertEqual(course_b.title, 'Admin changed') + + # 44. Portal User Mapping + def test_44_portal_user_mapping(self): + # A user without student_id should not see Student A's courses/submissions in LMS Context + user_no_student = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'no_student', + 'groups_id': [(4, self.group_student.id)] + }) + self.course_faculty_a.state = 'published' + with self.assertRaises(AccessError): + self.course_faculty_a.with_user(user_no_student).read(['title']) +''') diff --git a/debug_domain 2.py b/debug_domain 2.py new file mode 100644 index 0000000..24d5f99 --- /dev/null +++ b/debug_domain 2.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" # Debug Domain + rule = self.env['ir.rule'].sudo().search([('name', '=', 'Student Course Access')], limit=1) + domain = rule._compute_domain(rule.model_id.model, 'read') + print(f"\\nDEBUG DOMAIN GENERATED FOR STUDENT A: {domain}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/debug_domain.py b/debug_domain.py new file mode 100644 index 0000000..24d5f99 --- /dev/null +++ b/debug_domain.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" # Debug Domain + rule = self.env['ir.rule'].sudo().search([('name', '=', 'Student Course Access')], limit=1) + domain = rule._compute_domain(rule.model_id.model, 'read') + print(f"\\nDEBUG DOMAIN GENERATED FOR STUDENT A: {domain}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_all_perms 2.py b/fix_all_perms 2.py new file mode 100644 index 0000000..057dcbd --- /dev/null +++ b/fix_all_perms 2.py @@ -0,0 +1,48 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all student rules to explicitly have perm_read="True" +# We will do this by finding \n +# and adding perm_read="True" after it. + +def replace_perms(match): + return match.group(0) + '\n \n \n \n ' + +# First, remove existing perms to avoid duplicates +text = re.sub(r' *\n', '', text) + +# Now add default perms back to Faculty +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Admin +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Student (Read Only except for submission) +text = re.sub( + r'(.*?)', + r'\1\n \n \n \n ', + text, + flags=re.DOTALL +) + +# Exception for Student Submission Sandbox (needs write, create, unlink maybe?) +# Actually Submission Sandbox might need create/write +text = re.sub( + r'(.*?)\n \n ', + r'\1\n \n ', + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_all_perms.py b/fix_all_perms.py new file mode 100644 index 0000000..057dcbd --- /dev/null +++ b/fix_all_perms.py @@ -0,0 +1,48 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all student rules to explicitly have perm_read="True" +# We will do this by finding \n +# and adding perm_read="True" after it. + +def replace_perms(match): + return match.group(0) + '\n \n \n \n ' + +# First, remove existing perms to avoid duplicates +text = re.sub(r' *\n', '', text) + +# Now add default perms back to Faculty +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Admin +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Student (Read Only except for submission) +text = re.sub( + r'(.*?)', + r'\1\n \n \n \n ', + text, + flags=re.DOTALL +) + +# Exception for Student Submission Sandbox (needs write, create, unlink maybe?) +# Actually Submission Sandbox might need create/write +text = re.sub( + r'(.*?)\n \n ', + r'\1\n \n ', + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_mapped 2.py b/fix_mapped 2.py new file mode 100644 index 0000000..983c7fd --- /dev/null +++ b/fix_mapped 2.py @@ -0,0 +1,13 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace .mapped('field_name').ids with .field_name.ids +text = text.replace("user.student_ids.mapped('program_id').ids", "user.student_ids.program_id.ids") +text = text.replace("user.student_ids.mapped('academic_year_id').ids", "user.student_ids.academic_year_id.ids") +text = text.replace("user.student_ids.mapped('academic_term_id').ids", "user.student_ids.academic_term_id.ids") +text = text.replace("user.student_ids.mapped('batch_id').ids", "user.student_ids.batch_id.ids") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_mapped.py b/fix_mapped.py new file mode 100644 index 0000000..983c7fd --- /dev/null +++ b/fix_mapped.py @@ -0,0 +1,13 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace .mapped('field_name').ids with .field_name.ids +text = text.replace("user.student_ids.mapped('program_id').ids", "user.student_ids.program_id.ids") +text = text.replace("user.student_ids.mapped('academic_year_id').ids", "user.student_ids.academic_year_id.ids") +text = text.replace("user.student_ids.mapped('academic_term_id').ids", "user.student_ids.academic_term_id.ids") +text = text.replace("user.student_ids.mapped('batch_id').ids", "user.student_ids.batch_id.ids") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_perms 2.py b/fix_perms 2.py new file mode 100644 index 0000000..587cb0f --- /dev/null +++ b/fix_perms 2.py @@ -0,0 +1,33 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# I need to restore the original domain AND add perm_read="True" +# Let's replace the whole Student Course Access rule. +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """ + +text = re.sub( + r'.*?', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_perms.py b/fix_perms.py new file mode 100644 index 0000000..587cb0f --- /dev/null +++ b/fix_perms.py @@ -0,0 +1,33 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# I need to restore the original domain AND add perm_read="True" +# Let's replace the whole Student Course Access rule. +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """ + +text = re.sub( + r'.*?', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security 2.py b/fix_security 2.py new file mode 100644 index 0000000..e99983e --- /dev/null +++ b/fix_security 2.py @@ -0,0 +1,24 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace the Course rule domain +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security.py b/fix_security.py new file mode 100644 index 0000000..e99983e --- /dev/null +++ b/fix_security.py @@ -0,0 +1,24 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace the Course rule domain +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security2 2.py b/fix_security2 2.py new file mode 100644 index 0000000..67c66b6 --- /dev/null +++ b/fix_security2 2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all occurrences of user.student_ids.field.ids with user.student_ids.mapped('field').ids +# Specifically for program_id, academic_year_id, academic_term_id, batch_id + +fields = ['program_id', 'academic_year_id', 'academic_term_id', 'batch_id'] +for field in fields: + text = re.sub( + rf'user\.student_ids\.{field}\.ids', + f"user.student_ids.mapped('{field}').ids", + text + ) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security2.py b/fix_security2.py new file mode 100644 index 0000000..67c66b6 --- /dev/null +++ b/fix_security2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all occurrences of user.student_ids.field.ids with user.student_ids.mapped('field').ids +# Specifically for program_id, academic_year_id, academic_term_id, batch_id + +fields = ['program_id', 'academic_year_id', 'academic_term_id', 'batch_id'] +for field in fields: + text = re.sub( + rf'user\.student_ids\.{field}\.ids', + f"user.student_ids.mapped('{field}').ids", + text + ) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_state 2.py b/fix_state 2.py new file mode 100644 index 0000000..44905b6 --- /dev/null +++ b/fix_state 2.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace state check +text = text.replace("('state', 'in', ['published', 'closed']),", "('state', '=', 'published'),") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_state.py b/fix_state.py new file mode 100644 index 0000000..44905b6 --- /dev/null +++ b/fix_state.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace state check +text = text.replace("('state', 'in', ['published', 'closed']),", "('state', '=', 'published'),") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_student_user_type 2.py b/fix_student_user_type 2.py new file mode 100644 index 0000000..3362838 --- /dev/null +++ b/fix_student_user_type 2.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Replace base.group_portal with education_student.group_education_student_user +text = text.replace("self.env.ref('base.group_portal').id", "self.env.ref('education_student.group_education_student_user').id") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_student_user_type.py b/fix_student_user_type.py new file mode 100644 index 0000000..3362838 --- /dev/null +++ b/fix_student_user_type.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Replace base.group_portal with education_student.group_education_student_user +text = text.replace("self.env.ref('base.group_portal').id", "self.env.ref('education_student.group_education_student_user').id") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test 2.py b/fix_test 2.py new file mode 100644 index 0000000..33986a4 --- /dev/null +++ b/fix_test 2.py @@ -0,0 +1,9 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = re.sub(r' # Debug Domain\n.*?\n.*?\n.*?\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test.py b/fix_test.py new file mode 100644 index 0000000..33986a4 --- /dev/null +++ b/fix_test.py @@ -0,0 +1,9 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = re.sub(r' # Debug Domain\n.*?\n.*?\n.*?\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test_users 2.py b/fix_test_users 2.py new file mode 100644 index 0000000..e005fbc --- /dev/null +++ b/fix_test_users 2.py @@ -0,0 +1,37 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Add base.group_portal to user_student_a and b +text = text.replace( +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +text = text.replace( +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +# Also remove the debug prints I added previously, to keep the test clean +text = re.sub(r' *print\(f"\\nDEBUG RULE PERM_READ.*?student_ids\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test_users.py b/fix_test_users.py new file mode 100644 index 0000000..e005fbc --- /dev/null +++ b/fix_test_users.py @@ -0,0 +1,37 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Add base.group_portal to user_student_a and b +text = text.replace( +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +text = text.replace( +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +# Also remove the debug prints I added previously, to keep the test clean +text = re.sub(r' *print\(f"\\nDEBUG RULE PERM_READ.*?student_ids\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/get_rule_db 2.py b/get_rule_db 2.py new file mode 100644 index 0000000..057e0ea --- /dev/null +++ b/get_rule_db 2.py @@ -0,0 +1,18 @@ +import psycopg2 +import sys + +try: + conn = psycopg2.connect("dbname=student_db user=odoo password=odoo host=db") + cur = conn.cursor() + cur.execute(""" + SELECT r.name, r.domain_force, r.perm_read, r.global, g.name + FROM ir_rule r + LEFT JOIN rule_group_rel rg ON r.id = rg.rule_group_id + LEFT JOIN res_groups g ON rg.group_id = g.id + WHERE r.model_id = (SELECT id FROM ir_model WHERE model = 'education.lms.course') + """) + rows = cur.fetchall() + for r in rows: + print(r) +except Exception as e: + print(f"DB Error: {e}") diff --git a/get_rule_db.py b/get_rule_db.py new file mode 100644 index 0000000..057e0ea --- /dev/null +++ b/get_rule_db.py @@ -0,0 +1,18 @@ +import psycopg2 +import sys + +try: + conn = psycopg2.connect("dbname=student_db user=odoo password=odoo host=db") + cur = conn.cursor() + cur.execute(""" + SELECT r.name, r.domain_force, r.perm_read, r.global, g.name + FROM ir_rule r + LEFT JOIN rule_group_rel rg ON r.id = rg.rule_group_id + LEFT JOIN res_groups g ON rg.group_id = g.id + WHERE r.model_id = (SELECT id FROM ir_model WHERE model = 'education.lms.course') + """) + rows = cur.fetchall() + for r in rows: + print(r) +except Exception as e: + print(f"DB Error: {e}") diff --git a/print_debug 2.py b/print_debug 2.py new file mode 100644 index 0000000..1c08b69 --- /dev/null +++ b/print_debug 2.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}") + print(f"DEBUG STUDENT: p={self.student_a.program_id.id}, y={self.student_a.academic_year_id.id}, t={self.student_a.academic_term_id.id}, b={self.student_a.batch_id.id}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_debug.py b/print_debug.py new file mode 100644 index 0000000..1c08b69 --- /dev/null +++ b/print_debug.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}") + print(f"DEBUG STUDENT: p={self.student_a.program_id.id}, y={self.student_a.academic_year_id.id}, t={self.student_a.academic_term_id.id}, b={self.student_a.batch_id.id}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_debug2 2.py b/print_debug2 2.py new file mode 100644 index 0000000..165d433 --- /dev/null +++ b/print_debug2 2.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}")""", +""" print(f"\\nDEBUG COURSE_A: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}, state={course_a.state}") + print(f"DEBUG COURSE_FACULTY_A: p={self.course_faculty_a.program_id.id}, y={self.course_faculty_a.academic_year_id.id}, t={self.course_faculty_a.term_id.id}, b={self.course_faculty_a.batch_id.id}, state={self.course_faculty_a.state}") +""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_debug2.py b/print_debug2.py new file mode 100644 index 0000000..165d433 --- /dev/null +++ b/print_debug2.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}")""", +""" print(f"\\nDEBUG COURSE_A: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}, state={course_a.state}") + print(f"DEBUG COURSE_FACULTY_A: p={self.course_faculty_a.program_id.id}, y={self.course_faculty_a.academic_year_id.id}, t={self.course_faculty_a.term_id.id}, b={self.course_faculty_a.batch_id.id}, state={self.course_faculty_a.state}") +""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_ids 2.py b/print_ids 2.py new file mode 100644 index 0000000..ce49761 --- /dev/null +++ b/print_ids 2.py @@ -0,0 +1,6 @@ +import sys + +def print_student_ids(): + print("Writing diagnostic test...") + +print_student_ids() diff --git a/print_ids.py b/print_ids.py new file mode 100644 index 0000000..ce49761 --- /dev/null +++ b/print_ids.py @@ -0,0 +1,6 @@ +import sys + +def print_student_ids(): + print("Writing diagnostic test...") + +print_student_ids() diff --git a/print_perms 2.py b/print_perms 2.py new file mode 100644 index 0000000..537740e --- /dev/null +++ b/print_perms 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""", +""" rule = self.env.ref('education_lms.rule_lms_course_student') + print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + print(f"DEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_perms.py b/print_perms.py new file mode 100644 index 0000000..537740e --- /dev/null +++ b/print_perms.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""", +""" rule = self.env.ref('education_lms.rule_lms_course_student') + print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + print(f"DEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_test_39 2.py b/print_test_39 2.py new file mode 100644 index 0000000..9ae8138 --- /dev/null +++ b/print_test_39 2.py @@ -0,0 +1,9 @@ +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + in_test = False + for line in f: + if 'def test_39_course_eligibility' in line: + in_test = True + elif in_test and line.strip().startswith('def test_'): + break + if in_test: + print(line, end='') diff --git a/print_test_39.py b/print_test_39.py new file mode 100644 index 0000000..9ae8138 --- /dev/null +++ b/print_test_39.py @@ -0,0 +1,9 @@ +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + in_test = False + for line in f: + if 'def test_39_course_eligibility' in line: + in_test = True + elif in_test and line.strip().startswith('def test_'): + break + if in_test: + print(line, end='') diff --git a/replace_tests 2.py b/replace_tests 2.py new file mode 100644 index 0000000..7ce0835 --- /dev/null +++ b/replace_tests 2.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace(" res = course_a.with_user(self.user_student_a).read(['name'])", +" print(f'\\nDEBUG STUDENT IDS: {self.user_student_a.student_ids}')\n print(f'DEBUG STUDENT TERM: {self.user_student_a.student_ids.academic_term_id}')\n res = course_a.with_user(self.user_student_a).read(['name'])") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/replace_tests.py b/replace_tests.py new file mode 100644 index 0000000..7ce0835 --- /dev/null +++ b/replace_tests.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace(" res = course_a.with_user(self.user_student_a).read(['name'])", +" print(f'\\nDEBUG STUDENT IDS: {self.user_student_a.student_ids}')\n print(f'DEBUG STUDENT TERM: {self.user_student_a.student_ids.academic_term_id}')\n res = course_a.with_user(self.user_student_a).read(['name'])") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/restore_domain 2.py b/restore_domain 2.py new file mode 100644 index 0000000..cb31d66 --- /dev/null +++ b/restore_domain 2.py @@ -0,0 +1,26 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with original logic +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""" + +text = re.sub( + r'.*?\[.*?\]', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/restore_domain.py b/restore_domain.py new file mode 100644 index 0000000..cb31d66 --- /dev/null +++ b/restore_domain.py @@ -0,0 +1,26 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with original logic +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""" + +text = re.sub( + r'.*?\[.*?\]', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/rewrite_security 2.py b/rewrite_security 2.py new file mode 100644 index 0000000..218f5d3 --- /dev/null +++ b/rewrite_security 2.py @@ -0,0 +1,30 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Fix Course Student Rule +text = re.sub( + r'.*?', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/rewrite_security.py b/rewrite_security.py new file mode 100644 index 0000000..218f5d3 --- /dev/null +++ b/rewrite_security.py @@ -0,0 +1,30 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Fix Course Student Rule +text = re.sub( + r'.*?', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_1_1 2.py b/test_1_1 2.py new file mode 100644 index 0000000..a7538ad --- /dev/null +++ b/test_1_1 2.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +text = re.sub( + r'.*?', + """ + Student Course Access + + [(1, '=', 1)] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_1_1.py b/test_1_1.py new file mode 100644 index 0000000..a7538ad --- /dev/null +++ b/test_1_1.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +text = re.sub( + r'.*?', + """ + Student Course Access + + [(1, '=', 1)] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_db_rule 2.py b/test_db_rule 2.py new file mode 100644 index 0000000..9f0dfac --- /dev/null +++ b/test_db_rule 2.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}") + + all_course_rules = self.env['ir.rule'].sudo().search([('model_id.model', '=', 'education.lms.course')]) + print(f"DEBUG ALL COURSE RULES: {all_course_rules.mapped('name')}") + for r in all_course_rules: + print(f"RULE: {r.name}, dom: {r.domain_force}, read: {r.perm_read}, grps: {r.groups.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_db_rule.py b/test_db_rule.py new file mode 100644 index 0000000..9f0dfac --- /dev/null +++ b/test_db_rule.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}") + + all_course_rules = self.env['ir.rule'].sudo().search([('model_id.model', '=', 'education.lms.course')]) + print(f"DEBUG ALL COURSE RULES: {all_course_rules.mapped('name')}") + for r in all_course_rules: + print(f"RULE: {r.name}, dom: {r.domain_force}, read: {r.perm_read}, grps: {r.groups.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_db_rules 2.py b/test_db_rules 2.py new file mode 100644 index 0000000..4d4eb59 --- /dev/null +++ b/test_db_rules 2.py @@ -0,0 +1,2 @@ +import xmlrpc.client +# Actually I'm inside the docker so I will just run a simple python script via Odoo shell! diff --git a/test_db_rules.py b/test_db_rules.py new file mode 100644 index 0000000..4d4eb59 --- /dev/null +++ b/test_db_rules.py @@ -0,0 +1,2 @@ +import xmlrpc.client +# Actually I'm inside the docker so I will just run a simple python script via Odoo shell! diff --git a/test_domain 2.py b/test_domain 2.py new file mode 100644 index 0000000..40e71e0 --- /dev/null +++ b/test_domain 2.py @@ -0,0 +1,20 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with just state +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', '=', 'published'), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain.py b/test_domain.py new file mode 100644 index 0000000..40e71e0 --- /dev/null +++ b/test_domain.py @@ -0,0 +1,20 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with just state +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', '=', 'published'), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain2 2.py b/test_domain2 2.py new file mode 100644 index 0000000..2c6ce69 --- /dev/null +++ b/test_domain2 2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with (1, '=', 1) +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [(1, '=', 1)]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain2.py b/test_domain2.py new file mode 100644 index 0000000..2c6ce69 --- /dev/null +++ b/test_domain2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with (1, '=', 1) +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [(1, '=', 1)]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain_eval 2.py b/test_domain_eval 2.py new file mode 100644 index 0000000..5811909 --- /dev/null +++ b/test_domain_eval 2.py @@ -0,0 +1,9 @@ +env = self.env +course = env['education.lms.course'].search([('name', '=', 'Course No Term')], limit=1) +import odoo.osv.expression as expression +domain = [('term_id', '=', False)] +print("Is course term_id empty?", not course.term_id) +print("Does domain match in DB?", bool(env['education.lms.course'].search([('id', '=', course.id), ('term_id', '=', False)]))) +# Test in-memory +domain_evaluator = expression.expression(domain, env['education.lms.course'])._process_domain() +print("Domain evaluated:", domain_evaluator) diff --git a/test_domain_eval.py b/test_domain_eval.py new file mode 100644 index 0000000..5811909 --- /dev/null +++ b/test_domain_eval.py @@ -0,0 +1,9 @@ +env = self.env +course = env['education.lms.course'].search([('name', '=', 'Course No Term')], limit=1) +import odoo.osv.expression as expression +domain = [('term_id', '=', False)] +print("Is course term_id empty?", not course.term_id) +print("Does domain match in DB?", bool(env['education.lms.course'].search([('id', '=', course.id), ('term_id', '=', False)]))) +# Test in-memory +domain_evaluator = expression.expression(domain, env['education.lms.course'])._process_domain() +print("Domain evaluated:", domain_evaluator) diff --git a/test_env_student_ids 2.py b/test_env_student_ids 2.py new file mode 100644 index 0000000..fad3f6c --- /dev/null +++ b/test_env_student_ids 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}")""", +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + student_ids_as_student = self.user_student_a.with_user(self.user_student_a).student_ids + print(f"DEBUG STUDENT IDS AS STUDENT: {student_ids_as_student}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_env_student_ids.py b/test_env_student_ids.py new file mode 100644 index 0000000..fad3f6c --- /dev/null +++ b/test_env_student_ids.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}")""", +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + student_ids_as_student = self.user_student_a.with_user(self.user_student_a).student_ids + print(f"DEBUG STUDENT IDS AS STUDENT: {student_ids_as_student}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_groups 2.py b/test_groups 2.py new file mode 100644 index 0000000..dd15b73 --- /dev/null +++ b/test_groups 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}") + print(f"DEBUG HAS STUDENT GROUP: {self.group_student in self.user_student_a.groups_id}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_groups.py b/test_groups.py new file mode 100644 index 0000000..dd15b73 --- /dev/null +++ b/test_groups.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}") + print(f"DEBUG HAS STUDENT GROUP: {self.group_student in self.user_student_a.groups_id}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_invalidate 2.py b/test_invalidate 2.py new file mode 100644 index 0000000..a778b3f --- /dev/null +++ b/test_invalidate 2.py @@ -0,0 +1,12 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +" self.env.invalidate_all()\n res = course_a.with_user(self.user_student_a).read(['name'])" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_invalidate.py b/test_invalidate.py new file mode 100644 index 0000000..a778b3f --- /dev/null +++ b/test_invalidate.py @@ -0,0 +1,12 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +" self.env.invalidate_all()\n res = course_a.with_user(self.user_student_a).read(['name'])" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_domain 2.py b/test_rule_domain 2.py new file mode 100644 index 0000000..0bc4afc --- /dev/null +++ b/test_rule_domain 2.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_domain.py b/test_rule_domain.py new file mode 100644 index 0000000..0bc4afc --- /dev/null +++ b/test_rule_domain.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_name 2.py b/test_rule_name 2.py new file mode 100644 index 0000000..85d0da2 --- /dev/null +++ b/test_rule_name 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_name.py b/test_rule_name.py new file mode 100644 index 0000000..85d0da2 --- /dev/null +++ b/test_rule_name.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rules 2.py b/test_rules 2.py new file mode 100644 index 0000000..5a38c58 --- /dev/null +++ b/test_rules 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}") + all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rules.py b/test_rules.py new file mode 100644 index 0000000..5a38c58 --- /dev/null +++ b/test_rules.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}") + all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rules_odoo 2.py b/test_rules_odoo 2.py new file mode 100644 index 0000000..21da2aa --- /dev/null +++ b/test_rules_odoo 2.py @@ -0,0 +1,2 @@ +from odoo import api, SUPERUSER_ID +# I'll just put this in test_education_lms.py directly diff --git a/test_rules_odoo.py b/test_rules_odoo.py new file mode 100644 index 0000000..21da2aa --- /dev/null +++ b/test_rules_odoo.py @@ -0,0 +1,2 @@ +from odoo import api, SUPERUSER_ID +# I'll just put this in test_education_lms.py directly diff --git a/test_search 2.py b/test_search 2.py new file mode 100644 index 0000000..3834830 --- /dev/null +++ b/test_search 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([]) + print(f"\\nDEBUG ALL COURSES: {all_courses.ids}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_search.py b/test_search.py new file mode 100644 index 0000000..3834830 --- /dev/null +++ b/test_search.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([]) + print(f"\\nDEBUG ALL COURSES: {all_courses.ids}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text)